[
  {
    "path": ".editorconfig",
    "content": "# https://editorconfig.org/#supported-properties\n\nroot = true\n\n[*]\ncharset = utf-8\nindent_size = 4\nindent_style = space\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[justfile]\nindent_style = tab\nindent_size = tab\n\n[*.{bash,sh}]\nindent_style = tab\nindent_size = tab\n\n[*.html]\nindent_size = 2\n\n[*.nix]\nindent_size = 2\n\n[*.plist]\nindent_style = tab\nindent_size = tab\n\n[*.yml]\nindent_size = 2\n\n[*{.ts,tsx,js,jsx,json}]\nindent_size = 2\n\n# https://github.com/facebook/ktfmt/blob/main/docs/editorconfig/.editorconfig-kotlinlang\n[*.{kt,kts}]\nindent_style = space\ninsert_final_newline = true\nmax_line_length = 120\nindent_size = 4\nij_continuation_indent_size = 4\nij_java_names_count_to_use_import_on_demand = 9999\nij_kotlin_align_in_columns_case_branch = false\nij_kotlin_align_multiline_binary_operation = false\nij_kotlin_align_multiline_extends_list = false\nij_kotlin_align_multiline_method_parentheses = false\nij_kotlin_align_multiline_parameters = true\nij_kotlin_align_multiline_parameters_in_calls = false\nij_kotlin_allow_trailing_comma = true\nij_kotlin_allow_trailing_comma_on_call_site = true\nij_kotlin_assignment_wrap = normal\nij_kotlin_blank_lines_after_class_header = 0\nij_kotlin_blank_lines_around_block_when_branches = 0\nij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1\nij_kotlin_block_comment_at_first_column = true\nij_kotlin_call_parameters_new_line_after_left_paren = true\nij_kotlin_call_parameters_right_paren_on_new_line = false\nij_kotlin_call_parameters_wrap = on_every_item\nij_kotlin_catch_on_new_line = false\nij_kotlin_class_annotation_wrap = split_into_lines\nij_kotlin_code_style_defaults = KOTLIN_OFFICIAL\nij_kotlin_continuation_indent_for_chained_calls = true\nij_kotlin_continuation_indent_for_expression_bodies = true\nij_kotlin_continuation_indent_in_argument_lists = true\nij_kotlin_continuation_indent_in_elvis = false\nij_kotlin_continuation_indent_in_if_conditions = false\nij_kotlin_continuation_indent_in_parameter_lists = false\nij_kotlin_continuation_indent_in_supertype_lists = false\nij_kotlin_else_on_new_line = false\nij_kotlin_enum_constants_wrap = off\nij_kotlin_extends_list_wrap = normal\nij_kotlin_field_annotation_wrap = off\nij_kotlin_finally_on_new_line = false\nij_kotlin_if_rparen_on_new_line = false\nij_kotlin_import_nested_classes = false\nij_kotlin_imports_layout = *\nij_kotlin_insert_whitespaces_in_simple_one_line_method = true\nij_kotlin_keep_blank_lines_before_right_brace = 2\nij_kotlin_keep_blank_lines_in_code = 2\nij_kotlin_keep_blank_lines_in_declarations = 2\nij_kotlin_keep_first_column_comment = true\nij_kotlin_keep_indents_on_empty_lines = false\nij_kotlin_keep_line_breaks = true\nij_kotlin_lbrace_on_next_line = false\nij_kotlin_line_comment_add_space = false\nij_kotlin_line_comment_at_first_column = true\nij_kotlin_method_annotation_wrap = split_into_lines\nij_kotlin_method_call_chain_wrap = normal\nij_kotlin_method_parameters_new_line_after_left_paren = true\nij_kotlin_method_parameters_right_paren_on_new_line = true\nij_kotlin_method_parameters_wrap = on_every_item\nij_kotlin_name_count_to_use_star_import = 9999\nij_kotlin_name_count_to_use_star_import_for_members = 9999\nij_kotlin_parameter_annotation_wrap = off\nij_kotlin_space_after_comma = true\nij_kotlin_space_after_extend_colon = true\nij_kotlin_space_after_type_colon = true\nij_kotlin_space_before_catch_parentheses = true\nij_kotlin_space_before_comma = false\nij_kotlin_space_before_extend_colon = true\nij_kotlin_space_before_for_parentheses = true\nij_kotlin_space_before_if_parentheses = true\nij_kotlin_space_before_lambda_arrow = true\nij_kotlin_space_before_type_colon = false\nij_kotlin_space_before_when_parentheses = true\nij_kotlin_space_before_while_parentheses = true\nij_kotlin_spaces_around_additive_operators = true\nij_kotlin_spaces_around_assignment_operators = true\nij_kotlin_spaces_around_equality_operators = true\nij_kotlin_spaces_around_function_type_arrow = true\nij_kotlin_spaces_around_logical_operators = true\nij_kotlin_spaces_around_multiplicative_operators = true\nij_kotlin_spaces_around_range = false\nij_kotlin_spaces_around_relational_operators = true\nij_kotlin_spaces_around_unary_operator = false\nij_kotlin_spaces_around_when_arrow = true\nij_kotlin_variable_annotation_wrap = off\nij_kotlin_while_on_new_line = false\nij_kotlin_wrap_elvis_expressions = 1\nij_kotlin_wrap_expression_body_functions = 1\nij_kotlin_wrap_first_method_in_call_chain = false\nktfmt_trailing_comma_management_strategy = only_add\n"
  },
  {
    "path": ".envrc",
    "content": "use flake\n"
  },
  {
    "path": ".git-blame-ignore-revs",
    "content": "70ff75289a9cbce36f7b70a20b4f9c9f82e3b25e\n"
  },
  {
    "path": ".github/workflows/checks.yml",
    "content": "name: Checks\n\non:\n  workflow_dispatch:\n  pull_request:\n    branches:\n      - '**'\n  push:\n    branches:\n      - 'main'\n\nconcurrency:\n  # group is workflow specific and based on the branch (e.g. PRs) or tag\n  group: ${{ github.workflow }}-${{ github.ref }}\n  # we don't want to cancel checks on the main branch\n  cancel-in-progress: ${{ github.ref_name != 'main' }}\n\njobs:\n  flake_check_linux:\n    name: Flake Check Linux\n    runs-on: [nix]\n    steps:\n      - name: git checkout\n        uses: actions/checkout@v3\n      - name: Nix Checks\n        shell: bash\n        run: |\n          nix flake check \\\n            --keep-going \\\n            --no-update-lock-file \\\n            --print-build-logs \\\n            --show-trace\n      - name: Nix Build\n        shell: bash\n        run: |\n          nix build '.#apks-foss' \\\n            --no-update-lock-file \\\n            --print-build-logs \\\n            --show-trace\n      - name: Upload debug APK\n        uses: actions/upload-artifact@v4\n        with:\n          name: obscura-debug.apk\n          path: result/app-foss-debug.apk\n          retention-days: 30\n\n  flake_check_macos:\n    name: Flake Check macOS\n    runs-on:\n      # https://namespace.so/docs/features/faster-github-actions#using-runner-labels\n      - nscloud-macos-sequoia-arm64-12x28-with-cache\n      - nscloud-cache-tag-obscuravpn-client\n      - nscloud-cache-size-50gb\n    steps:\n      - name: git checkout\n        uses: actions/checkout@v3\n\n      - uses: namespacelabs/nscloud-cache-action@v1\n        with:\n          # The action fails to mount at `/nix` and we want to let the Nix installer handle that anyways.\n          path: /tmp/nix\n\n      - name: Install Nix\n        uses: DeterminateSystems/nix-installer-action@main\n        with:\n          determinate: false\n          mac-volume-label: obscuravpn-client\n\n      - name: Nix Checks\n        shell: bash\n        run: |\n          nix flake check \\\n            --keep-going \\\n            --no-update-lock-file \\\n            --print-build-logs \\\n            --show-trace\n\n  macos_build:\n    name: MacOS build\n    runs-on:\n      # https://namespace.so/docs/reference/github-actions/runner-configuration#runner-labels\n      - nscloud-macos-tahoe-arm64-12x28-with-cache\n      - nscloud-cache-tag-obscuravpn-client\n      - nscloud-cache-size-50gb\n    steps:\n      - name: git checkout\n        uses: actions/checkout@v3\n\n      - name: unshallow and fetch git tags\n        shell: bash\n        run: |\n          git fetch --prune --unshallow --tags\n\n      - uses: namespacelabs/nscloud-cache-action@v1\n        with:\n          # The action fails to mount at `/nix` and we want to let the Nix installer handle that anyways.\n          path: /tmp/nix\n\n      - name: Install Nix\n        uses: DeterminateSystems/nix-installer-action@main\n        with:\n          determinate: false\n          mac-volume-label: obscuravpn-client\n\n      - name: Build macOS client\n        shell: bash\n        run: |\n          xcodebuild build \\\n            -workspace apple/client.xcodeproj/project.xcworkspace \\\n            -scheme 'Prod Client' \\\n            CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_REQUIRED=\"NO\" CODE_SIGN_ENTITLEMENTS=\"\" CODE_SIGNING_ALLOWED=\"NO\"\n\n  ios_build:\n    name: iOS All\n    runs-on:\n      # https://namespace.so/docs/reference/github-actions/runner-configuration#runner-labels\n      - nscloud-macos-tahoe-arm64-12x28-with-cache\n      - nscloud-cache-tag-obscuravpn-client\n      - nscloud-cache-size-50gb\n    steps:\n      - name: git checkout\n        uses: actions/checkout@v3\n\n      - name: unshallow and fetch git tags\n        shell: bash\n        run: |\n          git fetch --prune --unshallow --tags\n\n      - uses: namespacelabs/nscloud-cache-action@v1\n        with:\n          # The action fails to mount at `/nix` and we want to let the Nix installer handle that anyways.\n          path: /tmp/nix\n\n      - name: Install Nix\n        uses: DeterminateSystems/nix-installer-action@main\n        with:\n          determinate: false\n          mac-volume-label: obscuravpn-client\n\n      - name: Build iOS client\n        shell: bash\n        run: |\n          xcodebuild build \\\n            -workspace apple/client.xcodeproj/project.xcworkspace \\\n            -scheme 'Obscura VPN iOS' \\\n            CODE_SIGN_IDENTITY=\"\" CODE_SIGNING_REQUIRED=\"NO\" CODE_SIGN_ENTITLEMENTS=\"\" CODE_SIGNING_ALLOWED=\"NO\"\n\n  sync:\n    name: Sync\n    if: |\n      github.repository == 'sovereign-engineering/obscuravpn-client-internal'\n      && github.ref == 'refs/heads/main'\n    needs:\n      - flake_check_linux\n      - flake_check_macos\n      - ios_build\n      - macos_build\n    runs-on: nscloud-ubuntu-20.04-amd64-2x2\n    environment: sync\n    steps:\n      - name: git checkout\n        uses: actions/checkout@v3\n        with:\n          fetch-depth: 0 # We need all history to check for internal commits.\n      - name: Check Restricted Commits\n        run: |\n          echo \"Ensuring that we have full history.\"\n          git log --pretty=oneline 5a25968c93bda974d63f9f96e2be38d7277d0993\n          echo \"Ensuring that internal history is not present in HEAD.\"\n          ! git merge-base --is-ancestor 5a25968c93bda974d63f9f96e2be38d7277d0993 HEAD\n      - name: Push to Public\n        env:\n          SSH_PRIVATE_KEY: ${{ secrets.OBSCURA_CLIENT_SSH_KEY }}\n        run: |\n          echo \"$SSH_PRIVATE_KEY\" > \"$RUNNER_TEMP/ssh-private-key\"\n          chmod 600 \"$RUNNER_TEMP/ssh-private-key\"\n          export GIT_SSH_COMMAND=\"ssh -i $RUNNER_TEMP/ssh-private-key\"\n\n          git push git@github.com:Sovereign-Engineering/obscuravpn-client.git HEAD:main\n"
  },
  {
    "path": ".gitignore",
    "content": "/.direnv/\n/.idea/\n/.devcontainer/\nxcuserdata\n.DS_STORE\n/.claude/\n\n# Nix\nresult\nresult-*\n\n# DMG\n*.dmg\n\n# Android\n*.apk\n*.apk.idsig\n*.aab\n\n# Linux\n/linux/vm/*.qcow2\n/linux/vm/*.iso\n*.rpm\n*.pkg.tar.zst\n*.deb\n\n# Windows\nwindows/wintun*/*\n"
  },
  {
    "path": ".shellcheckrc",
    "content": "source-path=.\nexternal-sources=true\n"
  },
  {
    "path": ".swiftformat",
    "content": "--self insert\n--disable andOperator,unusedArguments,hoistPatternLet\n--trailing-commas collections-only # Old versions of Swift can't handle failing commas in functions. Switch to always (default) when we don't use those anymore.\n"
  },
  {
    "path": "LICENSE.md",
    "content": "# PolyForm Noncommercial License 1.0.0\n\n<https://polyformproject.org/licenses/noncommercial/1.0.0>\n\n## Acceptance\n\nIn order to get any license under these terms, you must agree\nto them as both strict obligations and conditions to all\nyour licenses.\n\n## Copyright License\n\nThe licensor grants you a copyright license for the\nsoftware to do everything you might do with the software\nthat would otherwise infringe the licensor's copyright\nin it for any permitted purpose.  However, you may\nonly distribute the software according to [Distribution\nLicense](#distribution-license) and make changes or new works\nbased on the software according to [Changes and New Works\nLicense](#changes-and-new-works-license).\n\n## Distribution License\n\nThe licensor grants you an additional copyright license\nto distribute copies of the software.  Your license\nto distribute covers distributing the software with\nchanges and new works permitted by [Changes and New Works\nLicense](#changes-and-new-works-license).\n\n## Notices\n\nYou must ensure that anyone who gets a copy of any part of\nthe software from you also gets a copy of these terms or the\nURL for them above, as well as copies of any plain-text lines\nbeginning with `Required Notice:` that the licensor provided\nwith the software.  For example:\n\n> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)\n\n## Changes and New Works License\n\nThe licensor grants you an additional copyright license to\nmake changes and new works based on the software for any\npermitted purpose.\n\n## Patent License\n\nThe licensor grants you a patent license for the software that\ncovers patent claims the licensor can license, or becomes able\nto license, that you would infringe by using the software.\n\n## Noncommercial Purposes\n\nAny noncommercial purpose is a permitted purpose.\n\n## Personal Uses\n\nPersonal use for research, experiment, and testing for\nthe benefit of public knowledge, personal study, private\nentertainment, hobby projects, amateur pursuits, or religious\nobservance, without any anticipated commercial application,\nis use for a permitted purpose.\n\n## Noncommercial Organizations\n\nUse by any charitable organization, educational institution,\npublic research organization, public safety or health\norganization, environmental protection organization,\nor government institution is use for a permitted purpose\nregardless of the source of funding or obligations resulting\nfrom the funding.\n\n## Fair Use\n\nYou may have \"fair use\" rights for the software under the\nlaw. These terms do not limit them.\n\n## No Other Rights\n\nThese terms do not allow you to sublicense or transfer any of\nyour licenses to anyone else, or prevent the licensor from\ngranting licenses to anyone else.  These terms do not imply\nany other licenses.\n\n## Patent Defense\n\nIf you make any written claim that the software infringes or\ncontributes to infringement of any patent, your patent license\nfor the software granted under these terms ends immediately. If\nyour company makes such a claim, your patent license ends\nimmediately for work on behalf of your company.\n\n## Violations\n\nThe first time you are notified in writing that you have\nviolated any of these terms, or done anything with the software\nnot covered by your licenses, your licenses can nonetheless\ncontinue if you come into full compliance with these terms,\nand take practical steps to correct past violations, within\n32 days of receiving notice.  Otherwise, all your licenses\nend immediately.\n\n## No Liability\n\n***As far as the law allows, the software comes as is, without\nany warranty or condition, and the licensor will not be liable\nto you for any damages arising out of these terms or the use\nor nature of the software, under any kind of legal claim.***\n\n## Definitions\n\nThe **licensor** is the individual or entity offering these\nterms, and the **software** is the software the licensor makes\navailable under these terms.\n\n**You** refers to the individual or entity agreeing to these\nterms.\n\n**Your company** is any legal entity, sole proprietorship,\nor other kind of organization that you work for, plus all\norganizations that have control over, are under the control of,\nor are under common control with that organization.  **Control**\nmeans ownership of substantially all the assets of an entity,\nor the power to direct its management and policies by vote,\ncontract, or otherwise.  Control can be direct or indirect.\n\n**Your licenses** are all the licenses granted to you for the\nsoftware under these terms.\n\n**Use** means anything you do with the software requiring one\nof your licenses.\n"
  },
  {
    "path": "README.md",
    "content": "# Obscura VPN Client\n\nObscura VPN library, CLI client, and App\n\n## Support\n\nNo support is provided for this code directly. However, if you are experiencing issues with your Obscura VPN service please contact <support@obscura.net>.\n\n## Contributions\n\nAt this time we are unable to accept external contributions. This is something that we plan to resolve soon. However until we finish the paperwork we are unable to look at any patches and will close all PRs without looking at them.\n\n## macOS App\n\nOn macOS the app installs and manages a [network extension](https://developer.apple.com/documentation/networkextension) (system extension).\nThe network extension manages the virtual device and maintains the tunnel using the Rust code as library.\n\n### Setup\n\n1. [Setup Nix](#nix-setup)\n1. Install dependencies: `nix-env -iA nixpkgs.{cmake,rustup}`\n1. Open the main Xcode project\n    ```bash\n    nix develop --print-build-logs --command just xcode-open\n    ```\n1. In Xcode, login with an account with membership in \"Sovereign Engineering Inc.\"\n1. Register development machine in Apple Developer portal (can be done in Xcode)\n1. [Enable system extension developer mode](#enabling-system-extension-developer-mode)\n1. Setup Developer ID provisioning profile and codesigning for `Prod Client` build scheme\n    1. Go to https://developer.apple.com/account/resources/profiles/list\n        - Download \"Developer ID: System Network Extension\"\n        - Download \"Developer ID: VPN Client App\"\n    1. Install both provisioning profiles by double-clicking them.\n    1. Ask Carl to send the Developer ID codesigning certificate and the corresponding password\n    1. Double click the certificate, enter the password, and install it to your \"login\" keychain\n\n## Building and Running\n\n### For macOS and iOS\n\n1. Open the main Xcode project:\n    ```bash\n    nix develop --print-build-logs --command just xcode-open\n    ```\n1. Pick a build scheme using Xcode's GUI, one of:\n\n    ℹ️ **INFO**: Xcode differentiates between \"build schemes\" and \"build configurations\", see [Apple's docs on this](https://developer.apple.com/documentation/xcode/build-system) for more details.\n\n    1. `Dev Client`: Development Client\n\n        General purpose for development. Uses the main UI with additional developer and pre-release features exposed.\n\n        Uses the `Debug*` build configurations. Codesigned with the `Apple Development` xcode-managed identity.\n\n        ⚠️ **WARNING**: When using this build scheme, make sure you are quitting the app via the top-right status menu bar and **NOT** using Xcode's \"Stop\" as doing so does not actually stop the dev server. This is because stopping via Xcode doesn't run the build scheme's \"Run → Post-actions\"\n\n    1. `Prod Client`: The App with a static web bundle\n\n        Useful for reproducing what the final shippable app will look like and be built as.\n\n        Uses the `Release*` build configurations. Codesigned with the `Developer ID Application: Sovereign Engineering Inc. (5G943LR562)` manually-managed identity.\n\n        The static web bundle built with the build scheme's \"Build → Pre-actions\".\n\n        If you encounter trouble with this build scheme, especially with codesigning or provisioning profiles:\n\n        1. Make sure that you've completed the relevant steps in [setup](#setup)\n        1. See additional instructions in [Confirming \"Developer ID\" Setup](#confirming-developer-id-setup)\n\n    1. `Bare Client`: The App with a minimal HTML UI\n\n        Useful for fine-grain control and debugging.\n\n        Uses the `Debug*` build configurations. Codesigned with the `Apple Development` xcode-managed identity.\n\n1. Build or Run the App\n\n    - `⌘ + B` (Build), or\n    - `⌘ + R` (Run)\n\n    💡 **TIP**: It may initially _seem_ like Xcode is doing nothing when you run or build, but it may just be running the build scheme's \"Pre-actions\", see the \"Report navigator\" in Xcode's top-left app menu: \"View → Navigators → Reports\" to track the actual status.\n\n    💡 **TIP**: If a build fails with `could not find included file 'buildversion.xcconfig' in search paths`, see the [relevant troubleshooting entry](#error-on-build-in-clean-repo-could-not-find-included-file-buildversionxcconfig-in-search-paths).\n\n    -----\n\n    Xcode places built products in a deeply nested directory structure that it controls, with seperate folders for each build configuration. The easiest way to locate where the app is:\n\n    1. \"Run\" the app\n    1. Once the app's icon appears on the macOS Dock, `⌘-Click` the app icon to reveal it in the finder.\n\n💡 **TIP**: It is highly recommended to read through various sections in [Development Tips](#development-tips) to better understand the various ways we've configured the Xcode build system to work with our development process.\n\n### For Android\n\n#### Nix Builds\n\nNix builds provide an easy way to get a fully built APK. They are hermetic and reliable. However, they provide only coarse grained caching so if you are iterating during development you may prefer to use [Incremental Builds](#incremental-builds).\n\n```sh\nnix build '.#apks-foss'\napksigner sign --ks your-keystore.jks --ks-pass pass:hunter2 --out=obscura-signed.apk result/app-foss-release-unsigned.apk # Sign.\nadb install obscura-signed.apk # Push to your device.\n```\n\nInstead of `app-foss-release-unsigned` you can also use `app-foss-debug` for the debug build. Note that just the Android portion is a debug build, the Rust core and UI are still release builds.\n\n#### Incremental Builds\n\nThe Android app requires a special build of the Rust library and Obscura UI. These are built using Nix, while the Android app itself can be built using [Android Studio](https://developer.android.com/studio) for local development, or the Gradle build system to create an official build.\n\n1. Build the Obscura UI\n   ```bash\n   OBS_WEB_PLATFORM=\"android\" nix develop '.#web' --print-build-logs -c just web-bundle-build\n   ```\n2. Build the Rust library\n   ```bash\n   nix develop '.#android' --command bash -c 'cd rustlib && cargo ndk -t arm64-v8a build --release'\n   ```\n3. Open Android Studio and point it at the `android` directory, or\n4. Use Gradle to build everything\n    ```bash\n    nix develop '.#android' --command bash -c 'cd android && gradle --no-daemon $GRADLE_OPTS build'\n    ```\n\nIn order to iterate you can just repeat the steps. 1 and 2 are only required if you changed the UI or Rust core respectively but the final APK build must always be re-run.\n\n#### Gradle Dependencies\n\nTo ensure hermetic builds we pin our Gradle dependencies. If you change the dependencies you will need to regenerate the pin file.\n\n```\nbin/gradle-deps-update.sh\n```\n\n### For Windows\n\nInstall [Visual Studio](https://visualstudio.microsoft.com/downloads/) with the following Workloads:\n\n- Desktop development with C++\n- WinUI application development\n\nOn Windows, definitely ARM64 machines, you need to add `C:\\Program Files\\Microsoft Visual Studio\\18\\Community\\VC\\Tools\\Llvm\\ARM64\\bin` to path.\n\nDownload the signed [wintun 0.14.1 DLLs](https://www.wintun.net/).\n\nYou can use `Get-FileHash -Path .\\wintun-0.14.1.zip -Algorithm SHA256` to verify the hash against `SHA2-256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51`.\n\nExtract to `windows/wintun-0.14.1` such that `windows/wintun-0.14.1/bin/arm64/wintun.dll` is a file.\n\nTo test the service, run `sudo cargo run service`. You need to enable `sudo` under System > Advanced settings. Alternatively, you can run `cargo run service` in an administrative terminal.\n\nThe default config directory is `%APPDATA%\\Obscura`. When testing the service, you may find it beneficial to manually add in an account number.\n\nA helpful command to clean DNS query manually is `Remove-DnsClientNrptRule -Name \"{fb157da8-6578-4f53-81ea-0a9168e96c1f}\"`.\n\n## Swift unit tests\n\n\"Swift Testing\" tests are placed in `*Test.swift` files, which need to be a member of the `Tests` target. Testing (not running) with the `Tests` scheme builds and executes all tests.\n\n## Debugging\n\n### Logs\n\nBoth app and network extension logs are available via [Apple's unified logging system](https://developer.apple.com/documentation/os/logging).\n\n#### Analyzing Logs\n\nThere are tools for analyzing logs available as `bin/log-*`. They accept log files in JSON lines format. This can be found in the app's Debug Bundle or from the Apple `log` command by specifying `--style=ndjson`.\n\nThe main tool is `bin/log-text.py` which just turns the logs into a readable text format as well as applying some basic filtering with a few CLI options to apply more filters. Other tools are available, run with `--help` to get information about what they do.\n\nFor more in-depth analysis you are likely best using the tools as a starting point and modifying them as needed or using other tools like `jq`, `sqlite` or `duckdb`. If your analysis is generally useful consider committing it.\n\n#### Stream Logs\n\nThis will output logs starting at the point in time when you run this command:\n\n```bash\nlog stream --info --debug --predicate 'process CONTAINS[c] \"obscura\" || subsystem CONTAINS[c] \"obscura\"'\n```\n\n#### View Past Logs\n\n> [!WARNING]\n> Since Apple may or may not persist logs at the `INFO` or `DEBUG` level, logs at these level might be lost. See [Apple's developer docs on this](https://developer.apple.com/documentation/os/logging/generating_log_messages_from_your_code#3665947) for more information.\n>\n> You may be able to set a log configuration to ensure that these logs are persisted, though this has not been tested, please update this `README` with instructions if you successfully test this. See [Apple's docs on \"Customizing Logging Behavior While Debugging\"](https://developer.apple.com/documentation/os/logging/customizing_logging_behavior_while_debugging) for more information.\n\n```bash\nlog show --last 200 --info --debug --color always --predicate 'process CONTAINS[c] \"obscura\" || subsystem CONTAINS[c] \"obscura\"' | less +G -R\n```\n\n#### UserDefaults\n\n```sh\ndefaults read \"net.obscura.vpn-client-app\"\n# delete all defaults including Sparkle related keys (SU*)\ndefaults delete-all \"net.obscura.vpn-client-app\"\n# delete keys individually\ndefaults delete \"net.obscura.vpn-client-app\" <key>\n```\n\n## Running Checks\n\n### Linting\n\n```bash\nnix develop --print-build-logs --command just lint\n```\n\n### Formatting\n\n#### Checking\n\n```bash\nnix flake check\n```\n\n#### Auto-fixing\n\n```bash\nnix develop --print-build-logs --command just format-fix\n```\n\n## Building a Notarized Disk Image\n\n1. Save authentication credentials for the Apple notary service (only need to do once)\n\n    ```bash\n    xcrun notarytool store-credentials \"notarytool-password\" --team-id 5G943LR562\n    ```\n\n    Use [appleid.apple.com](https://appleid.apple.com/account/manage) --> App-Specific Passwords\n\n1. (OPTIONAL) If we're doing a release, tag the version `git tag -s v/1.23 -m v/1.23 && git push --tags`.\n1. Unlock the \"Login\" keychain: `security unlock-keychain`\n1. Build the signed and notarized disk image: `just build-dmg`\n\n    💡 **TIP**: This command uses AppleScript automation of Finder to change the background of Disk Images, so Finder windows may open.\n\n    The built disk image will appear in the current working directory as \"Obscura VPN.dmg\"\n\n## Troubleshooting\n\n### `cargo` not rebuilding when it should\n\nA lot of Xcode-set properties don't properly trigger a rebuild from `cargo` even\nthough they're supposed to. The most prominent of which is `MACOSX_DEPLOYMENT_TARGET`.\n\nThis is easily worked-around by \"Product → Clean Build Folder...\" in Xcode then rerunning the build.\n\nUpstream status on this:\n- https://github.com/rust-lang/cc-rs/issues/906\n- https://github.com/rust-lang/rust/issues/118204\n\n## Development Tips\n\n### Enabling system extension developer mode\n\nThis is necessary for:\n- The `systemextensionsctl` commands to work, and\n- To allow installing and running system extensions from places other than `/Applications`\n\nAccording to [Apple's docs for system extensions](https://developer.apple.com/documentation/driverkit/debugging_and_testing_system_extensions#3557204), as of 2024-07-04:\n\n> You must place all system extensions in the `Contents/Library/SystemExtensions` directory of your app bundle, and the app itself must be installed in one of the system’s `Applications` directories. To allow development of your app outside of these directories, use the `systemextensionsctl` command-line tool to enable developer mode. When in developer mode, the system doesn't check the location of your system extension prior to loading it, so you can load it from anywhere in the file system.\n\nTo accomplish this:\n1. [Disable system integrity protection](https://developer.apple.com/documentation/security/disabling_and_enabling_system_integrity_protection)\n1. Then, run\n    ```bash\n    systemextensionsctl developer on\n    ```\n\n### Removing network extension (system extension)\n\n1. Ensure that [system extension developer mode is enabled](#enabling-system-extension-developer-mode)\n1. Then, run\n    ```bash\n    systemextensionsctl uninstall 5G943LR562 net.obscura.vpn-client-app.system-network-extension\n    ```\n\n### Nix Setup\n\n- Install [`nix`](https://nixos.org/download/) (only the package manager is needed)\n- Enable [`flake`s](https://nixos.wiki/wiki/Flakes)\n\n    Add the following to `~/.config/nix/nix.conf` or `/etc/nix/nix.conf`:\n\n    ```\n    experimental-features = nix-command flakes\n    ```\n\n- Optional, but strongly recommended: Set up [`nix-direnv`](https://github.com/nix-community/nix-direnv) and integrate it with your preferred shell\n\n  If you do this, you can omit the `nix develop ... --command` parts, as `cd`-ing into the repository directory will set up your environment variables with the correct tools as long as you've `direnv allow`-ed the directory.\n\n### Confirming \"Developer ID\" Setup\n\nTo confirm that the Developer ID provisioning profile and codesigning are set up correctly (required for the `Prod Client` build scheme):\n\n1. Pick the `Prod Client` build scheme in Xcode\n1. Create an Archive\n    Choose from Xcode's top-left app menu: \"Product → Archive\"\n1. Ensure that the \"Archive\" action succeeds in the \"Report navigator\"\n    Choose from Xcode's top-left app menu: \"View → Navigators → Reports\"\n\n## Linux\n\n> [!WARNING]\n> As of 2024-07-04, the Linux client is not maintained.\n\n```bash\ncargo build --release && sudo RUST_LOG=info ./target/release/obscuravpn-client\n```\n"
  },
  {
    "path": "android/.gitignore",
    "content": "*.iml\n.gradle\n.kotlin\n/local.properties\n/.idea/caches\n/.idea/libraries\n/.idea/modules.xml\n/.idea/workspace.xml\n/.idea/navEditor.xml\n/.idea/assetWizardSettings.xml\n/.idea/appInsightsSettings.xml\n.DS_Store\n/build\n/lib/*/build\n/captures\n.externalNativeBuild\n.cxx\nlocal.properties\n"
  },
  {
    "path": "android/.idea/.gitignore",
    "content": "# Default ignored files\n/shelf/\n/workspace.xml\n/deploymentTargetSelector.xml\n"
  },
  {
    "path": "android/.idea/.name",
    "content": "ObscuraVPN"
  },
  {
    "path": "android/.idea/AndroidProjectSystem.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"AndroidProjectSystem\">\n    <option name=\"providerId\" value=\"com.android.tools.idea.GradleProjectSystem\" />\n  </component>\n</project>\n"
  },
  {
    "path": "android/.idea/codeStyles/Project.xml",
    "content": "<component name=\"ProjectCodeStyleConfiguration\">\n  <code_scheme name=\"Project\" version=\"173\">\n    <option name=\"RIGHT_MARGIN\" value=\"120\" />\n    <JavaCodeStyleSettings>\n      <option name=\"IMPORT_LAYOUT_TABLE\">\n        <value>\n          <package name=\"android\" withSubpackages=\"true\" static=\"true\" />\n          <package name=\"androidx\" withSubpackages=\"true\" static=\"true\" />\n          <package name=\"com\" withSubpackages=\"true\" static=\"true\" />\n          <package name=\"junit\" withSubpackages=\"true\" static=\"true\" />\n          <package name=\"net\" withSubpackages=\"true\" static=\"true\" />\n          <package name=\"org\" withSubpackages=\"true\" static=\"true\" />\n          <package name=\"java\" withSubpackages=\"true\" static=\"true\" />\n          <package name=\"javax\" withSubpackages=\"true\" static=\"true\" />\n          <package name=\"\" withSubpackages=\"true\" static=\"true\" />\n          <emptyLine />\n          <package name=\"android\" withSubpackages=\"true\" static=\"false\" />\n          <emptyLine />\n          <package name=\"androidx\" withSubpackages=\"true\" static=\"false\" />\n          <emptyLine />\n          <package name=\"com\" withSubpackages=\"true\" static=\"false\" />\n          <emptyLine />\n          <package name=\"junit\" withSubpackages=\"true\" static=\"false\" />\n          <emptyLine />\n          <package name=\"net\" withSubpackages=\"true\" static=\"false\" />\n          <emptyLine />\n          <package name=\"org\" withSubpackages=\"true\" static=\"false\" />\n          <emptyLine />\n          <package name=\"java\" withSubpackages=\"true\" static=\"false\" />\n          <emptyLine />\n          <package name=\"javax\" withSubpackages=\"true\" static=\"false\" />\n          <emptyLine />\n          <package name=\"\" withSubpackages=\"true\" static=\"false\" />\n          <emptyLine />\n        </value>\n      </option>\n    </JavaCodeStyleSettings>\n    <JetCodeStyleSettings>\n      <option name=\"CODE_STYLE_DEFAULTS\" value=\"KOTLIN_OFFICIAL\" />\n    </JetCodeStyleSettings>\n    <codeStyleSettings language=\"XML\">\n      <option name=\"FORCE_REARRANGE_MODE\" value=\"1\" />\n      <indentOptions>\n        <option name=\"CONTINUATION_INDENT_SIZE\" value=\"4\" />\n      </indentOptions>\n      <arrangement>\n        <rules>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>xmlns:android</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>^$</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>xmlns:.*</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>^$</XML_NAMESPACE>\n                </AND>\n              </match>\n              <order>BY_NAME</order>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*:id</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*:name</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>name</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>^$</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>style</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>^$</XML_NAMESPACE>\n                </AND>\n              </match>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>^$</XML_NAMESPACE>\n                </AND>\n              </match>\n              <order>BY_NAME</order>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>\n                </AND>\n              </match>\n              <order>ANDROID_ATTRIBUTE_ORDER</order>\n            </rule>\n          </section>\n          <section>\n            <rule>\n              <match>\n                <AND>\n                  <NAME>.*</NAME>\n                  <XML_ATTRIBUTE />\n                  <XML_NAMESPACE>.*</XML_NAMESPACE>\n                </AND>\n              </match>\n              <order>BY_NAME</order>\n            </rule>\n          </section>\n        </rules>\n      </arrangement>\n    </codeStyleSettings>\n    <codeStyleSettings language=\"kotlin\">\n      <option name=\"CODE_STYLE_DEFAULTS\" value=\"KOTLIN_OFFICIAL\" />\n    </codeStyleSettings>\n  </code_scheme>\n</component>"
  },
  {
    "path": "android/.idea/codeStyles/codeStyleConfig.xml",
    "content": "<component name=\"ProjectCodeStyleConfiguration\">\n  <state>\n    <option name=\"USE_PER_PROJECT_SETTINGS\" value=\"true\" />\n  </state>\n</component>"
  },
  {
    "path": "android/.idea/compiler.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"CompilerConfiguration\">\n    <bytecodeTargetLevel target=\"21\" />\n  </component>\n</project>"
  },
  {
    "path": "android/.idea/detekt.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"DetektPluginSettings\">\n    <option name=\"configurationFiles\">\n      <list>\n        <option value=\"$PROJECT_DIR$/detekt.yml\" />\n      </list>\n    </option>\n    <option name=\"enableDetekt\" value=\"true\" />\n  </component>\n</project>"
  },
  {
    "path": "android/.idea/deviceManager.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"DeviceTable\">\n    <option name=\"columnSorters\">\n      <list>\n        <ColumnSorterState>\n          <option name=\"column\" value=\"Name\" />\n          <option name=\"order\" value=\"ASCENDING\" />\n        </ColumnSorterState>\n      </list>\n    </option>\n  </component>\n</project>\n\n"
  },
  {
    "path": "android/.idea/dictionaries/project.xml",
    "content": "<component name=\"ProjectDictionaryState\">\n  <dictionary name=\"project\">\n    <words>\n      <w>obscura</w>\n      <w>obscuravpn</w>\n      <w>vpnclientapp</w>\n      <w>vpnservice</w>\n    </words>\n  </dictionary>\n</component>"
  },
  {
    "path": "android/.idea/gradle.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"GradleMigrationSettings\" migrationVersion=\"1\" />\n  <component name=\"GradleSettings\">\n    <option name=\"linkedExternalProjectsSettings\">\n      <GradleProjectSettings>\n        <compositeConfiguration>\n          <compositeBuild compositeDefinitionSource=\"SCRIPT\">\n            <builds>\n              <build path=\"$PROJECT_DIR$/buildSrc\" name=\"buildSrc\">\n                <projects>\n                  <project path=\"$PROJECT_DIR$/buildSrc\" />\n                </projects>\n              </build>\n            </builds>\n          </compositeBuild>\n        </compositeConfiguration>\n        <option name=\"testRunner\" value=\"CHOOSE_PER_TEST\" />\n        <option name=\"externalProjectPath\" value=\"$PROJECT_DIR$\" />\n        <option name=\"gradleJvm\" value=\"#GRADLE_LOCAL_JAVA_HOME\" />\n        <option name=\"modules\">\n          <set>\n            <option value=\"$PROJECT_DIR$\" />\n            <option value=\"$PROJECT_DIR$/app\" />\n            <option value=\"$PROJECT_DIR$/buildSrc\" />\n            <option value=\"$PROJECT_DIR$/lib\" />\n            <option value=\"$PROJECT_DIR$/lib/billing\" />\n            <option value=\"$PROJECT_DIR$/lib/util\" />\n          </set>\n        </option>\n      </GradleProjectSettings>\n    </option>\n  </component>\n</project>"
  },
  {
    "path": "android/.idea/kotlinc.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"KotlinJpsPluginSettings\">\n    <option name=\"externalSystemId\" value=\"Gradle\" />\n    <option name=\"version\" value=\"2.3.10\" />\n  </component>\n</project>"
  },
  {
    "path": "android/.idea/ktfmt.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"KtfmtSettings\">\n    <option name=\"customBlockIndent\" value=\"4\" />\n    <option name=\"customMaxLineLength\" value=\"120\" />\n    <option name=\"customTrailingCommaManagementStrategy\" value=\"Only add\" />\n    <option name=\"enableKtfmt\" value=\"Enabled\" />\n    <option name=\"uiFormatterStyle\" value=\"Custom\" />\n  </component>\n</project>"
  },
  {
    "path": "android/.idea/migrations.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"ProjectMigrations\">\n    <option name=\"MigrateToGradleLocalJavaHome\">\n      <set>\n        <option value=\"$PROJECT_DIR$\" />\n      </set>\n    </option>\n  </component>\n</project>\n"
  },
  {
    "path": "android/.idea/misc.xml",
    "content": "<project version=\"4\">\n  <component name=\"ExternalStorageConfigurationManager\" enabled=\"true\" />\n  <component name=\"ProjectRootManager\" version=\"2\" languageLevel=\"JDK_21\" default=\"true\" project-jdk-name=\"jbr-21\" project-jdk-type=\"JavaSDK\">\n    <output url=\"file://$PROJECT_DIR$/build/classes\" />\n  </component>\n  <component name=\"ProjectType\">\n    <option name=\"id\" value=\"Android\" />\n  </component>\n</project>"
  },
  {
    "path": "android/.idea/runConfigurations.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"RunConfigurationProducerService\">\n    <option name=\"ignoredProducers\">\n      <set>\n        <option value=\"com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer\" />\n        <option value=\"com.intellij.execution.junit.AllInPackageConfigurationProducer\" />\n        <option value=\"com.intellij.execution.junit.PatternConfigurationProducer\" />\n        <option value=\"com.intellij.execution.junit.TestInClassConfigurationProducer\" />\n        <option value=\"com.intellij.execution.junit.UniqueIdConfigurationProducer\" />\n        <option value=\"com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer\" />\n        <option value=\"org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer\" />\n        <option value=\"org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer\" />\n      </set>\n    </option>\n  </component>\n</project>\n"
  },
  {
    "path": "android/.idea/vcs.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n  <component name=\"VcsDirectoryMappings\">\n    <mapping directory=\"$PROJECT_DIR$/..\" vcs=\"Git\" />\n  </component>\n</project>\n"
  },
  {
    "path": "android/app/.gitignore",
    "content": "/build\n"
  },
  {
    "path": "android/app/build.gradle.kts",
    "content": "import com.android.build.api.dsl.ApplicationExtension\n\nplugins {\n    alias(libs.plugins.android.application)\n    alias(libs.plugins.hilt.android)\n    alias(libs.plugins.kotlin.android)\n    alias(libs.plugins.kotlinx.serialization)\n    alias(libs.plugins.ksp)\n}\n\nextensions.configure<ApplicationExtension> {\n    buildToolsVersion = \"36.0.0\"\n\n    namespace = \"net.obscura.vpnclientapp\"\n    compileSdk = 36\n\n    defaultConfig {\n        applicationId = \"net.obscura.vpnclientapp\"\n        minSdk = 31\n        targetSdk = 36\n        versionCode = 1\n        versionName = project.getVersionName(project.rootDir)\n\n        testInstrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"\n    }\n\n    buildFeatures {\n        aidl = true\n        buildConfig = true\n    }\n\n    buildTypes {\n        getByName(\"debug\") {\n            applicationIdSuffix = \".debug\"\n            isMinifyEnabled = false\n            isShrinkResources = false\n            resValue(\"string\", \"app_name\", \"Obscura VPN (Debug)\")\n        }\n\n        getByName(\"release\") {\n            isMinifyEnabled = true\n            isShrinkResources = true\n            proguardFiles(\n                getDefaultProguardFile(\"proguard-android-optimize.txt\"),\n                \"proguard-rules.pro\",\n            )\n        }\n    }\n\n    flavorDimensions += listOf(\"billing\")\n\n    productFlavors {\n        create(\"foss\") {\n            dimension = \"billing\"\n            isDefault = true\n        }\n\n        create(\"play\") { dimension = \"billing\" }\n    }\n}\n\nkotlin {\n    compilerOptions { freeCompilerArgs.add(\"-opt-in=kotlinx.serialization.ExperimentalSerializationApi\") }\n    jvmToolchain(21)\n}\n\ndependencies {\n    implementation(libs.androidx.appcompat)\n    implementation(libs.androidx.core.ktx)\n    implementation(libs.androidx.lifecycle)\n    implementation(libs.androidx.webkit)\n    implementation(libs.hilt.android)\n    implementation(libs.kotlinx.serialization.json)\n    implementation(libs.material)\n    implementation(project(\":lib:util\"))\n\n    \"playImplementation\"(project(\":lib:billing\"))\n\n    ksp(libs.hilt.android.compiler)\n\n    testImplementation(libs.junit)\n    androidTestImplementation(libs.androidx.junit)\n    androidTestImplementation(libs.androidx.espresso.core)\n}\n"
  },
  {
    "path": "android/app/google-services.json",
    "content": "{\n  \"project_info\": {\n    \"project_number\": \"980686794718\",\n    \"project_id\": \"obscura-vpn\",\n    \"storage_bucket\": \"obscura-vpn.firebasestorage.app\"\n  },\n  \"client\": [\n    {\n      \"client_info\": {\n        \"mobilesdk_app_id\": \"1:980686794718:android:db99381cf88e8d6712774f\",\n        \"android_client_info\": {\n          \"package_name\": \"net.obscura.vpnclientapp\"\n        }\n      },\n      \"oauth_client\": [],\n      \"api_key\": [\n        {\n          \"current_key\": \"AIzaSyDQpmOigUAURQCeVDA489R8ds78uvAdhCg\"\n        }\n      ],\n      \"services\": {\n        \"appinvite_service\": {\n          \"other_platform_oauth_client\": []\n        }\n      }\n    }\n  ],\n  \"configuration_version\": \"1\"\n}\n"
  },
  {
    "path": "android/app/proguard-rules.pro",
    "content": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguardFiles setting in build.gradle.\n#\n# For more details, see\n#   http://developer.android.com/guide/developing/tools/proguard.html\n\n# If your project uses WebView with JS, uncomment the following\n# and specify the fully qualified class name to the JavaScript interface\n# class:\n#-keepclassmembers class fqcn.of.javascript.interface.for.webview {\n#   public *;\n#}\n\n# Uncomment this to preserve the line number information for\n# debugging stack traces.\n#-keepattributes SourceFile,LineNumberTable\n\n# If you keep the line number information, uncomment this to\n# hide the original source file name.\n#-renamesourcefileattribute SourceFile\n"
  },
  {
    "path": "android/app/src/foss/java/net/obscura/vpnclientapp/BillingFacade.kt",
    "content": "package net.obscura.vpnclientapp\n\nimport android.content.Context\nimport net.obscura.vpnclientapp.activities.MainActivity\nimport net.obscura.vpnclientapp.client.errorCodeUnsupportedOnOS\n\nclass BillingFacade(@Suppress(\"UNUSED_PARAMETER\") context: Context) {\n    @Suppress(\"RedundantSuspendModifier\")\n    suspend fun launchFlow(@Suppress(\"UNUSED_PARAMETER\") mainActivity: MainActivity): Boolean =\n        throw errorCodeUnsupportedOnOS()\n\n    fun destroy() = Unit\n}\n"
  },
  {
    "path": "android/app/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:tools=\"http://schemas.android.com/tools\"\n    xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n    <uses-permission android:name=\"android.permission.INTERNET\" />\n    <uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" />\n    <uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\" />\n    <uses-permission\n        android:name=\"android.permission.FOREGROUND_SERVICE\"\n        tools:ignore=\"ForegroundServicesPolicy\" />\n    <uses-permission android:name=\"android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED\" />\n    <uses-permission android:name=\"android.permission.SCHEDULE_EXACT_ALARM\" />\n    <uses-permission android:name=\"android.permission.POST_NOTIFICATIONS\" />\n\n    <application\n        android:name=\".App\"\n        android:allowBackup=\"true\"\n        android:dataExtractionRules=\"@xml/data_extraction_rules\"\n        android:fullBackupContent=\"@xml/backup_rules\"\n        android:icon=\"@drawable/ic_launcher\"\n        android:label=\"@string/app_name\"\n        android:roundIcon=\"@drawable/ic_launcher\"\n        android:supportsRtl=\"true\"\n        android:theme=\"@style/Theme.ObscuraVPN\">\n\n        <!--\n        `WebView` necessitates aggressive use of `android:configChanges`:\n        https://developer.android.com/develop/ui/compose/quick-guides/content/manage-webview-state\n        -->\n        <activity\n            android:name=\".activities.MainActivity\"\n            android:configChanges=\"keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize\"\n            android:exported=\"true\"\n            android:enabled=\"true\"\n            android:launchMode=\"singleInstance\"\n            android:windowSoftInputMode=\"adjustNothing\">\n\n            <intent-filter>\n                <category android:name=\"android.intent.category.LAUNCHER\" />\n                <action android:name=\"android.intent.action.MAIN\" />\n            </intent-filter>\n\n            <intent-filter>\n                <category android:name=\"android.intent.category.DEFAULT\" />\n                <category android:name=\"android.intent.category.BROWSABLE\" />\n\n                <action android:name=\"android.intent.action.VIEW\" />\n                <data android:scheme=\"obscuravpn\" />\n            </intent-filter>\n        </activity>\n\n        <provider\n            android:authorities=\"${applicationId}.debug_archive_file_provider\"\n            android:name=\".sharing.DebugArchiveFileProvider\"\n            android:exported=\"false\"\n            android:grantUriPermissions=\"true\">\n            <meta-data\n                android:name=\"android.support.FILE_PROVIDER_PATHS\"\n                android:resource=\"@xml/debug_archive_file_provider_paths\" />\n        </provider>\n\n        <service\n            android:name=\".services.ObscuraVpnService\"\n            android:permission=\"android.permission.BIND_VPN_SERVICE\"\n            android:exported=\"false\"\n            android:foregroundServiceType=\"systemExempted\"\n            android:process=\":vpnservice\"\n            tools:ignore=\"VpnServicePolicy\">\n\n            <intent-filter>\n                <action android:name=\"android.net.VpnService\" />\n            </intent-filter>\n        </service>\n\n        <receiver\n            android:name=\".ui.JsonFfiBroadcastReceiver\"\n            android:exported=\"false\" />\n\n        <meta-data\n            android:name=\"android.webkit.WebView.MetricsOptOut\"\n            android:value=\"true\" />\n    </application>\n</manifest>\n"
  },
  {
    "path": "android/app/src/main/aidl/net/obscura/vpnclientapp/services/IObscuraVpnService.aidl",
    "content": "// IObscuraVpnService.aidl\npackage net.obscura.vpnclientapp.services;\n\ninterface IObscuraVpnService {\n    void startTunnel(String exitSelector);\n    void stopTunnel();\n\n    // Submits the command to the ObscuraLibrary.jsonFfi(String, CompletableFuture<String>)\n    // function, returning back a unique ID. To receive the result of the command, listen on the\n    // CommandBridge.Receiver (BroadcastReceiver) for an Intent with the \"id\" extra, \"result\" extra\n    // (indicating success) or \"exception\" extra (indicating an ErrorCodeException).\n    void jsonFfi(long id, String command);\n}\n"
  },
  {
    "path": "android/app/src/main/assets/adi-registration.properties",
    "content": "CPRHUVTQKXB6SAAAAAAAAAAAAA\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/App.kt",
    "content": "package net.obscura.vpnclientapp\n\nimport android.app.Application\nimport dagger.hilt.android.HiltAndroidApp\nimport net.obscura.lib.util.Logger\n\nprivate val log = Logger(App::class)\n\n@HiltAndroidApp\nclass App : Application() {\n    override fun onCreate() {\n        super.onCreate()\n        log.info(\"app version: ${BuildConfig.VERSION_NAME}\")\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/activities/MainActivity.kt",
    "content": "package net.obscura.vpnclientapp.activities\n\nimport android.content.ComponentName\nimport android.content.Intent\nimport android.content.ServiceConnection\nimport android.content.SharedPreferences\nimport android.content.res.Configuration\nimport android.os.Bundle\nimport android.os.IBinder\nimport androidx.activity.addCallback\nimport androidx.appcompat.app.AppCompatActivity\nimport androidx.appcompat.app.AppCompatDelegate\nimport androidx.core.view.WindowCompat\nimport dagger.hilt.android.AndroidEntryPoint\nimport javax.inject.Inject\nimport net.obscura.lib.util.Logger\nimport net.obscura.vpnclientapp.BillingFacade\nimport net.obscura.vpnclientapp.R\nimport net.obscura.vpnclientapp.helpers.requireUIProcess\nimport net.obscura.vpnclientapp.preferences.Preferences\nimport net.obscura.vpnclientapp.services.IObscuraVpnService\nimport net.obscura.vpnclientapp.services.bindVpnService\nimport net.obscura.vpnclientapp.services.unbindVpnService\nimport net.obscura.vpnclientapp.ui.ObscuraUI\nimport net.obscura.vpnclientapp.ui.OsStatusManager\nimport net.obscura.vpnclientapp.ui.VpnPermissionRequestManager\n\nprivate val log = Logger(MainActivity::class)\n\n@AndroidEntryPoint\nclass MainActivity : AppCompatActivity(), ServiceConnection, SharedPreferences.OnSharedPreferenceChangeListener {\n    @Inject lateinit var billingFacade: BillingFacade\n    @Inject lateinit var osStatusManager: OsStatusManager\n    @Inject lateinit var vpnPermissionRequestManager: VpnPermissionRequestManager\n\n    private lateinit var preferences: Preferences\n\n    private lateinit var ui: ObscuraUI\n\n    private var isFreshLaunch: Boolean = true\n    private var isVpnServiceBound: Boolean = false\n\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n\n        requireUIProcess()\n\n        this.isFreshLaunch = savedInstanceState == null\n\n        // Edge-to-edge is the future for Android\n        // https://developer.android.com/develop/ui/views/layout/edge-to-edge\n        WindowCompat.enableEdgeToEdge(this.window)\n\n        setContentView(R.layout.activity_main)\n\n        ui = findViewById(R.id.ui)\n\n        onBackPressedDispatcher.addCallback {\n            if (ui.canGoBack) {\n                ui.goBack()\n            } else {\n                isEnabled = false\n                onBackPressedDispatcher.onBackPressed()\n                isEnabled = true\n            }\n        }\n\n        preferences = Preferences(this).apply { registerListener(this@MainActivity) }\n\n        applyColorScheme()\n\n        this.isVpnServiceBound = this.bindVpnService(this)\n    }\n\n    override fun onNewIntent(intent: Intent) {\n        super.onNewIntent(intent)\n\n        intent.data?.let { uri -> this.ui.handleObscuraUri(uri) }\n    }\n\n    override fun onResume() {\n        super.onResume()\n\n        ui.onResume()\n    }\n\n    override fun onPause() {\n        super.onPause()\n\n        ui.onPause()\n    }\n\n    override fun onDestroy() {\n        super.onDestroy()\n        this.preferences.unregisterListener(this)\n        if (this.isVpnServiceBound) {\n            this.unbindVpnService(this)\n        }\n    }\n\n    override fun onConfigurationChanged(newConfig: Configuration) {\n        super.onConfigurationChanged(newConfig)\n\n        log.debug(\"configuration changed: $newConfig\")\n\n        this.ui.invalidate()\n    }\n\n    override fun onServiceConnected(name: ComponentName?, service: IBinder?) {\n        log.debug(\"onServiceConnected $name $service\")\n        this.ui.onCreate(\n            this.isFreshLaunch,\n            IObscuraVpnService.Stub.asInterface(service),\n            this,\n            this.osStatusManager,\n        )\n        this.isFreshLaunch = false\n    }\n\n    override fun onServiceDisconnected(name: ComponentName?) {\n        log.debug(\"onServiceDisconnected $name\")\n        this.ui.onDestroy()\n    }\n\n    override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {\n        if (key == \"color-scheme\") {\n            applyColorScheme()\n        }\n    }\n\n    private fun applyColorScheme() {\n        AppCompatDelegate.setDefaultNightMode(\n            when (this.preferences.colorScheme) {\n                Preferences.ColorScheme.Auto -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM\n                Preferences.ColorScheme.Dark -> AppCompatDelegate.MODE_NIGHT_YES\n                Preferences.ColorScheme.Light -> AppCompatDelegate.MODE_NIGHT_NO\n            }\n        )\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/client/ErrorCodeException.kt",
    "content": "package net.obscura.vpnclientapp.client\n\nimport androidx.annotation.Keep\n\n// This `Keep` annotation is applied defensively to ensure that this class won't be stripped even if\n// it's only constructed on the Rust side.\n@Keep data class ErrorCodeException(val errorCode: String) : Exception(errorCode)\n\nfun errorCodeOther() = ErrorCodeException(\"other\")\n\nfun errorCodePurchaseFailed() = ErrorCodeException(\"purchaseFailed\")\n\nfun errorCodePurchaseFailedAlreadyOwned() = ErrorCodeException(\"purchaseFailedAlreadyOwned\")\n\nfun errorCodeLegacyAlwaysOn() = ErrorCodeException(\"errorLegacyAlwaysOn\")\n\nfun errorCodeOtherAppAlwaysOn() = ErrorCodeException(\"errorOtherAppAlwaysOn\")\n\nfun errorCodePermissionNotGranted() = ErrorCodeException(\"errorPermissionNotGranted\")\n\nfun errorCodeUnsupportedOnOS() = ErrorCodeException(\"errorUnsupportedOnOS\")\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/client/JsonConfig.kt",
    "content": "package net.obscura.vpnclientapp.client\n\nimport kotlinx.serialization.json.ClassDiscriminatorMode\nimport kotlinx.serialization.json.Json\n\nval jsonConfig = Json {\n    this.classDiscriminatorMode = ClassDiscriminatorMode.NONE\n    this.encodeDefaults = true\n    this.ignoreUnknownKeys = true\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/client/ManagerCmd.kt",
    "content": "package net.obscura.vpnclientapp.client\n\nimport kotlinx.serialization.KeepGeneratedSerializer\nimport kotlinx.serialization.Serializable\nimport kotlinx.serialization.json.JsonObject\nimport net.obscura.lib.util.ExternallyTaggedEnumVariantSerializer\n\nsealed interface ManagerCmd {\n    @KeepGeneratedSerializer\n    @Serializable(with = CreateDebugArchive.Serializer::class)\n    data class CreateDebugArchive(\n        val userFeedback: String?,\n    ) : ManagerCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<CreateDebugArchive>(\"createDebugArchive\", generatedSerializer())\n    }\n\n    @KeepGeneratedSerializer\n    @Serializable(with = GetStatus.Serializer::class)\n    data class GetStatus(val knownVersion: String?) : ManagerCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<GetStatus>(\"getStatus\", generatedSerializer())\n    }\n\n    @KeepGeneratedSerializer\n    @Serializable(with = SetTunnelArgs.Serializer::class)\n    data class SetTunnelArgs(\n        val args: Map<String, JsonObject>? = null,\n        val active: Boolean? = null,\n    ) : ManagerCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<SetTunnelArgs>(\"setTunnelArgs\", generatedSerializer())\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/client/ManagerCmdOk.kt",
    "content": "package net.obscura.vpnclientapp.client\n\nimport kotlinx.serialization.KeepGeneratedSerializer\nimport kotlinx.serialization.Serializable\nimport net.obscura.lib.util.ExternallyTaggedEnumSerializer\nimport net.obscura.lib.util.ExternallyTaggedEnumVariantSerializer\n\nsealed interface ManagerCmdOk {\n    @Serializable\n    data class GetStatus(\n        val accountId: String?,\n        val autoConnect: Boolean,\n        val inNewAccountFlow: Boolean,\n        val version: String,\n        val vpnStatus: VpnStatus,\n    ) : ManagerCmdOk {\n        @Serializable(with = VpnStatus.Serializer::class)\n        sealed interface VpnStatus {\n            object Serializer :\n                ExternallyTaggedEnumSerializer<VpnStatus>(\n                    VpnStatus::class,\n                    listOf(\n                        Connected.Serializer,\n                        Connecting.Serializer,\n                        Disconnected.Serializer,\n                    ),\n                )\n\n            @KeepGeneratedSerializer\n            @Serializable(with = Connected.Serializer::class)\n            class Connected : VpnStatus {\n                internal object Serializer :\n                    ExternallyTaggedEnumVariantSerializer<Connected>(\"connected\", generatedSerializer())\n            }\n\n            @KeepGeneratedSerializer\n            @Serializable(with = Connecting.Serializer::class)\n            class Connecting : VpnStatus {\n                internal object Serializer :\n                    ExternallyTaggedEnumVariantSerializer<Connecting>(\"connecting\", generatedSerializer())\n            }\n\n            @KeepGeneratedSerializer\n            @Serializable(with = Disconnected.Serializer::class)\n            class Disconnected : VpnStatus {\n                internal object Serializer :\n                    ExternallyTaggedEnumVariantSerializer<Disconnected>(\"disconnected\", generatedSerializer())\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/client/ObscuraLibrary.java",
    "content": "package net.obscura.vpnclientapp.client;\n\nimport android.app.Application;\nimport android.content.Context;\nimport androidx.annotation.Keep;\nimport java.util.concurrent.CompletableFuture;\n\npublic class ObscuraLibrary {\n    /** Opaque handle returned by Rust initialization, required by all subsequent native FFI calls. */\n    @Keep\n    public static class FfiHandle {\n        private FfiHandle() {}\n    }\n\n    static FfiHandle load(Context context, String userAgent) {\n        if (!Application.getProcessName().endsWith(\":vpnservice\")) {\n            throw new IllegalStateException(\"Using this class outside of the :vpnservice process is not allowed.\");\n        }\n        System.loadLibrary(\"obscuravpn_client\");\n        return ObscuraLibrary.initialize(context.getFilesDir().getAbsolutePath(), userAgent);\n    }\n\n    static native FfiHandle initialize(String configDir, String userAgent);\n\n    static native void jsonFfi(FfiHandle handle, String json, CompletableFuture<String> future);\n\n    static native void setNetworkInterface(FfiHandle handle, String name, int index);\n    static native void unsetNetworkInterface(FfiHandle handle);\n\n    static native void forwardLog(int level, String tag, String message, String messageId, String throwableString);\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/client/RustFfi.kt",
    "content": "package net.obscura.vpnclientapp.client\n\nimport android.content.Context\nimport java.util.concurrent.CompletableFuture\nimport net.obscura.lib.util.Logger\n\nclass RustFfi(context: Context, userAgent: String) {\n    private val handle: ObscuraLibrary.FfiHandle =\n        ObscuraLibrary.load(\n            context,\n            userAgent,\n        )\n\n    fun logger(tag: String) =\n        Logger(tag) { params ->\n            ObscuraLibrary.forwardLog(\n                params.level.ordinal,\n                params.tag,\n                params.message,\n                params.messageId ?: \"JavaNoID\",\n                params.tr?.toString(),\n            )\n        }\n\n    fun jsonFfi(json: String, future: CompletableFuture<String>) {\n        ObscuraLibrary.jsonFfi(handle, json, future)\n    }\n\n    fun setNetworkInterface(name: String, index: Int) {\n        ObscuraLibrary.setNetworkInterface(handle, name, index)\n    }\n\n    fun unsetNetworkInterface() {\n        ObscuraLibrary.unsetNetworkInterface(handle)\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/helpers/Process.kt",
    "content": "package net.obscura.vpnclientapp.helpers\n\nimport android.app.Application\n\n/** Ensures the calling process is :vpnservice. */\nfun requireVpnServiceProcess() {\n    val currentProcess = Application.getProcessName()\n\n    if (!currentProcess.endsWith(\":vpnservice\")) {\n        throw RuntimeException(\"Called outside of the :vpnservice process ($currentProcess)\")\n    }\n}\n\n/** Ensures the calling process is the main application process. */\nfun requireUIProcess() {\n    val currentProcess = Application.getProcessName()\n\n    if (currentProcess.contains(\":\")) {\n        throw RuntimeException(\"Called outside of the application process ($currentProcess)\")\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/preferences/Preferences.kt",
    "content": "package net.obscura.vpnclientapp.preferences\n\nimport android.content.Context\nimport android.content.SharedPreferences\nimport androidx.core.content.edit\nimport kotlinx.serialization.SerialName\nimport kotlinx.serialization.Serializable\nimport net.obscura.vpnclientapp.client.jsonConfig\nimport net.obscura.vpnclientapp.helpers.requireUIProcess\n\nclass Preferences(context: Context) {\n    init {\n        requireUIProcess()\n    }\n\n    @Serializable\n    enum class ColorScheme {\n        @SerialName(\"dark\") Dark,\n        @SerialName(\"light\") Light,\n        @SerialName(\"auto\") Auto,\n    }\n\n    private val sharedPreferences = context.getSharedPreferences(\"preferences\", Context.MODE_PRIVATE)\n\n    var colorScheme: ColorScheme\n        get() = jsonConfig.decodeFromString<ColorScheme>(this.sharedPreferences.getString(\"color-scheme\", \"\\\"auto\\\"\")!!)\n        set(value) {\n            this.sharedPreferences.edit(commit = true) { putString(\"color-scheme\", jsonConfig.encodeToString(value)) }\n        }\n\n    fun registerListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {\n        this.sharedPreferences.registerOnSharedPreferenceChangeListener(listener)\n    }\n\n    fun unregisterListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {\n        this.sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/services/ContextExtension.kt",
    "content": "package net.obscura.vpnclientapp.services\n\nimport android.content.Context\nimport android.content.Context.BIND_AUTO_CREATE\nimport android.content.Intent\nimport android.content.ServiceConnection\nimport android.net.VpnService.prepare\nimport net.obscura.lib.util.Logger\nimport net.obscura.vpnclientapp.client.errorCodeOther\n\nprivate val log = Logger(\"ContextExtension\")\n\nfun Context.bindVpnService(serviceConnection: ServiceConnection): Boolean {\n    log.info(\"binding VPN service\")\n    val intent = Intent(this, ObscuraVpnService::class.java)\n    return try {\n        val isBinding = this.bindService(intent, serviceConnection, BIND_AUTO_CREATE)\n        if (!isBinding) {\n            log.error(\"missing permissions or service not found\")\n        }\n        isBinding\n    } catch (e: SecurityException) {\n        log.error(\"missing permissions or service not found\", tr = e)\n        this.unbindVpnService(serviceConnection)\n        false\n    }\n}\n\nfun Context.unbindVpnService(serviceConnection: ServiceConnection) {\n    log.info(\"unbinding VPN service\")\n    try {\n        this.unbindService(serviceConnection)\n    } catch (e: IllegalArgumentException) {\n        log.error(\"VPN service connection not registered\", tr = e)\n    }\n}\n\nsealed interface PrepareResult {\n    data class CreateProfile(val intent: Intent) : PrepareResult\n\n    data object Ready : PrepareResult\n\n    data object LegacyAlwaysOn : PrepareResult\n}\n\nfun Context.prepareVpnService(): PrepareResult =\n    try {\n        log.info(\"preparing VPN service\")\n        prepare(this)?.let { PrepareResult.CreateProfile(it) } ?: PrepareResult.Ready\n    } catch (e: IllegalStateException) {\n        // This is undocumented, but `prepare` throws when a Legacy VPN is set to Always-On.\n        // Legacy VPN profiles are created manually using the \"+\" button on \"Network & Internet\" -> \"VPN\".\n        // https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/services/core/java/com/android/server/VpnManagerService.java;l=226;drc=0b5a5f8c78ce8e8800b527216b70db35489b7c41\n        // https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/services/core/java/com/android/server/VpnManagerService.java;l=545-557;drc=0b5a5f8c78ce8e8800b527216b70db35489b7c41\n        log.error(\"a Legacy VPN profile is set to Always-On\", tr = e)\n        PrepareResult.LegacyAlwaysOn\n    }\n\nfun Context.startVpnService(): Result<Unit> =\n    try {\n        log.info(\"starting VPN service\")\n        this.startForegroundService(Intent(this, ObscuraVpnService::class.java))\n        Result.success(Unit)\n    } catch (e: SecurityException) {\n        log.error(\"missing permissions or service not found\", tr = e)\n        Result.failure(errorCodeOther())\n    } catch (e: IllegalStateException) {\n        log.error(\"app not foregrounded\", tr = e)\n        Result.failure(errorCodeOther())\n    }\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/services/IntentExtension.kt",
    "content": "package net.obscura.vpnclientapp.services\n\nimport android.content.Intent\nimport net.obscura.lib.util.Logger\nimport net.obscura.vpnclientapp.client.ErrorCodeException\nimport net.obscura.vpnclientapp.client.errorCodeOther\n\nprivate val log = Logger(\"IntentExtension\")\n\nprivate const val EXTRA_ID = \"id\"\nprivate const val EXTRA_VALUE = \"value\"\nprivate const val EXTRA_ERROR_CODE = \"errorCode\"\n\nfun Intent.putJsonFfiExtras(id: Long, value: String?, exception: Throwable?) {\n    this.putExtra(EXTRA_ID, id)\n    this.putExtra(EXTRA_VALUE, value)\n    this.putExtra(\n        EXTRA_ERROR_CODE,\n        when (exception) {\n            is ErrorCodeException -> exception.errorCode\n            is Throwable -> {\n                log.error(\"job $id threw unexpected exception type: $exception\", tr = exception)\n                null\n            }\n            else -> {\n                if (value == null) {\n                    log.error(\"job $id completed with no response\")\n                }\n                null\n            }\n        },\n    )\n}\n\ndata class JsonFfiIntentPayload(val id: Long, val result: Result<String>)\n\nfun Intent.getJsonFfiExtras(): JsonFfiIntentPayload {\n    val id = this.getLongExtra(EXTRA_ID, -1)\n    val value = this.getStringExtra(EXTRA_VALUE)\n    val errorCode = this.getStringExtra(EXTRA_ERROR_CODE)\n    return JsonFfiIntentPayload(\n        id,\n        if (value != null) {\n            log.trace(\"job $id completed with value: $value\")\n            Result.success(value)\n        } else {\n            log.trace(\"job $id completed with error code: $errorCode\")\n            Result.failure(errorCode?.let { ErrorCodeException(it) } ?: errorCodeOther())\n        },\n    )\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/services/ObscuraVpnService.kt",
    "content": "package net.obscura.vpnclientapp.services\n\nimport android.Manifest\nimport android.annotation.SuppressLint\nimport android.app.PendingIntent\nimport android.content.Intent\nimport android.content.pm.PackageManager\nimport android.content.pm.ServiceInfo\nimport android.net.ConnectivityManager\nimport android.net.ConnectivityManager.NetworkCallback\nimport android.net.Network\nimport android.net.NetworkCapabilities\nimport android.net.NetworkRequest\nimport android.net.VpnService\nimport android.os.Build\nimport android.os.Handler\nimport android.os.IBinder\nimport android.os.Looper\nimport android.os.ParcelFileDescriptor\nimport android.system.OsConstants\nimport androidx.core.app.NotificationChannelCompat\nimport androidx.core.app.NotificationCompat\nimport androidx.core.app.NotificationManagerCompat\nimport androidx.core.content.ContextCompat\nimport java.net.NetworkInterface\nimport java.util.concurrent.CompletableFuture\nimport net.obscura.lib.util.Logger\nimport net.obscura.vpnclientapp.BuildConfig\nimport net.obscura.vpnclientapp.R\nimport net.obscura.vpnclientapp.activities.MainActivity\nimport net.obscura.vpnclientapp.client.ManagerCmd\nimport net.obscura.vpnclientapp.client.ManagerCmdOk\nimport net.obscura.vpnclientapp.client.RustFfi\nimport net.obscura.vpnclientapp.client.jsonConfig\nimport net.obscura.vpnclientapp.helpers.requireVpnServiceProcess\nimport net.obscura.vpnclientapp.ui.JsonFfiBroadcastReceiver\n\nprivate val logNoFfi = Logger(ObscuraVpnService::class)\n\n@SuppressLint(\"VpnServicePolicy\")\nclass ObscuraVpnService : VpnService() {\n    private class Binder(\n        val service: ObscuraVpnService,\n    ) : IObscuraVpnService.Stub() {\n        override fun startTunnel(exitSelector: String?) {\n            service.log.info(\"startTunnel $exitSelector\", \"CddrThRg\")\n            service.startTunnel(exitSelector)\n        }\n\n        override fun stopTunnel() {\n            service.log.info(\"stopTunnel\", \"Gf6f2lwW\")\n            service.stopTunnel()\n        }\n\n        override fun jsonFfi(\n            id: Long,\n            command: String,\n        ) {\n            val future = CompletableFuture<String>()\n            service.rustFfi.jsonFfi(command, future)\n            future.handle { value: String?, exception: Throwable? ->\n                try {\n                    service.sendBroadcast(\n                        Intent(service, JsonFfiBroadcastReceiver::class.java).apply {\n                            this.putJsonFfiExtras(id, value, exception)\n                        }\n                    )\n                } catch (e: Throwable) {\n                    service.log.error(\"failed to broadcast job $id result: $e\", messageId = \"L74T4QBq\", tr = e)\n                }\n            }\n        }\n    }\n\n    companion object {\n        private const val NOTIFICATION_CHANNEL_ID = \"vpn_channel\"\n        private const val NOTIFICATION_ID = 1\n\n        private val instance = java.util.concurrent.atomic.AtomicReference<ObscuraVpnService?>(null)\n\n        @androidx.annotation.Keep\n        @JvmStatic\n        fun ffiSetNetworkConfig(json: String): Int {\n            val service = instance.get()\n            if (service == null) {\n                logNoFfi.error(\"ffiSetNetworkConfig called with no active service\", \"wK3xLm9p\")\n                return -1\n            }\n            val config: OsNetworkConfig =\n                try {\n                    jsonConfig.decodeFromString(json)\n                } catch (e: Exception) {\n                    service.log.error(\"failed to parse os network config: $e\", \"yN4zPn0q\", e)\n                    return -1\n                }\n            val pfd =\n                try {\n                    service.applyNetworkConfig(config)\n                } catch (e: Exception) {\n                    service.log.error(\"failed to apply os network config: $e\", \"U6hVQEJR\", e)\n                    return -1\n                }\n            return pfd?.detachFd() ?: -1\n        }\n    }\n\n    private data class NetworkInterfaceProps(val name: String, val index: Int)\n\n    private lateinit var rustFfi: RustFfi\n    private lateinit var log: Logger\n    private lateinit var handler: Handler\n\n    private val connectivityManager\n        get() = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager\n\n    private var vpnStatus: ManagerCmdOk.GetStatus.VpnStatus? = null\n\n    private var currentNetwork: Network? = null\n\n    override fun onCreate() {\n        super.onCreate()\n\n        logNoFfi.info(\"ObscuraVpnService onCreate entry\")\n        rustFfi = RustFfi(this, \"obscura.net/android/${BuildConfig.VERSION_NAME}\")\n        log = rustFfi.logger(logNoFfi.tag)\n\n        if (instance.getAndSet(this) != null) {\n            log.error(\"instance already initialized\", \"xR4mNb7c\")\n        }\n        requireVpnServiceProcess()\n\n        log.info(\"onCreate\", \"vqiGa01f\")\n\n        handler = Handler(Looper.getMainLooper())\n\n        val networkRequest =\n            NetworkRequest.Builder()\n                .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)\n                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)\n                .build()\n        val service = this\n        connectivityManager.registerBestMatchingNetworkCallback(\n            networkRequest,\n            object : NetworkCallback() {\n                override fun onAvailable(network: Network) {\n                    service.currentNetwork = network\n                    service.updateInterface(network)\n                }\n\n                override fun onLost(network: Network) {\n                    if (network == service.currentNetwork) {\n                        service.currentNetwork = null\n                        service.updateInterface(null)\n                    }\n                }\n            },\n            handler,\n        )\n\n        createNotificationChannel()\n\n        loadStatus(null)\n    }\n\n    private fun start() {\n        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {\n            this.startForeground(\n                NOTIFICATION_ID,\n                this.buildNotification(),\n                ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED,\n            )\n        } else {\n            this.startForeground(NOTIFICATION_ID, this.buildNotification())\n        }\n    }\n\n    override fun onStartCommand(\n        intent: Intent?,\n        flags: Int,\n        startId: Int,\n    ): Int {\n        log.info(\"onStartCommand $intent ${intent?.action} $flags $startId\", \"C9rsG0uh\")\n        this.start()\n        if (intent?.action == SERVICE_INTERFACE) {\n            log.info(\"onStartCommand was system-initiated\", \"sktWFegO\")\n            this.startTunnel(null)\n        }\n        return START_STICKY\n    }\n\n    override fun onBind(intent: Intent?): IBinder {\n        log.info(\"onBind $intent ${intent?.action}\", \"lckBR8hX\")\n        if (intent?.action == SERVICE_INTERFACE) {\n            log.info(\"onBind was system-initiated\", \"4olaayXf\")\n            this.start()\n        }\n        return Binder(this)\n    }\n\n    override fun onRebind(intent: Intent?) {\n        log.info(\"onRebind $intent ${intent?.action}\", \"AcVtL2Ub\")\n        super.onRebind(intent)\n        if (intent?.action == SERVICE_INTERFACE) {\n            log.info(\"onRebind was system-initiated\", \"YsdxJ7Ni\")\n            this.start()\n        }\n    }\n\n    override fun onUnbind(intent: Intent?): Boolean {\n        log.info(\"onUnbind $intent ${intent?.action}\", \"woAdA7g2\")\n        if (intent?.action == SERVICE_INTERFACE) {\n            log.info(\"onUnbind was system-initiated\", \"oNOWQoPR\")\n            this.stopTunnel()\n            this.stopForeground(STOP_FOREGROUND_DETACH)\n        }\n        return true\n    }\n\n    private fun onStatusUpdated(status: ManagerCmdOk.GetStatus) {\n        log.info(\"status updated $status\", \"xXx7PxdD\")\n        vpnStatus = status.vpnStatus\n        loadStatus(status.version)\n        updateNotification()\n    }\n\n    override fun onRevoke() {\n        super.onRevoke()\n        log.info(\"onRevoke\", \"V3qS5kil\")\n        this.stopTunnel()\n        this.stopForeground(STOP_FOREGROUND_DETACH)\n    }\n\n    override fun onDestroy() {\n        super.onDestroy()\n        if (instance.getAndSet(null) == null) {\n            log.error(\"instance already cleared\", \"bQ5wKr8d\")\n        }\n        log.info(\"onDestroy\", \"yNLRpqaN\")\n        stopTunnel()\n    }\n\n    private fun updateNotification() {\n        // permission should already have been granted, but checking here to avoid crashes and to fix\n        // the lint errors\n        if (\n            ContextCompat.checkSelfPermission(\n                this,\n                Manifest.permission.POST_NOTIFICATIONS,\n            ) == PackageManager.PERMISSION_GRANTED\n        ) {\n            NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, buildNotification())\n        }\n    }\n\n    private fun buildNotification() =\n        NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)\n            .setContentIntent(\n                PendingIntent.getActivity(\n                    this,\n                    0,\n                    Intent().apply {\n                        this.action = Intent.ACTION_MAIN\n                        this.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP\n                        this.setClassName(\n                            BuildConfig.APPLICATION_ID,\n                            MainActivity::class.qualifiedName!!,\n                        )\n                    },\n                    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,\n                )\n            )\n            .setContentTitle(getString(R.string.app_name))\n            .setContentText(\n                getString(\n                    R.string.notification_vpn_text,\n                    when (this.vpnStatus) {\n                        is ManagerCmdOk.GetStatus.VpnStatus.Connected ->\n                            getString(R.string.notification_vpn_status_connected)\n                        is ManagerCmdOk.GetStatus.VpnStatus.Connecting ->\n                            getString(R.string.notification_vpn_status_connecting)\n                        is ManagerCmdOk.GetStatus.VpnStatus.Disconnected,\n                        null -> getString(R.string.notification_vpn_status_disconnected)\n                    },\n                ),\n            )\n            .setSmallIcon(R.drawable.ic_stat_name)\n            .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)\n            .setOngoing(true)\n            .setLocalOnly(true)\n            .setOnlyAlertOnce(true)\n            .setCategory(NotificationCompat.CATEGORY_SERVICE)\n            .build()\n\n    private fun createNotificationChannel() {\n        NotificationManagerCompat.from(this)\n            .createNotificationChannel(\n                NotificationChannelCompat.Builder(\n                        NOTIFICATION_CHANNEL_ID,\n                        NotificationManagerCompat.IMPORTANCE_LOW,\n                    )\n                    .setName(getString(R.string.notification_channel_vpn_name))\n                    .build(),\n            )\n    }\n\n    private fun loadStatus(knownVersion: String?) {\n        log.info(\"load status $knownVersion\", \"8pXipD8h\")\n\n        CompletableFuture<String>().also {\n            rustFfi.jsonFfi(\n                jsonConfig.encodeToString(ManagerCmd.GetStatus(knownVersion)),\n                it,\n            )\n\n            it.handle { data, tr ->\n                log.info(\"getStatus completed $data\", \"oiAyY4gh\", tr)\n                data?.let { data -> onStatusUpdated(jsonConfig.decodeFromString(data)) }\n            }\n        }\n    }\n\n    private fun setTunnelArgs(exit: String?, active: Boolean?) {\n        CompletableFuture<String>().also {\n            rustFfi.jsonFfi(\n                jsonConfig.encodeToString(\n                    ManagerCmd.SetTunnelArgs(\n                        args = exit?.let { exit -> jsonConfig.decodeFromString(exit) },\n                        active,\n                    ),\n                ),\n                it,\n            )\n        }\n    }\n\n    private fun stopTunnel() {\n        setTunnelArgs(null, false)\n    }\n\n    private fun startTunnel(exitSelector: String?) {\n        setTunnelArgs(exitSelector, true)\n    }\n\n    private fun applyNetworkConfig(networkConfig: OsNetworkConfig): ParcelFileDescriptor? {\n        log.info(\"applying network config\", \"q9cnmRY0\")\n\n        val pfd =\n            Builder()\n                .apply {\n                    // always disallow current app so it doesn't get routed through the VPN\n                    addDisallowedApplication(applicationInfo.packageName)\n\n                    setMtu(networkConfig.mtu)\n\n                    // Inherit meteredness from the underlying network (set via setUnderlyingNetworks).\n                    // Without this, VpnService.Builder defaults to always marking the VPN as metered,\n                    // regardless of the underlying network.\n                    setMetered(false)\n\n                    if (!networkConfig.useSystemDns) {\n                        networkConfig.dns.forEach { addDnsServer(it) }\n                    }\n\n                    networkConfig.ipv4.split(\"/\").let { addAddress(it[0], if (it.size == 2) it[1].toInt() else 32) }\n\n                    networkConfig.ipv6.split(\"/\").let { addAddress(it[0], if (it.size == 2) it[1].toInt() else 128) }\n\n                    addRoute(\"0.0.0.0\", 0)\n                    addRoute(\"::\", 0)\n\n                    allowFamily(OsConstants.AF_INET)\n                    allowFamily(OsConstants.AF_INET6)\n                }\n                .establish()\n\n        if (pfd == null) {\n            log.error(\"VpnService.Builder.establish() returned null\", \"tR7uWe2x\")\n        }\n        return pfd\n    }\n\n    private fun getNetworkInterfaceProps(network: Network?): NetworkInterfaceProps? {\n        val network = network ?: return null\n        val linkProperties =\n            this.connectivityManager.getLinkProperties(network)\n                ?: run {\n                    log.error(\"failed to get link properties for network: $network\", \"W0JKaOGP\")\n                    return null\n                }\n        val name =\n            linkProperties.interfaceName\n                ?: run {\n                    log.error(\"network has no interface name: $network\", \"ukjpaGLl\")\n                    return null\n                }\n        val ni =\n            NetworkInterface.getByName(name)\n                ?: run {\n                    log.error(\"failed to get interface by name: $name\", \"JvEt0GtR\")\n                    return null\n                }\n        log.info(\"setting network interface: $name ${ni.index}\", \"pOsKRATd\")\n        return NetworkInterfaceProps(name, ni.index)\n    }\n\n    private fun updateInterface(network: Network?) {\n        log.info(\"network interface changed: $network\", \"crWriIOe\")\n        this.setUnderlyingNetworks(if (network != null) arrayOf(network) else emptyArray())\n        val networkInterface = this.getNetworkInterfaceProps(network)\n        if (networkInterface != null) {\n            rustFfi.setNetworkInterface(networkInterface.name, networkInterface.index)\n        } else {\n            rustFfi.unsetNetworkInterface()\n        }\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/services/OsNetworkConfig.kt",
    "content": "package net.obscura.vpnclientapp.services\n\nimport kotlinx.serialization.Serializable\n\n// Keep synchronized with rustlib/src/network_config.rs\n@Serializable\ndata class OsNetworkConfig(\n    val dns: List<String>,\n    val ipv4: String,\n    val ipv6: String,\n    val mtu: Int,\n    val useSystemDns: Boolean,\n)\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/sharing/DebugArchiveFileProvider.java",
    "content": "package net.obscura.vpnclientapp.sharing;\n\nimport androidx.core.content.FileProvider;\nimport net.obscura.vpnclientapp.R;\n\n// We need to extend `FileProvider` because some OEMs strip `meta-data` tags from the manifest:\n// https://github.com/androidx/androidx/commit/a4385569db989747caf6b110b345a09ceb86acc7\n// ...unfortunately, Kotlin subclasses don't inherit static methods, so we need to use Java.\npublic class DebugArchiveFileProvider extends FileProvider {\n    public DebugArchiveFileProvider() {\n        super(R.xml.debug_archive_file_provider_paths);\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/ui/BillingModule.kt",
    "content": "package net.obscura.vpnclientapp.ui\n\nimport android.content.Context\nimport dagger.Module\nimport dagger.Provides\nimport dagger.hilt.InstallIn\nimport dagger.hilt.android.ActivityRetainedLifecycle\nimport dagger.hilt.android.components.ActivityRetainedComponent\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport dagger.hilt.android.scopes.ActivityRetainedScoped\nimport net.obscura.vpnclientapp.BillingFacade\n\n// Lifecycle/scope discussion:\n// https://www.revenuecat.com/blog/engineering/hilt-sdk-lifecycle/\n@Module\n@InstallIn(ActivityRetainedComponent::class)\nobject BillingModule {\n    @Provides\n    @ActivityRetainedScoped\n    fun provideBillingFacade(\n        @ApplicationContext context: Context,\n        lifecycle: ActivityRetainedLifecycle,\n    ): BillingFacade {\n        val billing = BillingFacade(context)\n        lifecycle.addOnClearedListener { billing.destroy() }\n        return billing\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/ui/JsonFfiBroadcastReceiver.kt",
    "content": "package net.obscura.vpnclientapp.ui\n\nimport android.content.BroadcastReceiver\nimport android.content.Context\nimport android.content.Intent\nimport java.util.concurrent.ConcurrentHashMap\nimport java.util.concurrent.atomic.AtomicLong\nimport kotlinx.coroutines.CompletableDeferred\nimport kotlinx.coroutines.completeWith\nimport net.obscura.lib.util.Logger\nimport net.obscura.vpnclientapp.services.IObscuraVpnService\nimport net.obscura.vpnclientapp.services.getJsonFfiExtras\n\nprivate val log = Logger(JsonFfiBroadcastReceiver::class)\n\nclass JsonFfiBroadcastReceiver : BroadcastReceiver() {\n    companion object {\n        private val waiting by lazy { ConcurrentHashMap<Long, CompletableDeferred<String>>() }\n        private val currentId = AtomicLong(0)\n\n        internal fun waitForResponse(\n            binder: IObscuraVpnService,\n            cmd: String,\n        ) =\n            CompletableDeferred<String>().also { job ->\n                val id = this.currentId.incrementAndGet()\n                log.trace(\"job $id registered: $cmd\")\n                try {\n                    binder.jsonFfi(id, cmd)\n                    this.waiting[id] = job\n                } catch (e: Throwable) {\n                    log.error(\"job $id failed: $e\", tr = e)\n                    job.completeExceptionally(e)\n                }\n            }\n    }\n\n    override fun onReceive(context: Context, intent: Intent) {\n        val args = intent.getJsonFfiExtras()\n        waiting.remove(args.id)?.completeWith(args.result)\n            ?: run { log.error(\"job ${args.id} already completed (or never registered)\") }\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/ui/NetworkStatusObserver.kt",
    "content": "package net.obscura.vpnclientapp.ui\n\nimport android.content.Context\nimport android.net.ConnectivityManager\nimport android.net.Network\nimport android.net.NetworkCapabilities\nimport android.net.NetworkRequest\nimport net.obscura.lib.util.Logger\n\nprivate val log = Logger(NetworkStatusObserver::class)\n\n// Network callbacks run on the \"connectivity thread\" by default:\n// https://developer.android.com/develop/connectivity/network-ops/reading-network-state#listening-events\ninternal class NetworkStatusObserver(context: Context, private val callback: Callback) :\n    ConnectivityManager.NetworkCallback() {\n    interface Callback {\n        fun onAvailableNetworksChanged(availableNetworks: Int)\n    }\n\n    private var availableNetworks = 0\n    private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager\n\n    init {\n        val networkRequest =\n            NetworkRequest.Builder()\n                .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)\n                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)\n                .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)\n                .build()\n        this.connectivityManager.registerNetworkCallback(networkRequest, this)\n    }\n\n    override fun onAvailable(network: Network) {\n        this.availableNetworks += 1\n        log.debug(\"network available: $network (available networks: ${this.availableNetworks})\")\n        this.callback.onAvailableNetworksChanged(this.availableNetworks)\n    }\n\n    override fun onLost(network: Network) {\n        this.availableNetworks = (this.availableNetworks - 1).coerceAtLeast(0)\n        log.debug(\"network lost: $network (available networks: ${this.availableNetworks})\")\n        this.callback.onAvailableNetworksChanged(this.availableNetworks)\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/ui/ObscuraUI.kt",
    "content": "package net.obscura.vpnclientapp.ui\n\nimport android.content.Context\nimport android.net.Uri\nimport android.util.AttributeSet\nimport android.widget.FrameLayout\nimport androidx.core.graphics.Insets\nimport androidx.core.view.ViewCompat\nimport androidx.core.view.WindowInsetsCompat\nimport androidx.core.view.postDelayed\nimport com.google.android.material.bottomnavigation.BottomNavigationView\nimport com.google.android.material.navigation.NavigationBarView\nimport net.obscura.lib.util.Logger\nimport net.obscura.vpnclientapp.R\nimport net.obscura.vpnclientapp.activities.MainActivity\nimport net.obscura.vpnclientapp.client.ManagerCmdOk\nimport net.obscura.vpnclientapp.services.IObscuraVpnService\n\nprivate val log = Logger(ObscuraUI::class)\n\nclass ObscuraUI @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : FrameLayout(context, attrs) {\n    private lateinit var vpnStatusObserver: VpnStatusObserver\n\n    val canGoBack\n        get() = (webView?.canGoBack() ?: false) || (bottomNavigation.selectedItemId != R.id.nav_connection)\n\n    private lateinit var webViewContainer: FrameLayout\n    private lateinit var bottomNavigation: BottomNavigationView\n    private var loggedIn: Boolean = false\n\n    private var webView: ObscuraWebView? = null\n\n    private val itemReselectedListener = NavigationBarView.OnItemReselectedListener { navigateToTab(it.itemId) }\n\n    private val itemSelectedListener =\n        NavigationBarView.OnItemSelectedListener {\n            navigateToTab(it.itemId)\n\n            true\n        }\n\n    private fun setLoggedIn(loggedIn: Boolean) {\n        this.bottomNavigation.visibility = if (loggedIn) VISIBLE else GONE\n        this.loggedIn = loggedIn\n    }\n\n    override fun onFinishInflate() {\n        super.onFinishInflate()\n\n        this.webViewContainer = this.findViewById(R.id.web_view_container)\n        this.bottomNavigation = this.findViewById(R.id.nav_view)\n        this.bottomNavigation.visibility = GONE\n        this.bottomNavigation.setOnItemReselectedListener(itemReselectedListener)\n        this.bottomNavigation.setOnItemSelectedListener(itemSelectedListener)\n\n        // TODO: Synchronize padding with IME animation\n        // https://linear.app/soveng/issue/OBS-3233/android-ime-animation-jank\n        // TODO: Edge-to-edge `WebView`\n        // https://linear.app/soveng/issue/OBS-3237/android-edge-to-edge-webview\n        ViewCompat.setOnApplyWindowInsetsListener(this.webViewContainer) { view, windowInsets ->\n            val insetsMask =\n                WindowInsetsCompat.Type.displayCutout()\n                    .or(WindowInsetsCompat.Type.navigationBars())\n                    .or(WindowInsetsCompat.Type.statusBars())\n            val insets = windowInsets.getInsets(insetsMask)\n            val imeMask = WindowInsetsCompat.Type.ime()\n            val bottom =\n                if (windowInsets.isVisible(imeMask)) {\n                    windowInsets.getInsets(imeMask).bottom\n                } else if (!this.loggedIn) {\n                    insets.bottom\n                } else {\n                    0\n                }\n            // Only use non-zero insets when there's overlap\n            // https://developer.android.com/develop/ui/views/layout/webapps/understand-window-insets#bounds-overlap\n            view.setPadding(insets.left, insets.top, insets.right, bottom)\n            // Child `WebView` should ignore any insets we applied here\n            // https://developer.android.com/develop/ui/views/layout/webapps/understand-window-insets#inset-handling\n            WindowInsetsCompat.Builder(windowInsets).setInsets(insetsMask.or(imeMask), Insets.NONE).build()\n        }\n        ViewCompat.setOnApplyWindowInsetsListener(this.bottomNavigation) { view, windowInsets ->\n            // Hide bottom nav when IME is visible\n            // https://github.com/software-mansion/react-native-screens/issues/3647\n            val showBottomNav = this.loggedIn && !windowInsets.isVisible(WindowInsetsCompat.Type.ime())\n            view.visibility = if (showBottomNav) VISIBLE else GONE\n            val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())\n            view.setPadding(systemBars.left, 0, systemBars.right, systemBars.bottom)\n            WindowInsetsCompat.CONSUMED\n        }\n    }\n\n    fun onCreate(\n        isFreshLaunch: Boolean,\n        binder: IObscuraVpnService,\n        mainActivity: MainActivity,\n        osStatusManager: OsStatusManager,\n    ) {\n        onDestroy()\n\n        this.vpnStatusObserver =\n            VpnStatusObserver(\n                binder,\n                object : VpnStatusObserver.Callback {\n                    private var isAutoConnectEligible = isFreshLaunch\n\n                    override suspend fun onStatusChanged(status: ManagerCmdOk.GetStatus) {\n                        osStatusManager.update {\n                            this.vpnStatus =\n                                when (status.vpnStatus) {\n                                    is ManagerCmdOk.GetStatus.VpnStatus.Connected -> OsStatus.OsVpnStatus.Connected\n                                    is ManagerCmdOk.GetStatus.VpnStatus.Connecting -> OsStatus.OsVpnStatus.Connecting\n                                    is ManagerCmdOk.GetStatus.VpnStatus.Disconnected ->\n                                        OsStatus.OsVpnStatus.Disconnected\n                                }\n                        }\n                        this@ObscuraUI.setLoggedIn(status.accountId != null && !status.inNewAccountFlow)\n                        val shouldAutoConnect =\n                            this.isAutoConnectEligible &&\n                                status.autoConnect &&\n                                status.vpnStatus is ManagerCmdOk.GetStatus.VpnStatus.Disconnected\n                        this.isAutoConnectEligible = false\n                        if (shouldAutoConnect) {\n                            mainActivity.vpnPermissionRequestManager\n                                .requestVpnStart()\n                                .mapCatching { binder.startTunnel(null) }\n                                .onSuccess { log.info(\"auto-connected VPN\") }\n                                .onFailure { log.error(\"failed to auto-connect VPN: ${it.message}\", tr = it) }\n                        }\n                    }\n                },\n            )\n        mainActivity.lifecycle.addObserver(this.vpnStatusObserver)\n\n        webView =\n            ObscuraWebView(context, binder, mainActivity, osStatusManager).apply {\n                webViewContainer.addView(\n                    this,\n                    LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),\n                )\n\n                onPageLoadedCallback = {\n                    if (bottomNavigation.selectedItemId != R.id.nav_connection) {\n                        // TODO: make sure UI picks this up correctly\n\n                        var delay = 0L\n                        while (delay < 100L) {\n                            postDelayed(delay) { navigateToTab(bottomNavigation.selectedItemId) }\n                            delay += 10\n                        }\n                    }\n                }\n            }\n    }\n\n    fun onResume() {\n        webView?.onResume()\n    }\n\n    fun onPause() {\n        webView?.onPause()\n    }\n\n    fun onDestroy() {\n        bottomNavigation.visibility = GONE\n        webViewContainer.removeAllViews()\n\n        this.webView?.bridge?.cancel()\n        this.webView?.destroy()\n        this.webView = null\n    }\n\n    override fun invalidate() {\n        super.invalidate()\n\n        this.webView?.invalidate()\n    }\n\n    fun goBack() {\n        if (webView?.canGoBack() ?: false) {\n            webView?.goBack()\n        } else if (bottomNavigation.selectedItemId != R.id.nav_connection) {\n            bottomNavigation.selectedItemId = R.id.nav_connection\n        }\n    }\n\n    private fun navigateToTab(id: Int) {\n        val path =\n            when (id) {\n                R.id.nav_connection -> \"\"\n                R.id.nav_location -> \"location\"\n                R.id.nav_account -> \"account\"\n                R.id.nav_settings -> \"settings\"\n                R.id.nav_about -> \"about\"\n                else -> {\n                    log.error(\"unrecognized view id: $id\")\n                    return\n                }\n            }\n        this.webView?.navigate(path)\n    }\n\n    fun handleObscuraUri(uri: Uri) {\n        log.debug(\"handling deep link: $uri\")\n        val id =\n            when (uri.path) {\n                \"/account\" -> R.id.nav_account\n                \"/location\" -> R.id.nav_location\n                else -> {\n                    log.error(\"unrecognized path for deep link: $uri\")\n                    return\n                }\n            }\n        this.bottomNavigation.selectedItemId = id\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/ui/ObscuraWebView.kt",
    "content": "package net.obscura.vpnclientapp.ui\n\nimport android.annotation.SuppressLint\nimport android.content.Context\nimport android.content.Intent\nimport android.util.AttributeSet\nimport android.webkit.WebMessage\nimport android.webkit.WebResourceRequest\nimport android.webkit.WebView\nimport android.webkit.WebViewClient\nimport androidx.core.net.toUri\nimport androidx.webkit.WebViewAssetLoader\nimport net.obscura.vpnclientapp.activities.MainActivity\nimport net.obscura.vpnclientapp.services.IObscuraVpnService\nimport net.obscura.vpnclientapp.ui.bridge.WebCmdBridge\n\n@SuppressLint(\"SetJavaScriptEnabled\", \"ViewConstructor\")\nclass ObscuraWebView\n@JvmOverloads\nconstructor(\n    context: Context,\n    binder: IObscuraVpnService,\n    mainActivity: MainActivity,\n    osStatusManager: OsStatusManager,\n    attrs: AttributeSet? = null,\n) : WebView(context, attrs) {\n    companion object {\n        val ORIGIN = \"https://appassets.androidplatform.net\".toUri()\n\n        val HOME = \"$ORIGIN/assets/web/index.html\"\n    }\n\n    val bridge =\n        WebCmdBridge(context, binder, mainActivity, osStatusManager) { data ->\n            post { postWebMessage(WebMessage(\"android/$data\"), ORIGIN) }\n        }\n\n    var onPageLoadedCallback: ((String) -> Unit)? = null\n\n    init {\n        settings.domStorageEnabled = true\n        settings.javaScriptEnabled = true\n\n        addJavascriptInterface(bridge, \"obscuraAndroidCommandBridge\")\n\n        WebViewAssetLoader.Builder()\n            .addPathHandler(\"/assets/\", WebViewAssetLoader.AssetsPathHandler(context))\n            .addPathHandler(\"/res/\", WebViewAssetLoader.ResourcesPathHandler(context))\n            .build()\n            .also { assetLoader ->\n                webViewClient =\n                    object : WebViewClient() {\n                        override fun shouldOverrideUrlLoading(\n                            view: WebView,\n                            request: WebResourceRequest,\n                        ): Boolean {\n                            val shouldOverride = request.url.host != ORIGIN.host\n                            if (shouldOverride && request.isForMainFrame) {\n                                context.startActivity(\n                                    Intent(\n                                        Intent.ACTION_VIEW,\n                                        if (request.url.scheme == \"http\") {\n                                            request.url.buildUpon().scheme(\"https\").build()\n                                        } else {\n                                            request.url\n                                        },\n                                    )\n                                )\n                            }\n                            return shouldOverride\n                        }\n\n                        override fun shouldInterceptRequest(\n                            view: WebView?,\n                            request: WebResourceRequest,\n                        ) = assetLoader.shouldInterceptRequest(request.url)\n\n                        override fun onPageFinished(view: WebView?, url: String) {\n                            super.onPageFinished(view, url)\n\n                            onPageLoadedCallback?.invoke(url)\n                        }\n                    }\n            }\n\n        loadUrl(HOME)\n    }\n\n    fun navigate(path: String) {\n        postWebMessage(WebMessage(\"android-navigate/$path\"), ORIGIN)\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/ui/OsStatus.kt",
    "content": "package net.obscura.vpnclientapp.ui\n\nimport kotlinx.serialization.SerialName\nimport kotlinx.serialization.Serializable\n\n@Serializable\ndata class OsStatus(\n    val version: String,\n    val internetAvailable: Boolean,\n    val osVpnStatus: OsVpnStatus,\n    val srcVersion: String,\n    val updaterStatus: UpdaterStatus,\n    val debugBundleStatus: DebugBundleStatus,\n    val canSendMail: Boolean,\n    val loginItemStatus: LoginItemStatus?,\n    val playBilling: Boolean,\n) {\n    // TODO: https://linear.app/soveng/issue/OBS-2640/change-nevpnstatus-to-be-platform-agnostic\n    @Serializable\n    enum class OsVpnStatus {\n        @SerialName(\"disconnected\") Disconnected,\n        @SerialName(\"connecting\") Connecting,\n        @SerialName(\"connected\") Connected,\n    }\n\n    @Serializable data class LoginItemStatus(val registered: Boolean, val error: String?)\n\n    @Serializable\n    data class DebugBundleStatus(\n        var inProgress: Boolean?,\n        var latestPath: String?,\n        var inProgressCounter: Long,\n    )\n\n    @Serializable\n    data class UpdaterStatus(\n        val type: String, // TODO UpdaterStatusType\n        val appcast: AppcastSummary?,\n        val error: String?,\n        val errorCode: Long?,\n    ) {\n        @Serializable\n        data class AppcastSummary(\n            val date: String,\n            val description: String,\n            val version: String,\n            val minSystemVersionSdk: Boolean,\n        )\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/ui/OsStatusManager.kt",
    "content": "package net.obscura.vpnclientapp.ui\n\nimport android.content.Context\nimport dagger.hilt.android.qualifiers.ApplicationContext\nimport java.util.UUID\nimport javax.inject.Inject\nimport javax.inject.Singleton\nimport kotlinx.coroutines.CompletableDeferred\nimport kotlinx.coroutines.Deferred\nimport kotlinx.coroutines.completeWith\nimport net.obscura.lib.util.Logger\nimport net.obscura.vpnclientapp.BuildConfig\nimport net.obscura.vpnclientapp.client.jsonConfig\n\nprivate val log = Logger(OsStatusManager::class)\n\n@Singleton // Prevents loss of state on activity destruction\nclass OsStatusManager @Inject constructor(@ApplicationContext context: Context) : NetworkStatusObserver.Callback {\n    data class State(\n        var debugBundleStatus: OsStatus.DebugBundleStatus =\n            OsStatus.DebugBundleStatus(\n                inProgress = false,\n                latestPath = null,\n                inProgressCounter = 0,\n            ),\n        var internetAvailable: Boolean = false,\n        var vpnStatus: OsStatus.OsVpnStatus = OsStatus.OsVpnStatus.Disconnected,\n    )\n\n    private data class VersionedState(val version: UUID, val state: State)\n\n    private var current = VersionedState(UUID.randomUUID(), State())\n    private val waiting = ArrayList<CompletableDeferred<String>>()\n\n    init {\n        NetworkStatusObserver(context, this)\n    }\n\n    override fun onAvailableNetworksChanged(availableNetworks: Int) {\n        this.update { this.internetAvailable = availableNetworks > 0 }\n    }\n\n    @Synchronized\n    fun update(block: State.() -> Unit = {}) {\n        val version = UUID.randomUUID()\n        val result = runCatching {\n            block(this.current.state)\n            OsStatus(\n                    version = version.toString(),\n                    internetAvailable = this.current.state.internetAvailable,\n                    osVpnStatus = this.current.state.vpnStatus,\n                    srcVersion = BuildConfig.VERSION_NAME,\n                    updaterStatus =\n                        OsStatus.UpdaterStatus(\n                            type = \"uninitiated\",\n                            appcast = null,\n                            error = null,\n                            errorCode = null,\n                        ),\n                    debugBundleStatus = this.current.state.debugBundleStatus,\n                    canSendMail = true,\n                    loginItemStatus = null,\n                    playBilling =\n                        @Suppress(\"KotlinConstantConditions\", \"SimplifyBooleanWithConstants\")\n                        (BuildConfig.FLAVOR == \"play\"),\n                )\n                .let { jsonConfig.encodeToString(it) }\n        }\n        this.current = VersionedState(version, this.current.state)\n        log.debug(\"updated OS status: ${this.current}\")\n        this.waiting.forEach { it.completeWith(result) }\n        this.waiting.clear()\n    }\n\n    @Synchronized\n    fun waitForUpdate(knownVersion: String?): Deferred<String> =\n        CompletableDeferred<String>().also {\n            this.waiting.add(it)\n            if (this.current.version.toString() != knownVersion) {\n                this.update()\n            }\n        }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/ui/VpnPermissionRequestManager.kt",
    "content": "package net.obscura.vpnclientapp.ui\n\nimport android.Manifest\nimport android.app.Activity.RESULT_CANCELED\nimport android.app.Activity.RESULT_OK\nimport android.content.Intent\nimport android.content.pm.PackageManager\nimport android.os.Build\nimport androidx.activity.result.ActivityResult\nimport androidx.activity.result.ActivityResultLauncher\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.core.content.ContextCompat\nimport androidx.fragment.app.FragmentActivity\nimport dagger.hilt.android.scopes.ActivityScoped\nimport javax.inject.Inject\nimport kotlin.time.Duration\nimport kotlin.time.Duration.Companion.milliseconds\nimport kotlin.time.TimeSource\nimport kotlinx.coroutines.flow.MutableSharedFlow\nimport kotlinx.coroutines.flow.asSharedFlow\nimport kotlinx.coroutines.flow.firstOrNull\nimport kotlinx.coroutines.flow.onSubscription\nimport net.obscura.lib.util.Logger\nimport net.obscura.vpnclientapp.client.errorCodeLegacyAlwaysOn\nimport net.obscura.vpnclientapp.client.errorCodeOther\nimport net.obscura.vpnclientapp.client.errorCodeOtherAppAlwaysOn\nimport net.obscura.vpnclientapp.client.errorCodePermissionNotGranted\nimport net.obscura.vpnclientapp.services.PrepareResult\nimport net.obscura.vpnclientapp.services.prepareVpnService\nimport net.obscura.vpnclientapp.services.startVpnService\n\nprivate val log = Logger(VpnPermissionRequestManager::class)\n\n@ActivityScoped\nclass VpnPermissionRequestManager @Inject constructor(private val activity: FragmentActivity) {\n    private val vpnPermissionRequestCancelThreshold: Duration = 150.milliseconds\n\n    private val vpnPermissionRequestResultTx = MutableSharedFlow<ActivityResult>(extraBufferCapacity = 1)\n    private val vpnPermissionRequestResultRx = this.vpnPermissionRequestResultTx.asSharedFlow()\n\n    private val vpnPermissionRequestLauncher: ActivityResultLauncher<Intent> =\n        this.activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->\n            log.debug(\"VPN permission request activity result: $result\")\n            val wasEmitted = this.vpnPermissionRequestResultTx.tryEmit(result)\n            if (!wasEmitted) {\n                log.warn(\"multiple VPN permission requests while collecting\")\n            }\n        }\n\n    private val notificationPermissionRequestLauncher: ActivityResultLauncher<String> =\n        this.activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->\n            // We don't actually care if we're granted permission, since this is\n            // just the user's preference between \"classic\" foreground service\n            // notifications vs. the modern Task Manager.\n            log.debug(\"notification permission request activity result: $isGranted\")\n        }\n\n    private fun requestNotificationPermission() {\n        if (\n            Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&\n                ContextCompat.checkSelfPermission(this.activity, Manifest.permission.POST_NOTIFICATIONS) !=\n                    PackageManager.PERMISSION_GRANTED\n        ) {\n            this.notificationPermissionRequestLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)\n        }\n    }\n\n    private fun onSuccess(): Result<Unit> {\n        this.requestNotificationPermission()\n        return this.activity.startVpnService()\n    }\n\n    // Android 12+ has no API for checking if another app has Always-On enabled. Instead, the permission request\n    // receives `RESULT_CANCELED` immediately, requiring us to use a heuristic to determine that we're silently\n    // unable to request VPN permissions from the user.\n    private fun onCanceled(vpnPermissionRequestStart: TimeSource.Monotonic.ValueTimeMark): Result<Unit> {\n        val vpnPermissionRequestEnd = TimeSource.Monotonic.markNow()\n        val elapsed = vpnPermissionRequestEnd - vpnPermissionRequestStart\n        log.debug(\"$elapsed elapsed between VPN permission request launch and cancellation\")\n        return if (elapsed > this.vpnPermissionRequestCancelThreshold) {\n            log.debug(\"heuristic determined that cancellation was user-initiated\")\n            Result.failure(errorCodePermissionNotGranted())\n        } else {\n            log.debug(\"heuristic determined that cancellation was automatic\")\n            Result.failure(errorCodeOtherAppAlwaysOn())\n        }\n    }\n\n    suspend fun requestVpnStart(): Result<Unit> =\n        when (val prepareResult = this.activity.prepareVpnService()) {\n            is PrepareResult.CreateProfile -> {\n                val vpnPermissionRequestStart = TimeSource.Monotonic.markNow()\n                val vpnPermissionRequestResult =\n                    this.vpnPermissionRequestResultRx\n                        .onSubscription {\n                            this@VpnPermissionRequestManager.vpnPermissionRequestLauncher.launch(prepareResult.intent)\n                        }\n                        .firstOrNull()\n                        ?: run {\n                            log.error(\"VPN permission request result flow was empty\")\n                            return Result.failure(errorCodeOther())\n                        }\n                when (vpnPermissionRequestResult.resultCode) {\n                    RESULT_OK -> this.onSuccess()\n                    RESULT_CANCELED -> this.onCanceled(vpnPermissionRequestStart)\n                    else -> {\n                        log.error(\"unexpected VPN start activity result: $vpnPermissionRequestResult\")\n                        Result.failure(errorCodeOther())\n                    }\n                }\n            }\n            is PrepareResult.Ready -> this.onSuccess()\n            is PrepareResult.LegacyAlwaysOn -> Result.failure(errorCodeLegacyAlwaysOn())\n        }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/ui/VpnStatusObserver.kt",
    "content": "package net.obscura.vpnclientapp.ui\n\nimport androidx.lifecycle.DefaultLifecycleObserver\nimport androidx.lifecycle.LifecycleOwner\nimport androidx.lifecycle.lifecycleScope\nimport kotlin.time.Duration.Companion.milliseconds\nimport kotlinx.coroutines.CancellationException\nimport kotlinx.coroutines.Job\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.isActive\nimport kotlinx.coroutines.launch\nimport net.obscura.lib.util.Logger\nimport net.obscura.vpnclientapp.client.ManagerCmd\nimport net.obscura.vpnclientapp.client.ManagerCmdOk\nimport net.obscura.vpnclientapp.client.jsonConfig\nimport net.obscura.vpnclientapp.services.IObscuraVpnService\n\nprivate val log = Logger(VpnStatusObserver::class)\n\nclass VpnStatusObserver(\n    private val binder: IObscuraVpnService,\n    private val callback: Callback,\n) : DefaultLifecycleObserver {\n    interface Callback {\n        suspend fun onStatusChanged(status: ManagerCmdOk.GetStatus)\n    }\n\n    private var job: Job? = null\n\n    override fun onStart(owner: LifecycleOwner) {\n        this.job =\n            owner.lifecycleScope.launch {\n                var knownVersion: String? = null\n                while (this.isActive) {\n                    try {\n                        val status =\n                            JsonFfiBroadcastReceiver.waitForResponse(\n                                    this@VpnStatusObserver.binder,\n                                    jsonConfig.encodeToString(ManagerCmd.GetStatus(knownVersion)),\n                                )\n                                .await()\n                                .let { jsonConfig.decodeFromString<ManagerCmdOk.GetStatus>(it) }\n                        knownVersion = status.version\n                        log.debug(\"updated VPN status: $status\")\n                        this@VpnStatusObserver.callback.onStatusChanged(status)\n                    } catch (e: CancellationException) {\n                        log.debug(\"VPN status job canceled: ${e.message}\")\n                        throw e\n                    } catch (e: Throwable) {\n                        log.error(\"failed to update VPN status: $e\", tr = e)\n                    }\n                    delay(10.milliseconds)\n                }\n            }\n    }\n\n    override fun onStop(owner: LifecycleOwner) {\n        this.job?.cancel(CancellationException(\"lifecycle owner stopped\"))\n    }\n\n    override fun onDestroy(owner: LifecycleOwner) {\n        this.job?.cancel(CancellationException(\"lifecycle owner destroyed\"))\n        owner.lifecycle.removeObserver(this)\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/ui/bridge/WebCmd.kt",
    "content": "package net.obscura.vpnclientapp.ui.bridge\n\nimport android.content.Context\nimport kotlinx.serialization.KeepGeneratedSerializer\nimport kotlinx.serialization.Serializable\nimport net.obscura.lib.util.ExternallyTaggedEnumSerializer\nimport net.obscura.lib.util.ExternallyTaggedEnumVariantSerializer\nimport net.obscura.vpnclientapp.activities.MainActivity\nimport net.obscura.vpnclientapp.client.ManagerCmd\nimport net.obscura.vpnclientapp.client.errorCodeUnsupportedOnOS\nimport net.obscura.vpnclientapp.client.jsonConfig\nimport net.obscura.vpnclientapp.preferences.Preferences\nimport net.obscura.vpnclientapp.services.IObscuraVpnService\nimport net.obscura.vpnclientapp.ui.JsonFfiBroadcastReceiver\nimport net.obscura.vpnclientapp.ui.OsStatusManager\n\nprivate val jsonUnit = jsonConfig.encodeToString(Unit)\n\n@Serializable(with = WebCmd.Serializer::class)\ninternal sealed interface WebCmd {\n    object Serializer :\n        ExternallyTaggedEnumSerializer<WebCmd>(\n            WebCmd::class,\n            listOf(\n                DebuggingArchive.Serializer,\n                EmailDebugArchive.Serializer,\n                GetOsStatus.Serializer,\n                JsonFfiCmd.Serializer,\n                PurchaseSubscription.Serializer,\n                RevealItemInDir.Serializer,\n                SetColorScheme.Serializer,\n                SetFeatureFlag.Serializer,\n                ShareDebugArchive.Serializer,\n                StartTunnel.Serializer,\n                StopTunnel.Serializer,\n            ),\n        )\n\n    data class Args(\n        val context: Context,\n        val binder: IObscuraVpnService,\n        val mainActivity: MainActivity,\n        val osStatusManager: OsStatusManager,\n    )\n\n    suspend fun run(args: Args): String\n\n    @KeepGeneratedSerializer\n    @Serializable(with = DebuggingArchive.Serializer::class)\n    data class DebuggingArchive(\n        val userFeedback: String?,\n    ) : WebCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<DebuggingArchive>(\"debuggingArchive\", generatedSerializer())\n\n        // Eventually, all platforms should just use the JSON FFI command to create debug archives, but for now,\n        // adapting the command here is the least invasive change.\n        // TODO: https://linear.app/soveng/issue/OBS-3095/cross-platform-debug-archive-story\n        override suspend fun run(args: Args) =\n            jsonUnit.also {\n                args.osStatusManager.update { this.debugBundleStatus.inProgress = true }\n                val path = runCatching {\n                    JsonFfiCmd(jsonConfig.encodeToString(ManagerCmd.CreateDebugArchive(userFeedback))).run(args).let {\n                        jsonConfig.decodeFromString<String>(it)\n                    }\n                }\n                args.osStatusManager.update {\n                    this.debugBundleStatus.inProgress = false\n                    path.onSuccess { this.debugBundleStatus.latestPath = it }\n                }\n                path.getOrThrow()\n            }\n    }\n\n    @KeepGeneratedSerializer\n    @Serializable(with = EmailDebugArchive.Serializer::class)\n    data class EmailDebugArchive(\n        val path: String,\n        val subject: String,\n        val body: String,\n    ) : WebCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<EmailDebugArchive>(\"emailDebugArchive\", generatedSerializer())\n\n        override suspend fun run(args: Args) =\n            jsonUnit.also { shareDebugArchive(args.context, path, true, subject, body) }\n    }\n\n    @KeepGeneratedSerializer\n    @Serializable(with = GetOsStatus.Serializer::class)\n    data class GetOsStatus(val knownVersion: String?) : WebCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<GetOsStatus>(\"getOsStatus\", generatedSerializer())\n\n        override suspend fun run(args: Args) = args.osStatusManager.waitForUpdate(knownVersion).await()\n    }\n\n    @KeepGeneratedSerializer\n    @Serializable(with = JsonFfiCmd.Serializer::class)\n    data class JsonFfiCmd(\n        val cmd: String,\n    ) : WebCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<JsonFfiCmd>(\"jsonFfiCmd\", generatedSerializer())\n\n        override suspend fun run(args: Args) = JsonFfiBroadcastReceiver.waitForResponse(args.binder, this.cmd).await()\n    }\n\n    @KeepGeneratedSerializer\n    @Serializable(with = PurchaseSubscription.Serializer::class)\n    class PurchaseSubscription : WebCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<PurchaseSubscription>(\n                \"purchaseSubscription\",\n                generatedSerializer(),\n            )\n\n        override suspend fun run(args: Args) =\n            args.mainActivity.billingFacade.launchFlow(args.mainActivity).let { jsonConfig.encodeToString(it) }\n    }\n\n    @KeepGeneratedSerializer\n    @Serializable(with = RevealItemInDir.Serializer::class)\n    data class RevealItemInDir(\n        val path: String,\n    ) : WebCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<RevealItemInDir>(\n                \"revealItemInDir\",\n                generatedSerializer(),\n            )\n\n        override suspend fun run(args: Args) = throw errorCodeUnsupportedOnOS()\n    }\n\n    @KeepGeneratedSerializer\n    @Serializable(with = SetColorScheme.Serializer::class)\n    data class SetColorScheme(\n        val value: Preferences.ColorScheme,\n    ) : WebCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<SetColorScheme>(\n                \"setColorScheme\",\n                generatedSerializer(),\n            )\n\n        override suspend fun run(args: Args) = jsonUnit.also { Preferences(args.context).colorScheme = this.value }\n    }\n\n    @KeepGeneratedSerializer\n    @Serializable(with = SetFeatureFlag.Serializer::class)\n    data class SetFeatureFlag(\n        val flag: String,\n        val active: Boolean,\n    ) : WebCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<SetFeatureFlag>(\n                \"setFeatureFlag\",\n                generatedSerializer(),\n            )\n\n        override suspend fun run(args: Args) = throw errorCodeUnsupportedOnOS()\n    }\n\n    @KeepGeneratedSerializer\n    @Serializable(with = ShareDebugArchive.Serializer::class)\n    data class ShareDebugArchive(\n        val path: String,\n    ) : WebCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<ShareDebugArchive>(\n                \"shareDebugArchive\",\n                generatedSerializer(),\n            )\n\n        override suspend fun run(args: Args) = jsonUnit.also { shareDebugArchive(args.context, path, false) }\n    }\n\n    @KeepGeneratedSerializer\n    @Serializable(with = StartTunnel.Serializer::class)\n    data class StartTunnel(\n        val tunnelArgs: String? = null,\n    ) : WebCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<StartTunnel>(\n                \"startTunnel\",\n                generatedSerializer(),\n            )\n\n        override suspend fun run(args: Args) =\n            jsonUnit.also {\n                args.mainActivity.vpnPermissionRequestManager.requestVpnStart().getOrThrow()\n                args.binder.startTunnel(this@StartTunnel.tunnelArgs)\n            }\n    }\n\n    @KeepGeneratedSerializer\n    @Serializable(with = StopTunnel.Serializer::class)\n    class StopTunnel : WebCmd {\n        internal object Serializer :\n            ExternallyTaggedEnumVariantSerializer<StopTunnel>(\n                \"stopTunnel\",\n                generatedSerializer(),\n            )\n\n        override suspend fun run(args: Args) = jsonUnit.also { args.binder.stopTunnel() }\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/ui/bridge/WebCmdBridge.kt",
    "content": "package net.obscura.vpnclientapp.ui.bridge\n\nimport android.content.Context\nimport android.webkit.JavascriptInterface\nimport kotlinx.coroutines.CancellationException\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.SupervisorJob\nimport kotlinx.coroutines.cancel\nimport kotlinx.coroutines.launch\nimport kotlinx.serialization.Serializable\nimport net.obscura.lib.util.Logger\nimport net.obscura.vpnclientapp.activities.MainActivity\nimport net.obscura.vpnclientapp.client.ErrorCodeException\nimport net.obscura.vpnclientapp.client.errorCodeOther\nimport net.obscura.vpnclientapp.client.jsonConfig\nimport net.obscura.vpnclientapp.services.IObscuraVpnService\nimport net.obscura.vpnclientapp.ui.OsStatusManager\n\nprivate val log = Logger(WebCmdBridge::class)\n\nclass WebCmdBridge(\n    private val context: Context,\n    private val binder: IObscuraVpnService,\n    private val mainActivity: MainActivity,\n    private val osStatusManager: OsStatusManager,\n    private val postMessage: (data: String) -> Unit,\n) {\n    private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())\n\n    @Serializable\n    private data class Accept(\n        val id: Long,\n        val data: String,\n    )\n\n    @Serializable\n    private data class Reject(\n        val id: Long,\n        val error: String,\n    )\n\n    private fun accept(id: Long, data: String) {\n        this.postMessage(jsonConfig.encodeToString(Accept(id, data)))\n    }\n\n    private fun reject(id: Long, exception: ErrorCodeException) {\n        this.postMessage(jsonConfig.encodeToString(Reject(id, exception.errorCode)))\n    }\n\n    @JavascriptInterface\n    fun invoke(data: String, id: Long) {\n        this.scope.launch {\n            try {\n                this@WebCmdBridge.accept(\n                    id,\n                    jsonConfig\n                        .decodeFromString<WebCmd>(data)\n                        .run(WebCmd.Args(context, binder, this@WebCmdBridge.mainActivity, osStatusManager)),\n                )\n            } catch (exception: CancellationException) {\n                log.debug(\"invoke job canceled: ${exception.message}\")\n                throw exception\n            } catch (exception: ErrorCodeException) {\n                this@WebCmdBridge.reject(id, exception)\n            } catch (exception: Throwable) {\n                log.error(\"unexpected exception type: $exception\", tr = exception)\n                this@WebCmdBridge.reject(id, errorCodeOther())\n            }\n        }\n    }\n\n    fun cancel() {\n        this.scope.cancel()\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/java/net/obscura/vpnclientapp/ui/bridge/WebCmdHelpers.kt",
    "content": "package net.obscura.vpnclientapp.ui.bridge\n\nimport android.content.Context\nimport android.content.Intent\nimport java.io.File\nimport net.obscura.vpnclientapp.sharing.DebugArchiveFileProvider\n\ninternal fun shareDebugArchive(\n    context: Context,\n    path: String,\n    email: Boolean,\n    subject: String? = null,\n    body: String? = null,\n) {\n    val uri =\n        DebugArchiveFileProvider.getUriForFile(\n            context,\n            \"${context.packageName}.debug_archive_file_provider\",\n            File(path),\n        )\n    val intent =\n        Intent(Intent.ACTION_SEND).apply {\n            this.type = \"application/zip\"\n            this.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)\n            this.putExtra(Intent.EXTRA_STREAM, uri)\n            if (email) {\n                this.putExtra(Intent.EXTRA_EMAIL, arrayOf(\"support@obscura.net\"))\n                this.putExtra(Intent.EXTRA_SUBJECT, subject)\n                this.putExtra(Intent.EXTRA_TEXT, body)\n            }\n        }\n    if (email) {\n        // There unfortunately isn't a way to only show email apps *and* have attachments. By not\n        // using the chooser here, we at least give the user the option to save their previously\n        // selected email app.\n        context.startActivity(intent)\n    } else {\n        context.startActivity(Intent.createChooser(intent, null))\n    }\n}\n"
  },
  {
    "path": "android/app/src/main/res/drawable/ic_launcher.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:tools=\"http://schemas.android.com/tools\"\n    tools:ignore=\"MonochromeLauncherIcon\">\n    <background android:drawable=\"@color/ic_launcher_background\"/>\n    <foreground android:drawable=\"@mipmap/ic_launcher_foreground\"/>\n</adaptive-icon>\n"
  },
  {
    "path": "android/app/src/main/res/drawable/icon_about.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"48dp\"\n    android:height=\"48dp\"\n    android:viewportWidth=\"960\"\n    android:viewportHeight=\"960\">\n  <path\n      android:pathData=\"M453,680h60v-240h-60v240ZM479.98,366q14.02,0 23.52,-9.2T513,334q0,-14.45 -9.48,-24.22 -9.48,-9.78 -23.5,-9.78t-23.52,9.78Q447,319.55 447,334q0,13.6 9.48,22.8 9.48,9.2 23.5,9.2ZM480.27,880q-82.74,0 -155.5,-31.5Q252,817 197.5,762.5t-86,-127.34Q80,562.32 80,479.5t31.5,-155.66Q143,251 197.5,197t127.34,-85.5Q397.68,80 480.5,80t155.66,31.5Q709,143 763,197t85.5,127Q880,397 880,479.73q0,82.74 -31.5,155.5Q817,708 763,762.32q-54,54.31 -127,86Q563,880 480.27,880ZM480.5,820Q622,820 721,720.5t99,-241Q820,338 721.19,239T480,140q-141,0 -240.5,98.81T140,480q0,141 99.5,240.5t241,99.5ZM480,480Z\"\n      android:fillColor=\"#000\"/>\n</vector>\n"
  },
  {
    "path": "android/app/src/main/res/drawable/icon_account.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"48dp\"\n    android:height=\"48dp\"\n    android:viewportWidth=\"960\"\n    android:viewportHeight=\"960\">\n  <path\n      android:pathData=\"M222,705q63,-44 125,-67.5T480,614q71,0 133.5,23.5T739,705q44,-54 62.5,-109T820,480q0,-145 -97.5,-242.5T480,140q-145,0 -242.5,97.5T140,480q0,61 19,116t63,109ZM479.81,510q-57.81,0 -97.31,-39.69 -39.5,-39.68 -39.5,-97.5 0,-57.81 39.69,-97.31 39.68,-39.5 97.5,-39.5 57.81,0 97.31,39.69 39.5,39.68 39.5,97.5 0,57.81 -39.69,97.31 -39.68,39.5 -97.5,39.5ZM480.47,880Q398,880 325,848.5t-127.5,-86q-54.5,-54.5 -86,-127.27Q80,562.47 80,479.73 80,397 111.5,324.5q31.5,-72.5 86,-127t127.27,-86q72.76,-31.5 155.5,-31.5 82.73,0 155.23,31.5 72.5,31.5 127,86t86,127.03q31.5,72.53 31.5,155T848.5,635q-31.5,73 -86,127.5t-127.03,86Q562.94,880 480.47,880ZM480,820q55,0 107.5,-16T691,748q-51,-36 -104,-55t-107,-19q-54,0 -107,19t-104,55q51,40 103.5,56T480,820ZM480,450q34,0 55.5,-21.5T557,373q0,-34 -21.5,-55.5T480,296q-34,0 -55.5,21.5T403,373q0,34 21.5,55.5T480,450ZM480,373ZM480,747Z\"\n      android:fillColor=\"#000\"/>\n</vector>\n"
  },
  {
    "path": "android/app/src/main/res/drawable/icon_connection.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"48dp\"\n    android:height=\"48dp\"\n    android:viewportWidth=\"960\"\n    android:viewportHeight=\"960\">\n  <path\n      android:pathData=\"M480,880q-83,0 -156,-31.5t-127,-86q-54,-54.5 -85.5,-128T80,478q0,-83 31.5,-155.5T197,196q54,-54 127,-85t156,-31q26,0 50.5,4t49.5,9l-20,57q-20,-6 -40,-8t-40,-2q-42,35 -62,86t-34,104h196v60L373,390q-3,22 -4.5,44t-1.5,44q0,23 1.5,45.5T373,569h215q3,-23 4.5,-48t1.5,-61h60q0,36 -2,61t-4,48h160q6,-23 9,-47.5t3,-61.5h60q0,94 -31.5,171T763,763.5q-54,55.5 -127,86T480,880ZM152,569h159q-2,-23 -3,-45.5t-1,-45.5q0,-22 1,-44t4,-44L152,390q-6,22 -9,43.5t-3,44.5q0,23 3,45.5t9,45.5ZM395,810q-27,-42 -44.5,-87.5T322,629L172,629q35,66 93.5,111.5T395,810ZM172,330h151q10,-48 27.5,-93t43.5,-86q-73,18 -131,65t-91,114ZM480,822q38,-39 61,-89t36,-104L384,629q12,54 35,103.5t61,89.5ZM566,809q71,-22 129,-68t93,-112L639,629q-11,48 -28.5,93.5T566,809ZM674,400q-14,0 -24,-10t-10,-24v-132q0,-14 10,-24t24,-10h6v-40q0,-33 23.5,-56.5T760,80q33,0 56.5,23.5T840,160v40h6q14,0 24,10t10,24v132q0,14 -10,24t-24,10L674,400ZM720,200h80v-40q0,-17 -11.5,-28.5T760,120q-17,0 -28.5,11T720,158v42Z\"\n      android:fillColor=\"#000\"/>\n</vector>\n"
  },
  {
    "path": "android/app/src/main/res/drawable/icon_location.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"48dp\"\n    android:height=\"48dp\"\n    android:viewportWidth=\"960\"\n    android:viewportHeight=\"960\">\n  <path\n      android:pathData=\"M480,880q-83,0 -156,-31.5T197,763q-54,-54 -85.5,-127T80,480q0,-83 31.5,-156T197,197q54,-54 127,-85.5T480,80q157,0 270.5,104.5T878,443q-14,-8 -30,-14t-33,-9q-15,-87 -70,-156T607,161v18q0,35 -24,61t-59,26h-87v87q0,17 -13.5,28T393,392h-83v88h258q15,0 26,11t13,26q-13,23 -20,48.5t-7,51.5q0,60 34,112t66,98q-45,26 -95,39.5T480,880ZM437,819v-82q-35,0 -59,-26t-24,-61v-44L149,401q-5,20 -7,39.5t-2,39.5q0,130 84.5,227T437,819ZM780,880q-4,0 -6.5,-2t-3.5,-5q-13,-38 -34.5,-70.5T689,739q-21,-26 -35,-57t-14,-65q0,-58 41,-99t99,-41q58,0 99,41t41,99q0,34 -14,65t-35,57q-24,31 -46,63.5T790,873q-1,3 -3.5,5t-6.5,2ZM780,797q14,-21 28.5,-41t30.5,-40q17,-22 29,-46.5t12,-52.5q0,-42 -29,-71t-71,-29q-42,0 -71,29t-29,71q0,28 12,52.5t29,46.5q16,20 30.5,40t28.5,41ZM780,667q-21,0 -35.5,-14.5T730,617q0,-21 14.5,-35.5T780,567q21,0 35.5,14.5T830,617q0,21 -14.5,35.5T780,667Z\"\n      android:fillColor=\"#000\"/>\n</vector>\n"
  },
  {
    "path": "android/app/src/main/res/drawable/icon_settings.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"48dp\"\n    android:height=\"48dp\"\n    android:viewportWidth=\"960\"\n    android:viewportHeight=\"960\">\n  <path\n      android:pathData=\"m388,880 l-20,-126q-19,-7 -40,-19t-37,-25l-118,54 -93,-164 108,-79q-2,-9 -2.5,-20.5T185,480q0,-9 0.5,-20.5T188,439L80,360l93,-164 118,54q16,-13 37,-25t40,-18l20,-127h184l20,126q19,7 40.5,18.5T669,250l118,-54 93,164 -108,77q2,10 2.5,21.5t0.5,21.5q0,10 -0.5,21t-2.5,21l108,78 -93,164 -118,-54q-16,13 -36.5,25.5T592,754L572,880L388,880ZM436,820h88l14,-112q33,-8 62.5,-25t53.5,-41l106,46 40,-72 -94,-69q4,-17 6.5,-33.5T715,480q0,-17 -2,-33.5t-7,-33.5l94,-69 -40,-72 -106,46q-23,-26 -52,-43.5T538,252l-14,-112h-88l-14,112q-34,7 -63.5,24T306,318l-106,-46 -40,72 94,69q-4,17 -6.5,33.5T245,480q0,17 2.5,33.5T254,547l-94,69 40,72 106,-46q24,24 53.5,41t62.5,25l14,112ZM480,610q54,0 92,-38t38,-92q0,-54 -38,-92t-92,-38q-54,0 -92,38t-38,92q0,54 38,92t92,38ZM480,480Z\"\n      android:fillColor=\"#000\"/>\n</vector>\n"
  },
  {
    "path": "android/app/src/main/res/layout/activity_main.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\n<net.obscura.vpnclientapp.ui.ObscuraUI xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:tools=\"http://schemas.android.com/tools\"\n    android:id=\"@+id/ui\"\n    android:layout_width=\"match_parent\"\n    android:layout_height=\"match_parent\"\n    xmlns:app=\"http://schemas.android.com/apk/res-auto\">\n\n    <LinearLayout\n        android:layout_width=\"match_parent\"\n        android:layout_height=\"match_parent\"\n        android:orientation=\"vertical\">\n\n        <FrameLayout\n            android:id=\"@+id/web_view_container\"\n            android:isScrollContainer=\"true\"\n            android:layout_width=\"match_parent\"\n            android:layout_height=\"0dp\"\n            android:layout_weight=\"1\" />\n\n        <com.google.android.material.bottomnavigation.BottomNavigationView\n            android:id=\"@+id/nav_view\"\n            android:layout_width=\"match_parent\"\n            android:layout_height=\"wrap_content\"\n            android:layout_gravity=\"start\"\n            app:menu=\"@menu/nav_menu\"\n            app:labelVisibilityMode=\"labeled\"\n            app:elevation=\"8dp\"\n            android:elevation=\"8dp\" />\n\n    </LinearLayout>\n\n</net.obscura.vpnclientapp.ui.ObscuraUI>\n"
  },
  {
    "path": "android/app/src/main/res/menu/nav_menu.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<menu xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <item\n        android:id=\"@+id/nav_connection\"\n        android:icon=\"@drawable/icon_connection\"\n        android:title=\"@string/nav_connection\" />\n    <item\n        android:id=\"@+id/nav_location\"\n        android:icon=\"@drawable/icon_location\"\n        android:title=\"@string/nav_location\" />\n    <item\n        android:id=\"@+id/nav_account\"\n        android:icon=\"@drawable/icon_account\"\n        android:title=\"@string/nav_account\" />\n    <item\n        android:id=\"@+id/nav_settings\"\n        android:icon=\"@drawable/icon_settings\"\n        android:title=\"@string/nav_settings\" />\n    <item\n        android:id=\"@+id/nav_about\"\n        android:icon=\"@drawable/icon_about\"\n        android:title=\"@string/nav_about\" />\n</menu>\n"
  },
  {
    "path": "android/app/src/main/res/values/colors.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <color name=\"black\">#ff000000</color>\n    <color name=\"white\">#ffffff</color>\n    <color name=\"orange_400\">#f56b39</color>\n    <color name=\"orange_600\">#dc4d26</color>\n    <color name=\"orange_800\">#9f2d00</color>\n    <color name=\"slate_400\">#90a1b9</color>\n    <color name=\"slate_600\">#45556c</color>\n    <color name=\"slate_800\">#303030</color>\n    <color name=\"background_light\">#ffffff</color>\n    <color name=\"background_dark\">#303030</color>\n</resources>\n"
  },
  {
    "path": "android/app/src/main/res/values/ic_launcher_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <color name=\"ic_launcher_background\">#F55D24</color>\n</resources>"
  },
  {
    "path": "android/app/src/main/res/values/ids.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <item name=\"notification_id_vpn\" type=\"id\" />\n</resources>\n"
  },
  {
    "path": "android/app/src/main/res/values/strings.xml",
    "content": "<resources>\n    <string name=\"app_name\" translatable=\"false\">Obscura VPN</string>\n    <string name=\"notification_channel_vpn_name\" translatable=\"false\">Obscura VPN</string>\n    <string name=\"notification_vpn_text\">Status: %1$s</string>\n    <string name=\"notification_vpn_status_connecting\">Connecting…</string>\n    <string name=\"notification_vpn_status_connected\">Connected</string>\n    <string name=\"notification_vpn_status_disconnected\">Disconnected</string>\n    <string name=\"nav_connection\">Connection</string>\n    <string name=\"nav_location\">Location</string>\n    <string name=\"nav_account\">Account</string>\n    <string name=\"nav_settings\">Settings</string>\n    <string name=\"nav_about\">About</string>\n</resources>\n"
  },
  {
    "path": "android/app/src/main/res/values/themes.xml",
    "content": "<resources>\n    <style name=\"Theme.ObscuraVPN\" parent=\"Theme.Material3.DayNight.NoActionBar\">\n        <item name=\"colorPrimary\">@color/orange_600</item>\n        <item name=\"colorPrimaryVariant\">@color/orange_800</item>\n        <item name=\"colorOnPrimary\">@color/white</item>\n        <item name=\"colorSecondary\">@color/slate_600</item>\n        <item name=\"colorSecondaryVariant\">@color/slate_800</item>\n        <item name=\"colorOnSecondary\">@color/black</item>\n        <item name=\"android:windowBackground\">@color/background_light</item>\n        <item name=\"android:windowLightNavigationBar\">true</item>\n        <item name=\"android:windowLightStatusBar\">true</item>\n    </style>\n</resources>\n"
  },
  {
    "path": "android/app/src/main/res/values-night/themes.xml",
    "content": "<resources>\n    <style name=\"Theme.ObscuraVPN\" parent=\"Theme.Material3.DayNight.NoActionBar\">\n        <item name=\"colorPrimary\">@color/orange_400</item>\n        <item name=\"colorPrimaryVariant\">@color/orange_800</item>\n        <item name=\"colorOnPrimary\">@color/black</item>\n        <item name=\"colorSecondary\">@color/slate_400</item>\n        <item name=\"colorSecondaryVariant\">@color/slate_400</item>\n        <item name=\"colorOnSecondary\">@color/black</item>\n        <item name=\"android:windowBackground\">@color/background_dark</item>\n        <item name=\"android:windowLightNavigationBar\">false</item>\n        <item name=\"android:windowLightStatusBar\">false</item>\n    </style>\n</resources>\n"
  },
  {
    "path": "android/app/src/main/res/xml/backup_rules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n   Sample backup rules file; uncomment and customize as necessary.\n   See https://developer.android.com/guide/topics/data/autobackup\n   for details.\n   Note: This file is ignored for devices older than API 31\n   See https://developer.android.com/about/versions/12/backup-restore\n-->\n<full-backup-content>\n    <!--\n   <include domain=\"sharedpref\" path=\".\"/>\n   <exclude domain=\"sharedpref\" path=\"device.xml\"/>\n-->\n</full-backup-content>\n"
  },
  {
    "path": "android/app/src/main/res/xml/data_extraction_rules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n   Sample data extraction rules file; uncomment and customize as necessary.\n   See https://developer.android.com/about/versions/12/backup-restore#xml-changes\n   for details.\n-->\n<data-extraction-rules>\n    <cloud-backup>\n        <!-- TODO: Use <include> and <exclude> to control what is backed up.\n        <include .../>\n        <exclude .../>\n        -->\n    </cloud-backup>\n    <!--\n    <device-transfer>\n        <include .../>\n        <exclude .../>\n    </device-transfer>\n    -->\n</data-extraction-rules>\n"
  },
  {
    "path": "android/app/src/main/res/xml/debug_archive_file_provider_paths.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<paths>\n    <cache-path name=\"debug-archives\" path=\"debug-archives/\" />\n</paths>\n"
  },
  {
    "path": "android/app/src/play/java/net/obscura/vpnclientapp/BillingFacade.kt",
    "content": "package net.obscura.vpnclientapp\n\nimport android.content.Context\nimport net.obscura.lib.billing.BillingManager\nimport net.obscura.vpnclientapp.activities.MainActivity\nimport net.obscura.vpnclientapp.client.errorCodePurchaseFailed\nimport net.obscura.vpnclientapp.client.errorCodePurchaseFailedAlreadyOwned\n\nclass BillingFacade(context: Context) {\n    private val billingManager = BillingManager(context)\n\n    suspend fun launchFlow(mainActivity: MainActivity) =\n        when (this@BillingFacade.billingManager.launchFlow(mainActivity)) {\n            BillingManager.PurchaseResult.Completed -> true\n            BillingManager.PurchaseResult.Canceled -> false\n            BillingManager.PurchaseResult.AlreadyOwned -> throw errorCodePurchaseFailedAlreadyOwned()\n            BillingManager.PurchaseResult.Failed -> throw errorCodePurchaseFailed()\n        }\n\n    fun destroy() = this.billingManager.destroy()\n}\n"
  },
  {
    "path": "android/build.gradle.kts",
    "content": "import com.ncorti.ktfmt.gradle.KtfmtExtension\nimport com.ncorti.ktfmt.gradle.TrailingCommaManagementStrategy\nimport io.gitlab.arturbosch.detekt.extensions.DetektExtension\n\nplugins {\n    // Only declare a plugin here if it must be loaded once rather than per-subproject\n    // https://discuss.gradle.org/t/why-duplicate-plugins-in-top-level-build-scripts/49087/2\n    // https://www.reddit.com/r/androiddev/comments/1errttm/comment/li1vm93/\n    alias(libs.plugins.android.application) apply false\n    alias(libs.plugins.android.library) apply false\n    alias(libs.plugins.hilt.android) apply false\n    alias(libs.plugins.kotlin.android) apply false\n    alias(libs.plugins.ksp) apply false\n\n    // These are only here for the `subprojects` block to work\n    alias(libs.plugins.detekt) apply false\n    alias(libs.plugins.ktfmt) apply false\n}\n\nsubprojects {\n    apply(plugin = rootProject.libs.plugins.detekt.get().pluginId)\n    apply(plugin = rootProject.libs.plugins.ktfmt.get().pluginId)\n\n    // https://detekt.dev/docs/gettingstarted/gradle/#kotlin-dsl-3\n    extensions.configure<DetektExtension> {\n        config.setFrom(rootProject.file(\"detekt.yml\"))\n        parallel = true\n    }\n\n    extensions.configure<KtfmtExtension> {\n        blockIndent.set(4)\n        continuationIndent.set(4)\n        maxWidth.set(120)\n        removeUnusedImports.set(true)\n        trailingCommaManagementStrategy.set(TrailingCommaManagementStrategy.ONLY_ADD)\n    }\n}\n"
  },
  {
    "path": "android/buildSrc/.gitignore",
    "content": "/build\n"
  },
  {
    "path": "android/buildSrc/build.gradle.kts",
    "content": "import com.ncorti.ktfmt.gradle.KtfmtExtension\nimport com.ncorti.ktfmt.gradle.TrailingCommaManagementStrategy\nimport io.gitlab.arturbosch.detekt.extensions.DetektExtension\nimport org.gradle.kotlin.dsl.configure\n\nplugins {\n    alias(libs.plugins.detekt)\n    alias(libs.plugins.kotlin.jvm)\n    alias(libs.plugins.kotlinx.serialization)\n    alias(libs.plugins.ktfmt)\n}\n\ndependencies {\n    implementation(gradleKotlinDsl())\n    implementation(libs.kotlinx.serialization.json)\n}\n\nextensions.configure<DetektExtension> {\n    config.setFrom(rootProject.file(\"../detekt.yml\"))\n    parallel = true\n}\n\nextensions.configure<KtfmtExtension> {\n    kotlinLangStyle()\n    maxWidth.set(120)\n    removeUnusedImports.set(true)\n    trailingCommaManagementStrategy.set(TrailingCommaManagementStrategy.ONLY_ADD)\n}\n"
  },
  {
    "path": "android/buildSrc/settings.gradle.kts",
    "content": "dependencyResolutionManagement {\n    @Suppress(\"UnstableApiUsage\")\n    repositories {\n        google()\n        mavenCentral()\n    }\n    @Suppress(\"UnstableApiUsage\") repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n    versionCatalogs { create(\"libs\") { from(files(\"../gradle/libs.versions.toml\")) } }\n}\n"
  },
  {
    "path": "android/buildSrc/src/main/kotlin/VersionName.kt",
    "content": "import java.io.File\nimport kotlinx.serialization.Serializable\nimport kotlinx.serialization.json.Json\nimport org.gradle.api.Project\nimport org.gradle.api.provider.Property\nimport org.gradle.api.provider.ValueSource\nimport org.gradle.api.provider.ValueSourceParameters\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nprivate val logger: Logger = LoggerFactory.getLogger(\"version-name\")\n\nfun Project.getVersionName(projectRootDir: File): String =\n    providers.of(VersionName::class.java) { it.parameters.projectRootDir.set(projectRootDir) }.get()\n\nabstract class VersionName : ValueSource<String, VersionName.Parameters> {\n    interface Parameters : ValueSourceParameters {\n        val projectRootDir: Property<File>\n    }\n\n    @Serializable private data class Tag(val version: String)\n\n    private val json: Json = Json { ignoreUnknownKeys = true }\n\n    private fun fallback(): String {\n        logger.warn(\"building outside of nix; not intended for distribution\")\n        val tagString = File(parameters.projectRootDir.get().parentFile, \"tag.json\").readText()\n        val tag = this.json.decodeFromString<Tag>(tagString)\n        return \"v${tag.version}.1-dev\"\n    }\n\n    override fun obtain(): String {\n        val version = System.getenv(\"OBSCURA_VERSION\")\n        logger.info(\"OBSCURA_VERSION = $version\")\n        return version ?: this.fallback()\n    }\n}\n"
  },
  {
    "path": "android/detekt.yml",
    "content": "build:\n  maxIssues: 0\n  excludeCorrectable: false\n  weights:\n    # complexity: 2\n    # LongParameterList: 1\n    # style: 1\n    # comments: 1\n\nconfig:\n  validation: true\n  warningsAsErrors: false\n  checkExhaustiveness: false\n  # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]'\n  excludes: \"\"\n\nprocessors:\n  active: true\n  exclude:\n    - \"DetektProgressListener\"\n  # - 'KtFileCountProcessor'\n  # - 'PackageCountProcessor'\n  # - 'ClassCountProcessor'\n  # - 'FunctionCountProcessor'\n  # - 'PropertyCountProcessor'\n  # - 'ProjectComplexityProcessor'\n  # - 'ProjectCognitiveComplexityProcessor'\n  # - 'ProjectLLOCProcessor'\n  # - 'ProjectCLOCProcessor'\n  # - 'ProjectLOCProcessor'\n  # - 'ProjectSLOCProcessor'\n  # - 'LicenseHeaderLoaderExtension'\n\nconsole-reports:\n  active: true\n  exclude:\n    - \"ProjectStatisticsReport\"\n    - \"ComplexityReport\"\n    - \"NotificationReport\"\n    - \"FindingsReport\"\n    - \"FileBasedFindingsReport\"\n  #  - 'LiteFindingsReport'\n\noutput-reports:\n  active: true\n  exclude:\n  # - 'TxtOutputReport'\n  # - 'XmlOutputReport'\n  # - 'HtmlOutputReport'\n  # - 'MdOutputReport'\n  # - 'SarifOutputReport'\n\ncomments:\n  active: true\n  AbsentOrWrongFileLicense:\n    active: false\n    licenseTemplateFile: \"license.template\"\n    licenseTemplateIsRegex: false\n  CommentOverPrivateFunction:\n    active: false\n  CommentOverPrivateProperty:\n    active: false\n  DeprecatedBlockTag:\n    active: false\n  EndOfSentenceFormat:\n    active: false\n    endOfSentenceFormat: '([.?!][ \\t\\n\\r\\f<])|([.?!:]$)'\n  KDocReferencesNonPublicProperty:\n    active: false\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n      ]\n  OutdatedDocumentation:\n    active: false\n    matchTypeParameters: true\n    matchDeclarationsOrder: true\n    allowParamOnConstructorProperties: false\n  UndocumentedPublicClass:\n    active: false\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n      ]\n    searchInNestedClass: true\n    searchInInnerClass: true\n    searchInInnerObject: true\n    searchInInnerInterface: true\n    searchInProtectedClass: false\n    ignoreDefaultCompanionObject: false\n  UndocumentedPublicFunction:\n    active: false\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n      ]\n    searchProtectedFunction: false\n  UndocumentedPublicProperty:\n    active: false\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n      ]\n    searchProtectedProperty: false\n\ncomplexity:\n  active: true\n  CognitiveComplexMethod:\n    active: false\n    threshold: 15\n  ComplexCondition:\n    active: true\n    threshold: 4\n  ComplexInterface:\n    active: false\n    threshold: 10\n    includeStaticDeclarations: false\n    includePrivateDeclarations: false\n    ignoreOverloaded: false\n  CyclomaticComplexMethod:\n    active: false\n    threshold: 15\n    ignoreSingleWhenExpression: false\n    ignoreSimpleWhenEntries: false\n    ignoreNestingFunctions: false\n    nestingFunctions:\n      - \"also\"\n      - \"apply\"\n      - \"forEach\"\n      - \"isNotNull\"\n      - \"ifNull\"\n      - \"let\"\n      - \"run\"\n      - \"use\"\n      - \"with\"\n  LabeledExpression:\n    active: false\n    ignoredLabels: []\n  LargeClass:\n    active: true\n    threshold: 600\n  LongMethod:\n    active: true\n    threshold: 60\n  LongParameterList:\n    active: true\n    functionThreshold: 6\n    constructorThreshold: 7\n    ignoreDefaultParameters: false\n    ignoreDataClasses: true\n    ignoreAnnotatedParameter: []\n  MethodOverloading:\n    active: false\n    threshold: 6\n  NamedArguments:\n    active: false\n    threshold: 3\n    ignoreArgumentsMatchingNames: false\n  NestedBlockDepth:\n    active: true\n    threshold: 4\n  NestedScopeFunctions:\n    active: false\n    threshold: 1\n    functions:\n      - \"kotlin.apply\"\n      - \"kotlin.run\"\n      - \"kotlin.with\"\n      - \"kotlin.let\"\n      - \"kotlin.also\"\n  ReplaceSafeCallChainWithRun:\n    active: false\n  StringLiteralDuplication:\n    active: false\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n      ]\n    threshold: 3\n    ignoreAnnotation: true\n    excludeStringsWithLessThan5Characters: true\n    ignoreStringsRegex: \"$^\"\n  TooManyFunctions:\n    active: false\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n      ]\n    thresholdInFiles: 11\n    thresholdInClasses: 11\n    thresholdInInterfaces: 11\n    thresholdInObjects: 11\n    thresholdInEnums: 11\n    ignoreDeprecated: false\n    ignorePrivate: false\n    ignoreOverridden: false\n    ignoreAnnotatedFunctions: []\n\ncoroutines:\n  active: true\n  GlobalCoroutineUsage:\n    active: false\n  InjectDispatcher:\n    active: true\n    dispatcherNames:\n      - \"IO\"\n      - \"Default\"\n      - \"Unconfined\"\n  RedundantSuspendModifier:\n    active: true\n  SleepInsteadOfDelay:\n    active: true\n  SuspendFunSwallowedCancellation:\n    active: false\n  SuspendFunWithCoroutineScopeReceiver:\n    active: false\n  SuspendFunWithFlowReturnType:\n    active: true\n\nempty-blocks:\n  active: true\n  EmptyCatchBlock:\n    active: true\n    allowedExceptionNameRegex: \"_|(ignore|expected).*\"\n  EmptyClassBlock:\n    active: true\n  EmptyDefaultConstructor:\n    active: true\n  EmptyDoWhileBlock:\n    active: true\n  EmptyElseBlock:\n    active: true\n  EmptyFinallyBlock:\n    active: true\n  EmptyForBlock:\n    active: true\n  EmptyFunctionBlock:\n    active: true\n    ignoreOverridden: false\n  EmptyIfBlock:\n    active: true\n  EmptyInitBlock:\n    active: true\n  EmptyKtFile:\n    active: true\n  EmptySecondaryConstructor:\n    active: true\n  EmptyTryBlock:\n    active: true\n  EmptyWhenBlock:\n    active: true\n  EmptyWhileBlock:\n    active: true\n\nexceptions:\n  active: true\n  ExceptionRaisedInUnexpectedLocation:\n    active: true\n    methodNames:\n      - \"equals\"\n      - \"finalize\"\n      - \"hashCode\"\n      - \"toString\"\n  InstanceOfCheckForException:\n    active: true\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n      ]\n  NotImplementedDeclaration:\n    active: false\n  ObjectExtendsThrowable:\n    active: false\n  PrintStackTrace:\n    active: true\n  RethrowCaughtException:\n    active: true\n  ReturnFromFinally:\n    active: true\n    ignoreLabeled: false\n  SwallowedException:\n    active: true\n    ignoredExceptionTypes:\n      - \"InterruptedException\"\n      - \"MalformedURLException\"\n      - \"NumberFormatException\"\n      - \"ParseException\"\n    allowedExceptionNameRegex: \"_|(ignore|expected).*\"\n  ThrowingExceptionFromFinally:\n    active: true\n  ThrowingExceptionInMain:\n    active: false\n  ThrowingExceptionsWithoutMessageOrCause:\n    active: true\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n      ]\n    exceptions:\n      - \"ArrayIndexOutOfBoundsException\"\n      - \"Exception\"\n      - \"IllegalArgumentException\"\n      - \"IllegalMonitorStateException\"\n      - \"IllegalStateException\"\n      - \"IndexOutOfBoundsException\"\n      - \"NullPointerException\"\n      - \"RuntimeException\"\n      - \"Throwable\"\n  ThrowingNewInstanceOfSameException:\n    active: true\n  TooGenericExceptionCaught:\n    active: false\n  TooGenericExceptionThrown:\n    active: false\n    exceptionNames:\n      - \"Error\"\n      - \"Exception\"\n      - \"RuntimeException\"\n      - \"Throwable\"\n\nnaming:\n  active: true\n  BooleanPropertyNaming:\n    active: false\n    allowedPattern: \"^(is|has|are)\"\n  ClassNaming:\n    active: true\n    classPattern: \"[A-Z][a-zA-Z0-9]*\"\n  ConstructorParameterNaming:\n    active: true\n    parameterPattern: \"[a-z][A-Za-z0-9]*\"\n    privateParameterPattern: \"[a-z][A-Za-z0-9]*\"\n    excludeClassPattern: \"$^\"\n  EnumNaming:\n    active: true\n    enumEntryPattern: \"[A-Z][_a-zA-Z0-9]*\"\n  ForbiddenClassName:\n    active: false\n    forbiddenName: []\n  FunctionMaxLength:\n    active: false\n    maximumFunctionNameLength: 30\n  FunctionMinLength:\n    active: false\n    minimumFunctionNameLength: 3\n  FunctionNaming:\n    active: true\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n      ]\n    functionPattern: \"[a-z][a-zA-Z0-9]*\"\n    excludeClassPattern: \"$^\"\n  FunctionParameterNaming:\n    active: true\n    parameterPattern: \"[a-z][A-Za-z0-9]*\"\n    excludeClassPattern: \"$^\"\n  InvalidPackageDeclaration:\n    active: true\n    rootPackage: \"\"\n    requireRootInDeclaration: false\n  LambdaParameterNaming:\n    active: false\n    parameterPattern: \"[a-z][A-Za-z0-9]*|_\"\n  MatchingDeclarationName:\n    active: true\n    mustBeFirst: true\n    multiplatformTargets:\n      - \"ios\"\n      - \"android\"\n      - \"js\"\n      - \"jvm\"\n      - \"native\"\n      - \"iosArm64\"\n      - \"iosX64\"\n      - \"macosX64\"\n      - \"mingwX64\"\n      - \"linuxX64\"\n  MemberNameEqualsClassName:\n    active: true\n    ignoreOverridden: true\n  NoNameShadowing:\n    active: true\n  NonBooleanPropertyPrefixedWithIs:\n    active: false\n  ObjectPropertyNaming:\n    active: true\n    constantPattern: \"[A-Za-z][_A-Za-z0-9]*\"\n    propertyPattern: \"[A-Za-z][_A-Za-z0-9]*\"\n    privatePropertyPattern: \"(_)?[A-Za-z][_A-Za-z0-9]*\"\n  PackageNaming:\n    active: true\n    packagePattern: '[a-z]+(\\.[a-z][A-Za-z0-9]*)*'\n  TopLevelPropertyNaming:\n    active: true\n    constantPattern: \"[A-Z][_A-Z0-9]*\"\n    propertyPattern: \"[A-Za-z][_A-Za-z0-9]*\"\n    privatePropertyPattern: \"_?[A-Za-z][_A-Za-z0-9]*\"\n  VariableMaxLength:\n    active: false\n    maximumVariableNameLength: 64\n  VariableMinLength:\n    active: false\n    minimumVariableNameLength: 1\n  VariableNaming:\n    active: true\n    variablePattern: \"[a-z][A-Za-z0-9]*\"\n    privateVariablePattern: \"(_)?[a-z][A-Za-z0-9]*\"\n    excludeClassPattern: \"$^\"\n\nperformance:\n  active: true\n  ArrayPrimitive:\n    active: true\n  CouldBeSequence:\n    active: false\n    threshold: 3\n  ForEachOnRange:\n    active: true\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n      ]\n  SpreadOperator:\n    active: true\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n      ]\n  UnnecessaryPartOfBinaryExpression:\n    active: false\n  UnnecessaryTemporaryInstantiation:\n    active: true\n\npotential-bugs:\n  active: true\n  AvoidReferentialEquality:\n    active: true\n    forbiddenTypePatterns:\n      - \"kotlin.String\"\n  CastNullableToNonNullableType:\n    active: false\n  CastToNullableType:\n    active: false\n  Deprecation:\n    active: false\n  DontDowncastCollectionTypes:\n    active: false\n  DoubleMutabilityForCollection:\n    active: true\n    mutableTypes:\n      - \"kotlin.collections.MutableList\"\n      - \"kotlin.collections.MutableMap\"\n      - \"kotlin.collections.MutableSet\"\n      - \"java.util.ArrayList\"\n      - \"java.util.LinkedHashSet\"\n      - \"java.util.HashSet\"\n      - \"java.util.LinkedHashMap\"\n      - \"java.util.HashMap\"\n  ElseCaseInsteadOfExhaustiveWhen:\n    active: false\n    ignoredSubjectTypes: []\n  EqualsAlwaysReturnsTrueOrFalse:\n    active: true\n  EqualsWithHashCodeExist:\n    active: true\n  ExitOutsideMain:\n    active: false\n  ExplicitGarbageCollectionCall:\n    active: true\n  HasPlatformType:\n    active: true\n  IgnoredReturnValue:\n    active: true\n    restrictToConfig: true\n    returnValueAnnotations:\n      - \"CheckResult\"\n      - \"*.CheckResult\"\n      - \"CheckReturnValue\"\n      - \"*.CheckReturnValue\"\n    ignoreReturnValueAnnotations:\n      - \"CanIgnoreReturnValue\"\n      - \"*.CanIgnoreReturnValue\"\n    returnValueTypes:\n      - \"kotlin.sequences.Sequence\"\n      - \"kotlinx.coroutines.flow.*Flow\"\n      - \"java.util.stream.*Stream\"\n    ignoreFunctionCall: []\n  ImplicitDefaultLocale:\n    active: true\n  ImplicitUnitReturnType:\n    active: false\n    allowExplicitReturnType: true\n  InvalidRange:\n    active: true\n  IteratorHasNextCallsNextMethod:\n    active: true\n  IteratorNotThrowingNoSuchElementException:\n    active: true\n  LateinitUsage:\n    active: false\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n      ]\n    ignoreOnClassesPattern: \"\"\n  MapGetWithNotNullAssertionOperator:\n    active: true\n  MissingPackageDeclaration:\n    active: false\n    excludes: [\"**/*.kts\"]\n  NullCheckOnMutableProperty:\n    active: false\n  NullableToStringCall:\n    active: false\n  PropertyUsedBeforeDeclaration:\n    active: false\n  UnconditionalJumpStatementInLoop:\n    active: false\n  UnnecessaryNotNullCheck:\n    active: false\n  UnnecessaryNotNullOperator:\n    active: true\n  UnnecessarySafeCall:\n    active: true\n  UnreachableCatchBlock:\n    active: true\n  UnreachableCode:\n    active: true\n  UnsafeCallOnNullableType:\n    active: true\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n      ]\n  UnsafeCast:\n    active: true\n  UnusedUnaryOperator:\n    active: true\n  UselessPostfixExpression:\n    active: true\n  WrongEqualsTypeParameter:\n    active: true\n\nstyle:\n  active: true\n  AlsoCouldBeApply:\n    active: false\n  BracesOnIfStatements:\n    active: false\n    singleLine: \"never\"\n    multiLine: \"always\"\n  BracesOnWhenStatements:\n    active: false\n    singleLine: \"necessary\"\n    multiLine: \"consistent\"\n  CanBeNonNullable:\n    active: false\n  CascadingCallWrapping:\n    active: false\n    includeElvis: true\n  ClassOrdering:\n    active: false\n  CollapsibleIfStatements:\n    active: false\n  DataClassContainsFunctions:\n    active: false\n    conversionFunctionPrefix:\n      - \"to\"\n    allowOperators: false\n  DataClassShouldBeImmutable:\n    active: false\n  DestructuringDeclarationWithTooManyEntries:\n    active: true\n    maxDestructuringEntries: 3\n  DoubleNegativeLambda:\n    active: false\n    negativeFunctions:\n      - reason: \"Use `takeIf` instead.\"\n        value: \"takeUnless\"\n      - reason: \"Use `all` instead.\"\n        value: \"none\"\n    negativeFunctionNameParts:\n      - \"not\"\n      - \"non\"\n  EqualsNullCall:\n    active: true\n  EqualsOnSignatureLine:\n    active: false\n  ExplicitCollectionElementAccessMethod:\n    active: false\n  ExplicitItLambdaParameter:\n    active: true\n  ExpressionBodySyntax:\n    active: false\n    includeLineWrapping: false\n  ForbiddenAnnotation:\n    active: false\n    annotations:\n      - reason: \"it is a java annotation. Use `Suppress` instead.\"\n        value: \"java.lang.SuppressWarnings\"\n      - reason: \"it is a java annotation. Use `kotlin.Deprecated` instead.\"\n        value: \"java.lang.Deprecated\"\n      - reason: \"it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.\"\n        value: \"java.lang.annotation.Documented\"\n      - reason: \"it is a java annotation. Use `kotlin.annotation.Target` instead.\"\n        value: \"java.lang.annotation.Target\"\n      - reason: \"it is a java annotation. Use `kotlin.annotation.Retention` instead.\"\n        value: \"java.lang.annotation.Retention\"\n      - reason: \"it is a java annotation. Use `kotlin.annotation.Repeatable` instead.\"\n        value: \"java.lang.annotation.Repeatable\"\n      - reason: \"Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265\"\n        value: \"java.lang.annotation.Inherited\"\n  ForbiddenComment:\n    active: false\n    comments:\n      - reason: \"Forbidden FIXME todo marker in comment, please fix the problem.\"\n        value: \"FIXME:\"\n      - reason: \"Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.\"\n        value: \"STOPSHIP:\"\n      - reason: \"Forbidden TODO todo marker in comment, please do the changes.\"\n        value: \"TODO:\"\n    allowedPatterns: \"\"\n  ForbiddenImport:\n    active: false\n    imports: []\n    forbiddenPatterns: \"\"\n  ForbiddenMethodCall:\n    active: false\n    methods:\n      - reason: \"print does not allow you to configure the output stream. Use a logger instead.\"\n        value: \"kotlin.io.print\"\n      - reason: \"println does not allow you to configure the output stream. Use a logger instead.\"\n        value: \"kotlin.io.println\"\n  ForbiddenSuppress:\n    active: false\n    rules: []\n  ForbiddenVoid:\n    active: true\n    ignoreOverridden: false\n    ignoreUsageInGenerics: false\n  FunctionOnlyReturningConstant:\n    active: true\n    ignoreOverridableFunction: true\n    ignoreActualFunction: true\n    excludedFunctions: []\n  LoopWithTooManyJumpStatements:\n    active: true\n    maxJumpCount: 1\n  MagicNumber:\n    active: false\n    excludes:\n      [\n        \"**/test/**\",\n        \"**/androidTest/**\",\n        \"**/commonTest/**\",\n        \"**/jvmTest/**\",\n        \"**/androidUnitTest/**\",\n        \"**/androidInstrumentedTest/**\",\n        \"**/jsTest/**\",\n        \"**/iosTest/**\",\n        \"**/*.kts\",\n      ]\n    ignoreNumbers:\n      - \"-1\"\n      - \"0\"\n      - \"1\"\n      - \"2\"\n    ignoreHashCodeFunction: true\n    ignorePropertyDeclaration: false\n    ignoreLocalVariableDeclaration: false\n    ignoreConstantDeclaration: true\n    ignoreCompanionObjectPropertyDeclaration: true\n    ignoreAnnotation: false\n    ignoreNamedArgument: true\n    ignoreEnums: false\n    ignoreRanges: false\n    ignoreExtensionFunctions: true\n  MandatoryBracesLoops:\n    active: false\n  MaxChainedCallsOnSameLine:\n    active: false\n    maxChainedCalls: 5\n  MaxLineLength:\n    active: true\n    maxLineLength: 120\n    excludePackageStatements: true\n    excludeImportStatements: true\n    excludeCommentStatements: false\n    excludeRawStrings: true\n  MayBeConst:\n    active: true\n  ModifierOrder:\n    active: true\n  MultilineLambdaItParameter:\n    active: false\n  MultilineRawStringIndentation:\n    active: false\n    indentSize: 4\n    trimmingMethods:\n      - \"trimIndent\"\n      - \"trimMargin\"\n  NestedClassesVisibility:\n    active: true\n  NewLineAtEndOfFile:\n    active: true\n  NoTabs:\n    active: false\n  NullableBooleanCheck:\n    active: false\n  ObjectLiteralToLambda:\n    active: true\n  OptionalAbstractKeyword:\n    active: true\n  OptionalUnit:\n    active: false\n  PreferToOverPairSyntax:\n    active: false\n  ProtectedMemberInFinalClass:\n    active: true\n  RedundantExplicitType:\n    active: false\n  RedundantHigherOrderMapUsage:\n    active: true\n  RedundantVisibilityModifierRule:\n    active: false\n  ReturnCount:\n    active: false\n    max: 2\n    excludedFunctions:\n      - \"equals\"\n    excludeLabeled: false\n    excludeReturnFromLambda: true\n    excludeGuardClauses: false\n  SafeCast:\n    active: true\n  SerialVersionUIDInSerializableClass:\n    active: true\n  SpacingBetweenPackageAndImports:\n    active: false\n  StringShouldBeRawString:\n    active: false\n    maxEscapedCharacterCount: 2\n    ignoredCharacters: []\n  ThrowsCount:\n    active: true\n    max: 2\n    excludeGuardClauses: false\n  TrailingWhitespace:\n    active: false\n  TrimMultilineRawString:\n    active: false\n    trimmingMethods:\n      - \"trimIndent\"\n      - \"trimMargin\"\n  UnderscoresInNumericLiterals:\n    active: false\n    acceptableLength: 4\n    allowNonStandardGrouping: false\n  UnnecessaryAbstractClass:\n    active: true\n  UnnecessaryAnnotationUseSiteTarget:\n    active: false\n  UnnecessaryApply:\n    active: true\n  UnnecessaryBackticks:\n    active: false\n  UnnecessaryBracesAroundTrailingLambda:\n    active: false\n  UnnecessaryFilter:\n    active: true\n  UnnecessaryInheritance:\n    active: true\n  UnnecessaryInnerClass:\n    active: false\n  UnnecessaryLet:\n    active: false\n  UnnecessaryParentheses:\n    active: false\n    allowForUnclearPrecedence: false\n  UntilInsteadOfRangeTo:\n    active: false\n  UnusedImports:\n    active: false\n  UnusedParameter:\n    active: true\n    allowedNames: \"ignored|expected\"\n  UnusedPrivateClass:\n    active: true\n  UnusedPrivateMember:\n    active: true\n    allowedNames: \"\"\n  UnusedPrivateProperty:\n    active: true\n    allowedNames: \"_|ignored|expected|serialVersionUID\"\n  UseAnyOrNoneInsteadOfFind:\n    active: true\n  UseArrayLiteralsInAnnotations:\n    active: true\n  UseCheckNotNull:\n    active: true\n  UseCheckOrError:\n    active: true\n  UseDataClass:\n    active: false\n    allowVars: false\n  UseEmptyCounterpart:\n    active: false\n  UseIfEmptyOrIfBlank:\n    active: false\n  UseIfInsteadOfWhen:\n    active: false\n    ignoreWhenContainingVariableDeclaration: false\n  UseIsNullOrEmpty:\n    active: true\n  UseLet:\n    active: false\n  UseOrEmpty:\n    active: true\n  UseRequire:\n    active: true\n  UseRequireNotNull:\n    active: true\n  UseSumOfInsteadOfFlatMapSize:\n    active: false\n  UselessCallOnNotNull:\n    active: true\n  UtilityClassWithPublicConstructor:\n    active: true\n  VarCouldBeVal:\n    active: true\n    ignoreLateinitVar: false\n  WildcardImport:\n    active: true\n    excludeImports:\n      - \"java.util.*\"\n"
  },
  {
    "path": "android/gradle/libs.versions.toml",
    "content": "[versions]\nandroid-billingclient = \"8.3.0\"\nandroid-gradle-plugin = \"8.13.0\"\nandroidx-junit = \"1.3.0\"\nandroidx-lifecycle = \"2.10.0\"\nappcompat = \"1.7.1\"\ncore-ktx = \"1.18.0\"\n#noinspection NewerVersionAvailable\ndagger = \"2.58\" # Later versions require AGP 9+\ndetekt = \"1.23.8\"\nespresso-core = \"3.7.0\"\njunit = \"4.13.2\"\nkotlin = \"2.3.10\"\nkotlinx-serialization-json = \"1.10.0\"\nksp = \"2.3.6\"\nktfmt = \"0.25.0\"\nmaterial = \"1.13.0\"\nplay-services-location = \"21.3.0\"\nwebkit = \"1.15.0\"\n\n[libraries]\nandroid-billingclient = { module = \"com.android.billingclient:billing-ktx\", version.ref = \"android-billingclient\" }\nandroidx-appcompat = { group = \"androidx.appcompat\", name = \"appcompat\", version.ref = \"appcompat\" }\nandroidx-core-ktx = { group = \"androidx.core\", name = \"core-ktx\", version.ref = \"core-ktx\" }\nandroidx-espresso-core = { group = \"androidx.test.espresso\", name = \"espresso-core\", version.ref = \"espresso-core\" }\nandroidx-junit = { group = \"androidx.test.ext\", name = \"junit\", version.ref = \"androidx-junit\" }\nandroidx-lifecycle = { group = \"androidx.lifecycle\", name = \"lifecycle-runtime-ktx\", version.ref = \"androidx-lifecycle\" }\nandroidx-webkit = { group = \"androidx.webkit\", name = \"webkit\", version.ref = \"webkit\" }\nhilt-android = { group = \"com.google.dagger\", name = \"hilt-android\", version.ref = \"dagger\" }\nhilt-android-compiler = { group = \"com.google.dagger\", name = \"hilt-android-compiler\", version.ref = \"dagger\" }\njunit = { group = \"junit\", name = \"junit\", version.ref = \"junit\" }\nkotlin-stdlib = { group = \"org.jetbrains.kotlin\", name = \"kotlin-stdlib\", version.ref = \"kotlin\" }\nkotlinx-serialization-json = { group = \"org.jetbrains.kotlinx\", name = \"kotlinx-serialization-json\", version.ref = \"kotlinx-serialization-json\" }\nmaterial = { group = \"com.google.android.material\", name = \"material\", version.ref = \"material\" }\nplay-services-location = { module = \"com.google.android.gms:play-services-location\", version.ref = \"play-services-location\" }\n\n[plugins]\nandroid-application = { id = \"com.android.application\", version.ref = \"android-gradle-plugin\" }\nandroid-library = { id = \"com.android.library\", version.ref = \"android-gradle-plugin\" }\ndetekt = { id = \"io.gitlab.arturbosch.detekt\", version.ref = \"detekt\" }\nhilt-android = { id = \"com.google.dagger.hilt.android\", version.ref = \"dagger\" }\nkotlin-android = { id = \"org.jetbrains.kotlin.android\", version.ref = \"kotlin\" }\nkotlin-jvm = { id = \"org.jetbrains.kotlin.jvm\", version.ref = \"kotlin\" }\nkotlinx-serialization = { id = \"org.jetbrains.kotlin.plugin.serialization\", version.ref = \"kotlin\" }\nksp = { id = \"com.google.devtools.ksp\", version.ref = \"ksp\" }\nktfmt = { id = \"com.ncorti.ktfmt.gradle\", version.ref = \"ktfmt\" }\n"
  },
  {
    "path": "android/gradle/mitm-cache/deps.json",
    "content": "{\n \"!comment\": \"This is a nixpkgs Gradle dependency lockfile. For more details, refer to the Gradle section in the nixpkgs manual.\",\n \"!version\": 1,\n \"https://dl.google.com/dl/android/maven2\": {\n  \"androidx/activity#activity/1.2.3\": {\n   \"module\": \"sha256-6eLdnZmtrzQzaRh89qPwOA/50lRJRM38RzEO+86xs1E=\",\n   \"pom\": \"sha256-vK3ckl1R1VZsRaSmzzvwXHFEpjSHEwjQ5z0+JwOtdOA=\"\n  },\n  \"androidx/activity#activity/1.8.0\": {\n   \"module\": \"sha256-0UXYtTz9Ef0m5H591FwAcTPvluok9nFctlPHN2RdHfY=\",\n   \"pom\": \"sha256-ZRMShfU8sELo/9GJ5wXa/JuK5tYkNeh3ismrO44/dW8=\"\n  },\n  \"androidx/activity/activity/1.2.3/activity-1.2.3\": {\n   \"aar\": \"sha256-Hc4HBcM0prLvAzgkGNx1hvTlfuI4FyZ7QD6oz8Nsgk4=\"\n  },\n  \"androidx/activity/activity/1.8.0/activity-1.8.0\": {\n   \"aar\": \"sha256-06Z2cJ3qBPKoUG4q6FBS//dj21Jqx/FrBN5Q/dBbByA=\"\n  },\n  \"androidx/annotation#annotation-experimental/1.3.0\": {\n   \"module\": \"sha256-Xuvq/wHQQuBtzykqv4lkrTkeSwFZ8AkPFiU9YEXTjaA=\",\n   \"pom\": \"sha256-ui/kb3T2iFHiNCRhk6uJbAwB+glM03LBBRtr8E15sLs=\"\n  },\n  \"androidx/annotation#annotation-experimental/1.3.1\": {\n   \"module\": \"sha256-m2l0p9/ibTwgndY+FvjuJGG1egkXiRYMoetJK7G/P4Q=\",\n   \"pom\": \"sha256-Qm+UMQtHvfAE3CA42uqpQ8XOT0sGPob3i8U24jLj3fw=\"\n  },\n  \"androidx/annotation#annotation-experimental/1.4.1\": {\n   \"module\": \"sha256-KsL3EG4S8mNCW0pN/ICYlEf7iVZ1/pAthnWap0/RK30=\",\n   \"pom\": \"sha256-/leyKEF/TXxneQPcYftKfPmT1gNJneJtjYET5HfMTxs=\"\n  },\n  \"androidx/annotation#annotation-jvm/1.8.1\": {\n   \"module\": \"sha256-yVnjsM3HXBXv4BYF+laqefAz45I44VBji4+r3mqhIaA=\",\n   \"pom\": \"sha256-1JIDczqm+uBGw6PeTnlu7TR1lXVUhqZCc5iYRHWXULQ=\"\n  },\n  \"androidx/annotation#annotation-jvm/1.9.1\": {\n   \"jar\": \"sha256-HjQ5F+vye6lv5NxSscrX/TK3OPvGNVu2zVs7MF1yEtA=\",\n   \"module\": \"sha256-A/tlkXfIYY5HQlklwRvJHzhHA+omwmW+myXNeSkrURw=\",\n   \"pom\": \"sha256-ibmcIY1gAZMWtQqreYFnB7whaWyJagMOGxrgOJYby44=\"\n  },\n  \"androidx/annotation#annotation/1.1.0\": {\n   \"pom\": \"sha256-LpNyuneA70SVKtv4a2bh8IaCweUnfJJhhfZWShN5nv4=\"\n  },\n  \"androidx/annotation#annotation/1.3.0\": {\n   \"jar\": \"sha256-l9xFr+/joeQh2kK4tun5BJFHfEX8YXggPjpeigXuhVM=\",\n   \"module\": \"sha256-lRbCrkQoTqC9PQ6t4O5jiHm3CMvjHjr5K6lsMAYE68M=\",\n   \"pom\": \"sha256-BJUXxSVqS7yFKfzckMTqrzWBgKwgXJFmB2ffJkZe+7A=\"\n  },\n  \"androidx/annotation#annotation/1.8.1\": {\n   \"module\": \"sha256-5jhuha/dhlBE4hZXXkk+05pjpjJb2SU3miFCnDlByLU=\",\n   \"pom\": \"sha256-txIll07Ah+uWwl72gZ9VscIvUw6FykRrpzX7Zu0E/1w=\"\n  },\n  \"androidx/annotation#annotation/1.9.1\": {\n   \"module\": \"sha256-8gSwW3KKl1YXGLxxYkLkfGKcAIWoDudPylPU1ji8vj8=\",\n   \"pom\": \"sha256-xzOIHC4X1ffIZhzAKpFZyxYLeyCUon1ZORbIfT4lBjY=\"\n  },\n  \"androidx/annotation/annotation-experimental/1.4.1/annotation-experimental-1.4.1\": {\n   \"aar\": \"sha256-a9THx0dvgmDNO9u4EYNYPpP8n3kMJ96n3DFBgcv4eqA=\"\n  },\n  \"androidx/appcompat#appcompat-resources/1.7.1\": {\n   \"module\": \"sha256-FjzF4PJJQz3bABb70BGrjeB88j4H5hjHRkRojibNGOg=\",\n   \"pom\": \"sha256-L6+SLow9cG8p2GKz+7OqE0dwXCHG6uGFhtK7H3Nm6gE=\"\n  },\n  \"androidx/appcompat#appcompat/1.7.1\": {\n   \"module\": \"sha256-i78WeSyBzC3Qg2gXg6U4L1cwB+7oFXGCky8bRU4rfwQ=\",\n   \"pom\": \"sha256-H3mRDMIN4Zis84Z7HpP57VndtkNYWy6cKO5VUYrxvAA=\"\n  },\n  \"androidx/appcompat/appcompat-resources/1.7.1/appcompat-resources-1.7.1\": {\n   \"aar\": \"sha256-ji2zEiTKU7EIx4TaKzYZWQYnFtQWshDP7z1aOCgwbfA=\"\n  },\n  \"androidx/appcompat/appcompat/1.7.1/appcompat-1.7.1\": {\n   \"aar\": \"sha256-KtM0oyOygEbom3OMd9GEyz3MoypVGrBIhRsv2iOjuiY=\"\n  },\n  \"androidx/arch/core#core-common/2.1.0\": {\n   \"jar\": \"sha256-/hI3vwKdBj5/Kf45rq9z73TIsKNlhIb8KdPFQyZlOIk=\",\n   \"pom\": \"sha256-g7uzlg6qvGAKw2bJTLWUFORBUyodaqk4iwuL/6zlzwE=\"\n  },\n  \"androidx/arch/core#core-common/2.2.0\": {\n   \"jar\": \"sha256-ZTCKBrHADuGGy54ZMhOD8EO5k4E/FSLEf0o+MwO9ukE=\",\n   \"module\": \"sha256-7fQgDP3C2UYjIlLJnl3LnGG7kJ61RQsmE9HU/cl0uYE=\",\n   \"pom\": \"sha256-HhfUr41kJb4qafivTWVKh+BFYlmp7vFUKGm8sCNUfig=\"\n  },\n  \"androidx/arch/core#core-runtime/2.1.0\": {\n   \"pom\": \"sha256-wMTtAWDNLKGDkAFd6LOStpfBczJ8aywJR9TmL2lYwF0=\"\n  },\n  \"androidx/arch/core#core-runtime/2.2.0\": {\n   \"module\": \"sha256-qLF1E5SeXbbJYBwwvhnflTdi3Yd1EvHiz8+ugdJECUQ=\",\n   \"pom\": \"sha256-D5U3BlmBQNnYc1zdWusfo1kz9OLzHAvz64GgU6TIwaU=\"\n  },\n  \"androidx/arch/core/core-runtime/2.1.0/core-runtime-2.1.0\": {\n   \"aar\": \"sha256-3XdhW9PdJ1r7EbYt8luuRrELShF803lDr0W9y/h1WFI=\"\n  },\n  \"androidx/arch/core/core-runtime/2.2.0/core-runtime-2.2.0\": {\n   \"aar\": \"sha256-ob5eDKorB2I4Yq9q4hs6sHGBIyRRhNDjDeqBtT+ZCkc=\"\n  },\n  \"androidx/cardview#cardview/1.0.0\": {\n   \"pom\": \"sha256-5k704ItYNY/ie1meb+gKGxU9sBTGRL7uYwqycQYcPmw=\"\n  },\n  \"androidx/cardview/cardview/1.0.0/cardview-1.0.0\": {\n   \"aar\": \"sha256-EZPATCKj1rWUba6fToxZ1q3eanG2vV2H+5nYLdoa/sc=\"\n  },\n  \"androidx/collection#collection-jvm/1.4.2\": {\n   \"jar\": \"sha256-mEzpvXgAVer7ihBayj9RRoa/exticux8ZFqYzkD8fbQ=\",\n   \"module\": \"sha256-qtazU2wPDlcKpzPVFB1w+mubOt03D3OjEcpMpd7iVEg=\",\n   \"pom\": \"sha256-rbs3nisWDwjmPjQa0Eu8xwM/bYaOxRrBHkM0ZCpOOe8=\"\n  },\n  \"androidx/collection#collection/1.0.0\": {\n   \"pom\": \"sha256-p5E6UnWtaOVV0mEuvowUw2exU+FMpIoYcqZImQIOVO8=\"\n  },\n  \"androidx/collection#collection/1.1.0\": {\n   \"jar\": \"sha256-YyoOVAdGHed0QJNSlA4pKikQN3JCB6eHggx32vfTO3I=\",\n   \"pom\": \"sha256-Z+kGbKSs/cbjzFCCk8MboDmAV/8Rjk9wseGBPJo0VtE=\"\n  },\n  \"androidx/collection#collection/1.4.2\": {\n   \"module\": \"sha256-AybSz1rb5ZIxKBDKH3HGwMww91PEPwfHQCNht4ineEw=\",\n   \"pom\": \"sha256-TyYVoXrqz+EraCmCFKGfKhnGjGbVkAdb27+2WevOK38=\"\n  },\n  \"androidx/concurrent#concurrent-futures-ktx/1.2.0\": {\n   \"jar\": \"sha256-4fPhe7Q1jM1sd8pF9wY1yauiNyYfGeqk9koCGMAOKj4=\",\n   \"module\": \"sha256-gj9Gms2YSt/TCz0KV36093lqA3QqUm73DBWDtZS0O4A=\",\n   \"pom\": \"sha256-eGnTmqendUpPMRdxq/2dlcy9f/sn6XD4yxX99bU1W20=\"\n  },\n  \"androidx/concurrent#concurrent-futures/1.0.0\": {\n   \"pom\": \"sha256-RQW5peMKlBi1mprWcCw+QZOupuaRo9A88iDHZArQg+I=\"\n  },\n  \"androidx/concurrent#concurrent-futures/1.1.0\": {\n   \"jar\": \"sha256-DOBnxRSg0QSdG+vfcJ40TtMmb+l0QnVoKTfNyxMzTp4=\",\n   \"module\": \"sha256-d2OaCwUeIlELrZOv/OoOvXge8SS/m3YhqVdJk3vPzf0=\",\n   \"pom\": \"sha256-dIo0610T0Z0Yet7K6oJmf97V8bC5imVeE+0uSos9iuY=\"\n  },\n  \"androidx/constraintlayout#constraintlayout-core/1.0.0\": {\n   \"jar\": \"sha256-SWyMeWZL2XDGpr56lgog3RV9/Fqi8Y5bgzN2f/vFmRE=\",\n   \"module\": \"sha256-Ge5Jb2b8BVlilFTdAdNJ89PJWVmAs3No/NkfFH7WBNs=\",\n   \"pom\": \"sha256-k+AWIukLWHm7lQ/ttcQXyAJFVf3HeUgbzuIaO1l3y4w=\"\n  },\n  \"androidx/constraintlayout#constraintlayout/2.1.0\": {\n   \"module\": \"sha256-FBrMbPAzDdTjYwcolcSyaMC1lfhNck4ukE685fK3Lkg=\",\n   \"pom\": \"sha256-JRBK9a6R7+X+bJF47uNfawLjE0lQVzjM603y34OFtYU=\"\n  },\n  \"androidx/constraintlayout/constraintlayout/2.1.0/constraintlayout-2.1.0\": {\n   \"aar\": \"sha256-pFinViNjvVnCCafeqEcyag4rJrN6iD3D4pBlp5oceIs=\"\n  },\n  \"androidx/coordinatorlayout#coordinatorlayout/1.1.0\": {\n   \"pom\": \"sha256-pnxSyd36/y/7L9S5fNlPo4LoN+qKWHTQKeCgT6Y+XK8=\"\n  },\n  \"androidx/coordinatorlayout/coordinatorlayout/1.1.0/coordinatorlayout-1.1.0\": {\n   \"aar\": \"sha256-RKnjCr9WrxAlxSoK9Qb+6cQTGqVe/aUvn9lFEhHF6Ms=\"\n  },\n  \"androidx/core#core-ktx/1.18.0\": {\n   \"module\": \"sha256-P6V5wR3g9vN4ACwPC7S75xEAY0+A+LKClReJ6n0PuEk=\",\n   \"pom\": \"sha256-Ol4ACeBlF2J3VrkgPZqPBV/ah7IVoXt3W65WhpeFQZY=\"\n  },\n  \"androidx/core#core-viewtree/1.0.0\": {\n   \"module\": \"sha256-EThs+kbLv922pAWfFDVMAGkc9l09Y8NhiBioMybvPH8=\",\n   \"pom\": \"sha256-1PLtEXb6jFYSuA90yVKoeZFCqe02AioaI4/eWxQFgNk=\"\n  },\n  \"androidx/core#core/1.13.0\": {\n   \"module\": \"sha256-Lg5uXBIFt0YqDFw+MrWMLlJUNYEu2JlGx75nN0k7UeM=\",\n   \"pom\": \"sha256-RQLk7YtZEiAhrJocExLiMm5LD0P37Lu8m1Dud0KVdNQ=\"\n  },\n  \"androidx/core#core/1.18.0\": {\n   \"module\": \"sha256-tn/3ofm+lWh9+obWBVFH/B2LvjsdwZBIxDKvyhir/ZU=\",\n   \"pom\": \"sha256-V9ZE67N9MJrZWddAFX2OhCTic7nGcoQuT0x1jtxtMOc=\"\n  },\n  \"androidx/core#core/1.2.0\": {\n   \"pom\": \"sha256-PR9ON7d92SNTh5oECrTOL3BnmLy98GYUdJHDZCs/eaY=\"\n  },\n  \"androidx/core/core-ktx/1.18.0/core-ktx-1.18.0\": {\n   \"aar\": \"sha256-x8UR49+Dj8vLYazjaUTea7fBDrWXvlRpdPABEZk5yF0=\"\n  },\n  \"androidx/core/core-viewtree/1.0.0/core-viewtree-1.0.0\": {\n   \"aar\": \"sha256-3BtnjVjrzyv6FYe+aP+CZpTOPSISUbnvMNTUs2KX5t4=\"\n  },\n  \"androidx/core/core/1.18.0/core-1.18.0\": {\n   \"aar\": \"sha256-MR2DrGfTlAduwh0S7S0QpEtZyykpt9zgDlqQqThC430=\"\n  },\n  \"androidx/core/core/1.2.0/core-1.2.0\": {\n   \"aar\": \"sha256-UkuLiM62p0p+ROa1Z6E1Zg8hF5mQTLIYv+5b4RZoILI=\"\n  },\n  \"androidx/cursoradapter#cursoradapter/1.0.0\": {\n   \"pom\": \"sha256-YtlciYUK8hAwsZ8U1ffs1ti8yaMBTFkALsmWJMqsgQA=\"\n  },\n  \"androidx/cursoradapter/cursoradapter/1.0.0/cursoradapter-1.0.0\": {\n   \"aar\": \"sha256-qByP54gV+kffW3Sd61JyetEfk5faWLFgF/TrLBHihWQ=\"\n  },\n  \"androidx/customview#customview/1.0.0\": {\n   \"pom\": \"sha256-zp5HuHGE9b1eE56b7NWyZHbULXjDG/L97cN6y0G5rUk=\"\n  },\n  \"androidx/customview#customview/1.1.0\": {\n   \"pom\": \"sha256-yBTUNfc+nm0WmIbQ65a1xTYf60hEn7uzFckIwDxYjJQ=\"\n  },\n  \"androidx/customview/customview/1.0.0/customview-1.0.0\": {\n   \"aar\": \"sha256-IOW49lJqNFlaYE9WcY2oEWfAtAp6lKV9qjVWY/JZTfI=\"\n  },\n  \"androidx/customview/customview/1.1.0/customview-1.1.0\": {\n   \"aar\": \"sha256-AfdqsEN3CpewVARvmBVxe4LOA1XAKWfRbGGYE1ncGJo=\"\n  },\n  \"androidx/databinding#databinding-common/8.13.0\": {\n   \"jar\": \"sha256-Zsq4JjnawPbCQzRkwJOwdNYIxLuIfsOKm4vErJgSZzI=\",\n   \"pom\": \"sha256-C8GPkSrwK3kO+jmsVu2w5b/Inq/gmXaApazWXJinGU4=\"\n  },\n  \"androidx/databinding#databinding-compiler-common/8.13.0\": {\n   \"jar\": \"sha256-jpAGDUEdIEGfnvXOaYk8/EoyP2ETQDB4nAuy2xo99Hw=\",\n   \"pom\": \"sha256-JFCytoqObW96rQCoVlRbPN+sSEprHwr2IWhATDIybTQ=\"\n  },\n  \"androidx/drawerlayout#drawerlayout/1.0.0\": {\n   \"pom\": \"sha256-2mczQlqD9c6FCHj6cgEII0X+18Zo3VhVD90ZwDlsb6Q=\"\n  },\n  \"androidx/drawerlayout#drawerlayout/1.1.1\": {\n   \"pom\": \"sha256-wS+pA7pTAFlipyQF83iWYIcY9BZz/EpiMS5FNhMvb0U=\"\n  },\n  \"androidx/drawerlayout/drawerlayout/1.1.1/drawerlayout-1.1.1\": {\n   \"aar\": \"sha256-LF8NyjeOt4yixEA/mInHfaowWTAiYPJqB/6fY8CJJv4=\"\n  },\n  \"androidx/dynamicanimation#dynamicanimation/1.1.0\": {\n   \"module\": \"sha256-n19EJUtexrF7SWW4tkGFHSiI29R0xsqBqcr1EnGPAmI=\",\n   \"pom\": \"sha256-yuy3s3lYXY5AcOuvrjl0oTfGaJ3oB3+RZZNbNgV4SpM=\"\n  },\n  \"androidx/dynamicanimation/dynamicanimation/1.1.0/dynamicanimation-1.1.0\": {\n   \"aar\": \"sha256-kGLMZjcTakmtF/hgPHJRkFL/mlaeYX/BtttwhjBNqI8=\"\n  },\n  \"androidx/emoji2#emoji2-views-helper/1.2.0\": {\n   \"module\": \"sha256-o6nbWBq/F4ewH/FcQPBZUw6OZPOTfKoteI9C6zmJMmg=\",\n   \"pom\": \"sha256-21kFLonRO3lGSwS9H4dNeX8dl9g/STf2aaU+H2ioAjc=\"\n  },\n  \"androidx/emoji2#emoji2-views-helper/1.3.0\": {\n   \"module\": \"sha256-CZdLte+XgN6dVnFdcRcaNcePsuF/2GV3OwyDo6ysA5w=\",\n   \"pom\": \"sha256-JnOw9YWdQ2Gk6aMEV4UqZ0XQw8hbXgQSBwSD85Y+9SY=\"\n  },\n  \"androidx/emoji2#emoji2/1.3.0\": {\n   \"module\": \"sha256-3chR7bpl/RWnobw60YZI4vcy3VrY7zYCIkvOBkf1tNE=\",\n   \"pom\": \"sha256-FOu+qCNPATIRll/dCLQu6QfOAHWvMhAYyMKGXC5dsOs=\"\n  },\n  \"androidx/emoji2/emoji2-views-helper/1.3.0/emoji2-views-helper-1.3.0\": {\n   \"aar\": \"sha256-mhNRKVpPc53w7+g0Stqpr7NIVsOvWE1KmvvsEFpFuQs=\"\n  },\n  \"androidx/emoji2/emoji2/1.3.0/emoji2-1.3.0\": {\n   \"aar\": \"sha256-K/I4GLI6mW3aobX9W7MhKdr/a7stzhUWbi/M3SAQsaU=\"\n  },\n  \"androidx/fragment#fragment/1.0.0\": {\n   \"pom\": \"sha256-4ynWczYelNLo9NTRTh8FhjaL1D+xnv1XZs50mLzM0WI=\"\n  },\n  \"androidx/fragment#fragment/1.1.0\": {\n   \"pom\": \"sha256-73jrJ6wC3fNUXV+KOFfHOig3oBhT+NX893JRAR21JUQ=\"\n  },\n  \"androidx/fragment#fragment/1.5.4\": {\n   \"module\": \"sha256-rzJggI3OtlMu/C1yFb5Fhywkppna2n13v/c4zjuFp/A=\",\n   \"pom\": \"sha256-+MoYd8ZuZCxVSQfhzlpJOol8opJwyxniu21CS6Q7bJg=\"\n  },\n  \"androidx/fragment/fragment/1.1.0/fragment-1.1.0\": {\n   \"aar\": \"sha256-oUyLjyFT8SjoAPvSZqa+qxwoOYKinsVw0swF0wfYFJY=\"\n  },\n  \"androidx/fragment/fragment/1.5.4/fragment-1.5.4\": {\n   \"aar\": \"sha256-vDwkMd2kLpS7lRHFh+rokNJ25Kr+OTqNp7ABaRhtr94=\"\n  },\n  \"androidx/graphics#graphics-shapes-android/1.0.1\": {\n   \"module\": \"sha256-mlUIyvAtePzycpOU6X57J6Q3hic+F8y2W/jhsApZgyQ=\",\n   \"pom\": \"sha256-gd57zIVJCE0QTVrzOxIPk+ENRUJKFaRUz6HyQwSqTRE=\"\n  },\n  \"androidx/graphics#graphics-shapes/1.0.1\": {\n   \"module\": \"sha256-LX9tVQQimfnE+EeKoJS8QJmjRpAnef8wkf7R38K2L1M=\",\n   \"pom\": \"sha256-drUM5mT6RKSxZUIIHgiJkc8xaubnI6pM0BpicZ8aiic=\"\n  },\n  \"androidx/graphics/graphics-shapes-android/1.0.1/graphics-shapes-android-1.0.1\": {\n   \"aar\": \"sha256-cXEmmkEi/quwcXaN9G0Qs2Lkesdf0KqNvK+h9E9htAw=\"\n  },\n  \"androidx/interpolator#interpolator/1.0.0\": {\n   \"pom\": \"sha256-DdwHzDlpn0js2eyJS1gwwPCeIugpWSlO3zchciTIi3s=\"\n  },\n  \"androidx/interpolator/interpolator/1.0.0/interpolator-1.0.0\": {\n   \"aar\": \"sha256-MxkxNaZP4h+iw17sZojxp25RJgbA/IPcG2ieN63Xcyo=\"\n  },\n  \"androidx/lifecycle#lifecycle-common-jvm/2.10.0\": {\n   \"jar\": \"sha256-FZQwgth7zXiDA5j6N38sixJkPeKQ0JBu2OSaLTNd21Q=\",\n   \"module\": \"sha256-k9kBazr9A2OaQH9RoRnVyk2umI3jdsOA8OUd2diOaG0=\",\n   \"pom\": \"sha256-ygYrmrsCmLDUCuEMLRygbUEp9fYO/cXoxExsKzN10Vs=\"\n  },\n  \"androidx/lifecycle#lifecycle-common/2.10.0\": {\n   \"module\": \"sha256-XRJHse373J3e3L5SXJ0mKVZ7HFOMt6nGIM0EShJLXHM=\",\n   \"pom\": \"sha256-EAT03wURixPCqQmEa63GZkbtJ3lEcGpgTk3YKE1TVIs=\"\n  },\n  \"androidx/lifecycle#lifecycle-common/2.3.1\": {\n   \"jar\": \"sha256-FYSPtW2zL0x83HKzJAAxg9UqSITWvwm+cIrH9YfRObU=\",\n   \"module\": \"sha256-X7fIUU2MVsraXinvidwCiecZQqtMsLLm3KE3udy4/dQ=\",\n   \"pom\": \"sha256-jNI9iJoUCVxs4WhA0psaY4j6XhFRRMEwnU1tRpwbw1E=\"\n  },\n  \"androidx/lifecycle#lifecycle-common/2.5.1\": {\n   \"module\": \"sha256-fUvClhzVvTmeNiHUNPDEU91srfiR+RepRswpGr3ajxo=\",\n   \"pom\": \"sha256-gtIZLj3p/df0W5CI4nMV59vsAtCUYy+qajklFpDSjMA=\"\n  },\n  \"androidx/lifecycle#lifecycle-common/2.6.1\": {\n   \"module\": \"sha256-k3R6kUXLNrxxAF9Zjt4y4rEUmt5aFuYrDklpNFvGLYU=\",\n   \"pom\": \"sha256-PmaDDVn65S4QMLS+ckseV3rIFNJzNf34GnEoPcCCnxo=\"\n  },\n  \"androidx/lifecycle#lifecycle-common/2.6.2\": {\n   \"module\": \"sha256-D6fyj1z/ikBqT3hwskPLDW16fCD6p6K+yv9ZB64S+cw=\",\n   \"pom\": \"sha256-VzBM2sTaKJpuzdBzjhax2IWPHvjp+r4tZaljcZ/YHbo=\"\n  },\n  \"androidx/lifecycle#lifecycle-livedata-core-ktx/2.10.0\": {\n   \"module\": \"sha256-KyigMgHzB3sq4+KFP5g5RK/fUYsCk5rPyf6eX8q4cnU=\",\n   \"pom\": \"sha256-2YIILDJ57rnBh9mJdmXKmpDTRD812txokmH0snxy2aw=\"\n  },\n  \"androidx/lifecycle#lifecycle-livedata-core/2.10.0\": {\n   \"module\": \"sha256-HYO9XzzMEpjtolue0SjowYf4MOfzr40ClL5oirsDw10=\",\n   \"pom\": \"sha256-S+1ekc0qfAFVgyC2oXbbuGf8o8TGl9uUjiA75F+RDWQ=\"\n  },\n  \"androidx/lifecycle#lifecycle-livedata-core/2.3.1\": {\n   \"module\": \"sha256-seCV1VDTmn1sgVdh1tvj/WTrMaOdwoFG54u/LAG6j0E=\",\n   \"pom\": \"sha256-+5liIuPeR/G964s2WQuMEYmKT3L5E42hhzZdMQOhQ/s=\"\n  },\n  \"androidx/lifecycle#lifecycle-livedata-core/2.5.1\": {\n   \"module\": \"sha256-PziOngeJAZcMK/z8Av7K6UjeS0a+UhGRmuB9ASyimA0=\",\n   \"pom\": \"sha256-ZC8Oldo/OGux0TbvihFIHczThMKKCT2Utce82qLvpbM=\"\n  },\n  \"androidx/lifecycle#lifecycle-livedata-core/2.6.1\": {\n   \"module\": \"sha256-6cDcPwrFRBnAz+2P9c7LgpQ6fFj3pUFp8NhJssYKNVI=\",\n   \"pom\": \"sha256-4PZ+Ky5hLxO8Dzzqck2u3vpdRJQFHrRfW+5tiKbaiFk=\"\n  },\n  \"androidx/lifecycle#lifecycle-livedata/2.0.0\": {\n   \"pom\": \"sha256-qEhC/8DxTlGNt1wFzBEmgKikoWT6eDlb4y2IMEpDlCM=\"\n  },\n  \"androidx/lifecycle#lifecycle-livedata/2.10.0\": {\n   \"module\": \"sha256-Om1HOzEEyYNQHeSIPWunaE3IMzDEYO+Vg5CoJlsHgxA=\",\n   \"pom\": \"sha256-trk2hsVaHKjsIOfYSl/jQSZ+XaxuOQ+xzKOYODER6wg=\"\n  },\n  \"androidx/lifecycle#lifecycle-process/2.10.0\": {\n   \"module\": \"sha256-xorsfMHQ0Z9RMo59KMkdz7SsjhqYQ8/WGB4aqUrhnJs=\",\n   \"pom\": \"sha256-osMGIyCHP5rCne0dnKSiQVUpwuRWjVO18rgT7FKtxmQ=\"\n  },\n  \"androidx/lifecycle#lifecycle-process/2.4.1\": {\n   \"module\": \"sha256-46rj7QS0dE/zFFLpj9KZ4639KNO1cjZh2WeLkvoJzrQ=\",\n   \"pom\": \"sha256-2n4uEMrn12JaCuRUAXoSV0nheRoTnC13feka3YlBCvU=\"\n  },\n  \"androidx/lifecycle#lifecycle-process/2.6.1\": {\n   \"module\": \"sha256-WMnic3HM96IqIz9Ekm00jJ0H54xBpWWIpCZf9q52ZFo=\",\n   \"pom\": \"sha256-QR/JuQIWdanSvS1alUhSAR2KMXJjtuM5lGRJpgFdOoY=\"\n  },\n  \"androidx/lifecycle#lifecycle-runtime-android/2.10.0\": {\n   \"module\": \"sha256-dJtuekQikUWB4HldnUj7T5bao/7pd0dCH/QjSGAYX0c=\",\n   \"pom\": \"sha256-6X7gHa9vTcy5AL23Zyuqta08fCXEVJH9zPXroztur/8=\"\n  },\n  \"androidx/lifecycle#lifecycle-runtime-ktx-android/2.10.0\": {\n   \"module\": \"sha256-mAob4UbK+harOxaAmR3eFLIHeksT1bMUJ8uY3Ikw8ks=\",\n   \"pom\": \"sha256-XBomndDwrOh/IoMog7iYvXtCUtaofwTBc0fYPjEoZg4=\"\n  },\n  \"androidx/lifecycle#lifecycle-runtime-ktx/2.10.0\": {\n   \"module\": \"sha256-VW8VlXN2hYtuOrwI+z31ZupetAZxSSUxlL8qBJYG204=\",\n   \"pom\": \"sha256-BgrRdTOB56+deluJgp85Tvb9R/d+MbpMgSRivDe9phg=\"\n  },\n  \"androidx/lifecycle#lifecycle-runtime/2.0.0\": {\n   \"pom\": \"sha256-qSpG+nrsisMmpdV4c0otW2MgaXaZa54GyuFxs1sKtt4=\"\n  },\n  \"androidx/lifecycle#lifecycle-runtime/2.10.0\": {\n   \"module\": \"sha256-2oBfoBekrM4T9QFGmnWj8wYkjuvFj1vMKAGbABXfumU=\",\n   \"pom\": \"sha256-Sote7FTV7N8JnqU7Lk6fJNspZ+pdOTCXtqbFSe1pMI0=\"\n  },\n  \"androidx/lifecycle#lifecycle-runtime/2.3.1\": {\n   \"module\": \"sha256-KnuQ5QSbZ0s2vM/WhnezoLMXiz98Lvfd9hjTiVWYxM4=\",\n   \"pom\": \"sha256-tEQqhPw5ftsukIof33E8aufTqHZBoJ7hPRDsjuELMx8=\"\n  },\n  \"androidx/lifecycle#lifecycle-runtime/2.6.1\": {\n   \"module\": \"sha256-pMuwGkLQcEe9jYcAF8lqGwt7RnMyDoa2YxehO+LsEMc=\",\n   \"pom\": \"sha256-QecdAD1DfxadToraWcsZgvG0e30lwjAQuPyB1SinQNU=\"\n  },\n  \"androidx/lifecycle#lifecycle-runtime/2.6.2\": {\n   \"module\": \"sha256-6gExhGq+H+nepZrG3+Hw+52LbWAMnv+aH9StXuXny8c=\",\n   \"pom\": \"sha256-gXUlVUbipfUQhl+ErOaAZglUcwJAsZBdkXW0NFvtqXc=\"\n  },\n  \"androidx/lifecycle#lifecycle-viewmodel-android/2.10.0\": {\n   \"module\": \"sha256-BrB6B64Xx1kqMMUzw9RqEl/LG+8JbX5hNOJCDbJGTQo=\",\n   \"pom\": \"sha256-CqFxpQ9QqFyQaHrYLc038xr90b/SRjwhXRMilg5R4ig=\"\n  },\n  \"androidx/lifecycle#lifecycle-viewmodel-savedstate-android/2.10.0\": {\n   \"module\": \"sha256-lJ0qhitrWEzuka9wqeAXvbEX0iW7RgltqUJKFyjKeek=\",\n   \"pom\": \"sha256-X81Cm6V+Hd4bQ4U05Twj1idNI04zx0iWP2+qn4GTPIc=\"\n  },\n  \"androidx/lifecycle#lifecycle-viewmodel-savedstate/2.10.0\": {\n   \"module\": \"sha256-UIV3gePd+OOZBeTkpXowSWwkX5kB0bfT8XOCOCGINH0=\",\n   \"pom\": \"sha256-WWVv/K4eDhRwQ7DW2JlmpflnUvBSkev6AoQG7ZRETdE=\"\n  },\n  \"androidx/lifecycle#lifecycle-viewmodel-savedstate/2.3.1\": {\n   \"module\": \"sha256-gINxC3WKwJaJHpH1HZHuVqRFsmXXvs8jA1U3cyfAQYs=\",\n   \"pom\": \"sha256-IV5A7oT9r7Ke8liXexlrro+lpsejo0EUJ8eHsnHk9Fw=\"\n  },\n  \"androidx/lifecycle#lifecycle-viewmodel-savedstate/2.5.1\": {\n   \"module\": \"sha256-KazV/mFLP4kSPrg49ojWJeqotCLI0ZBbSK2OdgzXrYs=\",\n   \"pom\": \"sha256-PXf6Ow4CbYa3I7vBNHQuIy6Pmc4YoR31t4lWLppsCm8=\"\n  },\n  \"androidx/lifecycle#lifecycle-viewmodel-savedstate/2.6.1\": {\n   \"module\": \"sha256-2vuGSXY9KcKc2ie8IvzauanvxTwP/5rj3pCILquqiUQ=\",\n   \"pom\": \"sha256-qjTLhYY4S44xTP1lsYExl/Mve6cdJSmhHAcerwJ0Hls=\"\n  },\n  \"androidx/lifecycle#lifecycle-viewmodel/2.10.0\": {\n   \"module\": \"sha256-T9cd9zMkXEbkuI6FtKkLgsmvSjWe8x8r3yUPk6AsdFI=\",\n   \"pom\": \"sha256-HYYK/wylmNSOjoPaP05naQeEmFdKGNO4iewrjECQsZg=\"\n  },\n  \"androidx/lifecycle#lifecycle-viewmodel/2.3.1\": {\n   \"module\": \"sha256-pTGFPf4xbJC3Rm0kvpTb5gpg71SlLJBMhjgZhiAuUfQ=\",\n   \"pom\": \"sha256-I/RerQuaA4OC2cOq+ctR9QvrzLY4q4PfnYQqO/CMQRo=\"\n  },\n  \"androidx/lifecycle#lifecycle-viewmodel/2.5.1\": {\n   \"module\": \"sha256-AeQTtzy+OMtxTcW9shvYYJMRJMjl8jaYA/SqzEkIHJ8=\",\n   \"pom\": \"sha256-k+XPzLyf0vihCS7AawdXvYNOY9zSqAAbWUI5qx5KbeE=\"\n  },\n  \"androidx/lifecycle#lifecycle-viewmodel/2.6.1\": {\n   \"module\": \"sha256-K0BvrqXBLyuN9LemCTH4RmSPLh9NeDYeGY0RhPGaR5c=\",\n   \"pom\": \"sha256-3C6OZdtT0hZZon7ZO5Zt7jNsHC6OhyhhZ3OJqZuLkTQ=\"\n  },\n  \"androidx/lifecycle/lifecycle-livedata-core-ktx/2.10.0/lifecycle-livedata-core-ktx-2.10.0\": {\n   \"aar\": \"sha256-XxhCl0mhg+GReNhmWuQr97YxC1A1iung4MxFKv1uC8M=\"\n  },\n  \"androidx/lifecycle/lifecycle-livedata-core/2.10.0/lifecycle-livedata-core-2.10.0\": {\n   \"aar\": \"sha256-Et1hqYQ8zrtFR9Pr4vbQMMqPaYjSL4+tGcCvk7SpfpU=\"\n  },\n  \"androidx/lifecycle/lifecycle-livedata-core/2.3.1/lifecycle-livedata-core-2.3.1\": {\n   \"aar\": \"sha256-5V04w3JGDwoDmX3clQxnInURNA/XT4Y02Z0pZTzYGrE=\"\n  },\n  \"androidx/lifecycle/lifecycle-livedata/2.0.0/lifecycle-livedata-2.0.0\": {\n   \"aar\": \"sha256-yCYJztjEmPCnAaMPtncbt0gIYNruhNguCoHuhu33ujk=\"\n  },\n  \"androidx/lifecycle/lifecycle-livedata/2.10.0/lifecycle-livedata-2.10.0\": {\n   \"aar\": \"sha256-PowAn8iNocUuTtBagSadWsm9QFBG8wAwf146F+IBpt8=\"\n  },\n  \"androidx/lifecycle/lifecycle-process/2.10.0/lifecycle-process-2.10.0\": {\n   \"aar\": \"sha256-ELtbsSdz3KEdg0PAFM/RfftAln1uzjfo8V0E2M6wd1w=\"\n  },\n  \"androidx/lifecycle/lifecycle-runtime-android/2.10.0/lifecycle-runtime-android-2.10.0\": {\n   \"aar\": \"sha256-IZOhVz1iPzeyDH0n0aj5A6cvZRzG8y5XlPhd2nRP7nU=\"\n  },\n  \"androidx/lifecycle/lifecycle-runtime-ktx-android/2.10.0/lifecycle-runtime-ktx-android-2.10.0\": {\n   \"aar\": \"sha256-hxiDcDM5+HKE0jLLQ24xfG9K9WEtkrQ/J8axO5IQn6c=\"\n  },\n  \"androidx/lifecycle/lifecycle-runtime/2.3.1/lifecycle-runtime-2.3.1\": {\n   \"aar\": \"sha256-3SlPSmiccf+Hf9QfO2ejpi93YNRM5CDmEw8fw1adjwA=\"\n  },\n  \"androidx/lifecycle/lifecycle-viewmodel-android/2.10.0/lifecycle-viewmodel-android-2.10.0\": {\n   \"aar\": \"sha256-kwMocDACfKC4z5inSN3rEh7Bv6ExLABpQo5HZburzng=\"\n  },\n  \"androidx/lifecycle/lifecycle-viewmodel-savedstate-android/2.10.0/lifecycle-viewmodel-savedstate-android-2.10.0\": {\n   \"aar\": \"sha256-GUBCOfpYQyLC+B6WWeFivAEbdG0p3JUKEdJgYk2DIl4=\"\n  },\n  \"androidx/lifecycle/lifecycle-viewmodel-savedstate/2.10.0/lifecycle-viewmodel-savedstate-2.10.0\": {\n   \"aar\": \"sha256-H6InIMh7D7PBIk+FgM9pNp88/E2ZIXCLuUSfh4x8s+k=\"\n  },\n  \"androidx/lifecycle/lifecycle-viewmodel-savedstate/2.3.1/lifecycle-viewmodel-savedstate-2.3.1\": {\n   \"aar\": \"sha256-lxN6ivajF3ahTkhmq4CMfAp5G0hL28eIu9g+ZkB1ZMA=\"\n  },\n  \"androidx/lifecycle/lifecycle-viewmodel/2.10.0/lifecycle-viewmodel-2.10.0\": {\n   \"aar\": \"sha256-S4Cc+etzI9IAUE6Vkh/rd3/wgCFcrbgjd0LV7Qult5I=\"\n  },\n  \"androidx/lifecycle/lifecycle-viewmodel/2.3.1/lifecycle-viewmodel-2.3.1\": {\n   \"aar\": \"sha256-tttMJ0oS/4WkdH4eZmnH6Yrvolcazp0fGm+mvkF86Dg=\"\n  },\n  \"androidx/loader#loader/1.0.0\": {\n   \"pom\": \"sha256-yXjVUICLR0NKpJpjFkEQpQtVsLzGFgqTouN9URDfjF4=\"\n  },\n  \"androidx/loader/loader/1.0.0/loader-1.0.0\": {\n   \"aar\": \"sha256-Efc1yztVxFjUcL7Z4lJUN1tRi0sbrWkmeDpwJtsPUCU=\"\n  },\n  \"androidx/profileinstaller#profileinstaller/1.3.1\": {\n   \"module\": \"sha256-zH7tDtS2ad6EuFL3h5elABik8wAC4eOKqmaK8iyltGA=\",\n   \"pom\": \"sha256-CCY+twqBSnyybRHTqDgl+Ewp8+S1n5E0ck4OAuK712g=\"\n  },\n  \"androidx/profileinstaller#profileinstaller/1.4.0\": {\n   \"module\": \"sha256-Ob+ZeijY7tLLMZgZ9vNSobo6eLnJeQBPvgXia499Fgs=\",\n   \"pom\": \"sha256-1f45mBo7S3w5WFEw2gv8k4NQVMg0pDjT5j4GXl94vlU=\"\n  },\n  \"androidx/profileinstaller/profileinstaller/1.4.0/profileinstaller-1.4.0\": {\n   \"aar\": \"sha256-1QIUH8zpAkMPYrZ0wyrs0PdSYufuLNFcdK22F80TEwo=\"\n  },\n  \"androidx/recyclerview#recyclerview/1.2.1\": {\n   \"module\": \"sha256-I1QsjIXMWPr+CujLogHmyeAbTGeZIjNAotalHXeEgow=\",\n   \"pom\": \"sha256-2xYvz8Ayy3N7oH+vLRk2ye42K3Tgw48xzNLGoszXkJA=\"\n  },\n  \"androidx/recyclerview/recyclerview/1.2.1/recyclerview-1.2.1\": {\n   \"aar\": \"sha256-oeoDKe5tk4MF39D4zlxI3qKqwU5WBtI+f7YK/PtlXW4=\"\n  },\n  \"androidx/resourceinspection#resourceinspection-annotation/1.0.1\": {\n   \"jar\": \"sha256-jP+HDsb7MdtIpS9KeSM1tL+N4H4DvTeCMYFSZDPM1cs=\",\n   \"module\": \"sha256-NSoRqNikwb1s0sL+//nJTKlU17UgKgZWlZ25Upf2orc=\",\n   \"pom\": \"sha256-uOtJOO2V1/zaonEuvKNj5GoPkii4vn8jFU3vVoRoFdw=\"\n  },\n  \"androidx/savedstate#savedstate-android/1.4.0\": {\n   \"module\": \"sha256-AGJRTcuAwup+BBH6i+VFUIU7q3v2sb8UwlF8Lvh0/1A=\",\n   \"pom\": \"sha256-tEshW0zOJMpktfqlj/Rj9LkavAKvlbpAWt5IiCj093k=\"\n  },\n  \"androidx/savedstate#savedstate/1.1.0\": {\n   \"module\": \"sha256-buorwVCCjI/Lp3fpMDcDji7j7EQcQ9as7PLFzZ3cU3Q=\",\n   \"pom\": \"sha256-SXhLdctJm7n4E32COocvDG2lym26fJrPLeOmg8t9ttw=\"\n  },\n  \"androidx/savedstate#savedstate/1.2.1\": {\n   \"module\": \"sha256-W7ZW/HYNnjmWtTUWDLtBBgM8n3NukInm706wxml4UGY=\",\n   \"pom\": \"sha256-DTO8KF3x4S8ieA8WJKbws46iphgbCVXsZkHK9iDFDL8=\"\n  },\n  \"androidx/savedstate#savedstate/1.4.0\": {\n   \"module\": \"sha256-oPKsWgdgY/DIE891pG37eAvVpaLIZ3JAvKhVFVTaj9I=\",\n   \"pom\": \"sha256-0raKLQzHbyPOvG1Oqbvy1j+1Nv/ZTgVRStnabF9TBJI=\"\n  },\n  \"androidx/savedstate/savedstate-android/1.4.0/savedstate-android-1.4.0\": {\n   \"aar\": \"sha256-FlbOYs0jPUiL271C5TO4CyJDW3ppCsrm2+cwI5JRLBQ=\"\n  },\n  \"androidx/savedstate/savedstate/1.1.0/savedstate-1.1.0\": {\n   \"aar\": \"sha256-1gu+RMLAgIOhfF3GeKbWtNCi1mSFgBarXAScvqkKY7c=\"\n  },\n  \"androidx/savedstate/savedstate/1.4.0/savedstate-1.4.0\": {\n   \"aar\": \"sha256-2zgXAg7p/X42s2wcVryiMtMBml5MshxK2usgwn9XYF0=\"\n  },\n  \"androidx/startup#startup-runtime/1.0.0\": {\n   \"module\": \"sha256-QO/8oNbuH94yvClol+VOu8xM9KopsMUxA2y9KoJKPCQ=\",\n   \"pom\": \"sha256-GQtlQlHxEEUvZ/ahPXZuj+4IEfB+bwsPd9WJBiJqy6g=\"\n  },\n  \"androidx/startup#startup-runtime/1.1.1\": {\n   \"module\": \"sha256-z9ls9kUMbitpdZiSRymtmgSVxaT89Ovufi+BsH5BWGU=\",\n   \"pom\": \"sha256-9BFLXGhZuxvDyvKBy21vJZmPp/cpLGTOrqdKkyEOdGs=\"\n  },\n  \"androidx/startup/startup-runtime/1.1.1/startup-runtime-1.1.1\": {\n   \"aar\": \"sha256-4KYymjcSYv5MRQNytw/a8zt2nvaRcJRyN4fPzolrHdM=\"\n  },\n  \"androidx/test#core/1.7.0\": {\n   \"pom\": \"sha256-uX3REs1oStNob1zPWBuSsQbKdlZc8CzP7OBgrzvFB0M=\"\n  },\n  \"androidx/test#monitor/1.8.0\": {\n   \"pom\": \"sha256-aBzwjEFwDHOxRpU4Z8Csgm2pwqgQvSpKj4eOoRxk1Aw=\"\n  },\n  \"androidx/test#runner/1.7.0\": {\n   \"pom\": \"sha256-YigxxtMWZvs0OMsNLuH7u8eMq81JcXaZV42SVQBiIH8=\"\n  },\n  \"androidx/test/core/1.7.0/core-1.7.0\": {\n   \"aar\": \"sha256-9NrNjtzu7Ejgx27PKDObKPS09rdPjjTp5ZtHLCfZ64E=\"\n  },\n  \"androidx/test/espresso#espresso-core/3.7.0\": {\n   \"pom\": \"sha256-tuf0PLOWuJpM4ymn5azvedN1IDqHoRp2OdGMDm3lC1Y=\"\n  },\n  \"androidx/test/espresso#espresso-idling-resource/3.7.0\": {\n   \"pom\": \"sha256-csXz3/ITvRQ836LrQW0b3hztZlbZxUtp78qPdKLMgx0=\"\n  },\n  \"androidx/test/espresso/espresso-core/3.7.0/espresso-core-3.7.0\": {\n   \"aar\": \"sha256-XdkONmg4vwRMtS6uBkdN69KF3xinp3xARBrI6JUbsA8=\"\n  },\n  \"androidx/test/espresso/espresso-idling-resource/3.7.0/espresso-idling-resource-3.7.0\": {\n   \"aar\": \"sha256-X/YjJrScMIwdBgRmrjz0qg496vkpXwd6aIYEjdo+mxQ=\"\n  },\n  \"androidx/test/ext#junit/1.3.0\": {\n   \"pom\": \"sha256-kq9/JT5eq+LK7FxW8wgxJlPRIwQrInVNb/Zk4XUtzz8=\"\n  },\n  \"androidx/test/ext/junit/1.3.0/junit-1.3.0\": {\n   \"aar\": \"sha256-M2PfhNpFQLqNr/AsP3zWVHEDempTcFkafm3ro3ezbn8=\"\n  },\n  \"androidx/test/monitor/1.8.0/monitor-1.8.0\": {\n   \"aar\": \"sha256-Vst0lqBtny3KfT/3bFCoowvRjgCiSjsmfVoxQ3snjmc=\"\n  },\n  \"androidx/test/runner/1.7.0/runner-1.7.0\": {\n   \"aar\": \"sha256-lwMRxHEZkoouQGqIiSo9JwOHzFpJoYGhxEUREFtBuBg=\"\n  },\n  \"androidx/test/services#storage/1.6.0\": {\n   \"pom\": \"sha256-jl1y4rzu9u+Ae8OINNvHqlMXf9INo0n1Pc5aTyoCi78=\"\n  },\n  \"androidx/test/services/storage/1.6.0/storage-1.6.0\": {\n   \"aar\": \"sha256-+X489qr04/uX7yGdN6nAoHIBg8H224ezdkJSHib7bTA=\"\n  },\n  \"androidx/tracing#tracing/1.0.0\": {\n   \"module\": \"sha256-/Ish6+X6OnyW7gmLzc0A8HfrznPyQ/qFjisGcWFfddg=\",\n   \"pom\": \"sha256-zQKZqQ1HINePHPtf91BfTbwacNBf4j/Z9NS3fqWcoF4=\"\n  },\n  \"androidx/tracing#tracing/1.2.0\": {\n   \"module\": \"sha256-0NjUhra9MyBtvz8abRZ+m0PCaOpjwzIciGsVQ60F7OM=\",\n   \"pom\": \"sha256-80vsTrWIcdMQApATdxCKqh6/53+m2IK4uGIAsVjibqE=\"\n  },\n  \"androidx/tracing/tracing/1.0.0/tracing-1.0.0\": {\n   \"aar\": \"sha256-B7i2E5ZluIShYuzPl4kcpQ9/VoMSM78lForgT3tWhhI=\"\n  },\n  \"androidx/tracing/tracing/1.2.0/tracing-1.2.0\": {\n   \"aar\": \"sha256-b6qQOQ0f2/Ctuamb+Z3me5TGxvNa6pUQWTqdF5c3NqI=\"\n  },\n  \"androidx/transition#transition/1.5.0\": {\n   \"module\": \"sha256-rUM0Z/Xt6HrSs8Wff+BjmEzwcgzazRBZH12PhOOk4JU=\",\n   \"pom\": \"sha256-22x0l/DwlP8u7PsynhcAZY8NbFb7CISC9+m3dN6a1T8=\"\n  },\n  \"androidx/transition/transition/1.5.0/transition-1.5.0\": {\n   \"aar\": \"sha256-CqZqDqQG0loQkflqO3U7SxLkT9xDuR7FLBeDHpwx9Us=\"\n  },\n  \"androidx/vectordrawable#vectordrawable-animated/1.1.0\": {\n   \"pom\": \"sha256-J2ogEWtwX7dbkAPulJbFb2/TsyN1+yMkcoEeumCgQL0=\"\n  },\n  \"androidx/vectordrawable#vectordrawable/1.1.0\": {\n   \"pom\": \"sha256-Ww4tWyF55UgEeFy8Ic5fRzteHd1VpX2kgulNzTlJK7I=\"\n  },\n  \"androidx/vectordrawable/vectordrawable-animated/1.1.0/vectordrawable-animated-1.1.0\": {\n   \"aar\": \"sha256-dtosUCNx2cOAVN9eKySNANqHgJ7QWPM2Pq6Hzl4kA/g=\"\n  },\n  \"androidx/vectordrawable/vectordrawable/1.1.0/vectordrawable-1.1.0\": {\n   \"aar\": \"sha256-Rv1jOsAbSbf8q8JjvwmMWouemml3TSNO3MoE+wLfjiY=\"\n  },\n  \"androidx/versionedparcelable#versionedparcelable/1.1.0\": {\n   \"pom\": \"sha256-xynHvgzAYyO9qCnUYGZuedvUO3maIQiaRL07KT3CU7U=\"\n  },\n  \"androidx/versionedparcelable#versionedparcelable/1.1.1\": {\n   \"pom\": \"sha256-X1HmWHPKYS3jg4+pDS7pW40EDv0xucOQoZv5TWFc2y8=\"\n  },\n  \"androidx/versionedparcelable/versionedparcelable/1.1.0/versionedparcelable-1.1.0\": {\n   \"aar\": \"sha256-mh13FArCIreGa1BU7n0Vm8GACYftLUbdav3RRau3EME=\"\n  },\n  \"androidx/versionedparcelable/versionedparcelable/1.1.1/versionedparcelable-1.1.1\": {\n   \"aar\": \"sha256-V+jZMmDRjVuQB8nu08ZK0VnekMhgnr/HSjR8vVFFNaQ=\"\n  },\n  \"androidx/viewpager#viewpager/1.0.0\": {\n   \"pom\": \"sha256-H3L4NjOdA8brAT9lB152yocHWld1eOtPlfdKOl0lMSg=\"\n  },\n  \"androidx/viewpager/viewpager/1.0.0/viewpager-1.0.0\": {\n   \"aar\": \"sha256-FHr04UoZhAENjxVeXhnXgfA8HXDf7QKo4NGEKLj8hoI=\"\n  },\n  \"androidx/viewpager2#viewpager2/1.0.0\": {\n   \"pom\": \"sha256-QGO8p/6U/mXJj0Fo+XrhDgLaAkhZitOsIcQyx/YIoXo=\"\n  },\n  \"androidx/viewpager2/viewpager2/1.0.0/viewpager2-1.0.0\": {\n   \"aar\": \"sha256-6VwAMdTMJHzUgZbGKH5Y0s7lTZx5uFr+p8kJIDMCda8=\"\n  },\n  \"androidx/webkit#webkit/1.15.0\": {\n   \"module\": \"sha256-xdNHJ9+0XW32b68ddJJ6fxGICKSO9E0NnzOrjRyN9P4=\",\n   \"pom\": \"sha256-wb8AHuiPdfjpTZRPWW6Yq8ItdYGCsZ6YHgJPpCGX3h8=\"\n  },\n  \"androidx/webkit/webkit/1.15.0/webkit-1.15.0\": {\n   \"aar\": \"sha256-JByQqsk3tlYlkpdtpcxzEAU4vyVQAPSQ7TcpbaF3OWA=\"\n  },\n  \"com/android#signflinger/8.13.0\": {\n   \"jar\": \"sha256-wdyixoNjTuGilCmPnHF5V4r2qG4IC9xA+WGRW8XIFC8=\",\n   \"pom\": \"sha256-OkrXUCQ7Vqh1+Vc7f8p42UcVlOFAtdpY13lcyGX08rk=\"\n  },\n  \"com/android#zipflinger/8.13.0\": {\n   \"jar\": \"sha256-BwYAacNeRp18NDq8FfHWNivBNWuBv0YlOduIpT7WU/E=\",\n   \"pom\": \"sha256-Y2qWYiGqGrmJ+pOPBuxMzdu47f9BmmclG5NZkX8LolI=\"\n  },\n  \"com/android/application#com.android.application.gradle.plugin/8.13.0\": {\n   \"pom\": \"sha256-d8GqTwlczexLj/r3rOOuZ2xZrPxO360gzEUtJv4fqUE=\"\n  },\n  \"com/android/billingclient#billing-ktx/8.3.0\": {\n   \"pom\": \"sha256-Yfdvz4C6UuSgX+utw4xx82TrKfPkyBtekTvIhyc1n3Y=\"\n  },\n  \"com/android/billingclient#billing/8.3.0\": {\n   \"pom\": \"sha256-cJPjGv6SbvKyCMcsKUHo3GtbS13Jcjh+MEjrTd2R9SQ=\"\n  },\n  \"com/android/billingclient/billing-ktx/8.3.0/billing-ktx-8.3.0\": {\n   \"aar\": \"sha256-KoscetmEKbQkX/q6HbSPG9x49s0JeuwlbT1uC68bfK4=\"\n  },\n  \"com/android/billingclient/billing/8.3.0/billing-8.3.0\": {\n   \"aar\": \"sha256-mBC1zUfiC8grNU81g8nTv3xCsz5k4ZLc4EldA2qF9QU=\"\n  },\n  \"com/android/databinding#baseLibrary/8.13.0\": {\n   \"jar\": \"sha256-eUETcJ2rIbBsJis3lec8twj7rK5hcV80Nh4a9iN6GHA=\",\n   \"pom\": \"sha256-PZLfn3MWZqVlF4M4DtMuA1y/KTMKqrzHirWuYzURA/o=\"\n  },\n  \"com/android/library#com.android.library.gradle.plugin/8.13.0\": {\n   \"pom\": \"sha256-dhcDGWYBg4wNlBgLzcP3iinwHhQbAFZ9rV3Gym56yng=\"\n  },\n  \"com/android/tools#annotations/31.13.0\": {\n   \"jar\": \"sha256-O0u5YgwX0Z5b2RrBmICAVTVztMO3Of3ZJBb0Ly2vPng=\",\n   \"pom\": \"sha256-Iwt9E2YNaI/2m/7LXK/AlXWmHE4G0epZJeij/K2I3ls=\"\n  },\n  \"com/android/tools#common/31.13.0\": {\n   \"jar\": \"sha256-tLb0upSEPIbhNl8pTZCFtfTxT2P8zQ8OENp/2/pMPQQ=\",\n   \"pom\": \"sha256-R4QhNsLtFHaoDxQ/aY45NDUVAvSGOrRzinl9KOSwSQs=\"\n  },\n  \"com/android/tools#dvlib/31.13.0\": {\n   \"jar\": \"sha256-488/3JR3iN7o1bqnbLcqZlcRdLxHQe3w47q5enypDhs=\",\n   \"pom\": \"sha256-9WoffiwZb/8CJvKv0r9Kph0cW7fw8rJ75h8wcZl6iY4=\"\n  },\n  \"com/android/tools#play-sdk-proto/31.13.0\": {\n   \"jar\": \"sha256-xvwVpcIDBkz9LIoXb96scq4KLXQ+xHouZqAjjY2HC2s=\",\n   \"pom\": \"sha256-deEueVxle0h/0V0yguHuTnEz9JG37/+xQ5dci+YY+pA=\"\n  },\n  \"com/android/tools#repository/31.13.0\": {\n   \"jar\": \"sha256-6VCbMNCI6JmUj4yw1zKTwe/S4fEh/Mu+JdUztki5P6E=\",\n   \"pom\": \"sha256-uUBY6ELBtlTAHdJ88i/4gXP686I4juq0QH8oLGLHuy8=\"\n  },\n  \"com/android/tools#sdk-common/31.13.0\": {\n   \"jar\": \"sha256-jP35nW8XaJ5907zxg01zT23Rxk2MQ5BGMsZdVGlWWTQ=\",\n   \"pom\": \"sha256-fqorU3mG9siHaRfu2PwX9ktvZwTk4nuNRzL18pNlq7A=\"\n  },\n  \"com/android/tools#sdklib/31.13.0\": {\n   \"jar\": \"sha256-3vmw5/ROVK3ThcrBcVSDck+CfxZlEevAwQMZdCqoCGU=\",\n   \"pom\": \"sha256-1Ig+YTEazhApxPoOufKcmM38+rQnPNmgcEMDaQAAaPk=\"\n  },\n  \"com/android/tools/analytics-library#crash/31.13.0\": {\n   \"jar\": \"sha256-zKl6wpoTKb0xCj6DK25X9GIn5QGqUpwApj3yF8XX30E=\",\n   \"pom\": \"sha256-ryOUI9PO2T4v88PkO1euo2v8h5czIZJs2MoqIVnrZkY=\"\n  },\n  \"com/android/tools/analytics-library#protos/31.13.0\": {\n   \"jar\": \"sha256-st7SCol/upZJ7+sYui/AYu455QDU63EgRcsONLQ7Xvs=\",\n   \"pom\": \"sha256-YO8OzBOQSe/eID4PFjk7ok6IUN/2pwBbONrpYzVJ0YM=\"\n  },\n  \"com/android/tools/analytics-library#shared/31.13.0\": {\n   \"jar\": \"sha256-dUNYFvICt6PITZyvMSqJViWiRJkfj8UtBEYjnjrimpw=\",\n   \"pom\": \"sha256-tG8yfisNff7yGsrElVOMUuw6U/obFkT6aXvl58iVvHY=\"\n  },\n  \"com/android/tools/analytics-library#tracker/31.13.0\": {\n   \"jar\": \"sha256-G2ZRS/KRUkIu6KGbmOAgDZLrCj0oBI60hXVk6aHHuFs=\",\n   \"pom\": \"sha256-QPVQaB9n0YOudJBN61sBLDjHQWXomdCjMq19pDqc48A=\"\n  },\n  \"com/android/tools/build#aapt2-proto/8.13.0-13719691\": {\n   \"jar\": \"sha256-as7GPBbFhmf3uyqzG8HoRGWxE6g0ewP7iVW36r7h62k=\",\n   \"module\": \"sha256-cL4Y8GBA6Tt5lr4FroRGBiuiaqQR2F5zydzXHl5u3wQ=\",\n   \"pom\": \"sha256-29EePBH09HqyCK2U3ipb8J0/u0sKhP3PWc0CFB1Vjxw=\"\n  },\n  \"com/android/tools/build#aaptcompiler/8.13.0\": {\n   \"jar\": \"sha256-9ek62kopAqXYGBaLQHLQgffPoELqbQBlbaQhc7yn/n8=\",\n   \"module\": \"sha256-MsMT7QGm4AbuPqxxfBfB5dIYqd8lZ1KNNLF6WmdC3gk=\",\n   \"pom\": \"sha256-o7d5hvIUrN4e/dH+/qk5u1HO9UekRODAPaac9dy0VsI=\"\n  },\n  \"com/android/tools/build#apksig/8.13.0\": {\n   \"jar\": \"sha256-wHDtE5RinXRkGqCQb2Cy/6Hud+Y2ah+TQ39ZcXsa64k=\",\n   \"pom\": \"sha256-s5tsQWn8Qrt9OnTgRd6CA1Cfo3dpOzO351Th8Hx1mGk=\"\n  },\n  \"com/android/tools/build#apkzlib/8.13.0\": {\n   \"jar\": \"sha256-KQkclFclL5l93+r7M91lo3OtRYQBKPlFgy2Or9kRhWE=\",\n   \"pom\": \"sha256-p7hsdtNZ7+4sfrKVqtChxjIhkrpk3Q23Geb02+t+dTA=\"\n  },\n  \"com/android/tools/build#builder-model/8.13.0\": {\n   \"jar\": \"sha256-GhkEmYAFa3sTIeLB4M/26fMTYr3YM+p7PyXqh6+3f1A=\",\n   \"module\": \"sha256-F86TynVjAkBwXrgzTh55729jxsbnrNXQoBpaoWvd/+I=\",\n   \"pom\": \"sha256-VV9IuanQmUqAy1yUWQp9o2rLnFUm528JOiB0kKIUklU=\"\n  },\n  \"com/android/tools/build#builder-test-api/8.13.0\": {\n   \"jar\": \"sha256-q3NB3qJORrIpx4sGazdm2WWqktdcLet9Vc29rOPxnR4=\",\n   \"module\": \"sha256-Q+ybge0lm1AGl+zeJCraIG0EiJLRZQ8Eh9cx/OMrzeY=\",\n   \"pom\": \"sha256-Z1Q1tnMvTFewncDv4cQEW53jYFVVNgWvya069mi/izI=\"\n  },\n  \"com/android/tools/build#builder/8.13.0\": {\n   \"jar\": \"sha256-RkrqnMvyAN7WWX0oSCVNlSKel4J3nqZ/lyWINAgSpKk=\",\n   \"module\": \"sha256-D9sgwS9lktgI2m0Ade00Ug9hxxk2hotL3jkni7G3tZ0=\",\n   \"pom\": \"sha256-nyQ1neOGWJattFOSiFNXL1K/xmTzaS3ypIfnnw1cihM=\"\n  },\n  \"com/android/tools/build#bundletool/1.18.1\": {\n   \"jar\": \"sha256-pzNBp5RavLDmuJccexsoAb12UAZEfKDSQ3pCYNVyzqw=\",\n   \"pom\": \"sha256-XE33suMfF/IOS429YqK3hloJpJof0pMaNZ/TlOy5taU=\"\n  },\n  \"com/android/tools/build#gradle-api/8.13.0\": {\n   \"jar\": \"sha256-P+x5YqMQnwprC24nyJ/87nXIgJYmPxkQ1HXy47UVdX4=\",\n   \"module\": \"sha256-XP/UxYtwOYHY4ySI+FRVK6+MjrceXiv7xzSh83S9yEI=\",\n   \"pom\": \"sha256-S6sCnnPyoOigOK+FCIa3LQ+lxoGXgdgCQaZvSlG+TFo=\"\n  },\n  \"com/android/tools/build#gradle-common-api/8.13.0\": {\n   \"jar\": \"sha256-s6HcvV9e6czXomqfh+BxNOZusiZjTCbu+J4tO6uBwSE=\",\n   \"module\": \"sha256-68dcXU2WF7ALKaa03p0LKmwfjN+B8+ZdqgKqwd4KaQk=\",\n   \"pom\": \"sha256-726T8+2ihPbbbSIoutfPCdGQeWnWvfUlXHt2NYT9t6Q=\"\n  },\n  \"com/android/tools/build#gradle-settings-api/8.13.0\": {\n   \"jar\": \"sha256-C7Q8iD0zcZJS6UKyoKcR5ICiVCN8/fMT5TQ6qK0tKl0=\",\n   \"module\": \"sha256-P8c1U0Ih8nqq6a5BjhtArteQai2X+7Qnvg8NsD3s/Zc=\",\n   \"pom\": \"sha256-wvvYtu8RBTT1Bkn4szDG1Ol5YNjVtyt+aJCMBW0qXnE=\"\n  },\n  \"com/android/tools/build#gradle/8.13.0\": {\n   \"jar\": \"sha256-0BlYdEl7SsPq6zqFJb95TTpbY6mgEITKmsMw+2MGDQE=\",\n   \"module\": \"sha256-H/68JjTTItX/Fkb7VKqc2qoAq16zkAoZP6Vhsj4DGnc=\",\n   \"pom\": \"sha256-7kGC9LXLZUTzbSPbXRWWdMqeo9sFqOR0KVr8/Gz1ENo=\"\n  },\n  \"com/android/tools/build#manifest-merger/31.13.0\": {\n   \"jar\": \"sha256-PpNwiOXPzGeT2fKXGxZMnK3OEK/Ck4zYWOqVRaYhhtU=\",\n   \"module\": \"sha256-8SUIO300T17ySA7gx0osjbFoewz7udqc5mQmSv76+Hk=\",\n   \"pom\": \"sha256-JZpXvIp3fUF6JYuN3/CE/5IYmQ5vfA5upSZGtVt7Tpg=\"\n  },\n  \"com/android/tools/build#transform-api/2.0.0-deprecated-use-gradle-api\": {\n   \"jar\": \"sha256-TeSj0F4cU0wtueRYi/NAgrsr0jLYq7lyfEMCkM4iV0A=\",\n   \"pom\": \"sha256-fGLzhW6KvKHXkleSXybBJmhpP12VkEBWu6yIYFz9hXU=\"\n  },\n  \"com/android/tools/build/jetifier#jetifier-core/1.0.0-beta10\": {\n   \"jar\": \"sha256-Jqu0oTkn2QYhacUEyelP6A6a46T3tauIdasAdTapH14=\",\n   \"module\": \"sha256-8JF1iaQtJ2Fj8QBAq1hC6RiD3L2x1Iv9Hx/Kpywcp7c=\",\n   \"pom\": \"sha256-XJ1C5rfjXU2NAuCjIs8maTs+w2QrEHyPC+WnIdRaDG0=\"\n  },\n  \"com/android/tools/build/jetifier#jetifier-processor/1.0.0-beta10\": {\n   \"jar\": \"sha256-xQZ6e5KCN6EnGl6ctXEOn4C0lzKTlFvFHjpMhk6kv+0=\",\n   \"module\": \"sha256-NsJVdrGZk982AXBSjMYrckbDd3bWFYFUpnzfj8LVjhM=\",\n   \"pom\": \"sha256-M7F/OWmJQEpJF0dIVpvI7fTjmmKkKjXOk9ylwOS6CEI=\"\n  },\n  \"com/android/tools/ddms#ddmlib/31.13.0\": {\n   \"jar\": \"sha256-g5lX+WEQBxPqDu1iioaEzDmqR5Yxw2JJeT5t9+DNY9g=\",\n   \"pom\": \"sha256-9o0Z08HeMqDo+iVN4s+eyb9D5oFUE1jVXRKza1sRX40=\"\n  },\n  \"com/android/tools/emulator#proto/31.13.0\": {\n   \"jar\": \"sha256-t3+BzAdR15OT7EsusEb5ENIavNdgi1sPWh7+obMkO0g=\",\n   \"pom\": \"sha256-Dxsx0Qc0+bhXjctVkV+SNHQyoOt9BbzNJOBdQL/6XLk=\"\n  },\n  \"com/android/tools/external/com-intellij#intellij-core/31.13.0\": {\n   \"jar\": \"sha256-mm+qYGHQ89VKZN7LYZRMGyxpJ/jTJc0pjILCqNhn7mg=\",\n   \"pom\": \"sha256-YbAEUWWBcVMjtLu3101F7yW91VGE6HWFb9cuzpE4FKk=\"\n  },\n  \"com/android/tools/external/com-intellij#kotlin-compiler/31.13.0\": {\n   \"jar\": \"sha256-VS36/+KV0IUEhwgWwn/AkAfhIx+5sUwf+bv4YfmzWZA=\",\n   \"pom\": \"sha256-c+HV4hKaLGY+mPoDVFeO1NvE5asp2DLk+EPvEJ6crlM=\"\n  },\n  \"com/android/tools/external/org-jetbrains#uast/31.13.0\": {\n   \"jar\": \"sha256-ePGKwrJQn7bLGQWOj8lYXDYbl5kN19XbDCqUdE37CpY=\",\n   \"pom\": \"sha256-8mr1nLfxVVFRAXoV7aGZ76BfpX4Fzw5Y7EMVq98u5eA=\"\n  },\n  \"com/android/tools/layoutlib#layoutlib-api/31.13.0\": {\n   \"jar\": \"sha256-0GvGUCR2MqSk5llrhzEgGfRekAJnxUdsR6W/puP9MTI=\",\n   \"pom\": \"sha256-iY/RN1KxPWOQywmDx70j8ciixQt1keCrrRRoHQnq/8M=\"\n  },\n  \"com/android/tools/lint#lint-api/31.13.0\": {\n   \"jar\": \"sha256-j3cGV9ujPzBeWDxilTpPF0x1p7HNLafTETS+Nqlq4qw=\",\n   \"pom\": \"sha256-Y1/FLFcMEsSyt2SCb9w0w27/erwm4Al6fIBQVEBKbVs=\"\n  },\n  \"com/android/tools/lint#lint-checks/31.13.0\": {\n   \"jar\": \"sha256-O2Tzla4X/OoQSIKwCkrNx9xpH12spd/yvd6J+gUrsZk=\",\n   \"pom\": \"sha256-DFNHI/Dx6d0+vQXFLJ2vRwFtG5NmIL+CesfH+9iGr54=\"\n  },\n  \"com/android/tools/lint#lint-gradle/31.13.0\": {\n   \"jar\": \"sha256-pCtqQcQ22QyjGhPWevuhFXsVfvyJKnSW9nQyv4qDHL0=\",\n   \"pom\": \"sha256-wldyxmVXyNl/Ov6VrdQ6UPDhqkvv5cbKfiKwXRMlssU=\"\n  },\n  \"com/android/tools/lint#lint-model/31.13.0\": {\n   \"jar\": \"sha256-nuVdj9ACc27ZXul/sF9N964B9Pl29zj7837Kt5Xlkxk=\",\n   \"pom\": \"sha256-S7bme4aLcsJbFhS2HXUJguBBTNZxYXK+MVWtKqfW+4A=\"\n  },\n  \"com/android/tools/lint#lint-typedef-remover/31.13.0\": {\n   \"jar\": \"sha256-Sjujur/Xnm/Ge872R/tOz+r1m0gbEI98LrpNHFxt6o4=\",\n   \"pom\": \"sha256-+X0rIYob+njItTyCeITvq646344Sl5HPff8v9LNG89k=\"\n  },\n  \"com/android/tools/lint#lint/31.13.0\": {\n   \"jar\": \"sha256-f4damA7iORZDnTaNBzz7wu5OTZn/4bPhPaeU/vNH8po=\",\n   \"pom\": \"sha256-f167NcXWSHwddTYFMU/YyJ6DNCsJjjaUbgfVqkun/GE=\"\n  },\n  \"com/android/tools/utp#android-device-provider-ddmlib-proto/31.13.0\": {\n   \"jar\": \"sha256-BHrs3WbhBhN/d6UsRC8bg9t9boSWiZgAJR8gbH853mU=\",\n   \"pom\": \"sha256-qQVnzI9qaK8p3Oe8tkWLYUhuENJG14TCId81qHsTP5M=\"\n  },\n  \"com/android/tools/utp#android-device-provider-ddmlib/31.13.0\": {\n   \"jar\": \"sha256-5sLUZ0B3YQ4WVSTfbgYXtq8PkTWAdYVNV8EHL1+wwkU=\",\n   \"module\": \"sha256-x3m3ICzqcYzkhvgR0SSgxdSujGW8tkP2I41FJCnYmec=\",\n   \"pom\": \"sha256-Q3Yb/9k6QLUWjoKLjUilMYetQs4pzIPX0X8bMxO+DHc=\"\n  },\n  \"com/android/tools/utp#android-device-provider-profile-proto/31.13.0\": {\n   \"jar\": \"sha256-PnsJj24+yuMbb3kJw0O07AmqGNion0G/kgd7pLBW9FM=\",\n   \"pom\": \"sha256-vE0jPmc1PQr+1+iMqMQ34Ma490i3sERW21ACEk6t3RY=\"\n  },\n  \"com/android/tools/utp#android-device-provider-profile/31.13.0\": {\n   \"jar\": \"sha256-Gq5pCd0QzxXRAPJIcaW5MQ//bSVW1nw4CKHhZj70cco=\",\n   \"module\": \"sha256-WNmd8ITAq2CeOdyYmA6Q75r8IWum97kG0sTtYS18fig=\",\n   \"pom\": \"sha256-lOSDirGHZJ18wbQuI671v4ZsvtWq2ANGtobzikH3vRs=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-host-additional-test-output-proto/31.13.0\": {\n   \"jar\": \"sha256-a6fmrCII10wbtfHRRkq6/GpF2HELIEVaLcAq34cmvIM=\",\n   \"pom\": \"sha256-ibdgXQ7NnRVVmlbQN69jH0CySilrGOLNqRQNGIdxtYg=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-host-additional-test-output/31.13.0\": {\n   \"jar\": \"sha256-/F8iTbGx0hZCM53/COO8ybs7Z1PE5qfV51B2mny7vrQ=\",\n   \"module\": \"sha256-0cRnguU1tnS5VhSsNNHyPkfw8CwAQnPGZRvg2zaoQ58=\",\n   \"pom\": \"sha256-VMJZG3W8XWAHY55AIXMQkKcrBFa0WNfWb/wcMWjU5gI=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-host-apk-installer-proto/31.13.0\": {\n   \"jar\": \"sha256-TythBULpGjWjlrBDaKeEA25CuHhwIUYFULmjSVu4JFs=\",\n   \"pom\": \"sha256-fY+DUTrN4W/1r/SMCdF8h6S/7J/Z1ZsNGIhmoPaGmcU=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-host-apk-installer/31.13.0\": {\n   \"jar\": \"sha256-rUpl9T7YQSU7h1s/QjKCbKg+ktL43yx1eUSCTZ+bseI=\",\n   \"module\": \"sha256-K7r8U9YtpU0NKQ0nHfirM16kaK9h44klEmHHdopcK84=\",\n   \"pom\": \"sha256-asQz5n/da4RmEBi7ZgPC1BtL2mPhMLZmMA/kKJWWNeI=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-host-coverage-proto/31.13.0\": {\n   \"jar\": \"sha256-+oZxmj3F3kZffgwCMYRBTCf4/VOjT9VXKJwL9t80AkQ=\",\n   \"pom\": \"sha256-/YjLzACtSK0gBzGwZURJFIv3K+zmbpEr42Mb3Z2ynJs=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-host-coverage/31.13.0\": {\n   \"jar\": \"sha256-bvZB5hKpHc9cEe7sfhg65ha8zxjblo+Fk8MsIVukXms=\",\n   \"module\": \"sha256-fHcineMtmZ0rsTTClZACwW3mfExdnKvLdFbR3eTpEzc=\",\n   \"pom\": \"sha256-uioklU04iLQA6pnfMn8yJJvTIq49Lf7aoHuY0/AXRnE=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-host-device-info-proto/31.13.0\": {\n   \"jar\": \"sha256-loOsdkinpBvpoTSfaYFZKUT2JxZImMPIkloL7t6LuLs=\",\n   \"pom\": \"sha256-4H9sj1RvKhxyZKnhPVjiju5TcZf5fxGPNklrX4TVnzY=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-host-device-info/31.13.0\": {\n   \"jar\": \"sha256-u/GWphfV9QHyaWv+q77ktoe06AnY1W2cdGEAlPigB8s=\",\n   \"module\": \"sha256-frN3cItvF7vW//PvvYsjqai/0qSybLvQaJxfnn5WSNg=\",\n   \"pom\": \"sha256-oYrnne8ilug3u8jVGbfF9HFjQzq5tMBv65WwSkdImVw=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-host-emulator-control-proto/31.13.0\": {\n   \"jar\": \"sha256-pPNKrg+f+gJtv3FRQ23XrlO+y3JiK0DyxHnKyJQ9kxk=\",\n   \"pom\": \"sha256-UUl9OSCqen7ucxJgKFO3R3HVf0TW/Aw/GK8PaGhhjgA=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-host-emulator-control/31.13.0\": {\n   \"jar\": \"sha256-Q/WnO1WXWMHToRknehCepVKj5PdTaIQvt5r3MIrSJGU=\",\n   \"module\": \"sha256-xBsZWgRbA1URISzhX84K07eon0UxDaKwbU61B76nav0=\",\n   \"pom\": \"sha256-iqjmoJhmpJvjghgdbL/Es8FdlutICa+ZpVup4YHFPIg=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-host-logcat-proto/31.13.0\": {\n   \"jar\": \"sha256-wfbrus2tVZtu/k6qKVYVUrMxVjlfBpzZcD/aCcRi3qY=\",\n   \"pom\": \"sha256-LiA8at4apdhwhssgjC1rORk9eNgf+AyaHhfFkrn2Ueo=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-host-logcat/31.13.0\": {\n   \"jar\": \"sha256-vnzK0Ke40GJ4K3aQMOF9Vz6W0wIZay+ZhexRy0K2t2s=\",\n   \"module\": \"sha256-vm44ITCPBb4gLaOs1zgsdfdenk5j+1vl1YK9wbLBNlc=\",\n   \"pom\": \"sha256-L+YfJXBBs6krqdrJF+KJnncFefeLl0DNYrlKhxsKWDo=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-result-listener-gradle-proto/31.13.0\": {\n   \"jar\": \"sha256-1Cm5MS3/oFAzgdHuGxipmb2QHnRWYSsvtIxqXVosr4g=\",\n   \"pom\": \"sha256-QdmxPdex7v2JYLq0S1/QiN7vFueNVr6LlVdc8UVjrgk=\"\n  },\n  \"com/android/tools/utp#android-test-plugin-result-listener-gradle/31.13.0\": {\n   \"jar\": \"sha256-oblcvtX9S4xqROfbRkLxrMh/W/+VY1DormRLUFApwSA=\",\n   \"module\": \"sha256-GSCmFT95J9Ae1A2orIoIKTmf2Q+Z9jbx3l2tP9C/Xac=\",\n   \"pom\": \"sha256-Ubn5suXm6+aJKlkxvCsBQLib8sRts4iTYHa/tjn58lE=\"\n  },\n  \"com/android/tools/utp#utp-common/31.13.0\": {\n   \"jar\": \"sha256-zeZ4pksTBBzdLMna0WhZkKHQkPsE/12iJh/3WoNZgQY=\",\n   \"pom\": \"sha256-UnH7zAPgXRzcGx1cGrggJWSjaPmrJSu1rN36fXSIssU=\"\n  },\n  \"com/google/android/datatransport#transport-api/3.0.0\": {\n   \"pom\": \"sha256-FTe+vUTaLrfjvnP8QlnhEW8qaKUwX0/iPGzqmm+E95E=\"\n  },\n  \"com/google/android/datatransport#transport-backend-cct/3.1.8\": {\n   \"pom\": \"sha256-QmmGluvVrx6zP5F+WCuqQW4omiHNg+4ynCVYUiFring=\"\n  },\n  \"com/google/android/datatransport#transport-runtime/3.1.8\": {\n   \"pom\": \"sha256-1v92IlH7NVlK/l7+hgtYcQZOGMC9G9t3CE41c/kOTo8=\"\n  },\n  \"com/google/android/datatransport/transport-api/3.0.0/transport-api-3.0.0\": {\n   \"aar\": \"sha256-TmmDwHA7NX328cbOrLG138LFAGp4nHmf7CKYsrUzdGY=\"\n  },\n  \"com/google/android/datatransport/transport-backend-cct/3.1.8/transport-backend-cct-3.1.8\": {\n   \"aar\": \"sha256-4X7dHvf9R1yQuqTjlCIzLycIfTS8tGy0jOhq+aVKYS4=\"\n  },\n  \"com/google/android/datatransport/transport-runtime/3.1.8/transport-runtime-3.1.8\": {\n   \"aar\": \"sha256-y5NT7xeRrhcJfYeMpxHiWpwyzskEKtxJsAyt/uGnKQs=\"\n  },\n  \"com/google/android/gms#play-services-base/18.5.0\": {\n   \"pom\": \"sha256-JXC1FcJxevOGyJDpf2RHguP4bae2d6T/EDYUfn6mIqQ=\"\n  },\n  \"com/google/android/gms#play-services-basement/18.4.0\": {\n   \"pom\": \"sha256-Bcp8Cs4NYmCTH5ftMsYM5ZgHH/Vg0/pE9J5vBpXStoc=\"\n  },\n  \"com/google/android/gms#play-services-basement/18.9.0\": {\n   \"pom\": \"sha256-YB7wRxZ8bCgLixkJQtnpSgRj6JGs2cbM9TLrKYpe/ls=\"\n  },\n  \"com/google/android/gms#play-services-location/21.3.0\": {\n   \"pom\": \"sha256-05aVvPApr7ms5nvwTQNFSgYiyVZkE1P7oWBk0f3Ht5E=\"\n  },\n  \"com/google/android/gms#play-services-tasks/18.2.0\": {\n   \"pom\": \"sha256-a5nEioldFV5Yq87mbMIhRtuDq9XYTK9sj3oq6psbzSE=\"\n  },\n  \"com/google/android/gms/play-services-base/18.5.0/play-services-base-18.5.0\": {\n   \"aar\": \"sha256-WaXAwtoSMR112WXOH0GUmFNrGhZ/so/338Lf2c76QVc=\"\n  },\n  \"com/google/android/gms/play-services-basement/18.9.0/play-services-basement-18.9.0\": {\n   \"aar\": \"sha256-xu1yNrGzzgKWtQilwKCRCP1S2DVZ01ytJjlQXNBKslU=\"\n  },\n  \"com/google/android/gms/play-services-location/21.3.0/play-services-location-21.3.0\": {\n   \"aar\": \"sha256-1QJDhNW/Zsde3zU9leuOe1v2RIMThHTG4uk+LCOU+EI=\"\n  },\n  \"com/google/android/gms/play-services-tasks/18.2.0/play-services-tasks-18.2.0\": {\n   \"aar\": \"sha256-fyqqj1AgaOr1Q1bKkq7AQnHW58QWxSxFwNI0QPy9FlQ=\"\n  },\n  \"com/google/android/material#material/1.13.0\": {\n   \"module\": \"sha256-TBPV6U9KtPp8NazFJBGCvyWYWZRYPNTWK1+rvve8J3I=\",\n   \"pom\": \"sha256-nB88RgApXMXX9taIauoW1JfxP5r0Z1RCrScefC/r93I=\"\n  },\n  \"com/google/android/material/material/1.13.0/material-1.13.0\": {\n   \"aar\": \"sha256-bV4cu2fAW83Lv4QAV4fa/TVKFV9ZpNOAD9Rfp+s2ifU=\"\n  },\n  \"com/google/firebase#firebase-encoders-json/18.0.0\": {\n   \"pom\": \"sha256-On1ZeVp5loOvpkNZMQZsW7Y0rf69KIOgi0dl84zdPqE=\"\n  },\n  \"com/google/firebase#firebase-encoders-proto/16.0.0\": {\n   \"jar\": \"sha256-KT25ag0dQ/AzFniBtjjY/ehE5OVJX1EBz1IpV2UpXg4=\",\n   \"pom\": \"sha256-X1+3SvP/8OH5Yfy18/mSZ8EDsWojyJtVbIy0pxGo3LI=\"\n  },\n  \"com/google/firebase#firebase-encoders/17.0.0\": {\n   \"jar\": \"sha256-KCpacD+bfrVlCN3pfqkY6V1zMYsVcFD0V/eobcp1AVA=\",\n   \"pom\": \"sha256-QjV141AOmRDjqoP516bXVbX3asWRgjuvZ1cPts5+qBY=\"\n  },\n  \"com/google/firebase/firebase-encoders-json/18.0.0/firebase-encoders-json-18.0.0\": {\n   \"aar\": \"sha256-gK7Ofh71iVfKL8GVe8kgjskqOpUoIBMx08Y+MYJXD5c=\"\n  },\n  \"com/google/testing/platform#android-device-provider-local/0.0.9-alpha03\": {\n   \"jar\": \"sha256-ZnpNNbu6h9PIb1GA36Uh/b16TvXGDZSRVLAwHz4jLhs=\",\n   \"pom\": \"sha256-kTs67pWnFYGkCQOLHMonPOg10/K48qtaYAu+12nqbf0=\"\n  },\n  \"com/google/testing/platform#android-driver-instrumentation/0.0.9-alpha03\": {\n   \"jar\": \"sha256-UHxjLsfbd7yymbVRnVmxTMYkOqxUF2fGMv2+3cYiawc=\",\n   \"pom\": \"sha256-AtXBStCZWXGhCIGQdacXXRAHa9cVJ3LO00QrsZxMG1M=\"\n  },\n  \"com/google/testing/platform#android-test-plugin/0.0.9-alpha03\": {\n   \"jar\": \"sha256-1st+Em9DMDcZC808O5BLGbqELUaxew/SfDjMTM7L7JA=\",\n   \"pom\": \"sha256-qCucEMqqjl0fPNtjhHqbA6yXOKUlswmrcNTRZh7f6iI=\"\n  },\n  \"com/google/testing/platform#core-proto/0.0.9-alpha03\": {\n   \"jar\": \"sha256-0AHrDMu/yMueqhk6NY5jcSl0Y5d1ZHvpSasjLCsptAc=\",\n   \"pom\": \"sha256-O7RSgN8d0clrmgFySmFFZrfWDTNFP81SwsdB+ZmcOk4=\"\n  },\n  \"com/google/testing/platform#core/0.0.9-alpha03\": {\n   \"jar\": \"sha256-bhgG0BXEFllvU6RaMQDiV0PDE6bj/E9S8k6LJX8sgs4=\",\n   \"pom\": \"sha256-nwPalZsyYcweuciIa/iu1vXWC7lbjYY4vtVUQ65iDCo=\"\n  },\n  \"com/google/testing/platform#launcher/0.0.9-alpha03\": {\n   \"jar\": \"sha256-ABL5gKBZoMTCFtDx0AFoZ6sx64B54/j4efHwK3vjpuc=\",\n   \"pom\": \"sha256-GLgPNKXp+hpe7CJQ/XHlRl8utLTQnhgZQOOPG24GRFI=\"\n  }\n },\n \"https://plugins.gradle.org/m2\": {\n  \"com/google/android#annotations/4.1.1.4\": {\n   \"jar\": \"sha256-unNOHoTAnWFa9qCdMwNLTwRC+Hct7BIO+zdthqVlrhU=\",\n   \"pom\": \"sha256-5LtUdTw2onoOXXAVSlA0/t2P6sQoIpUDS/1IPWx6rng=\"\n  },\n  \"com/google/api/grpc#proto-google-common-protos/2.48.0\": {\n   \"jar\": \"sha256-Q+x4B0WaqkAS6Dihvk7y1ZDPIzMF2mCvW1TwjsjPIwI=\",\n   \"pom\": \"sha256-ReuSeyJoX8Eb+rac5q07Q59y2Bajp95GvSP07Rm/icM=\"\n  },\n  \"com/google/auto#auto-parent/6\": {\n   \"pom\": \"sha256-BfdAxmSBZdsAz2GN1WwgDEcl41jm1U9YU+C+wVc06go=\"\n  },\n  \"com/google/auto/value#auto-value-annotations/1.6.2\": {\n   \"jar\": \"sha256-tIsE3bpA6KwzvwNvBvxDmV/FCEvZS9qs6AfOJ9O+o/s=\",\n   \"pom\": \"sha256-HHbNRi/JbnqpbccM6C8NVAY9bfFts1ycfZzA0amdP/8=\"\n  },\n  \"com/google/auto/value#auto-value-parent/1.6.2\": {\n   \"pom\": \"sha256-J7ZAyCF59c/2IAnAtyAz2bxg9g6ZAqZoAidLf+N/yBw=\"\n  },\n  \"com/google/code/findbugs#jsr305/3.0.2\": {\n   \"jar\": \"sha256-dmrSoHg/JoeWLIrXTO7MOKKLn3Ki0IXuQ4t4E+ko0Mc=\",\n   \"pom\": \"sha256-GYidvfGyVLJgGl7mRbgUepdGRIgil2hMeYr+XWPXjf4=\"\n  },\n  \"com/google/code/gson#gson-parent/2.11.0\": {\n   \"pom\": \"sha256-issfO3Km8CaRasBzW62aqwKT1Sftt7NlMn3vE6k2e3o=\"\n  },\n  \"com/google/code/gson#gson/2.11.0\": {\n   \"jar\": \"sha256-V5KNblpu3rKr03cKj5W6RNzkXzsjt6ncKzCcWBVSp4s=\",\n   \"pom\": \"sha256-wOVHvqmYiI5uJcWIapDnYicryItSdTQ90sBd7Wyi42A=\"\n  },\n  \"com/google/crypto/tink#tink/1.7.0\": {\n   \"jar\": \"sha256-iJcKRWoIukxmsBsj5YRsoQlcwU5Uy0g2Pl0uFaEwcwg=\",\n   \"pom\": \"sha256-Ku41I3FfjyzRCyYDyNGeVhrHWDELfiyYU5RtLF57S/c=\"\n  },\n  \"com/google/dagger#dagger/2.28.3\": {\n   \"jar\": \"sha256-8d0j+K40qOkTZnI5kerQ1kmdGj6RY85VDCALAtdqhys=\",\n   \"pom\": \"sha256-JlupWajhPDoGEz8EtTkWnBAY2v/U0z9TxFOrTLOG9XA=\"\n  },\n  \"com/google/dagger#hilt-android-gradle-plugin/2.58\": {\n   \"jar\": \"sha256-un8zavJRIOQz+iEgvlhCrYoo9C9GOuD3F/Qmj+BVt7M=\",\n   \"pom\": \"sha256-/7LMGjbphgcDQMBdM6R4bkDaK++0y2Uu1AYezXr87GY=\"\n  },\n  \"com/google/dagger/hilt/android#com.google.dagger.hilt.android.gradle.plugin/2.58\": {\n   \"pom\": \"sha256-1l0XcxUOnNPX4mTeg/3GGu5xWjHFyf03otD1iv2s7M8=\"\n  },\n  \"com/google/devtools/ksp#com.google.devtools.ksp.gradle.plugin/2.3.6\": {\n   \"pom\": \"sha256-uneNdydnXBs9gWV3KRTfuGMfotCgSGdzBklCLGhZ6Yw=\"\n  },\n  \"com/google/devtools/ksp#symbol-processing-api/2.3.6\": {\n   \"jar\": \"sha256-Vd+DfVSxv9182+U72E3urjdGd+xKcpqa8VTT/uPAAjA=\",\n   \"module\": \"sha256-fTwGu1Wcn5r42q4q+DCS2VaXOBQrLpBlS2ut235974k=\",\n   \"pom\": \"sha256-YkfnkAfhEo350LIQnh7H657evU7rjleMDkMSlXeSsF0=\"\n  },\n  \"com/google/devtools/ksp#symbol-processing-common-deps/2.3.6\": {\n   \"jar\": \"sha256-917ObR1KiXj8nP4siVwyMjR926tcCEsE0M6Hax18lgQ=\",\n   \"module\": \"sha256-zC2bYeTXyh+cMHAaun4osQZS6/neLCgEr3rzNN57Tt0=\",\n   \"pom\": \"sha256-A29s8I+g0esgo/m+G33D1fOag42dbj/BJRrAxqRe07E=\"\n  },\n  \"com/google/devtools/ksp#symbol-processing-gradle-plugin/2.3.6\": {\n   \"jar\": \"sha256-FWx8hcLxO4TZ8fwDth/WWSN+vlncEtFK5Mb06z3ba0U=\",\n   \"module\": \"sha256-ew4oNjkHCPpx6DknXHfN9wxhPAqkHzWW/RTsFYVmTMg=\",\n   \"pom\": \"sha256-O71WDZRv1SD4LMzgZcSy6xTvSVHZ0Yx1LVGLsE4Zq0E=\"\n  },\n  \"com/google/errorprone#error_prone_annotations/2.18.0\": {\n   \"pom\": \"sha256-kgE1eX3MpZF7WlwBdkKljTQKTNG80S9W+JKlZjvXvdw=\"\n  },\n  \"com/google/errorprone#error_prone_annotations/2.27.0\": {\n   \"jar\": \"sha256-JMkjNyxY410LnxagKJKbua7cd1IYZ8J08r0HNd9bofU=\",\n   \"pom\": \"sha256-TKWjXWEjXhZUmsNG0eNFUc3w/ifoSqV+A8vrJV6k5do=\"\n  },\n  \"com/google/errorprone#error_prone_annotations/2.3.1\": {\n   \"pom\": \"sha256-PtzmtxG6No7+Frm3qssCFPvWSEFMublllTouftiagZo=\"\n  },\n  \"com/google/errorprone#error_prone_annotations/2.30.0\": {\n   \"jar\": \"sha256-FE86771uJ9rsVdN1OyxrE8Gv2vDPBIFs21ZFiO2S8b0=\",\n   \"pom\": \"sha256-9xOEnCOzSVPoVFZdzoqnlcrgwUFmEbcgwhRhMix5X4Y=\"\n  },\n  \"com/google/errorprone#error_prone_parent/2.18.0\": {\n   \"pom\": \"sha256-R/Iumce/RmOR3vFvg3eYXl07pvW7z2WFNkSAVRPhX60=\"\n  },\n  \"com/google/errorprone#error_prone_parent/2.27.0\": {\n   \"pom\": \"sha256-+oGCnQSVWd9pJ/nJpv1rvQn4tQ5tRzaucsgwC2w9dlQ=\"\n  },\n  \"com/google/errorprone#error_prone_parent/2.3.1\": {\n   \"pom\": \"sha256-dnUl2agRKc0IGWg4KYAzYye+QWKx4iUaGCkR2qczwSM=\"\n  },\n  \"com/google/errorprone#error_prone_parent/2.30.0\": {\n   \"pom\": \"sha256-Xog0zMDl7Qxy8wbCULUY5q0Q0HWpt7kQz2lcuh7gKi0=\"\n  },\n  \"com/google/flatbuffers#flatbuffers-java/1.12.0\": {\n   \"jar\": \"sha256-P4wIi03QSphYch8uFiUIyU2w3Yb5YeMG7mPvLtqHG/c=\",\n   \"pom\": \"sha256-yyJrr1RiYHcPIegVKmqoi6FSMNc591DfSA8qZo1D4Os=\"\n  },\n  \"com/google/guava#failureaccess/1.0.2\": {\n   \"jar\": \"sha256-io+Bz5s1nj9t+mkaHndphcBh7y8iPJssgHU+G0WOgGQ=\",\n   \"pom\": \"sha256-GevG9L207bs9B7bumU+Ea1TvKVWCqbVjRxn/qfMdA7I=\"\n  },\n  \"com/google/guava#guava-parent/26.0-android\": {\n   \"pom\": \"sha256-+GmKtGypls6InBr8jKTyXrisawNNyJjUWDdCNgAWzAQ=\"\n  },\n  \"com/google/guava#guava-parent/33.3.1-jre\": {\n   \"pom\": \"sha256-VUQdsn6Iad/v4FMFm99Hi9x+lVhWQr85HwAjNF/VYoc=\"\n  },\n  \"com/google/guava#guava/33.3.1-jre\": {\n   \"jar\": \"sha256-S/Dixa+ORSXJbo/eF6T3MH+X+EePEcTI41oOMpiuTpA=\",\n   \"module\": \"sha256-QYWMhHU/2WprfFESL8zvOVWMkcwIJk4IUGvPIODmNzM=\",\n   \"pom\": \"sha256-MTtn/BPrOwY07acVoSKZcfXem4GIvCgHYoFbg6J18ZM=\"\n  },\n  \"com/google/guava#listenablefuture/9999.0-empty-to-avoid-conflict-with-guava\": {\n   \"jar\": \"sha256-s3KgN9QjCqV/vv/e8w/WEj+cDC24XQrO0AyRuXTzP5k=\",\n   \"pom\": \"sha256-GNSx2yYVPU5VB5zh92ux/gXNuGLvmVSojLzE/zi4Z5s=\"\n  },\n  \"com/google/j2objc#j2objc-annotations/2.8\": {\n   \"pom\": \"sha256-N/h3mLGDhRE8kYv6nhJ2/lBzXvj6hJtYAMUZ1U2/Efg=\"\n  },\n  \"com/google/j2objc#j2objc-annotations/3.0.0\": {\n   \"jar\": \"sha256-iCQVc0Z93KRP/U10qgTCu/0Rv3wX4MNCyUyd56cKfGQ=\",\n   \"pom\": \"sha256-I7PQOeForYndEUaY5t1744P0osV3uId9gsc6ZRXnShc=\"\n  },\n  \"com/google/jimfs#jimfs-parent/1.1\": {\n   \"pom\": \"sha256-xxVVdR5X4O+RKHDorJYlrnglAqalucGcz4OyqX2LJr0=\"\n  },\n  \"com/google/jimfs#jimfs/1.1\": {\n   \"jar\": \"sha256-xIKOKNfAqTCvk4dRCzutp9qlwE18Jadce4sIHxwlfd0=\",\n   \"pom\": \"sha256-76huXNki8XtHL9/K5XI02NSsPhSLYlBzffzkVK96ekQ=\"\n  },\n  \"com/google/protobuf#protobuf-bom/3.25.5\": {\n   \"pom\": \"sha256-CA4phBcyOLUOBkwiav/7sbAjNSApXHkKf9PWrkWT8GM=\"\n  },\n  \"com/google/protobuf#protobuf-java-util/3.25.5\": {\n   \"jar\": \"sha256-2sxYssPS+o1L3cGsuIHnjWz3wTfdeLwdZ/aspzJDao0=\",\n   \"pom\": \"sha256-oJ0ZDqpqeWFrxfS1QE6UsMq1WYA6mMigkMQJmWL0H5I=\"\n  },\n  \"com/google/protobuf#protobuf-java/3.25.5\": {\n   \"jar\": \"sha256-hUAkf62eBrrvqPtF6zE4AtAZ9IXxQwDg+da1Vu2I51M=\",\n   \"pom\": \"sha256-51IDIVeno5vpvjeGaEB1RSpGzVhrKGWr0z5wdWikyK8=\"\n  },\n  \"com/google/protobuf#protobuf-parent/3.25.5\": {\n   \"pom\": \"sha256-ZMwOOtboX1rsj53Pk0HRN56VJTZP9T4j4W2NWCRnPvc=\"\n  },\n  \"com/googlecode/juniversalchardet#juniversalchardet/1.0.3\": {\n   \"jar\": \"sha256-dXv+kGGTuLZR553CbNZ9a1XQdwos37A4FZFQT3edSnY=\",\n   \"pom\": \"sha256-eEY5mzXHzWQqmzoADD4tYtBOs3pFR7aTPMixi8wvCGs=\"\n  },\n  \"com/ncorti/ktfmt/gradle#com.ncorti.ktfmt.gradle.gradle.plugin/0.25.0\": {\n   \"pom\": \"sha256-6UMcdrGCa/TUV0mE/Fo7fkZWpd3WrYQOxFz7Z6ACDio=\"\n  },\n  \"com/ncorti/ktfmt/gradle#plugin/0.25.0\": {\n   \"jar\": \"sha256-d5ySST49X3rhJ5Z5FhkZIoNpWYag5pYnnpVO4OisEkc=\",\n   \"module\": \"sha256-OrfvXBctMH3cZnXSk5zSB6eqDCcj2Nm0RfqqtBeFqAM=\",\n   \"pom\": \"sha256-hor4T+juXrqdQgGQmDCpnMpnuO0LohugEh/4K5mRjF0=\"\n  },\n  \"com/squareup#javapoet/1.10.0\": {\n   \"pom\": \"sha256-FpA0CiIiefLLrfNz6Igm+iD388w+wCUvNoGP7TJwGrE=\"\n  },\n  \"com/squareup#javapoet/1.13.0\": {\n   \"jar\": \"sha256-THUX6EinGzbQadErs79Gpw/UzaMQXYIrDtLhnAC2kpE=\",\n   \"pom\": \"sha256-VKNPqFAqRryQ79tJJiYAWR+oC/mjT1pMeYMRrsFsqXc=\"\n  },\n  \"com/squareup#javawriter/2.5.0\": {\n   \"jar\": \"sha256-/PsJ+w6gqpfTz+fqeSOYCBNI5GjxJrNgPLOAPyQBl/A=\",\n   \"pom\": \"sha256-4avX8RFs9eDFmUdpPiGJII7JQpayozlMlZ41EdOZp7A=\"\n  },\n  \"com/sun/activation#all/1.2.0\": {\n   \"pom\": \"sha256-HYUY46x1MqEE5Pe+d97zfJguUwcjxr2z1ncIzOKwwsQ=\"\n  },\n  \"com/sun/activation#all/1.2.1\": {\n   \"pom\": \"sha256-NgiDv2RIbs7xYbjygvZQNTbdGmcNU6Coccj7IBcOZ5U=\"\n  },\n  \"com/sun/activation#javax.activation/1.2.0\": {\n   \"jar\": \"sha256-mTMCsWzXBW8h53nMV30XWoELtJAO9zzY+/K1D5KLqc4=\",\n   \"pom\": \"sha256-+Hm26UWFTGkAsNvuHIOE16s95+FX/XrISTdAXEFtKl4=\"\n  },\n  \"com/sun/istack#istack-commons-runtime/3.0.8\": {\n   \"jar\": \"sha256-T/q7Br5FSgXkOY4gx3+itjCNS4jfvvfKMKdrW31VBe8=\",\n   \"pom\": \"sha256-wuAU00y4TtKH0GSYbEXDBaQSQiinM37M9sQh0U1wjxw=\"\n  },\n  \"com/sun/istack#istack-commons/3.0.8\": {\n   \"pom\": \"sha256-oPBRfoUS8PvMe4KVwS9lZqPQwthtZVY53GYu+MDH6+U=\"\n  },\n  \"com/sun/xml/bind#jaxb-bom-ext/2.3.2\": {\n   \"pom\": \"sha256-Gn3sKyfn4FV0TNuM8bkN70/Uc6zRuATv8JgTk1iVm9c=\"\n  },\n  \"com/sun/xml/bind/mvn#jaxb-parent/2.3.2\": {\n   \"pom\": \"sha256-IN1tw0q3VJrEDaHYLpIiLsQ0etDsDLEY72xXA77VOhg=\"\n  },\n  \"com/sun/xml/bind/mvn#jaxb-runtime-parent/2.3.2\": {\n   \"pom\": \"sha256-sk+NUfGEpovBuG1IwOPP7+shpE7eHF9zA8WK4EiFM+w=\"\n  },\n  \"com/sun/xml/bind/mvn#jaxb-txw-parent/2.3.2\": {\n   \"pom\": \"sha256-tV0++psVj0g6MOkseMy2APkzFHM9CJ66m3RDbwGzFKQ=\"\n  },\n  \"com/sun/xml/fastinfoset#FastInfoset/1.2.16\": {\n   \"jar\": \"sha256-BW86HhRECfIe0Wr8JoBfWOmiHz/OFUPELUAHGdJQxRE=\",\n   \"pom\": \"sha256-4UfSWKtuZpH3BZmpUkAObmx1WPjJwCjb4b4jF4MI6DA=\"\n  },\n  \"com/sun/xml/fastinfoset#fastinfoset-project/1.2.16\": {\n   \"pom\": \"sha256-kFgkJa3B9AtBNi2vuVFzkxIlrKpeeWINXmvVL2Rikro=\"\n  },\n  \"commons-codec#commons-codec/1.11\": {\n   \"jar\": \"sha256-5ZnVMY6Xqkj0ITaikn5t+k6Igd/w5sjjEJ3bv/Ude30=\",\n   \"pom\": \"sha256-wecUDR3qj981KLwePFRErAtUEpcxH0X5gGwhPsPumhA=\"\n  },\n  \"commons-io#commons-io/2.16.1\": {\n   \"jar\": \"sha256-9B97qs1xaJZEes6XWGIfYsHGsKkdiazuSI2ib8R3yE8=\",\n   \"pom\": \"sha256-V3fSkiUceJXASkxXAVaD7Ds1OhJIbJs+cXjpsLPDj/8=\"\n  },\n  \"commons-logging#commons-logging/1.2\": {\n   \"jar\": \"sha256-2t3qHqC+D1aXirMAa4rJKDSv7vvZt+TmMW/KV98PpjY=\",\n   \"pom\": \"sha256-yRq1qlcNhvb9B8wVjsa8LFAIBAKXLukXn+JBAHOfuyA=\"\n  },\n  \"io/github/java-diff-utils#java-diff-utils-parent/4.16\": {\n   \"pom\": \"sha256-pDRNpY5TqOjFe+Gekjut8ksn3UKwfebURtKmWaRWUJo=\"\n  },\n  \"io/github/java-diff-utils#java-diff-utils/4.16\": {\n   \"jar\": \"sha256-YgQDAw1nakon94CjrOx0ON7hsWUaHIBPprsRuwc5mm8=\",\n   \"pom\": \"sha256-Q01Zih4YExEThSNRYWJ6TE69YhNPcFRCjT4gKzLPOd4=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-gradle-plugin/1.23.8\": {\n   \"jar\": \"sha256-eDFQ5HCJQsXXcF/4t7kPn7WtAXnUatapsB0ZK3A3l1A=\",\n   \"module\": \"sha256-u3H5tXAGmJxcA5CbcCQuJeKEWLFVUiHjinxHrZdrLFQ=\",\n   \"pom\": \"sha256-DR7QsszZIm6wAtilqpiAsjRzYmLNf1kdg+g2pG2FiWI=\"\n  },\n  \"io/gitlab/arturbosch/detekt#io.gitlab.arturbosch.detekt.gradle.plugin/1.23.8\": {\n   \"pom\": \"sha256-a3qo6lJGZ5UjlEDJiB+ih1ysttNuzI7uu1kDVKYQoGc=\"\n  },\n  \"io/grpc#grpc-api/1.69.1\": {\n   \"jar\": \"sha256-qNPW3McfOrYT1miEIoK0iL3ZPT6ZoO9dyn7ub6c0woM=\",\n   \"pom\": \"sha256-vq8uR11cRdBjTU0yS/hNsqjWqSkilx5vfcJ+hRxCkH8=\"\n  },\n  \"io/grpc#grpc-context/1.69.1\": {\n   \"jar\": \"sha256-Re+VuMFYqLW906y2e55oLvJUFLsUj0iOyEdDirZHFdQ=\",\n   \"pom\": \"sha256-beKbzqslob0L4R7qhGjQ4/HAxHbQpTMhW0/eHIKEtXA=\"\n  },\n  \"io/grpc#grpc-core/1.69.1\": {\n   \"jar\": \"sha256-UTUsra7L+aSkqkLZP28fxyjx/QGwUWgDg+0J5WMf+9A=\",\n   \"pom\": \"sha256-loCY9KhFG7zT17iMaoq1t6dO+A397npgUvNS//eDssU=\"\n  },\n  \"io/grpc#grpc-inprocess/1.69.1\": {\n   \"jar\": \"sha256-t8asDjq/S41YJhDWMteUF7w9qBJU4aS89/Aejbe9Ve8=\",\n   \"pom\": \"sha256-/rswpc8jgjfQdaOSPok5khg9rxcaHrQ0rPEyEUpff4o=\"\n  },\n  \"io/grpc#grpc-netty/1.69.1\": {\n   \"jar\": \"sha256-Uqhu1m94kz6D0aP7cWKtFmdIlWTEVWNmt6NXnHAkpEc=\",\n   \"pom\": \"sha256-jwZlDMkUKMxRMRG/676r95mUGF1yREsC5d+so4vkNxQ=\"\n  },\n  \"io/grpc#grpc-protobuf-lite/1.69.1\": {\n   \"jar\": \"sha256-wp+Q+t88diD5NZokPAZ92FtzvXZbKPPZXfkQrC0zFVU=\",\n   \"pom\": \"sha256-Jj8fhE11bBXbX6uIaZZeM3IrP0/pB/4ciFqQ4EZGFzE=\"\n  },\n  \"io/grpc#grpc-protobuf/1.69.1\": {\n   \"jar\": \"sha256-TFLvlI+4mHo7qn1GujYre/MH3TxR8pJBzVxZg5igEN8=\",\n   \"pom\": \"sha256-v0ynbSrORNlpRxT7zz/XKcmO8uWGOrkgpIPAUqIvRCM=\"\n  },\n  \"io/grpc#grpc-stub/1.69.1\": {\n   \"jar\": \"sha256-45xjJz1TBS6+n2ONiumBdnNexWcyjZoXCSzdtvI5uMI=\",\n   \"pom\": \"sha256-9a2BV+xA2KI0djKWXWoD0YpIrUYl8y62SzenpHDOU7s=\"\n  },\n  \"io/grpc#grpc-util/1.69.1\": {\n   \"jar\": \"sha256-3Vl71nXqoELz41eGSNkFDIE8RZXF3mhp753btEkAYDE=\",\n   \"pom\": \"sha256-0g00aMt01WvlXtPUb2PKOO5LygkY2arXJ3pEj24HpbQ=\"\n  },\n  \"io/netty#netty-buffer/4.1.110.Final\": {\n   \"jar\": \"sha256-RtdOeRJarMBVwx8YFS/cXUpWmqjWAJEgPQuqgzlzrDw=\",\n   \"pom\": \"sha256-cQrBnMAc2A32vpo/qtPCIrShoy9LVRN74HtgmdXaNWI=\"\n  },\n  \"io/netty#netty-codec-http/4.1.110.Final\": {\n   \"jar\": \"sha256-3A1q9QVGMKcP8O81TyCqem5Gc4yfxWNu09T+d+OL1I0=\",\n   \"pom\": \"sha256-Ua6ZCvFKMh2209aIS5F7fUNj62Dd3A8Uk7GAIaFC560=\"\n  },\n  \"io/netty#netty-codec-http2/4.1.110.Final\": {\n   \"jar\": \"sha256-tUbHVEWkh7t7zVqUd5yuzOM1gs974xuLOfwOZbHuJvw=\",\n   \"pom\": \"sha256-KdL2wmw8yp/oOTZxcH/o75w+MQIKLf4GuCxCZJnCWDc=\"\n  },\n  \"io/netty#netty-codec-socks/4.1.110.Final\": {\n   \"jar\": \"sha256-l2BSo8m7KAvG2Z86KeZARnfPlYw94FsgUJPTjABriAw=\",\n   \"pom\": \"sha256-/+V7MWGR3U+WvuZsVwnBPL207KsIXAEMjbDGqoCav2w=\"\n  },\n  \"io/netty#netty-codec/4.1.110.Final\": {\n   \"jar\": \"sha256-nszOmo2Ce7jOhPnDGD/sWL0clqUQEM9xEpd0YDSvNwE=\",\n   \"pom\": \"sha256-qAa7U2uzI2Zbr/fNEiPysnKi1HF6tPmxI2EIbarl3z4=\"\n  },\n  \"io/netty#netty-common/4.1.110.Final\": {\n   \"jar\": \"sha256-mFHsZlSLng1BFkzpiUPN1LvjBfaN29JOrlLkUBoNexo=\",\n   \"pom\": \"sha256-fUF/UzUwTa4eoIoGWGA4yD/orYTB01uqFe0RkhzveSA=\"\n  },\n  \"io/netty#netty-handler-proxy/4.1.110.Final\": {\n   \"jar\": \"sha256-rVSrT+nEfvPnI9cSURJttT6NtUOHGtuer8lERlOe/1I=\",\n   \"pom\": \"sha256-xhPLTn4G9C76MduNiyoznti/QfAMRtONCQmkwGxlbc8=\"\n  },\n  \"io/netty#netty-handler/4.1.110.Final\": {\n   \"jar\": \"sha256-1aCNfeNkkS5ChZaN5NTM4/AdpLsEjVxpN+Xyrx+OFIo=\",\n   \"pom\": \"sha256-TUPBPRT1Y1oviw1QlNejHFCe4PUsck66DvMM/+PqFVU=\"\n  },\n  \"io/netty#netty-parent/4.1.110.Final\": {\n   \"pom\": \"sha256-aFra83Nmb8FUJ8gQ+K/zpP4ZSpfH7XS2nQfFSPDULxw=\"\n  },\n  \"io/netty#netty-resolver/4.1.110.Final\": {\n   \"jar\": \"sha256-oum0rnyqkvxb10fhHR3sINgbGPwAlZVUMCJErFxWznA=\",\n   \"pom\": \"sha256-ZV80GS6MdhizxaeeSI5NqjXe9BsNFtRfo2Ujw7TJ9kE=\"\n  },\n  \"io/netty#netty-transport-native-unix-common/4.1.110.Final\": {\n   \"jar\": \"sha256-UXF7t0cRQZUDkMZxOkSf2xBU0H5gc37n3acIN5bN7kg=\",\n   \"pom\": \"sha256-6hjOBMmpesDFH045exhSKf2VmX6QsRM5rc98UZRtU9g=\"\n  },\n  \"io/netty#netty-transport/4.1.110.Final\": {\n   \"jar\": \"sha256-pC3Wg5DKFLT/LUBiiglsdkhbStt8GWAtUokyGgZp5wQ=\",\n   \"pom\": \"sha256-MPXaDnZG8YQNYy+IYVyLnYIFSZ1oVZucRUezsEoGg80=\"\n  },\n  \"io/perfmark#perfmark-api/0.27.0\": {\n   \"jar\": \"sha256-x7R4UD7FJOVd8ZtCTUbSfIporrgBZk+t1PBptx9S0PY=\",\n   \"module\": \"sha256-n2xOamK43v0UFzrNt9spPQhjU7Ikkj7vYpP1gWGJPMo=\",\n   \"pom\": \"sha256-IsF1wsGCNmdjDITnMiV2f1lwSS2ObL/7gaZXXbpHLSY=\"\n  },\n  \"jakarta/activation#jakarta.activation-api/1.2.1\": {\n   \"jar\": \"sha256-iwoPUvqLBcVDGSGgY+2GbvqkHa3y46fuPhlh8rDZZFs=\",\n   \"pom\": \"sha256-QlhcsH3afyOqBOteCUAGGUSiRqZ609FpQvvlaf8DzTE=\"\n  },\n  \"jakarta/xml/bind#jakarta.xml.bind-api-parent/2.3.2\": {\n   \"pom\": \"sha256-FaVbfVN8n5lwrq0o0q+XwFn2X/YQL3a70p8SR92Kbfs=\"\n  },\n  \"jakarta/xml/bind#jakarta.xml.bind-api/2.3.2\": {\n   \"jar\": \"sha256-aRVjBAeb3u2fwK47OTifGbPMS6REO8gFCJlTlOrXQuo=\",\n   \"pom\": \"sha256-tTeziNurTMBpC50vsMdBJNZyUxc0VnrPblMTDqsTGtY=\"\n  },\n  \"javax/annotation#javax.annotation-api/1.3.2\": {\n   \"jar\": \"sha256-4EulGVvNVV3JVlD3zGFNFR5LzVLSmhC4qiGX86uJq5s=\",\n   \"pom\": \"sha256-RqSiUcpAbnjkhT16K66DKChEpJkoUUOe6aHyNxbwa5c=\"\n  },\n  \"javax/inject#javax.inject/1\": {\n   \"jar\": \"sha256-kcdwRKUMSBY2wy2Rb9ickRinIZU5BFLIEGUID5V95/8=\",\n   \"pom\": \"sha256-lD4SsQBieARjj6KFgFoKt4imgCZlMeZQkh6/5GIai/o=\"\n  },\n  \"net/java#jvnet-parent/1\": {\n   \"pom\": \"sha256-KBRAgRJo5l2eJms8yJgpfiFOBPCXQNA4bO60qJI9Y78=\"\n  },\n  \"net/java#jvnet-parent/3\": {\n   \"pom\": \"sha256-MPV4nvo53b+WCVqto/wSYMRWH68vcUaGcXyy3FBJR1o=\"\n  },\n  \"net/java/dev/jna#jna-platform/5.6.0\": {\n   \"jar\": \"sha256-ns6ovysbOZY5OdGLcEZO72DFCP7Ygg+dyroMNVGOq/c=\",\n   \"pom\": \"sha256-G+s1y0GE5skGp+Murr2FLdPaCiY5YumRNKuUWDI5Tig=\"\n  },\n  \"net/java/dev/jna#jna/5.6.0\": {\n   \"jar\": \"sha256-VVfiNaiqL5dm1dxgnWeUjyqIMsLXls6p7x1svgs7fq8=\",\n   \"pom\": \"sha256-X+gbAlWXjyRhbTexBgi3lJil8wc+HZsgONhzaoMfJgg=\"\n  },\n  \"net/sf/jopt-simple#jopt-simple/4.9\": {\n   \"jar\": \"sha256-JsWFbpVLX4ZNt28TuGkZtZxu7Pn9kwuWuqiIRia68vU=\",\n   \"pom\": \"sha256-evfi2LJLR5jwTCt9okyfvRt1V7TgF8IFRIFWWRYHkJI=\"\n  },\n  \"net/sf/kxml#kxml2/2.3.0\": {\n   \"jar\": \"sha256-8mTdn3mh/eEM5ezFMiHv8kvkyTMcgwt9UvLwintjPeI=\",\n   \"pom\": \"sha256-Mc5gb06VGJNimbsNJ8l4+mHhhf0d58mHT+lZpT40poU=\"\n  },\n  \"org/apache#apache/13\": {\n   \"pom\": \"sha256-/1E9sDYf1BI3vvR4SWi8FarkeNTsCpSW+BEHLMrzhB0=\"\n  },\n  \"org/apache#apache/18\": {\n   \"pom\": \"sha256-eDEwcoX9R1u8NrIK4454gvEcMVOx1ZMPhS1E7ajzPBc=\"\n  },\n  \"org/apache#apache/21\": {\n   \"pom\": \"sha256-rxDBCNoBTxfK+se1KytLWjocGCZfoq+XoyXZFDU3s4A=\"\n  },\n  \"org/apache#apache/23\": {\n   \"pom\": \"sha256-vBBiTgYj82V3+sVjnKKTbTJA7RUvttjVM6tNJwVDSRw=\"\n  },\n  \"org/apache#apache/31\": {\n   \"pom\": \"sha256-VV0MnqppwEKv+SSSe5OB6PgXQTbTVe6tRFIkRS5ikcw=\"\n  },\n  \"org/apache/commons#commons-compress/1.21\": {\n   \"jar\": \"sha256-auz9VFlyillWAc+gcljRMZcv/Dm0kutIvdWWV3ovJEo=\",\n   \"pom\": \"sha256-Z1uwI8m+7d4yMpSZebl0Kl/qlGKApVobRi1Mp4AQiM0=\"\n  },\n  \"org/apache/commons#commons-parent/34\": {\n   \"pom\": \"sha256-Oi5p0G1kHR87KTEm3J4uTqZWO/jDbIfgq2+kKS0Et5w=\"\n  },\n  \"org/apache/commons#commons-parent/42\": {\n   \"pom\": \"sha256-zTE0lMZwtIPsJWlyrxaYszDlmPgHACNU63ZUefYEsJw=\"\n  },\n  \"org/apache/commons#commons-parent/52\": {\n   \"pom\": \"sha256-ddvo806Y5MP/QtquSi+etMvNO18QR9VEYKzpBtu0UC4=\"\n  },\n  \"org/apache/commons#commons-parent/69\": {\n   \"pom\": \"sha256-1Q2pw5vcqCPWGNG0oDtz8ZZJf8uGFv0NpyfIYjWSqbs=\"\n  },\n  \"org/apache/httpcomponents#httpclient/4.5.14\": {\n   \"jar\": \"sha256-yLx+HFGm1M5y9A0uu6vxxLaL/nbnMhBLBDgbSTR46dY=\",\n   \"pom\": \"sha256-8YNVr0z4CopO8E69dCpH6Qp+rwgMclsgldvE/F2977c=\"\n  },\n  \"org/apache/httpcomponents#httpcomponents-client/4.5.14\": {\n   \"pom\": \"sha256-W60d5PEBRHZZ+J0ImGjMutZKaMxQPS1lQQtR9pBKoGE=\"\n  },\n  \"org/apache/httpcomponents#httpcomponents-client/4.5.6\": {\n   \"pom\": \"sha256-sEK0HyOR7bANNff05Qmu0hI2SMHSRs5Y0Pe5Bcn+H3M=\"\n  },\n  \"org/apache/httpcomponents#httpcomponents-core/4.4.16\": {\n   \"pom\": \"sha256-8tdaLC1COtGFOb8hZW1W+IpAkZRKZi/K8VnVrig9t/c=\"\n  },\n  \"org/apache/httpcomponents#httpcomponents-parent/10\": {\n   \"pom\": \"sha256-yq+WfZSvshdT82CCxghiBr0fSIJf9ZaTLM66crZdOfo=\"\n  },\n  \"org/apache/httpcomponents#httpcomponents-parent/11\": {\n   \"pom\": \"sha256-qQH4exFcVQcMfuQ+//Y+IOewLTCvJEOuKSvx9OUy06o=\"\n  },\n  \"org/apache/httpcomponents#httpcore/4.4.16\": {\n   \"jar\": \"sha256-bJs90UKgncRo4jrTmq1vdaDyuFElEERp8CblKkdORk8=\",\n   \"pom\": \"sha256-PLrYSbNdrP5s7DGtraLGI8AmwyYRQbDSbux+OZxs1/o=\"\n  },\n  \"org/apache/httpcomponents#httpmime/4.5.6\": {\n   \"jar\": \"sha256-CysRAsGNPH4Fp3IUubdQGm9gVhdK5WBODiVndu2nVT4=\",\n   \"pom\": \"sha256-37/W/+KnhMqYF8RjZap/ileDILgFveOdb1WgsJ2KqMo=\"\n  },\n  \"org/bitbucket/b_c#jose4j/0.9.5\": {\n   \"jar\": \"sha256-gI+zFm8+Z9rZgRwzECmrFoEkL9Urc1vD8z8oEWf8xy4=\",\n   \"pom\": \"sha256-utAkGAobRpy9lOXy2xKEG8rFRD2VRWB/Zzz95nfB2HI=\"\n  },\n  \"org/bouncycastle#bcpkix-jdk18on/1.79\": {\n   \"jar\": \"sha256-NjmiTd+bpLfroGWbRHcOkeuoFkIYiOVx8oWq3v5TLNY=\",\n   \"pom\": \"sha256-NeSfQTTeKsMmw6UKJXYsu021bzgC+j9zDMhbZTrQmHs=\"\n  },\n  \"org/bouncycastle#bcprov-jdk18on/1.79\": {\n   \"jar\": \"sha256-DYHswxJFNrU5vOmqP+liG3+Eyc7jcbY1pbMceLeasdo=\",\n   \"pom\": \"sha256-2PGgaxSddG6dmN5U4veqmy62E/s1ymfYrjls6qxmHuQ=\"\n  },\n  \"org/bouncycastle#bcutil-jdk18on/1.79\": {\n   \"jar\": \"sha256-xwuIraWJOMvC8AXUAykFQHi8+hFJ5v/APpJC62qyGDY=\",\n   \"pom\": \"sha256-4kwftM8WBUBaaYjp5NbksuH0OT/HOompRSrmJe4xHQI=\"\n  },\n  \"org/checkerframework#checker-qual/2.5.8\": {\n   \"pom\": \"sha256-M6xqDxNBrpZkfH1EZfSqPST+l9Jpe87izq5vyLXvLDw=\"\n  },\n  \"org/checkerframework#checker-qual/3.43.0\": {\n   \"jar\": \"sha256-P7wumPBYVMPfFt+auqlVuRsVs+ysM2IyCO1kJGQO8PY=\",\n   \"module\": \"sha256-+BYzJyRauGJVMpSMcqkwVIzZfzTWw/6GD6auxaNNebQ=\",\n   \"pom\": \"sha256-kxO/U7Pv2KrKJm7qi5bjB5drZcCxZRDMbwIxn7rr7UM=\"\n  },\n  \"org/codehaus/mojo#animal-sniffer-annotations/1.24\": {\n   \"jar\": \"sha256-xyDm5by+ay9I3tdaR7zNt2Pu3nnRQzAQLg01Lj2J7ZI=\",\n   \"pom\": \"sha256-iEhPYKatQjipf+us8rMz6eCMF4uPGAoFo+2/9KOKg24=\"\n  },\n  \"org/codehaus/mojo#animal-sniffer-parent/1.24\": {\n   \"pom\": \"sha256-Sd2rQ8g2HcLvDB/4fLWQ+nIxcCq59i4m1RLcGKHxzQQ=\"\n  },\n  \"org/codehaus/mojo#mojo-parent/84\": {\n   \"pom\": \"sha256-L+UQYYsvYPzV8vuCvEssLDRASNdPML5xn8uGgp7orDA=\"\n  },\n  \"org/eclipse/ee4j#project/1.0.2\": {\n   \"pom\": \"sha256-dJWgenl+iOQ8O8GodCG9ix/FXjIpH6GOTjLYAx3chz8=\"\n  },\n  \"org/eclipse/ee4j#project/1.0.5\": {\n   \"pom\": \"sha256-kWtHlNjYIgpZo/32pk2+eUrrIzleiIuBrjaptaLFkaY=\"\n  },\n  \"org/glassfish/jaxb#jaxb-bom/2.3.2\": {\n   \"pom\": \"sha256-oQGLtUZ47Z9ayy96QITjhf9RAgH06dv1913GpnX2a+c=\"\n  },\n  \"org/glassfish/jaxb#jaxb-runtime/2.3.2\": {\n   \"jar\": \"sha256-5uCh6J+2/3hieeagCC1c71LcLr5nBT0EGABzdlK0/Rs=\",\n   \"pom\": \"sha256-lEilrX+mimCD375PQsjIPggrkgKhBUAfxo6UTCZUizQ=\"\n  },\n  \"org/glassfish/jaxb#txw2/2.3.2\": {\n   \"jar\": \"sha256-SmqfSDOI1GG4GqmijGhbi3TAWXmTvxiEsE7dvKlfSP4=\",\n   \"pom\": \"sha256-p53QAvsDgYP/KGomNb4uaMEDuH4OZHF9jUS/0Bf9M+o=\"\n  },\n  \"org/gradle/toolchains#foojay-resolver/1.0.0\": {\n   \"jar\": \"sha256-eLhqR9/fdpfJvRXaeJg/2A2nJH1uAvwQa98H4DiLYKg=\",\n   \"module\": \"sha256-YZDPDkLmZMEeGsCnhWmasCtUnOo0OSxnnzbYosVQ/Lk=\",\n   \"pom\": \"sha256-m8SLSeQi2e2rw5asGNiwQd/CIhLX+ujjVmfShdSBApo=\"\n  },\n  \"org/gradle/toolchains/foojay-resolver-convention#org.gradle.toolchains.foojay-resolver-convention.gradle.plugin/1.0.0\": {\n   \"pom\": \"sha256-8TMkmhh1Suah0nAdANhJsa+6ewaD3bX8GxinAHHOwvo=\"\n  },\n  \"org/jdom#jdom2/2.0.6\": {\n   \"jar\": \"sha256-E0XxG6YG0VYD1nQFUajCGUfAIVZAdw7GcnH+eL6pfPU=\",\n   \"pom\": \"sha256-R7I6ef4za3QbgkNMbgSdaBZSVuQF51wQkh/XL6imXY0=\"\n  },\n  \"org/jetbrains#annotations/13.0\": {\n   \"jar\": \"sha256-rOKhDcji1f00kl7KwD5JiLLA+FFlDJS4zvSbob0RFHg=\",\n   \"pom\": \"sha256-llrrK+3/NpgZvd4b96CzuJuCR91pyIuGN112Fju4w5c=\"\n  },\n  \"org/jetbrains/kotlin#abi-tools-api/2.3.10\": {\n   \"jar\": \"sha256-E2nLVCrmR6nVVJ5thkkh6g+GApdJWRmXteWqFhyXGIs=\",\n   \"pom\": \"sha256-x12MiniT5DijbBZeA1I+uHRDZ6wNaV4sdrZ48LAjnE8=\"\n  },\n  \"org/jetbrains/kotlin#fus-statistics-gradle-plugin/2.3.10\": {\n   \"module\": \"sha256-S6VmEYmH5t40LreG9cBlq/KOD1GxChn5urHQnQ8BgAg=\",\n   \"pom\": \"sha256-Lq0FKRNzQaRlNuKfJLUrjci875dxQRBZvIeXikqlcFI=\"\n  },\n  \"org/jetbrains/kotlin#fus-statistics-gradle-plugin/2.3.10/gradle813\": {\n   \"jar\": \"sha256-aR4tKmjLngoIjxlX3nz7pWjiSRh2mUhhI9YHX9/znRc=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-bom/2.2.0\": {\n   \"pom\": \"sha256-RT+GUn7TuIQqnwFECFD5Mlo6iGzXNr73MFp1NSt52h0=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-build-statistics/2.3.10\": {\n   \"jar\": \"sha256-rATm9NehsNONNMb//SM90SpmzikVjyX68Ax17KzUQ7w=\",\n   \"pom\": \"sha256-D412s88lmTbUu/J7bZOrZtxor4wBaf4fXhMuQY9Ano0=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-build-tools-api/2.3.10\": {\n   \"jar\": \"sha256-EYZyC5EGhN+drZy5YBXM8ZF27FZVzrCbAfvEUjC9H2Q=\",\n   \"pom\": \"sha256-3pBa7tBWHZduxSEU8vPk5mls05Mg9DqxFvF/79c9U8I=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-compiler-runner/2.3.10\": {\n   \"jar\": \"sha256-CpGS+4AlHMrRzb8sq6KEiOjUaGnS5eSVfKF5wnwKTuA=\",\n   \"pom\": \"sha256-4CtYvQDZCjExN9L18ZIZ2tt3FXVebd1t74mYpa/RgCc=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-daemon-client/2.3.10\": {\n   \"jar\": \"sha256-0hpvd9mAOmFebRUIVzd+EKnox80Grm4cHrmZIP2ji5M=\",\n   \"pom\": \"sha256-sOpstyMer+j7oN+zf8MZhJkED3TE00EmPtq2yE0Z2sA=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-gradle-plugin-annotations/2.3.10\": {\n   \"jar\": \"sha256-yz8A2nIhxzuf45W3HFxXnuMmJgxXOjjCGd6t7vUkdks=\",\n   \"pom\": \"sha256-W4tTzgpFIWup6yZRWQfmdFi+cp+OqvXBlS9ssuCfcac=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-gradle-plugin-api/2.3.10\": {\n   \"module\": \"sha256-p7RKyP2FrLVZaQdkrIl8Haz7eUlefoXmY6IMRhECXa4=\",\n   \"pom\": \"sha256-qty9XFeR9sVMi0IgpGAvxFBOjrpsjs+kumG0AkRuutI=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-gradle-plugin-api/2.3.10/gradle813\": {\n   \"jar\": \"sha256-KSEBw7xFdm5gKUIbOJkyJEbM79WrzQJ4hj9SENNJSSI=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-gradle-plugin-idea-proto/2.3.10\": {\n   \"jar\": \"sha256-aBZWUlkS6+BkS2uQraFIPBOcXpMNT46kiCDb4YHP+9o=\",\n   \"pom\": \"sha256-AjxvG3UBfGU0IsaaeUiTaR5B7+jC47nxi1uMHsTQhIo=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-gradle-plugin-idea/2.3.10\": {\n   \"jar\": \"sha256-lS5zlI4qINOYwmuHprtwzPZkGPuvFSfDUVsYjqnUvWA=\",\n   \"module\": \"sha256-nKavltr37lkKDlnJ7+HfkBmrAegPRHZ2udyo/F/q9LU=\",\n   \"pom\": \"sha256-nAGu0VT697j/vvhlQZ8XFkBj9reptVgTJppQxrSOlxI=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-gradle-plugin/2.3.10\": {\n   \"module\": \"sha256-+zIU9bn6DZ/MvLxt8tG71VHloQ/lvFh7dsN03xL0mTI=\",\n   \"pom\": \"sha256-5fmNcJdy/v3XWP0KOcT3x1ny8BcdGqDMDMK58Ltv45U=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-gradle-plugin/2.3.10/gradle813\": {\n   \"jar\": \"sha256-uBYPRY9Qkj3wP9AbIE2V6A9jxK2xQPiUnOULQ9eUveM=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-gradle-plugins-bom/2.3.10\": {\n   \"module\": \"sha256-0WC3nI5dfs7/uQbYx0RhUO3J8bQ8ZXylBcqvH/UCzuk=\",\n   \"pom\": \"sha256-Kk97RKPQrHrsfb9bGqiNuTi/VC9zUVYfsdO1z4G2DQU=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-klib-commonizer-api/2.3.10\": {\n   \"jar\": \"sha256-K04jpJa9pG8kPL+6pm6xxMkBtTHB/uzGnnnxvET1hpM=\",\n   \"pom\": \"sha256-zKwUWegEbV4FD1MJ4qi2Zk5IBrVaXUBFCFWI1+0ZOkk=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-native-utils/2.3.10\": {\n   \"jar\": \"sha256-kgDRaWeyIar2AU5OsCVzFnmKR7soFnI9PZxkaFPXLhM=\",\n   \"pom\": \"sha256-inkaCCnJ8U8F7jxRq2OQWxdofTIfW7p8Vx446+7kdC0=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-reflect/2.0.21\": {\n   \"jar\": \"sha256-OtL8rQwJ3cCSLeurRETWEhRLe0Zbdai7dYfiDd+v15k=\",\n   \"pom\": \"sha256-Aqt66rA8aPQBAwJuXpwnc2DLw2CBilsuNrmjqdjosEk=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-serialization/2.3.10\": {\n   \"module\": \"sha256-y4SHb+infUQ6gbhjVDIUuMT3e9DXcFnNO4m5Nww8MZ8=\",\n   \"pom\": \"sha256-BdRcpwksNSXEYlGEktwffhXwrhzCO8BySexKQhscdNU=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-serialization/2.3.10/gradle813\": {\n   \"jar\": \"sha256-36XL+qo7OQVI6AXeABvJQBGS4z4CJRLRdsFA6raQOwQ=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib-jdk7/2.2.0\": {\n   \"jar\": \"sha256-DRC8DUK4YF8jYpo/MeonwZzbyp3N9PU/bSLNY2aDbRg=\",\n   \"pom\": \"sha256-lcIYnDXve/xIlRwyrXCEeyHzgJ0m9dCnbiNXCHmYjDA=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib-jdk8/2.2.0\": {\n   \"jar\": \"sha256-rcFmSNu881sNEOfsMBw110bRwv5GDGBqulnxKxF8+bA=\",\n   \"pom\": \"sha256-I00G/b3CncvAdEfijEomq6uVmdXD2qPZKjTmrt6iNqY=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib/2.0.21\": {\n   \"jar\": \"sha256-8xzFPxBafkjAk2g7vVQ3Vh0SM5IFE3dLRwgFZBvtvAk=\",\n   \"module\": \"sha256-gf1tGBASSH7jJG7/TiustktYxG5bWqcpcaTd8b0VQe0=\",\n   \"pom\": \"sha256-/LraTNLp85ZYKTVw72E3UjMdtp/R2tHKuqYFSEA+F9o=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-tooling-core/2.3.10\": {\n   \"jar\": \"sha256-NnFCeBKZvA+RIMHe7A5ik0oa+ep/AaqpxaU1TcXY19k=\",\n   \"pom\": \"sha256-5hhz7dWo3QMaa6l1nAXRVpBlnmEuPUjB7RInN9q0SYY=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-util-io/2.3.10\": {\n   \"jar\": \"sha256-4Bw3Dn83/E0Ck7lXEUH1NwnlEJ4o7J1QgnMtOCDWfy4=\",\n   \"pom\": \"sha256-LZfqo2d6QEoFKTIaZ4rrLRzDn5EwwRSamNFm3TL8K3Q=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-util-klib-metadata/2.3.10\": {\n   \"jar\": \"sha256-1uBU2zAOXqeyCOjjZoPNbE10dNiLaRaVFbew69e1DIs=\",\n   \"pom\": \"sha256-eT/ktDjwB3hoJDXOGxvPW6feZSR6JIuZpe6pmSov5aA=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-util-klib/2.3.10\": {\n   \"jar\": \"sha256-5b3gT8jS8h1wWfBcp01UtYkKC4zCqJD/IgjChB7HZfg=\",\n   \"pom\": \"sha256-SzSk2Br5xUmg8BOj6gAQeM5EotBEZweUakkhBbaSkx0=\"\n  },\n  \"org/jetbrains/kotlin/android#org.jetbrains.kotlin.android.gradle.plugin/2.3.10\": {\n   \"pom\": \"sha256-uzw8gaCzFbyJg60kJ7M2GY8VfuNUmvku/LSm8T+Ry60=\"\n  },\n  \"org/jetbrains/kotlin/jvm#org.jetbrains.kotlin.jvm.gradle.plugin/2.3.10\": {\n   \"pom\": \"sha256-0OzGk5CQFi+LIGtt4uumJVZzzJ0EOico5yUWmt07NP0=\"\n  },\n  \"org/jetbrains/kotlin/plugin/serialization#org.jetbrains.kotlin.plugin.serialization.gradle.plugin/2.3.10\": {\n   \"pom\": \"sha256-OXldry8vLwxty3VxvpVp232e1jJnGMKRmM3cc6Bing4=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.8.0\": {\n   \"pom\": \"sha256-Ejnp2+E5fNWXE0KVayURvDrOe2QYQuQ3KgiNz6i5rVU=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.9.0\": {\n   \"pom\": \"sha256-vqVRHpAB8sWTq1CA3xMbIZq14ghcxZec5YPqzUlG/Xg=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-core-jvm/1.8.0\": {\n   \"jar\": \"sha256-mGCQahk3SQv187BtLw4Q70UeZblbJp8i2vaKPR9QZcU=\",\n   \"module\": \"sha256-/2oi2kAECTh1HbCuIRd+dlF9vxJqdnlvVCZye/dsEig=\",\n   \"pom\": \"sha256-pWM6vVNGfOuRYi2B8umCCAh3FF4LduG3V4hxVDSIXQs=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-core-jvm/1.9.0\": {\n   \"jar\": \"sha256-rYnCiSI15nDyItgZyz2BGIFDyxmgW1nfmImuQmn1xwo=\",\n   \"module\": \"sha256-syGomeQNPONFcHqiz9qZg60NzGn+p0qbi/kGoWwc+Kk=\",\n   \"pom\": \"sha256-GcSImUGzqgmL1XzGTwL5razGVNVxoSqVbeS1uxSMZJk=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-core/1.9.0\": {\n   \"module\": \"sha256-rVNANKlTtOEsvuuHTGat+LHKFN8V/g0uZUeqNOht/so=\",\n   \"pom\": \"sha256-dw8nk9BeKwJ7nHmZOOwdLU7xQc5YGceAwyw5lcrbCkc=\"\n  },\n  \"org/junit#junit-bom/5.10.2\": {\n   \"module\": \"sha256-3iOxFLPkEZqP5usXvtWjhSgWaYus5nBxV51tkn67CAo=\",\n   \"pom\": \"sha256-Fp3ZBKSw9lIM/+ZYzGIpK/6fPBSpifqSEgckzeQ6mWg=\"\n  },\n  \"org/jvnet/staxex#stax-ex/1.8.1\": {\n   \"jar\": \"sha256-IFIlSQVunlCqNe8LRFouR6U9Br4LCpRn1wTiSD/7BJo=\",\n   \"pom\": \"sha256-j8hPNs5tps6MiTtlOBmaf2mmmgcG2bF6PuajoJRS7tY=\"\n  },\n  \"org/ow2#ow2/1.5.1\": {\n   \"pom\": \"sha256-Mh3bt+5v5PU96mtM1tt0FU1r+kI5HB92OzYbn0hazwU=\"\n  },\n  \"org/ow2/asm#asm-analysis/9.8\": {\n   \"jar\": \"sha256-5kBzL7zTxicZJaUE8SXjg4Roj037v5LIYi387g0J7bk=\",\n   \"pom\": \"sha256-xXR+JccuGwfVJjx1x4rWGmJt0kWPr8r8I/gdMlPuQu0=\"\n  },\n  \"org/ow2/asm#asm-commons/9.8\": {\n   \"jar\": \"sha256-MwGhwctMWfzFKSZI2sHXxa7UwPBn376IhzuM3+d0BPQ=\",\n   \"pom\": \"sha256-95PnjwH3A3F9CUcuVs3yEv4piXDIguIRbo5Un7bRQMI=\"\n  },\n  \"org/ow2/asm#asm-tree/9.8\": {\n   \"jar\": \"sha256-FLeIDLfIXu0QHicQQy/D/7gydVMqaolNxMQJXUmtWfE=\",\n   \"pom\": \"sha256-cUnn+qDhkSlvh5ru2SCciULTmPBpjSzKGpxijy4qj3c=\"\n  },\n  \"org/ow2/asm#asm-util/9.8\": {\n   \"jar\": \"sha256-i6BGDsso/Q4pgOXz7zQzpROkV7wHf4GlO9x1tYegjRU=\",\n   \"pom\": \"sha256-JNCXDhceKRe4Oo8PBdUKHNtcguUIVVtS28ydM2HE8Ow=\"\n  },\n  \"org/ow2/asm#asm/9.8\": {\n   \"jar\": \"sha256-h26raoPa7K1cpn65/KuwY8l7WuuM8fynqYns3hdSIFE=\",\n   \"pom\": \"sha256-wTZ8O7OD12Gef3l+ON91E4hfLu8ErntZCPaCImV7W6o=\"\n  },\n  \"org/slf4j#slf4j-api/1.7.30\": {\n   \"jar\": \"sha256-zboHlk0btAoHYUhcax6ML4/Z6x0ZxTkorA1/lRAQXFc=\",\n   \"pom\": \"sha256-fgdHdR6bZ+Gdy1IG8E6iLMA9JQxCJCZALq3QNRPywxQ=\"\n  },\n  \"org/slf4j#slf4j-parent/1.7.30\": {\n   \"pom\": \"sha256-EWR5VuSKDFv7OsM/bafoPzQQAraFfv0zWlBbaHvjS3U=\"\n  },\n  \"org/sonatype/oss#oss-parent/7\": {\n   \"pom\": \"sha256-tR+IZ8kranIkmVV/w6H96ne9+e9XRyL+kM5DailVlFQ=\"\n  },\n  \"org/sonatype/oss#oss-parent/9\": {\n   \"pom\": \"sha256-+0AmX5glSCEv+C42LllzKyGH7G8NgBgohcFO8fmCgno=\"\n  },\n  \"org/tensorflow#tensorflow-lite-metadata/0.2.0\": {\n   \"jar\": \"sha256-6fGLikHwF+kDPLDthciiuiMHKSzf4l6uNlkj56MdKnA=\",\n   \"pom\": \"sha256-D+MTJug7diLLzZx11GeykfAf/jzG4+dmUawFocHHo2A=\"\n  }\n },\n \"https://repo.maven.apache.org/maven2\": {\n  \"com/facebook#ktfmt/0.59\": {\n   \"jar\": \"sha256-MmXtnOos7DnR4qu5A02SBjcqXkR6cnR5M96+YyG3RB4=\",\n   \"module\": \"sha256-tBXyQQXgg21c1jfrbouup0DdHNnoWEVZG3ZYpbncOrU=\",\n   \"pom\": \"sha256-i+wlctivol5UhScjNo93boVBXBIBAQbzkAuhxnftVqY=\"\n  },\n  \"com/facebook#ktfmt/0.59/with-dependencies\": {\n   \"jar\": \"sha256-pJMnHBNwdENqEz1y6eMj+RYEyX4MQFVWKew5WY7+rVQ=\"\n  },\n  \"com/google/android#annotations/4.1.1.4\": {\n   \"jar\": \"sha256-unNOHoTAnWFa9qCdMwNLTwRC+Hct7BIO+zdthqVlrhU=\",\n   \"pom\": \"sha256-5LtUdTw2onoOXXAVSlA0/t2P6sQoIpUDS/1IPWx6rng=\"\n  },\n  \"com/google/api/grpc#proto-google-common-protos/2.17.0\": {\n   \"jar\": \"sha256-TvH+DDJ/wVIdHXU7CxxKh1pUvRTr3tOv/wyjlTILbqk=\",\n   \"pom\": \"sha256-PwKBU6WFxZ9Viz5Dp8mAmmAai7XpEGHWxlj/+iTLjiY=\"\n  },\n  \"com/google/api/grpc#proto-google-common-protos/2.48.0\": {\n   \"jar\": \"sha256-Q+x4B0WaqkAS6Dihvk7y1ZDPIzMF2mCvW1TwjsjPIwI=\",\n   \"pom\": \"sha256-ReuSeyJoX8Eb+rac5q07Q59y2Bajp95GvSP07Rm/icM=\"\n  },\n  \"com/google/auto#auto-common/1.2.1\": {\n   \"jar\": \"sha256-9D8p/ipuuvBLJZjN7sMqTjRtSalATpkPX8GcGfOijQ4=\",\n   \"pom\": \"sha256-E7S1AGKUn4sTQ5J8WBU207sFG4r+pQmqb5AvTeKLwbI=\"\n  },\n  \"com/google/auto/service#auto-service-aggregator/1.1.1\": {\n   \"pom\": \"sha256-4tw+JjpdsMyBcvsuubzSMqzs/clugXED6rPI2fGQDGo=\"\n  },\n  \"com/google/auto/service#auto-service-annotations/1.1.1\": {\n   \"jar\": \"sha256-Fqdt0AomUFaER/XW46niyAnZpCNn1WtFIVz7iXMfTSQ=\",\n   \"pom\": \"sha256-Fln9CiXOqfqav7GN6517GAzOOs6rFv+eYlRhVn5Eot8=\"\n  },\n  \"com/google/auto/service#auto-service/1.1.1\": {\n   \"jar\": \"sha256-H0j0UVA+Yj2rp9ntNozKD4Hh44FWU6RWARPhLAEp69U=\",\n   \"pom\": \"sha256-DFPC+HlJs76PHIxnu3bAjtT0vrjbqkdcMrpZPUYXYxc=\"\n  },\n  \"com/google/code/findbugs#jsr305/3.0.2\": {\n   \"jar\": \"sha256-dmrSoHg/JoeWLIrXTO7MOKKLn3Ki0IXuQ4t4E+ko0Mc=\",\n   \"pom\": \"sha256-GYidvfGyVLJgGl7mRbgUepdGRIgil2hMeYr+XWPXjf4=\"\n  },\n  \"com/google/code/gson#gson-parent/2.10.1\": {\n   \"pom\": \"sha256-QkjgiCQmxhUYI4XWCGw+8yYudplXGJ4pMGKAuFSCuDM=\"\n  },\n  \"com/google/code/gson#gson-parent/2.11.0\": {\n   \"pom\": \"sha256-issfO3Km8CaRasBzW62aqwKT1Sftt7NlMn3vE6k2e3o=\"\n  },\n  \"com/google/code/gson#gson-parent/2.8.9\": {\n   \"pom\": \"sha256-sW4CbmNCfBlyrQ/GhwPsN5sVduQRuknDL6mjGrC7z/s=\"\n  },\n  \"com/google/code/gson#gson/2.10.1\": {\n   \"jar\": \"sha256-QkHBSncnw0/uplB+yAExij1KkPBw5FJWgQefuU7kxZM=\",\n   \"pom\": \"sha256-0rEVY09cCF20ucn/wmWOieIx/b++IkISGhzZXU2Ujdc=\"\n  },\n  \"com/google/code/gson#gson/2.11.0\": {\n   \"jar\": \"sha256-V5KNblpu3rKr03cKj5W6RNzkXzsjt6ncKzCcWBVSp4s=\",\n   \"pom\": \"sha256-wOVHvqmYiI5uJcWIapDnYicryItSdTQ90sBd7Wyi42A=\"\n  },\n  \"com/google/code/gson#gson/2.8.9\": {\n   \"jar\": \"sha256-05mSkYVd5JXJTHQ3YbirUXbP6r4oGlqw2OjUUyb9cD4=\",\n   \"pom\": \"sha256-r97W5qaQ+/OtSuZa2jl/CpCl9jCzA9G3QbnJeSb91N4=\"\n  },\n  \"com/google/crypto/tink#tink/1.7.0\": {\n   \"jar\": \"sha256-iJcKRWoIukxmsBsj5YRsoQlcwU5Uy0g2Pl0uFaEwcwg=\",\n   \"pom\": \"sha256-Ku41I3FfjyzRCyYDyNGeVhrHWDELfiyYU5RtLF57S/c=\"\n  },\n  \"com/google/dagger#dagger-compiler/2.58\": {\n   \"jar\": \"sha256-FyqvLs7qchk8D2BpW8I4NXRxmFx/VJoD4kZgu1pjdfk=\",\n   \"pom\": \"sha256-jllGOdoMV7fzPaOXRuKZg6mgsRK9vO7XVxdK+Bd18ck=\"\n  },\n  \"com/google/dagger#dagger-lint-aar/2.58\": {\n   \"pom\": \"sha256-iZNhbn/AwNJuZxCU2+aAQxs0krwUPgbq9FiQQwKAcnk=\"\n  },\n  \"com/google/dagger#dagger-spi/2.58\": {\n   \"jar\": \"sha256-osA2h9lNAHzsae/mY/gyRcN2zFc+QMespxB/M1VnpKg=\",\n   \"pom\": \"sha256-yzcxB37s/3MPdF2nQi4C7tN9jSUq2wq4JZmVRqin0w4=\"\n  },\n  \"com/google/dagger#dagger/2.48\": {\n   \"jar\": \"sha256-H6Im0rSgLMgJUPpNSaSiNcyOztSZtYH8NYpVRGqD9Xk=\",\n   \"pom\": \"sha256-df10hXPlgHWxMG9/rhnPWwLAsIUJCPdlExsJKhFWqUQ=\"\n  },\n  \"com/google/dagger#dagger/2.58\": {\n   \"jar\": \"sha256-fHAeJFunR+aSXKoZOtf1EMbXVW1JYLMpmeYA0FadxfU=\",\n   \"pom\": \"sha256-+liss4wmYmPwSuxAgaDQAn8zx/BWOZFOiorNNwj4xvM=\"\n  },\n  \"com/google/dagger#hilt-android-compiler/2.58\": {\n   \"jar\": \"sha256-n2kQUQ4hI4/fcMbF6FewTnZ7u0ioLqIbFShXfw0cvE8=\",\n   \"pom\": \"sha256-f9FmaKi/IHD3I8EfJg9nN7X+Fz8Rvn19V+X/bVLnfhw=\"\n  },\n  \"com/google/dagger#hilt-android/2.58\": {\n   \"pom\": \"sha256-GQVrwpg6oiKIOGe/mptjqKxPF4sTAop5QN7wT8D32Ts=\"\n  },\n  \"com/google/dagger#hilt-compiler/2.58\": {\n   \"jar\": \"sha256-ngp5Lf+vA26j1kuv6cOw+lfn1LSgaZZlSEE0VyFIm4c=\",\n   \"pom\": \"sha256-tZsoqk70OP3v1QWo9IwIMZcICXIwQ43wJkuXoP2jLW8=\"\n  },\n  \"com/google/dagger#hilt-core/2.58\": {\n   \"jar\": \"sha256-nfWU/NyszBlwlqVD4Cic3afoZJ3sk2Llj77yeFIBC+0=\",\n   \"pom\": \"sha256-U0YjTGpoHshJcgoUILfPz5+h841DF8vD6+sNQV9nmUo=\"\n  },\n  \"com/google/dagger/dagger-lint-aar/2.58/dagger-lint-aar-2.58\": {\n   \"aar\": \"sha256-t4e7yOP7iZyxd6upHopKnLeZxGThdZ5yLD2wjcQ3om4=\"\n  },\n  \"com/google/dagger/hilt-android/2.58/hilt-android-2.58\": {\n   \"aar\": \"sha256-vwk5yKavFE+sIZTV/qMYnDT84VqOlbF1Bm6LnAFgBGg=\"\n  },\n  \"com/google/devtools/ksp#symbol-processing-aa-embeddable/2.3.6\": {\n   \"jar\": \"sha256-fK6i1CZoCmp3AHvKYNQOockhjz22rp0WxU8P/vxOG1s=\",\n   \"pom\": \"sha256-oa7nRVgcA+5ZqJYoiODuB5WifGwbVbczJS5SvfHPI/0=\"\n  },\n  \"com/google/devtools/ksp#symbol-processing-api/2.2.20-2.0.3\": {\n   \"jar\": \"sha256-ogZEVp7MAUZ9Pv5Pi5eHqHGc4n7RK2o0da4dgr+xag4=\",\n   \"module\": \"sha256-HVGBrIyxsW2CXjLznfHy74WtGZVjXqrzU5sy3gVuXY8=\",\n   \"pom\": \"sha256-AoGBVPu0W8crSiPLJCqgFlpIMk1rjsQ8srl8itLm3rA=\"\n  },\n  \"com/google/devtools/ksp#symbol-processing-api/2.3.6\": {\n   \"jar\": \"sha256-Vd+DfVSxv9182+U72E3urjdGd+xKcpqa8VTT/uPAAjA=\",\n   \"module\": \"sha256-fTwGu1Wcn5r42q4q+DCS2VaXOBQrLpBlS2ut235974k=\",\n   \"pom\": \"sha256-YkfnkAfhEo350LIQnh7H657evU7rjleMDkMSlXeSsF0=\"\n  },\n  \"com/google/devtools/ksp#symbol-processing-common-deps/2.3.6\": {\n   \"jar\": \"sha256-917ObR1KiXj8nP4siVwyMjR926tcCEsE0M6Hax18lgQ=\",\n   \"module\": \"sha256-zC2bYeTXyh+cMHAaun4osQZS6/neLCgEr3rzNN57Tt0=\",\n   \"pom\": \"sha256-A29s8I+g0esgo/m+G33D1fOag42dbj/BJRrAxqRe07E=\"\n  },\n  \"com/google/devtools/ksp#symbol-processing/2.3.6\": {\n   \"pom\": \"sha256-RTWI/ah2Ows/MadEwdl8lbvC5DlCLTfjKA3aZMF63+k=\"\n  },\n  \"com/google/errorprone#error_prone_annotations/2.15.0\": {\n   \"jar\": \"sha256-BnBHcUNJ53iaW9v62dHAr586HrKMVaDuP2jmgvkFxOs=\",\n   \"pom\": \"sha256-e65hfjJoHruyicIDyQX2RsKgOXWYr3htlhpUqqPSseY=\"\n  },\n  \"com/google/errorprone#error_prone_annotations/2.21.1\": {\n   \"jar\": \"sha256-0fPGaqkaxSVJ4Arjsgi6S5r31y1o8jBkNVO+s45hGKw=\",\n   \"pom\": \"sha256-9ZiID+766p1nTcQdsTqzcAS/A3drW7IcBN7ejpIMHxI=\"\n  },\n  \"com/google/errorprone#error_prone_annotations/2.23.0\": {\n   \"jar\": \"sha256-7G858Gi2/5rDI8aOKLkpn4wKgMpRLcyx1KcPQKw+wFQ=\",\n   \"pom\": \"sha256-1auxfyMbY78Ak1j6ZAKBt0SBDLlYflmUl3g0lZwH29g=\"\n  },\n  \"com/google/errorprone#error_prone_annotations/2.27.0\": {\n   \"pom\": \"sha256-TKWjXWEjXhZUmsNG0eNFUc3w/ifoSqV+A8vrJV6k5do=\"\n  },\n  \"com/google/errorprone#error_prone_annotations/2.28.0\": {\n   \"jar\": \"sha256-8/yKOgpAIHBqNzsA5/V8JRLdJtH4PSjH04do+GgrIx4=\",\n   \"pom\": \"sha256-DOkJ8TpWgUhHbl7iAPOA+Yx1ugiXGq8V2ylet3WY7zo=\"\n  },\n  \"com/google/errorprone#error_prone_annotations/2.30.0\": {\n   \"jar\": \"sha256-FE86771uJ9rsVdN1OyxrE8Gv2vDPBIFs21ZFiO2S8b0=\",\n   \"pom\": \"sha256-9xOEnCOzSVPoVFZdzoqnlcrgwUFmEbcgwhRhMix5X4Y=\"\n  },\n  \"com/google/errorprone#error_prone_parent/2.15.0\": {\n   \"pom\": \"sha256-Edys0XqqxpqZQFJzut/apflmHWDRebikT1A5WLqlX4g=\"\n  },\n  \"com/google/errorprone#error_prone_parent/2.21.1\": {\n   \"pom\": \"sha256-MrsLX/JB/Wuh/upEiuu5zt7xaZvnPLbzGTZTh7gr+Sw=\"\n  },\n  \"com/google/errorprone#error_prone_parent/2.23.0\": {\n   \"pom\": \"sha256-9UcKSzEE/jCfvpSoDRbDxU0g90j0xd5PaKQoaI8wy9Q=\"\n  },\n  \"com/google/errorprone#error_prone_parent/2.27.0\": {\n   \"pom\": \"sha256-+oGCnQSVWd9pJ/nJpv1rvQn4tQ5tRzaucsgwC2w9dlQ=\"\n  },\n  \"com/google/errorprone#error_prone_parent/2.28.0\": {\n   \"pom\": \"sha256-rM79u1QWzvX80t3DfbTx/LNKIZPMGlXf5ZcKExs+doM=\"\n  },\n  \"com/google/errorprone#error_prone_parent/2.30.0\": {\n   \"pom\": \"sha256-Xog0zMDl7Qxy8wbCULUY5q0Q0HWpt7kQz2lcuh7gKi0=\"\n  },\n  \"com/google/googlejavaformat#google-java-format-parent/1.23.0\": {\n   \"pom\": \"sha256-VgkdKaBZnNAKlXnrzT0dnRhfX3b0Q33zD0C4X+LIUqQ=\"\n  },\n  \"com/google/googlejavaformat#google-java-format-parent/1.33.0\": {\n   \"pom\": \"sha256-x1dS3W9SbEr79q24GLgFyL/XQp48rYBa/NrMUljc0uk=\"\n  },\n  \"com/google/googlejavaformat#google-java-format/1.23.0\": {\n   \"jar\": \"sha256-+cXxgfruXHs4D+uWwalL7AxVhZuustFOmkfZLUC+8CE=\",\n   \"pom\": \"sha256-3yjFvNmuLslzF3y7WqV7cyJ2yYxe1n+R75OjfmG19qA=\"\n  },\n  \"com/google/googlejavaformat#google-java-format/1.33.0\": {\n   \"jar\": \"sha256-EmWqdh7CkIU1MksRSZWhxt667cip4uhVMSe6vNtYVn8=\",\n   \"pom\": \"sha256-h8Z8okpEf0pab2LX7PuZ1Cyi108mo4Rol8GJ07mZg5A=\"\n  },\n  \"com/google/guava#failureaccess/1.0.1\": {\n   \"jar\": \"sha256-oXHuTHNN0tqDfksWvp30Zhr6typBra8x64Tf2vk2yiY=\",\n   \"pom\": \"sha256-6WBCznj+y6DaK+lkUilHyHtAopG1/TzWcqQ0kkEDxLk=\"\n  },\n  \"com/google/guava#failureaccess/1.0.2\": {\n   \"jar\": \"sha256-io+Bz5s1nj9t+mkaHndphcBh7y8iPJssgHU+G0WOgGQ=\",\n   \"pom\": \"sha256-GevG9L207bs9B7bumU+Ea1TvKVWCqbVjRxn/qfMdA7I=\"\n  },\n  \"com/google/guava#guava-parent/26.0-android\": {\n   \"pom\": \"sha256-+GmKtGypls6InBr8jKTyXrisawNNyJjUWDdCNgAWzAQ=\"\n  },\n  \"com/google/guava#guava-parent/32.0.0-jre\": {\n   \"pom\": \"sha256-rPkrzaF3RhUxw2hRncmrxfnVhUrcHCiem/gDcb79NcM=\"\n  },\n  \"com/google/guava#guava-parent/32.0.1-jre\": {\n   \"pom\": \"sha256-Q+0ONrNT9B5et1zXVmZ8ni35fO8G6xYGaWcVih0DTSo=\"\n  },\n  \"com/google/guava#guava-parent/32.1.3-jre\": {\n   \"pom\": \"sha256-8oPB8EiXqaiKP6T/RoBOZeghFICaCc0ECUv33gGxhXs=\"\n  },\n  \"com/google/guava#guava-parent/33.0.0-jre\": {\n   \"pom\": \"sha256-BAzIjGgLQT1wup/INxs2CTAhsQmWqjWYYh3nZ9QYIpo=\"\n  },\n  \"com/google/guava#guava-parent/33.3.1-jre\": {\n   \"pom\": \"sha256-VUQdsn6Iad/v4FMFm99Hi9x+lVhWQr85HwAjNF/VYoc=\"\n  },\n  \"com/google/guava#guava/32.0.0-jre\": {\n   \"pom\": \"sha256-nszOUItR1VIeU4g7l00rhy8tJoVDwsUbT4ZuP6B29ec=\"\n  },\n  \"com/google/guava#guava/32.0.1-jre\": {\n   \"jar\": \"sha256-vX+iJ1kfuFCWd9DREiz5UVjzuKn0VlP1goHYefbcSMU=\",\n   \"pom\": \"sha256-QsJX9/c203ezGv7u6XirJtcwzXCvYN3nZi4YI1LiSCo=\"\n  },\n  \"com/google/guava#guava/32.1.3-jre\": {\n   \"jar\": \"sha256-bU4rWhGKq2Lm5eKdGFoCJO7YLIXECsPTPPBKJww7N0Q=\",\n   \"module\": \"sha256-9f/3ZCwS52J7wUKJ/SZ+JgLBf5WQ4jUiw+YxB/YcKUI=\",\n   \"pom\": \"sha256-cA5tRudbWTmiKkHCXsK7Ei88vvTv7UXjMS/dy+mT2zM=\"\n  },\n  \"com/google/guava#guava/33.0.0-jre\": {\n   \"jar\": \"sha256-9NhcPk1BFpQzfLhzq+oJskK2ZLsBMyC+YQUyfEWZFTc=\",\n   \"module\": \"sha256-WaLb0FXRuqdi548aW6Orlz7dE/wn3MGHEQXi95f2gtM=\",\n   \"pom\": \"sha256-/XCxTEQZhsIubSLO0ldnh3Vr5JGLFFqKvSI+OoC24y0=\"\n  },\n  \"com/google/guava#guava/33.3.1-jre\": {\n   \"jar\": \"sha256-S/Dixa+ORSXJbo/eF6T3MH+X+EePEcTI41oOMpiuTpA=\",\n   \"module\": \"sha256-QYWMhHU/2WprfFESL8zvOVWMkcwIJk4IUGvPIODmNzM=\",\n   \"pom\": \"sha256-MTtn/BPrOwY07acVoSKZcfXem4GIvCgHYoFbg6J18ZM=\"\n  },\n  \"com/google/guava#listenablefuture/1.0\": {\n   \"jar\": \"sha256-5K12B+XAR3xviQ7yaknLjRu03/tlC6tFAq/uZGROMGk=\",\n   \"pom\": \"sha256-U4c8rya8HtilZ+psk5qyqqP0el4y1creld31CA0jI4o=\"\n  },\n  \"com/google/guava#listenablefuture/9999.0-empty-to-avoid-conflict-with-guava\": {\n   \"jar\": \"sha256-s3KgN9QjCqV/vv/e8w/WEj+cDC24XQrO0AyRuXTzP5k=\",\n   \"pom\": \"sha256-GNSx2yYVPU5VB5zh92ux/gXNuGLvmVSojLzE/zi4Z5s=\"\n  },\n  \"com/google/j2objc#j2objc-annotations/2.8\": {\n   \"jar\": \"sha256-8CqV+hpele2z7YWf0Pt99wnRIaNSkO/4t03OKrf01u0=\",\n   \"pom\": \"sha256-N/h3mLGDhRE8kYv6nhJ2/lBzXvj6hJtYAMUZ1U2/Efg=\"\n  },\n  \"com/google/j2objc#j2objc-annotations/3.0.0\": {\n   \"jar\": \"sha256-iCQVc0Z93KRP/U10qgTCu/0Rv3wX4MNCyUyd56cKfGQ=\",\n   \"pom\": \"sha256-I7PQOeForYndEUaY5t1744P0osV3uId9gsc6ZRXnShc=\"\n  },\n  \"com/google/jimfs#jimfs-parent/1.1\": {\n   \"pom\": \"sha256-xxVVdR5X4O+RKHDorJYlrnglAqalucGcz4OyqX2LJr0=\"\n  },\n  \"com/google/jimfs#jimfs/1.1\": {\n   \"jar\": \"sha256-xIKOKNfAqTCvk4dRCzutp9qlwE18Jadce4sIHxwlfd0=\",\n   \"pom\": \"sha256-76huXNki8XtHL9/K5XI02NSsPhSLYlBzffzkVK96ekQ=\"\n  },\n  \"com/google/protobuf#protobuf-bom/3.22.3\": {\n   \"pom\": \"sha256-E6Mt+53m/Bw8P3r1Pk1cd/130rR2uuOLdLdYHN7i5lU=\"\n  },\n  \"com/google/protobuf#protobuf-bom/3.24.4\": {\n   \"pom\": \"sha256-BOz9UsUN8Hp1VR+bCeDvMGMO5CN9CRyg7KceW/t4zOU=\"\n  },\n  \"com/google/protobuf#protobuf-bom/3.25.5\": {\n   \"pom\": \"sha256-CA4phBcyOLUOBkwiav/7sbAjNSApXHkKf9PWrkWT8GM=\"\n  },\n  \"com/google/protobuf#protobuf-java-util/3.22.3\": {\n   \"jar\": \"sha256-xhX3aHncXDA+TfW5Smr6OVNAWMdUXbLUg/2V2fY8i/4=\",\n   \"pom\": \"sha256-tEcBsGoGSGXsm1YUqT6eKPrdfU38S0YPIcgZ71Pb4tY=\"\n  },\n  \"com/google/protobuf#protobuf-java-util/3.24.4\": {\n   \"jar\": \"sha256-EzySniz+OZChBdGOrMxJEistL7SStCDvAtXZ+Tfq67g=\",\n   \"pom\": \"sha256-nwzsJ21NnVpD1uKcwrAk5GgEyThqlvpSfu/Xv3SI5/A=\"\n  },\n  \"com/google/protobuf#protobuf-java/3.24.4\": {\n   \"jar\": \"sha256-5WVVIr4apcwfLwkqoDawRFFX8pSSju3xMyrJOMe2loY=\",\n   \"pom\": \"sha256-OUEiHKZXgZ3evZX+i3QPRwr3q/MEYLE+ocmrefEPq5E=\"\n  },\n  \"com/google/protobuf#protobuf-java/3.25.5\": {\n   \"jar\": \"sha256-hUAkf62eBrrvqPtF6zE4AtAZ9IXxQwDg+da1Vu2I51M=\",\n   \"pom\": \"sha256-51IDIVeno5vpvjeGaEB1RSpGzVhrKGWr0z5wdWikyK8=\"\n  },\n  \"com/google/protobuf#protobuf-kotlin/3.24.4\": {\n   \"jar\": \"sha256-UIyhPZe1D1QE6qN+tEk8sHiEFi63lxv5JNj4A9TCG7Q=\",\n   \"pom\": \"sha256-O7Hm75SmpDa9L/Zq8N1gPCPtOilDsvuJn4PZ+Hktcqk=\"\n  },\n  \"com/google/protobuf#protobuf-parent/3.22.3\": {\n   \"pom\": \"sha256-OZEz1/b1eTTddsSxjoY0j0JFMhCNr0oByPgguGZfCSk=\"\n  },\n  \"com/google/protobuf#protobuf-parent/3.24.4\": {\n   \"pom\": \"sha256-+37AUFh2/bnseVEKztLR6wTDuM/GkLWJBJdXypgcrbM=\"\n  },\n  \"com/google/protobuf#protobuf-parent/3.25.5\": {\n   \"pom\": \"sha256-ZMwOOtboX1rsj53Pk0HRN56VJTZP9T4j4W2NWCRnPvc=\"\n  },\n  \"com/squareup#javapoet/1.13.0\": {\n   \"jar\": \"sha256-THUX6EinGzbQadErs79Gpw/UzaMQXYIrDtLhnAC2kpE=\",\n   \"pom\": \"sha256-VKNPqFAqRryQ79tJJiYAWR+oC/mjT1pMeYMRrsFsqXc=\"\n  },\n  \"com/squareup#kotlinpoet/1.11.0\": {\n   \"jar\": \"sha256-KIetocoD3YO6onWGQNh+hA0ZB1ZNsO+I0iichoqYBJI=\",\n   \"module\": \"sha256-LWrQnnysudpjxv3zM+NDKzmULVWGM28C6wb1tUUPNKA=\",\n   \"pom\": \"sha256-ww+ujSieimZrk/uiBfJayVFJOEGuFGwONcNdz2xPiSw=\"\n  },\n  \"com/sun/activation#all/1.2.0\": {\n   \"pom\": \"sha256-HYUY46x1MqEE5Pe+d97zfJguUwcjxr2z1ncIzOKwwsQ=\"\n  },\n  \"com/sun/activation#all/1.2.1\": {\n   \"pom\": \"sha256-NgiDv2RIbs7xYbjygvZQNTbdGmcNU6Coccj7IBcOZ5U=\"\n  },\n  \"com/sun/activation#javax.activation/1.2.0\": {\n   \"jar\": \"sha256-mTMCsWzXBW8h53nMV30XWoELtJAO9zzY+/K1D5KLqc4=\",\n   \"pom\": \"sha256-+Hm26UWFTGkAsNvuHIOE16s95+FX/XrISTdAXEFtKl4=\"\n  },\n  \"com/sun/istack#istack-commons-runtime/3.0.8\": {\n   \"jar\": \"sha256-T/q7Br5FSgXkOY4gx3+itjCNS4jfvvfKMKdrW31VBe8=\",\n   \"pom\": \"sha256-wuAU00y4TtKH0GSYbEXDBaQSQiinM37M9sQh0U1wjxw=\"\n  },\n  \"com/sun/istack#istack-commons/3.0.8\": {\n   \"pom\": \"sha256-oPBRfoUS8PvMe4KVwS9lZqPQwthtZVY53GYu+MDH6+U=\"\n  },\n  \"com/sun/xml/bind#jaxb-bom-ext/2.3.2\": {\n   \"pom\": \"sha256-Gn3sKyfn4FV0TNuM8bkN70/Uc6zRuATv8JgTk1iVm9c=\"\n  },\n  \"com/sun/xml/bind/mvn#jaxb-parent/2.3.2\": {\n   \"pom\": \"sha256-IN1tw0q3VJrEDaHYLpIiLsQ0etDsDLEY72xXA77VOhg=\"\n  },\n  \"com/sun/xml/bind/mvn#jaxb-runtime-parent/2.3.2\": {\n   \"pom\": \"sha256-sk+NUfGEpovBuG1IwOPP7+shpE7eHF9zA8WK4EiFM+w=\"\n  },\n  \"com/sun/xml/bind/mvn#jaxb-txw-parent/2.3.2\": {\n   \"pom\": \"sha256-tV0++psVj0g6MOkseMy2APkzFHM9CJ66m3RDbwGzFKQ=\"\n  },\n  \"com/sun/xml/fastinfoset#FastInfoset/1.2.16\": {\n   \"jar\": \"sha256-BW86HhRECfIe0Wr8JoBfWOmiHz/OFUPELUAHGdJQxRE=\",\n   \"pom\": \"sha256-4UfSWKtuZpH3BZmpUkAObmx1WPjJwCjb4b4jF4MI6DA=\"\n  },\n  \"com/sun/xml/fastinfoset#fastinfoset-project/1.2.16\": {\n   \"pom\": \"sha256-kFgkJa3B9AtBNi2vuVFzkxIlrKpeeWINXmvVL2Rikro=\"\n  },\n  \"commons-codec#commons-codec/1.10\": {\n   \"jar\": \"sha256-QkHfqU5xHUNfKaRgSj4t5cSqPBZeI70Ga+b8H8QwlWk=\",\n   \"pom\": \"sha256-vbjbcBLREqbj6o/bfFELMA2Z7/CBnSfd26nEM5fqTPs=\"\n  },\n  \"commons-io#commons-io/2.16.1\": {\n   \"jar\": \"sha256-9B97qs1xaJZEes6XWGIfYsHGsKkdiazuSI2ib8R3yE8=\",\n   \"pom\": \"sha256-V3fSkiUceJXASkxXAVaD7Ds1OhJIbJs+cXjpsLPDj/8=\"\n  },\n  \"commons-logging#commons-logging/1.2\": {\n   \"jar\": \"sha256-2t3qHqC+D1aXirMAa4rJKDSv7vvZt+TmMW/KV98PpjY=\",\n   \"pom\": \"sha256-yRq1qlcNhvb9B8wVjsa8LFAIBAKXLukXn+JBAHOfuyA=\"\n  },\n  \"dev/drewhamilton/poko#poko-annotations-jvm/0.17.1\": {\n   \"jar\": \"sha256-lA5tUERbxrCuJq1BTse5U6Pk6ALcd1bMFNVpWLyXzDE=\",\n   \"module\": \"sha256-7c77BBavhseEjvEMfUQX09svMr31hUhkrKnQXXbrauU=\",\n   \"pom\": \"sha256-G7U6/YIw/943O1uLFxHcoMg9OtVh86UOOcSoAp2DI5I=\"\n  },\n  \"dev/drewhamilton/poko#poko-annotations/0.17.1\": {\n   \"module\": \"sha256-x8I2DoKGnEjLnFFWuDXPKIGJzr3zmqY3sacCc6IxSFI=\",\n   \"pom\": \"sha256-eSsiGa7acmaiD/grNy5oOcJznA3KTFPBESXf2SZud3Q=\"\n  },\n  \"io/github/davidburstrom/contester#contester-breakpoint/0.2.0\": {\n   \"jar\": \"sha256-Zyy+u11Fpys13YH9YSfhh0Ubtvt/ujUxW73y9Xz86DU=\",\n   \"module\": \"sha256-JZPF2femBAbmrqzmBWP82SNxdTb90ngGQUKX6T1gn+U=\",\n   \"pom\": \"sha256-vi0ixQGThmVimejxY0LGjFJkW3R/+4YZKQjRexb+wK4=\"\n  },\n  \"io/github/detekt/sarif4k#sarif4k-jvm/0.6.0\": {\n   \"jar\": \"sha256-s6yW3ZesuoMY2+JvakMtbG25HEbHgIBeiSi4ED5XY9w=\",\n   \"module\": \"sha256-Lf6fe4j3KOGtSFq5cXrwalWeAl7NXwGb6LUy6TUrmHU=\",\n   \"pom\": \"sha256-i5D8N0XwCPUQiGznB1Ofa7cbvdG3yduP0dsK5WahwK8=\"\n  },\n  \"io/github/detekt/sarif4k#sarif4k/0.6.0\": {\n   \"module\": \"sha256-aK4T0fQBTLjEZUdLo/GgJGAWo0VcJV5u3udUjxUPqMU=\",\n   \"pom\": \"sha256-QFP71kaAT/u2ebMSGQGyI+3B+OQbNcp8KR4qjc2pwsE=\"\n  },\n  \"io/github/java-diff-utils#java-diff-utils-parent/4.12\": {\n   \"pom\": \"sha256-2BHPnxGMwsrRMMlCetVcF01MCm8aAKwa4cm8vsXESxk=\"\n  },\n  \"io/github/java-diff-utils#java-diff-utils/4.12\": {\n   \"jar\": \"sha256-mZCiA5d49rTMlHkBQcKGiGTqzuBiDGxFlFESGpAc1bU=\",\n   \"pom\": \"sha256-wm4JftyOxoBdExmBfSPU5JbMEBXMVdxSAhEtj2qRZfw=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-api/1.23.8\": {\n   \"jar\": \"sha256-3VuE1CCQTVxWSqsRXTbmKQqdfa9pVZIwmQFWGPK1yD8=\",\n   \"module\": \"sha256-8s4t9yehmVmTlnVLWiWVuJmQZGZkFmrq5eMej2CtTAY=\",\n   \"pom\": \"sha256-KDAQpI6V8+PzcO+qIcde2Xkwa5nrUdcm2X5i5V62hH4=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-cli/1.23.8\": {\n   \"jar\": \"sha256-6tfM0yC/MEzs4YlDjWo4R0fHZUWg+IDZyR1cpdLTCmM=\",\n   \"module\": \"sha256-fXV30ckqMrrIuFOkfF5BvNZja/Mmh7V24z9IgQUuAoQ=\",\n   \"pom\": \"sha256-ReQ3FQ62Dil0mslrkamQ4JJSOyleE7jlNI/n5BvA2iw=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-core/1.23.8\": {\n   \"jar\": \"sha256-GYHeqOTi6FQa8tg+T401gc5kfP5jF1ueuawqB4SddMk=\",\n   \"module\": \"sha256-XJbmQu79sMhURDM3a7hc1jFhmSzYNTyp5ZIQTkI2IXc=\",\n   \"pom\": \"sha256-iW0NWvPv2dGf/umtVeIKh0tjP4R4X0y3d8tCJz1WkKA=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-metrics/1.23.8\": {\n   \"jar\": \"sha256-cY6PcfWHKYbk9c1Ih6QeJqoK7/Qumc9KQlgikbhzjMQ=\",\n   \"module\": \"sha256-x6sTPxfP1A/5h6lXTbbrEQ/tI6FGPrFeaau4/N4zt3Y=\",\n   \"pom\": \"sha256-eYMhhgRaIYtz+9fYbirEOy07OAaaIqmhEhS9JjF5ioE=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-parser/1.23.8\": {\n   \"jar\": \"sha256-XN9FoBctk01udAHNQ4OOe5VPetsRfu2DWPyuCxd+kOU=\",\n   \"module\": \"sha256-0K3LJljKCW2Jj6+DK+sZmjUzxINqR7I8G+qcqxsOolk=\",\n   \"pom\": \"sha256-QfC6f7dnDh5b0zUeylx+KZD3UFmpLKRTcx2RdpNB8rc=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-psi-utils/1.23.8\": {\n   \"jar\": \"sha256-lQX6nU+adx0lal1BW11R/9eiTlAZ9qYOWmmhJjPc97o=\",\n   \"module\": \"sha256-XFZfcZCGBgrc3py1DZ35ZqVuWyr0KrZuSc0Nm7irSKM=\",\n   \"pom\": \"sha256-tHScWO7eil587tpAjFNGBYCLvvXXAI1yINlJQ/6rJrg=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-report-html/1.23.8\": {\n   \"jar\": \"sha256-gGihXgdxjjvb9QHt5fZmgS+1+fsUUNsGBElTTAXmcio=\",\n   \"module\": \"sha256-3WDckx67/ybl7EcfBhQSTHWWpyjP5l2KYmI2guCa2Io=\",\n   \"pom\": \"sha256-jMdSpvNpMrmLjlESAtafEUmll2aRVBV+x9IMWQAJyX4=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-report-md/1.23.8\": {\n   \"jar\": \"sha256-zFuQsUds75nhEhYtvNHbMrBAKEp2jzyXWknsC2OYDKM=\",\n   \"module\": \"sha256-YTshxfr1SfdKlU+I3yT1hnVO1XEga/C2Hsg6vm7l36Q=\",\n   \"pom\": \"sha256-Ja9GMe98lKTNTmcgE+hbCFcZgB8lwkQF8ts29lVpkZY=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-report-sarif/1.23.8\": {\n   \"jar\": \"sha256-yfkiH8V+0fvRN03lxNpsBpFg+JKnH4O1UdOjLIy60T0=\",\n   \"module\": \"sha256-Dn+gYrE0qurBeHUODabhinxqluuDjk2RSMAvMSuapNI=\",\n   \"pom\": \"sha256-ba9LdulqHZ4FzWJCcMdY3yB2JnuP7dNoCtFLZsvxJI4=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-report-txt/1.23.8\": {\n   \"jar\": \"sha256-Qehco1h6vOmgP43LKmfgX89Zt1GPoBbJNQ6nPHn5xUo=\",\n   \"module\": \"sha256-prv47yBLVpTbGgMKpeewIqQ9bq8SKXS8GMFNWpuEmto=\",\n   \"pom\": \"sha256-mUX7sz4pmT32Q0B9B8omJvqIUen41JUdBcQwd7AEhR4=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-report-xml/1.23.8\": {\n   \"jar\": \"sha256-1xq6qYiQyuimGIObEwnAE9/znGvX0N4LcE5hk+An7wk=\",\n   \"module\": \"sha256-geiqXklP8ZuZLH6oOmt/9sv3NqYgqHf4uWqwUrKUjFo=\",\n   \"pom\": \"sha256-2xALuQ97r4rtIRh3jRg0BOqw62sTR2tlgNwpSxxJPfk=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-rules-complexity/1.23.8\": {\n   \"jar\": \"sha256-OhaXRuOLk+67jrfhDe2wZlfKKUvIHPZ1pzX4W183Hm4=\",\n   \"module\": \"sha256-zQXYVo7PY/hUIIhgiS89orKCa7/fzXvVGILWoNi0YQs=\",\n   \"pom\": \"sha256-A3v3LOqm5oS7V2S6ugvf2vdH+ndOdQthy4xBsBsos/Q=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-rules-coroutines/1.23.8\": {\n   \"jar\": \"sha256-r+jJc8F0V/cU8FeVBGEVGIwD9FBpA7sluE2oRuLlaBY=\",\n   \"module\": \"sha256-3JMvjexdt3DwkB8AteRVBxwT6tlDMI4gRCPqMybJnsU=\",\n   \"pom\": \"sha256-ZlutPHrhKRzQTQRCtPlXRw5naEF0OfICc2mlAWONB7c=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-rules-documentation/1.23.8\": {\n   \"jar\": \"sha256-0lNnhV2MzRVvKD4c1t4P0VuPPpUwqXAgb0HWGftPvc0=\",\n   \"module\": \"sha256-PnZ1eotRfunRkKjJWG80yVGTcAzUQxnws0sMspRs5EE=\",\n   \"pom\": \"sha256-YTpMXFcIVS2BC42Edc1tBH0gH97IwfDLUbxxzxpA42A=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-rules-empty/1.23.8\": {\n   \"jar\": \"sha256-iIZBEUeJrEMpLUSDYiFWXaR6kij3+33+DGs/lOf1ivo=\",\n   \"module\": \"sha256-VpBNJ1Is9DP185NLv/x+ASl2w/qlOsIjVHRkGExgoLg=\",\n   \"pom\": \"sha256-poQXlxenotOzZ6Ni2IKaGgUnQ05EB8ynF5QQRw/QSD0=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-rules-errorprone/1.23.8\": {\n   \"jar\": \"sha256-/uHudlFopYlhYuvjmzT6QhwKW3aavI8VD541lpEqScQ=\",\n   \"module\": \"sha256-GX8CnrvhxrDAHDIYkSmWeTUf0qE1hmgoa4i4rao5s9A=\",\n   \"pom\": \"sha256-lOerkgYUNz+9J30k+C1v1Z174nUy940cRrZvwW7/29Q=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-rules-exceptions/1.23.8\": {\n   \"jar\": \"sha256-iJ/8cq/wYkEy4LEJMjMscdJPW6uPHBDCC/WsWSyiyvY=\",\n   \"module\": \"sha256-hlQWj6pWXed/GYw8XebD89uCXmef7cWrMVvokq2Ua3E=\",\n   \"pom\": \"sha256-4PKtnMpBnrN4vGu1P+jzHOQx8Q0pQzd3bE2sxCJPpcc=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-rules-naming/1.23.8\": {\n   \"jar\": \"sha256-7RmSsb2wSUVngFsKgUXOl49G5a10Pi6Dv4S9XsZhuus=\",\n   \"module\": \"sha256-m0Kgrxw9dl05uACWHlGCtINpUA7jOhqEB/B3VKuMW/A=\",\n   \"pom\": \"sha256-fy4xrRpHCOg0bh29xZz14tt8WrIxqwlNHacXJaZYS5c=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-rules-performance/1.23.8\": {\n   \"jar\": \"sha256-2/Xgbi+himz1euJiHkJ0wWO7W7s1Gl23X3JPhvItQ2s=\",\n   \"module\": \"sha256-BmoZa4/zQtVm24YK9D9/7lEMlUTNZmpw4dQkOzP4PZ8=\",\n   \"pom\": \"sha256-RaraEjayz9pk+QyilMtcugb1lIBD1Sr1VXz2MbLaSG4=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-rules-style/1.23.8\": {\n   \"jar\": \"sha256-ryZEwibSugZ56oa8pS75DI2fZPRG76nXTL0OYxGwOKg=\",\n   \"module\": \"sha256-FTHiCvvECye9YoSALKWTXwNX19lRzWQgpH9y+yhjDno=\",\n   \"pom\": \"sha256-7kcVqGMC4EjwRAfAp1tpUaHHksm+UEuRE0eULljhLH4=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-rules/1.23.8\": {\n   \"jar\": \"sha256-o+5Rbzg3+8AdXDuG9dx759uoE0W/xXxOtYrxL2kjpWA=\",\n   \"module\": \"sha256-vzP+AgL02uE4kMF0qYIFGWp7M4KMgOnqceKIUYK/MHk=\",\n   \"pom\": \"sha256-si4+rr2LAiDk5bCiVXAF1V0Wn9ja1TA12QW9L73orQM=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-tooling/1.23.8\": {\n   \"jar\": \"sha256-fpPpojtHj3ASiJOwZ0hnP5EhALfvAwQNfQMx4m0w0JI=\",\n   \"module\": \"sha256-o8avD6bmk+YG8b8cQAOZHMKf16sydfOhCZEExxgIli4=\",\n   \"pom\": \"sha256-rkS7B0IlXL/Gzj37bvtm077E4dt4emidSQ64u6IU5ZE=\"\n  },\n  \"io/gitlab/arturbosch/detekt#detekt-utils/1.23.8\": {\n   \"jar\": \"sha256-91/X6SS5Jn2exmGFnKkTEC3kqPWJWwloXOEHl9wm0FY=\",\n   \"module\": \"sha256-SjYqCzBBPaN1u0mYMjseG5rg2+M05kngUYs16ecBX4M=\",\n   \"pom\": \"sha256-vcv9/v+3y6mTOofXVl59ryHF/rpkUc2f3r7RstXQjcw=\"\n  },\n  \"io/grpc#grpc-api/1.57.2\": {\n   \"jar\": \"sha256-QrcuZXLAhAVaw84D5u/kM+sF72ILPa9RNqQ1n8csw+E=\",\n   \"pom\": \"sha256-x99FUaZPAoKnZugJUU1COEUKdCkFX5x3GIgdFqMQoCY=\"\n  },\n  \"io/grpc#grpc-api/1.69.1\": {\n   \"jar\": \"sha256-qNPW3McfOrYT1miEIoK0iL3ZPT6ZoO9dyn7ub6c0woM=\",\n   \"pom\": \"sha256-vq8uR11cRdBjTU0yS/hNsqjWqSkilx5vfcJ+hRxCkH8=\"\n  },\n  \"io/grpc#grpc-context/1.27.2\": {\n   \"pom\": \"sha256-DyErFOvYNMvtm9iGml1snBeY7OtRLH/MKNqJ9vik7dg=\"\n  },\n  \"io/grpc#grpc-context/1.57.2\": {\n   \"jar\": \"sha256-m4rIjZzvKBna/+1729LyJoAjfUgsbGcf4C022j8IzwA=\",\n   \"pom\": \"sha256-iSf3fWOB4kSHaCcIGWpspyg2i4/XzrsQT9kyS2sSSRc=\"\n  },\n  \"io/grpc#grpc-context/1.69.1\": {\n   \"jar\": \"sha256-Re+VuMFYqLW906y2e55oLvJUFLsUj0iOyEdDirZHFdQ=\",\n   \"pom\": \"sha256-beKbzqslob0L4R7qhGjQ4/HAxHbQpTMhW0/eHIKEtXA=\"\n  },\n  \"io/grpc#grpc-core/1.57.2\": {\n   \"jar\": \"sha256-WhAHCr/rSWbsTVgJYdzE5/afqDqyUkL5LBdl77B7hgY=\",\n   \"pom\": \"sha256-CpcgGv4Xh08DX4ol/7lwZ45Jqt8pksfZfG/5+x1dohE=\"\n  },\n  \"io/grpc#grpc-core/1.69.1\": {\n   \"jar\": \"sha256-UTUsra7L+aSkqkLZP28fxyjx/QGwUWgDg+0J5WMf+9A=\",\n   \"pom\": \"sha256-loCY9KhFG7zT17iMaoq1t6dO+A397npgUvNS//eDssU=\"\n  },\n  \"io/grpc#grpc-inprocess/1.69.1\": {\n   \"jar\": \"sha256-t8asDjq/S41YJhDWMteUF7w9qBJU4aS89/Aejbe9Ve8=\",\n   \"pom\": \"sha256-/rswpc8jgjfQdaOSPok5khg9rxcaHrQ0rPEyEUpff4o=\"\n  },\n  \"io/grpc#grpc-netty/1.57.2\": {\n   \"jar\": \"sha256-mAnUwQyU0R57KUbN61sohL4goJUQKJVE83Vp8CyHeiE=\",\n   \"pom\": \"sha256-ixIWHPKqz785j7Wvw7DXOiGvIGulDD2Pe/T2xLN16/g=\"\n  },\n  \"io/grpc#grpc-netty/1.69.1\": {\n   \"jar\": \"sha256-Uqhu1m94kz6D0aP7cWKtFmdIlWTEVWNmt6NXnHAkpEc=\",\n   \"pom\": \"sha256-jwZlDMkUKMxRMRG/676r95mUGF1yREsC5d+so4vkNxQ=\"\n  },\n  \"io/grpc#grpc-protobuf-lite/1.57.2\": {\n   \"jar\": \"sha256-/EkX3F1BmsgQ+z8nUjwU514f5QNyFU+rKTJCFe5qlVo=\",\n   \"pom\": \"sha256-YHeMHqQHo7oKfw8J3wmegnInjoq8KYIsnPUOGgUvG3U=\"\n  },\n  \"io/grpc#grpc-protobuf-lite/1.69.1\": {\n   \"jar\": \"sha256-wp+Q+t88diD5NZokPAZ92FtzvXZbKPPZXfkQrC0zFVU=\",\n   \"pom\": \"sha256-Jj8fhE11bBXbX6uIaZZeM3IrP0/pB/4ciFqQ4EZGFzE=\"\n  },\n  \"io/grpc#grpc-protobuf/1.57.2\": {\n   \"jar\": \"sha256-MWMNip6fCKlZhiAV4wpIY5CL42gMOmhvTB8I0v/q9wY=\",\n   \"pom\": \"sha256-xeIpKAIFOXfwRhCxcEhKmh6mrxVBwUSyfRiECsVE+p0=\"\n  },\n  \"io/grpc#grpc-protobuf/1.69.1\": {\n   \"jar\": \"sha256-TFLvlI+4mHo7qn1GujYre/MH3TxR8pJBzVxZg5igEN8=\",\n   \"pom\": \"sha256-v0ynbSrORNlpRxT7zz/XKcmO8uWGOrkgpIPAUqIvRCM=\"\n  },\n  \"io/grpc#grpc-services/1.57.2\": {\n   \"jar\": \"sha256-BXpDumR4M3VqsvhRusgKO5+NzgJvwawfF9TzFWSPQXI=\",\n   \"pom\": \"sha256-dHYBoDpYoYl5gZrFLO/8WGuPnGprQi4XI/Mmr0iBPrk=\"\n  },\n  \"io/grpc#grpc-stub/1.57.2\": {\n   \"jar\": \"sha256-hNKvEnGRaPdjdfKv39brUTOoZe26kkTUDmuWjjrd4dM=\",\n   \"pom\": \"sha256-IVnmFKh5R3XrmOLhyFg0q05ZEb4cSnXHFjqZPpyJK6w=\"\n  },\n  \"io/grpc#grpc-stub/1.69.1\": {\n   \"jar\": \"sha256-45xjJz1TBS6+n2ONiumBdnNexWcyjZoXCSzdtvI5uMI=\",\n   \"pom\": \"sha256-9a2BV+xA2KI0djKWXWoD0YpIrUYl8y62SzenpHDOU7s=\"\n  },\n  \"io/grpc#grpc-util/1.69.1\": {\n   \"jar\": \"sha256-3Vl71nXqoELz41eGSNkFDIE8RZXF3mhp753btEkAYDE=\",\n   \"pom\": \"sha256-0g00aMt01WvlXtPUb2PKOO5LygkY2arXJ3pEj24HpbQ=\"\n  },\n  \"io/netty#netty-buffer/4.1.110.Final\": {\n   \"jar\": \"sha256-RtdOeRJarMBVwx8YFS/cXUpWmqjWAJEgPQuqgzlzrDw=\",\n   \"pom\": \"sha256-cQrBnMAc2A32vpo/qtPCIrShoy9LVRN74HtgmdXaNWI=\"\n  },\n  \"io/netty#netty-buffer/4.1.93.Final\": {\n   \"jar\": \"sha256-AHx9nDeN8C05BWfQ1931Qv/dsCG3MT2/UCOSET/6uwg=\",\n   \"pom\": \"sha256-g/vFTitzuG1Vsgj2GNGioVaRDsFG9+zldWUAe3UK3Xg=\"\n  },\n  \"io/netty#netty-codec-http/4.1.110.Final\": {\n   \"jar\": \"sha256-3A1q9QVGMKcP8O81TyCqem5Gc4yfxWNu09T+d+OL1I0=\",\n   \"pom\": \"sha256-Ua6ZCvFKMh2209aIS5F7fUNj62Dd3A8Uk7GAIaFC560=\"\n  },\n  \"io/netty#netty-codec-http/4.1.93.Final\": {\n   \"jar\": \"sha256-2s94znirLSlXAyXbTNJFHqWJY5gH3pWIGg+nFVqea1U=\",\n   \"pom\": \"sha256-o9r/8HG20oToBj2WhD3iu4PPO4iergzJ4K22SlejG4I=\"\n  },\n  \"io/netty#netty-codec-http2/4.1.110.Final\": {\n   \"jar\": \"sha256-tUbHVEWkh7t7zVqUd5yuzOM1gs974xuLOfwOZbHuJvw=\",\n   \"pom\": \"sha256-KdL2wmw8yp/oOTZxcH/o75w+MQIKLf4GuCxCZJnCWDc=\"\n  },\n  \"io/netty#netty-codec-http2/4.1.93.Final\": {\n   \"jar\": \"sha256-2WzAkEWhNBxtR0lDUqomO4e3L7HS6p7KFhqnOCC/6Ls=\",\n   \"pom\": \"sha256-CEQztC1UH3rEtZKH3SUyhc/aOj1l3nLnNou37D02cnE=\"\n  },\n  \"io/netty#netty-codec-socks/4.1.110.Final\": {\n   \"jar\": \"sha256-l2BSo8m7KAvG2Z86KeZARnfPlYw94FsgUJPTjABriAw=\",\n   \"pom\": \"sha256-/+V7MWGR3U+WvuZsVwnBPL207KsIXAEMjbDGqoCav2w=\"\n  },\n  \"io/netty#netty-codec-socks/4.1.93.Final\": {\n   \"jar\": \"sha256-DqR7W6I8odqOuRRsj8dVwScUFGM7Hivizh33ZLoP/yo=\",\n   \"pom\": \"sha256-jNgW7ZkalGBBurTLJL2cjkHuBpJRJRHy2DzvU462Bdc=\"\n  },\n  \"io/netty#netty-codec/4.1.110.Final\": {\n   \"jar\": \"sha256-nszOmo2Ce7jOhPnDGD/sWL0clqUQEM9xEpd0YDSvNwE=\",\n   \"pom\": \"sha256-qAa7U2uzI2Zbr/fNEiPysnKi1HF6tPmxI2EIbarl3z4=\"\n  },\n  \"io/netty#netty-codec/4.1.93.Final\": {\n   \"jar\": \"sha256-mQw3gWjcY2TG/1aXAfTy8SL//omYs+GJ66TE2GjtEIQ=\",\n   \"pom\": \"sha256-Gc3tJnoHDf8avJ0Cm1UvrSYqzBq6XGxnsiePyhE6Jqs=\"\n  },\n  \"io/netty#netty-common/4.1.110.Final\": {\n   \"jar\": \"sha256-mFHsZlSLng1BFkzpiUPN1LvjBfaN29JOrlLkUBoNexo=\",\n   \"pom\": \"sha256-fUF/UzUwTa4eoIoGWGA4yD/orYTB01uqFe0RkhzveSA=\"\n  },\n  \"io/netty#netty-common/4.1.93.Final\": {\n   \"jar\": \"sha256-RDuzFlmfsW47rrovtYiBgU1/8LevF2/nbjgHGm6G+MA=\",\n   \"pom\": \"sha256-QtiDsT6zjKv1SWFkYsXzMfUzO/DI/JIVdE+DwBgKT2s=\"\n  },\n  \"io/netty#netty-handler-proxy/4.1.110.Final\": {\n   \"jar\": \"sha256-rVSrT+nEfvPnI9cSURJttT6NtUOHGtuer8lERlOe/1I=\",\n   \"pom\": \"sha256-xhPLTn4G9C76MduNiyoznti/QfAMRtONCQmkwGxlbc8=\"\n  },\n  \"io/netty#netty-handler-proxy/4.1.93.Final\": {\n   \"jar\": \"sha256-KsX3+++gtz73g4iQaTRNVRVQWhSyMDvmk8UALEht8rQ=\",\n   \"pom\": \"sha256-bcUNoOZ/WXgSh0+B6qRUBPfQdrgZnqkIiTKoXBthAkU=\"\n  },\n  \"io/netty#netty-handler/4.1.110.Final\": {\n   \"jar\": \"sha256-1aCNfeNkkS5ChZaN5NTM4/AdpLsEjVxpN+Xyrx+OFIo=\",\n   \"pom\": \"sha256-TUPBPRT1Y1oviw1QlNejHFCe4PUsck66DvMM/+PqFVU=\"\n  },\n  \"io/netty#netty-handler/4.1.93.Final\": {\n   \"jar\": \"sha256-Tl9WOuFO1xM4GBbVgvX8/QYVrvspIDSGzft4LYoAoCs=\",\n   \"pom\": \"sha256-hKFSXKwLR1nvrvKZekf+Gbm1ZC+Sc/oP1YoudsegWf4=\"\n  },\n  \"io/netty#netty-parent/4.1.110.Final\": {\n   \"pom\": \"sha256-aFra83Nmb8FUJ8gQ+K/zpP4ZSpfH7XS2nQfFSPDULxw=\"\n  },\n  \"io/netty#netty-parent/4.1.93.Final\": {\n   \"pom\": \"sha256-sQnLdvN1/tuKnvdaxYBjFw3rfqLd0CT0Zv723GXN/O4=\"\n  },\n  \"io/netty#netty-resolver/4.1.110.Final\": {\n   \"jar\": \"sha256-oum0rnyqkvxb10fhHR3sINgbGPwAlZVUMCJErFxWznA=\",\n   \"pom\": \"sha256-ZV80GS6MdhizxaeeSI5NqjXe9BsNFtRfo2Ujw7TJ9kE=\"\n  },\n  \"io/netty#netty-resolver/4.1.93.Final\": {\n   \"jar\": \"sha256-5Zdwtm6Bgi5dERrE5UTX6wxUPgooX1JijlOUGs2O11k=\",\n   \"pom\": \"sha256-WzUMPJHp5V0py+aM/k7yEWzB8DKGd+v59hW6twgsefQ=\"\n  },\n  \"io/netty#netty-transport-native-unix-common/4.1.110.Final\": {\n   \"jar\": \"sha256-UXF7t0cRQZUDkMZxOkSf2xBU0H5gc37n3acIN5bN7kg=\",\n   \"pom\": \"sha256-6hjOBMmpesDFH045exhSKf2VmX6QsRM5rc98UZRtU9g=\"\n  },\n  \"io/netty#netty-transport-native-unix-common/4.1.93.Final\": {\n   \"jar\": \"sha256-d0FlocTbqssX+cGtZms1aaallxWugo58PUdwP0eaU+c=\",\n   \"pom\": \"sha256-Fbwltn/wpJJysnDvK4z/1iAFfKFssp3/etVmGtyirhI=\"\n  },\n  \"io/netty#netty-transport/4.1.110.Final\": {\n   \"jar\": \"sha256-pC3Wg5DKFLT/LUBiiglsdkhbStt8GWAtUokyGgZp5wQ=\",\n   \"pom\": \"sha256-MPXaDnZG8YQNYy+IYVyLnYIFSZ1oVZucRUezsEoGg80=\"\n  },\n  \"io/netty#netty-transport/4.1.93.Final\": {\n   \"jar\": \"sha256-paeAGbwc1D28PHt83TgBkSyibR9Jj7VgUU/uSXhkupY=\",\n   \"pom\": \"sha256-DdYqDrPLHqABpNBCbk9cCN8ccNkmVnW/+lxYNhNCLUM=\"\n  },\n  \"io/opencensus#opencensus-api/0.31.0\": {\n   \"jar\": \"sha256-cCulXXjznVUZXc8EH9+qt6dJCprEUBNUJIftnk06TSM=\",\n   \"pom\": \"sha256-m0eVkefD4KtFOB+gQ6kWV4Fb3Yw1k68BDHrDb0yQWRk=\"\n  },\n  \"io/opencensus#opencensus-proto/0.2.0\": {\n   \"jar\": \"sha256-DBktRR6d106Ychsn0C8OK2vKRLUVY7Xavy4hH3o+vxM=\",\n   \"pom\": \"sha256-twh5B5IPyKgVNGhrLxorMxEnr5fwFau9s3hqUfP6HlI=\"\n  },\n  \"io/perfmark#perfmark-api/0.26.0\": {\n   \"jar\": \"sha256-t9I+k6NFN84zJwgmmg0UBHiKW14ZSegvVTX85Rs+qVs=\",\n   \"module\": \"sha256-MdgyMyR0zkgVD1uuADNDMZE28zav0QdqKJApMZ4+qXo=\",\n   \"pom\": \"sha256-ft7khhbhe2Epfq46gutIOoXlbSVnkpN4qkbzCpUDIto=\"\n  },\n  \"io/perfmark#perfmark-api/0.27.0\": {\n   \"jar\": \"sha256-x7R4UD7FJOVd8ZtCTUbSfIporrgBZk+t1PBptx9S0PY=\",\n   \"module\": \"sha256-n2xOamK43v0UFzrNt9spPQhjU7Ikkj7vYpP1gWGJPMo=\",\n   \"pom\": \"sha256-IsF1wsGCNmdjDITnMiV2f1lwSS2ObL/7gaZXXbpHLSY=\"\n  },\n  \"jakarta/activation#jakarta.activation-api/1.2.1\": {\n   \"jar\": \"sha256-iwoPUvqLBcVDGSGgY+2GbvqkHa3y46fuPhlh8rDZZFs=\",\n   \"pom\": \"sha256-QlhcsH3afyOqBOteCUAGGUSiRqZ609FpQvvlaf8DzTE=\"\n  },\n  \"jakarta/inject#jakarta.inject-api/2.0.1\": {\n   \"jar\": \"sha256-99yYBi/M8UEmq7dRtk+rEsMSVm6MvchINZi//OqTr3w=\",\n   \"pom\": \"sha256-5/1yMuljB6V1sklMk2fWjPQ+yYJEqs48zCPhdz/6b9o=\"\n  },\n  \"jakarta/xml/bind#jakarta.xml.bind-api-parent/2.3.2\": {\n   \"pom\": \"sha256-FaVbfVN8n5lwrq0o0q+XwFn2X/YQL3a70p8SR92Kbfs=\"\n  },\n  \"jakarta/xml/bind#jakarta.xml.bind-api/2.3.2\": {\n   \"jar\": \"sha256-aRVjBAeb3u2fwK47OTifGbPMS6REO8gFCJlTlOrXQuo=\",\n   \"pom\": \"sha256-tTeziNurTMBpC50vsMdBJNZyUxc0VnrPblMTDqsTGtY=\"\n  },\n  \"javax/annotation#javax.annotation-api/1.3.2\": {\n   \"jar\": \"sha256-4EulGVvNVV3JVlD3zGFNFR5LzVLSmhC4qiGX86uJq5s=\",\n   \"pom\": \"sha256-RqSiUcpAbnjkhT16K66DKChEpJkoUUOe6aHyNxbwa5c=\"\n  },\n  \"javax/inject#javax.inject/1\": {\n   \"jar\": \"sha256-kcdwRKUMSBY2wy2Rb9ickRinIZU5BFLIEGUID5V95/8=\",\n   \"pom\": \"sha256-lD4SsQBieARjj6KFgFoKt4imgCZlMeZQkh6/5GIai/o=\"\n  },\n  \"junit#junit/4.13.2\": {\n   \"jar\": \"sha256-jklbY0Rp1k+4rPo0laBly6zIoP/1XOHjEAe+TBbcV9M=\",\n   \"pom\": \"sha256-Vptpd+5GA8llwcRsMFj6bpaSkbAWDraWTdCSzYnq3ZQ=\"\n  },\n  \"net/java#jvnet-parent/1\": {\n   \"pom\": \"sha256-KBRAgRJo5l2eJms8yJgpfiFOBPCXQNA4bO60qJI9Y78=\"\n  },\n  \"net/java#jvnet-parent/3\": {\n   \"pom\": \"sha256-MPV4nvo53b+WCVqto/wSYMRWH68vcUaGcXyy3FBJR1o=\"\n  },\n  \"net/java/dev/jna#jna-platform/5.6.0\": {\n   \"jar\": \"sha256-ns6ovysbOZY5OdGLcEZO72DFCP7Ygg+dyroMNVGOq/c=\",\n   \"pom\": \"sha256-G+s1y0GE5skGp+Murr2FLdPaCiY5YumRNKuUWDI5Tig=\"\n  },\n  \"net/java/dev/jna#jna/4.2.2\": {\n   \"jar\": \"sha256-HzivVOBsbm9tvzm6LAUrlS3qXd20hxEns0Y53esRvb4=\",\n   \"pom\": \"sha256-q9ZtmMmz3dgvnXnK+k1mH0G7xZXSyB2nrz/AGRCa97c=\"\n  },\n  \"net/java/dev/jna#jna/5.6.0\": {\n   \"jar\": \"sha256-VVfiNaiqL5dm1dxgnWeUjyqIMsLXls6p7x1svgs7fq8=\",\n   \"pom\": \"sha256-X+gbAlWXjyRhbTexBgi3lJil8wc+HZsgONhzaoMfJgg=\"\n  },\n  \"net/ltgt/gradle/incap#incap/0.2\": {\n   \"jar\": \"sha256-tiW5gGsPHkvHouNFcRlIjePNV+og/u3VE9sHClc6T/0=\",\n   \"pom\": \"sha256-GkoIoeiNMgUs2C3C90CzTTBI4sDmp8K/4jCe0Adx9zo=\"\n  },\n  \"net/sf/kxml#kxml2/2.3.0\": {\n   \"jar\": \"sha256-8mTdn3mh/eEM5ezFMiHv8kvkyTMcgwt9UvLwintjPeI=\",\n   \"pom\": \"sha256-Mc5gb06VGJNimbsNJ8l4+mHhhf0d58mHT+lZpT40poU=\"\n  },\n  \"org/apache#apache/13\": {\n   \"pom\": \"sha256-/1E9sDYf1BI3vvR4SWi8FarkeNTsCpSW+BEHLMrzhB0=\"\n  },\n  \"org/apache#apache/15\": {\n   \"pom\": \"sha256-NsLy+XmsZ7RQwMtIDk6br2tA86aB8iupaSKH0ROa1JQ=\"\n  },\n  \"org/apache#apache/18\": {\n   \"pom\": \"sha256-eDEwcoX9R1u8NrIK4454gvEcMVOx1ZMPhS1E7ajzPBc=\"\n  },\n  \"org/apache#apache/21\": {\n   \"pom\": \"sha256-rxDBCNoBTxfK+se1KytLWjocGCZfoq+XoyXZFDU3s4A=\"\n  },\n  \"org/apache#apache/23\": {\n   \"pom\": \"sha256-vBBiTgYj82V3+sVjnKKTbTJA7RUvttjVM6tNJwVDSRw=\"\n  },\n  \"org/apache#apache/31\": {\n   \"pom\": \"sha256-VV0MnqppwEKv+SSSe5OB6PgXQTbTVe6tRFIkRS5ikcw=\"\n  },\n  \"org/apache/commons#commons-compress/1.21\": {\n   \"jar\": \"sha256-auz9VFlyillWAc+gcljRMZcv/Dm0kutIvdWWV3ovJEo=\",\n   \"pom\": \"sha256-Z1uwI8m+7d4yMpSZebl0Kl/qlGKApVobRi1Mp4AQiM0=\"\n  },\n  \"org/apache/commons#commons-parent/34\": {\n   \"pom\": \"sha256-Oi5p0G1kHR87KTEm3J4uTqZWO/jDbIfgq2+kKS0Et5w=\"\n  },\n  \"org/apache/commons#commons-parent/35\": {\n   \"pom\": \"sha256-cJihq4M27NTJ3CHLvKyGn4LGb2S4rE95iNQbT8tE5Jo=\"\n  },\n  \"org/apache/commons#commons-parent/52\": {\n   \"pom\": \"sha256-ddvo806Y5MP/QtquSi+etMvNO18QR9VEYKzpBtu0UC4=\"\n  },\n  \"org/apache/commons#commons-parent/69\": {\n   \"pom\": \"sha256-1Q2pw5vcqCPWGNG0oDtz8ZZJf8uGFv0NpyfIYjWSqbs=\"\n  },\n  \"org/apache/httpcomponents#httpclient/4.5.6\": {\n   \"jar\": \"sha256-wD+BMZXnqA42CNDd2NqAshaWpMkqaiKYhlvxSQcVUcc=\",\n   \"pom\": \"sha256-fvwSQec+f7smi/0zJC0R69PKBwYdfYXyli3DKg8LiFU=\"\n  },\n  \"org/apache/httpcomponents#httpcomponents-client/4.5.6\": {\n   \"pom\": \"sha256-sEK0HyOR7bANNff05Qmu0hI2SMHSRs5Y0Pe5Bcn+H3M=\"\n  },\n  \"org/apache/httpcomponents#httpcomponents-core/4.4.16\": {\n   \"pom\": \"sha256-8tdaLC1COtGFOb8hZW1W+IpAkZRKZi/K8VnVrig9t/c=\"\n  },\n  \"org/apache/httpcomponents#httpcomponents-parent/10\": {\n   \"pom\": \"sha256-yq+WfZSvshdT82CCxghiBr0fSIJf9ZaTLM66crZdOfo=\"\n  },\n  \"org/apache/httpcomponents#httpcomponents-parent/11\": {\n   \"pom\": \"sha256-qQH4exFcVQcMfuQ+//Y+IOewLTCvJEOuKSvx9OUy06o=\"\n  },\n  \"org/apache/httpcomponents#httpcore/4.4.16\": {\n   \"jar\": \"sha256-bJs90UKgncRo4jrTmq1vdaDyuFElEERp8CblKkdORk8=\",\n   \"pom\": \"sha256-PLrYSbNdrP5s7DGtraLGI8AmwyYRQbDSbux+OZxs1/o=\"\n  },\n  \"org/apache/httpcomponents#httpmime/4.5.6\": {\n   \"jar\": \"sha256-CysRAsGNPH4Fp3IUubdQGm9gVhdK5WBODiVndu2nVT4=\",\n   \"pom\": \"sha256-37/W/+KnhMqYF8RjZap/ileDILgFveOdb1WgsJ2KqMo=\"\n  },\n  \"org/bouncycastle#bcpkix-jdk18on/1.79\": {\n   \"jar\": \"sha256-NjmiTd+bpLfroGWbRHcOkeuoFkIYiOVx8oWq3v5TLNY=\",\n   \"pom\": \"sha256-NeSfQTTeKsMmw6UKJXYsu021bzgC+j9zDMhbZTrQmHs=\"\n  },\n  \"org/bouncycastle#bcprov-jdk18on/1.79\": {\n   \"jar\": \"sha256-DYHswxJFNrU5vOmqP+liG3+Eyc7jcbY1pbMceLeasdo=\",\n   \"pom\": \"sha256-2PGgaxSddG6dmN5U4veqmy62E/s1ymfYrjls6qxmHuQ=\"\n  },\n  \"org/bouncycastle#bcutil-jdk18on/1.79\": {\n   \"jar\": \"sha256-xwuIraWJOMvC8AXUAykFQHi8+hFJ5v/APpJC62qyGDY=\",\n   \"pom\": \"sha256-4kwftM8WBUBaaYjp5NbksuH0OT/HOompRSrmJe4xHQI=\"\n  },\n  \"org/checkerframework#checker-compat-qual/2.5.3\": {\n   \"jar\": \"sha256-12ua/qYcfAgpCAI/DLwUJ/q5q9LfkVyLij56UJvMvG0=\",\n   \"pom\": \"sha256-9/zayZ6zPRafbVKjVUHCL/0U9FirvPVvnEnuFIZZjJw=\"\n  },\n  \"org/checkerframework#checker-qual/3.33.0\": {\n   \"jar\": \"sha256-4xYlW7/Nn+UNFlMUuFq7KzPLKmapPEkdtkjkmKgsLeE=\",\n   \"module\": \"sha256-6FIddWJdQScsdn0mKhU6wWPMUFtmZEou9wX6iUn/tOU=\",\n   \"pom\": \"sha256-9VqSICenj92LPqFaDYv+P+xqXOrDDIaqivpKW5sN9gM=\"\n  },\n  \"org/checkerframework#checker-qual/3.37.0\": {\n   \"jar\": \"sha256-5M4TdswnNeHd4iC2KtCRP1EpdwTarRVaM/OGvF2w2fc=\",\n   \"module\": \"sha256-clinadyqJrmBVNIp2FzHLls2ZrC8tjfS2vFuxJiVZjg=\",\n   \"pom\": \"sha256-AjkvvUziGQH5RWFUcrHU1NNZGzqr3wExBfXJLsMstPA=\"\n  },\n  \"org/checkerframework#checker-qual/3.41.0\": {\n   \"jar\": \"sha256-L58kW/aOQlnWEIlPJAbcH2Nj3GOTAr1WboJy5PRUEXI=\",\n   \"module\": \"sha256-s4ZywX9FUnayEO00Av+S3OZmdwsajGEMfMNK1UxTLSA=\",\n   \"pom\": \"sha256-XHOwdwVAhCzwagHSZLu4muXiSGadydqA6GHoIz3UZ1s=\"\n  },\n  \"org/checkerframework#checker-qual/3.43.0\": {\n   \"jar\": \"sha256-P7wumPBYVMPfFt+auqlVuRsVs+ysM2IyCO1kJGQO8PY=\",\n   \"module\": \"sha256-+BYzJyRauGJVMpSMcqkwVIzZfzTWw/6GD6auxaNNebQ=\",\n   \"pom\": \"sha256-kxO/U7Pv2KrKJm7qi5bjB5drZcCxZRDMbwIxn7rr7UM=\"\n  },\n  \"org/codehaus/groovy#groovy/3.0.22\": {\n   \"jar\": \"sha256-ySySxLmxg/mYG6c5nzZZLl461vTNrHEBtaIswXmY0T8=\",\n   \"pom\": \"sha256-Ubcx5c/xIe/W0yD0qAHNYWhpxb7U6cDjU7KQYLHSNV8=\"\n  },\n  \"org/codehaus/mojo#animal-sniffer-annotations/1.23\": {\n   \"jar\": \"sha256-n/5Sa/Q6Y0jp2LM7nNb1gKf17tDPBVkTAH7aJj3pdNA=\",\n   \"pom\": \"sha256-VhDbBrczZBrLx6DEioDEAGnbYnutBD+MfI16+09qPSc=\"\n  },\n  \"org/codehaus/mojo#animal-sniffer-annotations/1.24\": {\n   \"jar\": \"sha256-xyDm5by+ay9I3tdaR7zNt2Pu3nnRQzAQLg01Lj2J7ZI=\",\n   \"pom\": \"sha256-iEhPYKatQjipf+us8rMz6eCMF4uPGAoFo+2/9KOKg24=\"\n  },\n  \"org/codehaus/mojo#animal-sniffer-parent/1.23\": {\n   \"pom\": \"sha256-a38FSrhqh/jiWZ81gIsJiZIuhrbKsTmIAhzRJkCktAQ=\"\n  },\n  \"org/codehaus/mojo#animal-sniffer-parent/1.24\": {\n   \"pom\": \"sha256-Sd2rQ8g2HcLvDB/4fLWQ+nIxcCq59i4m1RLcGKHxzQQ=\"\n  },\n  \"org/codehaus/mojo#mojo-parent/74\": {\n   \"pom\": \"sha256-FHIyWhbwsb2r7SH6SDk3KWSURhApTOJoGyBZ7cZU8rM=\"\n  },\n  \"org/codehaus/mojo#mojo-parent/84\": {\n   \"pom\": \"sha256-L+UQYYsvYPzV8vuCvEssLDRASNdPML5xn8uGgp7orDA=\"\n  },\n  \"org/eclipse/ee4j#project/1.0.2\": {\n   \"pom\": \"sha256-dJWgenl+iOQ8O8GodCG9ix/FXjIpH6GOTjLYAx3chz8=\"\n  },\n  \"org/eclipse/ee4j#project/1.0.5\": {\n   \"pom\": \"sha256-kWtHlNjYIgpZo/32pk2+eUrrIzleiIuBrjaptaLFkaY=\"\n  },\n  \"org/eclipse/ee4j#project/1.0.6\": {\n   \"pom\": \"sha256-Tn2DKdjafc8wd52CQkG+FF8nEIky9aWiTrkHZ3vI1y0=\"\n  },\n  \"org/glassfish/jaxb#jaxb-bom/2.3.2\": {\n   \"pom\": \"sha256-oQGLtUZ47Z9ayy96QITjhf9RAgH06dv1913GpnX2a+c=\"\n  },\n  \"org/glassfish/jaxb#jaxb-runtime/2.3.2\": {\n   \"jar\": \"sha256-5uCh6J+2/3hieeagCC1c71LcLr5nBT0EGABzdlK0/Rs=\",\n   \"pom\": \"sha256-lEilrX+mimCD375PQsjIPggrkgKhBUAfxo6UTCZUizQ=\"\n  },\n  \"org/glassfish/jaxb#txw2/2.3.2\": {\n   \"jar\": \"sha256-SmqfSDOI1GG4GqmijGhbi3TAWXmTvxiEsE7dvKlfSP4=\",\n   \"pom\": \"sha256-p53QAvsDgYP/KGomNb4uaMEDuH4OZHF9jUS/0Bf9M+o=\"\n  },\n  \"org/hamcrest#hamcrest-core/1.3\": {\n   \"jar\": \"sha256-Zv3vkelzk0jfeglqo4SlaF9Oh1WEzOiThqekclHE2Ok=\",\n   \"pom\": \"sha256-/eOGp5BRc6GxA95quCBydYS1DQ4yKC4nl3h8IKZP+pM=\"\n  },\n  \"org/hamcrest#hamcrest-library/1.3\": {\n   \"jar\": \"sha256-cR1kUi+exBCYO9MQk0KW2hNL5CVKElCAoEFuwXjfrRw=\",\n   \"pom\": \"sha256-HOtL+w8JiuKbk1BEsjY+ETIzE/4+0gVd+LeXN9UFYnc=\"\n  },\n  \"org/hamcrest#hamcrest-parent/1.3\": {\n   \"pom\": \"sha256-bVNflO+2Y722gsnyelAzU5RogAlkK6epZ3UEvBvkEps=\"\n  },\n  \"org/jcommander#jcommander/1.85\": {\n   \"jar\": \"sha256-+nVS0oMaKyB3jYaFHQk+3KaPvAp395K2IjEQ5PrmenA=\",\n   \"module\": \"sha256-v6NtKUfZh28yp0B5Y5nne7mMcyiZe0W9JrP32Ss3pUo=\",\n   \"pom\": \"sha256-5hQBz9Hk8g4qQEiHLkXu7ncWodb5n2aKR3cSLdKILEg=\"\n  },\n  \"org/jetbrains#annotations/13.0\": {\n   \"jar\": \"sha256-rOKhDcji1f00kl7KwD5JiLLA+FFlDJS4zvSbob0RFHg=\",\n   \"pom\": \"sha256-llrrK+3/NpgZvd4b96CzuJuCR91pyIuGN112Fju4w5c=\"\n  },\n  \"org/jetbrains#annotations/23.0.0\": {\n   \"jar\": \"sha256-ew8ZckCCy/y8ZuWr6iubySzwih6hHhkZM+1DgB6zzQU=\",\n   \"pom\": \"sha256-yUkPZVEyMo3yz7z990P1P8ORbWwdEENxdabKbjpndxw=\"\n  },\n  \"org/jetbrains/intellij/deps#trove4j/1.0.20200330\": {\n   \"jar\": \"sha256-xf1yW/+rUYRr88d9sTg8YKquv+G3/i8A0j/ht98KQ50=\",\n   \"pom\": \"sha256-h3IcuqZaPJfYsbqdIHhA8WTJ/jh1n8nqEP/iZWX40+k=\"\n  },\n  \"org/jetbrains/intellij/deps/kotlinx#kotlinx-coroutines-bom/1.8.0-intellij-14\": {\n   \"pom\": \"sha256-HUFjTSKbHviGsEg6F+S225NrRkP5QBqzS+UWCc+6YD0=\"\n  },\n  \"org/jetbrains/intellij/deps/kotlinx#kotlinx-coroutines-core-jvm/1.8.0-intellij-14\": {\n   \"jar\": \"sha256-7wQ4Vu+POHA5FpYPrBacNZ2Y1f69Vx1n/M3+dbo3jeM=\",\n   \"module\": \"sha256-Z3M5jeX7L0MyuzdL5AGgNdLxTBM4/rNEYR81hFmZx/c=\",\n   \"pom\": \"sha256-zgsI7fz5rY8Sp2+ZcAUQqOX0Md4tqrFvnwOsUkJbK64=\"\n  },\n  \"org/jetbrains/kotlin#abi-tools-api/2.3.10\": {\n   \"jar\": \"sha256-E2nLVCrmR6nVVJ5thkkh6g+GApdJWRmXteWqFhyXGIs=\",\n   \"pom\": \"sha256-x12MiniT5DijbBZeA1I+uHRDZ6wNaV4sdrZ48LAjnE8=\"\n  },\n  \"org/jetbrains/kotlin#abi-tools/2.3.10\": {\n   \"jar\": \"sha256-lbCSwjPTRrHTScN0ovZpHozCx5dLjCI8blhlVo5r4xw=\",\n   \"pom\": \"sha256-dF+mCZPgkwSOup63kIAcUPDvs4NKK9GDTZAbXwCuHdY=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-bom/1.8.22\": {\n   \"pom\": \"sha256-yNeU63YYiNNDaeZ33o6roLAfnop1bPv/UyFcz6XFjD8=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-build-tools-api/2.3.10\": {\n   \"jar\": \"sha256-EYZyC5EGhN+drZy5YBXM8ZF27FZVzrCbAfvEUjC9H2Q=\",\n   \"pom\": \"sha256-3pBa7tBWHZduxSEU8vPk5mls05Mg9DqxFvF/79c9U8I=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-build-tools-compat/2.3.10\": {\n   \"jar\": \"sha256-2CMtB+usVuEXJqBmYUPdSmF77b46C/HxQCD4BF2KybM=\",\n   \"pom\": \"sha256-D4sDOmFBIvxEBuu+rSSKRYoEkKikvJfYSpb8U3DD88Y=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-build-tools-impl/2.3.10\": {\n   \"jar\": \"sha256-MvAqavcrjgyC8mNDto4NHaHIRIJ0eP6y+p3EDfX5u1o=\",\n   \"pom\": \"sha256-21a2yydSjXKzGHKFbO2rCOx7GBJ0VMBux3iAhQQR21g=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-compiler-embeddable/2.0.21\": {\n   \"jar\": \"sha256-n6jN0d4NzP/hVMmX1CPsa19TzW2Rd+OnepsN4D+xvIE=\",\n   \"pom\": \"sha256-vUZWpG7EGCUuW8Xhwg6yAp+yqODjzJTu3frH6HyM1bY=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-compiler-embeddable/2.2.0\": {\n   \"jar\": \"sha256-svdD6luhL2ng815djUYGnXTI4oYQh1SKfg4Up4S8TPE=\",\n   \"pom\": \"sha256-FqFd0ZfPJBNJT3iMuWFE2aFGJnw9b38cFbejweBSNGo=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-compiler-embeddable/2.3.10\": {\n   \"jar\": \"sha256-rGoYJ4U0U4C121CF2+6j9fDpJeLhAOVKsFm1eH1PbkA=\",\n   \"pom\": \"sha256-/i/NN7clxYZIc8/3jOZNEyBuFOCPS5guEVD2jJwOiW0=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-compiler-runner/2.3.10\": {\n   \"jar\": \"sha256-CpGS+4AlHMrRzb8sq6KEiOjUaGnS5eSVfKF5wnwKTuA=\",\n   \"pom\": \"sha256-4CtYvQDZCjExN9L18ZIZ2tt3FXVebd1t74mYpa/RgCc=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-daemon-client/2.3.10\": {\n   \"jar\": \"sha256-0hpvd9mAOmFebRUIVzd+EKnox80Grm4cHrmZIP2ji5M=\",\n   \"pom\": \"sha256-sOpstyMer+j7oN+zf8MZhJkED3TE00EmPtq2yE0Z2sA=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-daemon-embeddable/2.0.21\": {\n   \"jar\": \"sha256-saCnPFAi+N0FpjjGt2sr1zYYGKHzhg/yZEEzsd0r2wM=\",\n   \"pom\": \"sha256-jbZ7QN1gJaLtBpKU8sm8+2uW2zFZz+927deEHCZq+/A=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-daemon-embeddable/2.2.0\": {\n   \"jar\": \"sha256-omzI4thhkZfMTRFb6ndm6aqODx54duoETuG58wVlRgE=\",\n   \"pom\": \"sha256-vSrk4skWBWVKilUn2nV/KyJ2WA0fXq6+q9M0OvjVxGc=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-daemon-embeddable/2.3.10\": {\n   \"jar\": \"sha256-NZcReADmCSO8rdAYf6XbyqZvgpAIKdaSXR6kHdfiejA=\",\n   \"pom\": \"sha256-2Hj9fQeNtQmxK/bHQK6jHPckUxN6hMd1vU5SHJ77eP0=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-klib-abi-reader/2.3.10\": {\n   \"jar\": \"sha256-nRYKDGxhKhFEhhRN8Fd7VXlfXaC4Z/4458uvYUzAQM0=\",\n   \"pom\": \"sha256-YNkcF6pipglPHMEJUybmqWjCjCtUBtrOmiYNsBtEa5Q=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-klib-commonizer-embeddable/2.3.10\": {\n   \"jar\": \"sha256-4jjzNyD2Tlk3klr3cvi9hzivM3EJvzYiPryjgqUyS2A=\",\n   \"pom\": \"sha256-2RshiOZU+OhfcoQDbE+hzl8xJ/B60GeQn1kRBCQEafg=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-metadata-jvm/2.2.20\": {\n   \"jar\": \"sha256-hSTqyQ9+jg8TZog/LGyCDJO/ph3z12hXyNPoA89nMV0=\",\n   \"pom\": \"sha256-e2qAtqLSZ2oEIvaWg4EyMVQlUfYbMgxochz7nh9ZCdA=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-metadata-jvm/2.3.10\": {\n   \"jar\": \"sha256-wd9bxlMLTHRLE3iNv4VApGmJj9+83mq8qRGRhPXTcHw=\",\n   \"pom\": \"sha256-M4BDrrtxc3PkFFgTeehvS0ztfs+9HcKH3zPRX8FXm+I=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-reflect/1.6.10\": {\n   \"jar\": \"sha256-MnesECrheq0QpVq+x1/1aWyNEJeQOWQ0tJbnUIeFQgM=\",\n   \"pom\": \"sha256-V5BVJCdKAK4CiqzMJyg/a8WSWpNKBGwcxdBsjuTW1ak=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-reflect/1.8.21\": {\n   \"jar\": \"sha256-imzVo88JKs7idM4sRE3Dbu/bYxV5hZ3U2FezMJpSnJE=\",\n   \"pom\": \"sha256-OdeNszIizbsythjHxSI9RFfMGXQoVtEWqFCQ5KlvYjo=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-reflect/2.0.21\": {\n   \"jar\": \"sha256-OtL8rQwJ3cCSLeurRETWEhRLe0Zbdai7dYfiDd+v15k=\",\n   \"pom\": \"sha256-Aqt66rA8aPQBAwJuXpwnc2DLw2CBilsuNrmjqdjosEk=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-reflect/2.2.0\": {\n   \"jar\": \"sha256-Iw2RwuQQ48/KOk3HPSVUVfYv9SqsCRozOXpuML3pG/c=\",\n   \"pom\": \"sha256-3u2DHvy2Y+TPPVEh5a55byAeN7gT0sfWB7Xx+Khv5S4=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-script-runtime/2.0.21\": {\n   \"jar\": \"sha256-nBEfjQit5FVWYnLVYZIa3CsstrekzO442YKcXjocpqM=\",\n   \"pom\": \"sha256-lbLpKa+hBxvZUv0Tey5+gdBP4bu4G3V+vtBrIW5aRSQ=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-script-runtime/2.2.0\": {\n   \"jar\": \"sha256-Ttl/0eDJux0JSO1eY8yRRWMTpZUYKVuSssFRSu9VYVA=\",\n   \"pom\": \"sha256-5QdIv0Z09lRgPnsbuDBjTsmevZwzDeukytzuwHZ8u1Y=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-script-runtime/2.3.10\": {\n   \"jar\": \"sha256-0LOwjs+JAcZhCqDo77Kds3Mb+9lq/U+YfMgXAWseAD4=\",\n   \"pom\": \"sha256-wiijGFXV31s4bhGfFyjPqeQ9Lc23gTnCkqjT7SZ6xYc=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-scripting-common/2.3.10\": {\n   \"jar\": \"sha256-fpFD0iyAs9AslpgkMbHJa6CR7/e/Q5CrS4lJ7O5D8oU=\",\n   \"pom\": \"sha256-dYvXM25rfVJwtwbCgO+qcSKwT+cWzwmZbhUhWQ8zB0E=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-scripting-compiler-embeddable/2.3.10\": {\n   \"jar\": \"sha256-cjlPfxS2o7mvwuHMncF6HZYQNXFVtMuazGY6O0QuwpA=\",\n   \"pom\": \"sha256-A2sKrxS+1njEO47BXQGqXIRan+kjA3q8r0TKaWC3vk4=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-scripting-compiler-impl-embeddable/2.3.10\": {\n   \"jar\": \"sha256-Ezqc4YWqR9FRphLyzFli0yuMpgX44ieh1/HhsJ4m7m4=\",\n   \"pom\": \"sha256-NFeCP8HSlV8GBVk7m+nWUIbwwaEeGzdt9BHwqifkAuU=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-scripting-jvm/2.3.10\": {\n   \"jar\": \"sha256-vyMqUZrwKVLhAQhvq9SIRDYldlbEXbCBeDysSxSWZLc=\",\n   \"pom\": \"sha256-gzabR9onQj0BR+UZh/tY4YbjWt7KQUDxfysOu7uYPTc=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-serialization-compiler-plugin-embeddable/2.3.10\": {\n   \"jar\": \"sha256-ddZuSpUHKpXnAkU+oH/MFHmsg1wWI9565znQROb182Y=\",\n   \"pom\": \"sha256-v0Km4mvetFGIxtf4N7PXltGIeT8uoeuO6IvSNNwYI2M=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib-common/1.8.21\": {\n   \"jar\": \"sha256-akTJ7MnXdU2elD+x41iMdNSj8Xhb5RB09J1sVyNoKnM=\",\n   \"pom\": \"sha256-4ZpVd8vOqJcolw21MzyCZMjGmuci7recv0HV8LDJrmU=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib-common/1.9.0\": {\n   \"jar\": \"sha256-KDJ0IEvXwCB4nsRvj45yr0JE1/VQszkqV+XKAGrXqiw=\",\n   \"pom\": \"sha256-NmDTanD+s6vknxG5BjPkHTYnNXbwcbDhCdqbOg3wgqU=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib-common/2.0.21\": {\n   \"module\": \"sha256-b134r2M2AKa5z7D8x2SvPVEZ83Zndne5G2rugWsdMKs=\",\n   \"pom\": \"sha256-X0As+413MZW5ZwUBJMnom1+EsXJGThiUkpeJv1xMLyk=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib-common/2.2.0\": {\n   \"module\": \"sha256-WPwNZk/Dpn5+a+n9vq7b0hLfo+Un90T4YeeSzacsDkc=\",\n   \"pom\": \"sha256-U3q0BzqEelm6dtmaZFqGCbU4L/pdJZGjVLL6MR9JlzM=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib-common/2.3.10\": {\n   \"module\": \"sha256-GrI+xfbZ3iNeKNRjo/IKJD96VY+aht3VOpSEyHfmeS8=\",\n   \"pom\": \"sha256-xTKXlnQm4FsXVVyEzWmqKo3Q3guFkXLoDsdH/5vq/tA=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib-jdk7/1.8.0\": {\n   \"jar\": \"sha256-TIidHZgD9fLrbBWSprfmI2msdmDJ7uFauhb+wFkWNmY=\",\n   \"pom\": \"sha256-36lkSmrluJjuR1ux9X6DC6H3cK7mycFfgRKqOBGAGEo=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib-jdk7/1.8.20\": {\n   \"jar\": \"sha256-rx7EDDuVGv3MDCoBc8e4F2PFKBwtW6+/CoVEokxdzAw=\",\n   \"pom\": \"sha256-NiLRBleM3cwKnsIPjOgV9/Sf9UL2QCKNIUH8r4BhawY=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib-jdk7/2.2.0\": {\n   \"jar\": \"sha256-DRC8DUK4YF8jYpo/MeonwZzbyp3N9PU/bSLNY2aDbRg=\",\n   \"pom\": \"sha256-lcIYnDXve/xIlRwyrXCEeyHzgJ0m9dCnbiNXCHmYjDA=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib-jdk8/1.8.0\": {\n   \"jar\": \"sha256-BbYoBEQbDJoZILa31c9zKaTiS2JYR44ysfBGygGQCUY=\",\n   \"pom\": \"sha256-K7bHVRuXx7oCn5hmWC56oZ1jq/1M1T2j/AxGLzq1/CY=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib-jdk8/1.8.20\": {\n   \"jar\": \"sha256-45i2eXdiJxi/GP+ZtznH2doGDzP7RYouJSAyIcFq8BA=\",\n   \"pom\": \"sha256-OkYiFKM26ZVod2lTGx43sMgdjhDJlJzV6nrh14A6AjI=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib-jdk8/2.2.0\": {\n   \"jar\": \"sha256-rcFmSNu881sNEOfsMBw110bRwv5GDGBqulnxKxF8+bA=\",\n   \"pom\": \"sha256-I00G/b3CncvAdEfijEomq6uVmdXD2qPZKjTmrt6iNqY=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib/1.8.21\": {\n   \"jar\": \"sha256-BCoc0ayXbNz+XrY/HY4LC4kskkjhWmnIz7pJXVRupSo=\",\n   \"pom\": \"sha256-/gzZ4yGT5FMzP9Kx9XfmYvtavGkHECu5Z4F7wTEoD9c=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib/1.9.0\": {\n   \"jar\": \"sha256-Na7/vi21qkRgcs7lD87ki3+p4vxRyjfAzH19C8OdlS4=\",\n   \"pom\": \"sha256-N3UiY/Ysw+MlCFbiiO5Kc9QQLXJqd2JwNPlIBsjBCso=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib/2.0.21\": {\n   \"jar\": \"sha256-8xzFPxBafkjAk2g7vVQ3Vh0SM5IFE3dLRwgFZBvtvAk=\",\n   \"module\": \"sha256-gf1tGBASSH7jJG7/TiustktYxG5bWqcpcaTd8b0VQe0=\",\n   \"pom\": \"sha256-/LraTNLp85ZYKTVw72E3UjMdtp/R2tHKuqYFSEA+F9o=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib/2.2.0\": {\n   \"jar\": \"sha256-ZdEthaO4ZcFg25FHhRcSpksQ2t1osi7qIqlb+KhnDco=\",\n   \"module\": \"sha256-pbmP3NnbAX1ULhlyJdzuGNZYpW3h2yzEHhMZbWsXaaQ=\",\n   \"pom\": \"sha256-jDyCEAfBNBFVhzm589U4LrgVUds4lc/7iVYeVsD03BY=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib/2.2.20\": {\n   \"jar\": \"sha256-iDbM/9NYX63amQEkSyDUKQHS881YEFjYQ04v+rzzo+c=\",\n   \"module\": \"sha256-yRj1IU0CGnLjdn8nVul9EDpSbgTxQj2jZj79+1hH25U=\",\n   \"pom\": \"sha256-SosIbmQxvPYjY39Ssv8ZLhrbkTg4dC5cDupwqN7kKcQ=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib/2.3.0\": {\n   \"jar\": \"sha256-iHWHyRcTJQrVL+FK2RZtBCwzg1BJiQ6UN/NV/8WhlbE=\",\n   \"module\": \"sha256-CRCoo7aWD8eSxFxWqR18Oj8mKG8DKVVUtRnP83h1baI=\",\n   \"pom\": \"sha256-TVJW0+SETmVrDKQF9jUNbyF5XCQ3WzRSUmxUZ92ZtaI=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-stdlib/2.3.10\": {\n   \"jar\": \"sha256-9hZixtOi+O9b00NioC2Hd3LDnzk805T+slnfr39NhDc=\",\n   \"module\": \"sha256-6ocfZjGc2ierJSL6jZKRMdXm/LUzROT1TNrgMRHRUKo=\",\n   \"pom\": \"sha256-Sj+O2KRMftizGuUQEzSIBYfBCXBlP0s7lE/bI4MLJCA=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-test/2.2.0\": {\n   \"jar\": \"sha256-jbF1o/Vs8Tnr34k28pPOWmSha1KgQIgE4OwHfohI6zI=\",\n   \"module\": \"sha256-VKfhwH1Wew4MfZE5V563Qe0H4N5bWgx/86V6017mWjw=\",\n   \"pom\": \"sha256-cBlAy7cIWLKmC6xeNyqmsKciVI0c3QrVtSU6RmH6R2M=\"\n  },\n  \"org/jetbrains/kotlin#kotlin-tooling-core/2.3.10\": {\n   \"jar\": \"sha256-NnFCeBKZvA+RIMHe7A5ik0oa+ep/AaqpxaU1TcXY19k=\",\n   \"pom\": \"sha256-5hhz7dWo3QMaa6l1nAXRVpBlnmEuPUjB7RInN9q0SYY=\"\n  },\n  \"org/jetbrains/kotlinx#atomicfu-jvm/0.22.0\": {\n   \"jar\": \"sha256-LaBzcn86teVYTnTBLhFRnJCK4t+vausl3tQrZoIpeII=\",\n   \"module\": \"sha256-NQcPkjzmn4fG+Q5TBXIOJwRAm2miN0SSrEW+cAde5Jo=\",\n   \"pom\": \"sha256-CSM9N5NaKWh4/H6+92ECEmT64oBb+SZCAyi3y5DWelY=\"\n  },\n  \"org/jetbrains/kotlinx#atomicfu/0.22.0\": {\n   \"module\": \"sha256-XIdu9+HuB90wchxDtG6tjtojzisiJg5w+TPL4AToBgc=\",\n   \"pom\": \"sha256-lsP4fQNiioMRHD35NOgD134Q1hNCD3vE0wBZg4bI7zc=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-android/1.7.3\": {\n   \"jar\": \"sha256-Wf/7Jr7hLDLa3PpdQgwqfbhdMlNRgSixcO/acmYTJW0=\",\n   \"module\": \"sha256-SN/YE57e5UgbzIsl4k11hqymFfDR7SvrJC3HR47UzuA=\",\n   \"pom\": \"sha256-AgUVJG0Z4XbVYm6xGPgPl/eHbRrM2M7BWxpBWSV9UFQ=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-android/1.9.0\": {\n   \"jar\": \"sha256-vXg6zS+XOIRdWDgPRvRbXN6V0MsDAn1WclM4smv8TXI=\",\n   \"module\": \"sha256-ffHgTXfwzEEYkNmZqUSbDjvNTxWaRsMGCxECBMpgfUM=\",\n   \"pom\": \"sha256-voYCDNW5O4poykMYWgSbmwuqNF/Rvh/aoBT9rvktbnw=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.6.0\": {\n   \"pom\": \"sha256-2iMnJQ6r8q3rW6TQFaQ380R1EC0Vuj10SNpx38/Rlt4=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.6.4\": {\n   \"pom\": \"sha256-qyYUhV+6ZqqKQlFNvj1aiEMV/+HtY/WTLnEKgAYkXOE=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.7.3\": {\n   \"pom\": \"sha256-Tl0ZAOY3nvP1lw0EqPMFKa3IL4WejMEHwhzoFJ72ZsQ=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.8.0\": {\n   \"pom\": \"sha256-Ejnp2+E5fNWXE0KVayURvDrOe2QYQuQ3KgiNz6i5rVU=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.9.0\": {\n   \"pom\": \"sha256-vqVRHpAB8sWTq1CA3xMbIZq14ghcxZec5YPqzUlG/Xg=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-core-jvm/1.6.4\": {\n   \"jar\": \"sha256-wkyLsnuzIMSpOHFQGn5eDGFgdjiQexl672dVE9TIIL4=\",\n   \"module\": \"sha256-DZTIpBSD58Jwfr1pPhsTV6hBUpmM6FVQ67xUykMho6c=\",\n   \"pom\": \"sha256-Cdlg+FkikDwuUuEmsX6fpQILQlxGnsYZRLPAGDVUciQ=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-core-jvm/1.7.3\": {\n   \"jar\": \"sha256-GrOsw48+c1XE+dHsYhB6RvpzyJnzBw0FXl1Dc9/mfhI=\",\n   \"module\": \"sha256-NNbumbdqwGK1FVW0pwvhg0n+VWbaeaGQYU8XHIC2U44=\",\n   \"pom\": \"sha256-dThYdT3su7I5c0PiuHHwYvaXgS6UIuQcnuRqZrk+7jA=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-core-jvm/1.8.0\": {\n   \"jar\": \"sha256-mGCQahk3SQv187BtLw4Q70UeZblbJp8i2vaKPR9QZcU=\",\n   \"module\": \"sha256-/2oi2kAECTh1HbCuIRd+dlF9vxJqdnlvVCZye/dsEig=\",\n   \"pom\": \"sha256-pWM6vVNGfOuRYi2B8umCCAh3FF4LduG3V4hxVDSIXQs=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-core-jvm/1.9.0\": {\n   \"jar\": \"sha256-rYnCiSI15nDyItgZyz2BGIFDyxmgW1nfmImuQmn1xwo=\",\n   \"module\": \"sha256-syGomeQNPONFcHqiz9qZg60NzGn+p0qbi/kGoWwc+Kk=\",\n   \"pom\": \"sha256-GcSImUGzqgmL1XzGTwL5razGVNVxoSqVbeS1uxSMZJk=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-core/1.6.0\": {\n   \"module\": \"sha256-USz6to8A1zY0YbXdAmN90ZBIs2Sonhlg+ZeExwWienw=\",\n   \"pom\": \"sha256-IlUE64cgcBMpbf1p4hrMYPViQFRubqjUJSeDvc+zJNk=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-core/1.6.4\": {\n   \"module\": \"sha256-pu7UoYNViOfIT817BHX86aezREyHDrx5e4i6ZMz0V2s=\",\n   \"pom\": \"sha256-E2tO24ekfhUlHwhC8xifoxl560q1Gs+0I5tL+NFtRYg=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-core/1.7.3\": {\n   \"module\": \"sha256-f7FiOWWU7CjhtqRBG0V5SadnD14SAZF2d04f1rlHG78=\",\n   \"pom\": \"sha256-7W6wOYcXA14p8cHWCk4927iYWPPbnge1etdZ03Ta6Ck=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-coroutines-core/1.9.0\": {\n   \"module\": \"sha256-rVNANKlTtOEsvuuHTGat+LHKFN8V/g0uZUeqNOht/so=\",\n   \"pom\": \"sha256-dw8nk9BeKwJ7nHmZOOwdLU7xQc5YGceAwyw5lcrbCkc=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-html-jvm/0.8.1\": {\n   \"jar\": \"sha256-mL2hx4pQKKE0zrJbY/XBMMiTSXMNNf1H73SQtr8LY7M=\",\n   \"module\": \"sha256-LmFHvYMmyke+lXIwrS7LOu6OGUoK/bc6Z3YRbu16Sl8=\",\n   \"pom\": \"sha256-pKgxcBDDGabHdxdr72MZsyzJbha0hDlOJqLL+Fcf/QQ=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-serialization-bom/1.10.0\": {\n   \"pom\": \"sha256-Lpc+Tfw7xjjdLmg9qVncK5eSMpe/O49gx/8A1h6sOwc=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-serialization-core-jvm/1.10.0\": {\n   \"jar\": \"sha256-FNbyfOKPYevEpRbVYvkRt7wBz75Tl/uITEXqDbBExjU=\",\n   \"module\": \"sha256-7tVPsrYUrZV8CP7iDeZeAK1dVs6jkORLpgorhUKBtgw=\",\n   \"pom\": \"sha256-XEwMgBAEK39UKrSE3qijaHuWwglE5ywGPqwWonOa2OQ=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-serialization-core-jvm/1.4.1\": {\n   \"jar\": \"sha256-66fxyFQpbkzhQY+wE2D48QxWg+fEWqNHIBhBegZ2NvM=\",\n   \"module\": \"sha256-c7yUvdX8hmIVCaZxXD/jRJBO59tYBqDGF5LOI1YInuk=\",\n   \"pom\": \"sha256-blIuCoY+rZXX8nMuAPb0W/MC6jLgm+iMZNBgwHZV+DI=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-serialization-core/1.10.0\": {\n   \"module\": \"sha256-pJJxm8QF9QTg2EjzTpQfL5RvgntHhzayW6nGlwjZyao=\",\n   \"pom\": \"sha256-ZCi5KEMl0zYxmSHrBY71Fd8BzY6O9s3BNB7PqO9rNXQ=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-serialization-core/1.4.1\": {\n   \"module\": \"sha256-YOWBw5fduUYewfHe5bu0oju37H0JspYCQZYiACKqcJA=\",\n   \"pom\": \"sha256-BKWsA/zZmdCmsP5RUG9ln6k/q0Cb0wkFDjMQg5NjdL8=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-serialization-json-jvm/1.10.0\": {\n   \"jar\": \"sha256-rx4+Ho7jF2Ro4exynfhTsgZgcd6UqExBKtn6E1yzfzo=\",\n   \"module\": \"sha256-pf5GHIQaWLDKWZaUF8ZaWe9wWHzJw4eD3ox4sKP5UMg=\",\n   \"pom\": \"sha256-eGypyBw8fsU9kkuyNqAI8hTCwJbRMX3J97N47NEP1c0=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-serialization-json-jvm/1.4.1\": {\n   \"jar\": \"sha256-r2BMRnNxIdQiX9tg7w4Xdmo8lLfBye92tOOlx3M9VX4=\",\n   \"module\": \"sha256-yPv95LXuHkGmkXUWXoOZkdFQFmWnWQ4jFiMmUBrGEiw=\",\n   \"pom\": \"sha256-Mtk0fHEBNfvnCnAZKFe8c7BpWZYu3s//MZJgOb4kuGE=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-serialization-json/1.10.0\": {\n   \"module\": \"sha256-yfNeuGIPLTiZoFgoXEF3NciVbu0rGFO+2jrBPHeCuMc=\",\n   \"pom\": \"sha256-+uXntE6iGb3qzoSlC/8y06x/bdGb5KjiRTbhgzxUoNY=\"\n  },\n  \"org/jetbrains/kotlinx#kotlinx-serialization-json/1.4.1\": {\n   \"module\": \"sha256-6ZIjAK/2Y+VezvfT/KMFy2ChR1Wx+YDZQDnjwcq2Rcw=\",\n   \"pom\": \"sha256-dkrPeeNju9YeyL+dUcOw2XzryNCLSTjDBiPA+vbRNaA=\"\n  },\n  \"org/jspecify#jspecify/1.0.0\": {\n   \"jar\": \"sha256-H61ua+dVd4Hk0zcp1Jrhzcj92m/kd7sMxozjUer9+6s=\",\n   \"module\": \"sha256-0wfKd6VOGKwe8artTlu+AUvS9J8p4dL4E+R8J4KDGVs=\",\n   \"pom\": \"sha256-zauSmjuVIR9D0gkMXi0N/oRllg43i8MrNYQdqzJEM6Y=\"\n  },\n  \"org/junit#junit-bom/5.10.2\": {\n   \"module\": \"sha256-3iOxFLPkEZqP5usXvtWjhSgWaYus5nBxV51tkn67CAo=\",\n   \"pom\": \"sha256-Fp3ZBKSw9lIM/+ZYzGIpK/6fPBSpifqSEgckzeQ6mWg=\"\n  },\n  \"org/junit#junit-bom/5.9.2\": {\n   \"module\": \"sha256-qxN7pajjLJsGa/kSahx23VYUtyS6XAsCVJdyten0zx8=\",\n   \"pom\": \"sha256-LtB9ZYRRMfUzaoZHbJpAVrWdC1i5gVqzZ5uw82819wU=\"\n  },\n  \"org/jvnet/staxex#stax-ex/1.8.1\": {\n   \"jar\": \"sha256-IFIlSQVunlCqNe8LRFouR6U9Br4LCpRn1wTiSD/7BJo=\",\n   \"pom\": \"sha256-j8hPNs5tps6MiTtlOBmaf2mmmgcG2bF6PuajoJRS7tY=\"\n  },\n  \"org/ow2#ow2/1.5.1\": {\n   \"pom\": \"sha256-Mh3bt+5v5PU96mtM1tt0FU1r+kI5HB92OzYbn0hazwU=\"\n  },\n  \"org/ow2/asm#asm-analysis/9.8\": {\n   \"jar\": \"sha256-5kBzL7zTxicZJaUE8SXjg4Roj037v5LIYi387g0J7bk=\",\n   \"pom\": \"sha256-xXR+JccuGwfVJjx1x4rWGmJt0kWPr8r8I/gdMlPuQu0=\"\n  },\n  \"org/ow2/asm#asm-commons/9.8\": {\n   \"jar\": \"sha256-MwGhwctMWfzFKSZI2sHXxa7UwPBn376IhzuM3+d0BPQ=\",\n   \"pom\": \"sha256-95PnjwH3A3F9CUcuVs3yEv4piXDIguIRbo5Un7bRQMI=\"\n  },\n  \"org/ow2/asm#asm-tree/9.8\": {\n   \"jar\": \"sha256-FLeIDLfIXu0QHicQQy/D/7gydVMqaolNxMQJXUmtWfE=\",\n   \"pom\": \"sha256-cUnn+qDhkSlvh5ru2SCciULTmPBpjSzKGpxijy4qj3c=\"\n  },\n  \"org/ow2/asm#asm/9.8\": {\n   \"jar\": \"sha256-h26raoPa7K1cpn65/KuwY8l7WuuM8fynqYns3hdSIFE=\",\n   \"pom\": \"sha256-wTZ8O7OD12Gef3l+ON91E4hfLu8ErntZCPaCImV7W6o=\"\n  },\n  \"org/snakeyaml#snakeyaml-engine/2.7\": {\n   \"jar\": \"sha256-QFP4eMFxaSqrh4L1Ojl09D5V4rbtEsNoKzakaWjF3tE=\",\n   \"pom\": \"sha256-pSpiQ/hhWFZ5HRKX+XN0B2v4E0gGGvcUrQlj03WuXLc=\"\n  },\n  \"org/sonatype/oss#oss-parent/7\": {\n   \"pom\": \"sha256-tR+IZ8kranIkmVV/w6H96ne9+e9XRyL+kM5DailVlFQ=\"\n  },\n  \"org/sonatype/oss#oss-parent/9\": {\n   \"pom\": \"sha256-+0AmX5glSCEv+C42LllzKyGH7G8NgBgohcFO8fmCgno=\"\n  }\n }\n}\n"
  },
  {
    "path": "android/gradle/wrapper/gradle-wrapper.properties",
    "content": "#Mon Sep 15 22:54:28 CEST 2025\ndistributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-8.13-bin.zip\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "android/gradle.properties",
    "content": "# Project-wide Gradle settings.\n# IDE (e.g. Android Studio) users:\n# Gradle settings configured through the IDE *will override*\n# any settings specified in this file.\n# For more details on how to configure your build environment visit\n# http://www.gradle.org/docs/current/userguide/build_environment.html\n# Specifies the JVM arguments used for the daemon process.\n# The setting is particularly useful for tweaking memory settings.\norg.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\norg.gradle.parallel=true\n# When configured, Gradle will run in incubating parallel mode.\n# This option should only be used with decoupled projects. For more details, visit\n# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects\n# org.gradle.parallel=true\n# AndroidX package structure to make it clearer which packages are bundled with the\n# Android operating system, and which are packaged with your app's APK\n# https://developer.android.com/topic/libraries/support-library/androidx-rn\nandroid.useAndroidX=true\n# Kotlin code style for this project: \"official\" or \"obsolete\":\nkotlin.code.style=official\n# Enables namespacing of each library's R class so that its R class includes only the\n# resources declared in the library itself and none from the library's dependencies,\n# thereby reducing the size of the R class for that library\nandroid.nonTransitiveRClass=true\n"
  },
  {
    "path": "android/gradlew",
    "content": "#!/usr/bin/env sh\n\n#\n# Copyright 2015 the original author or authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##############################################################################\n##\n##  Gradle start up script for UN*X\n##\n##############################################################################\n\n# Attempt to set APP_HOME\n# Resolve links: $0 may be a link\nPRG=\"$0\"\n# Need this for relative symlinks.\nwhile [ -h \"$PRG\" ] ; do\n    ls=`ls -ld \"$PRG\"`\n    link=`expr \"$ls\" : '.*-> \\(.*\\)$'`\n    if expr \"$link\" : '/.*' > /dev/null; then\n        PRG=\"$link\"\n    else\n        PRG=`dirname \"$PRG\"`\"/$link\"\n    fi\ndone\nSAVED=\"`pwd`\"\ncd \"`dirname \\\"$PRG\\\"`/\" >/dev/null\nAPP_HOME=\"`pwd -P`\"\ncd \"$SAVED\" >/dev/null\n\nAPP_NAME=\"Gradle\"\nAPP_BASE_NAME=`basename \"$0\"`\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=\"maximum\"\n\nwarn () {\n    echo \"$*\"\n}\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n}\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"`uname`\" in\n  CYGWIN* )\n    cygwin=true\n    ;;\n  Darwin* )\n    darwin=true\n    ;;\n  MINGW* )\n    msys=true\n    ;;\n  NONSTOP* )\n    nonstop=true\n    ;;\nesac\n\nCLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=\"$JAVA_HOME/jre/sh/java\"\n    else\n        JAVACMD=\"$JAVA_HOME/bin/java\"\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=\"java\"\n    which java >/dev/null 2>&1 || die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\nfi\n\n# Increase the maximum file descriptors if we can.\nif [ \"$cygwin\" = \"false\" -a \"$darwin\" = \"false\" -a \"$nonstop\" = \"false\" ] ; then\n    MAX_FD_LIMIT=`ulimit -H -n`\n    if [ $? -eq 0 ] ; then\n        if [ \"$MAX_FD\" = \"maximum\" -o \"$MAX_FD\" = \"max\" ] ; then\n            MAX_FD=\"$MAX_FD_LIMIT\"\n        fi\n        ulimit -n $MAX_FD\n        if [ $? -ne 0 ] ; then\n            warn \"Could not set maximum file descriptor limit: $MAX_FD\"\n        fi\n    else\n        warn \"Could not query maximum file descriptor limit: $MAX_FD_LIMIT\"\n    fi\nfi\n\n# For Darwin, add options to specify how the application appears in the dock\nif $darwin; then\n    GRADLE_OPTS=\"$GRADLE_OPTS \\\"-Xdock:name=$APP_NAME\\\" \\\"-Xdock:icon=$APP_HOME/media/gradle.icns\\\"\"\nfi\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif [ \"$cygwin\" = \"true\" -o \"$msys\" = \"true\" ] ; then\n    APP_HOME=`cygpath --path --mixed \"$APP_HOME\"`\n    CLASSPATH=`cygpath --path --mixed \"$CLASSPATH\"`\n\n    JAVACMD=`cygpath --unix \"$JAVACMD\"`\n\n    # We build the pattern for arguments to be converted via cygpath\n    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`\n    SEP=\"\"\n    for dir in $ROOTDIRSRAW ; do\n        ROOTDIRS=\"$ROOTDIRS$SEP$dir\"\n        SEP=\"|\"\n    done\n    OURCYGPATTERN=\"(^($ROOTDIRS))\"\n    # Add a user-defined pattern to the cygpath arguments\n    if [ \"$GRADLE_CYGPATTERN\" != \"\" ] ; then\n        OURCYGPATTERN=\"$OURCYGPATTERN|($GRADLE_CYGPATTERN)\"\n    fi\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    i=0\n    for arg in \"$@\" ; do\n        CHECK=`echo \"$arg\"|egrep -c \"$OURCYGPATTERN\" -`\n        CHECK2=`echo \"$arg\"|egrep -c \"^-\"`                                 ### Determine if an option\n\n        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition\n            eval `echo args$i`=`cygpath --path --ignore --mixed \"$arg\"`\n        else\n            eval `echo args$i`=\"\\\"$arg\\\"\"\n        fi\n        i=`expr $i + 1`\n    done\n    case $i in\n        0) set -- ;;\n        1) set -- \"$args0\" ;;\n        2) set -- \"$args0\" \"$args1\" ;;\n        3) set -- \"$args0\" \"$args1\" \"$args2\" ;;\n        4) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" ;;\n        5) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" ;;\n        6) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" ;;\n        7) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" ;;\n        8) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" \"$args7\" ;;\n        9) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" \"$args7\" \"$args8\" ;;\n    esac\nfi\n\n# Escape application args\nsave () {\n    for i do printf %s\\\\n \"$i\" | sed \"s/'/'\\\\\\\\''/g;1s/^/'/;\\$s/\\$/' \\\\\\\\/\" ; done\n    echo \" \"\n}\nAPP_ARGS=`save \"$@\"`\n\n# Collect all arguments for the java command, following the shell quoting and substitution rules\neval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \"\\\"-Dorg.gradle.appname=$APP_BASE_NAME\\\"\" -classpath \"\\\"$CLASSPATH\\\"\" org.gradle.wrapper.GradleWrapperMain \"$APP_ARGS\"\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "android/gradlew.bat",
    "content": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (the \"License\");\r\n@rem you may not use this file except in compliance with the License.\r\n@rem You may obtain a copy of the License at\r\n@rem\r\n@rem      https://www.apache.org/licenses/LICENSE-2.0\r\n@rem\r\n@rem Unless required by applicable law or agreed to in writing, software\r\n@rem distributed under the License is distributed on an \"AS IS\" BASIS,\r\n@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n@rem See the License for the specific language governing permissions and\r\n@rem limitations under the License.\r\n@rem\r\n\r\n@if \"%DEBUG%\" == \"\" @echo off\r\n@rem ##########################################################################\r\n@rem\r\n@rem  Gradle startup script for Windows\r\n@rem\r\n@rem ##########################################################################\r\n\r\n@rem Set local scope for the variables with windows NT shell\r\nif \"%OS%\"==\"Windows_NT\" setlocal\r\n\r\nset DIRNAME=%~dp0\r\nif \"%DIRNAME%\" == \"\" set DIRNAME=.\r\nset APP_BASE_NAME=%~n0\r\nset APP_HOME=%DIRNAME%\r\n\r\n@rem Resolve any \".\" and \"..\" in APP_HOME to make it shorter.\r\nfor %%i in (\"%APP_HOME%\") do set APP_HOME=%%~fi\r\n\r\n@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\r\nset DEFAULT_JVM_OPTS=\"-Xmx64m\" \"-Xms64m\"\r\n\r\n@rem Find java.exe\r\nif defined JAVA_HOME goto findJavaFromJavaHome\r\n\r\nset JAVA_EXE=java.exe\r\n%JAVA_EXE% -version >NUL 2>&1\r\nif \"%ERRORLEVEL%\" == \"0\" goto execute\r\n\r\necho.\r\necho ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\r\necho.\r\necho Please set the JAVA_HOME variable in your environment to match the\r\necho location of your Java installation.\r\n\r\ngoto fail\r\n\r\n:findJavaFromJavaHome\r\nset JAVA_HOME=%JAVA_HOME:\"=%\r\nset JAVA_EXE=%JAVA_HOME%/bin/java.exe\r\n\r\nif exist \"%JAVA_EXE%\" goto execute\r\n\r\necho.\r\necho ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%\r\necho.\r\necho Please set the JAVA_HOME variable in your environment to match the\r\necho location of your Java installation.\r\n\r\ngoto fail\r\n\r\n:execute\r\n@rem Setup the command line\r\n\r\nset CLASSPATH=%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar\r\n\r\n\r\n@rem Execute Gradle\r\n\"%JAVA_EXE%\" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% \"-Dorg.gradle.appname=%APP_BASE_NAME%\" -classpath \"%CLASSPATH%\" org.gradle.wrapper.GradleWrapperMain %*\r\n\r\n:end\r\n@rem End local scope for the variables with windows NT shell\r\nif \"%ERRORLEVEL%\"==\"0\" goto mainEnd\r\n\r\n:fail\r\nrem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of\r\nrem the _cmd.exe /c_ return code!\r\nif  not \"\" == \"%GRADLE_EXIT_CONSOLE%\" exit 1\r\nexit /b 1\r\n\r\n:mainEnd\r\nif \"%OS%\"==\"Windows_NT\" endlocal\r\n\r\n:omega\r\n"
  },
  {
    "path": "android/lib/billing/build.gradle.kts",
    "content": "import com.android.build.api.dsl.LibraryExtension\n\nplugins {\n    alias(libs.plugins.android.library)\n    alias(libs.plugins.kotlin.android)\n}\n\nextensions.configure<LibraryExtension> {\n    buildToolsVersion = \"36.0.0\"\n    compileSdk = 36\n    defaultConfig { minSdk = 31 }\n    namespace = \"net.obscura.lib.billing\"\n}\n\nkotlin { jvmToolchain(21) }\n\ndependencies {\n    implementation(libs.android.billingclient)\n    implementation(libs.kotlin.stdlib)\n    // This is a dep of `billingclient`, but we specify it manually to override\n    // an outdated dependency version:\n    // https://github.com/mullvad/mullvadvpn-app/pull/9887\n    implementation(libs.play.services.location)\n    implementation(project(\":lib:util\"))\n}\n"
  },
  {
    "path": "android/lib/billing/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\" />\n"
  },
  {
    "path": "android/lib/billing/src/main/java/net/obscura/lib/billing/BillingConnection.kt",
    "content": "package net.obscura.lib.billing\n\nimport android.content.Context\nimport com.android.billingclient.api.BillingClient\nimport com.android.billingclient.api.BillingClientStateListener\nimport com.android.billingclient.api.BillingResult\nimport com.android.billingclient.api.PendingPurchasesParams\nimport com.android.billingclient.api.Purchase\nimport com.android.billingclient.api.PurchasesUpdatedListener\nimport kotlinx.coroutines.flow.MutableSharedFlow\nimport kotlinx.coroutines.flow.asSharedFlow\nimport net.obscura.lib.util.Logger\n\nprivate val log = Logger(BillingConnection::class)\n\ninternal class BillingConnection<T>(\n    context: Context,\n    purchasesUpdatedListenerCallback: (BillingResult, List<Purchase>?) -> T,\n) {\n    private val purchaseUpdatesTx = MutableSharedFlow<T>(extraBufferCapacity = 1)\n    val purchaseUpdatesRx = this.purchaseUpdatesTx.asSharedFlow()\n\n    private val purchasesUpdatedListener = PurchasesUpdatedListener { result, purchases ->\n        log.info(\"purchases updated: $result $purchases\")\n        val wasEmitted = purchaseUpdatesTx.tryEmit(purchasesUpdatedListenerCallback(result, purchases))\n        if (!wasEmitted) {\n            log.warn(\"multiple purchase updates while collecting\")\n        }\n    }\n\n    val client =\n        BillingClient.newBuilder(context)\n            .setListener(this.purchasesUpdatedListener)\n            .enableAutoServiceReconnection()\n            .enablePendingPurchases(\n                PendingPurchasesParams.newBuilder()\n                    .enableOneTimeProducts() // This is mandatory\n                    .build()\n            )\n            .build()\n\n    init {\n        log.debug(\"starting billing connection\")\n        // Calling this doesn't appear to be necessary when using `enableAutoServiceReconnection`, but the callbacks can\n        // still be useful for:\n        // 1. Querying purchases/etc. at the earliest possible time\n        // 2. Logging\n        client.startConnection(\n            object : BillingClientStateListener {\n                override fun onBillingSetupFinished(result: BillingResult) {\n                    if (result.responseCode == BillingClient.BillingResponseCode.OK) {\n                        log.info(\"billing setup succeeded: $result\")\n                    } else {\n                        log.error(\"billing setup failed: $result\")\n                    }\n                }\n\n                override fun onBillingServiceDisconnected() {\n                    log.info(\"billing client disconnected\")\n                }\n            }\n        )\n    }\n\n    fun destroy() {\n        log.debug(\"destroying billing connection\")\n        this.client.endConnection()\n    }\n}\n"
  },
  {
    "path": "android/lib/billing/src/main/java/net/obscura/lib/billing/BillingManager.kt",
    "content": "package net.obscura.lib.billing\n\nimport android.app.Activity\nimport android.content.Context\nimport com.android.billingclient.api.BillingClient\nimport com.android.billingclient.api.BillingFlowParams\nimport com.android.billingclient.api.ProductDetails\nimport com.android.billingclient.api.QueryProductDetailsParams\nimport com.android.billingclient.api.queryProductDetails\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.flow.firstOrNull\nimport kotlinx.coroutines.flow.onSubscription\nimport kotlinx.coroutines.withContext\nimport net.obscura.lib.util.Logger\n\nprivate val log = Logger(BillingManager::class)\n\nprivate const val PRODUCT_ID = \"vpn_subscription_v1\"\nprivate const val BASE_PLAN_ID = \"monthly-autorenewing\"\n\nclass BillingManager(context: Context) {\n    sealed interface PurchaseResult {\n        object Completed : PurchaseResult\n\n        object Canceled : PurchaseResult\n\n        object AlreadyOwned : PurchaseResult\n\n        object Failed : PurchaseResult\n    }\n\n    private val connection =\n        BillingConnection(context) { result, _ ->\n            when (result.responseCode) {\n                BillingClient.BillingResponseCode.OK -> PurchaseResult.Completed\n                BillingClient.BillingResponseCode.USER_CANCELED -> PurchaseResult.Canceled\n                BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> PurchaseResult.AlreadyOwned\n                else -> {\n                    log.error(\"purchase failed: $result\")\n                    PurchaseResult.Failed\n                }\n            }\n        }\n\n    private data class SubscriptionDetails(\n        val productDetails: ProductDetails,\n        val offerDetails: ProductDetails.SubscriptionOfferDetails,\n    )\n\n    private suspend fun querySubscriptionDetails(client: BillingClient): SubscriptionDetails? {\n        val result =\n            withContext(Dispatchers.IO) {\n                client.queryProductDetails(\n                    QueryProductDetailsParams.newBuilder()\n                        .setProductList(\n                            listOf(\n                                QueryProductDetailsParams.Product.newBuilder()\n                                    .setProductId(PRODUCT_ID)\n                                    .setProductType(BillingClient.ProductType.SUBS)\n                                    .build()\n                            )\n                        )\n                        .build()\n                )\n            }\n        return when (result.billingResult.responseCode) {\n            BillingClient.BillingResponseCode.OK -> {\n                val productDetails = result.productDetailsList?.find { it.productId == PRODUCT_ID }\n                val offerDetails = productDetails?.subscriptionOfferDetails?.find { it.basePlanId == BASE_PLAN_ID }\n                if (offerDetails != null) {\n                    log.info(\"subscription details: $productDetails $offerDetails\")\n                    SubscriptionDetails(productDetails, offerDetails)\n                } else {\n                    log.error(\n                        \"subscription details for product $PRODUCT_ID and base plan $BASE_PLAN_ID not found: $result\"\n                    )\n                    null\n                }\n            }\n            else -> {\n                log.error(\"failed to query subscription details: $result\")\n                null\n            }\n        }\n    }\n\n    suspend fun launchFlow(activity: Activity): PurchaseResult {\n        val productDetailsParams =\n            this.querySubscriptionDetails(this.connection.client)?.let {\n                BillingFlowParams.ProductDetailsParams.newBuilder()\n                    .setProductDetails(it.productDetails)\n                    .setOfferToken(it.offerDetails.offerToken)\n                    .build()\n            } ?: return PurchaseResult.Failed\n        return this.connection.purchaseUpdatesRx\n            .onSubscription {\n                val result =\n                    // `launchBillingFlow` can only be called on the UI thread\n                    withContext(Dispatchers.Main) {\n                        this@BillingManager.connection.client.launchBillingFlow(\n                            activity,\n                            BillingFlowParams.newBuilder()\n                                .setProductDetailsParamsList(listOf(productDetailsParams))\n                                .build(),\n                        )\n                    }\n                // This is the result of launching the flow, not of the purchase within the flow!\n                when (result.responseCode) {\n                    BillingClient.BillingResponseCode.OK -> {\n                        log.info(\"launched billing flow successfully\")\n                    }\n                    BillingClient.BillingResponseCode.USER_CANCELED -> {\n                        log.info(\"user canceled billing flow\")\n                        this.emit(PurchaseResult.Canceled)\n                    }\n                    BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {\n                        log.warn(\"user already owns item\")\n                        this.emit(PurchaseResult.AlreadyOwned)\n                    }\n                    else -> {\n                        log.error(\"failed to launch billing flow: $result\")\n                        this.emit(PurchaseResult.Failed)\n                    }\n                }\n            }\n            // Wait for actual purchase result\n            .firstOrNull()\n            ?: run {\n                log.error(\"purchase updates flow was empty\")\n                PurchaseResult.Failed\n            }\n    }\n\n    fun destroy() {\n        this.connection.destroy()\n    }\n}\n"
  },
  {
    "path": "android/lib/util/build.gradle.kts",
    "content": "import com.android.build.api.dsl.LibraryExtension\n\nplugins {\n    alias(libs.plugins.android.library)\n    alias(libs.plugins.kotlin.android)\n    alias(libs.plugins.kotlinx.serialization)\n}\n\nextensions.configure<LibraryExtension> {\n    buildToolsVersion = \"36.0.0\"\n    compileSdk = 36\n    defaultConfig { minSdk = 31 }\n    namespace = \"net.obscura.lib.util\"\n}\n\nkotlin { jvmToolchain(21) }\n\ndependencies {\n    implementation(libs.kotlin.stdlib)\n    implementation(libs.kotlinx.serialization.json)\n}\n"
  },
  {
    "path": "android/lib/util/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\" />\n"
  },
  {
    "path": "android/lib/util/src/main/java/net/obscura/lib/util/ExternallyTaggedEnumSerializer.kt",
    "content": "package net.obscura.lib.util\n\nimport kotlin.reflect.KClass\nimport kotlinx.serialization.KSerializer\nimport kotlinx.serialization.json.JsonContentPolymorphicSerializer\nimport kotlinx.serialization.json.JsonElement\nimport kotlinx.serialization.json.jsonObject\n\nopen class ExternallyTaggedEnumSerializer<T : Any>(\n    private val baseClass: KClass<T>,\n    private val variants: List<ExternallyTaggedEnumVariantSerializer<out T>>,\n) : JsonContentPolymorphicSerializer<T>(baseClass) {\n    override fun selectDeserializer(element: JsonElement): KSerializer<out T> =\n        this.variants.find { it.tag in element.jsonObject } ?: error(\"invalid `${this.baseClass.simpleName}` variant\")\n}\n"
  },
  {
    "path": "android/lib/util/src/main/java/net/obscura/lib/util/ExternallyTaggedEnumVariantSerializer.kt",
    "content": "package net.obscura.lib.util\n\nimport kotlinx.serialization.KSerializer\nimport kotlinx.serialization.json.JsonElement\nimport kotlinx.serialization.json.JsonTransformingSerializer\nimport kotlinx.serialization.json.buildJsonObject\nimport kotlinx.serialization.json.jsonObject\n\nopen class ExternallyTaggedEnumVariantSerializer<T>(val tag: String, serializer: KSerializer<T>) :\n    JsonTransformingSerializer<T>(serializer) {\n    override fun transformDeserialize(element: JsonElement): JsonElement = checkNotNull(element.jsonObject[this.tag])\n\n    override fun transformSerialize(element: JsonElement): JsonElement = buildJsonObject {\n        this.put(this@ExternallyTaggedEnumVariantSerializer.tag, element)\n    }\n}\n"
  },
  {
    "path": "android/lib/util/src/main/java/net/obscura/lib/util/Log.kt",
    "content": "package net.obscura.lib.util\n\nimport android.util.Log\nimport kotlin.reflect.KClass\n\nenum class LogLevel {\n    TRACE,\n    DEBUG,\n    INFO,\n    WARN,\n    ERROR,\n}\n\ndata class LogParams(\n    val level: LogLevel,\n    val tag: String,\n    val message: String,\n    val messageId: String?,\n    val tr: Throwable?,\n)\n\nclass Logger(val tag: String, val cb: ((LogParams) -> Unit)? = null) {\n    constructor(\n        classRef: KClass<*>,\n        cb: ((LogParams) -> Unit)? = null,\n    ) : this(classRef.simpleName ?: \"AnonymousClass\", cb)\n\n    private fun forward(\n        level: LogLevel,\n        message: String,\n        messageId: String? = null,\n        tr: Throwable? = null,\n    ) {\n        if (this.cb != null) {\n            this.cb(LogParams(level, this.tag, message, messageId, tr))\n        }\n    }\n\n    fun trace(\n        message: String,\n        messageId: String? = null,\n        tr: Throwable? = null,\n    ) {\n        Log.v(this.tag, message, tr)\n        this.forward(LogLevel.TRACE, message, messageId, tr)\n    }\n\n    fun debug(\n        message: String,\n        messageId: String? = null,\n        tr: Throwable? = null,\n    ) {\n        Log.d(this.tag, message, tr)\n        this.forward(LogLevel.DEBUG, message, messageId, tr)\n    }\n\n    fun info(\n        message: String,\n        messageId: String? = null,\n        tr: Throwable? = null,\n    ) {\n        Log.i(this.tag, message, tr)\n        this.forward(LogLevel.INFO, message, messageId, tr)\n    }\n\n    fun warn(\n        message: String,\n        messageId: String? = null,\n        tr: Throwable? = null,\n    ) {\n        Log.w(this.tag, message, tr)\n        this.forward(LogLevel.WARN, message, messageId, tr)\n    }\n\n    fun error(\n        message: String,\n        messageId: String? = null,\n        tr: Throwable? = null,\n    ) {\n        Log.e(this.tag, message, tr)\n        this.forward(LogLevel.ERROR, message, messageId, tr)\n    }\n}\n"
  },
  {
    "path": "android/lint.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<lint>\n    <!-- We can't update AGP until nixpkgs updates it -->\n    <issue id=\"AndroidGradlePluginVersion\" severity=\"ignore\" />\n</lint>\n"
  },
  {
    "path": "android/settings.gradle.kts",
    "content": "pluginManagement {\n    repositories {\n        google {\n            content {\n                includeGroupByRegex(\"com\\\\.android.*\")\n                includeGroupByRegex(\"com\\\\.google.*\")\n                includeGroupByRegex(\"androidx.*\")\n            }\n        }\n        gradlePluginPortal()\n        mavenCentral()\n    }\n}\n\nplugins {\n    // https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support\n    // (Unfortunately, we can't use the version catalog for this plugin)\n    id(\"org.gradle.toolchains.foojay-resolver-convention\") version \"1.0.0\"\n}\n\ndependencyResolutionManagement {\n    @Suppress(\"UnstableApiUsage\")\n    repositories {\n        google()\n        mavenCentral()\n    }\n    @Suppress(\"UnstableApiUsage\")\n    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n}\n\nrootProject.name = \"ObscuraVPN\"\ninclude(\n    \":app\",\n    \":lib:billing\",\n    \":lib:util\",\n)\n"
  },
  {
    "path": "apple/Configurations/.gitignore",
    "content": "/buildversion.xcconfig\n"
  },
  {
    "path": "apple/Configurations/Base.xcconfig",
    "content": "#include? \"buildversion.xcconfig\"\n#include \"bundle-ids.xcconfig\"\n\nIPHONEOS_DEPLOYMENT_TARGET = 18.0\nMACOSX_DEPLOYMENT_TARGET = 13.0\n"
  },
  {
    "path": "apple/Configurations/Debug-app-network-extension.xcconfig",
    "content": "// Avoid accidental checkins:\n// git update-index --skip-worktree apple/Configurations/Debug*.xcconfig\n// git update-index --no-skip-worktree apple/Configurations/Debug*.xcconfig\n\n#include \"Debug.xcconfig\"\n#include \"app-network-extension.xcconfig\"\n\nPROVISIONING_PROFILE_SPECIFIER =\n"
  },
  {
    "path": "apple/Configurations/Debug-app.xcconfig",
    "content": "// Avoid accidental checkins:\n// git update-index --skip-worktree apple/Configurations/Debug*.xcconfig\n// git update-index --no-skip-worktree apple/Configurations/Debug*.xcconfig\n\n#include \"Debug.xcconfig\"\n#include \"app.xcconfig\"\n\nPROVISIONING_PROFILE_SPECIFIER =\n"
  },
  {
    "path": "apple/Configurations/Debug-system-network-extension.xcconfig",
    "content": "// Avoid accidental checkins:\n// git update-index --skip-worktree apple/Configurations/Debug*.xcconfig\n// git update-index --no-skip-worktree apple/Configurations/Debug*.xcconfig\n\n#include \"Debug.xcconfig\"\n#include \"system-network-extension.xcconfig\"\n\nPROVISIONING_PROFILE_SPECIFIER =\n"
  },
  {
    "path": "apple/Configurations/Debug.xcconfig",
    "content": "// Avoid accidental checkins:\n// git update-index --skip-worktree apple/Configurations/Debug*.xcconfig\n// git update-index --no-skip-worktree apple/Configurations/Debug*.xcconfig\n\n#include \"Base.xcconfig\"\n\nCODE_SIGN_IDENTITY = Apple Development\nCODE_SIGN_STYLE = Automatic\n\nENABLE_HARDENED_RUNTIME = NO\n\nOBSCURA_PACKET_TUNNEL_PROVIDER_ENTITLEMENT = packet-tunnel-provider\n\nSWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG\n"
  },
  {
    "path": "apple/Configurations/Release-app-network-extension.xcconfig",
    "content": "#include \"Release.xcconfig\"\n#include \"app-network-extension.xcconfig\"\n"
  },
  {
    "path": "apple/Configurations/Release-app.xcconfig",
    "content": "#include \"Release.xcconfig\"\n#include \"app.xcconfig\"\n\nPROVISIONING_PROFILE_SPECIFIER = Developer ID: VPN Client App\n"
  },
  {
    "path": "apple/Configurations/Release-system-network-extension.xcconfig",
    "content": "#include \"Release.xcconfig\"\n#include \"system-network-extension.xcconfig\"\n\nPROVISIONING_PROFILE_SPECIFIER = Developer ID: System Network Extension\n"
  },
  {
    "path": "apple/Configurations/Release.xcconfig",
    "content": "#include \"Base.xcconfig\"\n\nCODE_SIGN_IDENTITY = Developer ID Application: Sovereign Engineering Inc. (5G943LR562)\nCODE_SIGN_STYLE = Manual\n\nENABLE_HARDENED_RUNTIME = YES\n\nOBSCURA_PACKET_TUNNEL_PROVIDER_ENTITLEMENT = packet-tunnel-provider-systemextension\nOBSCURA_PACKET_TUNNEL_PROVIDER_ENTITLEMENT[sdk=iphoneos*] = packet-tunnel-provider\n"
  },
  {
    "path": "apple/Configurations/app-network-extension.xcconfig",
    "content": "PRODUCT_BUNDLE_IDENTIFIER = $(OBSCURA_APP_NETWORK_EXTENSION_PRODUCT_BUNDLE_IDENTIFIER)\nINFOPLIST_KEY_CFBundleDisplayName = Obscura Network Extension\nINFOPLIST_FILE = app-network-extension/Info.plist\nGENERATE_INFOPLIST_FILE = YES\n\nCODE_SIGN_ENTITLEMENTS = app-network-extension/entitlements.entitlements\n"
  },
  {
    "path": "apple/Configurations/app.xcconfig",
    "content": "PRODUCT_BUNDLE_IDENTIFIER = $(OBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER)\n\nINFOPLIST_FILE = client/Info.plist\nGENERATE_INFOPLIST_FILE = YES\n"
  },
  {
    "path": "apple/Configurations/bundle-ids.xcconfig",
    "content": "OBSCURA_SYSTEM_NETWORK_EXTENSION_PRODUCT_BUNDLE_IDENTIFIER = $(OBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER).system-network-extension\nOBSCURA_APP_NETWORK_EXTENSION_PRODUCT_BUNDLE_IDENTIFIER = $(OBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER).app-network-extension\n\n// Points to the identifier used for the network extension on this platform.\nOBSCURA_NETWORK_EXTENSION_BUNDLE_ID = $(OBSCURA_SYSTEM_NETWORK_EXTENSION_PRODUCT_BUNDLE_IDENTIFIER)\nOBSCURA_NETWORK_EXTENSION_BUNDLE_ID[sdk=iphoneos*] = $(OBSCURA_APP_NETWORK_EXTENSION_PRODUCT_BUNDLE_IDENTIFIER)\n\nDEVELOPMENT_TEAM = 5G943LR562\n// Per https://developer.apple.com/documentation/xcode/configuring-app-groups\n// On macOS app groups must start with team identifier IF the app is sandboxed/app store released. Technically we do not need this.\nOBSCURA_APP_APP_GROUP_ID = $(TeamIdentifierPrefix)group.$(OBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER)\n// On ios app groups must start with group.\nOBSCURA_APP_APP_GROUP_ID[sdk=iphoneos*] = group.$(OBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER)\n\nOBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER = net.obscura.vpn-client-app\nOBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*] = net.obscura.vpn-client-app-ios\n"
  },
  {
    "path": "apple/Configurations/system-network-extension.xcconfig",
    "content": "PRODUCT_BUNDLE_IDENTIFIER = $(OBSCURA_SYSTEM_NETWORK_EXTENSION_PRODUCT_BUNDLE_IDENTIFIER)\nINFOPLIST_KEY_CFBundleDisplayName = Obscura Network Extension\nINFOPLIST_FILE = system-network-extension/Info.plist\nGENERATE_INFOPLIST_FILE = YES\n\nCODE_SIGN_ENTITLEMENTS = system-network-extension/entitlements.entitlements\n"
  },
  {
    "path": "apple/ExportOptions.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>destination</key>\n\t<string>export</string>\n\t<key>method</key>\n\t<string>developer-id</string>\n\t<key>provisioningProfiles</key>\n\t<dict>\n\t\t<key>net.obscura.vpn-client-app</key>\n\t\t<string>Developer ID: VPN Client App</string>\n\t\t<key>net.obscura.vpn-client-app.system-network-extension</key>\n\t\t<string>Developer ID: System Network Extension</string>\n\t</dict>\n\t<key>signingCertificate</key>\n\t<string>Developer ID Application</string>\n\t<key>signingStyle</key>\n\t<string>manual</string>\n\t<key>teamID</key>\n\t<string>5G943LR562</string>\n</dict>\n</plist>\n"
  },
  {
    "path": "apple/Packet Tunnel Provider/Keychain.swift",
    "content": "import Foundation\nimport Security\n\nprivate let wgSecretKeyquery: [String: Any] = [\n    kSecClass as String: kSecClassGenericPassword,\n    kSecAttrService as String: \"obscura\",\n    kSecAttrAccount as String: \"wireguard-sk\",\n]\n\nfunc keychainSetWgSecretKey(_ sk: Data) -> Bool {\n    SecItemDelete(wgSecretKeyquery as CFDictionary)\n    var insert = wgSecretKeyquery\n    insert[kSecValueData as String] = sk\n    insert[kSecAttrAccessible as String] = kSecAttrAccessibleAlwaysThisDeviceOnly\n    let insertStatus = SecItemAdd(insert as CFDictionary, nil)\n    switch insertStatus {\n    case errSecSuccess:\n        ffiLog(.Info, \"keychain item inserted\")\n        return true\n    default:\n        ffiLog(.Error, \"error inserting keychain item: \\(insertStatus)\")\n        return false\n    }\n}\n\nfunc keychainGetWgSecretKey() -> Data? {\n    var get = wgSecretKeyquery\n    get[kSecMatchLimit as String] = kSecMatchLimitOne\n    get[kSecReturnData as String] = kCFBooleanTrue\n\n    var item: CFTypeRef?\n    let status = SecItemCopyMatching(get as CFDictionary, &item)\n    switch status {\n    case errSecSuccess:\n        ffiLog(.Info, \"keychain item found\")\n    case errSecItemNotFound:\n        ffiLog(.Info, \"keychain item not found\")\n    default:\n        ffiLog(.Error, \"error getting keychain item: \\(status)\")\n        return .none\n    }\n\n    guard let data = item as? NSData else {\n        ffiLog(.Error, \"got unexpected result format from keychain\")\n        return .none\n    }\n\n    return data as Data\n}\n"
  },
  {
    "path": "apple/Packet Tunnel Provider/NetworkSettings.swift",
    "content": "import Foundation\nimport NetworkExtension\n\nextension NEPacketTunnelNetworkSettings {\n    static func build(_ osNetworkConfig: OsNetworkConfig) -> NEPacketTunnelNetworkSettings {\n        let networkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: \"127.0.0.1\")\n\n        networkSettings.mtu = osNetworkConfig.mtu as NSNumber\n\n        let ipv4Settings = NEIPv4Settings(\n            addresses: [osNetworkConfig.ipv4],\n            subnetMasks: [\"255.255.255.255\"]\n        )\n        ipv4Settings.includedRoutes = [NEIPv4Route.default()]\n        networkSettings.ipv4Settings = ipv4Settings\n\n        let selfIpv6Parts = osNetworkConfig.ipv6.split(separator: \"/\", maxSplits: 1)\n        let selfIpv6Addr = String(selfIpv6Parts[0])\n        let selfIpv6Prefix = UInt8(selfIpv6Parts[1])!\n\n        let ipv6Settings = NEIPv6Settings(\n            addresses: [selfIpv6Addr],\n\n            // If a too-small network is used we won't be granted the default IPv6 route. So cap the prefix length. This shouldn't be in issue for us since the IP is always a private IP that gets NATed. If that ever changes we will likely end up with a bigger prefix anyways, but either way that is a problem for the future.\n            //\n            // Testing has shown that anything smaller than a /125 network won't work on macOS.\n            //\n            // wireguard-apple suggests that a /120 may be required on iOS: https://github.com/WireGuard/wireguard-apple/blob/af58bfcb00e7ebdd0c0f48d2f15df17ab3b2b8d7/WireGuard/WireGuardNetworkExtension/PacketTunnelSettingsGenerator.swift#L165-L170\n            networkPrefixLengths: [NSNumber(value: min(selfIpv6Prefix, 125))]\n        )\n        ipv6Settings.includedRoutes = [NEIPv6Route.default()]\n        networkSettings.ipv6Settings = ipv6Settings\n\n        let dns_settings = NEDNSSettings(servers: osNetworkConfig.dns)\n\n        if osNetworkConfig.useSystemDns {\n            // Contrary to apple documentation this is not ignored if the VPN tunnel is the default route and allows us to fall back on configured DNS profiles. (https://developer.apple.com/documentation/networkextension/nednssettings/matchdomains (2025-11-15))\n            dns_settings.matchDomains = [\"invalid.obscura.net\"]\n            if #available(iOS 26.0, macOS 26.0, *) {\n                dns_settings.allowFailover = true\n            }\n        } else {\n            // This is not necessary to match everything if the VPN tunnel is the default route, but is harmless either way. (https://developer.apple.com/documentation/networkextension/nednssettings/matchdomains (2025-11-15))\n            dns_settings.matchDomains = [\"\"]\n        }\n\n        networkSettings.dnsSettings = dns_settings\n\n        return networkSettings\n    }\n}\n"
  },
  {
    "path": "apple/Packet Tunnel Provider/PacketTunnelProvider.swift",
    "content": "import Combine\nimport libobscuravpn_client\nimport NetworkExtension\nimport OSLog\nimport UniformTypeIdentifiers\nimport UserNotifications\n\nclass PacketTunnelProvider: NEPacketTunnelProvider {\n    weak static var shared: PacketTunnelProvider?\n\n    private let providerId = genTaskId()\n    private let isActive = AsyncMutex(false)\n    private let isConnected = WatchableValue(false)\n    private let networkConfig: AsyncMutex<OsNetworkConfig?> = AsyncMutex(.none)\n    private let nwPathMonitor: NWPathMonitor = .init()\n    private let rustFfi: RustFfi\n\n    var selfObservation: NSKeyValueObservation?\n\n    override init() {\n        let logDir = logDir()\n        if let logDir = logDir {\n            let logger = Logger(subsystem: \"net.obscura.sys-ext\", category: \"pre-log-init\")\n            do {\n                try ensureDirWithMinimalProtection(dir: logDir)\n            } catch {\n                logger.error(\"failed to ensure log dir protection level: \\(error)\")\n            }\n        }\n        let logFlushGuard = ffiInitializeSystemLogging(logDir)\n        ffiLog(.Info, \"init entry \\(self.providerId)\")\n\n        if let other = Self.shared {\n            ffiLog(.Warn, \"Multiple live PacketTunnelProvider instances. me: \\(self.providerId) other: \\(other.providerId)\")\n        }\n\n        let configDir = configDir()\n        do {\n            try ensureDirWithMinimalProtection(dir: configDir)\n        } catch {\n            ffiLog(.Error, \"failed to ensure config directory protection level: \\(error)\")\n        }\n\n        #if os(macOS)\n            let userAgentPlatform = \"macos\"\n        #else\n            let userAgentPlatform = \"ios\"\n        #endif\n        let userAgent = \"obscura.net/\" + userAgentPlatform + \"/\" + sourceVersion()\n        ffiLog(.Info, \"config dir \\(configDir)\")\n        ffiLog(.Info, \"user agent \\(userAgent)\")\n        let rustFfi = RustFfi(configDir: configDir, userAgent: userAgent, logFlushGuard: logFlushGuard, receiveCallback, setNetworkConfigCallback)\n        self.rustFfi = rustFfi\n\n        self.nwPathMonitor.pathUpdateHandler = { path in\n            if path.status != .satisfied {\n                ffiLog(.Info, \"network path not satisfied\")\n                rustFfi.setNetworkInterface(.none)\n                return\n            }\n            switch path.availableInterfaces.first {\n            case .some(let preferredInterface):\n                ffiLog(.Info, \"preferred network path interface name: \\(preferredInterface.name), index: \\(preferredInterface.index)\")\n                rustFfi.setNetworkInterface(.some((preferredInterface.index, preferredInterface.name)))\n            case .none:\n                ffiLog(.Info, \"no available network path interface\")\n                rustFfi.setNetworkInterface(.none)\n            }\n        }\n        self.nwPathMonitor.start(queue: .main)\n\n        super.init()\n\n        self.selfObservation = self.observe(\n            \\.protocolConfiguration,\n            options: [.old, .new]\n        ) { [weak self] object, change in\n            Task {\n                await self?.handleProtocolConfigurationChange(change: change)\n            }\n        }\n\n        Self.shared = self\n        self.startSendLoop()\n        self.startStatusLoop()\n        ffiLog(.Info, \"init exit \\(self.providerId)\")\n    }\n\n    deinit {\n        ffiLog(.Info, \"PacketTunnelProvider.deinit \\(self.providerId)\")\n        /*\n         Hack to avoid macos bugs where handleAppMessage isn't called after deinit.\n         One way to reproduce the issue:\n         - disable network access (e.g. turn off wifi)\n         - start a tunnel\n         - any IPC that should result in handleAppMessage getting called will fail.\n         This is not redundant with the `exit` in stopTunnel, because in the case described above `stopTunnel` is not called.\n\n         https://linear.app/soveng/issue/OBS-2070\n         */\n        exit(0)\n    }\n\n    override func startTunnel(options: [String: NSObject]?) async throws {\n        ffiLog(.Info, \"startTunnel entry \\(self.providerId), includeAllNetworks: \\(self.protocolConfiguration.includeAllNetworks)\")\n\n        if options?.keys.contains(\"dontStartTunnel\") == .some(true) {\n            ffiLog(.Error, \"startTunnel \\(self.providerId) throws due to \\\"dontStartTunnel\\\" key in options\")\n            throw \"dummy start with \\\"dontStartTunnel\\\" flag\"\n        }\n\n        var tunnelArgs: TunnelArgs? = .none\n        switch options {\n        case .some(let options):\n            ffiLog(.Info, \"tunnel options: \\(options)\")\n            if let args = options[\"tunnelArgs\"] as? String {\n                ffiLog(.Info, \"startTunnel called with \\\"tunnelArgs\\\"\")\n                tunnelArgs = try TunnelArgs(json: args)\n            }\n        case .none:\n            ffiLog(.Info, \"startTunnel \\(self.providerId) called without options\")\n        }\n\n        try await self.isActive.withLock { isActiveGuard in\n            if isActiveGuard.value {\n                ffiLog(.Error, \"startTunnel called on active tunnel \\(self.providerId)\")\n                throw \"tunnel already active\"\n            }\n\n            let _: Empty = try await runManagerCmd(self.rustFfi, .setTunnelArgs(args: tunnelArgs, active: true))\n\n            ffiLog(.Info, \"set tunnel active flag \\(self.providerId)\")\n            isActiveGuard.value = true\n        }\n\n        // macos 14 cancels the tunnel if it stays on connecting for too long\n        if #available(macOS 15, *) {\n            ffiLog(.Info, \"waiting for tunnel to start \\(self.providerId)\")\n            _ = await self.isConnected.waitUntil { $0 == true }\n        }\n\n        ffiLog(.Info, \"startTunnel exit \\(self.providerId)\")\n    }\n\n    override func stopTunnel(with reason: NEProviderStopReason) async {\n        ffiLog(.Info, \"stopTunnel entry \\(self.providerId), reason: \\(providerStopReasonToString(reason))\")\n\n        let (disableOndemand, notificationBody): (Bool, String?) = switch reason {\n        case .userInitiated: (true, .none)\n        case .providerDisabled, .superceded, .configurationDisabled: (false, \"Tunnel was disabled by another VPN app.\")\n        case .none, .noNetworkAvailable, .providerFailed, .unrecoverableNetworkChange, .authenticationCanceled, .configurationFailed, .idleTimeout, .configurationRemoved, .userLogout, .userSwitch, .appUpdate, .connectionFailed, .sleep, .internalError: (false, nil)\n        @unknown default: (false, nil)\n        }\n\n        if let notificationBody = notificationBody {\n            let content = UNMutableNotificationContent()\n            content.title = \"Obscura VPN tunnel stopped\"\n            content.body = notificationBody\n            let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)\n            do {\n                try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: NotificationId.onDemandTunnelStopped.rawValue, content: content, trigger: trigger))\n            } catch {\n                ffiLog(.Error, \"notification error: \\(error)\")\n            }\n        }\n\n        if disableOndemand {\n            #if os(macOS)\n                ffiLog(.Info, \"ignoring disableOndemand on macOS\")\n            #else\n                await try_setting_ondemand(false)\n            #endif\n        }\n\n        await self.isActive.withLock { isActiveGuard in\n            if !isActiveGuard.value {\n                ffiLog(.Warn, \"stopTunnel called on inactive tunnel \\(self.providerId)\")\n            }\n            ffiLog(.Info, \"unset tunnel active flag \\(self.providerId)\")\n            isActiveGuard.value = false\n\n            ffiLog(.Info, \"stopping tunnel \\(self.providerId)\")\n            do {\n                let _: Empty = try await runManagerCmd(self.rustFfi, .setTunnelArgs(args: .none, active: false))\n            } catch {\n                ffiLog(.Error, \"setting empty tunnel args failed: \\(error)\")\n            }\n        }\n        ffiLog(.Info, \"waiting for tunnel to stop \\(self.providerId)\")\n        _ = await self.isConnected.waitUntil { $0 == false }\n        ffiLog(.Info, \"stopTunnel exit and abort \\(self.providerId)\")\n        /*\n         Hack to avoid macos bugs where no methods of self are called after stopTunnel including deinit.\n\n         https://linear.app/soveng/issue/OBS-2069\n         */\n        exit(0)\n    }\n\n    override func handleAppMessage(_ msg: Data, completionHandler: ((Data?) -> Void)?) {\n        guard let completionHandler = completionHandler else {\n            ffiLog(.Error, \"received app message without completion handler\")\n            return\n        }\n        Task {\n            let json_result = try! await self.rustFfi.jsonManagerCmd(msg).json()\n            completionHandler(json_result.data(using: .utf8))\n        }\n    }\n\n    override func sleep() async {\n        ffiLog(.Info, \"sleep entry \\(self.providerId)\")\n        ffiLog(.Info, \"sleep exit \\(self.providerId)\")\n    }\n\n    override func wake() {\n        ffiLog(.Info, \"wake entry \\(self.providerId)\")\n        self.rustFfi.wake()\n        ffiLog(.Info, \"wake exit \\(self.providerId)\")\n    }\n\n    func startSendLoop() {\n        /*\n             Note: This code is a bit unusual for a handful of reasons.\n\n             1. This must not keep `self` alive.\n             2. `self.packetFlow.readPackets` just never calls its completion handler when this provider is obsolete. This means that we can't run any cleanup code. It also means we can't use a `Task` as it would never complete.\n             3. We want to check and log if we are called after a new `PacketTunnelProvider` has been created.\n\n             In the end we still leak the `handle` callback. But this is basically the minimum we can leak. Neither we or anyone on GitHub appears to have found a way to leak nothing with this API. We aren't the only ones to notice as I found many examples of people using a `weak self` parameter.\n         */\n        let providerId = self.providerId\n        var handle: (([Data], [NSNumber]) -> Void)?\n        handle = { [weak self] (packets: [Data], _protocols: [NSNumber]) in\n            guard let self = self else {\n                ffiLog(.Error, \"Send task for deallocated PacketTunnelProvider \\(providerId) called\")\n                return\n            }\n            if providerId != Self.shared?.providerId {\n                ffiLog(.Error, \"Send task for obsolete PacketTunnelProvider \\(providerId) called\")\n                return\n            }\n\n            for packet in packets {\n                self.rustFfi.sendPacket(packet)\n            }\n\n            self.packetFlow.readPackets(completionHandler: handle!)\n        }\n        self.packetFlow.readPackets(completionHandler: handle!)\n    }\n\n    func startStatusLoop() {\n        let providerId = self.providerId\n        let rustFfi = self.rustFfi\n        Task { [weak self] in\n            let taskId = genTaskId()\n            ffiLog(.Info, \"status loop entry \\(taskId)\")\n\n            var knownVersion: UUID? = .none\n            while true {\n                let status = await getRustStatus(rustFfi, knownVersion: knownVersion)\n                knownVersion = status.version\n                guard let self = self else {\n                    ffiLog(.Error, \"status loop for deallocated PacketTunnelProvider \\(providerId) exiting\")\n                    break\n                }\n                await self.processStatusUpdate(status)\n            }\n            ffiLog(.Info, \"status loop exit \\(taskId)\")\n        }\n    }\n\n    func processStatusUpdate(_ status: NeStatus) async {\n        ffiLog(.Info, \"processing status update \\(status.version)\")\n        _ = self.isConnected.update {\n            $0 = switch status.vpnStatus {\n            case .connected: true\n            default: false\n            }\n        }\n        await self.isActive.withLock { isActiveGuard in\n            #if !os(macOS)\n                // Move to startTunnel once onDemand is unconditional ( https://linear.app/soveng/issue/OBS-2428 )\n                if isActiveGuard.value {\n                    await try_setting_ondemand(status.featureFlags.killSwitch == .some(true))\n                }\n            #endif\n            switch status.vpnStatus {\n            case .disconnected:\n                fallthrough\n            case .connecting:\n                if isActiveGuard.value {\n                    // macos 14 disconnects the tunnel if it stays on reasserting for 5min. This problem is exacerbated by unreliable sleep. 5min time awake can accumulate in less than an hour with the lid closed.\n                    if #available(macOS 15, *) {\n                        self.reasserting = true\n                    }\n                }\n            case .connected:\n                if isActiveGuard.value {\n                    self.reasserting = false\n                }\n            }\n        }\n        ffiLog(.Info, \"finished processing status update \\(status.version)\")\n    }\n\n    func ensureNetworkConfig(newNetworkConfig: OsNetworkConfig) async -> Bool {\n        return await self.isActive.withLock { isActiveGuard in\n            if !isActiveGuard.value {\n                ffiLog(.Error, \"Not active, ignoring new network config.\")\n                return false\n            }\n            return await self.networkConfig.withLock { networkConfigGuard in\n                // This check isn't needed for correctness, but skipping unnecessary calls to `setTunnelNetworkSettings` does prevent brief periods with packet loss and lot of OS activity visible in the system log.\n                if networkConfigGuard.value != newNetworkConfig {\n                    ffiLog(.Info, \"Setting network config: \\(newNetworkConfig)\")\n                    let networkSettings = NEPacketTunnelNetworkSettings.build(newNetworkConfig)\n                    do {\n                        try await self.setTunnelNetworkSettings(networkSettings)\n                    } catch {\n                        ffiLog(.Error, \"Setting network config failed: \\(error)\")\n                        return false\n                    }\n                    networkConfigGuard.value = newNetworkConfig\n                } else {\n                    ffiLog(.Info, \"Unchanged, keeping existing network config: \\(newNetworkConfig)\")\n                }\n                return true\n            }\n        }\n    }\n\n    func handleProtocolConfigurationChange(change: NSKeyValueObservedChange<NEVPNProtocol>) async {\n        ffiLog(.Info, \"handleProtocolConfigurationChange entry \\(change.oldValue) to \\(change.newValue)\")\n        defer {\n            ffiLog(.Info, \"handleProtocolConfigurationChange exit\")\n        }\n\n        guard let old = change.oldValue else {\n            // First value, no need to react.\n            return\n        }\n\n        guard let new = change.newValue else {\n            ffiLog(.Warn, \"protocolConfiguration changed to (null)!\")\n            return\n        }\n\n        guard !old.includeAllNetworks && new.includeAllNetworks else {\n            ffiLog(.Info, \"No interesting changes.\")\n            return\n        }\n        ffiLog(.Info, \"includeAllNetorks has been enabled.\")\n\n        await self.isActive.withLock { isActiveGuard in\n            if !isActiveGuard.value {\n                ffiLog(.Info, \"Not active, ignoring.\")\n                return\n            }\n\n            await self.networkConfig.withLock { networkConfigGuard in\n                guard let networkConfig = networkConfigGuard.value else {\n                    ffiLog(.Info, \"No existing network config, doing nothing.\")\n                    return\n                }\n                ffiLog(.Info, \"re-setting network config.\")\n                let networkSettings = NEPacketTunnelNetworkSettings.build(networkConfig)\n                do {\n                    try await self.setTunnelNetworkSettings(networkSettings)\n                    ffiLog(.Info, \"Network settings reconfigured.\")\n                } catch {\n                    ffiLog(.Error, \"Failed to apply network settings. User is probably offline \\(error)\")\n                }\n            }\n        }\n    }\n}\n\n// `done` is always non-null, but cbindgen can't emit _Nonnull for function pointers in typedefs.\nprivate func setNetworkConfigCallback(networkConfigJson: FfiBytes, context: UnsafeMutableRawPointer?, done: (@convention(c) (UnsafeMutableRawPointer?, Bool) -> Void)!) {\n    guard let inst = PacketTunnelProvider.shared else {\n        ffiLog(.Error, \"setNetworkConfigCallback called with no active PacketTunnelProvider\")\n        done(context, false)\n        return\n    }\n\n    let networkConfigData = networkConfigJson.data()\n    Task {\n        do {\n            let networkConfig = try JSONDecoder().decode(OsNetworkConfig.self, from: networkConfigData)\n            let success = await inst.ensureNetworkConfig(newNetworkConfig: networkConfig)\n            done(context, success)\n        } catch {\n            ffiLog(.Error, \"failed to decode OsNetworkConfig: \\(error)\")\n            done(context, false)\n        }\n    }\n}\n\nprivate func receiveCallback(packet: FfiBytes) {\n    guard let inst = PacketTunnelProvider.shared else {\n        ffiLog(.Error, \"Packet callback called with no active PacketTunnelProvider\")\n        return\n    }\n    let packet = packet.data()\n    Task {\n        inst.packetFlow.writePackets([packet], withProtocols: [NSNumber(value: AF_INET)])\n    }\n}\n\nprivate func genTaskId() -> String {\n    Data((1 ... 5).map { _ in UInt8.random(in: 65 ... 90) }).reduce(\"\") { $0 + String(format: \"%c\", $1) }\n}\n\nfunc getRustStatus(_ rustFfi: RustFfi, knownVersion: UUID?) async -> NeStatus {\n    while true {\n        do {\n            return try await runManagerCmd(rustFfi, .getStatus(knownVersion: knownVersion))\n        } catch {\n            ffiLog(.Error, \"error getting rust status \\(error)\")\n        }\n        try! await Task.sleep(seconds: 1)\n    }\n}\n\nfunc runManagerCmd<O: Codable>(_ rustFfi: RustFfi, _ cmd: NeManagerCmd) async throws -> O {\n    let jsonCmd = try cmd.json()\n    switch await rustFfi.jsonManagerCmd(Data(jsonCmd.utf8)) {\n    case .ok_json(let ok):\n        return try O(json: ok)\n    case .error(let err):\n        throw err\n    }\n}\n\nfunc providerStopReasonToString(_ reason: NEProviderStopReason) -> String {\n    switch reason {\n    case .none:\n        return \"none\"\n    case .userInitiated:\n        return \"userInitiated\"\n    case .providerFailed:\n        return \"providerFailed\"\n    case .noNetworkAvailable:\n        return \"noNetworkAvailable\"\n    case .unrecoverableNetworkChange:\n        return \"unrecoverableNetworkChange\"\n    case .providerDisabled:\n        return \"providerDisabled\"\n    case .authenticationCanceled:\n        return \"authenticationCanceled\"\n    case .configurationFailed:\n        return \"configurationFailed\"\n    case .idleTimeout:\n        return \"idleTimeout\"\n    case .configurationDisabled:\n        return \"configurationDisabled\"\n    case .configurationRemoved:\n        return \"configurationRemoved\"\n    case .superceded:\n        return \"superceded\"\n    case .userLogout:\n        return \"userLogout\"\n    case .userSwitch:\n        return \"userSwitch\"\n    case .appUpdate:\n        return \"appUpdate\"\n    case .connectionFailed:\n        return \"connectionFailed\"\n    case .sleep:\n        return \"sleep\"\n    case .internalError:\n        return \"internalError\"\n    @unknown default:\n        return \"unknown(\\(reason))\"\n    }\n}\n\nfunc ensureDirWithMinimalProtection(dir: String) throws {\n    #if os(macOS)\n        // Lower protection levels are not available on macOS: https://support.apple.com/en-gb/guide/security/secb010e978a/web\n        let protectionLevel = FileProtectionType.completeUntilFirstUserAuthentication\n    #else\n        let protectionLevel = FileProtectionType.none\n    #endif\n    if FileManager.default.fileExists(atPath: dir) {\n        ffiLog(.Info, \"\\(dir) already exists, ensuring correct protection level\")\n        try ensureProtectionLevel(dir, protectionLevel)\n    } else {\n        ffiLog(.Info, \"creating \\(dir)\")\n        try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true, attributes: [.protectionKey: protectionLevel])\n    }\n}\n\nfunc ensureProtectionLevel(_ path: String, _ protectionLevel: FileProtectionType) throws {\n    ffiLog(.Info, \"checking protection level of \\(path)\")\n    var currentProtectionLevel: FileProtectionType? = Optional.none\n    do {\n        currentProtectionLevel = try getProtectionLevel(path)\n        ffiLog(.Info, \"current protection level: \\(currentProtectionLevel.debugDescription)\")\n    } catch {\n        ffiLog(.Warn, \"could not get protection level of \\(path)\")\n    }\n    if currentProtectionLevel != protectionLevel {\n        ffiLog(.Info, \"changing protection level to \\(protectionLevel.rawValue)\")\n        try FileManager.default.setAttributes([.protectionKey: protectionLevel], ofItemAtPath: path)\n        try ffiLog(.Info, \"new protection level: \\(getProtectionLevel(path).debugDescription)\")\n    } else {\n        ffiLog(.Info, \"protection level already correct\")\n    }\n}\n\nfunc getProtectionLevel(_ path: String) throws -> FileProtectionType? {\n    let attributes = try FileManager.default.attributesOfItem(atPath: path)\n    return attributes[.protectionKey] as? FileProtectionType\n}\n\n#if !os(macOS)\n    func try_setting_ondemand(_ enabled: Bool) async {\n        do {\n            let managers = try await NETunnelProviderManager.loadAllFromPreferences()\n            if managers.isEmpty {\n                throw (\"no tunnel providers found\")\n            }\n            for manager in managers {\n                manager.isOnDemandEnabled = enabled\n                try await manager.saveToPreferences()\n            }\n        } catch {\n            ffiLog(.Error, \"setting isOnDemandEnabled to \\(enabled) failed: \\(error)\")\n        }\n    }\n#endif\n"
  },
  {
    "path": "apple/Packet Tunnel Provider/RustFfi.swift",
    "content": "import Foundation\nimport libobscuravpn_client\nimport Network\n\nfunc ffiInitializeSystemLogging(_ logDir: String?) -> OpaquePointer? {\n    let logDir: String = logDir ?? \"\"\n    let logFlushGuard = logDir.withFfiStr { ffiLogDir in\n        libobscuravpn_client.initialize_apple_system_logging(ffiLogDir)\n    }\n    return logFlushGuard\n}\n\nclass RustFfi {\n    private let ptr: OpaquePointer\n\n    init(configDir: String, userAgent: String, logFlushGuard: OpaquePointer?, _ receiveCallback: @convention(c) (FfiBytes) -> Void, _ setNetworkConfigCallback: @convention(c) (FfiBytes, UnsafeMutableRawPointer?, (@convention(c) (UnsafeMutableRawPointer?, Bool) -> Void)?) -> Void) {\n        let wgSecretKey = keychainGetWgSecretKey() ?? Data()\n        let p = configDir.withFfiStr { ffiConfigDir in\n            userAgent.withFfiStr { ffiUserAgent in\n                wgSecretKey.withFfiBytes { ffiWgSecretKey in\n                    libobscuravpn_client.initialize(ffiConfigDir, ffiUserAgent, ffiWgSecretKey, receiveCallback, setNetworkConfigCallback, keychainSetWgSecretKeyCallback, logFlushGuard)\n                }\n            }\n        }\n        self.ptr = p!\n    }\n\n    func jsonManagerCmd(_ jsonCmd: Data) async -> NeManagerCmdResult {\n        return await withCheckedContinuation { continuation in\n            let context = FfiCb.wrap { (ok_json: FfiStr, err: FfiStr) in\n                if let err = err.nonEmptyString() {\n                    continuation.resume(returning: .error(err))\n                    return\n                }\n                continuation.resume(returning: .ok_json(ok_json.string()))\n            }\n            jsonCmd.withFfiBytes {\n                libobscuravpn_client.json_ffi_cmd(self.ptr, context, $0) { FfiCb.call($0, ($1, $2)) }\n            }\n        }\n    }\n\n    func sendPacket(_ packet: Data) {\n        packet.withFfiBytes {\n            libobscuravpn_client.send_packet(self.ptr, $0)\n        }\n    }\n\n    func setNetworkInterface(_ networkInterface: (Int, String)?) {\n        if let (index, name): (Int, String) = networkInterface {\n            if index <= 0 || Int64(index) > Int64(UInt32.max) {\n                ffiLog(.Error, \"network interface index out of range \\(index)\")\n                \"\".withFfiStr { ffiEmptyName in\n                    libobscuravpn_client.set_network_interface(self.ptr, 0, ffiEmptyName)\n                }\n            } else {\n                name.withFfiStr { ffiName in\n                    libobscuravpn_client.set_network_interface(self.ptr, UInt32(index), ffiName)\n                }\n            }\n        } else {\n            \"\".withFfiStr { ffiEmptyName in\n                libobscuravpn_client.set_network_interface(self.ptr, 0, ffiEmptyName)\n            }\n        }\n    }\n\n    func wake() {\n        libobscuravpn_client.wake(self.ptr)\n    }\n}\n\nenum LogLevel: UInt8 {\n    case Trace\n    case Debug\n    case Info\n    case Warn\n    case Error\n}\n\nfunc ffiLog(\n    _ level: LogLevel,\n    _ message: String,\n    fileID: String = #fileID,\n    function: String = #function,\n    line: Int = #line\n) {\n    message.withFfiStr { ffiMessage in\n        fileID.withFfiStr { ffiFileID in\n            function.withFfiStr { ffiFunction in\n                libobscuravpn_client.forward_log(level.rawValue, ffiMessage, ffiFileID, ffiFunction, line)\n            }\n        }\n    }\n}\n\nprivate func keychainSetWgSecretKeyCallback(key: FfiBytes) -> Bool {\n    ffiLog(.Info, \"keychainSetWgSecretKeyCallback entry\")\n    let ret = keychainSetWgSecretKey(key.data())\n    if !ret {\n        ffiLog(.Info, \"keychainSetWgSecretKey returned false\")\n    }\n    ffiLog(.Info, \"keychainSetWgSecretKeyCallback exit\")\n    return ret\n}\n\nextension String {\n    func withFfiStr<R>(_ body: (libobscuravpn_client.FfiStr) -> R) -> R {\n        self.data(using: .utf8)!.withFfiBytes {\n            let ffiStr = libobscuravpn_client.FfiStr(bytes: $0)\n            return body(ffiStr)\n        }\n    }\n}\n\nextension FfiStr {\n    func string() -> String {\n        String(decoding: self.bytes.data(), as: UTF8.self)\n    }\n\n    func nonEmptyString() -> String? {\n        let s = self.string()\n        return s.isEmpty ? nil : s\n    }\n}\n\nextension Data {\n    func withFfiBytes<R>(_ body: (libobscuravpn_client.FfiBytes) -> R) -> R {\n        self.withUnsafeBytes {\n            let ffiBytes = libobscuravpn_client.FfiBytes(buffer: $0.baseAddress, len: UInt($0.count))\n            return body(ffiBytes)\n        }\n    }\n}\n\nextension FfiBytes {\n    func data() -> Data {\n        Data(bytes: self.buffer, count: Int(self.len))\n    }\n}\n"
  },
  {
    "path": "apple/Packet Tunnel Provider/main.swift",
    "content": "import Foundation\nimport NetworkExtension\n\n// TODO: Use `std::panic::set_backtrace_style()` in Rust initialization once stabilized.\n// https://doc.rust-lang.org/std/panic/fn.set_backtrace_style.html\nsetenv(\"RUST_BACKTRACE\", \"1\", 1)\n\nautoreleasepool {\n    NEProvider.startSystemExtensionMode()\n}\n\ndispatchMain()\n"
  },
  {
    "path": "apple/app-network-extension/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>NSExtension</key>\n\t<dict>\n\t\t<key>NSExtensionPointIdentifier</key>\n\t\t<string>com.apple.networkextension.packet-tunnel</string>\n\t\t<key>NSExtensionPrincipalClass</key>\n\t\t<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>\n\t</dict>\n\t<key>OSLogPreferences</key>\n\t<dict>\n\t\t<key>net.obscura.vpn-client-app-ios.app-network-extension</key>\n\t\t<dict>\n\t\t\t<key>DEFAULT-OPTIONS</key>\n\t\t\t<dict>\n\t\t\t\t<key>Enable-Oversize-Messages</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Enable-Private-Data</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Level</key>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>Enable</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t\t<key>Persist</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t</dict>\n\t\t\t</dict>\n\t\t</dict>\n\t\t<key>com.apple.extensionkit</key>\n\t\t<dict>\n\t\t\t<key>DEFAULT-OPTIONS</key>\n\t\t\t<dict>\n\t\t\t\t<key>Enable-Oversize-Messages</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Enable-Private-Data</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Level</key>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>Enable</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t\t<key>Persist</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t</dict>\n\t\t\t</dict>\n\t\t</dict>\n\t\t<key>com.apple.xpc.transaction</key>\n\t\t<dict>\n\t\t\t<key>DEFAULT-OPTIONS</key>\n\t\t\t<dict>\n\t\t\t\t<key>Enable-Oversize-Messages</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Enable-Private-Data</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Level</key>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>Enable</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t\t<key>Persist</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t</dict>\n\t\t\t</dict>\n\t\t</dict>\n\t\t<key>net.obscura.rust-apple</key>\n\t\t<dict>\n\t\t\t<key>DEFAULT-OPTIONS</key>\n\t\t\t<dict>\n\t\t\t\t<key>Enable-Oversize-Messages</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Enable-Private-Data</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Level</key>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>Enable</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t\t<key>Persist</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t</dict>\n\t\t\t</dict>\n\t\t</dict>\n\t</dict>\n\t<key>Obscura</key>\n\t<dict>\n\t\t<key>ObscuraSourceVersion</key>\n\t\t<string>$(OBSCURA_SOURCE_VERSION)</string>\n\t\t<key>AppGroupIdentifier</key>\n\t\t<string>$(OBSCURA_APP_APP_GROUP_ID)</string>\n\t</dict>\n</dict>\n</plist>\n"
  },
  {
    "path": "apple/app-network-extension/entitlements.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.developer.networking.networkextension</key>\n\t<array>\n\t\t<string>packet-tunnel-provider</string>\n\t</array>\n\t<key>com.apple.security.app-sandbox</key>\n\t<true/>\n\t<key>com.apple.security.application-groups</key>\n\t<array>\n\t\t<string>$(OBSCURA_APP_APP_GROUP_ID)</string>\n\t</array>\n\t<key>com.apple.security.network.client</key>\n\t<true/>\n\t<key>com.apple.security.network.server</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "apple/cbindgen-apple.toml",
    "content": "# NOTE: This is a `cbindgen` config that's specific to the apple platforms, as\n# only those platformw will have the TargetConditionals.h system header\n# necessary to reliably detect between macOS and iPhone targets.\n#\n# See:\n#   find /Applications/Xcode.app/Contents/Developer/Platforms -name 'TargetConditionals\\.h'\n\nlanguage = \"C\"\nsys_includes = [\"TargetConditionals.h\"]\n\nafter_includes = \"\"\"\n#if TARGET_OS_OSX\n    #define OBSCURA_DEFINE_TARGET_OS_MACOS\n#endif\n\n#if TARGET_OS_IOS\n    #define OBSCURA_DEFINE_TARGET_OS_IOS\n#endif\n\"\"\"\n\n[defines]\n\"target_os = ios\" = \"OBSCURA_DEFINE_TARGET_OS_IOS\"\n\"target_os = macos\" = \"OBSCURA_DEFINE_TARGET_OS_MACOS\"\n"
  },
  {
    "path": "apple/client/Assets.xcassets/AccentColor.colorset/Contents.json",
    "content": "{\n  \"colors\" : [\n    {\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/AppIcon.appiconset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"Icon 04 1024w.png\",\n      \"idiom\" : \"universal\",\n      \"platform\" : \"ios\",\n      \"size\" : \"1024x1024\"\n    },\n    {\n      \"filename\" : \"icon_16x16.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"1x\",\n      \"size\" : \"16x16\"\n    },\n    {\n      \"filename\" : \"icon_16x16@2x@2x.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"2x\",\n      \"size\" : \"16x16\"\n    },\n    {\n      \"filename\" : \"icon_32x32.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"1x\",\n      \"size\" : \"32x32\"\n    },\n    {\n      \"filename\" : \"icon_32x32@2x@2x.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"2x\",\n      \"size\" : \"32x32\"\n    },\n    {\n      \"filename\" : \"icon_128x128.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"1x\",\n      \"size\" : \"128x128\"\n    },\n    {\n      \"filename\" : \"icon_128x128@2x@2x.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"2x\",\n      \"size\" : \"128x128\"\n    },\n    {\n      \"filename\" : \"icon_256x256.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"1x\",\n      \"size\" : \"256x256\"\n    },\n    {\n      \"filename\" : \"icon_256x256@2x@2x.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"2x\",\n      \"size\" : \"256x256\"\n    },\n    {\n      \"filename\" : \"icon_512x512.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"1x\",\n      \"size\" : \"512x512\"\n    },\n    {\n      \"filename\" : \"icon_512x512@2x@2x.png\",\n      \"idiom\" : \"mac\",\n      \"scale\" : \"2x\",\n      \"size\" : \"512x512\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/Contents.json",
    "content": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/DecoPrimer.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"deco-primer.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"idiom\" : \"universal\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"idiom\" : \"universal\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/EmotePrimer.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"DecoEmote.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"idiom\" : \"universal\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"idiom\" : \"universal\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/MenuBarConnected.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"Connected.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"filename\" : \"Connected.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"filename\" : \"Connected.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/MenuBarConnectedDown.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"Connected - Down Cutout.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"filename\" : \"Connected - Down Cutout.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"filename\" : \"Connected - Down Cutout.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/MenuBarConnectedUp.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"Connected - Up Cutout.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"filename\" : \"Connected - Up Cutout.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"filename\" : \"Connected - Up Cutout.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/MenuBarConnectedUpDown.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"Connected - Up Down Cutout.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"filename\" : \"Connected - Up Down Cutout.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"filename\" : \"Connected - Up Down Cutout.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/MenuBarConnecting-1.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"Menu Bar Connecting 1.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"filename\" : \"Menu Bar Connecting 1.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"filename\" : \"Menu Bar Connecting 1.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/MenuBarConnecting-2.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"Menu Bar Connecting 2.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"filename\" : \"Menu Bar Connecting 2.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"filename\" : \"Menu Bar Connecting 2.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/MenuBarConnecting-3.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"Menu Bar Connecting 3.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"filename\" : \"Menu Bar Connecting 3.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"filename\" : \"Menu Bar Connecting 3.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/MenuBarDisconnected.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"Disconnected.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"filename\" : \"Disconnected.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"filename\" : \"Disconnected.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/ObscuraOrange.colorset/Contents.json",
    "content": "{\n  \"colors\" : [\n    {\n      \"color\" : {\n        \"color-space\" : \"srgb\",\n        \"components\" : {\n          \"alpha\" : \"1.000\",\n          \"blue\" : \"37\",\n          \"green\" : \"96\",\n          \"red\" : \"255\"\n        }\n      },\n      \"idiom\" : \"universal\"\n    },\n    {\n      \"appearances\" : [\n        {\n          \"appearance\" : \"luminosity\",\n          \"value\" : \"dark\"\n        }\n      ],\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/UpdateAvailable.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"Update Available.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"filename\" : \"Update Available.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"filename\" : \"Update Available.svg\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/Assets.xcassets/custom.globe.badge.gearshape.fill.symbolset/Contents.json",
    "content": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"symbols\" : [\n    {\n      \"filename\" : \"custom.globe.badge.gearshape.fill.svg\",\n      \"idiom\" : \"universal\"\n    }\n  ]\n}\n"
  },
  {
    "path": "apple/client/Constants.swift",
    "content": "import Foundation\n\nenum UserDefaultKeys {\n    static let LoginItemRegistered = \"LoginItemRegistered\"\n    static let SelectedAppearance = \"SelectedAppearance\"\n    static let allKeys = [LoginItemRegistered, SelectedAppearance]\n}\n\nenum URLs {\n    static let SystemExtensionHelp = URL(string: \"https://support.apple.com/en-ca/120363\")!\n    static let PrivacySecurityExtensionSettings = URL(string: \"x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Security\")!\n    static let ExtensionSettings = URL(string: \"x-apple.systempreferences:com.apple.LoginItems-Settings.extension?ExtensionItems\")!\n    static let NetworkSettings = URL(string: \"x-apple.systempreferences:com.apple.NetworkExtensionSettingsUI.NESettingsUIExtension\")!\n    // See [Deep Linking](https://soveng.getoutline.com/doc/deep-linking-rhhx0E5oDB)\n    static let AppOpenURL = URL(string: \"obscuravpn:///open\")!\n    static let AppAccountPage = URL(string: \"obscuravpn:///account\")!\n    static let AppLocationPage = URL(string: \"obscuravpn:///location\")!\n}\n"
  },
  {
    "path": "apple/client/ContentView.swift",
    "content": "import OrderedCollections\nimport OSLog\nimport SwiftUI\n#if !os(macOS)\n    import UIKit\n#endif\nimport UniformTypeIdentifiers\nimport WebKit\n\nprivate let logger = Logger(\n    subsystem: Bundle.main.bundleIdentifier!,\n    category: \"ContentView\"\n)\n\nenum AppView: String, Hashable, Identifiable {\n    case account\n    case connection\n    case location\n    case settings\n    case help\n    case about\n    case developer\n\n    var id: String {\n        self.rawValue\n    }\n\n    var systemImageName: String {\n        switch self {\n        case .account:\n            \"person.circle\"\n        case .connection:\n            \"network.badge.shield.half.filled\"\n        case .location:\n            \"mappin.and.ellipse\"\n        case .settings:\n            \"gear\"\n        case .help:\n            \"questionmark.circle\"\n        case .about:\n            \"info.circle\"\n        case .developer:\n            \"book.and.wrench\"\n        }\n    }\n\n    var ipcValue: String {\n        self.rawValue\n    }\n\n    var needsScroll: Bool {\n        switch self {\n        case .connection, .help:\n            false\n        case .account, .settings, .location, .about, .developer:\n            true\n        }\n    }\n}\n\nlet STABLE_VIEWS: OrderedSet<AppView> = OrderedSet([\n    .connection, .location, .account, .settings, .help, .about,\n])\n\nlet EXPERIMETNAL_VIEWS: OrderedSet<AppView> = OrderedSet()\n\nlet DEBUG_VIEWS: OrderedSet<AppView> = OrderedSet([.developer])\n\nlet VIEW_MODES = [\n    STABLE_VIEWS,\n    STABLE_VIEWS.union(DEBUG_VIEWS),\n    STABLE_VIEWS.union(EXPERIMETNAL_VIEWS).union(DEBUG_VIEWS),\n]\n\n#if DEBUG\n    let DEFAULT_VIEW_MODE = VIEW_MODES.count - 1\n#else\n    let DEFAULT_VIEW_MODE = 0\n#endif\n\nclass ViewModeManager: ObservableObject {\n    @Published private var viewIndex = DEFAULT_VIEW_MODE\n    private var eventMonitor: Any?\n\n    init() {\n        #if os(macOS)\n            self.eventMonitor = NSEvent.addLocalMonitorForEvents(\n                matching: .keyDown\n            ) { event in\n                if event.charactersIgnoringModifiers == \"D\",\n                   event.modifierFlags.contains(.command)\n                {\n                    // Cmd+Shift+d\n                    self.viewIndex = (self.viewIndex + 1) % VIEW_MODES.count\n                    return nil\n                }\n                return event\n            }\n        #endif\n    }\n\n    deinit {\n        #if os(macOS)\n            if self.eventMonitor != nil {\n                NSEvent.removeMonitor(self.eventMonitor!)\n            }\n        #endif\n    }\n\n    func getViews() -> OrderedSet<AppView> {\n        return VIEW_MODES[self.viewIndex]\n    }\n\n    func getIOSViews() -> OrderedSet<AppView> {\n        let iOSViews: Set<AppView> = [\n            .connection, .location, .account, .settings, .about,\n        ]\n        return self.getViews().filter { iOSViews.contains($0) }\n    }\n}\n\nextension AccountStatus {\n    var badgeText: String? {\n        guard let days = daysUntilExpiry() else { return nil }\n        if !expiringSoon() {\n            return nil\n        }\n        if days > 3 {\n            return \"expires soon\"\n        }\n        if days > 1 {\n            return \"exp. in \\(days)d\"\n        }\n        if days == 1 {\n            return \"exp. in 1d\"\n        }\n        return isActive() ? \"exp. today\" : \"expired\"\n    }\n\n    var badgeColor: Color? {\n        guard let days = daysUntilExpiry() else { return nil }\n        return days <= 3 ? .red : .yellow\n    }\n}\n\nstruct ContentView: View {\n    @ObservedObject var appState: AppState\n    @ObservedObject var webviewsController: WebviewsController\n\n    // when accountBadge and badgeColor are nil, the account status is either unknown OR a badge does not need to be shown\n    // if ever the account is reset to nil, these variables will maintain their last computed values\n    // see https://linear.app/soveng/issue/OBS-1159/ regarding why account could be reset to nil\n    @State private var accountBadge: String?\n    @State private var badgeColor: Color?\n    @State private var indicateUpdateAvailable: Bool = false\n\n    #if os(macOS)\n        @EnvironmentObject private var appDelegate: AppDelegate\n    #else\n        @State private var tabBarHeight: CGFloat = 0\n    #endif\n\n    @ObservedObject private var viewMode = ViewModeManager()\n\n    // when this variable is set, force hide the toolbar and show \"Obscura\" for the navigation title\n    // otherwise let macOS manage the state and let the navigation title be driven from the navigation view shown\n    @State private var loginViewShown: Bool\n    // set alongside above, want to hide the sidebar when navigation is not allowed\n    @State private var splitViewVisibility: NavigationSplitViewVisibility\n\n    let accountBadgeTimer = Timer.publish(every: 5, on: .main, in: .common)\n        .autoconnect()\n\n    init(appState: AppState) {\n        self.appState = appState\n        self.webviewsController = appState.webviewsController\n        let forceHide =\n            appState.status.accountId == nil || appState.status.inNewAccountFlow\n        self.loginViewShown = forceHide\n        self.splitViewVisibility = forceHide ? .detailOnly : .automatic\n    }\n\n    var body: some View {\n        self.content\n            .onReceive(\n                self.accountBadgeTimer,\n                perform: { _ in\n                    if let account = self.appState.status.account {\n                        self.accountBadge = account.badgeText\n                        self.badgeColor = account.badgeColor\n                    }\n                    self.indicateUpdateAvailable =\n                        self.appState.osStatus.get().updaterStatus.type\n                            == .available\n                }\n            )\n            .onChange(of: self.webviewsController.tab) { view in\n                // inform webUI to update navigation\n                self.webviewsController.obscuraWebView?.navigateTo(view: view)\n            }\n            .onChange(of: self.appState.status) { status in\n                if let account = self.appState.status.account {\n                    self.accountBadge = account.badgeText\n                    self.badgeColor = account.badgeColor\n                }\n                if status.accountId == nil || status.inNewAccountFlow {\n                    self.loginViewShown = true\n                    self.splitViewVisibility = .detailOnly\n                } else if self.loginViewShown {\n                    // If previously force closed pop it open.\n                    self.loginViewShown = false\n                    self.splitViewVisibility = .automatic\n                }\n            }\n            .onChange(of: self.webviewsController.showSubscriptionManageSheet) { newValue in\n                if !newValue {\n                    Task {\n                        try? await self.appState.getAccountInfo()\n                    }\n                }\n            }\n            // once we are targeting macOS 14+, we can use .toolbar(removing: .sidebarToggle) instead\n            .toolbar(self.loginViewShown ? .hidden : .automatic)\n            .onAppear {\n                self.appState.webviewsController.tab = STABLE_VIEWS.first!\n                logger.log(\"Registering openUrlCallback with AppDelegate\")\n                #if os(macOS)\n                    self.appDelegate.openUrlCallback = { url in\n                        self.webviewsController.handleObscuraURL(url: url)\n                    }\n                #endif\n            }\n    }\n\n    @ViewBuilder func viewLabel(_ view: AppView) -> some View {\n        let label = Label(\n            view.rawValue.capitalized,\n            systemImage: view.systemImageName\n        )\n        .listItemTint(Color(\"ObscuraOrange\"))\n        if view == .account && self.accountBadge != nil\n            && self.badgeColor != nil\n        {\n            label.badge(\n                Text(self.accountBadge!)\n                    .monospacedDigit()\n                    .foregroundColor(self.badgeColor)\n                    .bold()\n            )\n            // this has to be here, otherwise the label color is system accent default\n            .listItemTint(Color(\"ObscuraOrange\"))\n        } else if view == .about && self.indicateUpdateAvailable {\n            HStack {\n                label\n                Spacer()\n                Circle()\n                    .fill(Color.green)\n                    .frame(width: 8, height: 8)\n            }\n            // this has to be here, otherwise the label color is system accent default\n            .listItemTint(Color(\"ObscuraOrange\"))\n        } else {\n            label\n        }\n    }\n\n    @ViewBuilder var content: some View {\n        if let obscuraWebView = webviewsController.obscuraWebView {\n            #if os(macOS)\n                NavigationSplitView(columnVisibility: self.$splitViewVisibility) {\n                    List(\n                        self.viewMode.getViews(),\n                        id: \\.self,\n                        selection: self.$webviewsController.tab\n                    ) { view in\n                        self.viewLabel(view)\n                    }\n                    .environment(\\.sidebarRowSize, .large)\n                    .navigationSplitViewColumnWidth(min: 175, ideal: 200)\n                } detail: {\n                    ObscuraUIMacOSWrapper(\n                        webView: obscuraWebView)\n                        .navigationTitle(\n                            self.loginViewShown\n                                ? \"Obscura\" : self.webviewsController.tab.rawValue.capitalized\n                        )\n                        .frame(minWidth: 390)\n                }\n            #else\n                ObscuraUIIOSViewAndTabsWrapper(\n                    webView: obscuraWebView,\n                    webviewsController: self.webviewsController,\n                    tabs: self.viewMode.getIOSViews(),\n                    showTabBar: !self.loginViewShown\n                )\n                .ignoresSafeArea()\n                .ignoresSafeArea()\n                .tint(Color(\"ObscuraOrange\"))\n                .onChange(of: self.appState.storeKitModel.subscriptionProduct) {\n                    if let model = self.appState.storeKitModel.toSubscriptionModel() {\n                        _ = self.appState.osStatus.update { value in\n                            value.storeKit.subscriptionProduct = model\n                        }\n                    }\n                }\n                .onChange(of: self.appState.storeKitModel.externalPaymentsAllowed, initial: true) { _, allowed in\n                    _ = self.appState.osStatus.update { value in\n                        value.storeKit.externalPaymentsAllowed = allowed\n                    }\n                }\n                .sheet(\n                    isPresented: self.$webviewsController.showModalWebview)\n                {\n                    self.webviewsController.externalWebView\n                        .ignoresSafeArea()\n                        .presentationDetents([.large])\n                        .presentationDragIndicator(.visible)\n                }\n                .manageSubscriptionsSheet(\n                    isPresented: self.$webviewsController.showSubscriptionManageSheet)\n                .offerCodeRedemption(isPresented: self.$appState.showOfferCodeRedemption) { result in\n                    switch result {\n                    case .success:\n                        logger.info(\n                            \"Promo code redemption flow completed successfully. (errors only show up if a valid code fails to redeem. So invalid codes and not entering a code land you here)\"\n                        )\n                        _ = self.appState.osStatus.update { value in\n                            value.offerCodeRedemptionSuccess = true\n                        }\n                    case .failure(let error):\n                        logger.error(\"Promo code redemption failed: \\(error, privacy: .public)\")\n                        _ = self.appState.osStatus.update { value in\n                            value.offerCodeRedemptionSuccess = false\n                        }\n                    }\n                }\n                .onOpenURL { incomingURL in\n                    self.webviewsController.handleObscuraURL(url: incomingURL)\n                }\n                .onReceive(NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)) { _ in\n                    guard self.appState.status.inNewAccountFlow else {\n                        return\n                    }\n                    logger.debug(\"Screenshot detected during new account flow\")\n                    self.webviewsController.obscuraWebView?.handleScreenshotDetected()\n                }\n                .fullScreenCover(isPresented: self.$appState.needsIsEnabledFix) {\n                    VStack(spacing: 12) {\n                        Text(\"Obscura VPN was disabled by another VPN app. Click the button below if you want to enable it again. This will close any active VPN tunnels from other apps.\").font(.body)\n                        Button(\"Continue\") {\n                            self.appState.runIsEnabledFix()\n                        }.buttonStyle(.borderedProminent)\n                    }\n                    .padding()\n                }\n            #endif\n        } else {\n            EmptyView()\n        }\n    }\n}\n\nstruct SidebarButton: View {\n    var body: some View {\n        Button(\n            action: self.toggleSidebar,\n            label: {\n                Image(systemName: \"sidebar.leading\")\n            }\n        )\n    }\n\n    private func toggleSidebar() {\n        #if os(macOS)\n            NSApp.keyWindow?.firstResponder?.tryToPerform(\n                #selector(NSSplitViewController.toggleSidebar(_:)),\n                with: nil\n            )\n        #endif\n    }\n}\n"
  },
  {
    "path": "apple/client/DebugBundle+XP.swift",
    "content": "import Foundation\n\npublic class DebugBundleStatus: Encodable {\n    var inProgressCounter: Int = 0\n    var inProgress: Bool {\n        return self.inProgressCounter > 0\n    }\n\n    var latestPath: String?\n\n    func start() {\n        self.inProgressCounter += 1\n    }\n\n    func finish() {\n        self.inProgressCounter -= 1\n    }\n\n    func setPath(_ path: String) {\n        self.latestPath = path\n    }\n\n    func markError() {\n        self.latestPath = nil\n    }\n\n    enum CodingKeys: String, CodingKey {\n        case inProgressCounter\n        case inProgress\n        case latestPath\n    }\n\n    public func encode(to encoder: Encoder) throws {\n        var container = encoder.container(keyedBy: CodingKeys.self)\n        try container.encode(self.inProgressCounter, forKey: .inProgressCounter)\n        try container.encode(self.inProgress, forKey: .inProgress)\n        try container.encode(self.latestPath, forKey: .latestPath)\n    }\n}\n"
  },
  {
    "path": "apple/client/DebugBundle.swift",
    "content": "#if os(macOS)\n    import AppKit\n#else\n    import StoreKit\n    import UIKit\n#endif\nimport Foundation\nimport NetworkExtension\nimport OSLog\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"DebugBundle\")\n\nstruct TaskResult: Encodable {\n    var total_s: Float\n    var error: String?\n}\n\n/// A tool to track and manage individual bundle tasks.\nprivate class BundleTask {\n    let bundle: DebugBundleBuilder\n    let name: String\n\n    private var lock = NSLock()\n    private var task: Task<Void, Never>?\n\n    private var start = SuspendingClock.now\n    private var lastPing: SuspendingClock.Instant\n    private var done: SuspendingClock.Instant?\n\n    private var timeout = Duration.seconds(70)\n\n    /// Create and start the task.\n    @discardableResult\n    init(\n        _ bundle: DebugBundleBuilder,\n        _ name: String,\n        _ f: @escaping (BundleTask) async throws -> Void\n    ) {\n        self.bundle = bundle\n        self.name = name\n\n        self.lastPing = self.start\n\n        bundle.pendingTasks.start()\n\n        self.lock.withLock {\n            self.task = Task.detached(priority: .userInitiated) { [self] in\n                do {\n                    try await f(self)\n                    self.writeResult(error: nil)\n                } catch {\n                    self.writeResult(error: error.localizedDescription)\n                }\n            }\n        }\n\n        self.watchdog()\n    }\n\n    /// Ping the watchdog timer.\n    ///\n    /// Throws if the task was cancelled (for example due to a timeout).\n    func pingWatchdog() throws {\n        try Task.checkCancellation()\n\n        self.lastPing = SuspendingClock.now\n    }\n\n    private func watchdog() {\n        self.lock.withLock {\n            let deadline = self.lastPing + self.timeout\n            let now = SuspendingClock.now\n            if now > deadline {\n                self.writeResultWithLock(error: \"Timeout\")\n                self.task!.cancel()\n            } else {\n                let remaining_s = (deadline - now) / .seconds(1)\n                DispatchQueue.main.asyncAfter(deadline: .now() + remaining_s) {\n                    self.watchdog()\n                }\n            }\n        }\n    }\n\n    private func writeResultWithLock(error: String?) {\n        if self.done != nil { return }\n\n        let done = SuspendingClock.now\n        self.done = done\n\n        let duration = done - self.start\n\n        logger.info(\"Task \\(self.name, privacy: .public) finished in \\(duration, privacy: .public) error: \\(error ?? \"-\", privacy: .public)\")\n\n        self.bundle.lock.withLock {\n            self.bundle.tasks[self.name] = TaskResult(\n                total_s: Float(duration / .seconds(1)),\n                error: error\n            )\n        }\n\n        self.bundle.pendingTasks.complete()\n    }\n\n    private func writeResult(error: String?) {\n        self.lock.withLock {\n            self.writeResultWithLock(error: error)\n        }\n    }\n}\n\nprivate class DebugBundleBuilder {\n    let tmpFolder: URL\n    let archiveFolder: URL\n    let bootTimestamp: Date?\n    let bundleTimestamp = Date()\n    let jsonEncoder = JSONEncoder()\n    let logStartTimestamp: Date\n    let appState: AppState?\n    let userFeedback: String?\n\n    let dispatchQueue = DispatchQueue.global(qos: .userInitiated)\n    var lock = NSLock()\n    var pendingTasks = PendingTasks()\n    var tasks: [String: TaskResult] = [:]\n\n    init(appState: AppState?, userFeedback: String?) throws {\n        self.appState = appState\n        self.userFeedback = userFeedback\n        self.tmpFolder = try FileManager.default.url(\n            for: FileManager.SearchPathDirectory.itemReplacementDirectory,\n            in: FileManager.SearchPathDomainMask.userDomainMask,\n            appropriateFor: FileManager.default.temporaryDirectory,\n            create: true\n        )\n        self.archiveFolder = self.tmpFolder.appending(\n            component: \"Obscura Debugging Archive \\(utcDateFormat.string(from: self.bundleTimestamp))\"\n        )\n        try FileManager.default.createDirectory(\n            at: self.archiveFolder,\n            withIntermediateDirectories: false\n        )\n\n        #if os(iOS)\n            self.bootTimestamp = nil\n        #else\n            do {\n                let tv = try Sysctl.value(ofType: timeval.self, forName: \"kern.boottime\")\n                self.bootTimestamp = Date(timeIntervalSince1970: Double(tv.tv_sec) + Double(tv.tv_usec) / 1_000_000.0)\n            } catch {\n                logger.error(\"Failed to read kern.boottime \\(error, privacy: .public)\")\n                self.bootTimestamp = nil\n            }\n        #endif\n\n        self.jsonEncoder.outputFormatting = [\n            .prettyPrinted,\n            .sortedKeys,\n        ]\n\n        self.logStartTimestamp = if let boot = self.bootTimestamp, ProcessInfo.processInfo.systemUptime < 24 * 3600 {\n            // If awake for less than 24h get all logs since boot.\n            boot - 10\n        } else {\n            // Otherwise just go back 12h.\n            self.bundleTimestamp - 12 * 3600\n        }\n    }\n\n    deinit {\n        do {\n            try FileManager.default.removeItem(at: self.tmpFolder)\n        } catch {\n            logger.error(\"Error cleaning up debug bundle temp files \\(error, privacy: .public)\")\n        }\n    }\n\n    private func createDir(name: String) throws -> URL {\n        let path = self.archiveFolder.appending(component: name)\n        try FileManager.default.createDirectory(at: path, withIntermediateDirectories: false)\n        return path\n    }\n\n    private func copyFile(source: URL, name: String) throws {\n        let path = self.archiveFolder.appending(component: name)\n        try FileManager.default.copyItem(at: source, to: path)\n    }\n\n    private func openFile(name: String) throws -> FileHandle {\n        let path = self.archiveFolder.appending(component: name)\n        FileManager.default.createFile(atPath: path.path, contents: nil, attributes: nil)\n        return try FileHandle(forWritingTo: path)\n    }\n\n    private func writeFile(name: String, data: Data) throws {\n        let path = self.archiveFolder.appending(component: name)\n        try data.write(to: path)\n    }\n\n    func writeJson<T>(name: String, _ json: T) throws where T: Encodable {\n        let data = try self.jsonEncoder.encode(json)\n        try self.writeFile(name: name, data: data)\n    }\n\n    private func writeFile(name: String, string: String) throws {\n        // Safe to unwrap because String is unicode.\n        let data = string.data(using: .utf8)!\n\n        try self.writeFile(name: name, data: data)\n    }\n\n    func writeError(name: String, error: Error) {\n        logger.error(\"Error bundling \\(name, privacy: .public): \\(error, privacy: .public)\")\n        do {\n            try self.writeFile(name: \"bundle-error-\\(name).txt\", string: error.localizedDescription)\n        } catch {\n            logger.error(\"Error bundling error for \\(name, privacy: .public): \\(error, privacy: .public)\")\n        }\n    }\n\n    #if os(macOS)\n        private func bundleCmd(_ name: String, _ args: [String]) {\n            self.bundleTask(name) { _task in\n                let child = Process()\n                child.executableURL = URL(filePath: args[0])\n                child.arguments = Array(args.suffix(from: 1))\n                child.standardInput = FileHandle.nullDevice\n                child.standardOutput = try self.openFile(name: \"\\(name)-stdout.txt\")\n                child.standardError = try self.openFile(name: \"\\(name)-stderr.txt\")\n\n                try child.run()\n                child.waitUntilExit()\n\n                if child.terminationStatus != 0 {\n                    try self.writeFile(name: \"\\(name)-status.txt\", string: String(child.terminationStatus))\n                }\n            }\n        }\n    #endif\n\n    private func bundlePlist(name: String, path: URL) {\n        self.bundleTask(name) { _task in\n            let plist = try Data(contentsOf: path)\n            var value = try PropertyListSerialization.propertyList(from: plist, options: [], format: nil)\n            prepareForJson(&value)\n            let json = try JSONSerialization.data(\n                withJSONObject: value,\n                options: [.fragmentsAllowed, .prettyPrinted]\n            )\n            try self.writeFile(name: \"\\(name).json\", data: json)\n        }\n    }\n\n    private func bundlePlist(path: URL) {\n        self.bundlePlist(name: path.lastPathComponent, path: path)\n    }\n\n    func bundleInfo() throws {\n        struct Info: Encodable {\n            let AppVersion = sourceVersion()\n            let BuildNumber = buildVersion()\n            let BootTimestamp: String?\n            let BundleTimestamp: String\n            let LogStartTimestamp: String\n            let LowPowerMode = ProcessInfo.processInfo.isLowPowerModeEnabled\n            let OSVersion = [\n                ProcessInfo.processInfo.operatingSystemVersion.majorVersion,\n                ProcessInfo.processInfo.operatingSystemVersion.minorVersion,\n                ProcessInfo.processInfo.operatingSystemVersion.patchVersion,\n            ]\n            let OSVersionString = ProcessInfo.processInfo.operatingSystemVersionString\n            #if os(macOS)\n                let Model = Sysctl.model // Model identifier to model name: https://support.apple.com/en-ca/102869\n            #else\n                let Model = UIDevice.current.model\n            #endif\n            let PID = ProcessInfo.processInfo.processIdentifier\n            let ProcessName = ProcessInfo.processInfo.processName\n            let ProcessorCountActive = ProcessInfo.processInfo.processorCount\n            let ProcessorCountPhysical = ProcessInfo.processInfo.activeProcessorCount\n            #if os(macOS)\n                let ProcessorName: String = (try? Sysctl.string(for: \"machdep.cpu.brand_string\")) ?? \"Unknown\"\n            #endif\n            let RAMPhysicalGiB = Double(ProcessInfo.processInfo.physicalMemory) / 1024.0 / 1024.0 / 1024.0\n            let SourceID = sourceId()\n            let ThermalState: String\n            let UptimeHours = ProcessInfo.processInfo.systemUptime / 3600\n\n            init(_ this: DebugBundleBuilder) {\n                self.BootTimestamp = if let boot = this.bootTimestamp {\n                    utcDateFormat.string(from: boot)\n                } else {\n                    nil\n                }\n                self.BundleTimestamp = utcDateFormat.string(from: this.bundleTimestamp)\n                self.LogStartTimestamp = utcDateFormat.string(from: this.logStartTimestamp)\n                self.ThermalState = switch ProcessInfo.processInfo.thermalState {\n                case .nominal: \"nominal\"\n                case .fair: \"fair\"\n                case .serious: \"serious\"\n                case .critical: \"critical\"\n                default: \"unknown\"\n                }\n            }\n        }\n\n        try self.writeJson(name: \"info.json\", Info(self))\n    }\n\n    #if os(macOS)\n        func getLogStore() throws -> (OSLogStore, String) {\n            do {\n                let logStore = try OSLogStore.local()\n                return (logStore, \"system-log.json\")\n            } catch {\n                self.writeError(name: \"system-logs\", error: error)\n                let logStore = try OSLogStore(scope: .currentProcessIdentifier)\n                return (logStore, \"client-log.json\")\n            }\n        }\n    #endif\n\n    #if os(iOS)\n        func getLogStore() throws -> (OSLogStore, String) {\n            let logStore = try OSLogStore(scope: .currentProcessIdentifier)\n            return (logStore, \"client-log.json\")\n        }\n    #endif\n\n    func bundleLogs() {\n        self.bundleTask(\"logs\") { task in\n            let (logStore, fileName) = try self.getLogStore()\n            let logEntries = try logStore.getEntries(\n                at: logStore.position(date: self.logStartTimestamp),\n                matching: NSPredicate(format: \"\"\"\n                    process IN {\n                        \"Obscura VPN (Debug Dev Server)\",\n                        \"Obscura VPN (Debug)\",\n                        \"Obscura VPN\",\n                        \"kernel\",\n                        \"neagent\",\n                        \"nehelper\",\n                        \"nesessionmanager\",\n                        \"net.obscura.vpn-client-app.system-network-extension\",\n                        \"sysextd\" }\n                    || subsystem IN {\n                        \"com.apple.networkextension\",\n                        \"com.apple.powerd\" }\n                    || eventMessage CONTAINS \"bscura\"\n                    || subsystem CONTAINS \"bscura\"\n                \"\"\")\n            )\n\n            let dateFormatter = DateFormatter()\n            dateFormatter.dateFormat = \"yyyy-MM-dd HH:mm:ss.SSSxx\"\n\n            let encoder = JSONEncoder()\n            encoder.dateEncodingStrategy = .formatted(dateFormatter)\n\n            let file = try self.openFile(name: fileName)\n            let newline = \"\\n\".data(using: .utf8)!\n            for entry in logEntries {\n                if entry.date > self.bundleTimestamp {\n                    break\n                }\n\n                var line = try encoder.encode(entry)\n                line.append(newline)\n                try file.write(contentsOf: line)\n\n                try task.pingWatchdog()\n            }\n            try file.close()\n        }\n    }\n\n    #if os(macOS)\n        func bundleExtensions() async throws {\n            let extensions = await getExtensionDebugInfo()\n\n            struct ExtensionDebugInfo: Encodable {\n                let bundleIdentifier: String\n                let bundleVersion: String\n                let bundleShortVersion: String\n                let url: URL\n                let isAwaitingUserApproval: Bool\n                let isEnabled: Bool\n                let isUninstalling: Bool\n            }\n\n            try self.writeJson(\n                name: \"extensions.json\",\n                extensions.map {\n                    ExtensionDebugInfo(\n                        bundleIdentifier: $0.bundleIdentifier,\n                        bundleVersion: $0.bundleVersion,\n                        bundleShortVersion: $0.bundleShortVersion,\n                        url: $0.url,\n                        isAwaitingUserApproval: $0.isAwaitingUserApproval,\n                        isEnabled: $0.isEnabled,\n                        isUninstalling: $0.isUninstalling\n                    )\n                }\n            )\n\n            for (i, ext) in extensions.enumerated() {\n                if !ext.isEnabled { continue }\n\n                let name = \"extension-\\(ext.bundleIdentifier)-\\(i).provisionprofile\"\n                do {\n                    try self.copyFile(\n                        source: ext.url.appending(path: \"Contents/embedded.provisionprofile\"),\n                        name: name\n                    )\n                } catch {\n                    self.writeError(name: name, error: error)\n                }\n            }\n        }\n    #endif\n\n    func bundleNETunnelProviderManager() {\n        guard let manager = self.appState?.manager else {\n            self.writeError(name: \"ne-tunnel-provider-manager\", error: \"appState or manager is nil\")\n            return\n        }\n\n        struct ConnectionInfo: Encodable {\n            let status: NEVPNStatus\n\n            init(_ connection: NEVPNConnection) {\n                self.status = connection.status\n            }\n        }\n        struct ProxyServerInfo: Encodable {\n            let address: String\n            let authenticationRequired: Bool\n            let port: Int\n\n            init(_ proxyServer: NEProxyServer) {\n                self.address = proxyServer.address\n                self.authenticationRequired = proxyServer.authenticationRequired\n                self.port = proxyServer.port\n            }\n        }\n        struct ProxySettingsInfo: Encodable {\n            let autoProxyConfigurationEnabled: Bool\n            let exceptionList: [String]?\n            let excludeSimpleHostnames: Bool\n            let httpEnabled: Bool\n            let httpServer: ProxyServerInfo?\n            let matchDomains: [String]?\n            let proxyAutoConfigurationJavaScript: String?\n            let proxyAutoConfigurationURL: URL?\n\n            init(_ proxySettings: NEProxySettings) {\n                self.autoProxyConfigurationEnabled = proxySettings.autoProxyConfigurationEnabled\n                self.exceptionList = proxySettings.exceptionList\n                self.excludeSimpleHostnames = proxySettings.excludeSimpleHostnames\n                self.httpEnabled = proxySettings.httpEnabled\n                self.httpServer = proxySettings.httpServer.map { ProxyServerInfo($0) }\n                self.matchDomains = proxySettings.matchDomains\n                self.proxyAutoConfigurationJavaScript = proxySettings.proxyAutoConfigurationJavaScript\n                self.proxyAutoConfigurationURL = proxySettings.proxyAutoConfigurationURL\n            }\n        }\n        struct ProtocolConfigurationInfo: Encodable {\n            let disconnectOnSleep: Bool\n            let enforceRoutes: Bool\n            let excludeLocalNetworks: Bool\n            let includeAllNetworks: Bool\n            let proxySettings: ProxySettingsInfo?\n            let serverAddress: String?\n\n            init(_ protocolConfiguration: NEVPNProtocol) {\n                self.disconnectOnSleep = protocolConfiguration.disconnectOnSleep\n                self.enforceRoutes = protocolConfiguration.enforceRoutes\n                // TODO: include once our minimal version is macOS 13.3\n                // self.excludeAPNs = protocolConfiguration.excludeAPNs\n                // self.excludeCellularServices = protocolConfiguration.excludeCellularServices\n                // self.excludeDeviceCommunication = protocolConfiguration.excludeDeviceCommunication\n                self.excludeLocalNetworks = protocolConfiguration.excludeLocalNetworks\n                self.includeAllNetworks = protocolConfiguration.includeAllNetworks\n                self.proxySettings = protocolConfiguration.proxySettings.map { ProxySettingsInfo($0) }\n                self.serverAddress = protocolConfiguration.serverAddress\n            }\n        }\n        struct OnDemandRuleInfo: Encodable {\n            let action: String\n            let dnsSearchDomainMatch: [String]?\n            let dnsServerAddressMatch: [String]?\n            let interfaceTypeMatch: String\n            let probeURL: URL?\n            let ssidMatch: [String]?\n            init(_ onDemandRule: NEOnDemandRule) {\n                self.action = switch onDemandRule.action {\n                case .connect:\n                    \"connect\"\n                case .disconnect:\n                    \"disconnect\"\n                case .evaluateConnection:\n                    \"evaluateConnection\"\n                case .ignore:\n                    \"ignore\"\n                @unknown default:\n                    \"unknown\"\n                }\n                self.dnsSearchDomainMatch = onDemandRule.dnsSearchDomainMatch\n                self.dnsServerAddressMatch = onDemandRule.dnsServerAddressMatch\n                self.interfaceTypeMatch = switch onDemandRule.interfaceTypeMatch {\n                case .any:\n                    \"any\"\n                case .ethernet:\n                    \"ethernet\"\n                case .wiFi: \"wiFi\"\n                case .cellular: \"cellular\"\n                @unknown default:\n                    \"unknown\"\n                }\n                self.probeURL = onDemandRule.probeURL\n                self.ssidMatch = onDemandRule.ssidMatch\n            }\n        }\n        struct ManagerInfo: Encodable {\n            let connection: ConnectionInfo\n            let protocolConfiguration: ProtocolConfigurationInfo?\n            let routingMethod: String\n            let isEnabled: Bool\n            let isOnDemandEnabled: Bool\n            let onDemandRules: [OnDemandRuleInfo]?\n\n            init(_ manager: NETunnelProviderManager) {\n                self.connection = ConnectionInfo(manager.connection)\n                self.protocolConfiguration = manager.protocolConfiguration.map { ProtocolConfigurationInfo($0) }\n                self.routingMethod = switch manager.routingMethod {\n                case .destinationIP:\n                    \"destinationIP\"\n                case .networkRule:\n                    \"networkRule\"\n                case .sourceApplication:\n                    \"sourceApplication\"\n                @unknown default:\n                    \"unknown\"\n                }\n                self.isEnabled = manager.isEnabled\n                self.isOnDemandEnabled = manager.isOnDemandEnabled\n                self.onDemandRules = manager.onDemandRules.map { $0.map { OnDemandRuleInfo($0) }}\n            }\n        }\n\n        do {\n            try self.writeJson(name: \"ne-tunnel-provider-manager.json\", ManagerInfo(manager))\n        } catch {\n            self.writeError(name: \"ne-tunnel-provider-manager\", error: error)\n        }\n    }\n\n    func bundleNEDebugInfo() async {\n        guard let manager = self.appState?.manager else {\n            self.writeError(name: \"ne-debug-info\", error: \"appState or manager is nil\")\n            return\n        }\n        do {\n            let neDebugInfoJsonString = try await runNeJsonCommand(manager, NeManagerCmd.getDebugInfo.json(), name: \"getDebugInfo\", attemptTimeout: .seconds(70))\n            let value = try JSONSerialization.jsonObject(with: Data(neDebugInfoJsonString.utf8))\n            let json = try JSONSerialization.data(\n                withJSONObject: value,\n                options: [.fragmentsAllowed, .prettyPrinted, .sortedKeys]\n            )\n            try self.writeFile(name: \"ne-debug-info.json\", data: json)\n        } catch {\n            self.writeError(name: \"ne-debug-info\", error: error)\n        }\n    }\n\n    func bundleRustLog() async {\n        guard let logDir = logDir() else {\n            self.writeError(name: \"rust-log\", error: \"logDir is nil\")\n            return\n        }\n        do {\n            try self.copyFile(source: URL(fileURLWithPath: logDir), name: \"rust-logs\")\n        } catch {\n            self.writeError(name: \"rust-log\", error: error)\n        }\n    }\n\n    #if os(iOS)\n        @MainActor func bundleStoreKitInfo() async {\n            guard let storeKitModel = self.appState?.storeKitModel else {\n                self.writeError(name: \"storekit-info\", error: \"appState is nil\")\n                return\n            }\n            var products: [Any] = []\n            var transactionsVerified: [Any] = []\n            var transactionsUnverified: [[String: Any]] = []\n            do {\n                var products = try await storeKitModel.collectDebugData()\n                for await verificationResult in Transaction.all {\n                    switch verificationResult {\n                    case .verified(let transaction):\n                        try transactionsVerified.append(JSONSerialization.jsonObject(with: transaction.jsonRepresentation))\n                    case .unverified(let transaction, let error):\n                        try transactionsUnverified.append([\n                            \"transaction\": JSONSerialization.jsonObject(with: transaction.jsonRepresentation),\n                            \"error\": error.localizedDescription,\n                        ])\n                    }\n                }\n                let info: [String: Any] = [\n                    \"products\": products,\n                    \"transactions\": [\n                        \"verified\": transactionsVerified,\n                        \"unverified\": transactionsUnverified,\n                    ],\n                ]\n                let json = try JSONSerialization.data(\n                    withJSONObject: info,\n                    options: [.fragmentsAllowed, .prettyPrinted, .sortedKeys]\n                )\n                try self.writeFile(name: \"storekit-info.json\", data: json)\n            } catch {\n                self.writeError(name: \"storekit-info\", error: error)\n            }\n        }\n    #endif\n\n    func bundleTask(_ name: String, _ block: @escaping (BundleTask) async throws -> Void) {\n        BundleTask(self, name, block)\n    }\n\n    func bundleAll() async {\n        self.bundleLogs()\n\n        self.bundleTask(\"user-feedback\") { _task in\n            try self.writeFile(name: \"user-feedback.txt\", string: self.userFeedback ?? \"\")\n        }\n\n        self.bundleTask(\"app-provisionprofile\") { _task in\n            #if os(macOS)\n                let path = \"Contents/embedded.provisionprofile\"\n            #else\n                let path = \"embedded.mobileprovision\"\n            #endif\n            try self.copyFile(\n                source: Bundle.main.bundleURL.appending(path: path),\n                name: \"app.provisionprofile\"\n            )\n        }\n\n        self.bundleTask(\"app-extension-provisionprofile\") { _task in\n            #if os(macOS)\n                let source = extensionBundle()\n                    .bundleURL\n                    .appending(path: \"Contents/embedded.provisionprofile\")\n            #else\n                let source = Bundle.main.bundleURL\n                    .appending(path: \"PlugIns/App Network Extension.appex/embedded.mobileprovision\")\n            #endif\n            try self.copyFile(\n                source: source,\n                name: \"app-extension.provisionprofile\"\n            )\n        }\n\n        self.bundleTask(\"ne-tunnel-provider-manager\") { _task in self.bundleNETunnelProviderManager() }\n        self.bundleTask(\"ne-debug-info\") { _task in await self.bundleNEDebugInfo() }\n        self.bundleTask(\"info\") { _task in try self.bundleInfo() }\n\n        // TODO: https://linear.app/soveng/issue/OBS-2210/implement-more-diagnostics-on-ios\n        #if os(macOS)\n            self.bundleTask(\"extensions\") { _task in try await self.bundleExtensions() }\n\n            self.bundleCmd(\"arp\", [\"/usr/sbin/arp\", \"-na\"])\n            self.bundleCmd(\"csrutil-status\", [\"/usr/bin/csrutil\", \"status\"])\n            self.bundleCmd(\"dig-apple.com\", [\"/usr/bin/dig\", \"+time=2\", \"www.apple.com\"])\n            self.bundleCmd(\"dig-google.com\", [\"/usr/bin/dig\", \"+time=2\", \"google.com\"])\n            self.bundleCmd(\"dig-v1.api.prod.obscura.net\", [\"/usr/bin/dig\", \"+time=2\", \"v1.api.prod.obscura.net\"])\n            self.bundleCmd(\"dns\", [\"/usr/sbin/scutil\", \"--dns\", \"-dv\"])\n            self.bundleCmd(\"hostinfo\", [\"/usr/bin/hostinfo\"])\n            self.bundleCmd(\"http-v1.api.prod.obscura.net\", [\"/usr/bin/curl\", \"--verbose\", \"--insecure\", \"--location\", \"--silent\", \"--show-error\", \"https://v1.api.prod.obscura.net/api/ping\"])\n            self.bundleCmd(\"http-v1.api.prod.obscura.net-apple.com\", [\"/usr/bin/curl\", \"--verbose\", \"--insecure\", \"--silent\", \"--show-error\", \"--connect-to\", \"::v1.api.prod.obscura.net:\", \"https://apple.com/api/ping\", \"-Hhost:v1.api.prod.obscura.net\"])\n            self.bundleCmd(\"http-v1.api.prod.obscura.net-google.com\", [\"/usr/bin/curl\", \"--verbose\", \"--insecure\", \"--silent\", \"--show-error\", \"--connect-to\", \"::v1.api.prod.obscura.net:\", \"https://google.com/api/ping\", \"-Hhost:v1.api.prod.obscura.net\"])\n            self.bundleCmd(\"ifconfig\", [\"/sbin/ifconfig\", \"-aLbmrvv\"])\n            self.bundleCmd(\"netstat-interface-stats\", [\"/usr/sbin/netstat\", \"-ind\"])\n            self.bundleCmd(\"netstat-listen-queues\", [\"/usr/sbin/netstat\", \"-Lanv\"])\n            self.bundleCmd(\"netstat-routes\", [\"/usr/sbin/netstat\", \"-nral\"])\n            self.bundleCmd(\"netstat-stats\", [\"/usr/sbin/netstat\", \"-s\"])\n            self.bundleCmd(\"network-info\", [\"/usr/sbin/scutil\", \"--nwi\", \"-dv\"])\n            self.bundleCmd(\"ping-1.1.1.1\", [\"/sbin/ping\", \"-oc5\", \"1.1.1.1\"])\n            self.bundleCmd(\"ping-2001:4860:4860::8888\", [\"/sbin/ping6\", \"-oc5\", \"2001:4860:4860::8888\"])\n            self.bundleCmd(\"ping-2606:4700:4700::1111\", [\"/sbin/ping6\", \"-oc5\", \"2606:4700:4700::1111\"])\n            self.bundleCmd(\"ping-8.8.8.8\", [\"/sbin/ping\", \"-oc5\", \"8.8.8.8\"])\n            self.bundleCmd(\"ping-v1.api.prod.obscura.net\", [\"/sbin/ping\", \"-oc5\", \"v1.api.prod.obscura.net\"])\n            self.bundleCmd(\"processes\", [\"/bin/ps\", \"axlww\"])\n            self.bundleCmd(\"proxy\", [\"/usr/sbin/scutil\", \"--proxy\", \"-dv\"])\n            self.bundleCmd(\"reachability-0.0.0.0\", [\"/usr/sbin/scutil\", \"-r\", \"www.apple.com\", \"-dv\"])\n            self.bundleCmd(\"reachability-1.1.1.1\", [\"/usr/sbin/scutil\", \"-r\", \"1.1.1.1\", \"-dv\"])\n            self.bundleCmd(\"reachability-169.254.0.0\", [\"/usr/sbin/scutil\", \"-r\", \"169.254.0.0\", \"-dv\"])\n            self.bundleCmd(\"reachability-169.254.0.0\", [\"/usr/sbin/scutil\", \"-r\", \"169.254.0.0\", \"-dv\"])\n            self.bundleCmd(\"reachability-8.8.8.8\", [\"/usr/sbin/scutil\", \"-r\", \"8.8.8.8\", \"-dv\"])\n            self.bundleCmd(\"route-0.0.0.0\", [\"/sbin/route\", \"-nv\", \"get\", \"0.0.0.0\"])\n            self.bundleCmd(\"route-1.1.1.1\", [\"/sbin/route\", \"-nv\", \"get\", \"1.1.1.1\"])\n            self.bundleCmd(\"route-2001:4860:4860::8888\", [\"/sbin/route\", \"-nv\", \"get\", \"-inet6\", \"2001:4860:4860::8888\"])\n            self.bundleCmd(\"route-2606:4700:4700::1111\", [\"/sbin/route\", \"-nv\", \"get\", \"-inet6\", \"2606:4700:4700::1111\"])\n            self.bundleCmd(\"route-8.8.8.8\", [\"/sbin/route\", \"-nv\", \"get\", \"8.8.8.8\"])\n            self.bundleCmd(\"route-::\", [\"/sbin/route\", \"-nv\", \"get\", \"-inet6\", \"::\"])\n            self.bundleCmd(\"route-apple.com\", [\"/sbin/route\", \"-nv\", \"get\", \"www.apple.com\"])\n            self.bundleCmd(\"route-google.com\", [\"/sbin/route\", \"-nv\", \"get\", \"google.com\"])\n            self.bundleCmd(\"route-v1.api.prod.obscura.net\", [\"/sbin/route\", \"-nv\", \"get\", \"v1.api.prod.obscura.net\"])\n            self.bundleCmd(\"scutil-advisory\", [\"/usr/sbin/scutil\", \"--advisory\", \"\"])\n            self.bundleCmd(\"scutil-rank\", [\"/usr/sbin/scutil\", \"--rank\", \"\"])\n            self.bundleCmd(\"skywalk-status\", [\"/usr/sbin/skywalkctl\", \"status\"])\n            self.bundleCmd(\"sysctl\", [\"/usr/sbin/sysctl\", \"-a\"])\n            self.bundleCmd(\"system-profiles\", [\"/usr/sbin/system_profiler\", \"-json\", \"-detailLevel\", \"full\", \"SPAirPortDataType\", \"SPConfigurationProfileDataType\", \"SPDiagnosticsDataType\", \"SPEthernetDataType\", \"SPFibreChannelDataType\", \"SPFirewallDataType\", \"SPHardwareDataType\", \"SPInternationalDataType\", \"SPManagedClientDataType\", \"SPMemoryDataType\", \"SPNetworkDataType\", \"SPNetworkLocationDataType\", \"SPNVMeDataType\", \"SPPowerDataType\", \"SPSoftwareDataType\", \"SPStorageDataType\", \"SPUniversalAccessDataType\"])\n            self.bundleCmd(\"vpn-connections\", [\"/usr/sbin/scutil\", \"--nc\", \"list\"])\n\n            self.bundlePlist(path: URL(filePath: \"/Library/Preferences/SystemConfiguration/NetworkInterfaces.plist\"))\n            self.bundlePlist(path: URL(filePath: \"/Library/Preferences/com.apple.networkd.plist\"))\n            self.bundlePlist(path: URL(filePath: \"/Library/Preferences/com.apple.networkextension.cache.plist\"))\n            self.bundlePlist(path: URL(filePath: \"/Library/Preferences/com.apple.networkextension.control.plist\"))\n            self.bundlePlist(path: URL(filePath: \"/Library/Preferences/com.apple.networkextension.necp.plist\"))\n            self.bundlePlist(path: URL(filePath: \"/etc/bootpd.plist\"))\n        #endif\n\n        #if os(iOS)\n            self.bundleTask(\"rust-log\") { _task in await self.bundleRustLog() }\n            self.bundleTask(\"storekit-info\") { _task in await self.bundleStoreKitInfo() }\n        #endif\n\n        await self.pendingTasks.waitForAll()\n\n        do {\n            try self.lock.withLock {\n                try self.writeJson(name: \"tasks.json\", self.tasks)\n            }\n        } catch {\n            self.writeError(name: \"tasks-json\", error: error)\n        }\n    }\n\n    func createArchive() throws -> URL {\n        let zipName = \"Obscura Debugging Archive \\(utcDateFormat.string(from: self.bundleTimestamp)).zip\"\n\n        var zipPath: URL?\n        var coordinatorError: NSError?\n        var blockError: Error?\n\n        NSFileCoordinator().coordinate(\n            readingItemAt: self.archiveFolder,\n            options: [.forUploading],\n            error: &coordinatorError\n        ) { inUrl in\n            do {\n                let outDir = try FileManager.default.url(\n                    for: .itemReplacementDirectory,\n                    in: .userDomainMask,\n                    appropriateFor: inUrl,\n                    create: true\n                )\n                let outUrl = outDir.appendingPathComponent(zipName)\n\n                try FileManager.default.moveItem(at: inUrl, to: outUrl)\n\n                zipPath = outUrl\n            } catch {\n                blockError = error\n            }\n        }\n\n        if let error = coordinatorError {\n            throw error\n        }\n        if let error = blockError {\n            throw error\n        }\n        guard let zipPath = zipPath else {\n            throw \"Archive callback never ran.\"\n        }\n\n        return zipPath\n    }\n}\n\n// Abstract DebugBundleStatus manager which ensures that inProgressCounter is appropriately incremented/decremented\npublic class DebugBundleRC {\n    private let appState: AppState\n\n    init(_ appState: AppState) {\n        self.appState = appState\n\n        _ = self.appState.osStatus.update { value in\n            value.debugBundleStatus.start()\n        }\n    }\n\n    deinit {\n        _ = self.appState.osStatus.update { value in\n            value.debugBundleStatus.finish()\n        }\n    }\n}\n\nfunc _createDebuggingArchive(appState: AppState?, userFeedback: String?) async throws -> String {\n    let _ = ProcessInfo.processInfo.beginActivity(\n        options: [\n            .automaticTerminationDisabled,\n            .idleSystemSleepDisabled,\n            .suddenTerminationDisabled,\n            .userInitiated,\n        ],\n        reason: \"Generating Debug Bundle\"\n    )\n\n    let start = SuspendingClock.now\n\n    let builder = try DebugBundleBuilder(appState: appState, userFeedback: userFeedback)\n    await builder.bundleAll()\n    let zipPath = try builder.createArchive()\n\n    let elapsed = SuspendingClock.now - start\n    logger.info(\"Debug Bundle completed in \\(elapsed, privacy: .public)\")\n\n    #if os(macOS)\n        NSWorkspace.shared.selectFile(zipPath.path, inFileViewerRootedAtPath: \"\")\n    #endif\n    return zipPath.path\n}\n\nfunc createDebuggingArchive(appState: AppState?, userFeedback: String?) async throws -> String {\n    // ensure deinit occurs at the end of the method\n    var _debugBundleRc: DebugBundleRC?\n    defer { withExtendedLifetime(_debugBundleRc) {}}\n    if let appState = appState {\n        _debugBundleRc = DebugBundleRC(appState)\n    }\n    do {\n        let path = try await _createDebuggingArchive(appState: appState, userFeedback: userFeedback)\n        _ = appState?.osStatus.update { value in\n            value.debugBundleStatus.setPath(path)\n        }\n        return path\n    } catch {\n        _ = appState?.osStatus.update { value in\n            value.debugBundleStatus.markError()\n        }\n        throw error\n    }\n}\n"
  },
  {
    "path": "apple/client/DebugBundleExtensionInfo.swift",
    "content": "import Foundation\nimport NetworkExtension\nimport OSLog\nimport SystemExtensions\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"DebugBundleExtensionInfo\")\n\nfunc getExtensionDebugInfo() async -> [OSSystemExtensionProperties] {\n    var delegate: Delegate? // OSSystemExtensionManager doesn't keep our delegate alive, so we need to take a reference.\n\n    return await withCheckedContinuation { continuation in\n        let request = OSSystemExtensionRequest.propertiesRequest(\n            forExtensionWithIdentifier: networkExtensionBundleID(),\n            queue: .main\n        )\n        delegate = Delegate(continuation)\n        request.delegate = delegate\n        OSSystemExtensionManager.shared.submitRequest(request)\n    }\n}\n\nprivate class Delegate: NSObject {\n    let continuation: CheckedContinuation<[OSSystemExtensionProperties], Never>\n\n    init(_ continuation: CheckedContinuation<[OSSystemExtensionProperties], Never>) {\n        self.continuation = continuation\n    }\n}\n\nextension Delegate: OSSystemExtensionRequestDelegate {\n    func request(\n        _ request: OSSystemExtensionRequest,\n        actionForReplacingExtension existing: OSSystemExtensionProperties,\n        withExtension ext: OSSystemExtensionProperties\n    ) -> OSSystemExtensionRequest.ReplacementAction {\n        return .cancel\n    }\n\n    func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {}\n\n    func request(\n        _ request: OSSystemExtensionRequest,\n        didFinishWithResult result: OSSystemExtensionRequest.Result\n    ) {}\n\n    func request(\n        _ request: OSSystemExtensionRequest,\n        didFailWithError error: any Error\n    ) {}\n\n    func request(\n        _ request: OSSystemExtensionRequest,\n        foundProperties extensions: [OSSystemExtensionProperties]\n    ) {\n        self.continuation.resume(returning: extensions)\n    }\n}\n"
  },
  {
    "path": "apple/client/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>CFBundleURLTypes</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>CFBundleTypeRole</key>\n\t\t\t<string>Editor</string>\n\t\t\t<key>CFBundleURLName</key>\n\t\t\t<string>net.obscura.vpn-client-app</string>\n\t\t\t<key>CFBundleURLSchemes</key>\n\t\t\t<array>\n\t\t\t\t<string>obscuravpn</string>\n\t\t\t</array>\n\t\t</dict>\n\t</array>\n\t<key>OSLogPreferences</key>\n\t<dict>\n\t\t<key>com.apple.extensionkit</key>\n\t\t<dict>\n\t\t\t<key>DEFAULT-OPTIONS</key>\n\t\t\t<dict>\n\t\t\t\t<key>Enable-Oversize-Messages</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Enable-Private-Data</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Level</key>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>Enable</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t\t<key>Persist</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t</dict>\n\t\t\t</dict>\n\t\t</dict>\n\t\t<key>com.apple.xpc.transaction</key>\n\t\t<dict>\n\t\t\t<key>DEFAULT-OPTIONS</key>\n\t\t\t<dict>\n\t\t\t\t<key>Enable-Oversize-Messages</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Enable-Private-Data</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Level</key>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>Enable</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t\t<key>Persist</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t</dict>\n\t\t\t</dict>\n\t\t</dict>\n\t\t<key>net.obscura.vpn-client-app</key>\n\t\t<dict>\n\t\t\t<key>DEFAULT-OPTIONS</key>\n\t\t\t<dict>\n\t\t\t\t<key>Enable-Oversize-Messages</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Enable-Private-Data</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Level</key>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>Enable</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t\t<key>Persist</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t</dict>\n\t\t\t</dict>\n\t\t</dict>\n\t\t<key>net.obscura.vpn-client-app-ios</key>\n\t\t<dict>\n\t\t\t<key>DEFAULT-OPTIONS</key>\n\t\t\t<dict>\n\t\t\t\t<key>Enable-Oversize-Messages</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Enable-Private-Data</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Level</key>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>Enable</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t\t<key>Persist</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t</dict>\n\t\t\t</dict>\n\t\t</dict>\n\t</dict>\n\t<key>Obscura</key>\n\t<dict>\n\t\t<key>AppGroupIdentifier</key>\n\t\t<string>$(OBSCURA_APP_APP_GROUP_ID)</string>\n\t\t<key>OBSCURA_NETWORK_EXTENSION_BUNDLE_ID</key>\n\t\t<string>$(OBSCURA_NETWORK_EXTENSION_BUNDLE_ID)</string>\n\t\t<key>ObscuraSourceId</key>\n\t\t<string>$(OBSCURA_SOURCE_ID)</string>\n\t\t<key>ObscuraSourceVersion</key>\n\t\t<string>$(OBSCURA_SOURCE_VERSION)</string>\n\t</dict>\n\t<key>SUEnableAutomaticChecks</key>\n\t<true/>\n\t<key>SUEnableInstallerLauncherService</key>\n\t<true/>\n\t<key>SUFeedURL</key>\n\t<string>https://pkgs.obscura.net/macos/appcast.xml</string>\n\t<key>SUPublicEDKey</key>\n\t<string>R4CNa/L1zQGVdNot8RDQOpAxJdwzBGnZnLR/6G/Zyts=</string>\n\t<key>SUScheduledCheckInterval</key>\n\t<integer>21600</integer>\n\t<key>UIDesignRequiresCompatibility</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "apple/client/LoginItem.swift",
    "content": "import OSLog\nimport ServiceManagement\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"loginitem\")\n\nfunc unregisterAsLoginItem(appState: AppState) throws(String) {\n    do {\n        try SMAppService.mainApp.unregister()\n        let loginItemRegistered = isRegisteredAsLoginItem()\n        _ = appState.osStatus.update { value in\n            value.loginItemStatus = OsStatus.LoginItemStatus(registered: loginItemRegistered, error: nil)\n        }\n    } catch {\n        _ = appState.osStatus.update { value in\n            value.loginItemStatus?.error = error.localizedDescription\n        }\n        logger.error(\"failed to unregister app at login \\(error, privacy: .public)\")\n        throw errorCodeOther\n    }\n}\n\nfunc registerAsLoginItem(appState: AppState?) throws(String) {\n    do {\n        try SMAppService.mainApp.register()\n        let loginItemRegistered = isRegisteredAsLoginItem()\n        if let appState = appState {\n            _ = appState.osStatus.update { value in\n                value.loginItemStatus = OsStatus.LoginItemStatus(registered: loginItemRegistered, error: nil)\n            }\n        }\n    } catch {\n        if let appState = appState {\n            _ = appState.osStatus.update { value in\n                value.loginItemStatus?.error = error.localizedDescription\n            }\n        }\n        logger.error(\"failed to register app at login \\(error, privacy: .public)\")\n        throw errorCodeOther\n    }\n}\n\nfunc isRegisteredAsLoginItem() -> Bool {\n    return SMAppService.mainApp.status == .enabled\n}\n"
  },
  {
    "path": "apple/client/LoopingVideoPlayer.swift",
    "content": "import AVKit\nimport SwiftUI\n\nstruct LoopingVideoPlayer: View {\n    @State private var player: AVQueuePlayer\n    @State private var playerLooper: AVPlayerLooper\n    private var width: CGFloat\n    private var height: CGFloat\n\n    init(url: URL, width: CGFloat, height: CGFloat) {\n        let asset = if #available(macOS 15, *) {\n            AVURLAsset(url: url)\n        } else {\n            AVAsset(url: url)\n        }\n        let item = AVPlayerItem(asset: asset)\n\n        let player = AVQueuePlayer(playerItem: item)\n        self.player = player\n        self.playerLooper = AVPlayerLooper(player: player, templateItem: item)\n        self.width = width\n        self.height = height\n\n        self.player.isMuted = true\n    }\n\n    var body: some View {\n        VideoPlayer(player: self.player)\n            .frame(minWidth: self.width, maxWidth: .infinity, minHeight: self.height, maxHeight: .infinity, alignment: .center)\n            .aspectRatio(self.width / self.height, contentMode: .fit)\n            .onAppear { self.player.play() }\n            .onDisappear { self.player.pause() }\n            .disabled(true)\n            .cornerRadius(8)\n            .padding(.all, 20)\n            .shadow(radius: 5)\n    }\n}\n"
  },
  {
    "path": "apple/client/Notifications.swift",
    "content": "import OSLog\nimport UserNotifications\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"Notifications\")\n\nfunc displayNotification(\n    _ identifier: NotificationId,\n    _ content: UNMutableNotificationContent\n) {\n    Task {\n        do {\n            let granted = await requestNotificationAuthorization()\n            if !granted {\n                return\n            }\n\n            try await UNUserNotificationCenter.current().add(\n                UNNotificationRequest(\n                    identifier: identifier.rawValue,\n                    content: content,\n                    trigger: nil\n                )\n            )\n        } catch {\n            logger.error(\"Failed to display notification: \\(error, privacy: .public)\")\n        }\n    }\n}\n\nfunc requestNotificationAuthorization() async -> Bool {\n    do {\n        if try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) {\n            logger.info(\"Notifications authorization granted.\")\n            return true\n        } else {\n            logger.warning(\"Notifications blocked.\")\n        }\n    } catch {\n        logger.error(\"Notification authorization request failed: \\(error)\")\n    }\n    return false\n}\n\nfunc notifyConnectError(_ error: Error) {\n    let content = UNMutableNotificationContent()\n    if error.localizedDescription == \"accountExpired\" {\n        content.body = \"Your account has expired.\"\n    } else {\n        content.body = \"An error occurred while connecting to the tunnel.\"\n    }\n    content.title = \"Tunnel failed to connect\"\n    content.interruptionLevel = .active\n    content.sound = UNNotificationSound.defaultCritical\n    displayNotification(.connectFailed, content)\n}\n"
  },
  {
    "path": "apple/client/OSLogEntryEncodable.swift",
    "content": "import OSLog\n\nenum OSLogEntryCodingKeys: String, CodingKey {\n    case activityIdentifier\n    case category\n    case components\n    case eventMessage\n    case eventType\n    case formatString\n    case messageType\n    case parentActivityIdentifier\n    case processID\n    case processImagePath\n    case senderImagePath\n    case signpostID\n    case signpostName\n    case signpostType\n    case subsystem\n    case threadID\n    case timestamp\n}\n\nextension OSLogEntry: Encodable {\n    // Format a log matching `log show --style=ndjson`\n    public func encode(to encoder: Encoder) throws {\n        var container = encoder.container(keyedBy: OSLogEntryCodingKeys.self)\n\n        try container.encode(self.date, forKey: .timestamp)\n        try container.encode(self.composedMessage, forKey: .eventMessage)\n\n        let type = switch self {\n        case is OSLogEntryActivity: \"activityCreateEvent\"\n        case is OSLogEntryBoundary: \"boundary\" // TODO: What is the official name?\n        case is OSLogEntryLog: \"logEvent\"\n        case is OSLogEntrySignpost: \"signpostEvent\"\n        default: \"unknown\"\n        }\n\n        try container.encode(type, forKey: .eventType)\n\n        switch self {\n        case let entry as OSLogEntryActivity:\n            try container.encode(entry.parentActivityIdentifier, forKey: .parentActivityIdentifier)\n        case is OSLogEntryBoundary:\n            break // No extra data.\n        case let entry as OSLogEntryLog:\n            let level = switch entry.level {\n            case .undefined: nil as String?\n            case .debug: \"Debug\"\n            case .info: \"Info\"\n            case .notice: \"Default\"\n            case .error: \"Error\"\n            case .fault: \"Fault\"\n            default: \"unknown\"\n            }\n            try container.encode(level, forKey: .messageType)\n        case let entry as OSLogEntrySignpost:\n            try container.encode(entry.signpostIdentifier, forKey: .signpostID)\n            try container.encode(entry.signpostName, forKey: .signpostName)\n\n            let type = switch entry.signpostType {\n            case .undefined: nil as String?\n            case .intervalBegin: \"begin\"\n            case .intervalEnd: \"end\"\n            case .event: \"event\"\n            default: \"unknown\"\n            }\n            try container.encode(type, forKey: .signpostType)\n        default:\n            break\n        }\n\n        if let entry = self as? OSLogEntryFromProcess {\n            try container.encode(entry.activityIdentifier, forKey: .activityIdentifier)\n            try container.encode(entry.process, forKey: .processImagePath) // We only get the filename, whereas the log command gets the full path.\n            try container.encode(entry.processIdentifier, forKey: .processID)\n            try container.encode(entry.sender, forKey: .senderImagePath) // We only get the filename, whereas the log command gets the full path.\n            try container.encode(entry.threadIdentifier, forKey: .threadID)\n        }\n        if let entry = self as? OSLogEntryWithPayload {\n            try container.encode(entry.category, forKey: .category)\n            try container.encode(entry.formatString, forKey: .formatString)\n            try container.encode(entry.subsystem, forKey: .subsystem)\n\n            // The log command doesn't break these out, but it makes analysis easier.\n            var components = container.nestedUnkeyedContainer(forKey: .components)\n            for component in entry.components {\n                switch component.argument {\n                case .data(let data):\n                    try components.encode(data)\n                case .double(let num):\n                    try components.encode(num)\n                case .signed(let num):\n                    try components.encode(num)\n                case .string(let str):\n                    try components.encode(str)\n                case .undefined:\n                    try components.encode(nil as String?)\n                case .unsigned(let num):\n                    try components.encode(num)\n                @unknown default:\n                    try components.encode(\"unknown-type\")\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "apple/client/OsStatus.swift",
    "content": "import Foundation\n#if os(iOS)\n    import MessageUI\n#endif\nimport Network\nimport NetworkExtension\nimport OSLog\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"OsStatus\")\n\nclass OsStatus: Encodable {\n    var version: UUID = .init()\n    var internetAvailable: Bool = false\n    var osVpnStatus: NEVPNStatus\n    let srcVersion = sourceVersion()\n    var strictLeakPrevention: Bool\n    var updaterStatus = UpdaterStatus()\n    var debugBundleStatus = DebugBundleStatus()\n    #if os(macOS)\n        let canSendMail: Bool = false\n    #else\n        let canSendMail: Bool = MFMailComposeViewController.canSendMail()\n        var storeKit = StoreKitStatus()\n        var offerCodeRedemptionSuccess: Bool = false\n\n        struct StoreKitStatus: Codable {\n            var subscriptionProduct: SubscriptionProductModel?\n            var externalPaymentsAllowed: Bool = false\n        }\n    #endif\n\n    struct LoginItemStatus: Codable {\n        var registered: Bool\n        var error: String?\n    }\n\n    var loginItemStatus: LoginItemStatus?\n\n    init(strictLeakPrevention: Bool, osVpnStatus: NEVPNStatus) {\n        self.osVpnStatus = osVpnStatus\n        self.strictLeakPrevention = strictLeakPrevention\n    }\n\n    static func watchable(manager: NEVPNManager) -> WatchableValue<OsStatus> {\n        var lastIncludeAllNetworks = switch manager.protocolConfiguration {\n        case let .some(proto): proto.includeAllNetworks\n        case nil: false // Report safe default.\n        }\n        let w = WatchableValue(OsStatus(\n            strictLeakPrevention: lastIncludeAllNetworks,\n            osVpnStatus: manager.connection.status\n        ))\n\n        #if os(macOS)\n            let loginItemRegistered = isRegisteredAsLoginItem()\n            w.update { value in\n                value.loginItemStatus = LoginItemStatus(registered: loginItemRegistered, error: nil)\n            }\n        #endif\n        Task {\n            for await path in NWPathMonitor().stream() {\n                logger.info(\"NWPathMonitor event: \\(path.debugDescription, privacy: .public)\")\n                _ = w.update { value in\n                    value.internetAvailable = path.status == .satisfied\n                    value.version = UUID()\n                }\n            }\n        }\n\n        let vpnConfigNotifications = NotificationCenter.default.notifications(named: .NEVPNConfigurationChange, object: manager)\n        Task {\n            for await _ in vpnConfigNotifications {\n                let includeAllNetworks: Bool\n                if let proto = manager.protocolConfiguration {\n                    includeAllNetworks = proto.includeAllNetworks\n                } else {\n                    logger.warning(\"NEVPNManager.protocolConfiguration is nil\")\n                    includeAllNetworks = false // Safe default\n                }\n\n                logger.info(\"NEVPNConfigurationChangeNotification includeAllNetworks \\(includeAllNetworks, privacy: .public)\")\n\n                if includeAllNetworks == lastIncludeAllNetworks {\n                    continue\n                }\n\n                lastIncludeAllNetworks = includeAllNetworks\n                _ = w.update { value in\n                    value.strictLeakPrevention = includeAllNetworks\n                    value.version = UUID()\n                }\n            }\n        }\n\n        let vpnStatusNotifications = NotificationCenter.default.notifications(named: .NEVPNStatusDidChange, object: manager.connection)\n        Task {\n            for await _ in vpnStatusNotifications {\n                let osVpnStatus = manager.connection.status\n                logger.info(\"NEVPNStatus event: \\(osVpnStatus, privacy: .public)\")\n                _ = w.update { value in\n                    value.osVpnStatus = osVpnStatus\n                    value.version = UUID()\n                }\n            }\n        }\n\n        return w\n    }\n\n    func tunnelActivated() -> Bool {\n        switch self.osVpnStatus {\n        case .connected, .connecting, .reasserting:\n            return true\n        case .disconnected, .disconnecting, .invalid:\n            return false\n        @unknown default:\n            return false\n        }\n    }\n}\n\n// Remove this once min OS versions become macOS 14 and iOS 17\nextension NWPathMonitor {\n    func stream() -> AsyncStream<Network.NWPath> {\n        AsyncStream { continuation in\n            pathUpdateHandler = { continuation.yield($0) }\n            start(queue: DispatchQueue(label: \"NWPathMonitor queue\"))\n        }\n    }\n}\n"
  },
  {
    "path": "apple/client/Preview Content/Preview Assets.xcassets/Contents.json",
    "content": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "apple/client/ScriptMessageHandlers.swift",
    "content": "import Foundation\nimport OSLog\nimport WebKit\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"Webview\")\n\nclass CommandHandler: NSObject, WKScriptMessageHandlerWithReply {\n    var appState: AppState\n\n    init(appState: AppState) {\n        self.appState = appState\n    }\n\n    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage, replyHandler: @escaping (Any?, String?) -> Void) {\n        guard let commandJson = message.body as? String else {\n            replyHandler(nil, \"command not a string\")\n            return\n        }\n        let commandJsonBytes: Data! = commandJson.data(using: .utf8)\n        guard let command = try? JSONDecoder().decode(Command.self, from: commandJsonBytes) else {\n            replyHandler(nil, \"decoding command failed\")\n            return\n        }\n        Task {\n            do {\n                let response = try await handleWebViewCommand(command: command)\n                replyHandler(response, nil)\n            } catch let error as String {\n                replyHandler(nil, error)\n            }\n        }\n    }\n}\n\nclass ErrorHandler: NSObject, WKScriptMessageHandler {\n    static var shared = ErrorHandler()\n    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {\n        guard let string = message.body as? String else {\n            logger.error(\"webview error was not a string: \\(debugFormat(message.body), privacy: .public)\")\n            return\n        }\n        logger.info(\"error: \\(string, privacy: .public)\")\n    }\n}\n\nclass LogHandler: NSObject, WKScriptMessageHandler {\n    // handles console.log, console.info, console.error (log will include the level)\n    static var shared = LogHandler()\n    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {\n        guard let string = message.body as? String else {\n            logger.error(\"webview log was not a string: \\(debugFormat(message.body), privacy: .public)\")\n            return\n        }\n        logger.info(\"\\(string, privacy: .public)\")\n    }\n}\n"
  },
  {
    "path": "apple/client/StartupStatus.swift",
    "content": "enum StartupStatus {\n    case initial\n    #if os(macOS)\n        case networkExtensionInit(NetworkExtensionInit, NetworkExtensionInitStatus)\n    #endif\n    case tunnelProviderInit(TunnelProviderInit, TunnelProviderInitStatus)\n    #if os(macOS)\n        case askToRegisterLoginItem(ObservableValue<Bool>)\n    #endif\n    case ready\n}\n"
  },
  {
    "path": "apple/client/StatusItem/AccountStatusItem.swift",
    "content": "import Cocoa\nimport OSLog\nimport SwiftUI\nimport UserNotifications\n\nfunc getExpiredInDaysText(_ days: UInt64) -> String {\n    if days > 1 {\n        return \"in \\(days) days\"\n    }\n    if days == 1 {\n        return \"in \\(days) day\"\n    }\n    return \"in < 1 day\"\n}\n\nstruct StatusItemAccount: View {\n    @Environment(\\.openURL) private var openURL\n    var account: AccountStatus\n\n    var body: some View {\n        VStack {\n            if self.account.expiringSoon() {\n                Label {\n                    HStack {\n                        VStack(alignment: .leading, spacing: 2) {\n                            Text(\"Fund your account...\")\n                                .font(.system(size: 13))\n                            HStack {\n                                if self.account.isActive() {\n                                    Text(\"Account expires soon\")\n                                        .foregroundStyle(.secondary)\n                                } else {\n                                    Text(\"Account is expired\")\n                                        .foregroundStyle(.red)\n                                }\n                                Spacer()\n                                Text(self.account.isActive() ? getExpiredInDaysText(self.account.daysUntilExpiry()!) : \"        \")\n                                    .foregroundStyle(.tertiary)\n                                    .fixedSize()\n                                    .frame(minWidth: 50)\n                            }\n                            .font(.subheadline)\n                        }.fixedSize(horizontal: true, vertical: false)\n                        Spacer()\n                    }\n                } icon: {\n                    Image(systemName: \"exclamationmark.arrow.circlepath\")\n                }\n                // this allows the Spacer to be clickable\n                .contentShape(Rectangle())\n                .padding(EdgeInsets(top: 2, leading: 14, bottom: 2, trailing: 12))\n            }\n        }\n        .onTapGesture {\n            self.openURL(URLs.AppAccountPage)\n        }\n    }\n}\n"
  },
  {
    "path": "apple/client/StatusItem/BandwidthStatus.swift",
    "content": "import Cocoa\nimport SwiftUI\n\nclass BandwidthStatusModel: ObservableObject {\n    @Published var uploadBandwidth = BandwidthFmt.fromTransferRate(bytesPerSecond: 0)\n    @Published var downloadBandwidth = BandwidthFmt.fromTransferRate(bytesPerSecond: 0)\n}\n\nstruct BandwidthStatusItem: View {\n    var isUpload: Bool\n    var bandwidth: BandwidthFmt\n    @Environment(\\.colorScheme) var colorScheme\n\n    var body: some View {\n        HStack {\n            Image(systemName: self.isUpload ? \"arrow.up\" : \"arrow.down\")\n            Text(self.isUpload ? \"Upload\" : \"Download\")\n            Spacer()\n            HStack {\n                Text(\"\\(self.bandwidth.TransferPerSecond) \\(self.bandwidth.MeasurementUnit)\")\n                    .monospaced()\n            }\n        }\n        .padding(.horizontal, 8)\n        .padding(.vertical, 8)\n        .background(self.colorScheme == .dark ? Color.white.opacity(0.1) : Color.black.opacity(0.05))\n        .cornerRadius(5)\n        .shadow(radius: 2)\n    }\n}\n\nstruct BandwidthStatus: View {\n    @ObservedObject var bandwidthStatusModel: BandwidthStatusModel\n    @Environment(\\.colorScheme) var colorScheme\n\n    var body: some View {\n        VStack {\n            BandwidthStatusItem(isUpload: true, bandwidth: self.bandwidthStatusModel.uploadBandwidth)\n            BandwidthStatusItem(isUpload: false, bandwidth: self.bandwidthStatusModel.downloadBandwidth)\n        }\n        .padding(EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12))\n    }\n}\n\nlet BANDWIDTH_MAX_INTENSITY: Int = 4 // levels\n\nstruct BandwidthFmt {\n    let TransferPerSecond: String\n    // TB/s, GB/s, MB/s, KB/s\n    let MeasurementUnit: String\n    let Intensity: Int\n\n    static func fromTransferRate(bytesPerSecond: Double) -> BandwidthFmt {\n        var divisor: Double = 1\n        var unit = \" B/s\"\n        var intensityLvl = 0\n\n        if bytesPerSecond >= 1_000_000_000_000 {\n            divisor = 1_000_000_000_000\n            unit = \"TB/s\"\n            intensityLvl = BANDWIDTH_MAX_INTENSITY\n        } else if bytesPerSecond >= 1_000_000_000 {\n            divisor = 1_000_000_000\n            unit = \"GB/s\"\n            intensityLvl = BANDWIDTH_MAX_INTENSITY\n        } else if bytesPerSecond >= 1_000_000 {\n            divisor = 1_000_000\n            unit = \"MB/s\"\n            if bytesPerSecond >= 200_000_000 {\n                // 200+ MB/s is basically max bars, arbitrary but loosely backed\n                // e.g. Steam tops out near 250 MB/s\n                // https://www.reddit.com/r/Steam/comments/10nhtsr/testing_the_limits_of_what_download_speeds_steam/\n                intensityLvl = BANDWIDTH_MAX_INTENSITY\n            } else if bytesPerSecond > 100_000_000 {\n                intensityLvl = BANDWIDTH_MAX_INTENSITY - 1\n            } else if bytesPerSecond > 20_000_000 {\n                intensityLvl = BANDWIDTH_MAX_INTENSITY - 2\n            } else {\n                intensityLvl = 1\n            }\n        } else if bytesPerSecond >= 100 {\n            divisor = 1000\n            unit = \"KB/s\"\n            intensityLvl = bytesPerSecond >= 10000 ? 1 : 0\n        }\n\n        let transferRate = bytesPerSecond / divisor\n        var transferPerSecond: String\n        if transferRate >= 100 {\n            transferPerSecond = String(Int(transferRate))\n        } else {\n            // round to one decimal place (e.g. 10.1 KB/s, 1.1 KB/s, 0.1 KB/s)\n            transferPerSecond = String((transferRate * 10).rounded() / 10)\n        }\n        return BandwidthFmt(TransferPerSecond: leftPad(transferPerSecond, toLength: 4, withPad: \"\\u{2007}\"), MeasurementUnit: unit, Intensity: intensityLvl)\n    }\n}\n"
  },
  {
    "path": "apple/client/StatusItem/MenuItemView.swift",
    "content": "import Cocoa\nimport SwiftUI\n\n// https://github.com/j-f1/MenuBuilder/blob/ba0202c5ff6d63f0fd7ec6b1da11a769eff15000/Sources/MenuBuilder/MenuItemView.swift#L59 (MIT)\n// https://github.com/attheodo/Pingu/blob/affc3e4ccf88962d4bbb98dbef774c35801102e6/Pingu/Source/Views/HostMenuItemView/HostMenuItemView.swift\n// https://developer.apple.com/documentation/appkit/nsvisualeffectview\n// https://developer.apple.com/documentation/appkit/nsview/1514865-enclosingmenuitem\n\nclass MenuItemView<ContentView: View>: NSView {\n    private let effectView: NSVisualEffectView\n    let contentView: ContentView\n    let hostView: NSHostingView<AnyView>\n\n    init(_ view: ContentView) {\n        self.effectView = NSVisualEffectView()\n\n        self.effectView.state = .active\n        self.effectView.material = .selection\n        self.effectView.isEmphasized = true\n        self.effectView.blendingMode = .behindWindow\n        self.effectView.wantsLayer = true\n        self.effectView.layer?.cornerRadius = 4\n        self.effectView.layer?.cornerCurve = .continuous\n\n        // only enable when highlighted\n        self.effectView.isHidden = true\n\n        self.contentView = view\n        self.hostView = NSHostingView(rootView: AnyView(self.contentView))\n\n        let frame = CGRect(origin: .zero, size: hostView.fittingSize)\n\n        super.init(frame: frame)\n\n        addSubview(self.effectView)\n        addSubview(self.hostView)\n\n        self.setUpConstraints()\n    }\n\n    @available(*, unavailable)\n    required init?(coder decoder: NSCoder) {\n        fatalError(\"init(coder:) has not been implemented\")\n    }\n\n    override func viewDidMoveToWindow() {\n        super.viewDidMoveToWindow()\n        if window != nil {\n            frame = NSRect(\n                origin: frame.origin,\n                size: CGSize(width: enclosingMenuItem!.menu!.size.width, height: frame.height)\n            )\n\n            self.effectView.frame = NSRect(\n                origin: CGPoint(x: frame.origin.x + 5, y: frame.origin.y),\n                size: CGSize(width: enclosingMenuItem!.menu!.size.width - 10, height: frame.height)\n            )\n            self.hostView.frame = frame\n        }\n    }\n\n    // https://stackoverflow.com/q/6054331/7732434\n    override func draw(_ dirtyRect: NSRect) {\n        // Without this, it is possible for a Toggle/NSSwitch inside the status\n        // menu dropdown to appear \"inactive\". That is, without the app tint\n        // and greyed-out, even when the Toggle is in the \"ON\" position.\n        //\n        // This fix was discovered by observing that the only reliable\n        // difference between instances where the Toggle was and wasn't tinted\n        // was whether the `NSStatusBarWindow` (a private API class) had\n        // `isKeyWindow` true or false.\n        //\n        // References for possibly related problems and references:\n        //   - https://developer.apple.com/documentation/swiftui/environmentvalues/controlactivestate\n        //   - https://stackoverflow.com/a/59655207\n        //   - https://medium.com/@acwrightdesign/creating-a-macos-menu-bar-application-using-swiftui-54572a5d5f87\n        if let window = self.window {\n            if window.isVisible {\n                window.becomeKey()\n            }\n        }\n        // NOTE: an action must be defined in the NSMenuItem\n        // Sample usage; let menuItem = NSMenuItem(title: \"\", action: #selector(menuItemAction), keyEquivalent: \"\")\n        let highlighted = enclosingMenuItem?.isHighlighted ?? false\n        self.effectView.isHidden = !highlighted\n        // Note: I removed rehosting the view depending on highlighting\n        // I removed it because it would\n        // // NOTE: I removed it because on the first ever draw of the toggle, the vpn state would be visibly delayed by 0.5s\n        // if we ever want our subview to know if it's highlighted, we can use its own .onHover,\n        //  or for broader highlighting: `@Binding var menuItemIsHighlighted`\n        //  @State var menuItemIsHighlighted = false\n        //  which does require providing this class with the view struct and not an instance\n        super.draw(dirtyRect)\n    }\n\n    private func setUpConstraints() {\n        self.effectView.translatesAutoresizingMaskIntoConstraints = false\n        self.hostView.translatesAutoresizingMaskIntoConstraints = false\n        translatesAutoresizingMaskIntoConstraints = false\n\n        let margin: CGFloat = 5\n        self.effectView.topAnchor.constraint(equalTo: topAnchor).isActive = true\n        self.effectView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: margin).isActive = true\n        self.effectView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true\n        self.effectView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -margin).isActive = true\n\n        self.hostView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true\n        self.hostView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true\n        self.hostView.topAnchor.constraint(equalTo: topAnchor).isActive = true\n        self.hostView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true\n    }\n}\n"
  },
  {
    "path": "apple/client/StatusItem/ObscuraToggle.swift",
    "content": "import Cocoa\nimport OSLog\nimport SwiftUI\nimport UserNotifications\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"ObscuraToggle\")\n\nenum ToggleLabels: String {\n    case connected\n    case connecting\n    case reconnecting\n    case disconnecting\n    case notConnected\n}\n\nstruct ObscuraToggle: View {\n    @Environment(\\.openURL) private var openURL\n    @ObservedObject var startupModel = StartupModel.shared\n    @ObservedObject var osStatusModel: OsStatusModel\n    @State private var toggleLabel = ToggleLabels.notConnected\n    @State private var isToggled = false\n    @State private var allowToggleSync = true\n    @State private var vpnStatusId: UUID = .init()\n    @State private var disconnecting = false\n    @State private var isHovering = false\n    @State private var cityNames: [CityExit: String] = [:]\n\n    let vpnStatusTimer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()\n\n    func getVpnStatus() -> NeStatus? {\n        return self.startupModel.appState?.status\n    }\n\n    func getCityName() -> String? {\n        switch self.getVpnStatus()?.vpnStatus {\n        case .connected(_, let exit, _, _, _):\n            return exit.city_name\n        default:\n            return nil\n        }\n    }\n\n    func getConnectHint() -> String {\n        let exitSelector = self.getVpnStatus()?.lastExit\n\n        switch exitSelector {\n        case .city(let countryCode, let cityCode):\n            let cityExit = CityExit(city_code: cityCode, country_code: countryCode)\n            if let cityName = self.cityNames[cityExit] {\n                return \"Connect to \\(cityName), \\(countryCode.uppercased())\"\n            }\n            return cityCode\n        case .country(let countryCode):\n            return \"Connect to \\(countryCode.uppercased())\"\n        case .exit(let exitId):\n            return exitId\n        default:\n            return \"Connect via Quick Connect\"\n        }\n    }\n\n    func getToggleText() -> String {\n        switch self.toggleLabel {\n        case .connected:\n            let cityName = self.getCityName()\n            if cityName == nil {\n                return \"Connected\"\n            }\n            return \"Connected to \\(cityName!)\"\n        case .connecting:\n            if self.isHovering {\n                return \"Click to Cancel\"\n            }\n            return \"Connecting...\"\n        case .reconnecting:\n            if self.isHovering {\n                return \"Click to Cancel\"\n            }\n            return \"Reconnecting...\"\n        case .disconnecting: return \"Disconnecting...\"\n        case .notConnected:\n            if self.isHovering {\n                return self.getConnectHint()\n            }\n            // adding tabs prevents text overflow on the first status menu connect\n            return \"Not Connected\\t\\t\\t\"\n        }\n    }\n\n    func toggleClick() {\n        self.allowToggleSync = false\n        switch self.getVpnStatus()?.vpnStatus {\n        case .connected, .connecting:\n            self.isToggled = false\n            // this returns faster than the UI could show \"Disconnecting\"\n            self.toggleLabel = ToggleLabels.disconnecting\n            Task {\n                await self.startupModel.appState?.disableTunnel()\n            }\n            // since disconnect is fairly instant, we only need to delay the toggle sync for a bit\n            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {\n                // if for some reason the vpn is connected right after a disconnect,\n                // and we don't disable the override flag, we wil\n                self.allowToggleSync = true\n            }\n        default:\n            Task {\n                self.toggleLabel = ToggleLabels.connecting\n                do {\n                    let exitSelector = self.getVpnStatus()?.lastExit ?? .any\n                    try await self.startupModel.appState?.enableTunnel(\n                        TunnelArgs(exit: exitSelector))\n                } catch {\n                    logger.error(\n                        \"Failed to connect from status menu toggle \\(error, privacy: .public)\")\n                    self.toggleLabel = ToggleLabels.notConnected\n                }\n                self.allowToggleSync = true\n            }\n            self.allowToggleSync = true\n        }\n    }\n\n    var italicizeToggleLabel: Bool {\n        return self.isHovering &&\n            (self.toggleLabel == .notConnected\n                || self.toggleLabel == .reconnecting\n                || self.toggleLabel == .connecting)\n    }\n\n    // we're implicitly creating a (calculated) minimum width here with\n    //   - .fixedSize(...)\n    //   - Spacer(minLength: 54)\n    var body: some View {\n        let toggleDisabled = self.toggleLabel == ToggleLabels.disconnecting\n        // Separate the presentation from the function to avoid\n        // https://stackoverflow.com/a/59398852/7732434\n        let toggleBind = Binding<Bool>(\n            get: { self.isToggled },\n            set: { _ in\n                self.toggleClick()\n            }\n        )\n\n        HStack {\n            VStack(alignment: .leading) {\n                Text(\"Obscura VPN\")\n                    .font(.headline.weight(.regular))\n                    .foregroundStyle(toggleDisabled ? .secondary : .primary)\n                Text(self.getToggleText())\n                    .font(.subheadline)\n                    .foregroundStyle(toggleDisabled ? .tertiary : .secondary)\n                    .italic(self.italicizeToggleLabel)\n            }\n            .lineLimit(1)\n            // so that the text doesn't collapse horizontally and truncate\n            .fixedSize(horizontal: true, vertical: false)\n            Spacer()\n            Toggle(isOn: toggleBind) {}\n                .toggleStyle(.switch)\n                .tint(Color(\"ObscuraOrange\"))\n                .disabled(toggleDisabled)\n        }\n        // this allows the Spacer to be clickable\n        .contentShape(Rectangle())\n        // leading and trailing matches Tailscale's values as observed via Accessibility Inspector\n        .padding(EdgeInsets(top: 5, leading: 14, bottom: 5, trailing: 12))\n        .onTapGesture {\n            if !toggleDisabled {\n                self.toggleClick()\n            }\n        }\n        .onHover { hovering in\n            self.isHovering = hovering\n        }\n        .onReceive(\n            self.vpnStatusTimer,\n            perform: { _ in\n                if self.allowToggleSync {\n                    if self.osStatusModel.osStatus?.osVpnStatus == .disconnecting {\n                        self.isToggled = false\n                        self.toggleLabel = ToggleLabels.disconnecting\n                        return\n                    }\n                    guard let vpnStatus = self.getVpnStatus() else { return }\n                    // Don't update the toggle's state if the state has already been updated for a particular vpnStatus\n                    // This avoids bugs where the toggle is the component driving a vpn status change\n                    // E.g. The vpnStatus reports disconnected and the user starts a connection through the toggle\n                    //  -> Show the connecting state until the new vpnStatus rather than showing a disconnected state\n                    if vpnStatus.version == self.vpnStatusId { return }\n                    self.vpnStatusId = vpnStatus.version\n                    switch vpnStatus.vpnStatus {\n                    case .connected:\n                        self.isToggled = true\n                        self.toggleLabel = ToggleLabels.connected\n                    case .connecting(tunnelArgs: _, connectError: _, let reconnecting):\n                        self.isToggled = false\n                        self.toggleLabel =\n                            reconnecting ? ToggleLabels.reconnecting : ToggleLabels.connecting\n                    default:\n                        self.isToggled = false\n                        self.toggleLabel = ToggleLabels.notConnected\n                    }\n                }\n            }\n        )\n        .task {\n            var exitListKnownVersion: String?\n            while true {\n                var takeBreak = true\n                if let appState = self.startupModel.appState {\n                    do {\n                        let result = try await getCityNames(\n                            appState.manager, knownVersion: exitListKnownVersion\n                        )\n                        exitListKnownVersion = result.version\n                        self.cityNames = result.cityNames\n                        takeBreak = false\n                    } catch {\n                        logger.error(\n                            \"Failed to get exit list in ObscuraToggle: \\(error, privacy: .public)\")\n                    }\n                }\n                if takeBreak {\n                    do {\n                        try await Task.sleep(seconds: 1)\n                    } catch {\n                        logger.error(\n                            \"exitListWatcher Task cancelled in ObscuraToggle \\(error, privacy: .public)\"\n                        )\n                        return\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "apple/client/StatusItem/StatusMenu.swift",
    "content": "import AppKit\nimport Combine\nimport OSLog\nimport SwiftUI\nimport UserNotifications\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"StatusMenu\")\nprivate let creatingDebuggingArchiveStr = \"Creating Debugging Archive (takes a few minutes)\"\nprivate let createDebuggingArchiveStr = \"Create Debugging Archive\"\n\n// https://multi.app/blog/pushing-the-limits-nsstatusitem\nfinal class StatusItemManager: ObservableObject {\n    private var hostingView: NSHostingView<StatusItem>\n    private var statusItem: NSStatusItem\n\n    private var debuggingMenuItem: NSMenuItem\n    private var viewLatestDebugItem: NSMenuItem\n    private var accountMenuItemSeparator: NSMenuItem\n    private var accountMenuItem: NSMenuItem\n    private var quickConnectMenuItem: NSMenuItem\n    private var locationSubmenu: NSMenu\n\n    private var sizePassthrough = PassthroughSubject<CGSize, Never>()\n    private var bandwidthStatusModel = BandwidthStatusModel()\n    private var osStatusModel = OsStatusModel()\n    @Published private var cityNames: [CityExit: String] = [:]\n\n    // ensures sink() closures are retained in memory\n    // cancel() will be called on each item upon deinit\n    private var cancellables = Set<AnyCancellable>()\n    private var accountUpdateTask: Task<Void, Error>?\n\n    // intentionally empty to ensure that the menu item can be highlighted\n    @objc func emptyAction() {}\n\n    init() {\n        Self.exitRefreshSubscriber().store(in: &self.cancellables)\n\n        self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n        self.hostingView = NSHostingView(\n            rootView: StatusItem(\n                sizePassthrough: self.sizePassthrough,\n                bandwidthStatusModel: self.bandwidthStatusModel,\n                osStatusModel: self.osStatusModel\n            ))\n        self.hostingView.frame = NSRect(x: 0, y: 0, width: 100, height: 24)\n        self.statusItem.button?.frame = self.hostingView.frame\n        self.statusItem.button?.addSubview(self.hostingView)\n\n        let menu = NSMenu()\n\n        let toggleMenuItem = NSMenuItem(\n            title: \"Toggle VPN\",\n            action: #selector(self.emptyAction),\n            keyEquivalent: \"\"\n        )\n        let toggleHostingView = MenuItemView(ObscuraToggle(osStatusModel: self.osStatusModel))\n        // https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/MenuList/Articles/ViewsInMenuItems.html\n        toggleMenuItem.view = toggleHostingView\n\n        let locationSubmenuMenuItem = NSMenuItem()\n        locationSubmenuMenuItem.title = \"Connect via...\"\n        locationSubmenuMenuItem.image = NSImage(named: \"custom.globe.badge.gearshape.fill\")\n\n        let showWindowMenuItem = NSMenuItem(\n            title: \"Open Obscura Manager...\",\n            action: #selector(self.showWindow),\n            keyEquivalent: \"o\"\n        )\n        let image = NSImage(named: NSImage.applicationIconName)!\n        image.size = NSSize(width: 16.0, height: 16.0)\n        showWindowMenuItem.image = image\n\n        self.accountMenuItemSeparator = NSMenuItem.separator()\n        self.accountMenuItemSeparator.isHidden = true\n\n        self.accountMenuItem = NSMenuItem(title: \"\", action: #selector(self.emptyAction), keyEquivalent: \"\")\n        self.accountMenuItem.isHidden = true\n\n        let bandwidthStatusItem = NSMenuItem()\n        bandwidthStatusItem.view = MenuItemView(BandwidthStatus(bandwidthStatusModel: self.bandwidthStatusModel))\n\n        self.debuggingMenuItem = NSMenuItem(\n            title: createDebuggingArchiveStr,\n            action: #selector(self.createDebuggingArchiveAction),\n            keyEquivalent: \"\"\n        )\n\n        self.viewLatestDebugItem = NSMenuItem(\n            title: \"View Latest Debug Archive\",\n            action: #selector(self.viewLatestDebugArchive),\n            keyEquivalent: \"\"\n        )\n        self.viewLatestDebugItem.isHidden = true\n\n        self.locationSubmenu = NSMenu()\n        locationSubmenuMenuItem.submenu = self.locationSubmenu\n\n        self.quickConnectMenuItem = NSMenuItem(\n            title: \"Quick Connect\",\n            action: #selector(self.connectAction),\n            keyEquivalent: \"\"\n        )\n        self.quickConnectMenuItem.representedObject = ExitSelector.any\n        self.quickConnectMenuItem.indentationLevel = 1\n        self.locationSubmenu.addItem(self.quickConnectMenuItem)\n\n        let loadingLocationsItem = NSMenuItem(\n            title: \"Loading Locations...\",\n            action: nil,\n            keyEquivalent: \"\"\n        )\n        loadingLocationsItem.indentationLevel = 1\n        self.locationSubmenu.addItem(loadingLocationsItem)\n\n        self.addMoreLocationsItem()\n\n        toggleMenuItem.target = self\n        showWindowMenuItem.target = self\n        self.quickConnectMenuItem.target = self\n        self.accountMenuItem.target = self\n        self.debuggingMenuItem.target = self\n        self.viewLatestDebugItem.target = self\n\n        let disconnectAndQuitItem = NSMenuItem(\n            title: \"Quit and Disconnect\", action: #selector(self.disconnectAndQuit),\n            keyEquivalent: \"q\"\n        )\n        disconnectAndQuitItem.target = self\n\n        menu.items = [\n            toggleMenuItem,\n            locationSubmenuMenuItem,\n            .separator(),\n            showWindowMenuItem,\n            self.accountMenuItemSeparator,\n            self.accountMenuItem,\n            .separator(),\n            Self.createSectionHeaderMenuItem(title: \"Live Usage\"),\n            bandwidthStatusItem,\n            .separator(),\n            self.debuggingMenuItem,\n            self.viewLatestDebugItem,\n            .init(title: sourceVersion(), action: nil, keyEquivalent: \"\"),\n            disconnectAndQuitItem,\n        ]\n\n        self.statusItem.menu = menu\n\n        self.sizePassthrough.sink { [weak self] size in\n            let frame = NSRect(origin: .zero, size: .init(width: size.width, height: 24))\n            self?.hostingView.frame = frame\n            self?.statusItem.button?.frame = frame\n        }.store(in: &self.cancellables)\n\n        Publishers.CombineLatest(self.$cityNames,\n                                 StartupModel.shared.$appState\n                                     .filter { $0 != nil }\n                                     .flatMap { $0!.$status }).sink { [weak self] _, newStatus in\n            self?.triggerSetLocationMenuItems()\n        }.store(in: &self.cancellables)\n\n        StartupModel.shared.$appState\n            .compactMap { $0 }\n            .first()\n            .sink { appState in\n                Task { [weak self] in\n                    var exitListKnownVersion: String?\n                    while true {\n                        guard let self = self else { return }\n                        do {\n                            let result = try await getCityNames(appState.manager, knownVersion: exitListKnownVersion)\n                            exitListKnownVersion = result.version\n                            self.cityNames = result.cityNames\n                        } catch {\n                            logger.error(\"Failed to get exit list: \\(error, privacy: .public)\")\n                            try await Task.sleep(seconds: 1)\n                        }\n                    }\n                }\n\n                appState.$status\n                    .map { $0.account }\n                    .removeDuplicates()\n                    .sink { [weak self] _ in\n                        self?.accountUpdateTask?.cancel()\n\n                        self?.accountUpdateTask = Task { [weak self] in\n                            while true {\n                                self?.updateAccountItem()\n                                if let account = appState.status.account {\n                                    if !account.isActive() {\n                                        try await Task.sleep(for: .seconds(30), tolerance: .seconds(10))\n                                    } else if account.expiringSoon() {\n                                        try await Task.sleep(for: .seconds(60), tolerance: .seconds(30))\n                                    } else {\n                                        // sleep until we expect account item to show up\n                                        let toppedUpExpirationDate = account.accountInfo.topUp?.creditExpiresAt ?? 0\n                                        let stripeEndDate = account.accountInfo.stripeSubscription?.currentPeriodEnd ?? 0\n                                        let appleEndDate = account.accountInfo.appleSubscription?.renewalTime ?? 0\n                                        let end = max(toppedUpExpirationDate, stripeEndDate, appleEndDate, 0)\n\n                                        // 60 seconds after threshold (-10 days) timestamp\n                                        let sleepUntilTime = end - 10 * 24 * 60 * 60 + 60\n                                        let sleepUntilDate = Date(timeIntervalSince1970: TimeInterval(sleepUntilTime))\n\n                                        let sleepInterval = sleepUntilDate.timeIntervalSinceNow\n                                        if sleepInterval > 0 {\n                                            try await Task.sleep(for: .seconds(sleepInterval), tolerance: .seconds(30))\n                                        } else {\n                                            logger.error(\"account is not expiring soon, yet the estimated recheck date is in the past\")\n                                            try await Task.sleep(for: .seconds(60), tolerance: .seconds(30))\n                                        }\n                                    }\n                                } else {\n                                    try await Task.sleep(for: .seconds(30), tolerance: .seconds(30))\n                                }\n                            }\n                        }\n                    }.store(in: &self.cancellables)\n\n                // MainActor since osStatusModel is used by layout engine\n                Task { @MainActor [weak self] in\n                    while true {\n                        guard let self = self else { return }\n                        self.osStatusModel.osStatus = await appState.getOsStatus(knownVersion: self.osStatusModel.osStatus?.version)\n                    }\n                }\n\n                // MainActor since bandwidth status is used by layout engine\n                Task { @MainActor [weak self] in\n                    var trafficStats: TrafficStats?\n                    do {\n                        while true {\n                            try await Task.sleep(seconds: 1)\n                            if case .connected = appState.status.vpnStatus {\n                                do {\n                                    let newTrafficStats = try await appState.getTrafficStats()\n                                    let oldTrafficStats = trafficStats\n                                    trafficStats = newTrafficStats\n                                    if let oldTrafficStats = oldTrafficStats, oldTrafficStats.connId == newTrafficStats.connId {\n                                        let (txBytesDelta, overflowedTx) = newTrafficStats.txBytes.subtractingReportingOverflow(oldTrafficStats.txBytes)\n                                        let (rxBytesDelta, overflowedRx) = newTrafficStats.rxBytes.subtractingReportingOverflow(oldTrafficStats.rxBytes)\n                                        let (msElapsed, overflowedT) = newTrafficStats.connectedMs.subtractingReportingOverflow(oldTrafficStats.connectedMs)\n                                        if overflowedTx || overflowedRx || overflowedT {\n                                            logger.info(\"oldTrafficStats: tx \\(oldTrafficStats.txBytes, privacy: .public), rx \\(oldTrafficStats.rxBytes, privacy: .public), timestamp \\(oldTrafficStats.connectedMs, privacy: .public)\")\n                                            logger.info(\"newTrafficStats: tx \\(newTrafficStats.txBytes, privacy: .public), rx \\(newTrafficStats.rxBytes, privacy: .public), timestamp \\(newTrafficStats.connectedMs, privacy: .public)\")\n                                            #if DEBUG\n                                                fatalError(\"unexpected overflowed in bandwidth subtractions. tx overflowed? \\(overflowedTx), rx overflowed? \\(overflowedRx), timestamp overflowed?  \\(overflowedT)\")\n                                            #else\n                                                logger.error(\"unexpected overflowed in bandwidth subtractions. tx overflowed? \\(overflowedTx, privacy: .public), rx overflowed? \\(overflowedRx, privacy: .public), timestamp overflowed?  \\(overflowedT, privacy: .public)\")\n                                            #endif\n                                        } else {\n                                            let secondsDelta = Double(msElapsed) / 1000\n                                            if secondsDelta > 0 {\n                                                self?.bandwidthStatusModel.uploadBandwidth = BandwidthFmt.fromTransferRate(bytesPerSecond: Double(txBytesDelta) / secondsDelta)\n                                                self?.bandwidthStatusModel.downloadBandwidth = BandwidthFmt.fromTransferRate(bytesPerSecond: Double(rxBytesDelta) / secondsDelta)\n                                                continue\n                                            }\n                                        }\n                                    }\n                                } catch {\n                                    logger.info(\"StatusItemManager getTrafficStats failed while connected \\(error, privacy: .public)\")\n                                    continue\n                                }\n                            }\n                            self?.bandwidthStatusModel.uploadBandwidth = BandwidthFmt.fromTransferRate(\n                                bytesPerSecond: 0)\n                            self?.bandwidthStatusModel.downloadBandwidth = BandwidthFmt.fromTransferRate(\n                                bytesPerSecond: 0)\n                        }\n                    }\n                }\n            }.store(in: &self.cancellables)\n\n        self.osStatusModel.$osStatus.sink { [weak self] _ in\n            self?.updateDebugBundleMenuItem()\n        }.store(in: &self.cancellables)\n    }\n\n    @objc func connectAction(_ sender: NSMenuItem) {\n        // app crashes if this function is async\n        guard let exitSelector = sender.representedObject as? ExitSelector else {\n            logger.error(\"connectAction called with incorrect sender.representedObject\")\n            return\n        }\n        Task {\n            do {\n                guard let appState = StartupModel.shared.appState else { return }\n                try await appState.enableTunnel(TunnelArgs(exit: exitSelector))\n            } catch {\n                logger.error(\"Failed to connect from status location submenu: \\(error, privacy: .public)\")\n            }\n        }\n    }\n\n    @objc func showWindow() {\n        // Opening the app via the URL increases the probability of NSApp.activate()\n        // actually focusing the app.\n        // With `NSApp.activate(ignoringOtherApps: true)` deprecated,\n        // NSApp.activate() does not guarantee focus\n        NSWorkspace.shared.open(URLs.AppOpenURL)\n    }\n\n    @objc func openMoreLocations() {\n        NSWorkspace.shared.open(URLs.AppLocationPage)\n    }\n\n    @objc func disconnectAndQuit() {\n        Task {\n            await StartupModel.shared.appState?.disableTunnel()\n            await NSApp.terminate(nil)\n        }\n    }\n\n    @objc func viewLatestDebugArchive() {\n        if let mostRecentPath = self.osStatusModel.osStatus?.debugBundleStatus.latestPath {\n            NSWorkspace.shared.selectFile(mostRecentPath, inFileViewerRootedAtPath: \"\")\n        }\n    }\n\n    @objc func createDebuggingArchiveAction() {\n        guard let osVpnStatus = self.osStatusModel.osStatus?.osVpnStatus else { return }\n\n        let alert = NSAlert()\n        alert.messageText = \"Disconnect to Create Debugging Archive?\"\n        alert.informativeText = \"For the best diagnostics, we recommend creating a debugging archive while disconnected. How do you want to create the debugging archive?\"\n        alert.alertStyle = .warning\n        alert.addButton(withTitle: \"Disconnect\")\n        alert.addButton(withTitle: \"Stay Connected\")\n        alert.addButton(withTitle: \"Don't Create Debugging Archive\")\n\n        DispatchQueue.main.async {\n            self.debuggingMenuItem.target = nil\n            self.debuggingMenuItem.title = creatingDebuggingArchiveStr\n        }\n        Task {\n            do {\n                if osVpnStatus == .connected {\n                    let response = await alert.runModal()\n                    if response == .alertFirstButtonReturn {\n                        try await self.waitForDisconnect()\n                    }\n                    if response == .alertThirdButtonReturn {\n                        self.updateDebugBundleMenuItem()\n                        return\n                    }\n                }\n\n                let _ = try await createDebuggingArchive(appState: StartupModel.shared.appState, userFeedback: nil)\n            } catch {\n                logger.error(\"Error creating debug bundle: \\(error, privacy: .public)\")\n\n                let content = UNMutableNotificationContent()\n                content.title = \"Error Creating Debug Bundle\"\n                content.body = error.localizedDescription\n                content.interruptionLevel = .active\n                content.sound = UNNotificationSound.default\n                displayNotification(.debuggingBundleFailed, content)\n            }\n        }\n    }\n\n    private func waitForDisconnect(maxSeconds: Double = 30) async throws {\n        await StartupModel.shared.appState?.disableTunnel()\n        try await withTimeout(.seconds(maxSeconds)) {\n            while self.osStatusModel.osStatus?.osVpnStatus == .connected || self.osStatusModel.osStatus?.osVpnStatus == .disconnecting {\n                try await Task.sleep(for: .milliseconds(200))\n            }\n        }\n    }\n\n    private func getCityDisplayName(countryCode: String, cityCode: String) -> String {\n        return self.cityNames[CityExit(city_code: cityCode, country_code: countryCode)] ?? cityCode\n    }\n\n    private func updateDebugBundleMenuItem() {\n        DispatchQueue.main.async {\n            if let debugBundleStatus = self.osStatusModel.osStatus?.debugBundleStatus {\n                if debugBundleStatus.inProgress {\n                    self.debuggingMenuItem.target = nil\n                    self.debuggingMenuItem.title = creatingDebuggingArchiveStr\n                    self.viewLatestDebugItem.isHidden = true\n                } else if self.debuggingMenuItem.target == nil {\n                    self.debuggingMenuItem.target = self\n                    self.debuggingMenuItem.title = createDebuggingArchiveStr\n                    let viewLatestAllowed = debugBundleStatus.latestPath != nil\n                    self.viewLatestDebugItem.isHidden = !viewLatestAllowed\n                }\n            }\n        }\n    }\n\n    private func triggerSetLocationMenuItems() {\n        DispatchQueue.main.async {\n            // Remove all items except the Quick Connect item (which is always first)\n            self.locationSubmenu.items.removeLast(max(self.locationSubmenu.numberOfItems - 1, 0))\n\n            if let appState = StartupModel.shared.appState {\n                let pinnedLocations = appState.status.pinnedLocations\n                let lastExit = appState.status.lastExit\n\n                switch lastExit {\n                case .any:\n                    self.quickConnectMenuItem.state = .on\n                default:\n                    self.quickConnectMenuItem.state = .off\n                }\n\n                var lastExitIsPinned = false\n                let pinnedLocationsSubHeaderItem = Self.createSectionHeaderMenuItem(title: \"Pinned Locations\")\n                pinnedLocationsSubHeaderItem.indentationLevel = 1\n                self.locationSubmenu.addItem(pinnedLocationsSubHeaderItem)\n                if pinnedLocations.isEmpty {\n                    let placeholderItem = NSMenuItem(title: \"\", action: nil, keyEquivalent: \"\")\n                    let italicFont = NSFontManager.shared.convert(NSFont.menuFont(ofSize: 10), toHaveTrait: .italicFontMask)\n                    let attributes: [NSAttributedString.Key: Any] = [.font: italicFont]\n                    placeholderItem.indentationLevel = 1\n                    placeholderItem.attributedTitle = NSAttributedString(string: \"Pinned locations will appear here\", attributes: attributes)\n                    self.locationSubmenu.addItem(placeholderItem)\n                } else {\n                    for pinnedLocation in pinnedLocations {\n                        // Do not show location in status menu if the pinned exit is not found in the fetched cityNames\n                        let cityExit = CityExit(\n                            city_code: pinnedLocation.city_code,\n                            country_code: pinnedLocation.country_code\n                        )\n                        if !self.cityNames.isEmpty && self.cityNames[cityExit] == nil {\n                            continue\n                        }\n\n                        let cityName = self.getCityDisplayName(\n                            countryCode: pinnedLocation.country_code,\n                            cityCode: pinnedLocation.city_code\n                        )\n\n                        let menuItem = NSMenuItem(\n                            title: \"\\(cityName), \\(pinnedLocation.country_code.uppercased())\",\n                            action: #selector(self.connectAction),\n                            keyEquivalent: \"\"\n                        )\n                        menuItem.target = self\n                        menuItem.representedObject = ExitSelector.city(\n                            country_code: pinnedLocation.country_code,\n                            city_code: pinnedLocation.city_code\n                        )\n\n                        // Check if this pinned location matches the last chosen exit\n                        switch lastExit {\n                        case .city(let country_code, let city_code):\n                            if country_code == pinnedLocation.country_code && city_code == pinnedLocation.city_code {\n                                menuItem.state = .on\n                                lastExitIsPinned = true\n                            }\n                        default:\n                            break\n                        }\n\n                        menuItem.indentationLevel = 1\n                        self.locationSubmenu.addItem(menuItem)\n                    }\n                }\n\n                // If the last chosen exit is a city that's not in the pinned locations, add a header and menu item\n                if case .city(let country_code, let city_code) = lastExit, !lastExitIsPinned {\n                    let nonPinnedLocationHeaderItem = Self.createSectionHeaderMenuItem(title: \"Current Selection\")\n                    nonPinnedLocationHeaderItem.indentationLevel = 1\n                    self.locationSubmenu.addItem(nonPinnedLocationHeaderItem)\n\n                    let cityName = self.getCityDisplayName(\n                        countryCode: country_code,\n                        cityCode: city_code\n                    )\n\n                    let nonPinnedMenuItem = NSMenuItem(\n                        title: \"\\(cityName), \\(country_code.uppercased())\",\n                        action: #selector(self.connectAction),\n                        keyEquivalent: \"\"\n                    )\n                    nonPinnedMenuItem.target = self\n                    nonPinnedMenuItem.representedObject = ExitSelector.city(\n                        country_code: country_code,\n                        city_code: city_code\n                    )\n                    nonPinnedMenuItem.state = .on\n                    nonPinnedMenuItem.indentationLevel = 1\n\n                    self.locationSubmenu.addItem(nonPinnedMenuItem)\n                }\n            }\n            self.addMoreLocationsItem()\n        }\n    }\n\n    private static func createSectionHeaderMenuItem(title: String) -> NSMenuItem {\n        if #available(macOS 14.0, *) {\n            return NSMenuItem.sectionHeader(title: title)\n        } else {\n            return NSMenuItem(title: title, action: nil, keyEquivalent: \"\")\n        }\n    }\n\n    private func addMoreLocationsItem() {\n        self.locationSubmenu.addItem(NSMenuItem.separator())\n        let moreLocationsMenuItem = NSMenuItem(\n            title: \"More Locations…\",\n            action: #selector(self.openMoreLocations),\n            keyEquivalent: \"\"\n        )\n        moreLocationsMenuItem.target = self\n        let image = NSImage(named: NSImage.applicationIconName)!\n        image.size = NSSize(width: 16.0, height: 16.0)\n        moreLocationsMenuItem.image = image\n        self.locationSubmenu.addItem(moreLocationsMenuItem)\n    }\n\n    private static func refreshExitListIfNeeded() {\n        Task {\n            if let appState = StartupModel.shared.appState {\n                do {\n                    _ = try await refreshExitList(appState.manager, freshness: 3600)\n                } catch {\n                    logger.error(\n                        \"Failed to refresh exit list in status menu: \\(error, privacy: .public)\")\n                }\n            }\n        }\n    }\n\n    private func updateAccountItem() {\n        guard let appState = StartupModel.shared.appState else { return }\n        if let account = appState.status.account {\n            let secondsStamp = UInt64(Date().timeIntervalSince1970)\n            var pollAccount = false\n            if (account.accountInfo.periodEndDate == nil || account.accountInfo.periodEndDate! < Date())\n                && secondsStamp - account.lastUpdatedSec > 60 * 5\n            {\n                pollAccount = true\n            } else if account.expiringSoon()\n                && secondsStamp - account.lastUpdatedSec > 60 * 60 * 12\n            {\n                pollAccount = true\n            }\n\n            if pollAccount {\n                Task {\n                    // updateAccountItem task will restart upon appState.status.account change\n                    try? await appState.getAccountInfo()\n                }\n                return\n            }\n\n            DispatchQueue.main.async {\n                let accountHostingView = MenuItemView(StatusItemAccount(account: account))\n                self.accountMenuItem.view = accountHostingView\n                self.accountMenuItem.isHidden = !account.expiringSoon() || appState.status.inNewAccountFlow\n                self.accountMenuItemSeparator.isHidden = self.accountMenuItem.isHidden\n            }\n        } else {\n            DispatchQueue.main.async {\n                self.accountMenuItem.isHidden = true\n                self.accountMenuItemSeparator.isHidden = true\n            }\n        }\n    }\n\n    private static func exitRefreshSubscriber() -> AnyCancellable {\n        self.refreshExitListIfNeeded()\n        return Timer.publish(every: 3660, tolerance: 60, on: .current, in: .common)\n            .autoconnect()\n            .sink { _ in\n                Self.refreshExitListIfNeeded()\n            }\n    }\n}\n\nprivate struct SizePreferenceKey: PreferenceKey {\n    static var defaultValue: CGSize = .zero\n    static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() }\n}\n\nstruct StatusItem: View {\n    var sizePassthrough: PassthroughSubject<CGSize, Never>\n    @State private var osStatus: OsStatus?\n    @ObservedObject var startupModel = StartupModel.shared\n    @ObservedObject var bandwidthStatusModel: BandwidthStatusModel\n    @ObservedObject var osStatusModel: OsStatusModel\n\n    let connectingImageNames = [\"MenuBarConnecting-1\", \"MenuBarConnecting-2\", \"MenuBarConnecting-3\"]\n    @State private var menuBarImage = \"MenuBarDisconnected\"\n    @State private var statusIconIdx = 0\n    let statusIconTimer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()\n\n    func getVpnStatus() -> NeVpnStatus? {\n        return self.startupModel.appState?.status.vpnStatus\n    }\n\n    @ViewBuilder\n    var mainContent: some View {\n        HStack(spacing: 10) {\n            HStack(spacing: 3) {\n                ZStack {\n                    Image(self.menuBarImage)\n                        .renderingMode(.template)\n                        .onReceive(self.statusIconTimer, perform: { _ in\n                            if self.osStatusModel.osStatus?.osVpnStatus == .disconnecting {\n                                self.menuBarImage = self.connectingImageNames[self.statusIconIdx]\n                                // add a full count before using modulo to avoid negative indices\n                                self.statusIconIdx = (self.statusIconIdx + self.connectingImageNames.count - 1)\n                                    % self.connectingImageNames.count\n                                return\n                            }\n                            switch self.getVpnStatus() {\n                            case .connecting:\n                                self.menuBarImage = self.connectingImageNames[self.statusIconIdx]\n                                self.statusIconIdx = (self.statusIconIdx + 1) % self.connectingImageNames.count\n                            case .connected:\n                                self.menuBarImage = \"MenuBarConnected\"\n                                if self.bandwidthStatusModel.uploadBandwidth.Intensity > 0 {\n                                    self.menuBarImage += \"Up\"\n                                }\n                                if self.bandwidthStatusModel.downloadBandwidth.Intensity > 0 {\n                                    self.menuBarImage += \"Down\"\n                                }\n                                self.statusIconIdx = self.connectingImageNames.count - 1\n                            case .disconnected, nil:\n                                self.menuBarImage = \"MenuBarDisconnected\"\n                                self.statusIconIdx = 0\n                            }\n                        })\n                    if self.menuBarImage.starts(with: \"MenuBarConnected\") {\n                        Rectangle()\n                            .frame(width: 4, height: 4)\n                            .position(x: 20.5, y: 17)\n                            .foregroundStyle(Color(red: 84 / 255, green: 214 / 255, blue: 97 / 255))\n                    }\n                }\n            }\n        }\n        .padding(4)\n        .padding(.bottom, 2)\n        .fixedSize()\n    }\n\n    var body: some View {\n        self.mainContent\n            .overlay(\n                GeometryReader { geometryProxy in\n                    Color.clear\n                        .preference(key: SizePreferenceKey.self, value: geometryProxy.size)\n                }\n            )\n            .onPreferenceChange(\n                SizePreferenceKey.self,\n                perform: { size in\n                    self.sizePassthrough.send(size)\n                }\n            )\n    }\n}\n\nclass OsStatusModel: ObservableObject {\n    @Published var osStatus: OsStatus? = nil\n}\n"
  },
  {
    "path": "apple/client/Store/Obscura VPN Local.storekit",
    "content": "{\n  \"appPolicies\" : {\n    \"eula\" : \"\",\n    \"policies\" : [\n      {\n        \"locale\" : \"en_US\",\n        \"policyText\" : \"\",\n        \"policyURL\" : \"\"\n      }\n    ]\n  },\n  \"identifier\" : \"77437FF6\",\n  \"nonRenewingSubscriptions\" : [\n\n  ],\n  \"products\" : [\n\n  ],\n  \"settings\" : {\n    \"_compatibilityTimeRate\" : {\n      \"3\" : 6\n    },\n    \"_failTransactionsEnabled\" : false,\n    \"_locale\" : \"en_US\",\n    \"_storefront\" : \"USA\",\n    \"_storeKitErrors\" : [\n      {\n        \"current\" : {\n          \"index\" : 2,\n          \"type\" : \"generic\"\n        },\n        \"enabled\" : false,\n        \"name\" : \"Load Products\"\n      },\n      {\n        \"current\" : {\n          \"index\" : 1,\n          \"type\" : \"purchase\"\n        },\n        \"enabled\" : false,\n        \"name\" : \"Purchase\"\n      },\n      {\n        \"current\" : {\n          \"index\" : 0,\n          \"type\" : \"verification\"\n        },\n        \"enabled\" : false,\n        \"name\" : \"Verification\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : false,\n        \"name\" : \"App Store Sync\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : false,\n        \"name\" : \"Subscription Status\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : false,\n        \"name\" : \"App Transaction\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : false,\n        \"name\" : \"Manage Subscriptions Sheet\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : false,\n        \"name\" : \"Refund Request Sheet\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : false,\n        \"name\" : \"Offer Code Redeem Sheet\"\n      }\n    ],\n    \"_timeRate\" : 15\n  },\n  \"subscriptionGroups\" : [\n    {\n      \"id\" : \"21708276\",\n      \"localizations\" : [\n\n      ],\n      \"name\" : \"Obscura VPN\",\n      \"subscriptions\" : [\n        {\n          \"adHocOffers\" : [\n\n          ],\n          \"codeOffers\" : [\n            {\n              \"eligibility\" : [\n                \"existing\",\n                \"expired\",\n                \"new\"\n              ],\n              \"internalID\" : \"2D97084D\",\n              \"isStackable\" : false,\n              \"paymentMode\" : \"free\",\n              \"referenceName\" : \"One Month\",\n              \"subscriptionPeriod\" : \"P1M\"\n            }\n          ],\n          \"displayPrice\" : \"6.0\",\n          \"familyShareable\" : false,\n          \"groupNumber\" : 1,\n          \"internalID\" : \"6747273493\",\n          \"introductoryOffer\" : null,\n          \"localizations\" : [\n            {\n              \"description\" : \"Access to Obscura VPN on up to 3 devices.\",\n              \"displayName\" : \"Obscura VPN - Monthly\",\n              \"locale\" : \"en_US\"\n            }\n          ],\n          \"productID\" : \"subscriptions.monthly\",\n          \"recurringSubscriptionPeriod\" : \"P1M\",\n          \"referenceName\" : \"Obscura VPN - Monthly\",\n          \"subscriptionGroupID\" : \"21708276\",\n          \"type\" : \"RecurringSubscription\",\n          \"winbackOffers\" : [\n\n          ]\n        }\n      ]\n    }\n  ],\n  \"version\" : {\n    \"major\" : 4,\n    \"minor\" : 0\n  }\n}\n"
  },
  {
    "path": "apple/client/Store/Obscura VPN.storekit",
    "content": "{\n  \"appPolicies\" : {\n    \"eula\" : \"\",\n    \"policies\" : [\n      {\n        \"locale\" : \"en_US\",\n        \"policyText\" : \"\",\n        \"policyURL\" : \"\"\n      }\n    ]\n  },\n  \"identifier\" : \"77437FF6\",\n  \"nonRenewingSubscriptions\" : [\n\n  ],\n  \"products\" : [\n\n  ],\n  \"settings\" : {\n    \"_applicationInternalID\" : \"6746820048\",\n    \"_developerTeamID\" : \"5G943LR562\",\n    \"_failTransactionsEnabled\" : true,\n    \"_lastSynchronizedDate\" : 774578988.61326504,\n    \"_locale\" : \"en_US\",\n    \"_storefront\" : \"USA\",\n    \"_storeKitErrors\" : [\n      {\n        \"current\" : null,\n        \"enabled\" : true,\n        \"name\" : \"Load Products\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : true,\n        \"name\" : \"Purchase\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : true,\n        \"name\" : \"Verification\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : false,\n        \"name\" : \"App Store Sync\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : false,\n        \"name\" : \"Subscription Status\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : false,\n        \"name\" : \"App Transaction\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : false,\n        \"name\" : \"Manage Subscriptions Sheet\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : false,\n        \"name\" : \"Refund Request Sheet\"\n      },\n      {\n        \"current\" : null,\n        \"enabled\" : false,\n        \"name\" : \"Offer Code Redeem Sheet\"\n      }\n    ]\n  },\n  \"subscriptionGroups\" : [\n    {\n      \"id\" : \"21708276\",\n      \"localizations\" : [\n\n      ],\n      \"name\" : \"Obscura VPN\",\n      \"subscriptions\" : [\n        {\n          \"adHocOffers\" : [\n\n          ],\n          \"codeOffers\" : [\n\n          ],\n          \"displayPrice\" : \"6.0\",\n          \"familyShareable\" : false,\n          \"groupNumber\" : 1,\n          \"internalID\" : \"6747273493\",\n          \"introductoryOffer\" : null,\n          \"localizations\" : [\n            {\n              \"description\" : \"Access to Obscura VPN on up to 3 devices.\",\n              \"displayName\" : \"Obscura VPN - Monthly\",\n              \"locale\" : \"en_US\"\n            }\n          ],\n          \"productID\" : \"subscriptions.monthly\",\n          \"recurringSubscriptionPeriod\" : \"P1M\",\n          \"referenceName\" : \"Obscura VPN - Monthly\",\n          \"subscriptionGroupID\" : \"21708276\",\n          \"type\" : \"RecurringSubscription\",\n          \"winbackOffers\" : [\n\n          ]\n        }\n      ]\n    }\n  ],\n  \"version\" : {\n    \"major\" : 4,\n    \"minor\" : 0\n  }\n}\n"
  },
  {
    "path": "apple/client/Store/Product+Convenience.swift",
    "content": "import StoreKit\n\nextension Product {\n    func subscriptionPeriodFormatted() -> String? {\n        guard let subscription else { return nil }\n        return subscription.subscriptionPeriod.formatted(self.subscriptionPeriodFormatStyle)\n    }\n}\n"
  },
  {
    "path": "apple/client/Store/StoreKitListener.swift",
    "content": "import os\nimport StoreKit\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"StoreKitListener\")\n\n/// Apple wants us to start listening \"as soon as your app launches\":\n/// https://developer.apple.com/documentation/storekit/transaction/updates\nclass StoreKitListener {\n    private let purchaseIntentsListener: Task<Void, Error>\n    private let transactionUpdatesListener: Task<Void, Error>\n    private let storefrontUpdatesListener: Task<Void, Error>\n\n    init(appState: AppState) {\n        self.purchaseIntentsListener = Task.detached {\n            for await purchaseIntent in PurchaseIntent.intents {\n                do {\n                    _ = try await appState.purchase(product: purchaseIntent.product)\n                } catch {\n                    logger.error(\"failed to honor purchase intent: \\(error, privacy: .public)\")\n                }\n            }\n        }\n        self.transactionUpdatesListener = Task.detached {\n            // `updates` is for transactions that happen outside the app or on\n            // other devices, and also receives queued unfinished transactions\n            // once at launch.\n            for await result in Transaction.updates {\n                if case .verified(let transaction) = result {\n                    // We don't really have a concept of \"undelivered\"\n                    // transactions, so if any transactions are somehow left\n                    // unfinished we should just mark them as finished.\n                    await transaction.finish()\n                }\n                await appState.storeKitModel.updatePurchases()\n            }\n        }\n        self.storefrontUpdatesListener = Task.detached {\n            // \"The storefront value can change at any time.\"\n            // https://developer.apple.com/documentation/storekit/storefront/updates\n            for await storefront in Storefront.updates {\n                await appState.storeKitModel.updateStorefront(storefront)\n            }\n        }\n    }\n\n    deinit {\n        self.purchaseIntentsListener.cancel()\n        self.transactionUpdatesListener.cancel()\n        self.storefrontUpdatesListener.cancel()\n    }\n}\n"
  },
  {
    "path": "apple/client/Store/StoreKitModel.swift",
    "content": "import os\nimport StoreKit\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"StoreKitModel\")\n\n@MainActor class StoreKitModel: ObservableObject {\n    @Published private var products: [Product] = []\n    @Published private var purchasedProducts: [Product] = []\n    @Published var renewalPrice: String? = nil\n\n    private let subscriptionProductId = \"subscriptions.monthly\"\n    var subscriptionProduct: Product? {\n        return self.products.first { $0.id == self.subscriptionProductId }\n    }\n\n    var subscribed: Bool {\n        return self.purchasedProducts.contains(where: { $0.id == self.subscriptionProductId })\n    }\n\n    @Published private var storefront: Storefront?\n    var externalPaymentsAllowed: Bool {\n        // External payments are currently only straightforward in the US.\n        return self.storefront?.countryCode == \"USA\"\n    }\n\n    nonisolated init() {\n        Task { @MainActor in\n            await self.updateStorefront(await Storefront.current)\n        }\n    }\n\n    func updateStorefront(_ storefront: Storefront?) async {\n        self.storefront = storefront\n        do {\n            self.products = try await Product.products(for: [self.subscriptionProductId])\n        } catch {\n            logger.error(\"failed to load products: \\(error, privacy: .public)\")\n        }\n        await self.updatePurchases()\n    }\n\n    func updatePurchases() async {\n        self.purchasedProducts.removeAll()\n        self.renewalPrice = nil\n        // For auto-renewable subscriptions, `currentEntitlements` only contains\n        // the latest non-expired transaction.\n        for await result in Transaction.currentEntitlements {\n            if case .verified(let transaction) = result {\n                if let product = products.first(where: { $0.id == transaction.productID }) {\n                    self.purchasedProducts.append(product)\n                    if product.id == self.subscriptionProductId,\n                       let subscription = product.subscription\n                    {\n                        do {\n                            for status in try await subscription.status {\n                                if case .verified(let renewalInfo) = status.renewalInfo {\n                                    if let renewalPrice = renewalInfo.renewalPrice,\n                                       let renewalCurrency = renewalInfo.currency\n                                    {\n                                        self.renewalPrice = renewalPrice.formatted(.currency(code: renewalCurrency.identifier))\n                                    }\n                                }\n                            }\n                        } catch {\n                            logger.error(\"Failed to fetch subscription renewal info: \\(error, privacy: .public)\")\n                        }\n                        break\n                    }\n                }\n            }\n        }\n    }\n\n    func restorePurchases() async throws(String) {\n        do {\n            try await AppStore.sync()\n            await self.updatePurchases()\n        } catch {\n            logger.error(\"failed to restore purchases: \\(error, privacy: .public)\")\n            throw \"failed to restore purchases: \\(error)\"\n        }\n    }\n\n    // This is here just so we can keep `products` completely private.\n    func collectDebugData() async throws -> [Any] {\n        var debugData: [Any] = []\n        for product in self.products {\n            var subscriptionStatus: [[String: String]] = []\n            if let subscription = product.subscription {\n                for status in try await subscription.status {\n                    subscriptionStatus.append([\"state\": status.state.localizedDescription])\n                }\n            }\n            try debugData.append([\n                \"product\": JSONSerialization.jsonObject(with: product.jsonRepresentation),\n                \"subscriptionStatus\": subscriptionStatus,\n            ])\n        }\n        return debugData\n    }\n\n    func toSubscriptionModel() -> SubscriptionProductModel? {\n        if let subscriptionProduct = self.subscriptionProduct {\n            return SubscriptionProductModel(\n                displayName: subscriptionProduct.displayName,\n                description: subscriptionProduct.description,\n                displayPrice: subscriptionProduct.displayPrice,\n                renewalPrice: self.renewalPrice,\n                subscriptionPeriodFormatted: subscriptionProduct.subscriptionPeriodFormatted()\n            )\n        }\n        return nil\n    }\n}\n\n// static representation of useful information derived from a StoreKit Product\nclass SubscriptionProductModel: Codable {\n    var displayName: String\n    var description: String\n    var displayPrice: String\n    var renewalPrice: String?\n    var subscriptionPeriodFormatted: String?\n    init(displayName: String, description: String, displayPrice: String, renewalPrice: String?, subscriptionPeriodFormatted: String? = nil) {\n        self.displayName = displayName\n        self.description = description\n        self.displayPrice = displayPrice\n        self.renewalPrice = renewalPrice\n        self.subscriptionPeriodFormatted = subscriptionPeriodFormatted\n    }\n}\n"
  },
  {
    "path": "apple/client/Style/Appearance.swift",
    "content": "import SwiftUI\n\nenum AppAppearance: String, Codable {\n    case dark\n    case light\n    case auto\n\n    var colorScheme: ColorScheme? {\n        switch self {\n        case .dark:\n            return .dark\n        case .light:\n            return .light\n        case .auto:\n            return nil\n        }\n    }\n}\n"
  },
  {
    "path": "apple/client/Style/ConditionallyDisabled.swift",
    "content": "import SwiftUI\n\nstruct ConditionallyDisabledModifier: ViewModifier {\n    let isDisabled: Bool\n    let explanation: String\n    @State private var showAlert = false\n\n    func body(content: Content) -> some View {\n        content\n            .disabled(self.isDisabled)\n            .opacity(self.isDisabled ? 0.5 : 1.0)\n            .onTapGesture {\n                if self.isDisabled {\n                    self.showAlert = true\n                }\n            }\n            .alert(\"Not Available\", isPresented: self.$showAlert) {\n                Button(\"OK\", role: .cancel) {}\n            } message: {\n                Text(self.explanation)\n            }\n    }\n}\n\nextension View {\n    func conditionallyDisabled(\n        when isDisabled: Bool,\n        explanation: String\n    ) -> some View {\n        self.modifier(ConditionallyDisabledModifier(\n            isDisabled: isDisabled,\n            explanation: explanation\n        ))\n    }\n}\n"
  },
  {
    "path": "apple/client/Style/HyperlinkButtonStyle.swift",
    "content": "import SwiftUI\n\nstruct HyperlinkButtonStyle: ButtonStyle {\n    @Environment(\\.isEnabled) private var isEnabled\n\n    func makeBody(configuration: Configuration) -> some View {\n        configuration.label\n            .foregroundColor(self.isEnabled ? .blue : .blue.opacity(0.5))\n    }\n}\n"
  },
  {
    "path": "apple/client/Style/NoFadeButtonStyle.swift",
    "content": "import SwiftUI\n\nstruct NoFadeButtonStyle: ButtonStyle {\n    var backgroundColor: Color = .init(\"ObscuraOrange\")\n    @Environment(\\.isEnabled) private var isEnabled: Bool\n\n    func makeBody(configuration: Configuration) -> some View {\n        configuration.label\n            .padding()\n            .background(self.isEnabled ? self.backgroundColor : Color.gray)\n            .foregroundColor(.white)\n            .clipShape(RoundedRectangle(cornerRadius: 8))\n            .scaleEffect(configuration.isPressed ? 0.97 : 1)\n            .animation(.snappy(duration: 0.2), value: configuration.isPressed)\n    }\n}\n"
  },
  {
    "path": "apple/client/TunnelProvider.swift",
    "content": "import Foundation\nimport Network\nimport NetworkExtension\nimport OSLog\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"PacketTunnelProvider\")\n\nenum TunnelProviderInitStatus {\n    case checking\n    case blockingBeforePermissionPopup\n    case waitingForUserPermissionApproval\n    case waitingForUserStopOtherTunnelApproval(manager: NETunnelProviderManager)\n    case configuring\n    case testingCommunication\n    case permissionDenied\n    case unexpectedError\n}\n\nenum TunnelProviderInitEvent {\n    case status(TunnelProviderInitStatus)\n    case done(NETunnelProviderManager, NeStatus)\n}\n\nclass TunnelProviderInit {\n    var continuation: AsyncStream<TunnelProviderInitEvent>.Continuation?\n\n    func start() -> AsyncStream<TunnelProviderInitEvent> {\n        return AsyncStream<TunnelProviderInitEvent> { continuation in\n            self.continuation = continuation\n            self.update(.checking)\n            Task {\n                guard let managers = await Self.loadManagers() else {\n                    self.update(.unexpectedError)\n                    return\n                }\n                if managers.count > 1 {\n                    for manager in managers[1...] {\n                        do {\n                            logger.log(\"Removing extra tunnel provider: \\(manager.localizedDescription ?? \"nil\", privacy: .public)\")\n                            try await manager.removeFromPreferences()\n                        } catch {\n                            logger.error(\"error removing extra tunnel provider: \\(error)\")\n                            self.update(.unexpectedError)\n                            return\n                        }\n                    }\n                }\n                if managers.isEmpty {\n                    // There are no managers, we will get a permission prompt when we add one. Wait for a call to `continueAfterPermissionPriming()`, so we can prepare the user for the popup.\n                    self.update(.blockingBeforePermissionPopup)\n                } else {\n                    // There already is a manager we can use, no permission promp will be shown, continue automatically.\n                    self.continueAfterPermissionPriming()\n                }\n            }\n        }\n    }\n\n    func continueAfterPermissionPriming() {\n        Task {\n            guard let managers = await Self.loadManagers() else {\n                self.update(.unexpectedError)\n                return\n            }\n\n            var askedForUserApproval = false\n            if managers.isEmpty {\n                self.update(.waitingForUserPermissionApproval)\n                askedForUserApproval = true\n            } else {\n                self.update(.configuring)\n            }\n\n            let manager = switch managers.first {\n            case .some(let manager): manager\n            case .none: NETunnelProviderManager()\n            }\n\n            manager.onDemandRules = [NEOnDemandRuleConnect()]\n\n            let proto = NETunnelProviderProtocol()\n            proto.providerBundleIdentifier = networkExtensionBundleID()\n            proto.serverAddress = \"obscura.net\"\n            proto.includeAllNetworks = manager.protocolConfiguration?.includeAllNetworks ?? false\n            manager.protocolConfiguration = proto\n\n            do {\n                if askedForUserApproval {\n                    manager.isEnabled = true\n                }\n                try await manager.saveToPreferences()\n            } catch {\n                logger.error(\"error saving tunnel provider to preferences early: \\(error)\")\n                if (error as NSError).domain == NEVPNErrorDomain {\n                    switch NEVPNError.Code(rawValue: (error as NSError).code) {\n                    case .configurationReadWriteFailed:\n                        self.update(.permissionDenied)\n                    default:\n                        self.update(.unexpectedError)\n                    }\n                }\n                return\n            }\n\n            if manager.isEnabled {\n                self.continueAfterStopOtherTunnelPriming(manager)\n            } else {\n                logger.info(\"tunnel provider is not enabled, asking for permission to enable (which kills other tunnels)\")\n                self.update(.waitingForUserStopOtherTunnelApproval(manager: manager))\n            }\n        }\n    }\n\n    func continueAfterStopOtherTunnelPriming(_ manager: NETunnelProviderManager) {\n        Task {\n            do {\n                if !manager.isEnabled {\n                    logger.info(\"enabling tunnel provider\")\n                    manager.isEnabled = true\n                    try await manager.saveToPreferences()\n                }\n            } catch {\n                logger.error(\"error saving tunnel provider to preferences after late enablement: \\(error)\")\n                self.update(.unexpectedError)\n                return\n            }\n\n            do {\n                try await manager.loadFromPreferences()\n            } catch {\n                logger.error(\"error loading tunnel provider from preferences: \\(error)\")\n                self.update(.unexpectedError)\n                return\n            }\n\n            self.update(.testingCommunication)\n\n            var pingFailures = 0\n            while true {\n                do {\n                    let status = try await getNeStatus(\n                        manager,\n                        knownVersion: nil,\n                        attemptTimeout: .seconds(10),\n                        maxAttempts: 3\n                    )\n                    self.done(manager, status)\n                    return\n                } catch {\n                    logger.error(\"Ping error: \\(error, privacy: .public)\")\n                }\n\n                pingFailures += 1\n                if pingFailures > 2 {\n                    logger.error(\"Failed to reach tunnel provider.\")\n                    self.update(.unexpectedError)\n                    return\n                }\n\n                do {\n                    logger.log(\"Forcing network extension init\")\n                    try manager.connection.startVPNTunnel(options: [\"dontStartTunnel\": NSString(string: \"\")])\n                } catch {\n                    logger.error(\"Forced network extension init failed: \\(error)\")\n                }\n            }\n        }\n    }\n\n    private func update(_ status: TunnelProviderInitStatus) {\n        logger.log(\"TunnelProviderInit status: \\(debugFormat(status), privacy: .public)\")\n        if let cont = self.continuation {\n            cont.yield(.status(status))\n        }\n    }\n\n    private func done(_ manager: NETunnelProviderManager, _ status: NeStatus) {\n        if let cont = self.continuation {\n            cont.yield(.done(manager, status))\n            cont.finish()\n        }\n    }\n\n    private static func loadManagers() async -> [NETunnelProviderManager]? {\n        do {\n            let managers: [NETunnelProviderManager] = try await NETunnelProviderManager.loadAllFromPreferences()\n            return managers\n        } catch {\n            logger.error(\"loading all tunnel providers from preferences failed with error: \\(error)\")\n            return .none\n        }\n    }\n}\n\nfunc neLogin(_ manager: NETunnelProviderManager,\n             accountId: String,\n             attemptTimeout: Duration? = nil,\n             maxAttempts: UInt = 10) async throws\n{\n    _ = try await runNeJsonCommand(manager, NeManagerCmd.login(accountId: accountId, validate: false).json(), name: \"login\", attemptTimeout: attemptTimeout, maxAttempts: maxAttempts)\n}\n\nfunc getNeStatus(\n    _ manager: NETunnelProviderManager,\n    knownVersion: UUID?,\n    attemptTimeout: Duration? = nil,\n    maxAttempts: UInt = 10\n) async throws -> NeStatus {\n    try await runNeCommand(manager, NeManagerCmd.getStatus(knownVersion: knownVersion), attemptTimeout: attemptTimeout, maxAttempts: maxAttempts)\n}\n\nfunc getAccountInfo(\n    _ manager: NETunnelProviderManager,\n    attemptTimeout: Duration? = nil,\n    maxAttempts: UInt = 10\n) async throws -> AccountInfo {\n    return try await runNeCommand(manager, NeManagerCmd.apiGetAccountInfo, attemptTimeout: attemptTimeout, maxAttempts: maxAttempts)\n}\n\nfunc getExitList(_ manager: NETunnelProviderManager,\n                 knownVersion: String?,\n                 attemptTimeout: Duration? = nil,\n                 maxAttempts: UInt = 10) async throws -> CachedValue<ExitList>\n{\n    return try await runNeCommand(manager, NeManagerCmd.getExitList(knownVersion: knownVersion), attemptTimeout: attemptTimeout, maxAttempts: maxAttempts)\n}\n\nfunc refreshExitList(_ manager: NETunnelProviderManager,\n                     freshness: TimeInterval,\n                     attemptTimeout: Duration? = nil,\n                     maxAttempts: UInt = 10) async throws -> CachedValue<ExitList>\n{\n    return try await runNeCommand(manager, NeManagerCmd.refreshExitList(freshness: freshness), attemptTimeout: attemptTimeout, maxAttempts: maxAttempts)\n}\n\nstruct CachedValue<T: Codable>: Codable {\n    var version: String\n    var last_updated: TimeInterval\n    var value: T\n}\n\nstruct ExitList: Codable {\n    var exits: [OneExit]\n}\n\nstruct CityExit: Hashable {\n    var city_code: String\n    var country_code: String\n}\n\nstruct OneExit: Codable {\n    var id: String\n    var city_code: String\n    var country_code: String\n    var city_name: String\n    var provider_id: String\n    var provider_url: String\n    var provider_name: String\n    var provider_homepage_url: String\n    var datacenter_id: UInt32\n    var tier: UInt8\n}\n\nfunc getCityNames(_ manager: NETunnelProviderManager, knownVersion: String?) async throws -> (cityNames: [CityExit: String], version: String) {\n    let cachedValue = try await getExitList(manager, knownVersion: knownVersion)\n    var newCityNames: [CityExit: String] = [:]\n    for exit in cachedValue.value.exits {\n        newCityNames[CityExit(city_code: exit.city_code, country_code: exit.country_code)] = exit.city_name\n    }\n    return (cityNames: newCityNames, version: cachedValue.version)\n}\n\nfunc runNeCommand<T: Codable>(\n    _ manager: NETunnelProviderManager,\n    _ cmd: NeManagerCmd,\n    attemptTimeout: Duration? = .seconds(10),\n    maxAttempts: UInt = 10\n) async throws(String) -> T {\n    return try T(json: await runNeJsonCommand(manager, cmd.json(), name: getEnumCaseName(for: cmd), attemptTimeout: attemptTimeout, maxAttempts: maxAttempts))\n}\n\nfunc runNeJsonCommand(\n    _ manager: NETunnelProviderManager,\n    _ jsonCmd: String,\n    name: String?,\n    attemptTimeout: Duration?,\n    maxAttempts: UInt = 10\n) async throws(String) -> String {\n    var result: NeManagerCmdResult\n    do {\n        let resultJson = try await manager.sendAppMessage(\n            jsonCmd.data(using: .utf8)!,\n            maxAttempts: maxAttempts, attemptTimeout: attemptTimeout\n        )\n        result = try NeManagerCmdResult(json: resultJson)\n    } catch {\n        logger.error(\"could not run ne command \\(name, privacy: .public): \\(error, privacy: .public)\")\n        result = .error(errorCodeOther)\n    }\n    switch result {\n    case .ok_json(let ok):\n        logger.debug(\"ne command \\(name, privacy: .public) success\")\n        return ok\n    case .error(let error):\n        logger.debug(\"ne command \\(name, privacy: .public) error: \\(error, privacy: .public)\")\n        throw error\n    }\n}\n\nextension NETunnelProviderManager {\n    // TODO: Merge into runNeCommand without retry logic once we are confident that the UI handles errors and necessary retries for all commands nicely.\n    func sendAppMessage(\n        _ msg: Data,\n        maxAttempts: UInt,\n        attemptTimeout: Duration?\n    ) async throws -> Data {\n        guard let connection = self.connection as? NETunnelProviderSession else {\n            throw \"NETunnelProviderManager.connection is not a NETunnelProviderSession, got \\(debugFormat(self.connection))\"\n        }\n\n        for attempt in 0 ..< maxAttempts {\n            let clock = SuspendingClock.now\n            let response = try? await withTimeout(attemptTimeout) {\n                await withCheckedContinuation { continuation in\n                    do {\n                        logger.debug(\"calling sendProviderMessage\")\n                        try connection.sendProviderMessage(msg) { response in\n                            logger.debug(\"sendProviderMessage returned\")\n                            continuation.resume(returning: response)\n                        }\n                    } catch {\n                        logger.warning(\"sendProviderMessage failed: \\(error, privacy: .public)\")\n                        continuation.resume(returning: .none)\n                    }\n                }\n            }\n            if let response = response {\n                return response\n            }\n            let latency = SuspendingClock.now - clock\n            logger.log(\"sendProviderMessage message failed or lost after \\(latency, privacy: .public), attempt: \\(attempt, privacy: .public)\")\n            try await Task.sleep(seconds: 1.0)\n        }\n        throw \"sendProviderMessage message lost repeatedly\"\n    }\n}\n"
  },
  {
    "path": "apple/client/UXKit/UXImage.swift",
    "content": "/*\n Many UIKit and AppKit classes have fairly similar interfaces\n To that end you can get away with code like this. There are libraries out there\n With a more complete set but I did not wnat to add that dependency given we need such a\n small subset\n https://github.com/ZeeZide/UXKit\n */\n\nimport SwiftUI\n\n#if os(macOS)\n    import AppKit\n\n    typealias UXImage = NSImage\n#else\n    import UIKit\n\n    typealias UXImage = UIImage\n#endif\n\nextension Image {\n    init(uxImage: UXImage) {\n        #if os(macOS)\n            self.init(nsImage: uxImage)\n        #else\n            self.init(uiImage: uxImage)\n        #endif\n    }\n}\n"
  },
  {
    "path": "apple/client/UXKit/UXViewController.swift",
    "content": "/*\n Many UIKit and AppKit classes have fairly similar interfaces\n To that end you can get away with code like this. There are libraries out there\n With a more complete set but I did not wnat to add that dependency given we need such a\n small subset\n https://github.com/ZeeZide/UXKit\n */\n\n#if os(macOS)\n    import AppKit\n\n    typealias UXViewController = NSViewController\n#else\n    import UIKit\n\n    typealias UXViewController = UIViewController\n#endif\n"
  },
  {
    "path": "apple/client/UXKit/UXViewRepresentable.swift",
    "content": "/*\n Many UIKit and AppKit classes have fairly similar interfaces\n To that end you can get away with code like this. There are libraries out there\n With a more complete set but I did not wnat to add that dependency given we need such a\n small subset\n https://github.com/ZeeZide/UXKit\n */\n\nimport SwiftUI\n\n#if os(macOS)\n    typealias UXViewRepresentable = NSViewRepresentable\n#else\n    typealias UXViewRepresentable = UIViewRepresentable\n#endif\n"
  },
  {
    "path": "apple/client/UpdaterDriver+XP.swift",
    "content": "import Foundation\n\nenum UpdaterStatusType: String, Codable {\n    case uninitiated\n    case initiated\n    case available\n    case notFound\n    case error\n}\n\nstruct AppcastSummary: Codable {\n    var date: String\n    var description: String\n    var version: String\n    var minSystemVersionOk: Bool\n}\n\nstruct UpdaterStatus: Codable, CustomStringConvertible {\n    var description: String {\n        return \"UpdaterStatus(type: \\(self.type), appcast: \\(self.appcast as Optional), error: \\(self.error as Optional)), errorCode: \\(self.errorCode as Optional)\"\n    }\n\n    var type: UpdaterStatusType = .uninitiated\n    var appcast: AppcastSummary?\n    var error: String?\n    var errorCode: Int32?\n}\n"
  },
  {
    "path": "apple/client/Webviews/ExternalWebView.swift",
    "content": "import WebKit\n\nstruct ExternalWebView: UXViewRepresentable {\n    let webView: WKWebView\n\n    init(appState: AppState) {\n        let webConfiguration = WKWebViewConfiguration()\n        #if DEBUG\n            webConfiguration.preferences.setValue(true, forKey: \"developerExtrasEnabled\")\n        #endif\n        self.webView = WKWebView(frame: .zero, configuration: webConfiguration)\n        self.webView.navigationDelegate = appState.webviewsController\n    }\n}\n\n// MARK: - AppKit\n\nextension ExternalWebView {\n    func makeNSView(context: Context) -> WKWebView {\n        return self.webView\n    }\n\n    // [required] refresh the view\n    func updateNSView(_ webView: WKWebView, context: Context) {}\n}\n\n// MARK: - UIKit\n\n#if os(iOS)\n\n    extension ExternalWebView {\n        func makeUIView(context: Context) -> UIView {\n            return self.webView\n        }\n\n        func updateUIView(_ uiView: UIView, context: Context) {}\n    }\n\n#endif\n"
  },
  {
    "path": "apple/client/Webviews/ObscuraUIIOSWrapperAndTabs.swift",
    "content": "import Combine\nimport OrderedCollections\nimport SwiftUI\nimport UIKit\nimport WebKit\n\n// In SwiftUI in iOS targeting a minimum SDK of 18.0 SwiftUI TabView\n// requires each tab views view to be different. Sharing the same web view\n// between them creates significant problems. Workarounds were tried\n// going to just use UIKit.\n\nclass ObscuraUIIOSViewAndTabsViewController: UIViewController {\n    private let webView: ObscuraUIWebView\n    private let tabBar: UITabBar\n    private let tabBarItems: [UITabBarItem]\n    private let webviewsController: WebviewsController\n    private let tabs: OrderedSet<AppView>\n\n    var showTabBar: Bool {\n        didSet {\n            self.setupLayout()\n        }\n    }\n\n    private var cancellables = Set<AnyCancellable>()\n\n    init(\n        webView: ObscuraUIWebView,\n        webviewsController: WebviewsController,\n        tabs: OrderedSet<AppView>,\n        showTabBar: Bool\n    ) {\n        self.showTabBar = showTabBar\n        self.webView = webView\n        self.tabBar = UITabBar()\n        self.webviewsController = webviewsController\n        self.tabBarItems = tabs.map { view in\n            let item = UITabBarItem(\n                title: view.rawValue.capitalized,\n                image: UIImage(systemName: view.systemImageName),\n                selectedImage: UIImage(systemName: view.systemImageName)\n            )\n            return item\n        }\n        self.tabs = tabs\n\n        super.init(nibName: nil, bundle: nil)\n\n        self.setupTabBar()\n        self.setupLayout()\n\n        webviewsController.$tab.sink { [weak self] newTab in\n            self?.navigateTo(view: newTab)\n        }.store(in: &self.cancellables)\n    }\n\n    @available(*, unavailable)\n    required init?(coder: NSCoder) {\n        fatalError(\"init(coder:) has not been implemented\")\n    }\n\n    private func setupTabBar() {\n        self.tabBar.items = self.tabBarItems\n        self.tabBar.selectedItem = self.tabBarItems.first\n        self.tabBar.delegate = self\n        self.tabBar.tintColor = UIColor(named: \"ObscuraOrange\")\n    }\n\n    private func setupLayout() {\n        // Remove constraints and views if they were already subviews\n        self.webView.removeFromSuperview()\n        self.tabBar.removeFromSuperview()\n\n        view.addSubview(self.webView)\n        self.webView.translatesAutoresizingMaskIntoConstraints = false\n\n        if self.showTabBar {\n            view.insertSubview(self.tabBar, aboveSubview: self.webView)\n            self.tabBar.translatesAutoresizingMaskIntoConstraints = false\n\n            NSLayoutConstraint.activate([\n                self.webView.topAnchor.constraint(equalTo: view.topAnchor),\n                self.webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),\n                self.webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),\n                self.webView.bottomAnchor.constraint(equalTo: self.tabBar.topAnchor),\n\n                self.tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),\n                self.tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),\n                self.tabBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),\n            ])\n        } else {\n            NSLayoutConstraint.activate([\n                self.webView.topAnchor.constraint(equalTo: view.topAnchor),\n                self.webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),\n                self.webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),\n                self.webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),\n            ])\n        }\n    }\n\n    private func navigateTo(view: AppView) {\n        if let index = tabs.firstIndex(of: view) {\n            self.tabBar.selectedItem = self.tabBarItems[index]\n        }\n        self.webView.navigateTo(view: view)\n    }\n}\n\n// MARK: - UITabBarDelegate\n\nextension ObscuraUIIOSViewAndTabsViewController: UITabBarDelegate {\n    func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {\n        guard let index = tabBarItems.firstIndex(of: item), index < tabs.count else { return }\n\n        let selectedView = self.tabs[index]\n        self.webviewsController.tab = selectedView\n    }\n}\n\n// MARK: - SwiftUI Wrapper\n\nstruct ObscuraUIIOSViewAndTabsWrapper: UIViewControllerRepresentable {\n    let webView: ObscuraUIWebView\n    let webviewsController: WebviewsController\n    let tabs: OrderedSet<AppView>\n    let showTabBar: Bool\n\n    func makeUIViewController(context: Context) -> ObscuraUIIOSViewAndTabsViewController {\n        return ObscuraUIIOSViewAndTabsViewController(\n            webView: self.webView,\n            webviewsController: self.webviewsController,\n            tabs: self.tabs,\n            showTabBar: self.showTabBar\n        )\n    }\n\n    func updateUIViewController(_ uiViewController: ObscuraUIIOSViewAndTabsViewController, context: Context) {\n        uiViewController.showTabBar = self.showTabBar\n    }\n}\n"
  },
  {
    "path": "apple/client/Webviews/ObscuraUIMacOSWrapper.swift",
    "content": "import SwiftUI\nimport WebKit\n\nstruct ObscuraUIMacOSWrapper: UXViewRepresentable {\n    let webView: ObscuraUIWebView\n\n    init(webView: ObscuraUIWebView) {\n        self.webView = webView\n    }\n}\n\n// MARK: - AppKit\n\n// Hack not needed on macOS as NavigationSplitView allows each tab to share the same SwiftUI view\nextension ObscuraUIMacOSWrapper {\n    func makeNSView(context: Context) -> WKWebView {\n        return self.webView\n    }\n\n    // [required] refresh the view\n    func updateNSView(_ webView: WKWebView, context: Context) {}\n}\n"
  },
  {
    "path": "apple/client/Webviews/ObscuraUIWebView.swift",
    "content": "import SwiftUI\nimport WebKit\n\nclass ObscuraUIWebView: WKWebView {\n    init(appState: AppState) {\n        let webConfiguration = WKWebViewConfiguration()\n        // webConfiguration.preferences.javaScriptEnabled = true\n        let error_capture_script = WKUserScript(source: js_error_capture, injectionTime: .atDocumentStart, forMainFrameOnly: false)\n        webConfiguration.userContentController.addUserScript(error_capture_script)\n        let log_capture_script = WKUserScript(source: js_log_capture, injectionTime: .atDocumentStart, forMainFrameOnly: false)\n        webConfiguration.userContentController.addUserScript(log_capture_script)\n\n        // add bridges (command, console.error, console.log) between JS and Swift\n        webConfiguration.userContentController.addScriptMessageHandler(CommandHandler(appState: appState), contentWorld: .page, name: \"commandBridge\")\n        webConfiguration.userContentController.add(ErrorHandler.shared, name: \"errorBridge\")\n        webConfiguration.userContentController.add(LogHandler.shared, name: \"logBridge\")\n\n        // for React application\n        webConfiguration.setValue(true, forKey: \"allowUniversalAccessFromFileURLs\")\n        webConfiguration.preferences.setValue(true, forKey: \"allowFileAccessFromFileURLs\")\n        // note that text selection is disabled using CSS\n        webConfiguration.preferences.isTextInteractionEnabled = true\n        #if DEBUG\n            webConfiguration.preferences.setValue(true, forKey: \"developerExtrasEnabled\")\n        #endif\n        super.init(frame: .zero, configuration: webConfiguration)\n        self.navigationDelegate = appState.webviewsController\n\n        #if LOAD_DEV_SERVER\n            let urlRequest = URLRequest(url: URL(string: \"http://localhost:1420/\")!)\n            self.load(urlRequest)\n        #else\n            // see the Prod Client scheme\n            let url = Bundle.main.url(forResource: \"index\", withExtension: \"html\", subdirectory: \"build\")!\n            self.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())\n        #endif\n\n        #if !os(macOS)\n            // Safe area ignore\n            // https://stackoverflow.com/a/47814446/3833632\n            self.scrollView.delegate = self\n            self.scrollView.contentInsetAdjustmentBehavior = .never\n        #endif\n    }\n\n    @available(*, unavailable)\n    required init?(coder: NSCoder) {\n        fatalError(\"init(coder:) has not been implemented\")\n    }\n\n    func navigateTo(view: AppView) {\n        self.evaluateJavaScript(\n            ObscuraUIWebView.generateNavEventJS(viewName: view.ipcValue)\n        )\n        #if !os(macOS)\n            self.scrollView.bounces = view.needsScroll\n        #endif\n    }\n\n    static func generateNavEventJS(viewName: String) -> String {\n        // reuse the variable `__WK_WEBKIT_NAV_EVENT__`\n        let jsDispatchNavUpdateStr = \"\"\"\n        __WEBKIT_NAV_EVENT__ = new CustomEvent(\"navUpdate\", { detail: \"\\(viewName)\" });\n        window.dispatchEvent(__WEBKIT_NAV_EVENT__);\n        \"\"\"\n        return jsDispatchNavUpdateStr\n    }\n\n    func handlePaymentSucceeded() {\n        self.evaluateJavaScript(ObscuraUIWebView.generatePaymentSucceededEventJS())\n    }\n\n    static func generatePaymentSucceededEventJS() -> String {\n        return \"\"\"\n            window.dispatchEvent(new CustomEvent(\"paymentSucceeded\"))\n        \"\"\"\n    }\n\n    func handleScreenshotDetected() {\n        self.evaluateJavaScript(ObscuraUIWebView.generateScreenshotDetectedEventJS())\n    }\n\n    static func generateScreenshotDetectedEventJS() -> String {\n        return \"\"\"\n            window.dispatchEvent(new CustomEvent(\"screenshotDetected\"))\n        \"\"\"\n    }\n}\n\n#if !os(macOS)\n    extension ObscuraUIWebView: UIScrollViewDelegate {\n        func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {\n            scrollView.pinchGestureRecognizer?.isEnabled = false\n        }\n\n        func scrollViewDidZoom(_ scrollView: UIScrollView) {\n            scrollView.minimumZoomScale = scrollView.zoomScale\n            scrollView.maximumZoomScale = scrollView.zoomScale\n        }\n    }\n#endif\n\nlet js_error_capture = #\"\"\"\nwindow.onerror = (message, source, lineno, colno, error) => {\n    window.webkit.messageHandlers.errorBridge.postMessage(JSON.stringify({\n      message: message,\n      source: source,\n      lineno: lineno,\n      colno: colno,\n    }, undefined, \"\\t\"));\n};\nwindow.onunhandledrejection = (event) => {\n    console.error(\"unhandled promise rejection\", event.reason)\n}\n\"\"\"#\n\nlet js_log_capture = #\"\"\"\nfunction log(type, msg, ...args) {\n    let formatted = [type, msg, ...args.map(a => JSON.stringify(a, undefined, \"\\t\"))].join(\" \");\n    window.webkit.messageHandlers.logBridge.postMessage(formatted);\n}\nconsole.debug = log.bind(null, \"debug:\");\nconsole.log = log.bind(null, \"log:\");\nconsole.warn = log.bind(null, \"warn:\");\nconsole.error = log.bind(null, \"error:\");\n\"\"\"#\n"
  },
  {
    "path": "apple/client/Webviews/ObscuraUIWebViewMacOSWrapper.swift",
    "content": "import SwiftUI\nimport WebKit\n\nstruct ObscuraUIWebViewMacOSWrapper: View {\n    let webView: ObscuraUIWebView\n\n    init(webView: ObscuraUIWebView) {\n        self.webView = webView\n    }\n\n    var body: some View {\n        WebViewRepresentable(webView: self.webView)\n    }\n}\n\nprivate struct WebViewRepresentable: NSViewRepresentable {\n    let webView: ObscuraUIWebView\n\n    func makeNSView(context: Context) -> WKWebView {\n        return self.webView\n    }\n\n    func updateNSView(_ webView: WKWebView, context: Context) {\n        // No updates needed\n    }\n}\n"
  },
  {
    "path": "apple/client/Webviews/WebviewsController.swift",
    "content": "import OSLog\nimport SwiftUI\nimport WebKit\n\nprivate let logger = Logger(\n    subsystem: Bundle.main.bundleIdentifier!,\n    category: \"WebviewsController\"\n)\n\n// This is the navigation for all web views within the app\nclass WebviewsController: NSObject, ObservableObject, WKNavigationDelegate {\n    @Published var showModalWebview: Bool = false\n    @Published var showSubscriptionManageSheet: Bool = false\n\n    @Published var obscuraWebView: ObscuraUIWebView? = nil\n    @Published var externalWebView: ExternalWebView? = nil\n\n    @Published var tab: AppView = .connection\n\n    let useExernalBrowserForPayments = true\n\n    private enum LinkDestination {\n        case social\n        case checkConnection\n        case managePayment\n        case stripePayment\n        case homepage\n        case termsOfService\n\n        var openExternally: Bool {\n            switch self {\n            case .social, .checkConnection, .homepage, .stripePayment:\n                return true\n            case .termsOfService, .managePayment:\n                return false\n            }\n        }\n    }\n\n    func initializeWebviews(appState: AppState) {\n        self.obscuraWebView = ObscuraUIWebView(appState: appState)\n        self.externalWebView = ExternalWebView(appState: appState)\n    }\n\n    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {\n        if webView == self.obscuraWebView {\n            // Check if the navigation action is a form submission\n            if navigationAction.navigationType == .linkActivated, let url = navigationAction.request.url {\n                #if os(macOS)\n                    NSWorkspace.shared.open(url)\n                #else\n                    self.handleWebsiteLinkiOS(url: url)\n                #endif\n                decisionHandler(.cancel)\n            } else {\n                decisionHandler(.allow)\n            }\n        } else {\n            if let url = navigationAction.request.url, url.absoluteString.contains(\"obscuravpn\") {\n                self.handleObscuraURL(url: url)\n            }\n            decisionHandler(.allow)\n        }\n    }\n\n    #if !os(macOS)\n        func handleWebsiteLinkiOS(url: URL) {\n            if url.absoluteString.contains(\"obscuravpn:///\") {\n                self.handleObscuraURL(url: url)\n                return\n            }\n\n            if url.scheme == \"mailto\" {\n                UIApplication.shared.open(url)\n                return\n            }\n\n            // Check that it is a staging.obscura.com or obscura.com url\n            guard\n                let components = NSURLComponents(\n                    url: url,\n                    resolvingAgainstBaseURL: true\n                ), let path = components.path, let host = components.host\n            else {\n                logger.error(\"Failed to parse URL into components\")\n                return\n            }\n\n            let destination: LinkDestination?\n            if host.contains(\"obscura\") {\n                if path.contains(\"pay\") {\n                    destination = .stripePayment\n                } else if path.contains(\"check\") {\n                    destination = .checkConnection\n                } else if path.contains(\"legal\") {\n                    destination = .termsOfService\n                } else if path == \"/\" {\n                    destination = .homepage\n                } else {\n                    destination = nil\n                }\n            } else {\n                if host\n                    .contains(\"discord\") || host\n                    .contains(\"matrix.to\") || host\n                    .contains(\"x.com\")\n                {\n                    destination = .social\n                } else {\n                    destination = nil\n                }\n            }\n\n            if destination?.openExternally ?? true {\n                UIApplication.shared.open(url)\n                return\n            } else {\n                Task { @MainActor in\n                    // Clear webview\n                    self.externalWebView?.webView.load(URLRequest(url: URL(string: \"about:blank\")!))\n\n                    // Load the requested page\n                    self.externalWebView?.webView.load(URLRequest(url: url))\n\n                    self.showModalWebview = true\n                }\n            }\n        }\n    #endif\n\n    func handleObscuraURL(url: URL) {\n        logger.info(\"Handling URL: \\(url, privacy: .public)\")\n\n        // From: https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app#Handle-incoming-URLs\n        guard\n            let components = NSURLComponents(\n                url: url,\n                resolvingAgainstBaseURL: true\n            )\n        else {\n            logger.error(\"Failed to parse URL into components\")\n            return\n        }\n\n        #if os(macOS)\n            if let appDelegate = NSApp.delegate as? AppDelegate {\n                appDelegate.showPrimaryWindow()\n            }\n        #else\n            self.showModalWebview = false\n        #endif\n\n        switch components.path {\n        case .some(\"/open\"):\n            break\n        case .some(\"/manage-subscription\"):\n            self.showSubscriptionManageSheet = true\n        case .some(\"/payment-succeeded\"):\n            self.obscuraWebView?.handlePaymentSucceeded()\n        case .some(\"/account\"):\n            self.tab = .account\n        case .some(\"/location\"):\n            self.tab = .location\n        case let unknownPath:\n            logger.error(\n                \"Unknown URL path: \\(unknownPath, privacy: .public)\"\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "apple/client/app_state.swift",
    "content": "import Foundation\n#if os(iOS)\n    import MessageUI\n    import StoreKit\n#endif\nimport NetworkExtension\nimport OSLog\nimport SwiftUI\nimport UserNotifications\n\nclass AppState: ObservableObject {\n    private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"AppState\")\n    var manager: NETunnelProviderManager\n    private let configQueue: DispatchQueue = .init(label: \"config queue\")\n    let osStatus: WatchableValue<OsStatus>\n    @Published var status: NeStatus\n    @Published var needsIsEnabledFix: Bool = false\n    @Published var showOfferCodeRedemption: Bool = false\n\n    #if !os(macOS)\n        private var didBecomeActiveObserver: NSObjectProtocol?\n    #endif\n\n    #if os(macOS)\n        let updater: SparkleUpdater\n    #else\n        let mailDelegate = MailDelegate()\n        @Published var storeKitModel: StoreKitModel = .init()\n        private var storeKitListener: StoreKitListener?\n    #endif\n    @Published var webviewsController: WebviewsController\n\n    init(\n        _ manager: NETunnelProviderManager,\n        initialStatus: NeStatus\n    ) {\n        self.manager = manager\n        self.status = initialStatus\n        self.osStatus = OsStatus.watchable(manager: manager)\n        #if os(macOS)\n            self.updater = SparkleUpdater(osStatus: self.osStatus)\n        #endif\n\n        self.webviewsController = WebviewsController()\n        self.webviewsController.initializeWebviews(appState: self)\n\n        #if !os(macOS)\n            self.didBecomeActiveObserver = NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in\n                self?.updateNeedIsEnabledFix()\n            }\n            self.storeKitListener = StoreKitListener(appState: self)\n        #endif\n\n        if initialStatus.autoConnect {\n            Task {\n                Self.logger.info(\"Auto-connect is enabled, waiting for internet availability before connecting\")\n                while true {\n                    _ = await self.osStatus.waitUntil { $0.internetAvailable }\n                    // Wait a little to increase the chance that the OS NE session manager realizes internet is available, otherwise the NE will fail to start connecting and restart, which can cost much more time.\n                    try! await Task.sleep(seconds: 0.2)\n                    if self.manager.protocolConfiguration?.includeAllNetworks == .some(true) {\n                        // Wait even longer if includeAllNetworks is enabled. Otherwise the NE state tends to traverse connected->disconnected->connecting quickly without calling any of the appropriate callbacks and then gets stuck until stopped manually. This is very common on macos 14 and rare on macos 15.\n                        try! await Task.sleep(seconds: 2)\n                    }\n\n                    if self.osStatus.get().internetAvailable == false {\n                        Self.logger.info(\"Internet became unavailability before auto-connect was triggered. Retrying.\")\n                        continue\n                    }\n                    if !self.status.autoConnect {\n                        Self.logger.info(\"Auto-connect was disabled while waiting for internet availability, not connecting\")\n                        return\n                    }\n                    if self.osStatus.get().tunnelActivated() {\n                        Self.logger.info(\"Tunnel already activated abandoning auto-connect\")\n                        return\n                    }\n\n                    Self.logger.info(\"Auto-connecting\")\n\n                    do {\n                        try await self.enableTunnel(TunnelArgs(exit: self.status.lastExit))\n                    } catch {\n                        Self.logger.error(\"Could not trigger auto connect \\(error, privacy: .public)\")\n                        let content = UNMutableNotificationContent()\n                        content.title = \"Automatic connect failed\"\n                        content.body = \"Could not connect automatically at launch.\"\n                        content.interruptionLevel = .active\n                        content.sound = UNNotificationSound.defaultCritical\n                        displayNotification(.autoConnectFailed, content)\n                        return\n                    }\n\n                    if await self.waitForTunnelActivation(Duration.seconds(1)) {\n                        Self.logger.info(\"Successfully triggered auto-connect\")\n                        return\n                    }\n                    Self.logger.info(\"Auto-connect timed out, trying again\")\n                }\n            }\n        }\n\n        Task { @MainActor in\n            var version: UUID = initialStatus.version\n            while true {\n                if let status = try? await getNeStatus(self.manager, knownVersion: version) {\n                    Self.logger.info(\"Status updated: \\(debugFormat(status), privacy: .public)\")\n                    version = status.version\n                    self.status = status\n                    switch status.vpnStatus {\n                    case .connecting(_, connectError: let err, _):\n                        if err == \"accountExpired\" {\n                            Self.logger.info(\"found connecting error accountExpired\")\n                            // TODO: iOS app should respond to this error OBS-1542\n                            #if os(macOS)\n                                // can't use openURL due to a runtime warning stating that it was called outside of a view\n                                NSApp.delegate?.application?(NSApp, open: [URLs.AppAccountPage])\n                            #endif\n                        }\n                    default:\n                        break\n                    }\n                } else {\n                    // TODO: Mark status as \"unknown\".\n                    // https://linear.app/soveng/issue/OBS-358/status-icon-should-display-unknown-when-status-cant-be-read\n                }\n            }\n        }\n\n        Task {\n            /* Hacky loop to keep the network extension alive.\n\n                 After 60s of inactivty the network extension is decomissioned which has a number of downsides:\n\n                 1. It leaks a `utunN` device (macOS bug).\n                 2. It kills all active RPC calls (annoying).\n\n                 In order to resolve this we simply ping the network extension in a loop.\n             */\n            while true {\n                do {\n                    try await self.ping()\n                    try! await Task.sleep(seconds: 30)\n                } catch {\n                    Self.logger.error(\"Ping failed \\(error.localizedDescription, privacy: .public)\")\n                    try! await Task.sleep(seconds: 5)\n                }\n            }\n        }\n    }\n\n    func updateNeedIsEnabledFix() {\n        Self.logger.info(\"updating need for isEnabled fix\")\n        Task { @MainActor in\n            do {\n                try await self.manager.loadFromPreferences()\n                if self.manager.isEnabled {\n                    Self.logger.info(\"manager is enabled, isEnabled fix not needed\")\n                    return\n                }\n            } catch {\n                Self.logger.error(\"error loading NE preferences: \\(error), assuming isEnabled fix is not needed\")\n                return\n            }\n            Self.logger.info(\"manager is disabled\")\n            do {\n                try await self.ping()\n                Self.logger.info(\"ping succeeded, isEnabled fix not needed\")\n                return\n            } catch {\n                Self.logger.error(\"ping failed: \\(error)\")\n            }\n            Self.logger.error(\"manager is disabled and ping failed, isEnabled fix needed\")\n            self.needsIsEnabledFix = true\n        }\n    }\n\n    func runIsEnabledFix() {\n        Task { @MainActor in\n            Self.logger.info(\"running isEnabledFix\")\n            do {\n                self.manager.isEnabled = true\n                try await self.manager.saveToPreferences()\n                self.needsIsEnabledFix = false\n            } catch {\n                Self.logger.error(\"error loading NE preferences: \\(error)\")\n            }\n        }\n    }\n\n    func setIncludeAllNetworks(enable: Bool) async throws {\n        guard let proto = self.manager.protocolConfiguration else {\n            throw \"NEVPNManager.protocolConfiguration is nil\"\n        }\n\n        Self.logger.info(\"setIncludeAllNetworks \\(proto.includeAllNetworks, privacy: .public) → \\(enable, privacy: .public)\")\n\n        if proto.includeAllNetworks == enable { return }\n\n        proto.includeAllNetworks = enable\n        do {\n            try await self.manager.saveToPreferences()\n            return\n        } catch {\n            Self.logger.error(\"Failed to save NEVPNManager: \\(error.localizedDescription)\")\n        }\n\n        do {\n            try await self.manager.loadFromPreferences()\n            return\n        } catch {\n            Self.logger.error(\"Failed to reload NEVPNManager: \\(error.localizedDescription)\")\n        }\n\n        proto.includeAllNetworks = false\n        Self.logger.warning(\"Marking local includeAllNetworks to false as a safe default.\")\n\n        throw \"Unable to save VPN configuration.\"\n    }\n\n    func enableTunnel(_ tunnelArgs: TunnelArgs?) async throws(String) {\n        let useOnDemand = self.status.featureFlags.killSwitch ?? false\n        // TODO: move this into startup flow or post feature enablement flow (https://linear.app/soveng/issue/OBS-2428)\n        if useOnDemand {\n            _ = await requestNotificationAuthorization()\n        }\n\n        for _ in 1 ..< 3 {\n            let onDemandEnabled: Bool = await { () -> Bool in\n                do {\n                    try await self.manager.loadFromPreferences()\n                    return self.manager.isEnabled && self.manager.isOnDemandEnabled\n                } catch {\n                    Self.logger.error(\"Failed to check onDemand status of tunnel \\(error, privacy: .public)\")\n                    return false\n                }\n            }()\n            // Remove once onDemand is unconditional ( https://linear.app/soveng/issue/OBS-2428 )\n            let tunnelEnabled: Bool = self.manager.connection.status != .disconnected\n\n            if !self.osStatus.get().internetAvailable {\n                Self.logger.log(\"Failed to connect due to no network connectivity\")\n                throw errorConnectDeviceOffline\n            }\n\n            // Iff tunnel is already enabled update tunnel args without startVPNTunnel. Doing this unconditionally without returning would be correct as well, but NE round-trips can be a bit slow.\n            if tunnelEnabled || onDemandEnabled {\n                do {\n                    Self.logger.log(\"Tunnel already active, set tunnel args\")\n                    let _: Empty = try await runNeCommand(self.manager, .setTunnelArgs(args: tunnelArgs, active: .none))\n                    Self.logger.log(\"Successfully set tunnel args\")\n                    return\n                } catch {\n                    Self.logger.error(\"Setting tunnel args failed: \\(error, privacy: .public)\")\n                }\n            }\n\n            // Call startVPNTunnel unconditionally, because onDemand will not not start the tunnel until there is traffic, which can be confusing.\n            Self.logger.log(\"Starting tunnel\")\n            do {\n                try self.manager.connection.startVPNTunnel(options: [\"tunnelArgs\": NSString(string: tunnelArgs.json())])\n                Self.logger.log(\"startVPNTunnel called without error\")\n            } catch {\n                Self.logger.error(\"startVPNTunnel failed \\(error, privacy: .public)\")\n            }\n\n            // Enable tunnel and onDemand\n            do {\n                try await self.manager.loadFromPreferences()\n                self.manager.isOnDemandEnabled = useOnDemand\n                if !self.manager.isEnabled {\n                    Self.logger.info(\"NETunnelProviderManager is disabled, enabling\")\n                    self.manager.isEnabled = true\n                }\n                try await self.manager.saveToPreferences()\n                try await self.manager.loadFromPreferences()\n                return\n            } catch {\n                Self.logger.error(\"Could not set onDemand \\(error, privacy: .public)\")\n            }\n            try! await Task.sleep(seconds: 1)\n        }\n        Self.logger.error(\"Could not enable tunnel repeatedly, giving up...\")\n        throw errorCodeOther\n    }\n\n    func disableTunnel() async {\n        Self.logger.log(\"Stopping tunnel\")\n        self.manager.isOnDemandEnabled = false\n        do {\n            try await self.manager.saveToPreferences()\n            try await self.manager.loadFromPreferences()\n        } catch {\n            Self.logger.critical(\"Could not save NETunnelProviderManager preferences before stopping tunnel \\(error, privacy: .public)\")\n        }\n        self.manager.connection.stopVPNTunnel()\n    }\n\n    func getOsStatus(knownVersion: UUID?) async -> OsStatus {\n        return await self.osStatus.getIfOrNext { current in\n            current.version != knownVersion\n        }\n    }\n\n    func ping() async throws(String) {\n        let _: Empty = try await runNeCommand(self.manager, .ping, attemptTimeout: Duration.seconds(5), maxAttempts: 1)\n    }\n\n    func getAccountInfo() async throws(String) -> AccountInfo {\n        return try await runNeCommand(self.manager, .apiGetAccountInfo)\n    }\n\n    func getTrafficStats() async throws(String) -> TrafficStats {\n        return try await runNeCommand(self.manager, .getTrafficStats)\n    }\n\n    func resetUserDefaults() {\n        for k in UserDefaultKeys.allKeys {\n            UserDefaults.standard.removeObject(forKey: k)\n        }\n    }\n\n    func waitForTunnelActivation(_ timeout: Duration) async -> Bool {\n        let result = await self.osStatus.waitUntilWithTimeout(timeout) {\n            switch $0.osVpnStatus {\n            case .connected, .connecting, .reasserting:\n                return true\n            case .disconnected, .disconnecting, .invalid:\n                return false\n            @unknown default:\n                return false\n            }\n        }\n        return result != nil\n    }\n\n    // Unfortunately async notification iterators are not sendable, so we often need to resubscribe to state changes.\n    // This function:\n    //    - subscribes to state changes\n    //    - checks if the initial status is unchanged (because subscribing may race with changes)\n    //    - waits for a state change notification or timeout\n    //    - returns the changed state if it didn't time out\n    private static func waitForStateChange(connection: NEVPNConnection, initial: NEVPNStatus, maxSeconds: Double) async -> NEVPNStatus? {\n        enum Event {\n            case change\n            case timeout\n        }\n        return await withTaskGroup(of: Event.self) { taskGroup in\n            taskGroup.addTask {\n                let notifications = NotificationCenter.default.notifications(named: .NEVPNStatusDidChange, object: connection)\n                if connection.status != initial {\n                    Self.logger.debug(\"Status already changed.\")\n                    return Event.change\n                }\n                for await _ in notifications {\n                    Self.logger.debug(\"Status change notification received.\")\n                    return Event.change\n                }\n                if Task.isCancelled {\n                    Self.logger.debug(\"Status change notification cancelled\")\n                } else {\n                    Self.logger.error(\"Status change notification stream stopped unexpectedly.\")\n                }\n                return Event.timeout\n            }\n            taskGroup.addTask {\n                if let _ = try? await Task.sleep(seconds: maxSeconds) {\n                    Self.logger.debug(\"Status change timeout.\")\n                    return Event.timeout\n                }\n                return Event.change\n            }\n            let event = await taskGroup.next()!\n            taskGroup.cancelAll()\n            return event == .timeout ? nil : connection.status\n        }\n    }\n\n    private static func fetchDisconnectErrorAsErrorCode(connection: NEVPNConnection) async -> String {\n        do {\n            try await connection.fetchLastDisconnectError()\n            self.logger.error(\"Failed to fetch disconnect error\")\n            return \"failedWithoutDisconnectError\"\n        } catch {\n            if let connectErrorCode = (error as NSError).connectErrorCode() {\n                self.logger.log(\"Fetched connect error code: \\(connectErrorCode)\")\n                return connectErrorCode\n            }\n            if (error as NSError).domain == NEVPNConnectionErrorDomain {\n                switch NEVPNConnectionError(rawValue: (error as NSError).code) {\n                case .noNetworkAvailable:\n                    return \"noNetworkAvailable\"\n                default:\n                    Self.logger.error(\"Unexpected NEVPNConnectionError after startTunnel: \\(error, privacy: .public)\")\n                    return errorCodeOther\n                }\n            }\n            Self.logger.error(\"Unexpected error after startTunnel: \\(error, privacy: .public)\")\n            return errorCodeOther\n        }\n    }\n\n    #if os(iOS)\n        func associateAccount() async throws(String) -> AppleAssociateAccountOutput {\n            let appTransaction: String\n            do {\n                appTransaction = try await AppTransaction.shared.jwsRepresentation\n            } catch {\n                throw errorFailedToAssociateAccount\n            }\n            return try await runNeCommand(self.manager, .apiAppleAssociateAccount(appTransactionJws: appTransaction))\n        }\n\n        // TODO: Test interrupted purchase\n        // https://developer.apple.com/documentation/storekit/testing-an-interrupted-purchase\n        func purchase(product: Product) async throws -> Bool {\n            _ = try await self.associateAccount()\n            let result = try await product.purchase()\n            if case .success(let verification) = result {\n                if case .verified(let transaction) = verification {\n                    await transaction.finish()\n                    return true\n                }\n            }\n            return false\n        }\n\n        func purchaseSubscription() async throws(String) -> Bool {\n            guard let subscriptionProduct = await self.storeKitModel.subscriptionProduct else {\n                Self.logger.error(\"subscription product missing\")\n                return false\n            }\n            do {\n                return try await self.purchase(product: subscriptionProduct)\n            } catch {\n                Self.logger.error(\"Failed to purchase subscription: \\(error, privacy: .public)\")\n                throw errorPurchaseFailed\n            }\n        }\n\n        private func rootViewController() -> UIViewController? {\n            UIApplication.shared.connectedScenes\n                .compactMap { $0 as? UIWindowScene }\n                .filter { $0.activationState == .foregroundActive }\n                .first?.keyWindow?.rootViewController\n        }\n\n        private func presentFromRoot(viewController: UIViewController) {\n            let rvc = self.rootViewController()\n            // This generates a ton of spurious warnings and errors, which is\n            // apparently normal. Also, the first present will be slow when\n            // connected for debugging.\n            rvc?.present(viewController, animated: true, completion: nil)\n        }\n\n        func emailDebugArchive(path: String, subject: String, body: String) throws(String) {\n            if !MFMailComposeViewController.canSendMail() {\n                Self.logger.info(\"Mail services are not available\")\n                return\n            }\n            let cvc = MFMailComposeViewController()\n            cvc.mailComposeDelegate = self.mailDelegate\n            cvc.setToRecipients([\"support@obscura.net\"])\n            cvc.setSubject(subject)\n            cvc.setMessageBody(body, isHTML: false)\n            let url = URL(fileURLWithPath: path)\n            let data: Data\n            do {\n                data = try Data(contentsOf: url)\n            } catch {\n                throw \"Failed to read debugging archive: \\(error)\"\n            }\n            cvc.addAttachmentData(data, mimeType: \"application/zip\", fileName: url.lastPathComponent)\n            self.presentFromRoot(viewController: cvc)\n        }\n\n        func shareFile(path: String) {\n            let url = URL(fileURLWithPath: path)\n            let avc = UIActivityViewController(activityItems: [url], applicationActivities: nil)\n            self.presentFromRoot(viewController: avc)\n        }\n    #endif\n}\n\nstruct TrafficStats: Codable {\n    let connectedMs: UInt64\n    let connId: UUID\n    let txBytes: UInt64\n    let rxBytes: UInt64\n    let latestLatencyMs: UInt16\n}\n"
  },
  {
    "path": "apple/client/client-ios.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.developer.associated-domains</key>\n\t<array>\n\t\t<string>webcredentials:obscura.com</string>\n\t\t<string>webcredentials:obscura.net</string>\n\t</array>\n\t<key>com.apple.developer.networking.networkextension</key>\n\t<array>\n\t\t<string>$(OBSCURA_PACKET_TUNNEL_PROVIDER_ENTITLEMENT)</string>\n\t</array>\n\t<key>com.apple.security.application-groups</key>\n\t<array>\n\t\t<string>$(OBSCURA_APP_APP_GROUP_ID)</string>\n\t</array>\n</dict>\n</plist>\n"
  },
  {
    "path": "apple/client/client-macos.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n    <key>com.apple.developer.associated-domains</key>\n    <array>\n      <string>webcredentials:obscura.com</string>\n      <string>webcredentials:obscura.net</string>\n    </array>\n    <key>com.apple.developer.networking.networkextension</key>\n    <array>\n      <string>$(OBSCURA_PACKET_TUNNEL_PROVIDER_ENTITLEMENT)</string>\n    </array>\n    <key>com.apple.developer.system-extension.install</key>\n    <true/>\n    <key>com.apple.security.application-groups</key>\n    <array>\n      <string>$(OBSCURA_APP_APP_GROUP_ID)</string>\n    </array>\n  </dict>\n</plist>\n"
  },
  {
    "path": "apple/client/command.swift",
    "content": "#if os(macOS)\n    import AppKit\n#endif\nimport Foundation\nimport OSLog\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"command\")\n\nenum Command: Codable {\n    case startTunnel(tunnelArgs: String)\n    case stopTunnel\n    case setStrictLeakPrevention(enable: Bool)\n    case setColorScheme(value: AppAppearance)\n    case debuggingArchive(userFeedback: String?)\n    case revealItemInDir(path: String)\n    case emailDebugArchive(path: String, subject: String, body: String)\n    case shareDebugArchive(path: String)\n    case registerAsLoginItem\n    case unregisterAsLoginItem\n    case resetUserDefaults\n    case getOsStatus(knownVersion: UUID?)\n    case checkForUpdates\n    case installUpdate\n    case associateAccount\n    case purchaseSubscription\n    case restorePurchases\n    case showOfferCodeRedemption\n    case resetOfferCodeRedemptionSuccess\n    case jsonFfiCmd(\n        cmd: String,\n        timeoutMs: Int?\n    )\n}\n\nextension CommandHandler {\n    func handleWebViewCommand(command: Command) async throws(String) -> String {\n        switch command {\n        case .startTunnel(tunnelArgs: let jsonArgs):\n            let args = try TunnelArgs(json: jsonArgs)\n            try await appState.enableTunnel(args)\n        case .stopTunnel:\n            await appState.disableTunnel()\n        case .resetUserDefaults:\n            // NOTE: only shown in the Developer View\n            appState.resetUserDefaults()\n        case .setStrictLeakPrevention(let enable):\n            do {\n                try await appState.setIncludeAllNetworks(enable: enable)\n            } catch {\n                logger.error(\"Could not set includeAllNetworks \\(error, privacy: .public)\")\n                throw errorCodeOther\n            }\n        case .setColorScheme(let colorScheme):\n            DispatchQueue.main.async {\n                StartupModel.shared.selectedAppearance = colorScheme\n            }\n\n            // When setting color scheme to no preference (nil),\n            //  only the header changes appearance immediately\n            // This bug is applicable to iOS 18 & macOS Sequoia:\n            //  https://developer.apple.com/forums/thread/677212?answerId=805661022#805661022\n\n            // Setting to nil a second time results in the expected visual change\n            DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {\n                StartupModel.shared.selectedAppearance = colorScheme\n            }\n        case .jsonFfiCmd(cmd: let jsonCmd, let timeoutMs):\n            let attemptTimeout: Duration? = switch timeoutMs {\n            case .some(let ms): .milliseconds(ms)\n            case .none: nil\n            }\n            return try await runNeJsonCommand(\n                appState.manager,\n                jsonCmd,\n                name: getEnumCaseName(for: jsonCmd),\n                attemptTimeout: attemptTimeout\n            )\n        case .getOsStatus(knownVersion: let version):\n            return try await appState.getOsStatus(knownVersion: version).json()\n        case .debuggingArchive(let userFeedback):\n            let path: String\n            do {\n                path = try await createDebuggingArchive(appState: appState, userFeedback: userFeedback)\n            } catch {\n                logger.error(\"could not create debugging archive \\(error, privacy: .public)\")\n                throw errorCodeOther\n            }\n            return try path.json()\n        #if os(macOS)\n            case .emailDebugArchive, .shareDebugArchive, .purchaseSubscription, .restorePurchases, .associateAccount, .showOfferCodeRedemption, .resetOfferCodeRedemptionSuccess:\n                throw errorUnsupportedOnOS\n            case .revealItemInDir(let path):\n                NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: \"\")\n            case .registerAsLoginItem:\n                try registerAsLoginItem(appState: self.appState)\n            case .unregisterAsLoginItem:\n                try unregisterAsLoginItem(appState: self.appState)\n            case .checkForUpdates:\n                try? appState.updater.checkForUpdates()\n            case .installUpdate:\n                guard appState.updater.canCheckForUpdates else {\n                    throw errorCodeUpdaterInstall\n                }\n                appState.updater.showUpdaterIfNeeded()\n        #else\n            case .associateAccount:\n                try await appState.associateAccount()\n            case .purchaseSubscription:\n                let result = try await appState.purchaseSubscription()\n                return try result.json()\n            case .restorePurchases:\n                try await appState.storeKitModel.restorePurchases()\n            case .showOfferCodeRedemption:\n                DispatchQueue.main.async {\n                    self.appState.showOfferCodeRedemption = true\n                }\n            case .resetOfferCodeRedemptionSuccess:\n                _ = appState.osStatus.update { value in\n                    value.offerCodeRedemptionSuccess = false\n                }\n            case .emailDebugArchive(let path, let subject, let body):\n                try appState.emailDebugArchive(path: path, subject: subject, body: body)\n            case .shareDebugArchive(let path):\n                appState.shareFile(path: path)\n            case .revealItemInDir, .registerAsLoginItem, .unregisterAsLoginItem, .checkForUpdates, .installUpdate:\n                throw errorUnsupportedOnOS\n        #endif\n        }\n        return \"{}\"\n    }\n}\n"
  },
  {
    "path": "apple/client/extensions/NEVPNStatus.swift",
    "content": "import Foundation\nimport NetworkExtension\n\nextension NEVPNStatus: Encodable {\n    public func encode(to encoder: any Encoder) throws {\n        var container = encoder.singleValueContainer()\n        switch self {\n        case .invalid:\n            try container.encode(\"invalid\")\n        case .disconnected:\n            try container.encode(\"disconnected\")\n        case .connecting:\n            try container.encode(\"connecting\")\n        case .connected:\n            try container.encode(\"connected\")\n        case .reasserting:\n            try container.encode(\"reasserting\")\n        case .disconnecting:\n            try container.encode(\"disconnecting\")\n        @unknown default:\n            try container.encode(\"unknown\")\n        }\n    }\n}\n"
  },
  {
    "path": "apple/client/iOS/MailDelegate.swift",
    "content": "import MessageUI\nimport OSLog\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"Mail\")\n\nclass MailDelegate: NSObject, MFMailComposeViewControllerDelegate {\n    func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {\n        switch result {\n        case MFMailComposeResult.cancelled:\n            logger.debug(\"Cancelled mail\")\n        case MFMailComposeResult.saved:\n            logger.debug(\"Saved mail\")\n        case MFMailComposeResult.sent:\n            logger.info(\"Sent mail successfully\")\n        case MFMailComposeResult.failed:\n            logger.error(\"Failed to send mail: \\(error?.localizedDescription, privacy: .public)\")\n        default:\n            break\n        }\n        controller.dismiss(animated: true)\n    }\n}\n"
  },
  {
    "path": "apple/client/iOS/iOSClientApp.swift",
    "content": "import OSLog\nimport SwiftUI\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"App\")\n\n@main\nstruct iOSClientApp: App {\n    init() {\n        logger.debug(\"App init\")\n    }\n\n    @ObservedObject var startupModel = StartupModel.shared\n\n    var body: some Scene {\n        WindowGroup {\n            if let appState = self.startupModel.appState {\n                ContentView(appState: appState)\n                    .preferredColorScheme(self.startupModel.selectedAppearance.colorScheme)\n            } else {\n                StartupView()\n                    .preferredColorScheme(self.startupModel.selectedAppearance.colorScheme)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "apple/client/initNetworkExtension.swift",
    "content": "import Foundation\nimport NetworkExtension\nimport OSLog\nimport SystemExtensions\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"NetworkExtensionInit\")\n\nenum NetworkExtensionInitStatus {\n    case checking\n    case blockingBeforePermissionPopup\n    case blockingBeforeTunnelDisconnect\n    case enabling\n    case waitingForUserApproval\n\n    // Terminal states.\n    case failed(String)\n    case waitingForReboot\n}\n\nenum NetworkExtensionInitEvent {\n    case status(NetworkExtensionInitStatus)\n    case done\n}\n\nclass NetworkExtensionInit: NSObject {\n    var continuation: AsyncStream<NetworkExtensionInitEvent>.Continuation?\n    private var canceling = false\n    private var activationRequested = false\n    private let tunnelConnected: Bool\n\n    init(tunnelConnected: Bool) {\n        self.tunnelConnected = tunnelConnected\n    }\n\n    func start() -> AsyncStream<NetworkExtensionInitEvent> {\n        logger.log(\"Starting NetworkExtensionInit\")\n        return AsyncStream<NetworkExtensionInitEvent> { continuation in\n            self.continuation = continuation\n\n            self.update(.checking)\n            logger.log(\"Requesting system extension properties...\")\n            let request = OSSystemExtensionRequest.propertiesRequest(\n                forExtensionWithIdentifier: networkExtensionBundleID(),\n                queue: .main\n            )\n            request.delegate = self\n            OSSystemExtensionManager.shared.submitRequest(request)\n        }\n    }\n\n    func continueAfterPriming() {\n        if self.activationRequested {\n            logger.error(\"activation requested multiple times\")\n            return\n        }\n        self.activationRequested = true\n        logger.log(\"Requesting system extension activation/replacement...\")\n        let request = OSSystemExtensionRequest.activationRequest(\n            forExtensionWithIdentifier: networkExtensionBundleID(),\n            queue: .main\n        )\n        request.delegate = self\n        OSSystemExtensionManager.shared.submitRequest(request)\n    }\n\n    private func update(_ status: NetworkExtensionInitStatus) {\n        logger.log(\"NetworkExtensionInit state: \\(debugFormat(status), privacy: .public)\")\n        if let cont = self.continuation {\n            cont.yield(.status(status))\n        }\n    }\n\n    private func done() {\n        logger.log(\"NetworkExtensionInit done\")\n        if let cont = self.continuation {\n            cont.yield(.done)\n            cont.finish()\n        }\n    }\n}\n\nextension NetworkExtensionInit: OSSystemExtensionRequestDelegate {\n    func request(\n        _ request: OSSystemExtensionRequest,\n        foundProperties sysExts: [OSSystemExtensionProperties]\n    ) {\n        // This method will be called after we submit a `OSSystemExtensionRequest.propertiesRequest`, which happens automatically in `start()`\n        logger.debug(\"Step 1: OSSystemExtensionRequestDelegate.request(... foundProperties ...) called\")\n        let buildVersion = buildVersion()\n        logger.debug(\"matching system extension bundle version against app build version \\(buildVersion, privacy: .public)\")\n        var matchingBundleIdAlreadyEnabled = false\n        var matchingBuildVersionAlreadyEnabled = false\n        for sysExt in sysExts {\n            logger.debug(\"found system extensions \\(sysExt.bundleIdentifier) \\(sysExt.bundleShortVersion) \\(sysExt.bundleVersion), enabled: \\(sysExt.isEnabled), awaitingUserApproval: \\(sysExt.isAwaitingUserApproval)\")\n            if sysExt.bundleIdentifier == networkExtensionBundleID() && sysExt.isEnabled {\n                matchingBundleIdAlreadyEnabled = true\n                if sysExt.bundleVersion == buildVersion {\n                    matchingBuildVersionAlreadyEnabled = true\n                }\n            }\n        }\n\n        if matchingBuildVersionAlreadyEnabled {\n            logger.info(\"found enabled system extension with matching build version, not expecting a replacement, requesting activation\")\n            self.continueAfterPriming()\n        } else if self.tunnelConnected {\n            logger.info(\"found connected tunnel, but the build version of the enabled system extension doesn't match, expecting tunnel disconnect, waiting for external activation trigger\")\n            self.update(.blockingBeforeTunnelDisconnect)\n        } else if matchingBundleIdAlreadyEnabled {\n            logger.info(\"found enabled system extension, tunnel not connected, not expecting to get blocked, requesting activation\")\n            self.continueAfterPriming()\n        } else {\n            logger.info(\"found no enabled system extension, expecting to get blocked, waiting for external activation trigger\")\n            self.update(.blockingBeforePermissionPopup)\n        }\n    }\n\n    func request(\n        _ request: OSSystemExtensionRequest,\n        actionForReplacingExtension oldExt: OSSystemExtensionProperties,\n        withExtension newExt: OSSystemExtensionProperties\n    ) -> OSSystemExtensionRequest.ReplacementAction {\n        // This method will be called after we submit a `OSSystemExtensionRequest.activationRequest`, which is either:\n        // - automatcially triggered if we don't expect to get blocked by the OS\n        // - triggered by `Self.continueAfterPriming()` being called from the outside, so the caller can prepare the user for the popup and approval steps or tunnel disconnect\n        logger.debug(\"Step 2: OSSystemExtensionRequestDelegate.request(... actionForReplacingExtension ...) called\")\n\n        var replacementRequired = false\n\n        let matchingBundleId = oldExt.bundleIdentifier == newExt.bundleIdentifier\n        logger.debug(\"bundleIdentifier matches? \\(matchingBundleId, privacy: .public) (\\(newExt.bundleIdentifier))\")\n        if !matchingBundleId {\n            logger.error(\"Unexpected bundleIdentifier old: \\(oldExt.bundleIdentifier, privacy: .public)\")\n            replacementRequired = true\n        }\n\n        let matchingShortVersion = oldExt.bundleShortVersion == newExt.bundleShortVersion\n        logger.debug(\"bundleShortVersion maches? \\(matchingShortVersion, privacy: .public) (\\(newExt.bundleShortVersion))\")\n        if !matchingShortVersion {\n            replacementRequired = true\n            logger.debug(\"old.bundleShortVersion: \\(oldExt.bundleShortVersion, privacy: .public)\")\n        }\n\n        let matchingVersion = oldExt.bundleVersion == newExt.bundleVersion\n        logger.debug(\"bundleVersion matches? \\(matchingVersion, privacy: .public) (\\(newExt.bundleVersion))\")\n        if !matchingVersion {\n            replacementRequired = true\n            logger.debug(\"old.bundleVersion: \\(oldExt.bundleVersion, privacy: .public)\")\n        }\n\n        logger.log(\"System extension replacement required? \\(replacementRequired)\")\n        if replacementRequired {\n            self.update(.enabling)\n            return .replace\n        } else {\n            self.canceling = true\n            return .cancel\n        }\n    }\n\n    func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {\n        logger.debug(\"Step 3: OSSystemExtensionRequestDelegate.requestNeedsUserApproval(...) called\")\n        self.update(.waitingForUserApproval)\n    }\n\n    func request(\n        _ request: OSSystemExtensionRequest,\n        didFinishWithResult result: OSSystemExtensionRequest.Result\n    ) {\n        logger.debug(\"Step 4: OSSystemExtensionRequestDelegate.request(... didFinishWithResult ...) called\")\n        switch result {\n        case .completed:\n            self.done()\n        case .willCompleteAfterReboot:\n            self.update(.waitingForReboot)\n        @unknown default:\n            logger.error(\"sys ext request unknown result variant: \\(debugFormat(result), privacy: .public)\")\n            self.update(.failed(\"Unknown activation result: \\(result.rawValue)\"))\n        }\n    }\n\n    func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) {\n        logger.error(\"OSSystemExtensionRequestDelegate.request(... didFailWithError ...) called: \\(error.localizedDescription, privacy: .public)\")\n\n        switch error {\n        case let error as OSSystemExtensionError:\n            switch OSSystemExtensionError.Code(rawValue: error.errorCode) {\n            case .requestCanceled:\n                if self.canceling {\n                    logger.info(\"System extension installation skipped.\")\n                    // This should only happen for systems with system extension dev mode enabled.\n                    self.done()\n                } else {\n                    self.update(.failed(\"Unexpected system extension install cancellation.\"))\n                }\n            case nil:\n                self.update(.failed(\"Invalid error code: \\(error.errorCode)\"))\n            default:\n                self.update(.failed(error.localizedDescription))\n            }\n        default:\n            self.update(.failed(error.localizedDescription))\n        }\n    }\n}\n"
  },
  {
    "path": "apple/client/macOS/CheckForUpdatesView.swift",
    "content": "import Sparkle\nimport SwiftUI\n\n/**\n This is the view for the Check for Updates menu item\n\n Note this intermediate view is necessary for the disabled state on the menu item to work properly before Monterey.\n See https://stackoverflow.com/questions/68553092/menu-not-updating-swiftui-bug for more info.\n **/\nstruct CheckForUpdatesView: View {\n    @State var canCheckForUpdates: Bool = false\n\n    private let updater: SparkleUpdater\n\n    init(updater: SparkleUpdater) {\n        self.updater = updater\n    }\n\n    var body: some View {\n        Button(\"Check for Updates…\") {\n            self.updater.showUpdaterIfNeeded()\n        }\n        .onReceive(self.updater.canCheckForUpdatesPublisher) { canCheckForUpdates in\n            self.canCheckForUpdates = canCheckForUpdates\n        }\n        .disabled(!self.canCheckForUpdates)\n    }\n}\n"
  },
  {
    "path": "apple/client/macOS/ClientApp.swift",
    "content": "import Combine\nimport Network\nimport NetworkExtension\nimport OSLog\nimport Sparkle\nimport SwiftUI\nimport SystemExtensions\nimport UserNotifications\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"App\")\n\n@main\nclass AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, UNUserNotificationCenterDelegate, ObservableObject {\n    var primaryWindow: NSWindow!\n    var statusItemManager: StatusItemManager?\n    var updaterMenuItemSubscription: AnyCancellable?\n    var updater: SparkleUpdater?\n    var statusItem: NSStatusItem?\n    static let FrameAutoSaveName = \"root-view\"\n\n    static func main() {\n        logger.debug(\"App init\")\n        // Auto-exit if app is already running\n        // Note that this is already rare, but can happen if an installed app is running before running a build from XCode\n        if NSWorkspace.shared.runningApplications.filter({\n            $0.bundleIdentifier == Bundle.main.bundleIdentifier\n        }).count > 1 {\n            logger.info(\"App already running.\")\n            NSApp.terminate(nil)\n            return\n        }\n\n        let app = NSApplication.shared\n        let delegate = AppDelegate()\n        app.delegate = delegate\n        app.run()\n    }\n\n    func applicationWillFinishLaunching(_ notification: Notification) {\n        UNUserNotificationCenter.current().delegate = self\n    }\n\n    func applicationDidFinishLaunching(_ notification: Notification) {\n        // https://stackoverflow.com/a/19890943/7732434\n        let event = NSAppleEventManager.shared().currentAppleEvent\n        let launchedAsLoginItem = (event?.eventID == kAEOpenApplication && event?.paramDescriptor(forKeyword: keyAEPropData)?.enumCodeValue == keyAELaunchedAsLogInItem)\n        logger.log(\"launched as login item: \\(launchedAsLoginItem)\")\n        if launchedAsLoginItem {\n            // Otherwise, the app icon appears in the dock with a black dot with no window\n            NSApp.setActivationPolicy(.accessory)\n        }\n        self.createPrimaryWindow(launchedAsLoginItem: launchedAsLoginItem)\n        self.setupMainMenu()\n        self.statusItemManager = StatusItemManager()\n    }\n\n    @objc func quitApp() {\n        NSApp.terminate(nil)\n    }\n\n    func openPrimaryWindow() {\n        self.primaryWindow.makeKeyAndOrderFront(nil)\n        self.primaryWindow.orderFrontRegardless()\n    }\n\n    // According to NSWorkspace.shared.menuBarOwningApplication?.localizedName and appWithFocus?.ownsMenuBar\n    // obscura owns the menubar and the app with focus owns the menu bar\n    // implication: owning the menu bar does not guarantee it shows up...\n    // The menubar is blank when Obscura is opened up after switching displays to a monitor (in clamshell)\n    // To reproduce the bug, comment out the menu recreation line and follow these instructions.\n    // Note that LocalSend also has the same bug, but it cannot even be fixed by switching focus.\n    // 1. Close Obscura window (keep it running in status menu)\n    // 2. Close macbook lid\n    // 3. Connect it to a monitor\n    // 4. Open Obscura Manager\n    func showPrimaryWindow() {\n        NSApp.setActivationPolicy(.regular)\n        self.openPrimaryWindow()\n        // Fix for blank menubar when switching displays (clamshell mode):\n        // Recreate the main menu to ensure it displays correctly\n        self.setupMainMenu()\n        focusApp()\n    }\n\n    // Apple added this method to the template to address a process injection vulnerability related to saving/restoring state\n    // https://sector7.computest.nl/post/2022-08-process-injection-breaking-all-macos-security-layers-with-a-single-vulnerability/\n    // https://stackoverflow.com/a/77320845/7732434\n    // Without this method, log warnings will show up, and the app is apparently vulnerable to compromising SIP\n    func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {\n        return true\n    }\n\n    private func createPrimaryWindow(launchedAsLoginItem: Bool) {\n        let window = NSWindow(\n            contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),\n            styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],\n            backing: .buffered,\n            defer: launchedAsLoginItem\n        )\n        // must be done before calling `setFrameAutosaveName`\n        window.delegate = self\n        window.toolbarStyle = .unified\n        window.tabbingMode = .disallowed\n\n        // the following enforces view-specific constraints\n        let contentView = MainWindowContentView().environmentObject(self)\n        let hostingVC = NSHostingController(rootView: contentView)\n        hostingVC.sizingOptions = [.minSize]\n        window.contentViewController = hostingVC\n        // try to restore saved frame\n        if !window.setFrameUsingName(Self.FrameAutoSaveName) {\n            // without this, the window will off centre on first launch\n            window.updateConstraintsIfNeeded()\n            window.center()\n        }\n        window.setFrameAutosaveName(Self.FrameAutoSaveName)\n        // maintain previous swift-ui Window behaviour\n        window.isReleasedWhenClosed = false\n        self.primaryWindow = window\n\n        if !launchedAsLoginItem {\n            self.showPrimaryWindow()\n        }\n    }\n\n    func setupMainMenu() {\n        let mainMenu = NSMenu()\n\n        let appMenuItem = NSMenuItem()\n        let appMenu = NSMenu()\n        appMenuItem.submenu = appMenu\n\n        let aboutItem = NSMenuItem(title: \"About Obscura\", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: \"\")\n\n        let servicesItem = NSMenuItem(title: \"Services\", action: nil, keyEquivalent: \"\")\n        let servicesMenu = NSMenu()\n        servicesItem.submenu = servicesMenu\n        NSApp.servicesMenu = servicesMenu\n\n        let hideItem = NSMenuItem(title: \"Hide Obscura VPN\", action: #selector(NSApplication.hide(_:)), keyEquivalent: \"h\")\n\n        let hideOthersItem = NSMenuItem(title: \"Hide Others\", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: \"h\")\n        hideOthersItem.keyEquivalentModifierMask = [.command, .option]\n\n        let showAllItem = NSMenuItem(title: \"Show All\", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: \"\")\n\n        let closeWindowItem = NSMenuItem(title: \"Close Window\", action: #selector(NSWindow.performClose(_:)), keyEquivalent: \"q\")\n\n        appMenu.items = [\n            aboutItem,\n            NSMenuItem.separator(),\n            // Check for Updates will be inserted here when updater is available\n            servicesItem,\n            NSMenuItem.separator(),\n            hideItem,\n            hideOthersItem,\n            showAllItem,\n            NSMenuItem.separator(),\n            closeWindowItem,\n        ]\n\n        // Check for Updates menu item - will be added when updater is available\n        self.updaterMenuItemSubscription = StartupModel.shared.$appState\n            .compactMap { $0?.updater }\n            .first()\n            .receive(on: DispatchQueue.main)\n            .sink { [weak self] updater in\n                self?.updater = updater\n                self?.addCheckForUpdatesMenuItem(to: appMenu)\n            }\n\n        let editMenuItem = NSMenuItem()\n        let editMenu = NSMenu(title: \"Edit\")\n        editMenuItem.submenu = editMenu\n\n        editMenu.items = [\n            NSMenuItem(title: \"Undo\", action: Selector((\"undo:\")), keyEquivalent: \"z\"),\n            NSMenuItem(title: \"Redo\", action: Selector((\"redo:\")), keyEquivalent: \"Z\"),\n            NSMenuItem.separator(),\n            NSMenuItem(title: \"Cut\", action: #selector(NSText.cut(_:)), keyEquivalent: \"x\"),\n            NSMenuItem(title: \"Copy\", action: #selector(NSText.copy(_:)), keyEquivalent: \"c\"),\n            NSMenuItem(title: \"Paste\", action: #selector(NSText.paste(_:)), keyEquivalent: \"v\"),\n            NSMenuItem(title: \"Delete\", action: #selector(NSText.delete(_:)), keyEquivalent: \"\"),\n            NSMenuItem(title: \"Select All\", action: #selector(NSText.selectAll(_:)), keyEquivalent: \"a\"),\n            NSMenuItem.separator(),\n        ]\n\n        let viewMenuItem = NSMenuItem()\n        let viewMenu = NSMenu(title: \"View\")\n        viewMenuItem.submenu = viewMenu\n\n        let fullScreenItem = NSMenuItem(title: \"Enter Full Screen\", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: \"f\")\n        fullScreenItem.keyEquivalentModifierMask = [.function]\n\n        viewMenu.items = [\n            fullScreenItem,\n        ]\n\n        let windowMenuItem = NSMenuItem()\n        let windowMenu = NSMenu(title: \"Window\")\n        NSApp.windowsMenu = windowMenu\n        windowMenuItem.submenu = windowMenu\n\n        let closeItem = NSMenuItem(title: \"Close\", action: #selector(NSWindow.performClose(_:)), keyEquivalent: \"w\")\n        let closeAllItem = NSMenuItem(title: \"Close All\", action: Selector((\"closeAll:\")), keyEquivalent: \"w\")\n        closeAllItem.keyEquivalentModifierMask = [.command, .option]\n        closeAllItem.isAlternate = true\n\n        let minimizeItem = NSMenuItem(title: \"Minimize\", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: \"m\")\n        let minimizeAllItem = NSMenuItem(title: \"Minimize All\", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: \"m\")\n        minimizeAllItem.keyEquivalentModifierMask = [.command, .option]\n        minimizeAllItem.isAlternate = true\n\n        let zoomItem = NSMenuItem(title: \"Zoom\", action: #selector(NSWindow.zoom(_:)), keyEquivalent: \"\")\n        let zoomAllItem = NSMenuItem(title: \"Zoom All\", action: #selector(NSWindow.zoom(_:)), keyEquivalent: \"\")\n        zoomAllItem.keyEquivalentModifierMask = [.option]\n        zoomAllItem.isAlternate = true\n\n        // https://github.com/avaidyam/Parrot/tree/6cf7ba419176c386ed8f18e838690a7272fe57ee/Parrot\n        windowMenu.items = [\n            closeItem,\n            closeAllItem,\n            minimizeItem,\n            minimizeAllItem,\n            zoomItem,\n            zoomAllItem,\n            NSMenuItem.separator(),\n            NSMenuItem(\n                title: \"Bring All to Front\", action: #selector(NSApplication.arrangeInFront(_:)),\n                keyEquivalent: \"\"\n            ),\n        ]\n\n        let helpMenuItem = NSMenuItem()\n        let helpMenu = NSMenu(title: \"Help\")\n        helpMenuItem.submenu = helpMenu\n        NSApp.helpMenu = helpMenu\n\n        mainMenu.items = [\n            appMenuItem,\n            editMenuItem,\n            viewMenuItem,\n            windowMenuItem,\n            helpMenuItem,\n        ]\n\n        NSApp.mainMenu = mainMenu\n    }\n\n    private func addCheckForUpdatesMenuItem(to menu: NSMenu) {\n        let checkForUpdatesItem = NSMenuItem(\n            title: \"Check for Updates…\", action: #selector(self.checkForUpdates), keyEquivalent: \"\"\n        )\n        checkForUpdatesItem.target = self\n        menu.insertItem(checkForUpdatesItem, at: 2)\n        menu.insertItem(NSMenuItem.separator(), at: 3)\n    }\n\n    @objc private func checkForUpdates() {\n        self.updater?.showUpdaterIfNeeded()\n    }\n\n    // We do not want to depend on applicationShouldTerminateAfterLastWindowClosed,\n    // because it can be triggered for a variety of reasons versus triggering for exactly what we want\n\n    // Based on Carl's initial debugging, it was determined:\n    // On macOS, when a menu item is highlighted, there is a callback to unhighlight the menu item.\n    // If the app is set to accessory before the callback runs, the callback is unable to unhighlight the menu item (for whatever reason).\n    // This results in a pre-highlight/stuck state.\n    // Recreating the main menu upon opening the window alleviates the need to carefully wait to set the activation policy.\n    func windowWillClose(_ notification: Notification) {\n        NSApp.setActivationPolicy(.accessory)\n    }\n\n    func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {\n        logger.debug(\"from applicationShouldHandleReopen. hasVisibleWindows = \\(hasVisibleWindows)\")\n\n        if NSApp.activationPolicy() == .regular {\n            self.openPrimaryWindow()\n            return true\n        }\n\n        NSApp.setActivationPolicy(.regular)\n\n        if #available(macOS 14.0, *) {\n            self.openPrimaryWindow()\n            return true\n        }\n\n        // On macos ventura or earlier, without this workaround, if the user\n        // reopens the App using Finder while the App is already running, the\n        // App Menu (the left side) becomes completely frozen and unusable\n        // (even the  one)\n        /// more info here:\n        // https://linear.app/soveng/issue/OBS-175/no-obscura-vpn-in-menu-bar-dock-or-app-switcher-when-application-is#comment-2ecf3e57\n        NSRunningApplication.runningApplications(withBundleIdentifier: \"com.apple.systemuiserver\")\n            .first!.activate(options: [])\n        self.openPrimaryWindow()\n        NSApp.activate(ignoringOtherApps: true)\n\n        return true\n    }\n\n    func userNotificationCenter(\n        _ center: UNUserNotificationCenter,\n        willPresent notification: UNNotification\n    ) async -> UNNotificationPresentationOptions {\n        // Always show notifications, even if we have focus.\n        // Right now we use notifications as the only feedback for some actions.\n        // This is probably not ideal UX but until we can improve that ensure that they appear on screen.\n        return .banner\n    }\n\n    var openUrlCallback: ((_ url: URL) -> Void)?\n\n    func application(\n        _ application: NSApplication,\n        open urls: [URL]\n    ) {\n        logger.log(\"AppDelegate \\(#function) called with URLs: \\(urls)\")\n        guard let openUrlCallback = self.openUrlCallback else {\n            logger.warning(\"AppDelegate has NO registered openUrlCallback\")\n            return\n        }\n\n        logger.log(\"AppDelegate: Calling registered openUrlCallback\")\n        for url in urls {\n            openUrlCallback(url)\n        }\n    }\n}\n\nstruct MainWindowContentView: View {\n    @ObservedObject var startupModel = StartupModel.shared\n    @EnvironmentObject var appDelegate: AppDelegate\n\n    var body: some View {\n        Group {\n            if let appState = self.startupModel.appState {\n                ContentView(appState: appState)\n                    .frame(minWidth: 700, minHeight: 525)\n            } else {\n                StartupView()\n                    .frame(minWidth: 800, minHeight: 525)\n            }\n        }\n        .preferredColorScheme(self.startupModel.selectedAppearance.colorScheme)\n    }\n}\n\nfunc focusApp() {\n    // When opening the app from status menu via\n    // the URLs, NSApp.activate() does not cause troubles\n    // regarding focus. However, just to be safe regarding edge cases and users, we want to continue\n    // using `ignoringOtherApps: true` until it is removed\n    if #available(macOS 26.4, *) {\n        NSApp.activate()\n    } else {\n        NSApp.activate(ignoringOtherApps: true)\n    }\n}\n"
  },
  {
    "path": "apple/client/macOS/InstallSystemExtensionView.swift",
    "content": "import SwiftUI\n\nlet macOS14DemoVideo = Bundle.main.url(forResource: \"videos/macOS 14 System Extension Demo\", withExtension: \"mov\")!\nlet macOS15DemoVideo = Bundle.main.url(forResource: \"videos/macOS 15 System Extension Demo\", withExtension: \"mov\")!\n\nstruct InstallSystemExtensionView: View {\n    @ObservedObject var startupModel: StartupModel\n    var subtext: String\n\n    @Environment(\\.openURL) private var openURL\n    var neInit: NetworkExtensionInit? = nil\n\n    var body: some View {\n        ZStack {\n            VStack {\n                Spacer()\n                Image(\"DecoPrimer\")\n                    .resizable()\n                    .scaledToFit()\n                    .frame(minWidth: 0, minHeight: 50)\n            }\n            VStack {\n                Spacer()\n                    .frame(minHeight: 20)\n                HStack {\n                    Spacer()\n                    VStack(alignment: .leading, spacing: 10) {\n                        Image(\"EmotePrimer\")\n                        Text(\"Allow System Extension\")\n                            .font(.title)\n                        Text(self.subtext)\n                            .font(.body)\n                            .multilineTextAlignment(.leading)\n                            .fixedSize(horizontal: false, vertical: true)\n                        if let neInit = self.neInit {\n                            Button(action: neInit.continueAfterPriming) {\n                                Text(\"Install Now\")\n                                    .font(.headline)\n                                    .frame(width: 300)\n                            }\n                            .buttonStyle(NoFadeButtonStyle())\n                        } else {\n                            Button(action: {\n                                if #available(macOS 15, *) {\n                                    self.openURL(URLs.ExtensionSettings)\n                                } else {\n                                    self.openURL(URLs.PrivacySecurityExtensionSettings)\n                                }\n                            }) {\n                                if #available(macOS 15, *) {\n                                    Text(\"Open Login Items & Extensions Settings\")\n                                        .font(.headline)\n                                        .frame(width: 300)\n                                } else {\n                                    Text(\"Open Privacy & Security Settings\")\n                                        .font(.headline)\n                                        .frame(width: 300)\n                                }\n                            }\n                            .buttonStyle(NoFadeButtonStyle())\n                        }\n                    }\n                    .frame(width: 350)\n                    .padding(.leading, 50)\n                    Spacer()\n                    if #available(macOS 15, *) {\n                        LoopingVideoPlayer(url: macOS15DemoVideo, width: 360, height: 410)\n                    } else {\n                        LoopingVideoPlayer(url: macOS14DemoVideo, width: 360, height: 410)\n                    }\n                    Spacer()\n                }\n                Spacer()\n                    .frame(minHeight: 50)\n            }\n            VStack(alignment: .trailing) {\n                Spacer()\n                HStack(alignment: .bottom) {\n                    Spacer()\n                    if #available(macOS 14.0, *) {\n                        HelpLink(destination: URLs.SystemExtensionHelp)\n                            .padding(.bottom, 2)\n                    } else {\n                        Button {\n                            self.openURL(URLs.SystemExtensionHelp)\n                        } label: {\n                            Image(systemName: \"questionmark.circle.fill\")\n                                .font(.system(size: 19))\n                                .foregroundStyle(.white, .gray.opacity(0.4))\n                        }\n                        .buttonStyle(.plain)\n                        .padding(.bottom, 2)\n                        .padding(.trailing, 2)\n                    }\n                }\n                .padding()\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "apple/client/macOS/RegisterLoginItemView.swift",
    "content": "import SwiftUI\n\nstruct RegisterLoginItemView: View {\n    var value: ObservableValue<Bool>\n    @Environment(\\.openURL) private var openURL\n    @State private var isRegistering = false\n\n    var body: some View {\n        Image(systemName: \"desktopcomputer.and.arrow.down\")\n            .font(.system(size: 48))\n            .symbolRenderingMode(.palette)\n            .foregroundStyle(.white, .blue)\n            .padding()\n            .buttonStyle(.plain)\n\n        Text(\"Open at Login\")\n            .font(.title)\n\n        Text(\"Do you want Obscura VPN to open automatically when you log in?\")\n            .font(.body)\n            .multilineTextAlignment(.center)\n            .padding()\n\n        if self.isRegistering {\n            ProgressView()\n        } else {\n            Button(action: { self.value.publish(true) }) {\n                Text(\"Yes\")\n                    .font(.headline)\n                    .frame(width: 300)\n            }\n            .buttonStyle(NoFadeButtonStyle())\n\n            Button(action: { self.value.publish(false) }) {\n                Text(\"No\")\n                    .frame(width: 300)\n            }\n            .buttonStyle(NoFadeButtonStyle(backgroundColor: Color(.darkGray)))\n        }\n    }\n}\n"
  },
  {
    "path": "apple/client/macOS/SparkleUpdater.swift",
    "content": "import Combine\nimport Sparkle\n\nclass SparkleUpdater {\n    /**\n     Sparkle updater.\n\n     - seealso: [How to integrate the Sparkle framework into a SwiftUI app for MacOS](https://medium.com/@matteospada.m/how-to-integrate-the-sparkle-framework-into-a-swiftui-app-for-macos-98ca029f83f7)\n     - seealso: [Sparkle: Basic Setup](https://sparkle-project.org/documentation/)\n     - seealso: [Sparkle: Create an Updater in SwiftUI](https://sparkle-project.org/documentation/programmatic-setup/#create-an-updater-in-swiftui)\n     */\n    private let sparkleUpdater: SPUUpdater\n    private let updaterController: SPUStandardUpdaterController\n\n    init(osStatus: WatchableValue<OsStatus>) {\n        self.updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)\n        self.sparkleUpdater = UpdaterDriver.createUpdater(osStatus: osStatus)\n    }\n\n    var sessionInProgress: Bool {\n        return self.sparkleUpdater.sessionInProgress\n    }\n\n    var canCheckForUpdates: Bool {\n        return self.sparkleUpdater.canCheckForUpdates\n    }\n\n    func checkForUpdates() throws {\n        if self.sessionInProgress {\n            return\n        }\n        guard self.canCheckForUpdates else {\n            throw errorCodeUpdaterCheck\n        }\n        self.sparkleUpdater.checkForUpdates()\n    }\n\n    func showUpdaterIfNeeded() {\n        self.updaterController.checkForUpdates(nil)\n    }\n\n    var canCheckForUpdatesPublisher: AnyPublisher<Bool, Never> {\n        self.sparkleUpdater.publisher(for: \\.canCheckForUpdates).eraseToAnyPublisher()\n    }\n}\n"
  },
  {
    "path": "apple/client/macOS/UpdateSystemExtensionView.swift",
    "content": "import SwiftUI\n\nstruct UpdateSystemExtensionView: View {\n    @ObservedObject var startupModel: StartupModel\n    var subtext: String\n\n    @Environment(\\.openURL) private var openURL\n    var neInit: NetworkExtensionInit\n\n    var body: some View {\n        Spacer()\n            .frame(height: 60)\n        // extensions symbol for macOS <= 15\n        // coincidentally used for the network extensions symbol on macOS 15\n        Image(systemName: \"puzzlepiece.extension.fill\")\n            .font(.system(size: 48))\n            .padding()\n        Text(\"System Extension Update Required\")\n            .font(.title)\n        Text(self.subtext)\n            .font(.body)\n            .multilineTextAlignment(.center)\n            .fixedSize(horizontal: false, vertical: true)\n            .frame(width: 350)\n            .padding()\n\n        Button(action: self.neInit.continueAfterPriming) {\n            Text(\"Disconnect and Update\")\n                .font(.headline)\n                .frame(width: 300)\n        }\n        .buttonStyle(NoFadeButtonStyle())\n    }\n}\n"
  },
  {
    "path": "apple/client/macOS/UpdaterDriver.swift",
    "content": "import OSLog\nimport Sparkle\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"UpdaterDriver\")\n\nclass UpdaterDriver: NSObject, SPUUserDriver {\n    private var osStatus: WatchableValue<OsStatus>\n\n    static func createUpdater(osStatus: WatchableValue<OsStatus>) -> SPUUpdater {\n        let updater = SPUUpdater(hostBundle: Bundle.main, applicationBundle: Bundle.main, userDriver: UpdaterDriver(osStatus: osStatus), delegate: nil)\n        do {\n            try updater.start()\n        } catch {\n            logger.error(\"Error starting custom updater: \\(error, privacy: .public)\")\n        }\n        return updater\n    }\n\n    init(osStatus: WatchableValue<OsStatus>) {\n        self.osStatus = osStatus\n        super.init()\n    }\n\n    private func updateOsStatus(updaterStatus: UpdaterStatus) {\n        logger.info(\"New osStatus.updaterStatus \\(updaterStatus, privacy: .public))\")\n        _ = self.osStatus.update { value in\n            value.updaterStatus = updaterStatus\n            value.version = UUID()\n        }\n    }\n\n    func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {\n        let status = UpdaterStatus(type: .initiated, appcast: nil, error: nil, errorCode: nil)\n        self.updateOsStatus(updaterStatus: status)\n    }\n\n    func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState) async -> SPUUserUpdateChoice {\n        let appcast = AppcastSummary(\n            date: appcastItem.dateString ?? \"\",\n            description: appcastItem.itemDescription ?? \"\",\n            version: appcastItem.displayVersionString,\n            minSystemVersionOk: appcastItem.minimumOperatingSystemVersionIsOK\n        )\n        let status = UpdaterStatus(type: .available, appcast: appcast, error: nil, errorCode: nil)\n        self.updateOsStatus(updaterStatus: status)\n        // don't want to install it\n        return .dismiss\n    }\n\n    func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) {\n        let appcastItem = (error as NSError).userInfo[SPULatestAppcastItemFoundKey] as? SUAppcastItem\n        let notFoundReason = (error as NSError).userInfo[SPUNoUpdateFoundReasonKey] as? Int32\n\n        let appcast = appcastItem.map { item in\n            AppcastSummary(\n                date: item.dateString ?? \"\",\n                description: item.itemDescription ?? \"\",\n                version: item.displayVersionString,\n                minSystemVersionOk: item.minimumOperatingSystemVersionIsOK\n            )\n        }\n\n        let status = UpdaterStatus(type: .notFound, appcast: appcast, error: error.localizedDescription, errorCode: notFoundReason)\n        self.updateOsStatus(updaterStatus: status)\n        acknowledgement()\n    }\n\n    func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) {\n        let status = UpdaterStatus(type: .error, appcast: nil, error: error.localizedDescription, errorCode: nil)\n        self.updateOsStatus(updaterStatus: status)\n        acknowledgement()\n    }\n\n    func show(_ request: SPUUpdatePermissionRequest) async -> SUUpdatePermissionResponse {\n        return SUUpdatePermissionResponse(automaticUpdateChecks: false, sendSystemProfile: false)\n    }\n\n    func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {}\n\n    func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) {}\n\n    func showDownloadInitiated(cancellation: @escaping () -> Void) {}\n\n    func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {}\n\n    func showDownloadDidReceiveData(ofLength length: UInt64) {}\n\n    func showDownloadDidStartExtractingUpdate() {}\n\n    func showExtractionReceivedProgress(_ progress: Double) {}\n\n    func showReadyToInstallAndRelaunch() async -> SPUUserUpdateChoice {\n        return .install\n    }\n\n    func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {}\n\n    func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {\n        acknowledgement()\n    }\n\n    func showUpdateInFocus() {}\n\n    func dismissUpdateInstallation() {}\n}\n"
  },
  {
    "path": "apple/client/startup.swift",
    "content": "import AVKit\nimport NetworkExtension\nimport OSLog\nimport SwiftUI\nimport UserNotifications\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"startup\")\n\nstruct StartupView: View {\n    @StateObject var model = StartupModel.shared\n    @Environment(\\.openURL) private var openURL\n\n    var body: some View {\n        VStack {\n            switch self.model.status {\n            case .initial:\n                AppIcon()\n                Text(\"Starting Obscura\")\n            #if os(macOS)\n                case .networkExtensionInit(_, .checking):\n                    VpnChecksView(subtext: \"Checking Network Extension\")\n                case .networkExtensionInit(_, .enabling):\n                    VpnChecksView(subtext: \"Enabling Network Extension\")\n                case .networkExtensionInit(_, .waitingForReboot):\n                    Text(\"Reboot Required\")\n                case .networkExtensionInit(let neInit, .blockingBeforePermissionPopup):\n                    InstallSystemExtensionView(startupModel: self.model, subtext: \"Please allow Obscura VPN's network extension to be installed in System Settings to extend the networking features of your Mac.\", neInit: neInit)\n                case .networkExtensionInit(let neInit, .blockingBeforeTunnelDisconnect):\n                    UpdateSystemExtensionView(startupModel: self.model, subtext: \"An updated version of Obscura VPN's network extension is required.\", neInit: neInit)\n                case .networkExtensionInit(_, .waitingForUserApproval):\n                    InstallSystemExtensionView(startupModel: self.model, subtext: \"Please allow Obscura VPN's network extension to be installed in System Settings to extend the networking features of your Mac.\")\n                case .networkExtensionInit(_, .failed(let error)):\n                    InstallSystemExtensionView(startupModel: self.model, subtext: \"Could not start the network extension. \\(error). Please restart your Mac or contact support for help.\")\n            #endif\n            case .tunnelProviderInit(_, .checking):\n                VpnChecksView(subtext: \"Checking Tunnel Provider\")\n            case .tunnelProviderInit(let tpInit, .blockingBeforePermissionPopup):\n                VpnConfigurationView(startupModel: self.model, subtext: \"This configuration is required for Obscura VPN to anonymize your network traffic.\", tpInit: tpInit)\n            case .tunnelProviderInit(_, .waitingForUserPermissionApproval):\n                VpnConfigurationView(startupModel: self.model, subtext: \"For Obscura VPN to add itself as a VPN to your system, please click \\\"Allow\\\" in the request for permission. If you are currently connected to a VPN, this will disconnect it.\")\n            case .tunnelProviderInit(let tpInit, .permissionDenied):\n                VpnConfigurationView(startupModel: self.model, subtext: \"Permission was denied. Click below to request permission again.\", tpInit: tpInit, isError: true)\n            case .tunnelProviderInit(_, .configuring):\n                VpnChecksView(subtext: \"Configuring Tunnel Provider\")\n            case .tunnelProviderInit(let tpInit, .waitingForUserStopOtherTunnelApproval(let manager)):\n                VpnEnableView(manager: manager, subtext: \"Obscura VPN was disabled by another VPN. Click below to enable it. If you are currently connected to a VPN, this will disconnect it.\", tpInit: tpInit)\n            case .tunnelProviderInit(_, .testingCommunication):\n                VpnChecksView(subtext: \"Testing Tunnel Provider communication\")\n            case .tunnelProviderInit(_, .unexpectedError):\n                VpnFailedView()\n            #if os(macOS)\n                case .askToRegisterLoginItem(let value):\n                    RegisterLoginItemView(value: value)\n            #endif\n            case .ready:\n                AppIcon()\n                Text(\"Ready to launch\")\n            }\n        }\n    }\n}\n\nfunc AppIcon() -> some View {\n    return Image(uxImage: UXImage(named: \"AppIcon\") ?? UXImage())\n        .resizable()\n        .frame(width: 64, height: 64)\n}\n\nstruct VpnChecksView: View {\n    var manager: NETunnelProviderManager?\n    var subtext: String\n\n    var body: some View {\n        VStack(spacing: 20) {\n            AppIcon()\n            ProgressView()\n            Text(self.subtext)\n                .font(.headline)\n        }\n    }\n}\n\nstruct VpnEnableView: View {\n    var manager: NETunnelProviderManager\n    var subtext: String\n    var tpInit: TunnelProviderInit\n\n    var body: some View {\n        ZStack(alignment: .topLeading) {\n            Image(systemName: \"network.badge.shield.half.filled\")\n                .font(.system(size: 48))\n                .foregroundStyle(.blue)\n                .buttonStyle(.plain)\n        }\n        .padding()\n\n        Text(\"Enable Obscura VPN\")\n            .font(.title)\n        if !self.subtext.isEmpty {\n            Text(self.subtext)\n                .padding()\n                .italic()\n                .frame(width: 350)\n                .frame(minHeight: 100)\n                .multilineTextAlignment(.center)\n        }\n        Button(action: { self.tpInit.continueAfterStopOtherTunnelPriming(self.manager) }) {\n            Text(\"Continue\")\n                .font(.headline)\n                .frame(width: 300)\n        }\n        .buttonStyle(NoFadeButtonStyle())\n    }\n}\n\nstruct VpnConfigurationView: View {\n    @ObservedObject var startupModel: StartupModel\n    var subtext = \"\"\n\n    @Environment(\\.openURL) private var openURL\n    var tpInit: TunnelProviderInit? = nil\n    var isError = false\n\n    var body: some View {\n        let primer = self.tpInit != nil && !self.isError\n        ZStack(alignment: .topLeading) {\n            Image(systemName: \"network.badge.shield.half.filled\")\n                .font(.system(size: 48))\n                .foregroundStyle(.blue)\n                .buttonStyle(.plain)\n                .opacity(primer ? 1 : 0)\n            Image(systemName: \"network\")\n                .font(.system(size: 48))\n                .foregroundStyle(.blue)\n                .buttonStyle(.plain)\n                .opacity(primer ? 0 : 1)\n                .overlay(alignment: .bottomTrailing) {\n                    Image(systemName: self.isError ? \"xmark.circle.fill\" : \"ellipsis.circle.fill\")\n                        .font(.system(size: 19))\n                        .foregroundStyle(.black, self.isError ? .red : .white)\n                        .opacity(primer ? 0 : 1)\n                        .alignmentGuide(.bottom, computeValue: { $0.height })\n                        .alignmentGuide(.trailing, computeValue: { $0.width })\n                }\n        }\n        .padding()\n\n        Text(\"Allow VPN Configuration\")\n            .font(.title)\n        if !self.subtext.isEmpty {\n            Text(self.subtext)\n                .padding()\n                .italic()\n                .frame(width: 350)\n                .frame(minHeight: 100)\n                .multilineTextAlignment(.center)\n        }\n        Button(action: { self.tpInit?.continueAfterPermissionPriming() }) {\n            Text(self.isError ? \"Retry VPN Configuration\" : \"Allow VPN Configuration\")\n                .font(.headline)\n                .frame(width: 300)\n        }\n        .buttonStyle(NoFadeButtonStyle())\n        .disabled(!primer && !self.isError)\n    }\n}\n\nstruct VpnFailedView: View {\n    var body: some View {\n        VStack(spacing: 20) {\n            AppIcon()\n            Image(systemName: \"xmark.circle.fill\")\n                .font(.system(size: 40))\n                .foregroundStyle(.black, .red)\n                .padding()\n            Text(\"Problem initializing Tunnel Provider\")\n                .font(.headline)\n            Text(\"Please try restarting your device or contact support for help.\")\n        }\n    }\n}\n\nclass StartupModel: ObservableObject {\n    static let shared = StartupModel()\n\n    @Published var status = StartupStatus.initial\n    @Published var appState: AppState?\n\n    // This must be in StartupModel, otherwise color scheme applies only to the content view and not to the startup view\n    @MainActor @AppStorage(UserDefaultKeys.SelectedAppearance) var selectedAppearance: AppAppearance = .auto\n\n    init() {\n        self.start()\n    }\n\n    private func start() {\n        Task { @MainActor in\n            #if os(macOS)\n                guard let () = await self.stepNetworkExtensionInit() else {\n                    return\n                }\n            #endif\n\n            guard let (tunnelProviderManager, status) = await self.stepTunnelProviderInit() else {\n                return\n            }\n\n            #if os(macOS)\n                await self.stepRegisterLoginItem()\n            #endif\n\n            self.update(status: .ready)\n            self.appState = AppState(tunnelProviderManager, initialStatus: status)\n        }\n    }\n\n    @MainActor private func update(status: StartupStatus) {\n        logger.info(\"StartupModel.status = \\(debugFormat(status), privacy: .public)\")\n        self.status = status\n    }\n\n    #if os(macOS)\n        @MainActor private func stepNetworkExtensionInit() async -> Void? {\n            var tunnelConnected = false\n            do {\n                let managers: [NETunnelProviderManager] = try await NETunnelProviderManager.loadAllFromPreferences()\n                for manager in managers {\n                    let status = manager.connection.status\n                    if status != .disconnected {\n                        logger.info(\"connection status is \\(status, privacy: .public), assume tunnel is connected\")\n                        tunnelConnected = true\n                    }\n                }\n            } catch {\n                logger.error(\"could not determine connection status, assume tunnel is connected: \\(error, privacy: .public)\")\n                tunnelConnected = true\n            }\n\n            let neInit = NetworkExtensionInit(tunnelConnected: tunnelConnected)\n            for await event in neInit.start() {\n                switch event {\n                case .status(let status):\n                    self.update(status: .networkExtensionInit(neInit, status))\n                case .done:\n                    return ()\n                }\n            }\n            logger.error(\"Failed to initialize network extension! \\(debugFormat(self.status), privacy: .public)\")\n            return nil\n        }\n    #endif\n\n    @MainActor private func stepTunnelProviderInit() async -> (NETunnelProviderManager, NeStatus)? {\n        let tpInit = TunnelProviderInit()\n        for await event in tpInit.start() {\n            switch event {\n            case .status(let status):\n                self.update(status: .tunnelProviderInit(tpInit, status))\n            case .done(let manager, let status):\n                return (manager, status)\n            }\n        }\n        logger.error(\"Failed to initialize tunnel provider! \\(debugFormat(self.status), privacy: .public)\")\n        return nil\n    }\n\n    #if os(macOS)\n        @MainActor private func stepRegisterLoginItem() async {\n            if !UserDefaults.standard.bool(forKey: UserDefaultKeys.LoginItemRegistered) {\n                let value = ObservableValue<Bool>()\n                self.update(status: .askToRegisterLoginItem(value))\n                if await value.get() {\n                    do {\n                        try registerAsLoginItem(appState: self.appState)\n                    } catch {}\n                }\n                UserDefaults.standard.set(true, forKey: UserDefaultKeys.LoginItemRegistered)\n            }\n        }\n    #endif\n}\n"
  },
  {
    "path": "apple/client.xcodeproj/project.pbxproj",
    "content": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 73;\n\tobjects = {\n\n/* Begin PBXBuildFile section */\n\t\t1799341F2E94117C0089B6EB /* NotificationIds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1799341E2E9411770089B6EB /* NotificationIds.swift */; };\n\t\t179934202E94117C0089B6EB /* NotificationIds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1799341E2E9411770089B6EB /* NotificationIds.swift */; };\n\t\t179934212E94117C0089B6EB /* NotificationIds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1799341E2E9411770089B6EB /* NotificationIds.swift */; };\n\t\t179934222E94117C0089B6EB /* NotificationIds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1799341E2E9411770089B6EB /* NotificationIds.swift */; };\n\t\t17B910022E0DB3E50073AAD7 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B910012E0DB3E00073AAD7 /* Keychain.swift */; };\n\t\t17B910032E0DB3E50073AAD7 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B910012E0DB3E00073AAD7 /* Keychain.swift */; };\n\t\t1D9F4FDE2E5BC912001C080B /* DebugBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BCB05D2DC54AC0006C0133 /* DebugBundle.swift */; };\n\t\t1D9F4FE02E5BCDED001C080B /* time.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A6F2532C6121C2004E1A7C /* time.swift */; };\n\t\t1D9F50082E6280FF001C080B /* MailDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D9F50072E6280FF001C080B /* MailDelegate.swift */; };\n\t\t1DAD5D622ED7F27800DDB469 /* StoreKitListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAD5D612ED7F27500DDB469 /* StoreKitListener.swift */; };\n\t\t1DAD5D662ED8FAA100DDB469 /* StoreKitModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAD5D652ED8FAA100DDB469 /* StoreKitModel.swift */; };\n\t\t1DB5666B2EA565E7009CEB08 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C920682CD91D94002C85EA /* String.swift */; };\n\t\t300452782C49BC90000B78F7 /* Json.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300452772C49BC90000B78F7 /* Json.swift */; };\n\t\t300452792C49BC90000B78F7 /* Json.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300452772C49BC90000B78F7 /* Json.swift */; };\n\t\t30497E662D9EC09E008B22F9 /* ConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30497E652D9EC09E008B22F9 /* ConcurrencyTests.swift */; };\n\t\t30497E672D9EC0BA008B22F9 /* Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C152C764B97007ECFEC /* Concurrency.swift */; };\n\t\t30497E682D9EC129008B22F9 /* Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C182C764F0D007ECFEC /* Swift.swift */; };\n\t\t30497E692D9EC16A008B22F9 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C920682CD91D94002C85EA /* String.swift */; };\n\t\t30497E6A2D9EC19E008B22F9 /* StringError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309B467C2C4C152900A1B00F /* StringError.swift */; };\n\t\t30497E6B2D9EC7AC008B22F9 /* Sleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9122C45DFC8000A7428 /* Sleep.swift */; };\n\t\t30497E6E2D9ED584008B22F9 /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30497E6D2D9ED57F008B22F9 /* Box.swift */; };\n\t\t30497E6F2D9ED584008B22F9 /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30497E6D2D9ED57F008B22F9 /* Box.swift */; };\n\t\t30497E702D9ED5AD008B22F9 /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30497E6D2D9ED57F008B22F9 /* Box.swift */; };\n\t\t30497E722D9ED7D4008B22F9 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 30497E712D9ED7D4008B22F9 /* DequeModule */; };\n\t\t30497E742D9ED7DF008B22F9 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 30497E732D9ED7DF008B22F9 /* DequeModule */; };\n\t\t30497E762D9ED7E9008B22F9 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 30497E752D9ED7E9008B22F9 /* DequeModule */; };\n\t\t30497E782D9ED7EE008B22F9 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 30497E772D9ED7EE008B22F9 /* DequeModule */; };\n\t\t30761C222B6EB17100E5F60D /* ClientApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30761C212B6EB17100E5F60D /* ClientApp.swift */; };\n\t\t30761C242B6EB17100E5F60D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30761C232B6EB17100E5F60D /* ContentView.swift */; };\n\t\t30761C262B6EB17200E5F60D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 30761C252B6EB17200E5F60D /* Assets.xcassets */; };\n\t\t30761C292B6EB17200E5F60D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 30761C282B6EB17200E5F60D /* Preview Assets.xcassets */; };\n\t\t30920D752C1F71BB008690C3 /* startup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920D742C1F71BB008690C3 /* startup.swift */; };\n\t\t30920D792C2057B1008690C3 /* initNetworkExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920D782C2057B1008690C3 /* initNetworkExtension.swift */; };\n\t\t30920D7D2C207379008690C3 /* TunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920D7C2C207379008690C3 /* TunnelProvider.swift */; };\n\t\t30920D942C3D51EC008690C3 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 30761C362B6EB1F900E5F60D /* NetworkExtension.framework */; };\n\t\t30920DA92C3D53F1008690C3 /* libobscuravpn-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C90161542BE01159005B14AF /* libobscuravpn-client.a */; platformFilter = ios; };\n\t\t30920DAD2C3DC174008690C3 /* InfoDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920DAC2C3DC174008690C3 /* InfoDict.swift */; };\n\t\t30920DAE2C3DC174008690C3 /* InfoDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920DAC2C3DC174008690C3 /* InfoDict.swift */; };\n\t\t30920DAF2C3DC174008690C3 /* InfoDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920DAC2C3DC174008690C3 /* InfoDict.swift */; };\n\t\t3096BFF92CECD50F003D062E /* NEVPNStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096BFF82CECD50F003D062E /* NEVPNStatus.swift */; };\n\t\t3096E0222BDC1FFB0026DE7F /* ScriptMessageHandlers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096E0212BDC1FFB0026DE7F /* ScriptMessageHandlers.swift */; };\n\t\t3096E0402BEFC6770026DE7F /* command.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096E03F2BEFC6770026DE7F /* command.swift */; };\n\t\t3096E0462BF0F5870026DE7F /* app_state.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096E0452BF0F5860026DE7F /* app_state.swift */; };\n\t\t3098C18F2B921489008877AA /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 30761C362B6EB1F900E5F60D /* NetworkExtension.framework */; };\n\t\t3098C1922B921489008877AA /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3098C1912B921489008877AA /* PacketTunnelProvider.swift */; };\n\t\t3098C1942B921489008877AA /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3098C1932B921489008877AA /* main.swift */; };\n\t\t3098C1992B921489008877AA /* net.obscura.vpn-client-app.system-network-extension.systemextension in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 3098C18E2B921489008877AA /* net.obscura.vpn-client-app.system-network-extension.systemextension */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };\n\t\t309B467D2C4C152900A1B00F /* StringError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309B467C2C4C152900A1B00F /* StringError.swift */; };\n\t\t309B467E2C4C152900A1B00F /* StringError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309B467C2C4C152900A1B00F /* StringError.swift */; };\n\t\t309BA90A2C443978000A7428 /* RustFfi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9072C44394E000A7428 /* RustFfi.swift */; };\n\t\t309BA90C2C446125000A7428 /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA90B2C446125000A7428 /* NetworkSettings.swift */; };\n\t\t309BA9132C45DFC8000A7428 /* Sleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9122C45DFC8000A7428 /* Sleep.swift */; };\n\t\t309BA9142C45DFC8000A7428 /* Sleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9122C45DFC8000A7428 /* Sleep.swift */; };\n\t\t309BFE3F2D9C169500366431 /* WatchableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C532092CC9558000936E1F /* WatchableValue.swift */; };\n\t\t30C5320A2CC9558000936E1F /* WatchableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C532092CC9558000936E1F /* WatchableValue.swift */; };\n\t\t30C5320C2CC959AE00936E1F /* OsStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C5320B2CC959AE00936E1F /* OsStatus.swift */; };\n\t\t30EF74C12BFFE48C0095439F /* FfiCb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74C02BFFE48C0095439F /* FfiCb.swift */; };\n\t\t30EF74C22BFFE86B0095439F /* FfiCb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74C02BFFE48C0095439F /* FfiCb.swift */; };\n\t\t30EF74CD2C02244C0095439F /* NetworkExtensionIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74CC2C02244C0095439F /* NetworkExtensionIpc.swift */; };\n\t\t30EF74CE2C02244C0095439F /* NetworkExtensionIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74CC2C02244C0095439F /* NetworkExtensionIpc.swift */; };\n\t\t35219C3B2C6BD57F00E63BB8 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35219C3A2C6BD57F00E63BB8 /* Debug.swift */; };\n\t\t35219C3C2C6BD57F00E63BB8 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35219C3A2C6BD57F00E63BB8 /* Debug.swift */; };\n\t\t35230C162C764B97007ECFEC /* Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C152C764B97007ECFEC /* Concurrency.swift */; };\n\t\t35230C172C764B97007ECFEC /* Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C152C764B97007ECFEC /* Concurrency.swift */; };\n\t\t35230C192C764F0D007ECFEC /* Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C182C764F0D007ECFEC /* Swift.swift */; };\n\t\t35230C1A2C764F0D007ECFEC /* Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C182C764F0D007ECFEC /* Swift.swift */; };\n\t\t35230C282C775FF1007ECFEC /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C272C775FF1007ECFEC /* Notifications.swift */; };\n\t\t352D58E02C4AE796002F3404 /* ObservableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352D58DF2C4AE796002F3404 /* ObservableValue.swift */; };\n\t\t352D58E12C4AE796002F3404 /* ObservableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352D58DF2C4AE796002F3404 /* ObservableValue.swift */; };\n\t\t35A6F2522C611DD1004E1A7C /* DebugBundle+XP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A6F2512C611DD1004E1A7C /* DebugBundle+XP.swift */; };\n\t\t35A6F2542C6121C2004E1A7C /* time.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A6F2532C6121C2004E1A7C /* time.swift */; };\n\t\t35A6F2552C613366004E1A7C /* time.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A6F2532C6121C2004E1A7C /* time.swift */; };\n\t\t35F8DE7F2C6559D20016CEEB /* OSLogEntryEncodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F8DE7E2C6559D20016CEEB /* OSLogEntryEncodable.swift */; };\n\t\t35F8DE872C6666C40016CEEB /* DebugBundleExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F8DE862C6666C40016CEEB /* DebugBundleExtensionInfo.swift */; };\n\t\t962325182C875B3E008A9B76 /* StatusMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962325172C875B3E008A9B76 /* StatusMenu.swift */; };\n\t\t962325212C88D4E8008A9B76 /* ObscuraToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962325202C88D4E8008A9B76 /* ObscuraToggle.swift */; };\n\t\t962325232C8A3B58008A9B76 /* MenuItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962325222C8A3B58008A9B76 /* MenuItemView.swift */; };\n\t\t9632E4642D19C5EC00BC8E3F /* AccountStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9632E4632D19C5EA00BC8E3F /* AccountStatusItem.swift */; };\n\t\t9649F9592DA4233F009EFF4F /* LoopingVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9649F9582DA4233F009EFF4F /* LoopingVideoPlayer.swift */; };\n\t\t9649F9732DA56142009EFF4F /* AVKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9649F9722DA56142009EFF4F /* AVKit.framework */; };\n\t\t96516AC32BF928DD00576562 /* build in Resources */ = {isa = PBXBuildFile; fileRef = 96516AC22BF928DD00576562 /* build */; };\n\t\t96615E292C598A9600120DEF /* CwlSysctl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96615E282C598A9600120DEF /* CwlSysctl.swift */; };\n\t\t966967C12D440B450019AF9F /* LoginItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966967C02D440B430019AF9F /* LoginItem.swift */; };\n\t\t967D0C862C41A1E500FD0767 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967D0C852C41A1E500FD0767 /* Constants.swift */; };\n\t\t968B02B92CFF5B7B0053D0EF /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968B02B82CFF5B7B0053D0EF /* Account.swift */; };\n\t\t968B02BA2CFF5B7B0053D0EF /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968B02B82CFF5B7B0053D0EF /* Account.swift */; };\n\t\t96C9205B2CD549B1002C85EA /* BandwidthStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C9205A2CD549B1002C85EA /* BandwidthStatus.swift */; };\n\t\t96C920692CD91D96002C85EA /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C920682CD91D94002C85EA /* String.swift */; };\n\t\t96C9206A2CD91D96002C85EA /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C920682CD91D94002C85EA /* String.swift */; };\n\t\t96DB3D542E60F01B005B4D1B /* Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96DB3D532E60F01B005B4D1B /* Appearance.swift */; };\n\t\t96DB3D552E60F01B005B4D1B /* Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96DB3D532E60F01B005B4D1B /* Appearance.swift */; };\n\t\tA082F8232C46BF5B002AF810 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A082F8222C46BF5B002AF810 /* Sparkle */; };\n\t\tA9029C192DEEDCBC00AAD761 /* ObscuraUIMacOSWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9029C182DEEDCBC00AAD761 /* ObscuraUIMacOSWrapper.swift */; };\n\t\tA9200A862DD1251C00FD035C /* ObscuraUIWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9200A842DD1251C00FD035C /* ObscuraUIWebView.swift */; };\n\t\tA9200A872DD127A200FD035C /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C272C775FF1007ECFEC /* Notifications.swift */; };\n\t\tA936D4452DD1492A0031B646 /* UXViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4432DD149290031B646 /* UXViewRepresentable.swift */; };\n\t\tA936D4462DD1492A0031B646 /* UXViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4422DD149290031B646 /* UXViewController.swift */; };\n\t\tA936D4472DD1492A0031B646 /* UXViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4432DD149290031B646 /* UXViewRepresentable.swift */; };\n\t\tA936D4482DD1492A0031B646 /* UXViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4422DD149290031B646 /* UXViewController.swift */; };\n\t\tA936D4712DD14DC30031B646 /* ObscuraUIWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9200A842DD1251C00FD035C /* ObscuraUIWebView.swift */; };\n\t\tA936D4722DD14E040031B646 /* ScriptMessageHandlers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096E0212BDC1FFB0026DE7F /* ScriptMessageHandlers.swift */; };\n\t\tA936D4732DD14E470031B646 /* command.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096E03F2BEFC6770026DE7F /* command.swift */; };\n\t\tA936D48F2DD15AED0031B646 /* startup.swift in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 30920D742C1F71BB008690C3 /* startup.swift */; };\n\t\tA936D4902DD15B6B0031B646 /* startup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920D742C1F71BB008690C3 /* startup.swift */; };\n\t\tA936D4932DD15F6A0031B646 /* StartupStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4922DD15F6A0031B646 /* StartupStatus.swift */; };\n\t\tA936D4942DD15F6A0031B646 /* StartupStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4922DD15F6A0031B646 /* StartupStatus.swift */; };\n\t\tA936D4962DD160750031B646 /* UpdateSystemExtensionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4952DD160750031B646 /* UpdateSystemExtensionView.swift */; };\n\t\tA936D4992DD1617F0031B646 /* UXImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4982DD1617F0031B646 /* UXImage.swift */; };\n\t\tA936D49A2DD161B00031B646 /* UXImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4982DD1617F0031B646 /* UXImage.swift */; };\n\t\tA936D49C2DD162AB0031B646 /* InstallSystemExtensionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D49B2DD162AB0031B646 /* InstallSystemExtensionView.swift */; };\n\t\tA94743F92DD17143002ACD85 /* iOSClientApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94743F72DD170F5002ACD85 /* iOSClientApp.swift */; };\n\t\tA94B1D9C2E286B3900E5F325 /* ConditionallyDisabled.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94B1D9B2E286B3900E5F325 /* ConditionallyDisabled.swift */; };\n\t\tA94B1DCD2E28B13E00E5F325 /* AccountInfo+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94B1DCC2E28B13E00E5F325 /* AccountInfo+Util.swift */; };\n\t\tA94B1DCE2E28B13E00E5F325 /* AccountInfo+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94B1DCC2E28B13E00E5F325 /* AccountInfo+Util.swift */; };\n\t\tA94B1DD42E28B16800E5F325 /* Product+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94B1DD02E28B16800E5F325 /* Product+Convenience.swift */; };\n\t\tA94B1E242E2B03B900E5F325 /* ConditionallyDisabled.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94B1D9B2E286B3900E5F325 /* ConditionallyDisabled.swift */; };\n\t\tA94B1E3D2E2B236800E5F325 /* Obscura VPN Local.storekit in Resources */ = {isa = PBXBuildFile; fileRef = A94B1E3C2E2B236800E5F325 /* Obscura VPN Local.storekit */; };\n\t\tA94BF4462E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94BF4452E53BBE9007FDD1C /* Account+CustomStringConvertible.swift */; };\n\t\tA94BF4472E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94BF4452E53BBE9007FDD1C /* Account+CustomStringConvertible.swift */; };\n\t\tA94BF4482E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94BF4452E53BBE9007FDD1C /* Account+CustomStringConvertible.swift */; };\n\t\tA94BF4492E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94BF4452E53BBE9007FDD1C /* Account+CustomStringConvertible.swift */; };\n\t\tA95A91332DF9910C005CB52A /* ObscuraUIIOSWrapperAndTabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95A91322DF9910C005CB52A /* ObscuraUIIOSWrapperAndTabs.swift */; };\n\t\tA9758FDE2E41910300741928 /* HyperlinkButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9758FDD2E41910300741928 /* HyperlinkButtonStyle.swift */; };\n\t\tA9758FDF2E41910300741928 /* HyperlinkButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9758FDD2E41910300741928 /* HyperlinkButtonStyle.swift */; };\n\t\tA9768FAD2DBB01AD00A4595F /* UpdaterDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9768FAC2DBB01AD00A4595F /* UpdaterDriver.swift */; };\n\t\tA9768FAF2DBB01C400A4595F /* CheckForUpdatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9768FAE2DBB01C400A4595F /* CheckForUpdatesView.swift */; };\n\t\tA9768FB42DBB01EE00A4595F /* SparkleUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9768FB32DBB01EE00A4595F /* SparkleUpdater.swift */; };\n\t\tA9768FBE2DBB02BC00A4595F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30761C232B6EB17100E5F60D /* ContentView.swift */; };\n\t\tA9768FC02DBB02C400A4595F /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = A9768FBF2DBB02C400A4595F /* OrderedCollections */; };\n\t\tA9768FC22DBB02C900A4595F /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = A9768FC12DBB02C900A4595F /* OrderedCollections */; };\n\t\tA983D7E02DF25435007306C5 /* WebviewsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A983D7DF2DF25435007306C5 /* WebviewsController.swift */; };\n\t\tA983D7E12DF25435007306C5 /* WebviewsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A983D7DF2DF25435007306C5 /* WebviewsController.swift */; };\n\t\tA983D7E32DF261BB007306C5 /* ExternalWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A983D7E22DF261BB007306C5 /* ExternalWebView.swift */; };\n\t\tA983D7E42DF261BB007306C5 /* ExternalWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A983D7E22DF261BB007306C5 /* ExternalWebView.swift */; };\n\t\tA98F1BCE2DADFF17007F04D3 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = A98F1BA22DADFF17007F04D3 /* DequeModule */; };\n\t\tA98F1BD02DADFF17007F04D3 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 30761C282B6EB17200E5F60D /* Preview Assets.xcassets */; };\n\t\tA98F1BD12DADFF17007F04D3 /* build in Resources */ = {isa = PBXBuildFile; fileRef = 96516AC22BF928DD00576562 /* build */; };\n\t\tA98F1BD22DADFF17007F04D3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 30761C252B6EB17200E5F60D /* Assets.xcassets */; };\n\t\tA98F1BDA2DAE00A0007F04D3 /* App Network Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 30920D932C3D51EC008690C3 /* App Network Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };\n\t\tA9963C562DB83FBE00D10893 /* FfiCb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74C02BFFE48C0095439F /* FfiCb.swift */; };\n\t\tA9963C572DB83FC200D10893 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968B02B82CFF5B7B0053D0EF /* Account.swift */; };\n\t\tA9963C582DB83FC600D10893 /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30497E6D2D9ED57F008B22F9 /* Box.swift */; };\n\t\tA9963C592DB83FCB00D10893 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35219C3A2C6BD57F00E63BB8 /* Debug.swift */; };\n\t\tA9963C5A2DB83FCF00D10893 /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA90B2C446125000A7428 /* NetworkSettings.swift */; };\n\t\tA9963C5B2DB83FD200D10893 /* Json.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300452772C49BC90000B78F7 /* Json.swift */; };\n\t\tA9963C5C2DB83FD600D10893 /* Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C182C764F0D007ECFEC /* Swift.swift */; };\n\t\tA9963C5D2DB83FF300D10893 /* StringError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309B467C2C4C152900A1B00F /* StringError.swift */; };\n\t\tA9963C5E2DB83FF700D10893 /* RustFfi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9072C44394E000A7428 /* RustFfi.swift */; };\n\t\tA9963C5F2DB83FFD00D10893 /* NetworkExtensionIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74CC2C02244C0095439F /* NetworkExtensionIpc.swift */; };\n\t\tA9963C602DB8400300D10893 /* WatchableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C532092CC9558000936E1F /* WatchableValue.swift */; };\n\t\tA9963C612DB8400900D10893 /* Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C152C764B97007ECFEC /* Concurrency.swift */; };\n\t\tA9963C622DB8400D00D10893 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3098C1912B921489008877AA /* PacketTunnelProvider.swift */; };\n\t\tA9963C632DB8401100D10893 /* Sleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9122C45DFC8000A7428 /* Sleep.swift */; };\n\t\tA998710E2DBAF7FE0044D136 /* RegisterLoginItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A998710C2DBAF7FE0044D136 /* RegisterLoginItemView.swift */; };\n\t\tA99871112DBAF8080044D136 /* NoFadeButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A998710F2DBAF8080044D136 /* NoFadeButtonStyle.swift */; };\n\t\tA99871122DBAF8080044D136 /* NoFadeButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A998710F2DBAF8080044D136 /* NoFadeButtonStyle.swift */; };\n\t\tA99871152DBAF84D0044D136 /* app_state.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096E0452BF0F5860026DE7F /* app_state.swift */; };\n\t\tA99871162DBAF8510044D136 /* OsStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C5320B2CC959AE00936E1F /* OsStatus.swift */; };\n\t\tA99871182DBAF8590044D136 /* WatchableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C532092CC9558000936E1F /* WatchableValue.swift */; };\n\t\tA99871192DBAF85C0044D136 /* ObservableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352D58DF2C4AE796002F3404 /* ObservableValue.swift */; };\n\t\tA998711A2DBAF8610044D136 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967D0C852C41A1E500FD0767 /* Constants.swift */; };\n\t\tA998711B2DBAF8680044D136 /* FfiCb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74C02BFFE48C0095439F /* FfiCb.swift */; };\n\t\tA998711C2DBAF8700044D136 /* Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C182C764F0D007ECFEC /* Swift.swift */; };\n\t\tA998711D2DBAF8740044D136 /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30497E6D2D9ED57F008B22F9 /* Box.swift */; };\n\t\tA998711E2DBAF8790044D136 /* Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C152C764B97007ECFEC /* Concurrency.swift */; };\n\t\tA998711F2DBAF87D0044D136 /* Sleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9122C45DFC8000A7428 /* Sleep.swift */; };\n\t\tA99871202DBAF8810044D136 /* StringError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309B467C2C4C152900A1B00F /* StringError.swift */; };\n\t\tA99871212DBAF8850044D136 /* Json.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300452772C49BC90000B78F7 /* Json.swift */; };\n\t\tA99871222DBAF8890044D136 /* OSLogEntryEncodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F8DE7E2C6559D20016CEEB /* OSLogEntryEncodable.swift */; };\n\t\tA99871232DBAF88F0044D136 /* NEVPNStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096BFF82CECD50F003D062E /* NEVPNStatus.swift */; };\n\t\tA99871242DBAF8940044D136 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968B02B82CFF5B7B0053D0EF /* Account.swift */; };\n\t\tA99871252DBAF8990044D136 /* InfoDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920DAC2C3DC174008690C3 /* InfoDict.swift */; };\n\t\tA99871262DBAF89E0044D136 /* NetworkExtensionIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74CC2C02244C0095439F /* NetworkExtensionIpc.swift */; };\n\t\tA99871272DBAF8A60044D136 /* TunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920D7C2C207379008690C3 /* TunnelProvider.swift */; };\n\t\tA99871282DBAF8FB0044D136 /* DebugBundle+XP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A6F2512C611DD1004E1A7C /* DebugBundle+XP.swift */; };\n\t\tA99871292DBAF90B0044D136 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35219C3A2C6BD57F00E63BB8 /* Debug.swift */; };\n\t\tA9BCB05E2DC54AC0006C0133 /* DebugBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BCB05D2DC54AC0006C0133 /* DebugBundle.swift */; };\n\t\tA9BCB0612DC54B68006C0133 /* UpdaterDriver+XP.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BCB0602DC54B68006C0133 /* UpdaterDriver+XP.swift */; };\n\t\tA9BCB0622DC54B68006C0133 /* UpdaterDriver+XP.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BCB0602DC54B68006C0133 /* UpdaterDriver+XP.swift */; };\n\t\tA9E44A782E093DA7006B4616 /* Obscura VPN.storekit in Resources */ = {isa = PBXBuildFile; fileRef = A9E44A762E093DA7006B4616 /* Obscura VPN.storekit */; };\n\t\tA9E44A7C2E0947EA006B4616 /* Product+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E44A7B2E0947EA006B4616 /* Product+Convenience.swift */; };\n\t\tC90161572BE011B2005B14AF /* libobscuravpn-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C90161542BE01159005B14AF /* libobscuravpn-client.a */; };\n/* End PBXBuildFile section */\n\n/* Begin PBXContainerItemProxy section */\n\t\t3098C1972B921489008877AA /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 30761C162B6EB17100E5F60D /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = 3098C18D2B921489008877AA;\n\t\t\tremoteInfo = \"system-network-extension\";\n\t\t};\n\t\tA98F1BDB2DAE00A0007F04D3 /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 30761C162B6EB17100E5F60D /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = 30920D922C3D51EC008690C3;\n\t\t\tremoteInfo = \"App Network Extension\";\n\t\t};\n\t\tA98F1BDE2DAE00A5007F04D3 /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 30761C162B6EB17100E5F60D /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = 30920D922C3D51EC008690C3;\n\t\t\tremoteInfo = \"App Network Extension\";\n\t\t};\n\t\tC90161532BE01159005B14AF /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = C901614E2BE01159005B14AF /* obscuravpn-client.xcodeproj */;\n\t\t\tproxyType = 2;\n\t\t\tremoteGlobalIDString = CA0090E2379FFD96F1473BE9;\n\t\t\tremoteInfo = \"obscuravpn-client.a (static library)\";\n\t\t};\n\t\tC90161552BE01159005B14AF /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = C901614E2BE01159005B14AF /* obscuravpn-client.xcodeproj */;\n\t\t\tproxyType = 2;\n\t\t\tremoteGlobalIDString = CA01272F0B60B8156098F4D0;\n\t\t\tremoteInfo = \"obscuravpn-client (standalone executable)\";\n\t\t};\n/* End PBXContainerItemProxy section */\n\n/* Begin PBXCopyFilesBuildPhase section */\n\t\t3098C1862B9211B1008877AA /* Embed System Extensions */ = {\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = \"$(SYSTEM_EXTENSIONS_FOLDER_PATH)\";\n\t\t\tdstSubfolderSpec = 16;\n\t\t\tfiles = (\n\t\t\t\t3098C1992B921489008877AA /* net.obscura.vpn-client-app.system-network-extension.systemextension in Embed System Extensions */,\n\t\t\t);\n\t\t\tname = \"Embed System Extensions\";\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t9649F96E2DA45808009EFF4F /* Copy Files */ = {\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = videos;\n\t\t\tdstSubfolderSpec = 7;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tname = \"Copy Files\";\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tA98F1BDD2DAE00A0007F04D3 /* Embed Foundation Extensions */ = {\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = \"\";\n\t\t\tdstSubfolderSpec = 13;\n\t\t\tfiles = (\n\t\t\t\tA936D48F2DD15AED0031B646 /* startup.swift in Embed Foundation Extensions */,\n\t\t\t\tA98F1BDA2DAE00A0007F04D3 /* App Network Extension.appex in Embed Foundation Extensions */,\n\t\t\t);\n\t\t\tname = \"Embed Foundation Extensions\";\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXCopyFilesBuildPhase section */\n\n/* Begin PBXFileReference section */\n\t\t1799341E2E9411770089B6EB /* NotificationIds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationIds.swift; sourceTree = \"<group>\"; };\n\t\t17B910012E0DB3E00073AAD7 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = \"<group>\"; };\n\t\t1D9F50072E6280FF001C080B /* MailDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailDelegate.swift; sourceTree = \"<group>\"; };\n\t\t1DAD5D612ED7F27500DDB469 /* StoreKitListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitListener.swift; sourceTree = \"<group>\"; };\n\t\t1DAD5D652ED8FAA100DDB469 /* StoreKitModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitModel.swift; sourceTree = \"<group>\"; };\n\t\t300452772C49BC90000B78F7 /* Json.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Json.swift; sourceTree = \"<group>\"; };\n\t\t30497E5E2D9EC038008B22F9 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t30497E652D9EC09E008B22F9 /* ConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrencyTests.swift; sourceTree = \"<group>\"; };\n\t\t30497E6D2D9ED57F008B22F9 /* Box.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Box.swift; sourceTree = \"<group>\"; };\n\t\t30761C1E2B6EB17100E5F60D /* Obscura VPN (Debug).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = \"Obscura VPN (Debug).app\"; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t30761C212B6EB17100E5F60D /* ClientApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientApp.swift; sourceTree = \"<group>\"; };\n\t\t30761C232B6EB17100E5F60D /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = \"<group>\"; };\n\t\t30761C252B6EB17200E5F60D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"<group>\"; };\n\t\t30761C282B6EB17200E5F60D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = \"Preview Assets.xcassets\"; sourceTree = \"<group>\"; };\n\t\t30761C2A2B6EB17200E5F60D /* client.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = client.entitlements; sourceTree = \"<group>\"; };\n\t\t30761C362B6EB1F900E5F60D /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; };\n\t\t30920D742C1F71BB008690C3 /* startup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = startup.swift; sourceTree = \"<group>\"; };\n\t\t30920D782C2057B1008690C3 /* initNetworkExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = initNetworkExtension.swift; sourceTree = \"<group>\"; };\n\t\t30920D7C2C207379008690C3 /* TunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelProvider.swift; sourceTree = \"<group>\"; };\n\t\t30920D932C3D51EC008690C3 /* App Network Extension.appex */ = {isa = PBXFileReference; explicitFileType = \"wrapper.app-extension\"; includeInIndex = 0; path = \"App Network Extension.appex\"; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t30920D982C3D51EC008690C3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; };\n\t\t30920D992C3D51EC008690C3 /* entitlements.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = entitlements.entitlements; sourceTree = \"<group>\"; };\n\t\t30920DAC2C3DC174008690C3 /* InfoDict.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDict.swift; sourceTree = \"<group>\"; };\n\t\t3096BFF82CECD50F003D062E /* NEVPNStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NEVPNStatus.swift; sourceTree = \"<group>\"; };\n\t\t3096E0212BDC1FFB0026DE7F /* ScriptMessageHandlers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptMessageHandlers.swift; sourceTree = \"<group>\"; };\n\t\t3096E03F2BEFC6770026DE7F /* command.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = command.swift; sourceTree = \"<group>\"; };\n\t\t3096E0452BF0F5860026DE7F /* app_state.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = app_state.swift; sourceTree = \"<group>\"; };\n\t\t3098C18E2B921489008877AA /* net.obscura.vpn-client-app.system-network-extension.systemextension */ = {isa = PBXFileReference; explicitFileType = \"wrapper.system-extension\"; includeInIndex = 0; path = \"net.obscura.vpn-client-app.system-network-extension.systemextension\"; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t3098C1912B921489008877AA /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = \"<group>\"; };\n\t\t3098C1932B921489008877AA /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = \"<group>\"; };\n\t\t3098C1952B921489008877AA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; };\n\t\t3098C1962B921489008877AA /* entitlements.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = entitlements.entitlements; sourceTree = \"<group>\"; };\n\t\t309B467C2C4C152900A1B00F /* StringError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringError.swift; sourceTree = \"<group>\"; };\n\t\t309BA9072C44394E000A7428 /* RustFfi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RustFfi.swift; sourceTree = \"<group>\"; };\n\t\t309BA90B2C446125000A7428 /* NetworkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSettings.swift; sourceTree = \"<group>\"; };\n\t\t309BA9122C45DFC8000A7428 /* Sleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sleep.swift; sourceTree = \"<group>\"; };\n\t\t30C532092CC9558000936E1F /* WatchableValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchableValue.swift; sourceTree = \"<group>\"; };\n\t\t30C5320B2CC959AE00936E1F /* OsStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OsStatus.swift; sourceTree = \"<group>\"; };\n\t\t30EF74C02BFFE48C0095439F /* FfiCb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FfiCb.swift; sourceTree = \"<group>\"; };\n\t\t30EF74CC2C02244C0095439F /* NetworkExtensionIpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkExtensionIpc.swift; sourceTree = \"<group>\"; };\n\t\t35219C3A2C6BD57F00E63BB8 /* Debug.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Debug.swift; sourceTree = \"<group>\"; };\n\t\t35230C152C764B97007ECFEC /* Concurrency.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Concurrency.swift; sourceTree = \"<group>\"; };\n\t\t35230C182C764F0D007ECFEC /* Swift.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Swift.swift; sourceTree = \"<group>\"; };\n\t\t35230C272C775FF1007ECFEC /* Notifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = \"<group>\"; };\n\t\t352D58DF2C4AE796002F3404 /* ObservableValue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableValue.swift; sourceTree = \"<group>\"; };\n\t\t35A6F2512C611DD1004E1A7C /* DebugBundle+XP.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = \"DebugBundle+XP.swift\"; sourceTree = \"<group>\"; };\n\t\t35A6F2532C6121C2004E1A7C /* time.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = time.swift; sourceTree = \"<group>\"; };\n\t\t35F8DE7E2C6559D20016CEEB /* OSLogEntryEncodable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLogEntryEncodable.swift; sourceTree = \"<group>\"; };\n\t\t35F8DE862C6666C40016CEEB /* DebugBundleExtensionInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugBundleExtensionInfo.swift; sourceTree = \"<group>\"; };\n\t\t962325172C875B3E008A9B76 /* StatusMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMenu.swift; sourceTree = \"<group>\"; };\n\t\t962325202C88D4E8008A9B76 /* ObscuraToggle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObscuraToggle.swift; sourceTree = \"<group>\"; };\n\t\t962325222C8A3B58008A9B76 /* MenuItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemView.swift; sourceTree = \"<group>\"; };\n\t\t9632E4632D19C5EA00BC8E3F /* AccountStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountStatusItem.swift; sourceTree = \"<group>\"; };\n\t\t9649F9582DA4233F009EFF4F /* LoopingVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopingVideoPlayer.swift; sourceTree = \"<group>\"; };\n\t\t9649F9722DA56142009EFF4F /* AVKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVKit.framework; path = System/Library/Frameworks/AVKit.framework; sourceTree = SDKROOT; };\n\t\t96516AC22BF928DD00576562 /* build */ = {isa = PBXFileReference; lastKnownFileType = folder; name = build; path = \"../obscura-ui/build\"; sourceTree = \"<group>\"; };\n\t\t96615E282C598A9600120DEF /* CwlSysctl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CwlSysctl.swift; sourceTree = \"<group>\"; };\n\t\t966967C02D440B430019AF9F /* LoginItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginItem.swift; sourceTree = \"<group>\"; };\n\t\t967D0C852C41A1E500FD0767 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = \"<group>\"; };\n\t\t968B02B82CFF5B7B0053D0EF /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = \"<group>\"; };\n\t\t96C9205A2CD549B1002C85EA /* BandwidthStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BandwidthStatus.swift; sourceTree = \"<group>\"; };\n\t\t96C920682CD91D94002C85EA /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = \"<group>\"; };\n\t\t96DB3D532E60F01B005B4D1B /* Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Appearance.swift; sourceTree = \"<group>\"; };\n\t\tA06E85A62C2ECA680087C8C8 /* bundle-ids.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = \"bundle-ids.xcconfig\"; sourceTree = \"<group>\"; };\n\t\tA06E85A92C2ECA680087C8C8 /* app.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = app.xcconfig; sourceTree = \"<group>\"; };\n\t\tA06E85AA2C2ECA680087C8C8 /* buildversion.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = buildversion.xcconfig; sourceTree = \"<group>\"; };\n\t\tA9029C182DEEDCBC00AAD761 /* ObscuraUIMacOSWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObscuraUIMacOSWrapper.swift; sourceTree = \"<group>\"; };\n\t\tA9200A842DD1251C00FD035C /* ObscuraUIWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObscuraUIWebView.swift; sourceTree = \"<group>\"; };\n\t\tA936D4422DD149290031B646 /* UXViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UXViewController.swift; sourceTree = \"<group>\"; };\n\t\tA936D4432DD149290031B646 /* UXViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UXViewRepresentable.swift; sourceTree = \"<group>\"; };\n\t\tA936D4922DD15F6A0031B646 /* StartupStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupStatus.swift; sourceTree = \"<group>\"; };\n\t\tA936D4952DD160750031B646 /* UpdateSystemExtensionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateSystemExtensionView.swift; sourceTree = \"<group>\"; };\n\t\tA936D4982DD1617F0031B646 /* UXImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UXImage.swift; sourceTree = \"<group>\"; };\n\t\tA936D49B2DD162AB0031B646 /* InstallSystemExtensionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallSystemExtensionView.swift; sourceTree = \"<group>\"; };\n\t\tA94743F72DD170F5002ACD85 /* iOSClientApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSClientApp.swift; sourceTree = \"<group>\"; };\n\t\tA94B1D9B2E286B3900E5F325 /* ConditionallyDisabled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionallyDisabled.swift; sourceTree = \"<group>\"; };\n\t\tA94B1DCC2E28B13E00E5F325 /* AccountInfo+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = \"AccountInfo+Util.swift\"; sourceTree = \"<group>\"; };\n\t\tA94B1DD02E28B16800E5F325 /* Product+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = \"Product+Convenience.swift\"; sourceTree = \"<group>\"; };\n\t\tA94B1E3C2E2B236800E5F325 /* Obscura VPN Local.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = \"Obscura VPN Local.storekit\"; sourceTree = \"<group>\"; };\n\t\tA94BF4452E53BBE9007FDD1C /* Account+CustomStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = \"Account+CustomStringConvertible.swift\"; sourceTree = \"<group>\"; };\n\t\tA95A91322DF9910C005CB52A /* ObscuraUIIOSWrapperAndTabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObscuraUIIOSWrapperAndTabs.swift; sourceTree = \"<group>\"; };\n\t\tA9758FDD2E41910300741928 /* HyperlinkButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HyperlinkButtonStyle.swift; sourceTree = \"<group>\"; };\n\t\tA9768FAC2DBB01AD00A4595F /* UpdaterDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterDriver.swift; sourceTree = \"<group>\"; };\n\t\tA9768FAE2DBB01C400A4595F /* CheckForUpdatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckForUpdatesView.swift; sourceTree = \"<group>\"; };\n\t\tA9768FB32DBB01EE00A4595F /* SparkleUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdater.swift; sourceTree = \"<group>\"; };\n\t\tA983D7DF2DF25435007306C5 /* WebviewsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebviewsController.swift; sourceTree = \"<group>\"; };\n\t\tA983D7E22DF261BB007306C5 /* ExternalWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalWebView.swift; sourceTree = \"<group>\"; };\n\t\tA98F1BD82DADFF17007F04D3 /* Obscura VPN iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = \"Obscura VPN iOS.app\"; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\tA998710C2DBAF7FE0044D136 /* RegisterLoginItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterLoginItemView.swift; sourceTree = \"<group>\"; };\n\t\tA998710F2DBAF8080044D136 /* NoFadeButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoFadeButtonStyle.swift; sourceTree = \"<group>\"; };\n\t\tA9BCB05D2DC54AC0006C0133 /* DebugBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugBundle.swift; sourceTree = \"<group>\"; };\n\t\tA9BCB0602DC54B68006C0133 /* UpdaterDriver+XP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = \"UpdaterDriver+XP.swift\"; sourceTree = \"<group>\"; };\n\t\tA9E44A762E093DA7006B4616 /* Obscura VPN.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = \"Obscura VPN.storekit\"; sourceTree = \"<group>\"; };\n\t\tA9E44A7B2E0947EA006B4616 /* Product+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = \"Product+Convenience.swift\"; sourceTree = \"<group>\"; };\n\t\tC901614E2BE01159005B14AF /* obscuravpn-client.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = \"wrapper.pb-project\"; path = \"obscuravpn-client.xcodeproj\"; sourceTree = \"<group>\"; };\n\t\tC90161A62BE01A8E005B14AF /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = \"<group>\"; };\n\t\tC90161A72BE01A8E005B14AF /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = \"<group>\"; };\n\t\tC90161A82BE01A8E005B14AF /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = \"<group>\"; };\n\t\tC92777D22C248A740058BBFB /* Debug-app.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = \"Debug-app.xcconfig\"; sourceTree = \"<group>\"; };\n\t\tC92778072C24FCE40058BBFB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; };\n\t\tC96FFC8C2C3DD2FD00D87937 /* system-network-extension.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = \"system-network-extension.xcconfig\"; sourceTree = \"<group>\"; };\n\t\tC96FFC8F2C3DD2FD00D87937 /* Debug-system-network-extension.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = \"Debug-system-network-extension.xcconfig\"; sourceTree = \"<group>\"; };\n\t\tC96FFC902C3DD2FD00D87937 /* Release-system-network-extension.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = \"Release-system-network-extension.xcconfig\"; sourceTree = \"<group>\"; };\n\t\tC96FFC922C3DD5DC00D87937 /* Release-app-network-extension.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = \"Release-app-network-extension.xcconfig\"; sourceTree = \"<group>\"; };\n\t\tC96FFC952C3DD5DC00D87937 /* Debug-app-network-extension.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = \"Debug-app-network-extension.xcconfig\"; sourceTree = \"<group>\"; };\n\t\tC96FFC962C3DD5DC00D87937 /* app-network-extension.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = \"app-network-extension.xcconfig\"; sourceTree = \"<group>\"; };\n\t\tC9D486352C123078007D5F2F /* Release-app.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = \"Release-app.xcconfig\"; sourceTree = \"<group>\"; };\n/* End PBXFileReference section */\n\n/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */\n\t\t9614762B2DAEE5210081B2DE /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {\n\t\t\tisa = PBXFileSystemSynchronizedBuildFileExceptionSet;\n\t\t\tmembershipExceptions = (\n\t\t\t\t\"macOS 14 System Extension Demo.mov\",\n\t\t\t\t\"macOS 15 System Extension Demo.mov\",\n\t\t\t);\n\t\t\ttarget = 30761C1D2B6EB17100E5F60D /* Obscura VPN */;\n\t\t};\n/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */\n\n/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */\n\t\t9614762E2DAEE5270081B2DE /* PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet */ = {\n\t\t\tisa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;\n\t\t\tbuildPhase = 9649F96E2DA45808009EFF4F /* Copy Files */;\n\t\t\tmembershipExceptions = (\n\t\t\t\t\"macOS 14 System Extension Demo.mov\",\n\t\t\t\t\"macOS 15 System Extension Demo.mov\",\n\t\t\t);\n\t\t};\n/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */\n\n/* Begin PBXFileSystemSynchronizedRootGroup section */\n\t\t9649F96A2DA457B2009EFF4F /* videos */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (9614762B2DAEE5210081B2DE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 9614762E2DAEE5270081B2DE /* PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = videos; sourceTree = \"<group>\"; };\n/* End PBXFileSystemSynchronizedRootGroup section */\n\n/* Begin PBXFrameworksBuildPhase section */\n\t\t30497E5B2D9EC038008B22F9 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t30497E722D9ED7D4008B22F9 /* DequeModule in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t30761C1B2B6EB17100E5F60D /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t9649F9732DA56142009EFF4F /* AVKit.framework in Frameworks */,\n\t\t\t\tA082F8232C46BF5B002AF810 /* Sparkle in Frameworks */,\n\t\t\t\t30497E782D9ED7EE008B22F9 /* DequeModule in Frameworks */,\n\t\t\t\tA9768FC22DBB02C900A4595F /* OrderedCollections in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t30920D902C3D51EC008690C3 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t30920D942C3D51EC008690C3 /* NetworkExtension.framework in Frameworks */,\n\t\t\t\t30920DA92C3D53F1008690C3 /* libobscuravpn-client.a in Frameworks */,\n\t\t\t\t30497E742D9ED7DF008B22F9 /* DequeModule in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t3098C18B2B921489008877AA /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tC90161572BE011B2005B14AF /* libobscuravpn-client.a in Frameworks */,\n\t\t\t\t3098C18F2B921489008877AA /* NetworkExtension.framework in Frameworks */,\n\t\t\t\t30497E762D9ED7E9008B22F9 /* DequeModule in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tA98F1BCC2DADFF17007F04D3 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tA9768FC02DBB02C400A4595F /* OrderedCollections in Frameworks */,\n\t\t\t\tA98F1BCE2DADFF17007F04D3 /* DequeModule in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXFrameworksBuildPhase section */\n\n/* Begin PBXGroup section */\n\t\t30761C152B6EB17100E5F60D = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tA9963C552DB83E4100D10893 /* Packet Tunnel Provider */,\n\t\t\t\t9649F96A2DA457B2009EFF4F /* videos */,\n\t\t\t\t96516AC22BF928DD00576562 /* build */,\n\t\t\t\tC90161A52BE01A8E005B14AF /* Configurations */,\n\t\t\t\tC901614E2BE01159005B14AF /* obscuravpn-client.xcodeproj */,\n\t\t\t\t30761C202B6EB17100E5F60D /* client */,\n\t\t\t\t3098C1902B921489008877AA /* system-network-extension */,\n\t\t\t\t30EF74BF2BFFC2A90095439F /* shared */,\n\t\t\t\t30920D952C3D51EC008690C3 /* app-network-extension */,\n\t\t\t\t35A6F2502C611341004E1A7C /* third-party */,\n\t\t\t\t30761C352B6EB1F900E5F60D /* Frameworks */,\n\t\t\t\t30761C1F2B6EB17100E5F60D /* Products */,\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t30761C1F2B6EB17100E5F60D /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t30761C1E2B6EB17100E5F60D /* Obscura VPN (Debug).app */,\n\t\t\t\t3098C18E2B921489008877AA /* net.obscura.vpn-client-app.system-network-extension.systemextension */,\n\t\t\t\t30920D932C3D51EC008690C3 /* App Network Extension.appex */,\n\t\t\t\t30497E5E2D9EC038008B22F9 /* Tests.xctest */,\n\t\t\t\tA98F1BD82DADFF17007F04D3 /* Obscura VPN iOS.app */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t30761C202B6EB17100E5F60D /* client */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tA9E44A752E093D3F006B4616 /* Store */,\n\t\t\t\tA983D8002DF276DF007306C5 /* Webviews */,\n\t\t\t\tA94743F62DD170E8002ACD85 /* iOS */,\n\t\t\t\tA936D4972DD161630031B646 /* UXKit */,\n\t\t\t\tA99871102DBAF8080044D136 /* Style */,\n\t\t\t\tA998710D2DBAF7FE0044D136 /* macOS */,\n\t\t\t\t9649F9582DA4233F009EFF4F /* LoopingVideoPlayer.swift */,\n\t\t\t\tA9BCB0602DC54B68006C0133 /* UpdaterDriver+XP.swift */,\n\t\t\t\t966967C02D440B430019AF9F /* LoginItem.swift */,\n\t\t\t\tA936D4922DD15F6A0031B646 /* StartupStatus.swift */,\n\t\t\t\t3096BFF72CECD4BA003D062E /* extensions */,\n\t\t\t\t9623252A2C8BACBC008A9B76 /* StatusItem */,\n\t\t\t\t35230C272C775FF1007ECFEC /* Notifications.swift */,\n\t\t\t\t30761C2A2B6EB17200E5F60D /* client.entitlements */,\n\t\t\t\tC92778072C24FCE40058BBFB /* Info.plist */,\n\t\t\t\t30761C252B6EB17200E5F60D /* Assets.xcassets */,\n\t\t\t\t30761C272B6EB17200E5F60D /* Preview Content */,\n\t\t\t\t3096E0452BF0F5860026DE7F /* app_state.swift */,\n\t\t\t\t3096E03F2BEFC6770026DE7F /* command.swift */,\n\t\t\t\t967D0C852C41A1E500FD0767 /* Constants.swift */,\n\t\t\t\t30761C232B6EB17100E5F60D /* ContentView.swift */,\n\t\t\t\tA9BCB05D2DC54AC0006C0133 /* DebugBundle.swift */,\n\t\t\t\t35A6F2512C611DD1004E1A7C /* DebugBundle+XP.swift */,\n\t\t\t\t35F8DE862C6666C40016CEEB /* DebugBundleExtensionInfo.swift */,\n\t\t\t\t30920D782C2057B1008690C3 /* initNetworkExtension.swift */,\n\t\t\t\t35F8DE7E2C6559D20016CEEB /* OSLogEntryEncodable.swift */,\n\t\t\t\t3096E0212BDC1FFB0026DE7F /* ScriptMessageHandlers.swift */,\n\t\t\t\t30920D742C1F71BB008690C3 /* startup.swift */,\n\t\t\t\t30920D7C2C207379008690C3 /* TunnelProvider.swift */,\n\t\t\t\t30C5320B2CC959AE00936E1F /* OsStatus.swift */,\n\t\t\t);\n\t\t\tpath = client;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t30761C272B6EB17200E5F60D /* Preview Content */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t30761C282B6EB17200E5F60D /* Preview Assets.xcassets */,\n\t\t\t);\n\t\t\tpath = \"Preview Content\";\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t30761C352B6EB1F900E5F60D /* Frameworks */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t9649F9722DA56142009EFF4F /* AVKit.framework */,\n\t\t\t\t30761C362B6EB1F900E5F60D /* NetworkExtension.framework */,\n\t\t\t);\n\t\t\tname = Frameworks;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t30920D952C3D51EC008690C3 /* app-network-extension */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t30920D982C3D51EC008690C3 /* Info.plist */,\n\t\t\t\t30920D992C3D51EC008690C3 /* entitlements.entitlements */,\n\t\t\t);\n\t\t\tpath = \"app-network-extension\";\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t3096BFF72CECD4BA003D062E /* extensions */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t3096BFF82CECD50F003D062E /* NEVPNStatus.swift */,\n\t\t\t);\n\t\t\tpath = extensions;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t3098C1902B921489008877AA /* system-network-extension */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t3098C1952B921489008877AA /* Info.plist */,\n\t\t\t\t3098C1962B921489008877AA /* entitlements.entitlements */,\n\t\t\t);\n\t\t\tpath = \"system-network-extension\";\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t30EF74BF2BFFC2A90095439F /* shared */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t1799341E2E9411770089B6EB /* NotificationIds.swift */,\n\t\t\t\t30497E6D2D9ED57F008B22F9 /* Box.swift */,\n\t\t\t\t968B02B82CFF5B7B0053D0EF /* Account.swift */,\n\t\t\t\tA94BF4452E53BBE9007FDD1C /* Account+CustomStringConvertible.swift */,\n\t\t\t\t96C920682CD91D94002C85EA /* String.swift */,\n\t\t\t\t35230C182C764F0D007ECFEC /* Swift.swift */,\n\t\t\t\t35230C152C764B97007ECFEC /* Concurrency.swift */,\n\t\t\t\t30497E652D9EC09E008B22F9 /* ConcurrencyTests.swift */,\n\t\t\t\t35219C3A2C6BD57F00E63BB8 /* Debug.swift */,\n\t\t\t\t35A6F2532C6121C2004E1A7C /* time.swift */,\n\t\t\t\t352D58DF2C4AE796002F3404 /* ObservableValue.swift */,\n\t\t\t\t30EF74C02BFFE48C0095439F /* FfiCb.swift */,\n\t\t\t\t30EF74CC2C02244C0095439F /* NetworkExtensionIpc.swift */,\n\t\t\t\t30920DAC2C3DC174008690C3 /* InfoDict.swift */,\n\t\t\t\t309BA9122C45DFC8000A7428 /* Sleep.swift */,\n\t\t\t\t300452772C49BC90000B78F7 /* Json.swift */,\n\t\t\t\t309B467C2C4C152900A1B00F /* StringError.swift */,\n\t\t\t\t30C532092CC9558000936E1F /* WatchableValue.swift */,\n\t\t\t\tA94B1DCC2E28B13E00E5F325 /* AccountInfo+Util.swift */,\n\t\t\t);\n\t\t\tpath = shared;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t35A6F2502C611341004E1A7C /* third-party */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t96615E282C598A9600120DEF /* CwlSysctl.swift */,\n\t\t\t);\n\t\t\tpath = \"third-party\";\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t9623252A2C8BACBC008A9B76 /* StatusItem */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t9632E4632D19C5EA00BC8E3F /* AccountStatusItem.swift */,\n\t\t\t\t962325172C875B3E008A9B76 /* StatusMenu.swift */,\n\t\t\t\t962325222C8A3B58008A9B76 /* MenuItemView.swift */,\n\t\t\t\t962325202C88D4E8008A9B76 /* ObscuraToggle.swift */,\n\t\t\t\t96C9205A2CD549B1002C85EA /* BandwidthStatus.swift */,\n\t\t\t);\n\t\t\tpath = StatusItem;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tA936D4972DD161630031B646 /* UXKit */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tA936D4982DD1617F0031B646 /* UXImage.swift */,\n\t\t\t\tA936D4422DD149290031B646 /* UXViewController.swift */,\n\t\t\t\tA936D4432DD149290031B646 /* UXViewRepresentable.swift */,\n\t\t\t);\n\t\t\tpath = UXKit;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tA94743F62DD170E8002ACD85 /* iOS */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tA94743F72DD170F5002ACD85 /* iOSClientApp.swift */,\n\t\t\t\t1D9F50072E6280FF001C080B /* MailDelegate.swift */,\n\t\t\t);\n\t\t\tpath = iOS;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tA983D8002DF276DF007306C5 /* Webviews */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tA983D7DF2DF25435007306C5 /* WebviewsController.swift */,\n\t\t\t\tA9029C182DEEDCBC00AAD761 /* ObscuraUIMacOSWrapper.swift */,\n\t\t\t\tA95A91322DF9910C005CB52A /* ObscuraUIIOSWrapperAndTabs.swift */,\n\t\t\t\tA9200A842DD1251C00FD035C /* ObscuraUIWebView.swift */,\n\t\t\t\tA983D7E22DF261BB007306C5 /* ExternalWebView.swift */,\n\t\t\t);\n\t\t\tpath = Webviews;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tA9963C552DB83E4100D10893 /* Packet Tunnel Provider */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t309BA90B2C446125000A7428 /* NetworkSettings.swift */,\n\t\t\t\t17B910012E0DB3E00073AAD7 /* Keychain.swift */,\n\t\t\t\t309BA9072C44394E000A7428 /* RustFfi.swift */,\n\t\t\t\t3098C1912B921489008877AA /* PacketTunnelProvider.swift */,\n\t\t\t\t3098C1932B921489008877AA /* main.swift */,\n\t\t\t);\n\t\t\tpath = \"Packet Tunnel Provider\";\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tA998710D2DBAF7FE0044D136 /* macOS */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t30761C212B6EB17100E5F60D /* ClientApp.swift */,\n\t\t\t\tA936D49B2DD162AB0031B646 /* InstallSystemExtensionView.swift */,\n\t\t\t\tA936D4952DD160750031B646 /* UpdateSystemExtensionView.swift */,\n\t\t\t\tA9768FAE2DBB01C400A4595F /* CheckForUpdatesView.swift */,\n\t\t\t\tA998710C2DBAF7FE0044D136 /* RegisterLoginItemView.swift */,\n\t\t\t\tA9768FAC2DBB01AD00A4595F /* UpdaterDriver.swift */,\n\t\t\t\tA9768FB32DBB01EE00A4595F /* SparkleUpdater.swift */,\n\t\t\t);\n\t\t\tpath = macOS;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tA99871102DBAF8080044D136 /* Style */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t96DB3D532E60F01B005B4D1B /* Appearance.swift */,\n\t\t\t\tA94B1D9B2E286B3900E5F325 /* ConditionallyDisabled.swift */,\n\t\t\t\tA998710F2DBAF8080044D136 /* NoFadeButtonStyle.swift */,\n\t\t\t\tA9758FDD2E41910300741928 /* HyperlinkButtonStyle.swift */,\n\t\t\t);\n\t\t\tpath = Style;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tA9E44A752E093D3F006B4616 /* Store */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t1DAD5D652ED8FAA100DDB469 /* StoreKitModel.swift */,\n\t\t\t\t1DAD5D612ED7F27500DDB469 /* StoreKitListener.swift */,\n\t\t\t\tA94B1DD02E28B16800E5F325 /* Product+Convenience.swift */,\n\t\t\t\tA9E44A7B2E0947EA006B4616 /* Product+Convenience.swift */,\n\t\t\t\tA94B1E3C2E2B236800E5F325 /* Obscura VPN Local.storekit */,\n\t\t\t\tA9E44A762E093DA7006B4616 /* Obscura VPN.storekit */,\n\t\t\t);\n\t\t\tpath = Store;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tC901614F2BE01159005B14AF /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tC90161542BE01159005B14AF /* libobscuravpn-client.a */,\n\t\t\t\tC90161562BE01159005B14AF /* obscuravpn-client */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tC90161A52BE01A8E005B14AF /* Configurations */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tC96FFC962C3DD5DC00D87937 /* app-network-extension.xcconfig */,\n\t\t\t\tC96FFC952C3DD5DC00D87937 /* Debug-app-network-extension.xcconfig */,\n\t\t\t\tC96FFC922C3DD5DC00D87937 /* Release-app-network-extension.xcconfig */,\n\t\t\t\tA06E85AA2C2ECA680087C8C8 /* buildversion.xcconfig */,\n\t\t\t\tC96FFC8F2C3DD2FD00D87937 /* Debug-system-network-extension.xcconfig */,\n\t\t\t\tC96FFC902C3DD2FD00D87937 /* Release-system-network-extension.xcconfig */,\n\t\t\t\tC96FFC8C2C3DD2FD00D87937 /* system-network-extension.xcconfig */,\n\t\t\t\tA06E85A62C2ECA680087C8C8 /* bundle-ids.xcconfig */,\n\t\t\t\tC90161A82BE01A8E005B14AF /* Base.xcconfig */,\n\t\t\t\tA06E85A92C2ECA680087C8C8 /* app.xcconfig */,\n\t\t\t\tC90161A62BE01A8E005B14AF /* Debug.xcconfig */,\n\t\t\t\tC90161A72BE01A8E005B14AF /* Release.xcconfig */,\n\t\t\t\tC92777D22C248A740058BBFB /* Debug-app.xcconfig */,\n\t\t\t\tC9D486352C123078007D5F2F /* Release-app.xcconfig */,\n\t\t\t);\n\t\t\tpath = Configurations;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXGroup section */\n\n/* Begin PBXNativeTarget section */\n\t\t30497E5D2D9EC038008B22F9 /* Tests */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 30497E622D9EC038008B22F9 /* Build configuration list for PBXNativeTarget \"Tests\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t30497E5A2D9EC038008B22F9 /* Sources */,\n\t\t\t\t30497E5B2D9EC038008B22F9 /* Frameworks */,\n\t\t\t\t30497E5C2D9EC038008B22F9 /* Resources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = Tests;\n\t\t\tpackageProductDependencies = (\n\t\t\t\t30497E712D9ED7D4008B22F9 /* DequeModule */,\n\t\t\t);\n\t\t\tproductName = Tests;\n\t\t\tproductReference = 30497E5E2D9EC038008B22F9 /* Tests.xctest */;\n\t\t\tproductType = \"com.apple.product-type.bundle.unit-test\";\n\t\t};\n\t\t30761C1D2B6EB17100E5F60D /* Obscura VPN */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 30761C2D2B6EB17200E5F60D /* Build configuration list for PBXNativeTarget \"Obscura VPN\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t30761C1A2B6EB17100E5F60D /* Sources */,\n\t\t\t\t30761C1B2B6EB17100E5F60D /* Frameworks */,\n\t\t\t\t30761C1C2B6EB17100E5F60D /* Resources */,\n\t\t\t\t9649F96E2DA45808009EFF4F /* Copy Files */,\n\t\t\t\t3098C1862B9211B1008877AA /* Embed System Extensions */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t\t3098C1982B921489008877AA /* PBXTargetDependency */,\n\t\t\t);\n\t\t\tfileSystemSynchronizedGroups = (\n\t\t\t\t9649F96A2DA457B2009EFF4F /* videos */,\n\t\t\t);\n\t\t\tname = \"Obscura VPN\";\n\t\t\tpackageProductDependencies = (\n\t\t\t\tA082F8222C46BF5B002AF810 /* Sparkle */,\n\t\t\t\t30497E772D9ED7EE008B22F9 /* DequeModule */,\n\t\t\t\tA9768FC12DBB02C900A4595F /* OrderedCollections */,\n\t\t\t);\n\t\t\tproductName = client;\n\t\t\tproductReference = 30761C1E2B6EB17100E5F60D /* Obscura VPN (Debug).app */;\n\t\t\tproductType = \"com.apple.product-type.application\";\n\t\t};\n\t\t30920D922C3D51EC008690C3 /* App Network Extension */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 30920DA22C3D51EC008690C3 /* Build configuration list for PBXNativeTarget \"App Network Extension\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t30920D8F2C3D51EC008690C3 /* Sources */,\n\t\t\t\t30920D902C3D51EC008690C3 /* Frameworks */,\n\t\t\t\t30920D912C3D51EC008690C3 /* Resources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = \"App Network Extension\";\n\t\t\tproductName = \"app-network-extension\";\n\t\t\tproductReference = 30920D932C3D51EC008690C3 /* App Network Extension.appex */;\n\t\t\tproductType = \"com.apple.product-type.app-extension\";\n\t\t};\n\t\t3098C18D2B921489008877AA /* System Network Extension */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 3098C19A2B921489008877AA /* Build configuration list for PBXNativeTarget \"System Network Extension\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t3098C18A2B921489008877AA /* Sources */,\n\t\t\t\t3098C18B2B921489008877AA /* Frameworks */,\n\t\t\t\t3098C18C2B921489008877AA /* Resources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = \"System Network Extension\";\n\t\t\tproductName = \"system-network-extension\";\n\t\t\tproductReference = 3098C18E2B921489008877AA /* net.obscura.vpn-client-app.system-network-extension.systemextension */;\n\t\t\tproductType = \"com.apple.product-type.system-extension\";\n\t\t};\n\t\tA98F1B9D2DADFF17007F04D3 /* Obscura VPN iOS */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = A98F1BD52DADFF17007F04D3 /* Build configuration list for PBXNativeTarget \"Obscura VPN iOS\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tA98F1BA42DADFF17007F04D3 /* Sources */,\n\t\t\t\tA98F1BCC2DADFF17007F04D3 /* Frameworks */,\n\t\t\t\tA98F1BCF2DADFF17007F04D3 /* Resources */,\n\t\t\t\tA98F1BDD2DAE00A0007F04D3 /* Embed Foundation Extensions */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t\tA98F1BDC2DAE00A0007F04D3 /* PBXTargetDependency */,\n\t\t\t\tA98F1BDF2DAE00A5007F04D3 /* PBXTargetDependency */,\n\t\t\t);\n\t\t\tname = \"Obscura VPN iOS\";\n\t\t\tpackageProductDependencies = (\n\t\t\t\tA98F1BA22DADFF17007F04D3 /* DequeModule */,\n\t\t\t\tA9768FBF2DBB02C400A4595F /* OrderedCollections */,\n\t\t\t);\n\t\t\tproductName = client;\n\t\t\tproductReference = A98F1BD82DADFF17007F04D3 /* Obscura VPN iOS.app */;\n\t\t\tproductType = \"com.apple.product-type.application\";\n\t\t};\n/* End PBXNativeTarget section */\n\n/* Begin PBXProject section */\n\t\t30761C162B6EB17100E5F60D /* Project object */ = {\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {\n\t\t\t\tBuildIndependentTargetsInParallel = 1;\n\t\t\t\tLastSwiftUpdateCheck = 1620;\n\t\t\t\tLastUpgradeCheck = 1520;\n\t\t\t\tTargetAttributes = {\n\t\t\t\t\t30497E5D2D9EC038008B22F9 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 16.2;\n\t\t\t\t\t};\n\t\t\t\t\t30761C1D2B6EB17100E5F60D = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 15.2;\n\t\t\t\t\t};\n\t\t\t\t\t30920D922C3D51EC008690C3 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 15.2;\n\t\t\t\t\t};\n\t\t\t\t\t3098C18D2B921489008877AA = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 15.2;\n\t\t\t\t\t};\n\t\t\t\t};\n\t\t\t};\n\t\t\tbuildConfigurationList = 30761C192B6EB17100E5F60D /* Build configuration list for PBXProject \"client\" */;\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (\n\t\t\t\ten,\n\t\t\t\tBase,\n\t\t\t);\n\t\t\tmainGroup = 30761C152B6EB17100E5F60D;\n\t\t\tpackageReferences = (\n\t\t\t\tA082F8212C46BF5B002AF810 /* XCRemoteSwiftPackageReference \"Sparkle\" */,\n\t\t\t\t30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference \"swift-collections\" */,\n\t\t\t);\n\t\t\tpreferredProjectObjectVersion = 56;\n\t\t\tproductRefGroup = 30761C1F2B6EB17100E5F60D /* Products */;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectReferences = (\n\t\t\t\t{\n\t\t\t\t\tProductGroup = C901614F2BE01159005B14AF /* Products */;\n\t\t\t\t\tProjectRef = C901614E2BE01159005B14AF /* obscuravpn-client.xcodeproj */;\n\t\t\t\t},\n\t\t\t);\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\t30761C1D2B6EB17100E5F60D /* Obscura VPN */,\n\t\t\t\t3098C18D2B921489008877AA /* System Network Extension */,\n\t\t\t\t30920D922C3D51EC008690C3 /* App Network Extension */,\n\t\t\t\t30497E5D2D9EC038008B22F9 /* Tests */,\n\t\t\t\tA98F1B9D2DADFF17007F04D3 /* Obscura VPN iOS */,\n\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n/* Begin PBXReferenceProxy section */\n\t\tC90161542BE01159005B14AF /* libobscuravpn-client.a */ = {\n\t\t\tisa = PBXReferenceProxy;\n\t\t\tfileType = archive.ar;\n\t\t\tpath = \"libobscuravpn-client.a\";\n\t\t\tremoteRef = C90161532BE01159005B14AF /* PBXContainerItemProxy */;\n\t\t\tsourceTree = BUILT_PRODUCTS_DIR;\n\t\t};\n\t\tC90161562BE01159005B14AF /* obscuravpn-client */ = {\n\t\t\tisa = PBXReferenceProxy;\n\t\t\tfileType = \"compiled.mach-o.executable\";\n\t\t\tpath = \"obscuravpn-client\";\n\t\t\tremoteRef = C90161552BE01159005B14AF /* PBXContainerItemProxy */;\n\t\t\tsourceTree = BUILT_PRODUCTS_DIR;\n\t\t};\n/* End PBXReferenceProxy section */\n\n/* Begin PBXResourcesBuildPhase section */\n\t\t30497E5C2D9EC038008B22F9 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t30761C1C2B6EB17100E5F60D /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t30761C292B6EB17200E5F60D /* Preview Assets.xcassets in Resources */,\n\t\t\t\t96516AC32BF928DD00576562 /* build in Resources */,\n\t\t\t\t30761C262B6EB17200E5F60D /* Assets.xcassets in Resources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t30920D912C3D51EC008690C3 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t3098C18C2B921489008877AA /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tA98F1BCF2DADFF17007F04D3 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tA94B1E3D2E2B236800E5F325 /* Obscura VPN Local.storekit in Resources */,\n\t\t\t\tA98F1BD02DADFF17007F04D3 /* Preview Assets.xcassets in Resources */,\n\t\t\t\tA98F1BD12DADFF17007F04D3 /* build in Resources */,\n\t\t\t\tA9E44A782E093DA7006B4616 /* Obscura VPN.storekit in Resources */,\n\t\t\t\tA98F1BD22DADFF17007F04D3 /* Assets.xcassets in Resources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXResourcesBuildPhase section */\n\n/* Begin PBXSourcesBuildPhase section */\n\t\t30497E5A2D9EC038008B22F9 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t30497E672D9EC0BA008B22F9 /* Concurrency.swift in Sources */,\n\t\t\t\t30497E702D9ED5AD008B22F9 /* Box.swift in Sources */,\n\t\t\t\t30497E692D9EC16A008B22F9 /* String.swift in Sources */,\n\t\t\t\t30497E6A2D9EC19E008B22F9 /* StringError.swift in Sources */,\n\t\t\t\t30497E662D9EC09E008B22F9 /* ConcurrencyTests.swift in Sources */,\n\t\t\t\t30497E682D9EC129008B22F9 /* Swift.swift in Sources */,\n\t\t\t\t30497E6B2D9EC7AC008B22F9 /* Sleep.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t30761C1A2B6EB17100E5F60D /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tA9758FDF2E41910300741928 /* HyperlinkButtonStyle.swift in Sources */,\n\t\t\t\t3096E0222BDC1FFB0026DE7F /* ScriptMessageHandlers.swift in Sources */,\n\t\t\t\tA983D7E02DF25435007306C5 /* WebviewsController.swift in Sources */,\n\t\t\t\tA9200A862DD1251C00FD035C /* ObscuraUIWebView.swift in Sources */,\n\t\t\t\t30EF74C12BFFE48C0095439F /* FfiCb.swift in Sources */,\n\t\t\t\t309B467D2C4C152900A1B00F /* StringError.swift in Sources */,\n\t\t\t\tA9768FB42DBB01EE00A4595F /* SparkleUpdater.swift in Sources */,\n\t\t\t\t352D58E02C4AE796002F3404 /* ObservableValue.swift in Sources */,\n\t\t\t\t962325232C8A3B58008A9B76 /* MenuItemView.swift in Sources */,\n\t\t\t\t30C5320C2CC959AE00936E1F /* OsStatus.swift in Sources */,\n\t\t\t\tA998710E2DBAF7FE0044D136 /* RegisterLoginItemView.swift in Sources */,\n\t\t\t\t30497E6E2D9ED584008B22F9 /* Box.swift in Sources */,\n\t\t\t\tA936D4452DD1492A0031B646 /* UXViewRepresentable.swift in Sources */,\n\t\t\t\tA936D4462DD1492A0031B646 /* UXViewController.swift in Sources */,\n\t\t\t\tA94B1DCE2E28B13E00E5F325 /* AccountInfo+Util.swift in Sources */,\n\t\t\t\tA94BF4462E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */,\n\t\t\t\t9632E4642D19C5EC00BC8E3F /* AccountStatusItem.swift in Sources */,\n\t\t\t\t30920D752C1F71BB008690C3 /* startup.swift in Sources */,\n\t\t\t\t30920D792C2057B1008690C3 /* initNetworkExtension.swift in Sources */,\n\t\t\t\tA99871112DBAF8080044D136 /* NoFadeButtonStyle.swift in Sources */,\n\t\t\t\t30920DAD2C3DC174008690C3 /* InfoDict.swift in Sources */,\n\t\t\t\t96C9205B2CD549B1002C85EA /* BandwidthStatus.swift in Sources */,\n\t\t\t\t966967C12D440B450019AF9F /* LoginItem.swift in Sources */,\n\t\t\t\t35230C162C764B97007ECFEC /* Concurrency.swift in Sources */,\n\t\t\t\t35F8DE7F2C6559D20016CEEB /* OSLogEntryEncodable.swift in Sources */,\n\t\t\t\t96C920692CD91D96002C85EA /* String.swift in Sources */,\n\t\t\t\t35219C3B2C6BD57F00E63BB8 /* Debug.swift in Sources */,\n\t\t\t\t962325182C875B3E008A9B76 /* StatusMenu.swift in Sources */,\n\t\t\t\tA9768FAF2DBB01C400A4595F /* CheckForUpdatesView.swift in Sources */,\n\t\t\t\t30761C242B6EB17100E5F60D /* ContentView.swift in Sources */,\n\t\t\t\t35F8DE872C6666C40016CEEB /* DebugBundleExtensionInfo.swift in Sources */,\n\t\t\t\tA9768FAD2DBB01AD00A4595F /* UpdaterDriver.swift in Sources */,\n\t\t\t\t35A6F2542C6121C2004E1A7C /* time.swift in Sources */,\n\t\t\t\tA936D4932DD15F6A0031B646 /* StartupStatus.swift in Sources */,\n\t\t\t\tA9BCB0622DC54B68006C0133 /* UpdaterDriver+XP.swift in Sources */,\n\t\t\t\tA983D7E32DF261BB007306C5 /* ExternalWebView.swift in Sources */,\n\t\t\t\tA94B1E242E2B03B900E5F325 /* ConditionallyDisabled.swift in Sources */,\n\t\t\t\t300452782C49BC90000B78F7 /* Json.swift in Sources */,\n\t\t\t\t968B02B92CFF5B7B0053D0EF /* Account.swift in Sources */,\n\t\t\t\t35A6F2522C611DD1004E1A7C /* DebugBundle+XP.swift in Sources */,\n\t\t\t\t96615E292C598A9600120DEF /* CwlSysctl.swift in Sources */,\n\t\t\t\t9649F9592DA4233F009EFF4F /* LoopingVideoPlayer.swift in Sources */,\n\t\t\t\t30EF74CD2C02244C0095439F /* NetworkExtensionIpc.swift in Sources */,\n\t\t\t\t35230C282C775FF1007ECFEC /* Notifications.swift in Sources */,\n\t\t\t\t30761C222B6EB17100E5F60D /* ClientApp.swift in Sources */,\n\t\t\t\t967D0C862C41A1E500FD0767 /* Constants.swift in Sources */,\n\t\t\t\tA9BCB05E2DC54AC0006C0133 /* DebugBundle.swift in Sources */,\n\t\t\t\t30920D7D2C207379008690C3 /* TunnelProvider.swift in Sources */,\n\t\t\t\t3096E0462BF0F5870026DE7F /* app_state.swift in Sources */,\n\t\t\t\t30C5320A2CC9558000936E1F /* WatchableValue.swift in Sources */,\n\t\t\t\tA936D4962DD160750031B646 /* UpdateSystemExtensionView.swift in Sources */,\n\t\t\t\t179934222E94117C0089B6EB /* NotificationIds.swift in Sources */,\n\t\t\t\t3096E0402BEFC6770026DE7F /* command.swift in Sources */,\n\t\t\t\t35230C192C764F0D007ECFEC /* Swift.swift in Sources */,\n\t\t\t\t3096BFF92CECD50F003D062E /* NEVPNStatus.swift in Sources */,\n\t\t\t\tA936D4992DD1617F0031B646 /* UXImage.swift in Sources */,\n\t\t\t\t96DB3D542E60F01B005B4D1B /* Appearance.swift in Sources */,\n\t\t\t\t309BA9132C45DFC8000A7428 /* Sleep.swift in Sources */,\n\t\t\t\tA9029C192DEEDCBC00AAD761 /* ObscuraUIMacOSWrapper.swift in Sources */,\n\t\t\t\tA936D49C2DD162AB0031B646 /* InstallSystemExtensionView.swift in Sources */,\n\t\t\t\t962325212C88D4E8008A9B76 /* ObscuraToggle.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t30920D8F2C3D51EC008690C3 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tA9963C632DB8401100D10893 /* Sleep.swift in Sources */,\n\t\t\t\tA9963C622DB8400D00D10893 /* PacketTunnelProvider.swift in Sources */,\n\t\t\t\tA94BF4492E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */,\n\t\t\t\tA9963C612DB8400900D10893 /* Concurrency.swift in Sources */,\n\t\t\t\tA9963C602DB8400300D10893 /* WatchableValue.swift in Sources */,\n\t\t\t\tA9963C5F2DB83FFD00D10893 /* NetworkExtensionIpc.swift in Sources */,\n\t\t\t\t17B910032E0DB3E50073AAD7 /* Keychain.swift in Sources */,\n\t\t\t\tA9963C5E2DB83FF700D10893 /* RustFfi.swift in Sources */,\n\t\t\t\tA9963C5D2DB83FF300D10893 /* StringError.swift in Sources */,\n\t\t\t\tA9963C5C2DB83FD600D10893 /* Swift.swift in Sources */,\n\t\t\t\tA9963C5B2DB83FD200D10893 /* Json.swift in Sources */,\n\t\t\t\tA9963C5A2DB83FCF00D10893 /* NetworkSettings.swift in Sources */,\n\t\t\t\t179934202E94117C0089B6EB /* NotificationIds.swift in Sources */,\n\t\t\t\tA9963C592DB83FCB00D10893 /* Debug.swift in Sources */,\n\t\t\t\tA9963C582DB83FC600D10893 /* Box.swift in Sources */,\n\t\t\t\tA9963C572DB83FC200D10893 /* Account.swift in Sources */,\n\t\t\t\tA9963C562DB83FBE00D10893 /* FfiCb.swift in Sources */,\n\t\t\t\t30920DAF2C3DC174008690C3 /* InfoDict.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t3098C18A2B921489008877AA /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t35A6F2552C613366004E1A7C /* time.swift in Sources */,\n\t\t\t\t30EF74CE2C02244C0095439F /* NetworkExtensionIpc.swift in Sources */,\n\t\t\t\t1799341F2E94117C0089B6EB /* NotificationIds.swift in Sources */,\n\t\t\t\t30497E6F2D9ED584008B22F9 /* Box.swift in Sources */,\n\t\t\t\t309BA9142C45DFC8000A7428 /* Sleep.swift in Sources */,\n\t\t\t\t35230C1A2C764F0D007ECFEC /* Swift.swift in Sources */,\n\t\t\t\t352D58E12C4AE796002F3404 /* ObservableValue.swift in Sources */,\n\t\t\t\t30920DAE2C3DC174008690C3 /* InfoDict.swift in Sources */,\n\t\t\t\tA94BF4482E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */,\n\t\t\t\t35219C3C2C6BD57F00E63BB8 /* Debug.swift in Sources */,\n\t\t\t\t3098C1942B921489008877AA /* main.swift in Sources */,\n\t\t\t\t17B910022E0DB3E50073AAD7 /* Keychain.swift in Sources */,\n\t\t\t\t35230C172C764B97007ECFEC /* Concurrency.swift in Sources */,\n\t\t\t\t309BFE3F2D9C169500366431 /* WatchableValue.swift in Sources */,\n\t\t\t\t309B467E2C4C152900A1B00F /* StringError.swift in Sources */,\n\t\t\t\t309BA90C2C446125000A7428 /* NetworkSettings.swift in Sources */,\n\t\t\t\t300452792C49BC90000B78F7 /* Json.swift in Sources */,\n\t\t\t\t968B02BA2CFF5B7B0053D0EF /* Account.swift in Sources */,\n\t\t\t\t309BA90A2C443978000A7428 /* RustFfi.swift in Sources */,\n\t\t\t\t30EF74C22BFFE86B0095439F /* FfiCb.swift in Sources */,\n\t\t\t\t96C9206A2CD91D96002C85EA /* String.swift in Sources */,\n\t\t\t\t3098C1922B921489008877AA /* PacketTunnelProvider.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tA98F1BA42DADFF17007F04D3 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t1DB5666B2EA565E7009CEB08 /* String.swift in Sources */,\n\t\t\t\t1D9F4FE02E5BCDED001C080B /* time.swift in Sources */,\n\t\t\t\t1D9F4FDE2E5BC912001C080B /* DebugBundle.swift in Sources */,\n\t\t\t\tA94743F92DD17143002ACD85 /* iOSClientApp.swift in Sources */,\n\t\t\t\tA936D49A2DD161B00031B646 /* UXImage.swift in Sources */,\n\t\t\t\tA936D4902DD15B6B0031B646 /* startup.swift in Sources */,\n\t\t\t\tA936D4732DD14E470031B646 /* command.swift in Sources */,\n\t\t\t\t1DAD5D662ED8FAA100DDB469 /* StoreKitModel.swift in Sources */,\n\t\t\t\tA936D4722DD14E040031B646 /* ScriptMessageHandlers.swift in Sources */,\n\t\t\t\tA94BF4472E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */,\n\t\t\t\tA936D4712DD14DC30031B646 /* ObscuraUIWebView.swift in Sources */,\n\t\t\t\tA9200A872DD127A200FD035C /* Notifications.swift in Sources */,\n\t\t\t\tA9758FDE2E41910300741928 /* HyperlinkButtonStyle.swift in Sources */,\n\t\t\t\tA9768FBE2DBB02BC00A4595F /* ContentView.swift in Sources */,\n\t\t\t\tA99871292DBAF90B0044D136 /* Debug.swift in Sources */,\n\t\t\t\tA99871282DBAF8FB0044D136 /* DebugBundle+XP.swift in Sources */,\n\t\t\t\tA99871272DBAF8A60044D136 /* TunnelProvider.swift in Sources */,\n\t\t\t\tA99871262DBAF89E0044D136 /* NetworkExtensionIpc.swift in Sources */,\n\t\t\t\tA99871252DBAF8990044D136 /* InfoDict.swift in Sources */,\n\t\t\t\tA9E44A7C2E0947EA006B4616 /* Product+Convenience.swift in Sources */,\n\t\t\t\tA99871242DBAF8940044D136 /* Account.swift in Sources */,\n\t\t\t\tA94B1D9C2E286B3900E5F325 /* ConditionallyDisabled.swift in Sources */,\n\t\t\t\tA99871232DBAF88F0044D136 /* NEVPNStatus.swift in Sources */,\n\t\t\t\t96DB3D552E60F01B005B4D1B /* Appearance.swift in Sources */,\n\t\t\t\tA99871222DBAF8890044D136 /* OSLogEntryEncodable.swift in Sources */,\n\t\t\t\tA99871212DBAF8850044D136 /* Json.swift in Sources */,\n\t\t\t\tA99871202DBAF8810044D136 /* StringError.swift in Sources */,\n\t\t\t\tA983D7E12DF25435007306C5 /* WebviewsController.swift in Sources */,\n\t\t\t\tA998711F2DBAF87D0044D136 /* Sleep.swift in Sources */,\n\t\t\t\tA983D7E42DF261BB007306C5 /* ExternalWebView.swift in Sources */,\n\t\t\t\tA936D4942DD15F6A0031B646 /* StartupStatus.swift in Sources */,\n\t\t\t\tA95A91332DF9910C005CB52A /* ObscuraUIIOSWrapperAndTabs.swift in Sources */,\n\t\t\t\tA998711E2DBAF8790044D136 /* Concurrency.swift in Sources */,\n\t\t\t\tA998711D2DBAF8740044D136 /* Box.swift in Sources */,\n\t\t\t\t179934212E94117C0089B6EB /* NotificationIds.swift in Sources */,\n\t\t\t\tA94B1DD42E28B16800E5F325 /* Product+Convenience.swift in Sources */,\n\t\t\t\tA9BCB0612DC54B68006C0133 /* UpdaterDriver+XP.swift in Sources */,\n\t\t\t\t1D9F50082E6280FF001C080B /* MailDelegate.swift in Sources */,\n\t\t\t\tA998711C2DBAF8700044D136 /* Swift.swift in Sources */,\n\t\t\t\tA936D4472DD1492A0031B646 /* UXViewRepresentable.swift in Sources */,\n\t\t\t\tA936D4482DD1492A0031B646 /* UXViewController.swift in Sources */,\n\t\t\t\tA998711B2DBAF8680044D136 /* FfiCb.swift in Sources */,\n\t\t\t\tA998711A2DBAF8610044D136 /* Constants.swift in Sources */,\n\t\t\t\tA99871192DBAF85C0044D136 /* ObservableValue.swift in Sources */,\n\t\t\t\tA99871182DBAF8590044D136 /* WatchableValue.swift in Sources */,\n\t\t\t\tA94B1DCD2E28B13E00E5F325 /* AccountInfo+Util.swift in Sources */,\n\t\t\t\tA99871162DBAF8510044D136 /* OsStatus.swift in Sources */,\n\t\t\t\tA99871152DBAF84D0044D136 /* app_state.swift in Sources */,\n\t\t\t\tA99871122DBAF8080044D136 /* NoFadeButtonStyle.swift in Sources */,\n\t\t\t\t1DAD5D622ED7F27800DDB469 /* StoreKitListener.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXSourcesBuildPhase section */\n\n/* Begin PBXTargetDependency section */\n\t\t3098C1982B921489008877AA /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\ttarget = 3098C18D2B921489008877AA /* System Network Extension */;\n\t\t\ttargetProxy = 3098C1972B921489008877AA /* PBXContainerItemProxy */;\n\t\t};\n\t\tA98F1BDC2DAE00A0007F04D3 /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\ttarget = 30920D922C3D51EC008690C3 /* App Network Extension */;\n\t\t\ttargetProxy = A98F1BDB2DAE00A0007F04D3 /* PBXContainerItemProxy */;\n\t\t};\n\t\tA98F1BDF2DAE00A5007F04D3 /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\ttarget = 30920D922C3D51EC008690C3 /* App Network Extension */;\n\t\t\ttargetProxy = A98F1BDE2DAE00A5007F04D3 /* PBXContainerItemProxy */;\n\t\t};\n/* End PBXTargetDependency section */\n\n/* Begin XCBuildConfiguration section */\n\t\t30497E632D9EC038008B22F9 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tDEVELOPMENT_TEAM = 5G943LR562;\n\t\t\t\tGCC_PREPROCESSOR_DEFINITIONS = (\n\t\t\t\t\t\"DEBUG=1\",\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t);\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 15.2;\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = net.obscura.Tests;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = \"DEBUG $(inherited)\";\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t30497E642D9EC038008B22F9 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tDEVELOPMENT_TEAM = 5G943LR562;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 15.2;\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = net.obscura.Tests;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t30761C2C2B6EB17200E5F60D /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++20\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu17;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tLOCALIZATION_PREFERS_STRING_CATALOGS = YES;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t30761C2F2B6EB17200E5F60D /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = C9D486352C123078007D5F2F /* Release-app.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = \"client/client-macos.entitlements\";\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tDEVELOPMENT_ASSET_PATHS = \"\\\"client/Preview Content\\\"\";\n\t\t\t\tENABLE_PREVIEWS = YES;\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tOBSCURA_MAGIC_NO_NIX = \"\";\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t30920D9E2C3D51EC008690C3 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = C96FFC952C3DD5DC00D87937 /* Debug-app-network-extension.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t\t\"@executable_path/../../../../Frameworks\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSUPPORTED_PLATFORMS = \"iphoneos iphonesimulator\";\n\t\t\t\tSUPPORTS_MACCATALYST = NO;\n\t\t\t\tSUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t30920D9F2C3D51EC008690C3 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = C96FFC922C3DD5DC00D87937 /* Release-app-network-extension.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tCODE_SIGN_IDENTITY = \"Apple Development\";\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tDEVELOPMENT_TEAM = 5G943LR562;\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t\t\"@executable_path/../../../../Frameworks\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = \"\";\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSUPPORTED_PLATFORMS = \"iphoneos iphonesimulator\";\n\t\t\t\tSUPPORTS_MACCATALYST = NO;\n\t\t\t\tSUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t3098C19C2B921489008877AA /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = C96FFC902C3DD2FD00D87937 /* Release-system-network-extension.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tINFOPLIST_KEY_NSSystemExtensionUsageDescription = \"\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t\t\"@executable_path/../../../../Frameworks\",\n\t\t\t\t);\n\t\t\t\tLIBRARY_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(PROJECT_DIR)/libobscuravpn_client\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_NAME = \"$(inherited)\";\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t964304952BEEF6D000B3119B /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++20\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_TESTABILITY = YES;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu17;\n\t\t\t\tGCC_DYNAMIC_NO_PIC = NO;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tLOCALIZATION_PREFERS_STRING_CATALOGS = YES;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tONLY_ACTIVE_ARCH = YES;\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t964304962BEEF6D000B3119B /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = C92777D22C248A740058BBFB /* Debug-app.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = \"client/client-macos.entitlements\";\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tDEVELOPMENT_ASSET_PATHS = \"\\\"client/Preview Content\\\"\";\n\t\t\t\tDEVELOPMENT_TEAM = 5G943LR562;\n\t\t\t\tENABLE_PREVIEWS = YES;\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tOBSCURA_MAGIC_NO_NIX = \"\";\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME) (Debug)\";\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = \"$(inherited) DEBUG LOAD_DEV_SERVER\";\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t964304972BEEF6D000B3119B /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = C96FFC8F2C3DD2FD00D87937 /* Debug-system-network-extension.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tINFOPLIST_KEY_NSSystemExtensionUsageDescription = \"\";\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t\t\"@executable_path/../../../../Frameworks\",\n\t\t\t\t);\n\t\t\t\tLIBRARY_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(PROJECT_DIR)/libobscuravpn_client\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_NAME = \"$(inherited)\";\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tA98F1BD62DADFF17007F04D3 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = C92777D22C248A740058BBFB /* Debug-app.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = \"client/client-ios.entitlements\";\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tDEVELOPMENT_ASSET_PATHS = \"\\\"client/Preview Content\\\"\";\n\t\t\t\tENABLE_PREVIEWS = YES;\n\t\t\t\tINFOPLIST_FILE = client/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = \"Obscura VPN\";\n\t\t\t\tINFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tINFOPLIST_KEY_UILaunchScreen_Generation = YES;\n\t\t\t\tINFOPLIST_KEY_UIRequiresFullScreen = NO;\n\t\t\t\tINFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = \"UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight\";\n\t\t\t\tINFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tOBSCURA_MAGIC_NO_NIX = \"\";\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSUPPORTED_PLATFORMS = \"iphoneos iphonesimulator\";\n\t\t\t\tSUPPORTS_MACCATALYST = NO;\n\t\t\t\tSUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = 1;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tA98F1BD72DADFF17007F04D3 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = C9D486352C123078007D5F2F /* Release-app.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = \"client/client-ios.entitlements\";\n\t\t\t\tCODE_SIGN_IDENTITY = \"Apple Development\";\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tDEVELOPMENT_ASSET_PATHS = \"\\\"client/Preview Content\\\"\";\n\t\t\t\tDEVELOPMENT_TEAM = 5G943LR562;\n\t\t\t\tENABLE_PREVIEWS = YES;\n\t\t\t\tINFOPLIST_FILE = client/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = \"Obscura VPN\";\n\t\t\t\tINFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tINFOPLIST_KEY_UILaunchScreen_Generation = YES;\n\t\t\t\tINFOPLIST_KEY_UIRequiresFullScreen = NO;\n\t\t\t\tINFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = \"UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight\";\n\t\t\t\tINFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tOBSCURA_MAGIC_NO_NIX = \"\";\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = \"\";\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSUPPORTED_PLATFORMS = \"iphoneos iphonesimulator\";\n\t\t\t\tSUPPORTS_MACCATALYST = NO;\n\t\t\t\tSUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = 1;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n/* End XCBuildConfiguration section */\n\n/* Begin XCConfigurationList section */\n\t\t30497E622D9EC038008B22F9 /* Build configuration list for PBXNativeTarget \"Tests\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t30497E632D9EC038008B22F9 /* Debug */,\n\t\t\t\t30497E642D9EC038008B22F9 /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t30761C192B6EB17100E5F60D /* Build configuration list for PBXProject \"client\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t964304952BEEF6D000B3119B /* Debug */,\n\t\t\t\t30761C2C2B6EB17200E5F60D /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t30761C2D2B6EB17200E5F60D /* Build configuration list for PBXNativeTarget \"Obscura VPN\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t964304962BEEF6D000B3119B /* Debug */,\n\t\t\t\t30761C2F2B6EB17200E5F60D /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t30920DA22C3D51EC008690C3 /* Build configuration list for PBXNativeTarget \"App Network Extension\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t30920D9E2C3D51EC008690C3 /* Debug */,\n\t\t\t\t30920D9F2C3D51EC008690C3 /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t3098C19A2B921489008877AA /* Build configuration list for PBXNativeTarget \"System Network Extension\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t964304972BEEF6D000B3119B /* Debug */,\n\t\t\t\t3098C19C2B921489008877AA /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\tA98F1BD52DADFF17007F04D3 /* Build configuration list for PBXNativeTarget \"Obscura VPN iOS\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tA98F1BD62DADFF17007F04D3 /* Debug */,\n\t\t\t\tA98F1BD72DADFF17007F04D3 /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n/* End XCConfigurationList section */\n\n/* Begin XCRemoteSwiftPackageReference section */\n\t\t30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference \"swift-collections\" */ = {\n\t\t\tisa = XCRemoteSwiftPackageReference;\n\t\t\trepositoryURL = \"https://github.com/apple/swift-collections.git\";\n\t\t\trequirement = {\n\t\t\t\tkind = upToNextMajorVersion;\n\t\t\t\tminimumVersion = 1.1.4;\n\t\t\t};\n\t\t};\n\t\tA082F8212C46BF5B002AF810 /* XCRemoteSwiftPackageReference \"Sparkle\" */ = {\n\t\t\tisa = XCRemoteSwiftPackageReference;\n\t\t\trepositoryURL = \"https://github.com/sparkle-project/Sparkle\";\n\t\t\trequirement = {\n\t\t\t\tkind = upToNextMajorVersion;\n\t\t\t\tminimumVersion = 2.6.4;\n\t\t\t};\n\t\t};\n\t\tA98F1BA32DADFF17007F04D3 /* XCRemoteSwiftPackageReference \"swift-collections\" */ = {\n\t\t\tisa = XCRemoteSwiftPackageReference;\n\t\t\trepositoryURL = \"https://github.com/apple/swift-collections.git\";\n\t\t\trequirement = {\n\t\t\t\tkind = upToNextMajorVersion;\n\t\t\t\tminimumVersion = 1.1.4;\n\t\t\t};\n\t\t};\n/* End XCRemoteSwiftPackageReference section */\n\n/* Begin XCSwiftPackageProductDependency section */\n\t\t30497E712D9ED7D4008B22F9 /* DequeModule */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference \"swift-collections\" */;\n\t\t\tproductName = DequeModule;\n\t\t};\n\t\t30497E732D9ED7DF008B22F9 /* DequeModule */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference \"swift-collections\" */;\n\t\t\tproductName = DequeModule;\n\t\t};\n\t\t30497E752D9ED7E9008B22F9 /* DequeModule */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference \"swift-collections\" */;\n\t\t\tproductName = DequeModule;\n\t\t};\n\t\t30497E772D9ED7EE008B22F9 /* DequeModule */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference \"swift-collections\" */;\n\t\t\tproductName = DequeModule;\n\t\t};\n\t\tA082F8222C46BF5B002AF810 /* Sparkle */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = A082F8212C46BF5B002AF810 /* XCRemoteSwiftPackageReference \"Sparkle\" */;\n\t\t\tproductName = Sparkle;\n\t\t};\n\t\tA9768FBF2DBB02C400A4595F /* OrderedCollections */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference \"swift-collections\" */;\n\t\t\tproductName = OrderedCollections;\n\t\t};\n\t\tA9768FC12DBB02C900A4595F /* OrderedCollections */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference \"swift-collections\" */;\n\t\t\tproductName = OrderedCollections;\n\t\t};\n\t\tA98F1BA22DADFF17007F04D3 /* DequeModule */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = A98F1BA32DADFF17007F04D3 /* XCRemoteSwiftPackageReference \"swift-collections\" */;\n\t\t\tproductName = DequeModule;\n\t\t};\n/* End XCSwiftPackageProductDependency section */\n\t};\n\trootObject = 30761C162B6EB17100E5F60D /* Project object */;\n}\n"
  },
  {
    "path": "apple/client.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"self:\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": "apple/client.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEDidComputeMac32BitWarning</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "apple/client.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict/>\n</plist>\n"
  },
  {
    "path": "apple/client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved",
    "content": "{\n  \"originHash\" : \"840c50f4b7a782fbbd66f4b517616cd6023cbf8b74ecbd0cd20faecf3b23df79\",\n  \"pins\" : [\n    {\n      \"identity\" : \"sparkle\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/sparkle-project/Sparkle\",\n      \"state\" : {\n        \"revision\" : \"0ef1ee0220239b3776f433314515fd849025673f\",\n        \"version\" : \"2.6.4\"\n      }\n    },\n    {\n      \"identity\" : \"swift-collections\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/apple/swift-collections.git\",\n      \"state\" : {\n        \"revision\" : \"671108c96644956dddcd89dd59c203dcdb36cec7\",\n        \"version\" : \"1.1.4\"\n      }\n    }\n  ],\n  \"version\" : 3\n}\n"
  },
  {
    "path": "apple/client.xcodeproj/xcshareddata/xcschemes/App Network Extension.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1520\"\n   wasCreatedForAppExtension = \"YES\"\n   version = \"2.0\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"30920D922C3D51EC008690C3\"\n               BuildableName = \"App Network Extension.appex\"\n               BlueprintName = \"App Network Extension\"\n               ReferencedContainer = \"container:client.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      shouldAutocreateTestPlan = \"YES\">\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"\"\n      selectedLauncherIdentifier = \"Xcode.IDEFoundation.Launcher.PosixSpawn\"\n      launchStyle = \"0\"\n      askForAppToLaunch = \"Yes\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\"\n      launchAutomaticallySubstyle = \"2\">\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Release\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      askForAppToLaunch = \"Yes\"\n      launchAutomaticallySubstyle = \"2\">\n      <MacroExpansion>\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"30920D922C3D51EC008690C3\"\n            BuildableName = \"App Network Extension.appex\"\n            BlueprintName = \"App Network Extension\"\n            ReferencedContainer = \"container:client.xcodeproj\">\n         </BuildableReference>\n      </MacroExpansion>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "apple/client.xcodeproj/xcshareddata/xcschemes/Dev Client.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1520\"\n   version = \"1.7\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\">\n      <PreActions>\n         <ExecutionAction\n            ActionType = \"Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction\">\n            <ActionContent\n               title = \"Run Script\"\n               scriptText = \"exec &quot;${SRCROOT}/xcodescripts/pre-action.bash&quot;&#10;\">\n               <EnvironmentBuildable>\n                  <BuildableReference\n                     BuildableIdentifier = \"primary\"\n                     BlueprintIdentifier = \"30761C1D2B6EB17100E5F60D\"\n                     BuildableName = \"Obscura VPN.app\"\n                     BlueprintName = \"Obscura VPN\"\n                     ReferencedContainer = \"container:client.xcodeproj\">\n                  </BuildableReference>\n               </EnvironmentBuildable>\n            </ActionContent>\n         </ExecutionAction>\n      </PreActions>\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"30761C1D2B6EB17100E5F60D\"\n               BuildableName = \"Obscura VPN.app\"\n               BlueprintName = \"Obscura VPN\"\n               ReferencedContainer = \"container:client.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      shouldAutocreateTestPlan = \"YES\">\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\">\n      <PreActions>\n         <ExecutionAction\n            ActionType = \"Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction\">\n            <ActionContent\n               title = \"Run Script\"\n               scriptText = \"exec &quot;${SRCROOT}/xcodescripts/nix-web-dev-server-start.bash&quot;&#10;\">\n               <EnvironmentBuildable>\n                  <BuildableReference\n                     BuildableIdentifier = \"primary\"\n                     BlueprintIdentifier = \"30761C1D2B6EB17100E5F60D\"\n                     BuildableName = \"Obscura VPN.app\"\n                     BlueprintName = \"Obscura VPN\"\n                     ReferencedContainer = \"container:client.xcodeproj\">\n                  </BuildableReference>\n               </EnvironmentBuildable>\n            </ActionContent>\n         </ExecutionAction>\n      </PreActions>\n      <PostActions>\n         <ExecutionAction\n            ActionType = \"Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction\">\n            <ActionContent\n               title = \"Run Script\"\n               scriptText = \"&quot;${SRCROOT}/xcodescripts/nix-web-dev-server-stop.bash&quot;&#10;\">\n               <EnvironmentBuildable>\n                  <BuildableReference\n                     BuildableIdentifier = \"primary\"\n                     BlueprintIdentifier = \"30761C1D2B6EB17100E5F60D\"\n                     BuildableName = \"Obscura VPN.app\"\n                     BlueprintName = \"Obscura VPN\"\n                     ReferencedContainer = \"container:client.xcodeproj\">\n                  </BuildableReference>\n               </EnvironmentBuildable>\n            </ActionContent>\n         </ExecutionAction>\n      </PostActions>\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"30761C1D2B6EB17100E5F60D\"\n            BuildableName = \"Obscura VPN.app\"\n            BlueprintName = \"Obscura VPN\"\n            ReferencedContainer = \"container:client.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Debug\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"30761C1D2B6EB17100E5F60D\"\n            BuildableName = \"Obscura VPN.app\"\n            BlueprintName = \"Obscura VPN\"\n            ReferencedContainer = \"container:client.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Debug\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "apple/client.xcodeproj/xcshareddata/xcschemes/Obscura VPN iOS.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1600\"\n   version = \"1.7\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\"\n      buildArchitectures = \"Automatic\">\n      <PreActions>\n         <ExecutionAction\n            ActionType = \"Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction\">\n            <ActionContent\n               title = \"Run Script\"\n               scriptText = \"exec &quot;${SRCROOT}/xcodescripts/pre-action.bash&quot;&#10;\">\n               <EnvironmentBuildable>\n                  <BuildableReference\n                     BuildableIdentifier = \"primary\"\n                     BlueprintIdentifier = \"A98F1B9D2DADFF17007F04D3\"\n                     BuildableName = \"Obscura VPN iOS.app\"\n                     BlueprintName = \"Obscura VPN iOS\"\n                     ReferencedContainer = \"container:client.xcodeproj\">\n                  </BuildableReference>\n               </EnvironmentBuildable>\n            </ActionContent>\n         </ExecutionAction>\n         <ExecutionAction\n            ActionType = \"Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction\">\n            <ActionContent\n               title = \"Run Script\"\n               scriptText = \"exec &quot;${SRCROOT}/xcodescripts/nix-build-web-bundle.bash&quot;&#10;\">\n               <EnvironmentBuildable>\n                  <BuildableReference\n                     BuildableIdentifier = \"primary\"\n                     BlueprintIdentifier = \"A98F1B9D2DADFF17007F04D3\"\n                     BuildableName = \"Obscura VPN iOS.app\"\n                     BlueprintName = \"Obscura VPN iOS\"\n                     ReferencedContainer = \"container:client.xcodeproj\">\n                  </BuildableReference>\n               </EnvironmentBuildable>\n            </ActionContent>\n         </ExecutionAction>\n      </PreActions>\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"A98F1B9D2DADFF17007F04D3\"\n               BuildableName = \"Obscura VPN iOS.app\"\n               BlueprintName = \"Obscura VPN iOS\"\n               ReferencedContainer = \"container:client.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      shouldAutocreateTestPlan = \"YES\">\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"A98F1B9D2DADFF17007F04D3\"\n            BuildableName = \"Obscura VPN iOS.app\"\n            BlueprintName = \"Obscura VPN iOS\"\n            ReferencedContainer = \"container:client.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Release\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"A98F1B9D2DADFF17007F04D3\"\n            BuildableName = \"Obscura VPN iOS.app\"\n            BlueprintName = \"Obscura VPN iOS\"\n            ReferencedContainer = \"container:client.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "apple/client.xcodeproj/xcshareddata/xcschemes/Prod Client.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1520\"\n   version = \"1.7\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\">\n      <PreActions>\n         <ExecutionAction\n            ActionType = \"Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction\">\n            <ActionContent\n               title = \"Run Script\"\n               scriptText = \"exec &quot;${SRCROOT}/xcodescripts/pre-action.bash&quot;&#10;\">\n               <EnvironmentBuildable>\n                  <BuildableReference\n                     BuildableIdentifier = \"primary\"\n                     BlueprintIdentifier = \"30761C1D2B6EB17100E5F60D\"\n                     BuildableName = \"Obscura VPN.app\"\n                     BlueprintName = \"Obscura VPN\"\n                     ReferencedContainer = \"container:client.xcodeproj\">\n                  </BuildableReference>\n               </EnvironmentBuildable>\n            </ActionContent>\n         </ExecutionAction>\n         <ExecutionAction\n            ActionType = \"Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction\">\n            <ActionContent\n               title = \"Run Script\"\n               scriptText = \"exec &quot;${SRCROOT}/xcodescripts/nix-build-web-bundle.bash&quot;&#10;\">\n               <EnvironmentBuildable>\n                  <BuildableReference\n                     BuildableIdentifier = \"primary\"\n                     BlueprintIdentifier = \"30761C1D2B6EB17100E5F60D\"\n                     BuildableName = \"Obscura VPN.app\"\n                     BlueprintName = \"Obscura VPN\"\n                     ReferencedContainer = \"container:client.xcodeproj\">\n                  </BuildableReference>\n               </EnvironmentBuildable>\n            </ActionContent>\n         </ExecutionAction>\n      </PreActions>\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"30761C1D2B6EB17100E5F60D\"\n               BuildableName = \"Obscura VPN.app\"\n               BlueprintName = \"Obscura VPN\"\n               ReferencedContainer = \"container:client.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Release\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      shouldAutocreateTestPlan = \"YES\">\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Release\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"30761C1D2B6EB17100E5F60D\"\n            BuildableName = \"Obscura VPN.app\"\n            BlueprintName = \"Obscura VPN\"\n            ReferencedContainer = \"container:client.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Release\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"30761C1D2B6EB17100E5F60D\"\n            BuildableName = \"Obscura VPN.app\"\n            BlueprintName = \"Obscura VPN\"\n            ReferencedContainer = \"container:client.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Release\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "apple/client.xcodeproj/xcshareddata/xcschemes/System Network Extension.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1600\"\n   version = \"1.7\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\"\n      buildArchitectures = \"Automatic\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"3098C18D2B921489008877AA\"\n               BuildableName = \"net.obscura.vpn-client-app.system-network-extension.systemextension\"\n               BlueprintName = \"System Network Extension\"\n               ReferencedContainer = \"container:client.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      shouldAutocreateTestPlan = \"YES\">\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\">\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Release\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <MacroExpansion>\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"3098C18D2B921489008877AA\"\n            BuildableName = \"net.obscura.vpn-client-app.system-network-extension.systemextension\"\n            BlueprintName = \"System Network Extension\"\n            ReferencedContainer = \"container:client.xcodeproj\">\n         </BuildableReference>\n      </MacroExpansion>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "apple/libobscuravpn_client/.gitignore",
    "content": "libobscuravpn_client.a\n"
  },
  {
    "path": "apple/obscuravpn-client.xcodeproj/project.pbxproj",
    "content": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 54;\n\tobjects = {\n\n/* Begin PBXBuildFile section */\n\t\tCA016413314BAAAB3AB4761B /* Cargo.toml in Sources */ = {isa = PBXBuildFile; fileRef = CAF9DEE597793EF4668187A5 /* Cargo.toml */; settings = {COMPILER_FLAGS = \"--bin 'obscuravpn-client'\"; }; };\n/* End PBXBuildFile section */\n\n/* Begin PBXBuildRule section */\n\t\tCAF4DEE59779AC6C1400ACA8 /* PBXBuildRule */ = {\n\t\t\tisa = PBXBuildRule;\n\t\t\tcompilerSpec = com.apple.compilers.proxy.script;\n\t\t\tdependencyFile = \"$(DERIVED_FILE_DIR)/$(ARCHS)-$(EXECUTABLE_NAME).d\";\n\t\t\tfilePatterns = \"*/Cargo.toml\";\n\t\t\tfileType = pattern.proxy;\n\t\t\tinputFiles = (\n\t\t\t\t\"$(SRCROOT)/xcodescripts/cargo-build-static-lib.bash\",\n\t\t\t);\n\t\t\tisEditable = 0;\n\t\t\tname = \"Cargo project build\";\n\t\t\toutputFiles = (\n\t\t\t\t\"$(TARGET_BUILD_DIR)/$(EXECUTABLE_NAME)\",\n\t\t\t\t\"$(TARGET_BUILD_DIR)/$(PUBLIC_HEADERS_FOLDER_PATH)/$(EXECUTABLE_NAME).h\",\n\t\t\t\t\"$(TARGET_BUILD_DIR)/$(PUBLIC_HEADERS_FOLDER_PATH)/module.modulemap\",\n\t\t\t);\n\t\t\trunOncePerArchitecture = 0;\n\t\t\tscript = \"exec \\\"${SCRIPT_INPUT_FILE_0}\\\"\\n\";\n\t\t};\n/* End PBXBuildRule section */\n\n/* Begin PBXFileReference section */\n\t\tC90161AC2BE01A9B005B14AF /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = \"<group>\"; };\n\t\tC90161AD2BE01A9B005B14AF /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = \"<group>\"; };\n\t\tC90161AE2BE01A9B005B14AF /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = \"<group>\"; };\n\t\tCA0090E2379FFD96F1473BE9 /* libobscuravpn-client.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = \"libobscuravpn-client.a\"; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\tCA01272F0B60B8156098F4D0 /* obscuravpn-client */ = {isa = PBXFileReference; explicitFileType = \"compiled.mach-o.executable\"; includeInIndex = 0; path = \"obscuravpn-client\"; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\tCAF9DEE597793EF4668187A5 /* Cargo.toml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = Cargo.toml; path = ../rustlib/Cargo.toml; sourceTree = \"<group>\"; };\n/* End PBXFileReference section */\n\n/* Begin PBXGroup section */\n\t\tC90161AB2BE01A9B005B14AF /* Configurations */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tC90161AE2BE01A9B005B14AF /* Base.xcconfig */,\n\t\t\t\tC90161AC2BE01A9B005B14AF /* Debug.xcconfig */,\n\t\t\t\tC90161AD2BE01A9B005B14AF /* Release.xcconfig */,\n\t\t\t);\n\t\t\tpath = Configurations;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tCAF0DEE59779D65BC3C892A8 = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tC90161AB2BE01A9B005B14AF /* Configurations */,\n\t\t\t\tCAF9DEE597793EF4668187A5 /* Cargo.toml */,\n\t\t\t\tCAF1DEE5977922869D176AE5 /* Products */,\n\t\t\t\tCAF2DEE5977998AF0B5890DB /* Frameworks */,\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tCAF1DEE5977922869D176AE5 /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tCA0090E2379FFD96F1473BE9 /* libobscuravpn-client.a */,\n\t\t\t\tCA01272F0B60B8156098F4D0 /* obscuravpn-client */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tCAF2DEE5977998AF0B5890DB /* Frameworks */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t);\n\t\t\tname = Frameworks;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXGroup section */\n\n/* Begin PBXNativeTarget section */\n\t\tCA0090E2379FB3AB1D86918A /* obscuravpn-client.a (static library) */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = CA0006912554B3AB1D86918A /* Build configuration list for PBXNativeTarget \"obscuravpn-client.a (static library)\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tC9CFB0122BE9A36D008B27D6 /* Run Script */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t\tCAF4DEE59779AC6C1400ACA8 /* PBXBuildRule */,\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = \"obscuravpn-client.a (static library)\";\n\t\t\tproductName = \"libobscuravpn-client.a\";\n\t\t\tproductReference = CA0090E2379FFD96F1473BE9 /* libobscuravpn-client.a */;\n\t\t\tproductType = \"com.apple.product-type.library.static\";\n\t\t};\n\t\tCA01272F0B60AAAB3AB4761B /* obscuravpn-client (standalone executable) */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = CA0106912554AAAB3AB4761B /* Build configuration list for PBXNativeTarget \"obscuravpn-client (standalone executable)\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tCA0108875302AAAB3AB4761B /* Sources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t\tCAF4DEE59779AC6C1400ACA8 /* PBXBuildRule */,\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = \"obscuravpn-client (standalone executable)\";\n\t\t\tproductName = \"obscuravpn-client\";\n\t\t\tproductReference = CA01272F0B60B8156098F4D0 /* obscuravpn-client */;\n\t\t\tproductType = \"com.apple.product-type.tool\";\n\t\t};\n/* End PBXNativeTarget section */\n\n/* Begin PBXProject section */\n\t\tCAF3DEE59779E04653AD465F /* Project object */ = {\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {\n\t\t\t\tBuildIndependentTargetsInParallel = YES;\n\t\t\t\tLastUpgradeCheck = 1510;\n\t\t\t\tTargetAttributes = {\n\t\t\t\t\tCA0090E2379FB3AB1D86918A = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 9.2;\n\t\t\t\t\t\tProvisioningStyle = Automatic;\n\t\t\t\t\t};\n\t\t\t\t\tCA01272F0B60AAAB3AB4761B = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 9.2;\n\t\t\t\t\t\tProvisioningStyle = Automatic;\n\t\t\t\t\t};\n\t\t\t\t};\n\t\t\t};\n\t\t\tbuildConfigurationList = CAF6DEE5977980E02D6C7F57 /* Build configuration list for PBXProject \"obscuravpn-client\" */;\n\t\t\tcompatibilityVersion = \"Xcode 11.4\";\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (\n\t\t\t\ten,\n\t\t\t\tBase,\n\t\t\t);\n\t\t\tmainGroup = CAF0DEE59779D65BC3C892A8;\n\t\t\tproductRefGroup = CAF1DEE5977922869D176AE5 /* Products */;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\tCA0090E2379FB3AB1D86918A /* obscuravpn-client.a (static library) */,\n\t\t\t\tCA01272F0B60AAAB3AB4761B /* obscuravpn-client (standalone executable) */,\n\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n/* Begin PBXShellScriptBuildPhase section */\n\t\tC9CFB0122BE9A36D008B27D6 /* Run Script */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"$(SRCROOT)/xcodescripts/cargo-build-static-lib.bash\",\n\t\t\t\t\"$(SRCROOT)/../rustlib/Cargo.toml\",\n\t\t\t\t\"$(SRCROOT)/cbindgen-apple.toml\",\n\t\t\t);\n\t\t\tname = \"Run Script\";\n\t\t\toutputFileListPaths = (\n\t\t\t);\n\t\t\toutputPaths = (\n\t\t\t\t\"$(BUILT_PRODUCTS_DIR)/$(EXECUTABLE_NAME)\",\n\t\t\t\t\"$(BUILT_PRODUCTS_DIR)/$(PUBLIC_HEADERS_FOLDER_PATH)/$(EXECUTABLE_NAME).h\",\n\t\t\t\t\"$(BUILT_PRODUCTS_DIR)/$(PUBLIC_HEADERS_FOLDER_PATH)/module.modulemap\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"exec \\\"${SCRIPT_INPUT_FILE_0}\\\"\\n\";\n\t\t};\n/* End PBXShellScriptBuildPhase section */\n\n/* Begin PBXSourcesBuildPhase section */\n\t\tCA0108875302AAAB3AB4761B /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tCA016413314BAAAB3AB4761B /* Cargo.toml in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXSourcesBuildPhase section */\n\n/* Begin XCBuildConfiguration section */\n\t\t9643049A2BEEFBAE00B3119B /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = C90161AC2BE01A9B005B14AF /* Debug.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCARGO_TARGET_DIR = \"$(PROJECT_TEMP_DIR)/cargo_target\";\n\t\t\t\tCARGO_XCODE_BUILD_PROFILE = debug;\n\t\t\t\tCARGO_XCODE_FEATURES = \"\";\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = NO;\n\t\t\t\tONLY_ACTIVE_ARCH = YES;\n\t\t\t\tPRODUCT_NAME = \"obscuravpn-client\";\n\t\t\t\tRUSTUP_TOOLCHAIN = \"\";\n\t\t\t\tSDKROOT = auto;\n\t\t\t\tSUPPORTS_MACCATALYST = YES;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t9643049B2BEEFBAE00B3119B /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCARGO_XCODE_CARGO_DEP_FILE_NAME = libobscuravpn_client.d;\n\t\t\t\tCARGO_XCODE_CARGO_FILE_NAME = libobscuravpn_client.a;\n\t\t\t\tINSTALL_GROUP = \"\";\n\t\t\t\tINSTALL_MODE_FLAG = \"\";\n\t\t\t\tINSTALL_OWNER = \"\";\n\t\t\t\tPRODUCT_NAME = \"obscuravpn-client\";\n\t\t\t\tPUBLIC_HEADERS_FOLDER_PATH = \"include/$(PRODUCT_NAME)\";\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSUPPORTED_PLATFORMS = \"iphoneos iphonesimulator macosx\";\n\t\t\t\tSUPPORTS_MACCATALYST = NO;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t9643049C2BEEFBAE00B3119B /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCARGO_XCODE_CARGO_DEP_FILE_NAME = \"obscuravpn-client.d\";\n\t\t\t\tCARGO_XCODE_CARGO_FILE_NAME = \"obscuravpn-client\";\n\t\t\t\tPRODUCT_NAME = \"obscuravpn-client\";\n\t\t\t\tSUPPORTED_PLATFORMS = macosx;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tCA0090CB90CFB3AB1D86918A /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCARGO_XCODE_CARGO_DEP_FILE_NAME = libobscuravpn_client.d;\n\t\t\t\tCARGO_XCODE_CARGO_FILE_NAME = libobscuravpn_client.a;\n\t\t\t\tINSTALL_GROUP = \"\";\n\t\t\t\tINSTALL_MODE_FLAG = \"\";\n\t\t\t\tINSTALL_OWNER = \"\";\n\t\t\t\tPRODUCT_NAME = \"obscuravpn-client\";\n\t\t\t\tPUBLIC_HEADERS_FOLDER_PATH = \"include/$(PRODUCT_NAME)\";\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSUPPORTED_PLATFORMS = \"iphoneos iphonesimulator macosx\";\n\t\t\t\tSUPPORTS_MACCATALYST = NO;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tCA0190CB90CFAAAB3AB4761B /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCARGO_XCODE_CARGO_DEP_FILE_NAME = \"obscuravpn-client.d\";\n\t\t\t\tCARGO_XCODE_CARGO_FILE_NAME = \"obscuravpn-client\";\n\t\t\t\tPRODUCT_NAME = \"obscuravpn-client\";\n\t\t\t\tSUPPORTED_PLATFORMS = macosx;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tCAF7A11709B13CC16B37690B /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = C90161AD2BE01A9B005B14AF /* Release.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCARGO_TARGET_DIR = \"$(PROJECT_TEMP_DIR)/cargo_target\";\n\t\t\t\tCARGO_XCODE_BUILD_PROFILE = release;\n\t\t\t\tCARGO_XCODE_FEATURES = \"\";\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = NO;\n\t\t\t\tPRODUCT_NAME = \"obscuravpn-client\";\n\t\t\t\tRUSTUP_TOOLCHAIN = \"\";\n\t\t\t\tSDKROOT = auto;\n\t\t\t\tSUPPORTS_MACCATALYST = YES;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n/* End XCBuildConfiguration section */\n\n/* Begin XCConfigurationList section */\n\t\tCA0006912554B3AB1D86918A /* Build configuration list for PBXNativeTarget \"obscuravpn-client.a (static library)\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tCA0090CB90CFB3AB1D86918A /* Release */,\n\t\t\t\t9643049B2BEEFBAE00B3119B /* Debug */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\tCA0106912554AAAB3AB4761B /* Build configuration list for PBXNativeTarget \"obscuravpn-client (standalone executable)\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tCA0190CB90CFAAAB3AB4761B /* Release */,\n\t\t\t\t9643049C2BEEFBAE00B3119B /* Debug */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\tCAF6DEE5977980E02D6C7F57 /* Build configuration list for PBXProject \"obscuravpn-client\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tCAF7A11709B13CC16B37690B /* Release */,\n\t\t\t\t9643049A2BEEFBAE00B3119B /* Debug */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n/* End XCConfigurationList section */\n\t};\n\trootObject = CAF3DEE59779E04653AD465F /* Project object */;\n}\n"
  },
  {
    "path": "apple/shared/Account+CustomStringConvertible.swift",
    "content": "import Foundation\n\nprivate func descriptionOrNilString(_ object: CustomStringConvertible?) -> String {\n    if let object {\n        return \"\\(object)\"\n    } else {\n        return \"(nil)\"\n    }\n}\n\nprivate func shortRelativeTimeSubscription(_ date: Date) -> String {\n    let formatter = RelativeDateTimeFormatter()\n    formatter.unitsStyle = RelativeDateTimeFormatter.UnitsStyle.abbreviated\n    return formatter.localizedString(for: date, relativeTo: Date())\n}\n\nextension AccountInfo: CustomStringConvertible {\n    var description: String {\n        let str = \"{AccountInfo -- id \\(id), active \\(active), topUp: \\(descriptionOrNilString(topUp)), stripSubscription: \\(descriptionOrNilString(stripeSubscription)), appleSubscription: \\(descriptionOrNilString(appleSubscription))}\"\n        return str\n    }\n}\n\nextension TopUpInfo: CustomStringConvertible {\n    var description: String {\n        \"{TopUpInfo -- creditExpiresAt: \\(shortRelativeTimeSubscription(self.creditExpiresAtDate))}\"\n    }\n}\n\nextension StripeSubscriptionInfo: CustomStringConvertible {\n    var description: String {\n        \"{StripeSubscriptionInfo -- status: \\(self.status.rawValue), currentPeriodStart: \\(shortRelativeTimeSubscription(self.currentPeriodStartDate)), currentPeriodEnd: \\(shortRelativeTimeSubscription(self.currentPeriodEndDate))\"\n    }\n}\n\nextension AppleSubscriptionInfo: CustomStringConvertible {\n    var description: String {\n        \"{StripeSubscriptionInfo -- status: \\(subscriptionStatus.description), autoRenewalStatus: \\(autoRenewalStatus), renewalTime: \\(shortRelativeTimeSubscription(self.renewalDate))}\"\n    }\n}\n"
  },
  {
    "path": "apple/shared/Account.swift",
    "content": "import Foundation\n\nstruct AccountStatus: Codable, Equatable {\n    var accountInfo: AccountInfo\n    var lastUpdatedSec: UInt64\n\n    enum CodingKeys: String, CodingKey {\n        case accountInfo = \"account_info\"\n        case lastUpdatedSec = \"last_updated_sec\"\n    }\n\n    // returns nil when:\n    //  subscription is active and renewing\n    // returns zero when:\n    //  never topped up\n    // returns a date in the past when:\n    //  account is past its expiration date (or never funded)\n    var expirationDate: Date? {\n        if self.accountInfo.autoRenews {\n            return nil\n        }\n        return Date(timeIntervalSince1970: TimeInterval(self.accountInfo.currentExpiry ?? 0))\n    }\n\n    func daysUntilExpiry() -> UInt64? {\n        if !self.accountInfo.active {\n            return 0\n        }\n        if let end = self.expirationDate {\n            let now = Date()\n            return UInt64(max(Calendar.current.dateComponents([.day], from: now, to: end).day ?? 0, 0))\n        }\n        return nil\n    }\n\n    func isActive() -> Bool {\n        if self.accountInfo.autoRenews {\n            return true\n        }\n        if let timestamp = self.expirationDate {\n            return timestamp > Date()\n        }\n        return self.accountInfo.active\n    }\n\n    func expiringSoon() -> Bool {\n        if let daysTillExpiry = daysUntilExpiry() {\n            return daysTillExpiry <= 10\n        }\n        return false\n    }\n\n    static func == (left: AccountStatus, right: AccountStatus) -> Bool {\n        return left.lastUpdatedSec == right.lastUpdatedSec\n    }\n}\n\nstruct AccountInfo: Codable {\n    let id: String\n    let active: Bool\n    let topUp: TopUpInfo?\n    let stripeSubscription: StripeSubscriptionInfo?\n    let appleSubscription: AppleSubscriptionInfo?\n    let _autoRenews: Int64?\n    let currentExpiry: Int64?\n\n    var hasRenewingStripeSubscription: Bool {\n        guard let stripeSubscription else { return false }\n        return !stripeSubscription.cancelAtPeriodEnd\n            && stripeSubscription.status != .unpaid\n            && stripeSubscription.status != .canceled\n    }\n\n    enum CodingKeys: String, CodingKey {\n        case topUp = \"top_up\"\n        case id\n        case active\n        case stripeSubscription = \"subscription\"\n        case appleSubscription = \"apple_subscription\"\n        case _autoRenews = \"auto_renews\"\n        case currentExpiry = \"current_expiry\"\n    }\n\n    var autoRenews: Bool {\n        self._autoRenews != nil\n    }\n\n    /// returns the expected date when\n    ///   1) the account's subscription renews\n    ///   2) or the account will expire\n    /// this is used to determined when the account info should be refreshed\n    var periodEndDate: Date? {\n        if let currentExpiry = self.currentExpiry {\n            return Date(timeIntervalSince1970: TimeInterval(currentExpiry))\n        }\n        if let autoRenews = self._autoRenews {\n            return Date(timeIntervalSince1970: TimeInterval(autoRenews))\n        }\n        return nil\n    }\n}\n\nstruct TopUpInfo: Codable {\n    let creditExpiresAt: Int64\n\n    enum CodingKeys: String, CodingKey {\n        case creditExpiresAt = \"credit_expires_at\"\n    }\n\n    var creditExpiresAtDate: Date {\n        return Date(timeIntervalSince1970: TimeInterval(self.creditExpiresAt))\n    }\n}\n\nextension TopUpInfo {\n    var expiryDate: Date {\n        return Date(timeIntervalSince1970: TimeInterval(self.creditExpiresAt))\n    }\n}\n\nstruct StripeSubscriptionInfo: Codable {\n    let status: StripeSubscriptionStatus\n    let currentPeriodStart: Int64\n    let currentPeriodEnd: Int64\n    let cancelAtPeriodEnd: Bool\n\n    enum CodingKeys: String, CodingKey {\n        case currentPeriodStart = \"current_period_start\"\n        case currentPeriodEnd = \"current_period_end\"\n        case cancelAtPeriodEnd = \"cancel_at_period_end\"\n        case status\n    }\n\n    var currentPeriodStartDate: Date {\n        return Date(timeIntervalSince1970: TimeInterval(self.currentPeriodStart))\n    }\n\n    var currentPeriodEndDate: Date {\n        return Date(timeIntervalSince1970: TimeInterval(self.currentPeriodEnd))\n    }\n}\n\nenum StripeSubscriptionStatus: String, Codable {\n    case active\n    case canceled\n    case incomplete\n    case incompleteExpired = \"incomplete_expired\"\n    case pastDue = \"past_due\"\n    case paused\n    case trialing\n    case unpaid\n}\n\nstruct AppleSubscriptionInfo: Codable {\n    // https://developer.apple.com/documentation/appstoreserverapi/status\n    let status: Int32\n    let autoRenewalStatus: Bool\n    let renewalTime: Int64\n\n    enum CodingKeys: String, CodingKey {\n        case status\n        case autoRenewalStatus = \"auto_renew_status\"\n        case renewalTime = \"renewal_date\"\n    }\n\n    enum Status: Int32 {\n        case active = 1\n        case expired = 2\n        case billingRetry = 3\n        case gracePeriod = 4\n        case revoked = 5\n\n        var description: String {\n            switch self {\n            case .active:\n                \"Active\"\n            case .expired:\n                \"Expired\"\n            case .billingRetry:\n                \"In Billing Retry Period\"\n            case .gracePeriod:\n                \"In Billing Grace Period\"\n            case .revoked:\n                \"Revoked\"\n            }\n        }\n    }\n\n    var subscriptionStatus: Status {\n        Status(rawValue: self.status) ?? .expired\n    }\n\n    var renewalDate: Date {\n        return Date(timeIntervalSince1970: TimeInterval(self.renewalTime))\n    }\n}\n\n// https://github.com/Sovereign-Engineering/obscuravpn-api/blob/main/src/cmd/apple/associate_account.rs\nstruct AppleAssociateAccountOutput: Codable {}\n"
  },
  {
    "path": "apple/shared/AccountInfo+Util.swift",
    "content": "import Foundation\n\nextension AccountInfo {\n    var hasTopUp: Bool {\n        guard let topUp else { return false }\n\n        return topUp.creditExpiresAtDate > .now\n    }\n\n    var hasStripeSubscription: Bool {\n        guard let stripeSubscription else { return false }\n        if self.hasRenewingStripeSubscription { return true }\n        let expirationDate = Date(\n            timeIntervalSince1970: TimeInterval(\n                stripeSubscription.currentPeriodEnd\n            )\n        )\n        return expirationDate > .now\n    }\n\n    var activeNotApple: Bool {\n        return self.active && !self.hasActiveAppleSubscription\n    }\n\n    var hasActiveAppleSubscription: Bool {\n        guard let appleSubscription else {\n            return false\n        }\n        return appleSubscription.subscriptionStatus == .active || appleSubscription.subscriptionStatus == .billingRetry || appleSubscription.subscriptionStatus == .gracePeriod\n    }\n}\n"
  },
  {
    "path": "apple/shared/Box.swift",
    "content": "class Box<T> {\n    var boxed: T\n    init(_ value: T) {\n        self.boxed = value\n    }\n}\n"
  },
  {
    "path": "apple/shared/Concurrency.swift",
    "content": "import DequeModule\nimport Foundation\nimport OSLog\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"Concurrency\")\n\n/// Track a set of callbacks that can be triggered.\nclass Callbacks<V> {\n    typealias CallbackId = ObjectId<(V) -> Void>\n\n    private var pending: Set<CallbackId> = []\n\n    /// Add a callback to the queue.\n    ///\n    /// The return value can be used to cancel the callback.\n    @discardableResult\n    func add(_ f: @escaping (V) -> Void) -> CallbackId {\n        let cb = ObjectId(f)\n        self.pending.insert(cb)\n        return cb\n    }\n\n    /// Cancel a scheduled callback.\n    ///\n    /// Does nothing if the callback has already been executed or removed.\n    func remove(_ cb: CallbackId) {\n        self.pending.remove(cb)\n    }\n\n    /// Trigger all callbacks.\n    ///\n    /// This triggers all callbacks and clears the queue.\n    func dispatch(_ value: V) {\n        // Swap first to be re-entrant.\n        let pending = self.pending\n        self.pending = []\n\n        for cb in pending {\n            cb.value(value)\n        }\n    }\n}\n\n/// A tool for tracking outstanding tasks.\n///\n/// Note: If `TaskGroup` is suitable for your use case you should prefer that. (https://developer.apple.com/documentation/swift/taskgroup)\n///\n/// This type is internally synchronized and all methods are safe to be called concurrently.\nclass PendingTasks {\n    private var lock = NSLock()\n    private var count: UInt64 = 0\n    private var waiting = Callbacks<Void>()\n\n    init() {}\n\n    /// Record that a task has been started.\n    func start(tasks: UInt64 = 1) {\n        self.lock.withLock {\n            self.count += tasks\n        }\n    }\n\n    /// Record that a task has completed.\n    func complete(tasks: UInt64 = 1) {\n        self.lock.withLock {\n            if tasks > self.count {\n                logger.error(\"More tasks completed (\\(tasks, privacy: .public)) than running (\\(self.count, privacy: .public))\")\n                self.count = 0\n            } else {\n                self.count -= tasks\n            }\n\n            if self.count == 0 {\n                self.waiting.dispatch(())\n            }\n        }\n    }\n\n    /// Wait until there are no tasks running.\n    ///\n    /// This will return the first time there are no outstanding tasks, or immediately if there are currently none. Tasks that are added while waiting will also be waited for.\n    func waitForAll() async {\n        await withCheckedContinuation { continuation in\n            self.lock.withLock {\n                if self.count == 0 {\n                    continuation.resume(returning: ())\n                } else {\n                    self.waiting.add {\n                        continuation.resume(returning: ())\n                    }\n                }\n            }\n        }\n    }\n}\n\nstruct TimeoutError: Error {\n    var localizedDescription = \"Operation Timed Out\"\n}\n\nfunc withTimeout<T>(\n    _ timeout: Duration?,\n    operation: @escaping () async throws -> T\n) async throws -> T {\n    guard let timeout = timeout else {\n        return try await operation()\n    }\n\n    return try await withCheckedThrowingContinuation { continuation in\n        let done = Atomic<Bool>(false)\n\n        let task = Task {\n            do {\n                let v = try await operation()\n                let (exchanged, _) = done.compareExchange(expected: false, desired: true)\n                if exchanged {\n                    continuation.resume(returning: v)\n                }\n            } catch {\n                let (exchanged, _) = done.compareExchange(expected: false, desired: true)\n                if exchanged {\n                    continuation.resume(throwing: error)\n                }\n            }\n        }\n\n        let timeoutNs = Int(timeout / .nanoseconds(1))\n        DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .nanoseconds(timeoutNs))) {\n            let (exchanged, _) = done.compareExchange(expected: false, desired: true)\n            if exchanged {\n                task.cancel()\n                continuation.resume(throwing: \"Timeout elapsed\")\n            }\n        }\n    }\n}\n\n/// Atomic container until macos 15 becomes the minimum version.\nclass Atomic<T> {\n    private var value: T\n    private let lock = NSLock()\n\n    init(_ value: T) {\n        self.value = value\n    }\n\n    func load() -> T {\n        self.lock.withLock {\n            self.value\n        }\n    }\n\n    func store(_ value: T) {\n        self.lock.withLock {\n            self.value = value\n        }\n    }\n}\n\nextension Atomic where T: Equatable {\n    func compareExchange(expected: T, desired: T) -> (exchanged: Bool, original: T) {\n        self.lock.withLock {\n            let original = self.value\n            let exchanged = self.value == expected\n            if exchanged {\n                self.value = desired\n            }\n            return (exchanged, original)\n        }\n    }\n}\n\nclass AsyncMutex<T> {\n    class AsyncMutexGuard {\n        let mutex: AsyncMutex\n        var value: T {\n            get {\n                return self.mutex.value\n            }\n            set(newValue) {\n                self.mutex.value = newValue\n            }\n        }\n\n        init(mutex: AsyncMutex) {\n            self.mutex = mutex\n        }\n\n        deinit {\n            self.mutex.unlock()\n        }\n    }\n\n    private enum State {\n        case unlocked\n        case locked(Box<Deque<CheckedContinuation<AsyncMutexGuard, Never>>>)\n    }\n\n    private var sync = NSLock()\n    private var state: State = .unlocked\n    private var value: T\n\n    init(_ value: T) {\n        self.value = value\n    }\n\n    func lock() async -> AsyncMutexGuard {\n        await withCheckedContinuation { continuation in\n            self.sync.withLock {\n                switch self.state {\n                case .unlocked:\n                    self.state = .locked(Box([]))\n                    continuation.resume(returning: AsyncMutexGuard(mutex: self))\n                    return\n                case .locked(let waiting):\n                    waiting.boxed.append(continuation)\n                }\n            }\n        }\n    }\n\n    private func unlock() {\n        self.sync.withLock {\n            switch self.state {\n            case .unlocked:\n                logger.critical(\"unlock in unlocked state\")\n            case .locked(let waiting):\n                guard let continuation = waiting.boxed.popFirst() else {\n                    self.state = .unlocked\n                    return\n                }\n                continuation.resume(returning: AsyncMutexGuard(mutex: self))\n            }\n        }\n    }\n\n    func withLock<R, E>(_ body: (AsyncMutexGuard) async throws(E) -> R) async throws(E) -> R {\n        let mutexGuard = await self.lock()\n        defer { withExtendedLifetime(mutexGuard) {}}\n        return try await body(mutexGuard)\n    }\n}\n"
  },
  {
    "path": "apple/shared/ConcurrencyTests.swift",
    "content": "import Testing\n\n@Test(.timeLimit(.minutes(1)))\nfunc asyncMutex() async throws {\n    let mutex = AsyncMutex(false)\n\n    await withTaskGroup(of: Void.self) { tasks in\n        for _ in 0 ..< 100 {\n            tasks.addTask {\n                await mutex.withLock { mutex_guard in\n                    #expect(!mutex_guard.value)\n                    mutex_guard.value = true\n                    #expect(mutex_guard.value)\n                    try! await Task.sleep(seconds: 0.01)\n                    #expect(mutex_guard.value)\n                    mutex_guard.value = false\n                }\n            }\n        }\n        await tasks.waitForAll()\n    }\n\n    // Write your test here and use APIs like `#expect(...)` to check expected conditions.\n    #expect(true)\n}\n"
  },
  {
    "path": "apple/shared/Debug.swift",
    "content": "func debugFormat(_ v: Any?) -> String {\n    guard let v = v else { return \"nil\" }\n\n    var r = \"\"\n    debugPrint(v, terminator: \"\", to: &r)\n    return r\n}\n"
  },
  {
    "path": "apple/shared/FfiCb.swift",
    "content": "import Foundation\n\n/// Unsafe callback wrapper, which allows calling the wrapped callback exactly once using only a pointer sized integer.\n/// Calling it more than once is unsafe. Never calling it is a memory leak.\n/// This is used to pass capturing closures across FFI boundaries.\nclass FfiCb<T> {\n    typealias CallbackType = (T) -> Void\n    private let callback: CallbackType\n\n    private init(_ callback: @escaping CallbackType) {\n        self.callback = callback\n    }\n\n    /// Get a pointer to the wrapped callback, which will prevent it being released until it is called.\n    static func wrap(_ callback: @escaping CallbackType) -> UInt {\n        let this = FfiCb(callback)\n        return UInt(bitPattern: Unmanaged.passRetained(this).toOpaque())\n    }\n\n    /// Call the callback and then release it. The pointer will be unsafe to use after that.\n    static func call(_ ptr: UInt, _ args: T) {\n        let this = Unmanaged<FfiCb<T>>.fromOpaque(UnsafeRawPointer(bitPattern: ptr)!).takeRetainedValue()\n        this.callback(args)\n    }\n}\n"
  },
  {
    "path": "apple/shared/InfoDict.swift",
    "content": "import Foundation\nimport OSLog\nimport UniformTypeIdentifiers\n\n// The unique build ID.\n//\n// This is basically a meaningless number, it shouldn't be shown to users. It can just be used to tell if the exact same binary is being used. It is also used for updates as it is a monotonically increasing value.\nfunc buildVersion() -> String {\n    Bundle.main.infoDictionary![\"CFBundleVersion\"] as! String\n}\n\nprivate func obscuraInfoDict() -> [String: Any] {\n    Bundle.main.infoDictionary![\"Obscura\"] as! [String: Any]\n}\n\n// This is the main version number.\n//\n// This number is suitable for showing to the user as it contains just the information needed to usefully describe the version.\n//\n// In release builds it will be pretty such as v1.23.\n//\n// In other builds will will be something like `v1.23-3-abcde123` or `v1.23-6-a1b2c3-dirty`.\nfunc sourceVersion() -> String {\n    return obscuraInfoDict()[\"ObscuraSourceVersion\"] as! String\n}\n\n// The source commit ID.\n//\n// This will be a full commit ID, suffixed with -dirty if the working directory was not comitted.\n//\n// It generally shouldn't be shown to users, use `sourceVersion` instead.\nfunc sourceId() -> String {\n    return obscuraInfoDict()[\"ObscuraSourceId\"] as! String\n}\n\n#if os(macOS)\n    func extensionBundle() -> Bundle {\n        let url = Bundle.main.bundleURL\n            .appending(path: \"Contents/Library/SystemExtensions/\")\n            .appending(component: \"\\(networkExtensionBundleID()).systemextension\")\n\n        return Bundle(url: url)!\n    }\n#endif\n\n// The correct bundle ID for the client app to connect to based on the build configuration\npublic func networkExtensionBundleID() -> String {\n    return obscuraInfoDict()[\"OBSCURA_NETWORK_EXTENSION_BUNDLE_ID\"] as! String\n}\n\nfunc appGroupID() -> String {\n    return obscuraInfoDict()[\"AppGroupIdentifier\"] as! String\n}\n\nfunc configDir() -> String {\n    #if os(macOS)\n        return \"/Library/Application Support/obscura-vpn/system-network-extension/\"\n    #else\n        return URL.libraryDirectory.appendingPathComponent(\"obscura\", conformingTo: UTType.folder).path(percentEncoded: false)\n    #endif\n}\n\nfunc groupContainerDir() -> String? {\n    #if os(macOS)\n        return nil\n    #else\n        return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: \"group.net.obscura.vpn-client-app-ios\")?.path\n    #endif\n}\n\nfunc logDir() -> String? {\n    #if os(macOS)\n        return nil\n    #else\n        guard let containerDir = groupContainerDir() else {\n            Logger(subsystem: \"net.obscura.sys-ext\", category: \"pre-log-init\").error(\"no container url for group\")\n            return nil\n        }\n        return URL(filePath: containerDir).appending(path: \"rust-log\").path\n    #endif\n}\n"
  },
  {
    "path": "apple/shared/Json.swift",
    "content": "import Foundation\nimport OSLog\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"json\")\n\nextension Encodable {\n    func json(function: String = #function, file: String = #fileID, line: Int = #line) throws(String) -> String {\n        do {\n            let json = try JSONEncoder().encode(self)\n            return String(data: json, encoding: .utf8)!\n        } catch let err {\n            logger.error(\"JSON encoding failed (\\(file, privacy: .public):\\(function, privacy: .public):\\(line, privacy: .public)): \\(err, privacy: .private)\")\n            throw errorCodeOther\n        }\n    }\n}\n\nextension Decodable {\n    init(json: Data, function: String = #function, file: String = #fileID, line: Int = #line) throws(String) {\n        do {\n            self = try JSONDecoder().decode(Self.self, from: json)\n        } catch let err {\n            logger.error(\"JSON decoding failed (\\(file, privacy: .public):\\(function, privacy: .public):\\(line, privacy: .public)): \\(err, privacy: .private)\")\n            throw errorCodeOther\n        }\n    }\n\n    init(json: String, function: String = #function, file: String = #fileID, line: Int = #line) throws(String) {\n        try self.init(json: json.data(using: .utf8)!, function: function, file: file, line: line)\n    }\n}\n\n/// Mutates the values in a dictionary so that they are able to be JSON encoded.\n///\n/// For it just converts binary data to Base 64.\nfunc prepareForJson(_ value: inout Any) {\n    switch value {\n    case var array as [Any]:\n        for (i, v) in array.enumerated() {\n            var updated = v\n            prepareForJson(&updated)\n            array[i] = updated\n        }\n        value = array\n    case var dict as [String: Any]:\n        for (k, v) in dict {\n            var updated = v\n            prepareForJson(&updated)\n            dict[k] = updated\n        }\n        value = dict\n    case let data as Data:\n        value = data.base64EncodedString()\n    default:\n        if !JSONSerialization.isValidJSONObject([value]) {\n            value = debugFormat(value)\n        }\n    }\n}\n\npublic struct Empty: Codable {\n    public init() {}\n}\n"
  },
  {
    "path": "apple/shared/NetworkExtensionIpc.swift",
    "content": "import Foundation\nimport NetworkExtension\nimport OSLog\n\nprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: \"network extension ipc\")\n\n// See ../../rustlib/src/manager_cmd.rs\nenum NeManagerCmdResult: Codable {\n    case ok_json(String)\n    case error(String)\n}\n\n// See ../../rustlib/src/manager_cmd.rs\nenum NeManagerCmd: Codable {\n    case apiAppleAssociateAccount(appTransactionJws: String)\n    case getDebugInfo\n    case apiAppleCreateAppAccountToken\n    case apiApplePollSubscription(originalTransactionId: String)\n    case apiGetAccountInfo\n    case getStatus(knownVersion: UUID?)\n    case getTrafficStats\n    case ping\n    case setTunnelArgs(args: TunnelArgs?, active: Bool?)\n    case login(accountId: String, validate: Bool)\n    case getExitList(knownVersion: String?)\n    case refreshExitList(freshness: TimeInterval)\n}\n\n// See ../../rustlib/src/manager.rs\nstruct TunnelArgs: Codable {\n    var exit: ExitSelector\n}\n\n// See ../../rustlib/src/manager.rs\nenum ExitSelector: Codable {\n    case any\n    case exit(id: String)\n    case country(country_code: String)\n    case city(\n        country_code: String,\n        city_code: String\n    )\n}\n\nstruct NeStatus: Codable, Equatable {\n    var version: UUID\n    var vpnStatus: NeVpnStatus\n    var accountId: String?\n    var inNewAccountFlow: Bool\n    var pinnedLocations: [PinnedLocation]\n    var lastChosenExit: ExitSelector\n    var lastExit: ExitSelector\n    var apiUrl: String\n    var account: AccountStatus?\n    var autoConnect: Bool\n    var featureFlags: NeStatusFeatureFlags\n    var useSystemDns: Bool\n\n    static func == (left: NeStatus, right: NeStatus) -> Bool {\n        return left.version == right.version\n    }\n}\n\nstruct NeStatusFeatureFlags: Codable, Equatable {\n    var killSwitch: Bool?\n}\n\nstruct PinnedLocation: Codable, Equatable {\n    var country_code: String\n    var city_code: String\n    var pinned_at: Int64\n}\n\nenum NeVpnStatus: Codable {\n    case connecting(tunnelArgs: TunnelArgs, connectError: String?, reconnecting: Bool)\n    case connected(tunnelArgs: TunnelArgs, exit: ExitInfo, exitPublicKey: String, clientPublicKey: String, transport: TransportKind)\n    case disconnected\n}\n\nstruct ExitInfo: Codable {\n    var id: String\n    var country_code: String\n    var city_name: String\n    var city_code: String\n}\n\nenum TransportKind: String, Codable, Equatable {\n    case quic\n    case tcpTls\n}\n\n// Keep synchronized with rustlib/src/network_config.rs\nstruct OsNetworkConfig: Codable, CustomStringConvertible, Equatable {\n    var description: String {\n        return \"ipv4: \\(self.ipv4), use system dns: \\(self.useSystemDns), dns: \\(self.dns), ipv6: \\(self.ipv6)\"\n    }\n\n    var dns: [String]\n    var ipv4: String\n    var ipv6: String\n    var mtu: UInt16\n    var useSystemDns: Bool\n}\n\n// We must use NSError to communicate errors via startTunnel.\n// This defines an error domain and related methods for our Rust `ConnectErrorCode`.\nextension NSError {\n    convenience init(connectErrorCode: String) {\n        self.init(domain: connectErrorDomain, code: 0, userInfo: [variantKey: connectErrorCode])\n    }\n\n    func connectErrorCode() -> String? {\n        if self.domain == connectErrorDomain {\n            guard let value = self.userInfo[variantKey] else {\n                logger.error(\"domain is \\(connectErrorDomain) no \\(variantKey) key on userInfo\")\n                return nil\n            }\n            guard let connectErrorCode = value as? String else {\n                logger.error(\"domain is \\(connectErrorDomain), but userInfo.\\(variantKey) is not a String\")\n                return nil\n            }\n            return connectErrorCode\n        }\n        return nil\n    }\n}\n\nprivate let connectErrorDomain = \"net.obscura.ConnectErrorCode\"\nprivate let variantKey = \"variant\"\n\nextension NEVPNStatus: CustomStringConvertible {\n    public var description: String {\n        return switch self {\n        case .invalid:\n            \"invalid\"\n        case .disconnected:\n            \"disconnected\"\n        case .connecting:\n            \"connecting\"\n        case .connected:\n            \"connected\"\n        case .reasserting:\n            \"reasserting\"\n        case .disconnecting:\n            \"disconnecting\"\n        @unknown default:\n            \"unknown (rawValue: \\(self.rawValue))\"\n        }\n    }\n}\n"
  },
  {
    "path": "apple/shared/NotificationIds.swift",
    "content": "enum NotificationId: String {\n    case autoConnectFailed = \"net.obscura.obscura-auto-connect-failed\"\n    case connectFailed = \"net.obscura.obscura-connect-failed\"\n    case debuggingBundleFailed = \"net.obscura.obscura-debugging-bundle-failed\"\n    case onDemandTunnelStopped = \"net.obscura.ondemand-tunnel-stopped\"\n}\n"
  },
  {
    "path": "apple/shared/ObservableValue.swift",
    "content": "import Foundation\n\nclass ObservableValue<T> {\n    var lock: NSLock = .init()\n    var set = false\n    var value: T?\n    var continuations: [CheckedContinuation<T, Never>] = []\n\n    func publish(_ value: T) {\n        self.lock.withLock {\n            self.set = true\n            self.value = value\n            for continuation in self.continuations {\n                continuation.resume(returning: value)\n            }\n            self.continuations.removeAll()\n        }\n    }\n\n    /// Get the value.\n    ///\n    /// This will block if the value hasn't been set yet.\n    func get() async -> T {\n        await withCheckedContinuation { continuation in\n            self.lock.withLock {\n                if self.set {\n                    continuation.resume(returning: self.value!)\n                } else {\n                    self.continuations.append(continuation)\n                }\n            }\n        }\n    }\n\n    /// Get the value if it has been set.\n    func tryGet() -> T? {\n        self.lock.withLock {\n            self.value\n        }\n    }\n}\n"
  },
  {
    "path": "apple/shared/Sleep.swift",
    "content": "import Foundation\n\nextension Task where Success == Never, Failure == Never {\n    static func sleep(seconds: Double) async throws {\n        let duration = UInt64(seconds * 1_000_000_000)\n        try await Task.sleep(nanoseconds: duration)\n    }\n}\n"
  },
  {
    "path": "apple/shared/String.swift",
    "content": "// https://stackoverflow.com/a/74896180/7732434\nfunc leftPad(_ str: String, toLength: Int, withPad character: Character) -> String {\n    if str.count < toLength {\n        return String(repeating: character, count: toLength - str.count) + str\n    } else {\n        return str\n    }\n}\n\n// https://forums.swift.org/t/getting-the-name-of-a-swift-enum-value/35654/18\n@_silgen_name(\"swift_EnumCaseName\")\nfunc _getEnumCaseName<T>(_ value: T) -> UnsafePointer<CChar>?\n\nfunc getEnumCaseName<T>(for value: T) -> String? {\n    if let stringPtr = _getEnumCaseName(value) {\n        return String(validatingUTF8: stringPtr)\n    }\n    return nil\n}\n"
  },
  {
    "path": "apple/shared/StringError.swift",
    "content": "import Foundation\n\n// Required to use `String` as `.failure` variant in `Result`\nextension String: LocalizedError {\n    public var errorDescription: String? { return self }\n}\n\n// Define \"ipcError-$\" in webUI i18n files\nlet errorCodeOther: String = \"other\"\nlet errorCodeUpdaterCheck: String = \"updaterFailedToCheck\"\nlet errorCodeUpdaterInstall: String = \"updaterFailedToStartInstall\"\nlet errorUnsupportedOnOS: String = \"errorUnsupportedOnOS\"\nlet errorFailedToAssociateAccount: String = \"failedToAssociateAccount\"\nlet errorPurchaseFailed: String = \"purchaseFailed\"\nlet errorConnectDeviceOffline: String = \"deviceOffline\"\n"
  },
  {
    "path": "apple/shared/Swift.swift",
    "content": "/// A wrapper for objects that gives them identity based on their address.\nclass ObjectId<V>: Equatable, Hashable {\n    let value: V\n\n    init(_ v: V) {\n        self.value = v\n    }\n\n    static func == (l: ObjectId<V>, r: ObjectId<V>) -> Bool {\n        return l === r\n    }\n\n    func hash(into hasher: inout Hasher) {\n        hasher.combine(ObjectIdentifier(self))\n    }\n}\n"
  },
  {
    "path": "apple/shared/WatchableValue.swift",
    "content": "import Foundation\n\nclass WatchableValue<T> {\n    private var lock: NSLock = .init()\n    private var value: T\n    private var continuations: [CheckedContinuation<T, Never>] = []\n\n    init(_ value: T) {\n        self.value = value\n    }\n\n    func publish(_ value: T) {\n        _ = self.update { current in\n            current = value\n        }\n    }\n\n    func update(_ f: (inout T) -> Void) -> T {\n        self.lock.withLock {\n            f(&self.value)\n            for continuation in self.continuations {\n                continuation.resume(returning: self.value)\n            }\n            self.continuations.removeAll()\n            return self.value\n        }\n    }\n\n    /// Get the current value.\n    func get() -> T {\n        self.lock.withLock {\n            self.value\n        }\n    }\n\n    /// Get the current value if `predicate` returns true, otherwise return the next published value\n    func getIfOrNext(_ predicate: (T) -> Bool) async -> T {\n        await withCheckedContinuation { continuation in\n            self.lock.withLock {\n                if predicate(self.value) {\n                    continuation.resume(returning: self.value)\n                } else {\n                    self.continuations.append(continuation)\n                }\n            }\n        }\n    }\n\n    /// Returns the current value if `predicate` returns true, otherwise returns the next published value that does\n    func waitUntil(_ predicate: (T) -> Bool) async -> T {\n        while true {\n            let value = await self.getIfOrNext(predicate)\n            if predicate(value) {\n                return value\n            }\n        }\n    }\n\n    func waitUntilWithTimeout(_ timeout: Duration, _ predicate: @escaping (T) -> Bool) async -> T? {\n        do {\n            return try await withTimeout(timeout, operation: { await self.waitUntil(predicate) })\n        } catch {\n            return nil\n        }\n    }\n}\n"
  },
  {
    "path": "apple/shared/time.swift",
    "content": "import Foundation\n\nlet utcDateFormat: ISO8601DateFormatter = .init()\n"
  },
  {
    "path": "apple/system-network-extension/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>NetworkExtension</key>\n\t<dict>\n\t\t<key>NEProviderClasses</key>\n\t\t<dict>\n\t\t\t<key>com.apple.networkextension.packet-tunnel</key>\n\t\t\t<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>\n\t\t</dict>\n\t</dict>\n\t<key>OSLogPreferences</key>\n\t<dict>\n\t\t<key>com.apple.extensionkit</key>\n\t\t<dict>\n\t\t\t<key>DEFAULT-OPTIONS</key>\n\t\t\t<dict>\n\t\t\t\t<key>Enable-Oversize-Messages</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Enable-Private-Data</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Level</key>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>Enable</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t\t<key>Persist</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t</dict>\n\t\t\t</dict>\n\t\t</dict>\n\t\t<key>com.apple.xpc.transaction</key>\n\t\t<dict>\n\t\t\t<key>DEFAULT-OPTIONS</key>\n\t\t\t<dict>\n\t\t\t\t<key>Enable-Oversize-Messages</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Enable-Private-Data</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Level</key>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>Enable</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t\t<key>Persist</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t</dict>\n\t\t\t</dict>\n\t\t</dict>\n\t\t<key>net.obscura.rust-apple</key>\n\t\t<dict>\n\t\t\t<key>DEFAULT-OPTIONS</key>\n\t\t\t<dict>\n\t\t\t\t<key>Enable-Oversize-Messages</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Enable-Private-Data</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Level</key>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>Enable</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t\t<key>Persist</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t</dict>\n\t\t\t</dict>\n\t\t</dict>\n\t\t<key>net.obscura.vpn-client-app.system-network-extension</key>\n\t\t<dict>\n\t\t\t<key>DEFAULT-OPTIONS</key>\n\t\t\t<dict>\n\t\t\t\t<key>Enable-Oversize-Messages</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Enable-Private-Data</key>\n\t\t\t\t<true/>\n\t\t\t\t<key>Level</key>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>Enable</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t\t<key>Persist</key>\n\t\t\t\t\t<string>Debug</string>\n\t\t\t\t</dict>\n\t\t\t</dict>\n\t\t</dict>\n\t</dict>\n\t<key>Obscura</key>\n\t<dict>\n\t\t<key>AppGroupIdentifier</key>\n\t\t<string>$(OBSCURA_APP_APP_GROUP_ID)</string>\n\t\t<key>ObscuraSourceVersion</key>\n\t\t<string>$(OBSCURA_SOURCE_VERSION)</string>\n\t</dict>\n</dict>\n</plist>\n"
  },
  {
    "path": "apple/system-network-extension/entitlements.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.developer.networking.networkextension</key>\n\t<array>\n\t\t<string>$(OBSCURA_PACKET_TUNNEL_PROVIDER_ENTITLEMENT)</string>\n\t</array>\n\t<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>\n\t<array>\n\t\t<string>/Library/Application Support/obscura-vpn/</string>\n\t</array>\n\t<key>com.apple.security.app-sandbox</key>\n\t<true/>\n\t<key>com.apple.security.application-groups</key>\n\t<array>\n\t\t<string>$(OBSCURA_APP_APP_GROUP_ID)</string>\n\t</array>\n\t<key>com.apple.security.network.client</key>\n\t<true/>\n\t<key>com.apple.security.network.server</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "apple/third-party/CwlSysctl.swift",
    "content": "//\n//  CwlSysctl.swift\n//  CwlUtils\n//\n//  Created by Matt Gallagher on 2016/02/03.\n//  Copyright © 2016 Matt Gallagher ( https://www.cocoawithlove.com ). All rights reserved.\n//\n//  Permission to use, copy, modify, and/or distribute this software for any\n//  purpose with or without fee is hereby granted, provided that the above\n//  copyright notice and this permission notice appear in all copies.\n//\n//  THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\n//  WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\n//  MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY\n//  SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\n//  WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\n//  ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR\n//  IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n//\n\nimport Foundation\n\n/// A \"static\"-only namespace around a series of functions that operate on buffers returned from the `Darwin.sysctl` function\npublic enum Sysctl {\n    /// Possible errors.\n    public enum Error: Swift.Error {\n        case unknown\n        case malformedUTF8\n        case invalidSize\n        case posixError(POSIXErrorCode)\n    }\n\n    /// Access the raw data for an array of sysctl identifiers.\n    public static func data(for keys: [Int32]) throws -> [Int8] {\n        return try keys.withUnsafeBufferPointer { keysPointer throws -> [Int8] in\n            // Preflight the request to get the required data size\n            var requiredSize = 0\n            let preFlightResult = Darwin.sysctl(UnsafeMutablePointer<Int32>(mutating: keysPointer.baseAddress), UInt32(keys.count), nil, &requiredSize, nil, 0)\n            if preFlightResult != 0 {\n                throw POSIXErrorCode(rawValue: errno).map {\n                    print($0.rawValue)\n                    return Error.posixError($0)\n                } ?? Error.unknown\n            }\n\n            // Run the actual request with an appropriately sized array buffer\n            let data = [Int8](repeating: 0, count: requiredSize)\n            let result = data.withUnsafeBufferPointer { dataBuffer -> Int32 in\n                Darwin.sysctl(UnsafeMutablePointer<Int32>(mutating: keysPointer.baseAddress), UInt32(keys.count), UnsafeMutableRawPointer(mutating: dataBuffer.baseAddress), &requiredSize, nil, 0)\n            }\n            if result != 0 {\n                throw POSIXErrorCode(rawValue: errno).map { Error.posixError($0) } ?? Error.unknown\n            }\n\n            return data\n        }\n    }\n\n    /// Convert a sysctl name string like \"hw.memsize\" to the array of `sysctl` identifiers (e.g. [CTL_HW, HW_MEMSIZE])\n    public static func keys(for name: String) throws -> [Int32] {\n        var keysBufferSize = Int(CTL_MAXNAME)\n        var keysBuffer = [Int32](repeating: 0, count: keysBufferSize)\n        try keysBuffer.withUnsafeMutableBufferPointer { (lbp: inout UnsafeMutableBufferPointer<Int32>) throws in\n            try name.withCString { (nbp: UnsafePointer<Int8>) throws in\n                guard sysctlnametomib(nbp, lbp.baseAddress, &keysBufferSize) == 0 else {\n                    throw POSIXErrorCode(rawValue: errno).map { Error.posixError($0) } ?? Error.unknown\n                }\n            }\n        }\n        if keysBuffer.count > keysBufferSize {\n            keysBuffer.removeSubrange(keysBufferSize ..< keysBuffer.count)\n        }\n        return keysBuffer\n    }\n\n    /// Invoke `sysctl` with an array of identifers, interpreting the returned buffer as the specified type. This function will throw `Error.invalidSize` if the size of buffer returned from `sysctl` fails to match the size of `T`.\n    public static func value<T>(ofType: T.Type, forKeys keys: [Int32]) throws -> T {\n        let buffer = try data(for: keys)\n        if buffer.count != MemoryLayout<T>.size {\n            throw Error.invalidSize\n        }\n        return try buffer.withUnsafeBufferPointer { bufferPtr throws -> T in\n            guard let baseAddress = bufferPtr.baseAddress else { throw Error.unknown }\n            return baseAddress.withMemoryRebound(to: T.self, capacity: 1) { $0.pointee }\n        }\n    }\n\n    /// Invoke `sysctl` with an array of identifers, interpreting the returned buffer as the specified type. This function will throw `Error.invalidSize` if the size of buffer returned from `sysctl` fails to match the size of `T`.\n    public static func value<T>(ofType type: T.Type, forKeys keys: Int32...) throws -> T {\n        return try self.value(ofType: type, forKeys: keys)\n    }\n\n    /// Invoke `sysctl` with the specified name, interpreting the returned buffer as the specified type. This function will throw `Error.invalidSize` if the size of buffer returned from `sysctl` fails to match the size of `T`.\n    public static func value<T>(ofType type: T.Type, forName name: String) throws -> T {\n        return try self.value(ofType: type, forKeys: self.keys(for: name))\n    }\n\n    /// Invoke `sysctl` with an array of identifers, interpreting the returned buffer as a `String`. This function will throw `Error.malformedUTF8` if the buffer returned from `sysctl` cannot be interpreted as a UTF8 buffer.\n    public static func string(for keys: [Int32]) throws -> String {\n        let optionalString = try data(for: keys).withUnsafeBufferPointer { dataPointer -> String? in\n            dataPointer.baseAddress.flatMap { String(validatingUTF8: $0) }\n        }\n        guard let s = optionalString else {\n            throw Error.malformedUTF8\n        }\n        return s\n    }\n\n    /// Invoke `sysctl` with an array of identifers, interpreting the returned buffer as a `String`. This function will throw `Error.malformedUTF8` if the buffer returned from `sysctl` cannot be interpreted as a UTF8 buffer.\n    public static func string(for keys: Int32...) throws -> String {\n        return try self.string(for: keys)\n    }\n\n    /// Invoke `sysctl` with the specified name, interpreting the returned buffer as a `String`. This function will throw `Error.malformedUTF8` if the buffer returned from `sysctl` cannot be interpreted as a UTF8 buffer.\n    public static func string(for name: String) throws -> String {\n        return try self.string(for: self.keys(for: name))\n    }\n\n    /// e.g. \"MyComputer.local\" (from System Preferences -> Sharing -> Computer Name) or\n    /// \"My-Name-iPhone\" (from Settings -> General -> About -> Name)\n    public static var hostName: String { return try! Sysctl.string(for: [CTL_KERN, KERN_HOSTNAME]) }\n\n    /// e.g. \"x86_64\" or \"N71mAP\"\n    /// NOTE: this is *corrected* on iOS devices to fetch hw.model\n    public static var machine: String {\n        #if os(iOS) && !arch(x86_64) && !arch(i386)\n            return try! Sysctl.string(for: [CTL_HW, HW_MODEL])\n        #else\n            return try! Sysctl.string(for: [CTL_HW, HW_MACHINE])\n        #endif\n    }\n\n    /// e.g. \"MacPro4,1\" or \"iPhone8,1\"\n    /// NOTE: this is *corrected* on iOS devices to fetch hw.machine\n    public static var model: String {\n        #if os(iOS) && !arch(x86_64) && !arch(i386)\n            return try! Sysctl.string(for: [CTL_HW, HW_MACHINE])\n        #else\n            return try! Sysctl.string(for: [CTL_HW, HW_MODEL])\n        #endif\n    }\n\n    /// e.g. \"8\" or \"2\"\n    public static var activeCPUs: Int32 { return try! Sysctl.value(ofType: Int32.self, forKeys: [CTL_HW, HW_AVAILCPU]) }\n\n    /// e.g. \"15.3.0\" or \"15.0.0\"\n    public static var osRelease: String { return try! Sysctl.string(for: [CTL_KERN, KERN_OSRELEASE]) }\n\n    /// e.g. \"Darwin\" or \"Darwin\"\n    public static var osType: String { return try! Sysctl.string(for: [CTL_KERN, KERN_OSTYPE]) }\n\n    /// e.g. \"15D21\" or \"13D20\"\n    public static var osVersion: String { return try! Sysctl.string(for: [CTL_KERN, KERN_OSVERSION]) }\n\n    /// e.g. \"Darwin Kernel Version 15.3.0: Thu Dec 10 18:40:58 PST 2015; root:xnu-3248.30.4~1/RELEASE_X86_64\" or\n    /// \"Darwin Kernel Version 15.0.0: Wed Dec  9 22:19:38 PST 2015; root:xnu-3248.31.3~2/RELEASE_ARM64_S8000\"\n    public static var version: String { return try! Sysctl.string(for: [CTL_KERN, KERN_VERSION]) }\n\n    #if os(macOS)\n        /// e.g. 199506 (not available on iOS)\n        public static var osRev: Int32 { return try! Sysctl.value(ofType: Int32.self, forKeys: [CTL_KERN, KERN_OSREV]) }\n\n        /// e.g. 2659000000 (not available on iOS)\n        public static var cpuFreq: Int64 { return try! Sysctl.value(ofType: Int64.self, forName: \"hw.cpufrequency\") }\n\n        /// e.g. 25769803776 (not available on iOS)\n        public static var memSize: UInt64 { return try! Sysctl.value(ofType: UInt64.self, forKeys: [CTL_HW, HW_MEMSIZE]) }\n    #endif\n}\n"
  },
  {
    "path": "apple/xcodescripts/cargo-build-static-lib.bash",
    "content": "#!/usr/bin/env bash\n\n# Originally generated with cargo-xcode 1.10.0, since modified heavily\n# See original cargo-xcode build script here https://gitlab.com/kornelski/cargo-xcode/-/blob/v1.10.0/src/xcodebuild.sh?ref_type=tags\n\nset -euo pipefail\nexport PATH=\"$HOME/.nix-profile/bin/:$PATH:/usr/local/bin:$HOME/.cargo/bin:/opt/homebrew/bin\"\n\n## don't use ios/watchos linker for build scripts and proc macros\n## This If statement is due to an oddity where defining these creates issues on Archive see OBS-1521\nif [ \"${CONFIGURATION}\" != \"Release\" ] || [ \"${PLATFORM_NAME}\" != \"macosx\" ]; then\n\tCARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=$(xcrun --find ld)\n\texport CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER\n\tCARGO_TARGET_X86_64_APPLE_DARWIN_LINKER=$(xcrun --find ld)\n\texport CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER\nfi\n\nexport OBSCURA_CLIENT_RUSTLIB_CBINDGEN_OUTPUT_HEADER_PATH=\"$SCRIPT_OUTPUT_FILE_1\"\nexport OBSCURA_CLIENT_RUSTLIB_CBINDGEN_CONFIG_PATH=\"$SCRIPT_INPUT_FILE_2\"\n\nCARGO_XCODE_CARGO_MANIFEST_PATH=\"${SCRIPT_INPUT_FILE:-\"$SCRIPT_INPUT_FILE_1\"}\"\n\n# So that we pick up .cargo/config.toml\ncd \"$(dirname \"$CARGO_XCODE_CARGO_MANIFEST_PATH\")\"\n\n# NOTE: We need the '-' paramaeter expansion because we're in bash's \"set -u\" mode\nif [ -n \"${OTHER_INPUT_FILE_FLAGS-}\" ]; then\n\tread -r -a CARGO_XCODE_CARGO_EXTRA_FLAGS <<<\"$OTHER_INPUT_FILE_FLAGS\"\nelse\n\tCARGO_XCODE_CARGO_EXTRA_FLAGS=(\"--lib\")\nfi\n\ncase \"$PLATFORM_NAME\" in\n\"macosx\")\n\tCARGO_XCODE_TARGET_OS=darwin\n\tif [ \"${IS_MACCATALYST-NO}\" = YES ]; then\n\t\tCARGO_XCODE_TARGET_OS=ios-macabi\n\tfi\n\t;;\n\"iphoneos\") CARGO_XCODE_TARGET_OS=ios ;;\n\"iphonesimulator\") CARGO_XCODE_TARGET_OS=ios-sim ;;\n\"appletvos\" | \"appletvsimulator\") CARGO_XCODE_TARGET_OS=tvos ;;\n\"watchos\") CARGO_XCODE_TARGET_OS=watchos ;;\n\"watchsimulator\") CARGO_XCODE_TARGET_OS=watchos-sim ;;\n*)\n\tCARGO_XCODE_TARGET_OS=\"$PLATFORM_NAME\"\n\techo >&2 \"warning: cargo-xcode needs to be updated to handle $PLATFORM_NAME\"\n\t;;\nesac\n\ndeclare -a CARGO_XCODE_TARGET_TRIPLES\ndeclare -a CARGO_XCODE_TARGET_FLAGS\ndeclare -a LIPO_INPUT_FILES\nfor arch in $ARCHS; do\n\tif [[ \"$arch\" == \"arm64\" ]]; then arch=aarch64; fi\n\tif [[ \"$arch\" == \"i386\" && \"$CARGO_XCODE_TARGET_OS\" != \"ios\" ]]; then arch=i686; fi\n\ttriple=\"${arch}-apple-$CARGO_XCODE_TARGET_OS\"\n\tCARGO_XCODE_TARGET_TRIPLES+=(\"$triple\")\n\tCARGO_XCODE_TARGET_FLAGS+=(\"--target=${triple}\")\n\tLIPO_INPUT_FILES+=(\"$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$CARGO_XCODE_CARGO_FILE_NAME\")\ndone\n\necho >&2 \"Cargo $CARGO_XCODE_BUILD_PROFILE $ACTION for $PLATFORM_NAME $ARCHS =${CARGO_XCODE_TARGET_TRIPLES[*]}; using ${SDK_NAMES:-}. \\$PATH is:\"\ntr >&2 : '\\n' <<<\"$PATH\"\n\nif command -v rustup &>/dev/null; then\n\tfor triple in \"${CARGO_XCODE_TARGET_TRIPLES[@]}\"; do\n\t\tif ! rustup target list --installed | grep -Eq \"^$triple$\"; then\n\t\t\techo >&2 \"warning: this build requires rustup toolchain for $triple, but it isn't installed (will try rustup next)\"\n\t\t\trustup target add \"$triple\" || {\n\t\t\t\techo >&2 \"warning: can't install $triple, will try nightly -Zbuild-std\"\n\t\t\t\tCARGO_XCODE_CARGO_EXTRA_FLAGS+=(\"-Zbuild-std\")\n\t\t\t\tif [ -z \"${RUSTUP_TOOLCHAIN:-}\" ]; then\n\t\t\t\t\texport RUSTUP_TOOLCHAIN=nightly\n\t\t\t\tfi\n\t\t\t\tbreak\n\t\t\t}\n\t\tfi\n\tdone\nfi\n\nif [ \"$CARGO_XCODE_BUILD_PROFILE\" = release ]; then\n\tCARGO_XCODE_CARGO_EXTRA_FLAGS+=(\"--release\")\nfi\n\nif [ \"$ACTION\" = clean ]; then\n\tcargo clean --verbose --manifest-path=\"$CARGO_XCODE_CARGO_MANIFEST_PATH\" \"${CARGO_XCODE_TARGET_FLAGS[@]}\" \"${CARGO_XCODE_CARGO_EXTRA_FLAGS[@]}\"\n\trm -f \"$SCRIPT_OUTPUT_FILE_0\" \"$SCRIPT_OUTPUT_FILE_1\" \"$SCRIPT_OUTPUT_FILE_2\"\n\texit 0\nfi\ncargo build --verbose --manifest-path=\"$CARGO_XCODE_CARGO_MANIFEST_PATH\" --features=\"${CARGO_XCODE_FEATURES:-}\" \"${CARGO_XCODE_TARGET_FLAGS[@]}\" \"${CARGO_XCODE_CARGO_EXTRA_FLAGS[@]}\" || {\n\techo >&2 \"error: cargo build failed\"\n\texit 1\n}\n\nlipo \"${LIPO_INPUT_FILES[@]}\" -create -output \"$SCRIPT_OUTPUT_FILE_0\"\n\nif [ -n \"${LD_DYLIB_INSTALL_NAME-}\" ]; then\n\tinstall_name_tool -id \"$LD_DYLIB_INSTALL_NAME\" \"$SCRIPT_OUTPUT_FILE_0\"\nfi\n\necho \"success: $ACTION of $SCRIPT_OUTPUT_FILE_0 for ${CARGO_XCODE_TARGET_TRIPLES[*]}\"\n\n# Generate .modulemap file\ncat <<EOF >\"$SCRIPT_OUTPUT_FILE_2\"\nmodule libobscuravpn_client {\n    header \"$(basename \"$SCRIPT_OUTPUT_FILE_1\")\"\n\n    export *\n}\nEOF\n"
  },
  {
    "path": "apple/xcodescripts/nix-build-web-bundle.bash",
    "content": "#!/usr/bin/env bash\nset -eo pipefail # No -u since we're sourcing external things\n\npushd \"${SRCROOT}/../\"\n\nsource contrib/shell/source-nix.sh\nOBS_WEB_PLATFORM=\"$PLATFORM_NAME\" exec nix develop \".#web\" --print-build-logs -c just web-bundle-build\n"
  },
  {
    "path": "apple/xcodescripts/nix-web-dev-server-start.bash",
    "content": "#!/usr/bin/env bash\nset -eo pipefail # No -u since we're sourcing external things\n\npushd \"${SRCROOT}/../\"\n\nsource contrib/shell/source-nix.sh\n\n# TODO: remove magic 1420 port\nPORT=1420\n\n\"$SRCROOT/xcodescripts/nix-web-dev-server-stop.bash\" || true\n\nOBS_WEB_PLATFORM=\"$PLATFORM_NAME\" WK_WEB_VIEW=1 nix develop \".#web\" --print-build-logs -c just web-bundle-start &\n\nwhile jobs %% && ! nc -z localhost $PORT; do\n\tsleep 0.05\ndone\ndisown %%\n"
  },
  {
    "path": "apple/xcodescripts/nix-web-dev-server-stop.bash",
    "content": "#!/usr/bin/env bash\n\n# This just kills whatever is using the port. It is ugly but the best option for a few reasons.\n# 1. Xcode ignores the result of pre-actions. This means that we have no way to signal a failure.\n# 2. The clean action will destroy our PID file.\n#\n# So we want the best chance of succeeding or the user will unknowingly be using a stale web server. The only way to reliably free up the port is to kill what is listening on it.\n\n# TODO: remove magic 1420 port\nkill \"$(lsof -ti 'tcp:1420')\"\n"
  },
  {
    "path": "apple/xcodescripts/pre-action.bash",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\ncd \"$SRCROOT/..\"\n\napple/xcodescripts/set-build-info.bash\n\nmkdir -pv obscura-ui/build\n"
  },
  {
    "path": "apple/xcodescripts/set-build-info.bash",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\ncd \"$SRCROOT/..\"\n\nsource contrib/shell/source-echoerr.bash\n\ngit_commit=$(git rev-parse HEAD)\nif ! git diff --quiet; then\n\tgit_commit=\"$git_commit-dirty\"\nfi\n\ngit_describe=$(git describe --match \"v/*\" --abbrev=12 --dirty)\nechoerr \"git describe: $git_describe\"\ngit_tag=\"${git_describe%%-*}\"\n\nbuild_version=$(date -u '+1.%Y%m%d.%H%M%S')\n\nmarketing_version=\"${git_tag#v/}\"\nif [[ \"$git_tag\" != \"$git_describe\" ]]; then\n\t# For builds that don't exactly match a tag add a `.1` to indicate a \"dev\" build.\n\tmarketing_version=\"${marketing_version}.1\"\nfi\n\nsource_version=\"v${git_describe#v/}\"\n\ntee apple/Configurations/buildversion.xcconfig <<END\n// NOTE: This file is generated prior to each build, and is git-ignored\n\nCURRENT_PROJECT_VERSION = $build_version\nMARKETING_VERSION = $marketing_version\nOBSCURA_SOURCE_ID = $git_commit\nOBSCURA_SOURCE_VERSION = $source_version\nEND\n"
  },
  {
    "path": "bin/gradle-deps-update.sh",
    "content": "#!/usr/bin/env bash\nexec nix run '.#gradle-deps-update' --print-build-logs\n"
  },
  {
    "path": "bin/log-sleeps.py",
    "content": "#!/usr/bin/env python3\n\n\"\"\"\nRun with `--help` for info.\n\"\"\"\n\nimport argparse\nimport datetime\nimport json\n\ndef fmt_time(timestamp):\n    return timestamp.isoformat(sep=\" \", timespec=\"milliseconds\")\n\ndef is_sleep_log(log):\n    if log[\"eventType\"] != \"logEvent\":\n        return False\n\n    if (\n        log[\"subsystem\"] == \"com.apple.powerd\"\n        and log[\"category\"] == \"sleepWake\"\n        and \"from Deep Idle\" in log[\"eventMessage\"]\n    ):\n        return True\n\n    if (\n        log[\"subsystem\"] == \"net.obscura.vpn-client-app.system-network-extension\"\n        and (\n            \"wake entry\" in log[\"eventMessage\"]\n            or \"sleep exit\" in log[\"eventMessage\"]\n        )\n    ):\n        return True\n\n    return False\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser(\n        description=\"Extracts messages related to sleeping as well as printing periods of no logs (when the machine is presumably asleep.\",\n    )\n    parser.add_argument(\"path\")\n    parser.add_argument(\n        \"-s\",\n        \"--min-seconds\",\n        default=60,\n        help=\"Minimum idle duration to log.\",\n    )\n    args = parser.parse_args()\n\n    max_sleep = datetime.timedelta()\n    max_sleep_time = None\n    last_entry = None\n\n    noteworthy = datetime.timedelta(seconds=args.min_seconds)\n\n    with open(args.path) as f:\n        for line in f:\n            log = json.loads(line)\n\n            strtimestamp = log.get(\"timestamp\")\n            if not strtimestamp:\n                continue\n\n            timestamp = datetime.datetime.fromisoformat(strtimestamp)\n\n            if is_sleep_log(log):\n                print(f\"{fmt_time(timestamp)} {log.get(\"eventMessage\")}\")\n\n            if last_entry is not None:\n                delta = timestamp - last_entry\n\n                if delta > max_sleep:\n                    max_sleep = delta\n                    max_sleep_time = timestamp\n\n                if delta >= noteworthy:\n                    print(f\"{fmt_time(timestamp)} sleep for {delta}\")\n\n            last_entry = timestamp\n\n    print(f\"max sleep {max_sleep} at {fmt_time(max_sleep_time)}\")\n"
  },
  {
    "path": "bin/log-summary.py",
    "content": "#!/usr/bin/env python3\n\nimport argparse\nimport datetime\nimport json\nimport re\nimport sys\nimport zoneinfo\n\nLEVELS = {\n    \"Debug\": 0,\n    \"Info\": 1,\n    \"Default\": 2,\n    \"Error\": 3,\n    \"Fault\": 4,\n}\n\nLEVEL_MAX = 5 # Higher than all levels.\n\nRUST_PRINT_RE = re.compile(\"|\".join([\n    \"creating tunnel  .*\",\n    \"deriving connect error code for tunnel (creation|connect): .*\",\n    \"finishing tunnel connection  .*\",\n    \"Ignoring failure to update exit list: .*\",\n    \"Selected exit  .*\",\n    \"selected relay  .*\",\n    \"tunnel connected\",\n    '\"preferred network path interface name:.*',\n    '\"sleep entry .*',\n    '\"startTunnel entry .*',\n    '\"stopTunnel entry .*',\n    '\"wake entry .*',\n    '.* message_id=\"(3rOUXFti|Azzlo6j2|KT91bgvI|OfLfwKhf|TJ4nH30h|uQ0xQcPP|UROUZerU)\".*',\n]), re.DOTALL)\n\nSWIFT_PRINT_RE = re.compile(\"|\".join([\n    \"NWPathMonitor event: .*\",\n]))\n\ndef format_log_time(log):\n    date = datetime.datetime.fromisoformat(log[\"timestamp\"])\n    return format_time(date)\n\ndef format_time(date):\n    if args.zone == \"\":\n        return \"\"\n\n    r = \"\"\n\n    for zone in args.zone.split(\",\"):\n        if zone == \"local\":\n            converted = date.astimezone(None)\n        elif zone == \"source\":\n            converted = date\n        elif zone == \"utc\":\n            converted = date.astimezone(datetime.timezone.utc)\n        else:\n            converted = date.astimezone(zoneinfo.ZoneInfo(zone))\n\n        if args.date:\n            r += converted.strftime(\"%Y-%m-%d %H:%M:%S.%f\")[:-3] + \" \"\n        else:\n            r += converted.strftime(\"%H:%M:%S.%f\")[:-3] + \" \"\n\n    return r\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"path\")\n    parser.add_argument(\n        \"-d\",\n        \"--date\",\n        action=\"store_true\",\n        help=\"Show the date along with the time.\"\n    )\n    parser.add_argument(\n        \"-l\",\n        \"--level\",\n        choices=list(LEVELS),\n        default=\"Fault\",\n        help=\"Print all logs at or above this level\",\n    )\n    parser.add_argument(\n        \"-z\",\n        \"--zone\",\n        default=\"source\",\n        help=\"A comma separated list of timezones in which to display times. Each item is either `source` (for the users timezone), `local` for your timezone or an IANNA timezone name (like `America/Toronto`).\"\n    )\n    args = parser.parse_args()\n\n    min_level = LEVELS[args.level]\n\n    with open(args.path) as f:\n        for line in f:\n            entry = json.loads(line)\n\n            if entry.get(\"eventType\") != \"logEvent\":\n                continue\n\n            subsystem = entry[\"subsystem\"]\n\n            if subsystem == \"net.obscura.rust-apple\":\n                msg = entry[\"eventMessage\"]\n                if RUST_PRINT_RE.match(msg):\n                    print(format_log_time(entry), msg)\n                elif ' message_id=\"eech6Ier\"' in msg:\n                    print(format_log_time(entry), \"Racing relays... CONNECTION ATTEMPT START\")\n                elif 'Ignoring failure to update exit list' in msg:\n                    print(\"WTF\", msg)\n                    print(\"MATCH\", RUST_PRINT_RE.match(msg))\n                elif LEVELS.get(entry[\"messageType\"], LEVEL_MAX) >= min_level:\n                    print(format_log_time(entry), msg)\n            elif subsystem == \"net.obscura.vpn-client-app\":\n                msg = entry[\"eventMessage\"]\n                if SWIFT_PRINT_RE.match(msg):\n                    print(format_log_time(entry), msg)\n            elif subsystem == \"\" and entry[\"processID\"] == 0:\n                msg = entry[\"eventMessage\"]\n                if msg == \"PMRD: trace point 0x18\":\n                    print(format_log_time(entry), \"########## KERNEL SLEEP ##########\")\n"
  },
  {
    "path": "bin/log-text.py",
    "content": "#!/usr/bin/env python3\n\nimport argparse\nimport datetime\nimport json\nimport sys\nimport zoneinfo\n\nLEVELS = {\n    \"Debug\": 0,\n    \"Info\": 1,\n    \"Default\": 2,\n    \"Error\": 3,\n    \"Fault\": 4,\n}\n\nLEVEL_MAX = 5 # Higher than all levels.\n\nLEVEL_FMT = {\n    \"Debug\": \"D\",\n    \"Info\": \"I\",\n    \"Default\": \"L\",\n    \"Error\": \"E\",\n    \"Fault\": \"F\",\n\n    None: \"N\",\n    \"unknown\": \"U\",\n}\n\nIGNORED_TYPES = {\n    \"activityCreateEvent\",\n    \"signpostEvent\",\n    \"stateEvent\",\n    \"unknown\",\n    \"userActionEvent\",\n}\n\nOUR_PROCESSES = {\n    \"Obscura VPN\",\n    \"net.obscura.vpn-client-app.system-network-extension\",\n}\n\nUI_SUBSYSTEMS = {\n    \"com.apple.AppKit\",\n    \"com.apple.CFBundle\",\n    \"com.apple.defaults\",\n}\n\ndef format_time(date):\n    if args.zone == \"\":\n        return \"\"\n\n    r = \"\"\n\n    for zone in args.zone.split(\",\"):\n        if zone == \"local\":\n            converted = date.astimezone(None)\n        elif zone == \"source\":\n            converted = date\n        elif zone == \"utc\":\n            converted = date.astimezone(datetime.timezone.utc)\n        else:\n            converted = date.astimezone(zoneinfo.ZoneInfo(zone))\n\n        if args.date:\n            r += converted.strftime(\"%Y-%m-%d %H:%M:%S.%f\")[:-3] + \" \"\n        else:\n            r += converted.strftime(\"%H:%M:%S.%f\")[:-3] + \" \"\n\n    return r\n\ndef format_log(log):\n    if log.get(\"finished\") == 1:\n        return \"Finished\"\n\n    if log[\"eventType\"] in IGNORED_TYPES:\n        return None\n\n    if log[\"eventType\"] == \"timesyncEvent\":\n        date = datetime.datetime.fromisoformat(log[\"timestamp\"])\n        datestr = format_time(date)\n        return f\"{datestr}timesyncEvent\"\n\n    if args.obscura and log[\"processImagePath\"] not in OUR_PROCESSES:\n        return None\n\n    level_int = LEVELS.get(log[\"messageType\"], LEVEL_MAX)\n    if level_int < min_level:\n        return None\n\n    if not args.ui and log[\"subsystem\"] in UI_SUBSYSTEMS:\n        return None\n\n    date = datetime.datetime.fromisoformat(log[\"timestamp\"])\n    datestr = format_time(date)\n\n    level_s = LEVEL_FMT.get(log[\"messageType\"], \"?\")\n\n    return f\"{datestr}{level_s} {log[\"processImagePath\"]}:{log[\"subsystem\"]}:{log[\"category\"]} | {log[\"eventMessage\"]}\"\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"path\")\n    parser.add_argument(\n        \"-d\",\n        \"--date\",\n        action=\"store_true\",\n        help=\"Show the date along with the time.\"\n    )\n    parser.add_argument(\n        \"-l\",\n        \"--level\",\n        choices=list(LEVELS),\n        default=\"Debug\",\n        help=\"Minimum log level to print.\",\n    )\n    parser.add_argument(\n        \"--obscura\",\n        action=\"store_true\",\n        help=\"Show only logs from our processes.\"\n    )\n    parser.add_argument(\n        \"--ui\",\n        action=\"store_true\",\n        help=\"Show UI-related logs.\"\n    )\n    parser.add_argument(\n        \"-z\",\n        \"--zone\",\n        default=\"source\",\n        help=\"A comma separated list of timezones in which to display times. Each item is either `source` (for the users timezone), `local` for your timezone or an IANNA timezone name (like `America/Toronto`).\"\n    )\n    args = parser.parse_args()\n\n    min_level = LEVELS[args.level]\n\n    with open(args.path) as f:\n        for line in f:\n            entry = json.loads(line)\n\n            formatted = format_log(entry)\n            if formatted == None:\n                continue\n\n            print(formatted)\n"
  },
  {
    "path": "contrib/bin/build-obscuravpn-dmg.bash",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nsource contrib/shell/source-echoerr.bash\nsource contrib/shell/source-die.bash\n\nif [ \"$#\" -ne 0 ]; then\n\tdie \"No parameters accepted.\"\nfi\n\nKEYCHAIN_PROFILE=\"notarytool-password\"\nCERT='Developer ID Application: Sovereign Engineering Inc. (5G943LR562)'\n\nAPP_NAME=\"Obscura VPN\"\nAPP_BASENAME=\"$APP_NAME.app\"\nDMG_FILE_NAME=\"$APP_NAME.dmg\"\nVOLUME_NAME=\"$APP_NAME\"\n\nBACKGROUND=\"apple/dmg-building/installer_background.tiff\"\n\n# Temp directory setup\nTMP_DIR=$(mktemp -d)\n\ncleanup() {\n\techo \"Cleaning up temporary directory: $TMP_DIR\"\n\trm -rf \"$TMP_DIR\"\n}\n\ntrap cleanup EXIT\n\nARCHIVE_DIR=\"$TMP_DIR/client-prod.xcarchive\"\nEXPORT_DIR=\"$TMP_DIR/client-prod-export\"\nAPP_PATH=\"$EXPORT_DIR/$APP_BASENAME\"\n\nSOURCE_DIR=\"$TMP_DIR/dmg-contents\"\nmkdir \"$SOURCE_DIR\"\n\n# Size of the Finder window toolbar\nWINDOW_TOP_PADDING=28\n\n# NOTE: Keep in sync with \"$BACKGROUND\"\nICON_SIZE=120\n\n# We need to specify the center location of the icons\nICON_CENTERING_DELTA=$((ICON_SIZE / 2))\n\nICONS_Y_FROM_TOP=277\n\nOBSCURA_APP_ICON_X_FROM_LEFT=287\nAPPLICATIONS_DROP_ICON_X_FROM_LEFT=553\n\nBACKGROUND_IMAGE_HEIGHT=601\nBACKGROUND_IMAGE_WIDTH=960\n\nWINDOW_POS_X=200\nWINDOW_POS_Y=120\n\nset -x\n\nxcodebuild archive \\\n\t-workspace apple/client.xcodeproj/project.xcworkspace \\\n\t-scheme 'Prod Client' \\\n\t-archivePath \"$ARCHIVE_DIR\"\n\nxcodebuild -exportArchive \\\n\t-archivePath \"$ARCHIVE_DIR\" \\\n\t-exportOptionsPlist apple/ExportOptions.plist \\\n\t-exportPath \"$EXPORT_DIR\"\n\nNOTARIZE_ZIP=\"$TMP_DIR/obscura-notarize.zip\"\nditto -c -k --keepParent \"$APP_PATH\" \"$NOTARIZE_ZIP\"\nxcrun notarytool submit \\\n\t--keychain-profile \"$KEYCHAIN_PROFILE\" \\\n\t--verbose \\\n\t--wait \\\n\t\"$NOTARIZE_ZIP\"\n\nxcrun stapler staple -v \"$APP_PATH\"\nxcrun stapler validate -v \"$APP_PATH\"\n\n# Ref: https://developer.apple.com/forums/thread/130560\nspctl -a -t exec -vvv \"$APP_PATH\"\n\nmv \"$APP_PATH\" \"$SOURCE_DIR/$APP_BASENAME\"\n\n# Create the DMG\nrm -vf \"$DMG_FILE_NAME\"\ncreate-dmg \\\n\t--volname \"${VOLUME_NAME}\" \\\n\t--background \"$BACKGROUND\" \\\n\t--window-pos \"$WINDOW_POS_X\" \"$WINDOW_POS_Y\" \\\n\t--window-size \"$BACKGROUND_IMAGE_WIDTH\" $(( BACKGROUND_IMAGE_HEIGHT + WINDOW_TOP_PADDING )) \\\n\t--icon-size \"$ICON_SIZE\" \\\n\t--icon \"$APP_BASENAME\" $(( OBSCURA_APP_ICON_X_FROM_LEFT + ICON_CENTERING_DELTA )) $(( ICONS_Y_FROM_TOP + ICON_CENTERING_DELTA )) \\\n\t--hide-extension \"$APP_BASENAME\" \\\n\t--app-drop-link $(( APPLICATIONS_DROP_ICON_X_FROM_LEFT + ICON_CENTERING_DELTA )) $(( ICONS_Y_FROM_TOP + ICON_CENTERING_DELTA )) \\\n\t--no-internet-enable \\\n\t\"$DMG_FILE_NAME\" \\\n\t\"$SOURCE_DIR\"\n\n# Codesign the DMG\n# Ref: https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/Procedures/Procedures.html\ncodesign --sign \"$CERT\" \"$DMG_FILE_NAME\"\nxcrun notarytool submit \\\n\t--keychain-profile \"$KEYCHAIN_PROFILE\" \\\n\t--verbose \\\n\t--wait \\\n\t\"$DMG_FILE_NAME\"\n\nxcrun stapler staple -v \"$DMG_FILE_NAME\"\nxcrun stapler validate -v \"$DMG_FILE_NAME\"\n\n# Ref: https://developer.apple.com/library/archive/technotes/tn2206/_index.html\nspctl -a -t open --context context:primary-signature -v \"$DMG_FILE_NAME\"\n\n# Ref: https://wiki.freepascal.org/Notarization_for_macOS_10.14.5%2B#Step_7_-_Verify_notarization_of_the_disk_image\nspctl -a -vv -t install \"$DMG_FILE_NAME\"\n"
  },
  {
    "path": "contrib/bin/check-in-obscura-nix-shell.bash",
    "content": "#!/usr/bin/env bash\nset -eo pipefail\n\nsource contrib/shell/source-die.bash\n\nif [ -z \"$OBSCURA_MAGIC_IN_NIX_SHELL\" ]; then\n\tdie \"ERROR: Not running in Obscura Nix Shell, see README.md for setup\"\nfi\n"
  },
  {
    "path": "contrib/bin/find-nix-files.bash",
    "content": "#!/usr/bin/env bash\nset -eo pipefail\n\nsource contrib/shell/source-die.bash\n\n# Parse command line options\nwhile getopts \":z\" opt; do\n  case $opt in\n    z)\n      NULL_OUTPUT=true\n      ;;\n    \\?)\n      die \"Invalid option: -${OPTARG}\"\n      ;;\n  esac\ndone\n\nnix_file_git_patterns=(\n  '*.nix'\n)\n\n./contrib/bin/ls-non-ignored-files.bash ${NULL_OUTPUT:+-z} -- \"${nix_file_git_patterns[@]}\"\n"
  },
  {
    "path": "contrib/bin/find-shellcheck-files.bash",
    "content": "#!/usr/bin/env bash\nset -eo pipefail\n\nsource contrib/shell/source-die.bash\n\n# Parse command line options\nwhile getopts \":z\" opt; do\n\tcase $opt in\n\t\tz)\n\t\t\tNULL_OUTPUT=true\n\t\t\t;;\n\t\t\\?)\n\t\t\tdie \"Invalid option: -${OPTARG}\"\n\t\t\t;;\n\tesac\ndone\n\nshell_script_git_patterns=(\n\t'*.sh'\n\t'*.bash'\n)\n\n./contrib/bin/ls-non-ignored-files.bash ${NULL_OUTPUT:+-z} -- \"${shell_script_git_patterns[@]}\"\n"
  },
  {
    "path": "contrib/bin/linux-packages.bash",
    "content": "#!/usr/bin/env bash\nset -eu\n\n./contrib/bin/package-deb.bash\n./contrib/bin/package-rpm.bash\n./contrib/bin/package-arch.bash\n"
  },
  {
    "path": "contrib/bin/linux-test.bash",
    "content": "#!/usr/bin/env bash\nset -eu\ntrap 'pkill -P $$' EXIT\n\nfunction error() {\n  echo \"$@\" >&2\n  kill $$\n}\n\nfunction check_args() {\n  if [ \"$1\" -ne \"$2\" ]; then\n    error \"L${BASH_LINENO[0]}: wrong number of function arguments, got $1, expected $2\"\n  fi\n}\n\nfunction reset() {\n  check_args $# 2\n  local DISTRO=$1\n  local FLAVOR=$2\n\n  echo \"Creating disk image\"\n  virsh --connect qemu:///session destroy \"obs-${DISTRO}-${FLAVOR}\" &> /dev/null || true\n  qemu-img create -f qcow2 \"$(disk_image_path \"${DISTRO}\" \"${FLAVOR}\").tmp\" 20G\n\n  echo \"Downloading ${DISTRO}-${FLAVOR} installation media if necessary\"\n  download \"${DISTRO}\" \"${FLAVOR}\"\n  prepare \"${DISTRO}\" \"${FLAVOR}\"\n\n  echo \"Installing ${DISTRO}-${FLAVOR}\"\n  mapfile -t AUTOINSTALL_ARGS < <(autoinstall \"${DISTRO}\" \"${FLAVOR}\")\n  virt-install \\\n    --connect qemu:///session \\\n    --transient \\\n    --name \"obs-${DISTRO}-${FLAVOR}\" \\\n    --ram 4096 \\\n    --vcpus $(($(nproc)-1)) \\\n    --cpu host-model \\\n    --disk path=\"$(disk_image_path \"${DISTRO}\" \"${FLAVOR}\").tmp,format=qcow2,bus=virtio\" \\\n    --network user \\\n    --graphics none \\\n    --video virtio \\\n    \"${AUTOINSTALL_ARGS[@]}\"\n\n    mv \"$(disk_image_path \"${DISTRO}\" \"${FLAVOR}\").tmp\" \"$(disk_image_path \"${DISTRO}\" \"${FLAVOR}\")\"\n}\n\nfunction disk_image_path() {\n  check_args $# 2\n  local DISTRO=$1\n  local FLAVOR=$2\n  echo \"./linux/vm/${DISTRO}-${FLAVOR}.qcow2\"\n}\n\nfunction download() {\n  check_args $# 2\n  local DISTRO=$1\n  local FLAVOR=$2\n  # Ubuntu doesn't have small desktop or netinstall images, so we need to download the iso\n  declare -A map=(\n    [\"ubuntu24.04-desktop\"]=\"https://releases.ubuntu.com/noble/ubuntu-24.04.3-desktop-amd64.iso\"\n  )\n  if [[ -v map[${DISTRO}-${FLAVOR}] ]]; then\n    local ISO=\"./linux/vm/${DISTRO}-${FLAVOR}.iso\"\n    if [ ! -e \"${ISO}\" ]; then\n      wget \"${map[${DISTRO}-${FLAVOR}]}\" -O \"${ISO}\"\n    fi\n  fi\n}\n\nfunction prepare() {\n  check_args $# 2\n  local DISTRO=$1\n  local FLAVOR=$2\n  # Ubuntu on desktop doesn't support auto install via initrd injected files\n  declare -A map=(\n    [\"ubuntu24.04-desktop\"]=\"x\"\n    [\"archlinux-desktop\"]=\"x\"\n  )\n  if [[ -v map[${DISTRO}-${FLAVOR}] ]]; then\n    cloud-localds \"./linux/vm/${DISTRO}-${FLAVOR}.seed.iso\" \"./linux/vm/${DISTRO}-${FLAVOR}-cloud-init/user-data\" \"./linux/vm/${DISTRO}-${FLAVOR}-cloud-init/meta-data\"\n  fi\n}\nfunction autoinstall() {\n    check_args $# 2\n    local DISTRO=$1\n    local FLAVOR=$2\n\n    echo \"--os-variant\"\n    declare -A map=(\n      [\"debian12-desktop\"]=\"debian12\"\n      [\"debian13-desktop\"]=\"debian13\"\n      [\"ubuntu24.04-desktop\"]=\"ubuntu24.04\"\n      [\"fedora43-desktop\"]=\"fedora41\"\n      [\"archlinux-desktop\"]=\"archlinux\"\n    )\n    if [[ ! -v map[${DISTRO}-${FLAVOR}] ]]; then\n      error \"unknown autoinstall os-variant for ${DISTRO}-${FLAVOR}\"\n    fi\n    echo \"${map[${DISTRO}-${FLAVOR}]}\"\n\n    echo \"--location\"\n    declare -A map=(\n      [\"debian12-desktop\"]=\"https://deb.debian.org/debian/dists/bookworm/main/installer-amd64/\"\n      [\"debian13-desktop\"]=\"https://deb.debian.org/debian/dists/trixie/main/installer-amd64/\"\n      [\"ubuntu24.04-desktop\"]=\"./linux/vm/ubuntu24.04-desktop.iso,kernel=casper/vmlinuz,initrd=casper/initrd\"\n      [\"fedora43-desktop\"]=\"https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/os/\"\n      [\"archlinux-desktop\"]=\"https://mirrors.edge.kernel.org/archlinux/iso/latest/,kernel=arch/boot/x86_64/vmlinuz-linux,initrd=arch/boot/x86_64/initramfs-linux.img\"\n    )\n    if [[ ! -v map[${DISTRO}-${FLAVOR}] ]]; then\n      error \"unknown autoinstall location for ${DISTRO}-${FLAVOR}\"\n    fi\n    echo \"${map[${DISTRO}-${FLAVOR}]}\"\n\n    declare -A map=(\n      [\"ubuntu24.04-desktop\"]=\"x\"\n      [\"archlinux-desktop\"]=\"x\"\n    )\n    if [[ -v map[${DISTRO}-${FLAVOR}] ]]; then\n      echo \"--disk\"\n      echo \"./linux/vm/${DISTRO}-${FLAVOR}.seed.iso\"\n    fi\n\n    echo \"--extra-args\"\n    declare -A map=(\n      [\"debian12-desktop\"]=\"auto=true priority=critical file=/debian-desktop.preseed.cfg console=ttyS0\"\n      [\"debian13-desktop\"]=\"auto=true priority=critical file=/debian-desktop.preseed.cfg console=ttyS0\"\n      [\"ubuntu24.04-desktop\"]=\"autoinstall console=ttyS0\"\n      [\"fedora43-desktop\"]=\"inst.ks=file:/fedora43-desktop.ks console=tty0 console=ttyS0\"\n      [\"archlinux-desktop\"]=\"ip=dhcp archisobasedir=arch archiso_http_srv=https://mirrors.edge.kernel.org/archlinux/iso/latest/ console=ttyS0\"\n    )\n    if [[ ! -v map[${DISTRO}-${FLAVOR}] ]]; then\n        error \"unknown autoinstall extra-args for ${DISTRO}-${FLAVOR}\"\n    fi\n    echo \"${map[${DISTRO}-${FLAVOR}]}\"\n\n    declare -A map=(\n      [\"debian12-desktop\"]=\"./linux/vm/debian12-desktop.preseed.cfg\"\n      [\"debian13-desktop\"]=\"./linux/vm/debian13-desktop.preseed.cfg\"\n      [\"fedora43-desktop\"]=\"./linux/vm/fedora43-desktop.ks\"\n    )\n    if [[ -v map[${DISTRO}-${FLAVOR}] ]]; then\n      echo \"--initrd-inject\"\n      echo \"${map[${DISTRO}-${FLAVOR}]}\"\n    fi\n}\n\nfunction ssh_run() {\n  sxx_run ssh -p 2222 user@localhost \"$@\"\n}\n\nfunction scp_run() {\n  check_args $# 2\n  local SRC=$1\n  local DEST=$2\n  sxx_run scp -P 2222 \"${SRC}\" \"user@localhost:${DEST}\"\n}\n\nfunction sxx_run() {\n  local CMD=$1\n  shift\n  sshpass -p pw \"${CMD}\" -o ConnectTimeout=1 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \"$@\"\n}\n\nfunction start_vm() {\n  check_args $# 2\n  local DISTRO=$1\n  local FLAVOR=$2\n\n  qemu-system-x86_64 \\\n    -enable-kvm \\\n    -m 4G \\\n    -smp $(($(nproc) - 1)) \\\n    -drive file=\"$(disk_image_path \"${DISTRO}\" \"${FLAVOR}\"),format=qcow2,if=virtio,snapshot=on\" \\\n    -netdev user,id=n1,hostfwd=tcp::2222-:22 \\\n    -device virtio-net,netdev=n1 &\n\n  echo \"### Started ${DISTRO}-${FLAVOR}, waiting for SSH login\"\n  until ssh_run exit; do\n    sleep 1\n  done\n  echo \"### SSH login on ${DISTRO}-${FLAVOR} successful\"\n}\n\nfunction install_package() {\n  check_args $# 2\n  local DISTRO=$1\n  local FLAVOR=$2\n\n  if [[ ${DISTRO} == debian* ]] || [[ ${DISTRO} == ubuntu* ]]; then\n    scp_run ./obscura_0.0.1_amd64.deb /home/user/obscura.deb\n    ssh_run sudo dpkg -i /home/user/obscura.deb\n  elif [[ ${DISTRO} == fedora* ]] || [[ ${DISTRO} == alma* ]]; then\n    scp_run ./obscura-0.0.1-1.x86_64.rpm /home/user/obscura.rpm\n    ssh_run sudo dnf install -y /home/user/obscura.rpm\n  elif [[ ${DISTRO} == archlinux* ]]; then\n    scp_run ./obscura-0.0.1-1-x86_64.pkg.tar.zst /home/user/obscura.zst\n    ssh_run sudo pacman --noconfirm -U /home/user/obscura.zst\n    ssh_run sudo systemctl enable --now obscura\n  else\n    error \"no package install instructions for this ${DISTRO}\"\n  fi\n  sleep 1\n}\n\nfunction setup_and_connect() {\n  check_args $# 1\n  local ACCOUNT_ID=$1\n  ssh_run obscura add-operator '&&' RUST_LOG=debug obscura ipc-test\n  ssh_run obscura login \"${ACCOUNT_ID}\"\n  ssh_run obscura start\n}\n\n# shellcheck disable=SC2120\nfunction check_if_mullvad() {\n  check_args $# 0\n  local MULLVAD_CHECK_OUTPUT\n  for IP_VERSION in 4 6; do\n    MULLVAD_CHECK_OUTPUT=\"$(ssh_run curl -sS https://ipv${IP_VERSION}.am.i.mullvad.net/json)\"\n    if [[ \"${MULLVAD_CHECK_OUTPUT}\" == *'\"mullvad_exit_ip\":true'* ]]; then\n      echo \"Mullvad IPv${IP_VERSION} check passed\"\n    else\n      error \"Mullvad IPv${IP_VERSION} check failed: ${MULLVAD_CHECK_OUTPUT}\"\n    fi\n  done\n}\n\n# MAIN\nif [ $# -ne 2 ]; then\n  error \"usage: $0 <account_id> <distro>\"\nfi\nACCOUNT_ID=$1\nDISTRO=$2\nFLAVOR=\"desktop\"\n\nif [ ! -f \"$(disk_image_path \"${DISTRO}\" \"${FLAVOR}\")\" ]; then\n  reset \"${DISTRO}\" \"${FLAVOR}\"\nfi\n\nstart_vm \"${DISTRO}\" \"${FLAVOR}\"\n\ninstall_package \"${DISTRO}\" \"${FLAVOR}\"\n\nsetup_and_connect \"${ACCOUNT_ID}\"\ncheck_if_mullvad\n\nsleep 100000000\n"
  },
  {
    "path": "contrib/bin/linux_run_service.sh",
    "content": "#!/usr/bin/env bash\nset -eux\n\n(cd rustlib && cargo build)\nsudo --preserve-env=RUST_LOG sg obscura \"umask 002 && ./rustlib/target/debug/obscura service\"\n"
  },
  {
    "path": "contrib/bin/ls-non-ignored-files.bash",
    "content": "#!/usr/bin/env bash\nset -eo pipefail\n\nexec -- \\\n\tgit ls-files \\\n\t\t--exclude-standard \\\n\t\t--others \\\n\t\t--cached \\\n\t\t\"$@\"\n"
  },
  {
    "path": "contrib/bin/nixfmt-auto-files.bash",
    "content": "#!/usr/bin/env bash\nset -eo pipefail\n\n# NOTE: we can't use `nix fmt` because it doesn't have `--check` mode\n./contrib/bin/find-nix-files.bash -z \\\n\t| exec xargs --null -- \\\n\t\tnixfmt --width=120 \"$@\" --\n"
  },
  {
    "path": "contrib/bin/package-arch.bash",
    "content": "#!/usr/bin/env bash\nset -eu\n\nnix build .#rust-static\n\ndocker build -f linux/arch_Dockerfile -t obscura-arch .\ndocker run --rm --security-opt label=disable -v \"$PWD:/wd\" -v \"$(realpath result/bin/obscura):/obscura\" obscura-arch sh -c '\n    set -eu\n    mkdir -p ~/build/src && cd ~/build\n    cp /wd/linux/arch_PKGBUILD PKGBUILD\n    cp /obscura src/obscura\n    cp /wd/linux/obscura.service src/\n    cp /wd/linux/obscura-sysusers.conf src/\n    cp /wd/LICENSE.md src/\n    makepkg -f\n    namcap -e elfnoshstk *.pkg.tar.zst\n    sudo cp *.pkg.tar.zst /wd/\n'\n"
  },
  {
    "path": "contrib/bin/package-deb.bash",
    "content": "#!/usr/bin/env bash\nset -eu\n\nnix build .#rust-static\n\ndocker build -f linux/deb_Dockerfile -t obscura-deb .\ndocker run --rm --security-opt label=disable -v \"$PWD:/wd\" -v \"$(realpath result/bin/obscura):/obscura\" obscura-deb sh -c '\n    set -eu\n    mkdir -p /build/obscura/debian\n    cd /build/obscura\n    cp /wd/linux/deb_control debian/control\n    cp /wd/linux/deb_rules debian/rules\n    cp /wd/linux/deb_install debian/obscura.install\n    echo \"obscura (0.0.1) unstable; urgency=low\" > debian/changelog\n    echo \"\" >> debian/changelog\n    echo \"  * Release\" >> debian/changelog\n    echo \"\" >> debian/changelog\n    echo \" -- obscura authors <support@obscura.net>  Thu, 01 Jan 1970 00:00:00 +0000\" >> debian/changelog\n    cp /wd/linux/obscura.service debian/obscura.service\n    cp /wd/linux/obscura-sysusers.conf debian/obscura.sysusers\n    install -m755 /obscura obscura\n    chmod +x debian/rules\n    dpkg-buildpackage -us -uc -b\n    lintian --allow-root --suppress-tags no-copyright-file,no-manual-page,shared-library-lacks-prerequisites,description-starts-with-package-name /build/*.deb\n    cp /build/*.deb /wd/\n'\n"
  },
  {
    "path": "contrib/bin/package-rpm.bash",
    "content": "#!/usr/bin/env bash\nset -eu\n\nnix build .#rust-static\n\ndocker build -f linux/rpm_Dockerfile -t obscura-rpm .\ndocker run --rm --security-opt label=disable -v \"$PWD:/wd\" -v \"$(realpath result/bin/obscura):/obscura\" obscura-rpm sh -c '\n    set -eu\n    mkdir -p ~/rpmbuild/{SOURCES,SPECS,RPMS}\n    cp /wd/linux/rpm_obscura.spec ~/rpmbuild/SPECS/obscura.spec\n    cp /obscura ~/rpmbuild/SOURCES/\n    cp /wd/linux/obscura.service ~/rpmbuild/SOURCES/\n    cp /wd/linux/obscura-sysusers.conf ~/rpmbuild/SOURCES/\n    cp /wd/linux/obscura-preset.conf ~/rpmbuild/SOURCES/\n    rpmbuild -bb ~/rpmbuild/SPECS/obscura.spec\n    rpmlint -r /wd/linux/rpm_rpmlintrc ~/rpmbuild/RPMS/*/*.rpm\n    cp ~/rpmbuild/RPMS/*/*.rpm /wd/\n'\n"
  },
  {
    "path": "contrib/bin/shellcheck-auto-files.bash",
    "content": "#!/usr/bin/env bash\nset -eo pipefail\n\n./contrib/bin/find-shellcheck-files.bash -z | exec xargs --null -- shellcheck --\n"
  },
  {
    "path": "contrib/licenses.mjs",
    "content": "import {readFileSync} from \"node:fs\";\n\nfunction rpartition(s, p) {\n    let i = s.lastIndexOf(p);\n    if (i < 0) {\n        throw new Error(`No ${JSON.stringify(p)} in ${JSON.stringify(s)}`)\n    }\n    return [\n        s.slice(0, i),\n        s.slice(i + 1),\n    ];\n}\n\nconst LICENSES_NODE = JSON.parse(readFileSync(process.env.LICENSES_NODE));\nconst LICENSES_RUST = JSON.parse(readFileSync(process.env.LICENSES_RUST));\n\nlet overview = new Map;\nlet licenses = new Map;\n\nlet out = {\n    overview: [],\n    licenses: [],\n};\n\nfunction addLicense(info) {\n    let o = overview.get(info.id);\n    if (!o) {\n        o = {\n            id: info.id,\n            name: info.name,\n            count: 0,\n        };\n        overview.set(info.id, o);\n        out.overview.push(o);\n    }\n    o.count += 1;\n\n    let l = licenses.get(info.text);\n    if (!l) {\n        l = {\n            id: info.id,\n            name: o.name,\n            text: info.text,\n            used_by: [],\n        };\n        licenses.set(info.text, l);\n        out.licenses.push(l);\n    }\n    l.used_by.push(info.package);\n}\n\n// https://github.com/sparkle-project/Sparkle/blob/2c95fa406a92b683ed649cde2975034f2f774289/LICENSE\naddLicense({\n    id: \"MIT\",\n    name: \"MIT License\",\n    text: `Copyright (c) 2006-2013 Andy Matuschak.\nCopyright (c) 2009-2013 Elgato Systems GmbH.\nCopyright (c) 2011-2014 Kornel Lesiński.\nCopyright (c) 2015-2017 Mayur Pawashe.\nCopyright (c) 2014 C.W. Betts.\nCopyright (c) 2014 Petroules Corporation.\nCopyright (c) 2014 Big Nerd Ranch.\nAll rights reserved.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n=================\nEXTERNAL LICENSES\n=================\n\nbspatch.c and bsdiff.c, from bsdiff 4.3 <http://www.daemonology.net/bsdiff/>:\n\nCopyright 2003-2005 Colin Percival\nAll rights reserved\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted providing that the following conditions \nare met:\n1. Redistributions of source code must retain the above copyright\n   notice, this list of conditions and the following disclaimer.\n2. Redistributions in binary form must reproduce the above copyright\n   notice, this list of conditions and the following disclaimer in the\n   documentation and/or other materials provided with the distribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE AUTHOR ${\"``\"}AS IS'' AND ANY EXPRESS OR\nIMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\nWARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\nARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY\nDIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS\nOR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)\nHOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,\nSTRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING\nIN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\nPOSSIBILITY OF SUCH DAMAGE.\n\n--\n\nsais.c and sais.h, from sais-lite (2010/08/07) <https://sites.google.com/site/yuta256/sais>:\n\nThe sais-lite copyright is as follows:\n\nCopyright (c) 2008-2010 Yuta Mori All Rights Reserved.\n\nPermission is hereby granted, free of charge, to any person\nobtaining a copy of this software and associated documentation\nfiles (the \"Software\"), to deal in the Software without\nrestriction, including without limitation the rights to use,\ncopy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\nOF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\nHOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nWHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE.\n\n--\n\nPortable C implementation of Ed25519, from https://github.com/orlp/ed25519\n\nCopyright (c) 2015 Orson Peters <orsonpeters@gmail.com>\n\nThis software is provided 'as-is', without any express or implied warranty. In no event will the\nauthors be held liable for any damages arising from the use of this software.\n\nPermission is granted to anyone to use this software for any purpose, including commercial\napplications, and to alter it and redistribute it freely, subject to the following restrictions:\n\n1. The origin of this software must not be misrepresented; you must not claim that you wrote the\n   original software. If you use this software in a product, an acknowledgment in the product\n   documentation would be appreciated but is not required.\n\n2. Altered source versions must be plainly marked as such, and must not be misrepresented as\n   being the original software.\n\n3. This notice may not be removed or altered from any source distribution.\n\n--\n\nSUSignatureVerifier.m:\n\nCopyright (c) 2011 Mark Hamlin.\n\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted providing that the following conditions\nare met:\n1. Redistributions of source code must retain the above copyright\n   notice, this list of conditions and the following disclaimer.\n2. Redistributions in binary form must reproduce the above copyright\n   notice, this list of conditions and the following disclaimer in the\n   documentation and/or other materials provided with the distribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE AUTHOR ${\"``\"}AS IS'' AND ANY EXPRESS OR\nIMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\nWARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\nARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY\nDIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS\nOR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)\nHOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,\nSTRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING\nIN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\nPOSSIBILITY OF SUCH DAMAGE.`,\n    package: {\n        name: \"Sparkle\",\n        url: \"https://sparkle-project.org/\",\n        version: \"2.6.4\",\n    },\n});\n\n// ../apple/third-party/CwlSysctl.swift\naddLicense({\n    id: \"ISC\",\n    name: \"ISC License\",\n    text: `Created by Matt Gallagher on 2016/02/03.\nCopyright © 2016 Matt Gallagher ( https://www.cocoawithlove.com ). All rights reserved.\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY\nSPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR\nIN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.`,\n    package: {\n        name: \"CwlUtils\",\n        url: \"https://github.com/mattgallagher/CwlUtils\",\n        version: \"d58bb51c9370b0b73adaead17f29f21a863cc126\",\n    },\n});\n\n// Note: Do Rust licenses first as they have nice names.\nfor (let license of LICENSES_RUST.licenses) {\n    for (let {crate} of license.used_by) {\n        addLicense({\n            id: license.id,\n            name: license.name,\n            text: license.text,\n            package: {\n                name: crate.name,\n                url: crate.repository ?? `https://crates.io/crates/${crate.name}`,\n                version: crate.version,\n            },\n        });\n    }\n}\n\nfor (let [pkg, info] of Object.entries(LICENSES_NODE)) {\n    let id = /[a-zA-Z0-9-]+/.exec(info.licenses)[0];\n    let [name, version] = rpartition(pkg, \"@\");\n    addLicense({\n        id,\n        name: id,\n        text: info.licenseFile ? readFileSync(info.licenseFile).toString() : info.id,\n        package: {\n            name,\n            url: info.repository ?? `https://www.npmjs.com/package/${name}`,\n            version,\n        },\n    });\n}\n\nfunction cmp(l, r) {\n    if (l < r) return -1;\n    if (l > r) return 1;\n    return 0;\n}\nout.overview.sort((l, r) => {\n    return cmp(l.id, r.id)\n        || cmp(l.name, r.name)\n        || cmp(l.count, r.count);\n})\nout.licenses.sort((l, r) => {\n    return cmp(l.id, r.id)\n        || cmp(l.name, r.name)\n        || cmp(l.text, r.text);\n})\n\nconsole.log(JSON.stringify(out, null, \"\\t\"));\n"
  },
  {
    "path": "contrib/shell/source-die.bash",
    "content": "# shellcheck shell=bash\n\nsource contrib/shell/source-echoerr.bash\n\ndie() {\n\techoerr \"$@\"\n\texit 1\n}\n"
  },
  {
    "path": "contrib/shell/source-echoerr.bash",
    "content": "# shellcheck shell=bash\n\nechoerr() { echo \"$@\" 1>&2; }\n"
  },
  {
    "path": "contrib/shell/source-nix.sh",
    "content": "# Use POSIX shell dialect because we can\n# shellcheck shell=sh\n\n# shellcheck source=/dev/null\n. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh\n# shellcheck source=/dev/null\n. /nix/var/nix/profiles/default/etc/profile.d/nix.sh\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  inputs = {\n    crane.url = \"github:ipetkov/crane\";\n    flake-utils.url = \"github:numtide/flake-utils\";\n    nixpkgs.url = \"nixpkgs/nixos-unstable\";\n    rust-overlay.url = \"github:oxalica/rust-overlay\";\n  };\n\n  outputs = { crane, flake-utils, nixpkgs, rust-overlay, self }:\n    flake-utils.lib.eachDefaultSystem (system:\n      let\n        overlays = [ (import rust-overlay) ];\n        pkgs = import nixpkgs {\n          inherit overlays system;\n\n          config = {\n            allowUnfree = true; # sadly, for Android\n            android_sdk.accept_license = true;\n          };\n        };\n\n        lib = pkgs.lib;\n\n        evaluatedSource = lib.fileset.toSource {\n          root = ./.;\n          fileset = lib.fileset.difference ./. ./tag.json;\n        };\n\n        # Extract the hash from the store path.\n        sourceHash = builtins.substring 0 32 (baseNameOf evaluatedSource);\n\n        tag = builtins.fromJSON (builtins.readFile ./tag.json);\n        commitShort = self.shortRev or self.dirtyShortRev;\n\n        # Note: We need to check `self.rev` to ensure that a modification of `tag.json` doesn't get marked as clean. Otherwise only the hash matters.\n        isCleanBuild = self ? rev && tag.sourceHash == sourceHash;\n        version = if isCleanBuild then \"v${tag.version}\" else \"v${tag.version}.1-${commitShort}\";\n\n        hash = pkgs.writeText \"obscura-source-hash.txt\" sourceHash;\n\n        androidBuildToolsVersion = \"36.0.0\";\n        androidCmakeVersion = \"3.31.6\";\n        android = pkgs.androidenv.composeAndroidPackages {\n          toolsVersion = \"26.1.1\"; # frozen legacy version\n          platformToolsVersion = \"36.0.0\";\n\n          platformVersions = [ \"36\" ];\n          buildToolsVersions = [ androidBuildToolsVersion ];\n\n          includeEmulator = false;\n          includeSources = false;\n\n          cmakeVersions = [ androidCmakeVersion ];\n\n          includeNDK = true;\n          ndkVersion = \"26.3.11579264\";\n\n          useGoogleAPIs = true;\n          useGoogleTVAddOns = false;\n\n          includeExtras = [ \"extras;google;google_play_services\" ];\n        };\n        androidBuildTools = \"${android.androidsdk}/libexec/android-sdk/build-tools/${androidBuildToolsVersion}\";\n        androidGradleEnv = {\n          ANDROID_HOME = \"${android.androidsdk}/libexec/android-sdk\";\n          OBSCURA_VERSION = version;\n        };\n        androidRustEnv = { ANDROID_NDK_ROOT = \"${android.ndk-bundle}/libexec/android-sdk/ndk-bundle\"; };\n\n        gradleOpts = [ \"-Dorg.gradle.project.android.aapt2FromMavenOverride=${androidBuildTools}/aapt2\" ];\n        gradleFlags = gradleOpts ++ [\n          # Prevents dependency on group-index and SNAPSHOT files: https://github.com/NixOS/nixpkgs/issues/501643\n          \"-xlint\"\n        ];\n\n        rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rustlib/rust-toolchain.toml;\n        craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;\n\n        rustDepsArgs = {\n          src = ./rustlib;\n\n          strictDeps = true;\n          nativeBuildInputs = [ pkgs.cmake ];\n        };\n        rustDepsArgs-android = rustDepsArgs // androidRustEnv // {\n          buildInputs = [ android.androidsdk ];\n          nativeBuildInputs = rustDepsArgs.nativeBuildInputs ++ [ pkgs.cargo-ndk ];\n          CARGO_BUILD_TARGET = \"aarch64-linux-android\";\n          doCheck = false;\n\n          # TODO: Long-term it is probably better to just configure the environment ourselves using nixpkgs's standard cross-compilation framework. Right now this is a weird state where we are \"secretly\" cross-compiling.\n          cargoBuildCommand = \"cargo ndk -t arm64-v8a build --release\";\n          cargoCheckCommand = \"cargo ndk -t arm64-v8a check --release\";\n        };\n        rustDepsArgs-musl = rustDepsArgs // {\n          nativeBuildInputs = rustDepsArgs.nativeBuildInputs ++ [ pkgs.pkgsCross.musl64.stdenv.cc ];\n          CARGO_BUILD_TARGET = \"x86_64-unknown-linux-musl\";\n          CC_x86_64_unknown_linux_musl =\n            \"${pkgs.pkgsCross.musl64.stdenv.cc}/bin/${pkgs.pkgsCross.musl64.stdenv.cc.targetPrefix}cc\";\n        };\n\n        rustArgs = rustDepsArgs // { cargoArtifacts = craneLib.buildDepsOnly rustDepsArgs; };\n        rustArgs-android = rustDepsArgs-android // { cargoArtifacts = craneLib.buildDepsOnly rustDepsArgs-android; };\n        rustArgs-musl = rustDepsArgs-musl // { cargoArtifacts = craneLib.buildDepsOnly rustDepsArgs-musl; };\n\n        rustLibArgs = {\n          # Environment variables for cbindgen, see rustlib/build.rs\n          outputs = [ \"out\" \"dev\" ]; # Assumes that crane's derivation only has \"out\"\n          OBSCURA_CLIENT_RUSTLIB_CBINDGEN_CONFIG_PATH = ./apple/cbindgen-apple.toml;\n          OBSCURA_CLIENT_RUSTLIB_CBINDGEN_OUTPUT_HEADER_PATH = \"${placeholder \"dev\"}/include/libobscuravpn_client.h\";\n          OBSCURA_VERSION = version;\n        };\n\n        rust = craneLib.buildPackage (rustArgs // rustLibArgs);\n        rust-android = craneLib.buildPackage (rustArgs-android // rustLibArgs);\n        rust-static = craneLib.buildPackage rustArgs-musl;\n\n        nodeModules = pkgs.importNpmLock.buildNodeModules {\n          npmRoot = ./obscura-ui;\n          nodejs = pkgs.nodejs;\n        };\n\n        nodeDerivation = { name, nativeBuildInputs ? [ ], preBuildPhases ? [ ], ... }@args:\n          pkgs.stdenv.mkDerivation (args // {\n            name = \"obscuravpn-client-${name}\";\n\n            nativeBuildInputs = nativeBuildInputs ++ [ pkgs.nodejs ];\n\n            preBuildPhases = [ \"preBuildNodeDerivation\" ] ++ preBuildPhases;\n            preBuildNodeDerivation = ''\n              ln -s ${nodeModules}/node_modules .\n              export PATH=\"${nodeModules}/node_modules/.bin/:$PATH\"\n            '';\n          });\n\n        licenses = pkgs.runCommand \"licenses.json\" {\n          nativeBuildInputs = [ pkgs.nodejs ];\n\n          LICENSES_NODE = licenses-node;\n          LICENSES_RUST = licenses-rust;\n        } ''\n          node ${contrib/licenses.mjs} >\"$out\"\n        '';\n\n        licenses-node = nodeDerivation {\n          name = \"licenses-node.json\";\n\n          nativeBuildInputs = [ pkgs.pnpm ];\n\n          src = lib.fileset.toSource {\n            root = ./obscura-ui;\n            fileset = lib.fileset.unions [ ./obscura-ui/package.json ./obscura-ui/package-lock.json ];\n          };\n\n          buildPhase = ''\n            license-checker \\\n              --start ${nodeModules} \\\n              --onlyAllow '0BSD;Apache-2.0;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;OFL-1.1;Python-2.0' \\\n              --excludePrivatePackages \\\n              --unknown \\\n              --json \\\n              >\"$out\"\n          '';\n        };\n\n        licenses-rust = craneLib.mkCargoDerivation (rustArgs // {\n          name = \"licenses-rust.json\";\n          nativeBuildInputs = [ pkgs.cargo-about ];\n          src = lib.fileset.toSource {\n            root = ./rustlib;\n            fileset = lib.fileset.unions [ rustlib/about.toml rustlib/Cargo.lock rustlib/Cargo.toml ];\n          };\n          buildPhaseCargoCommand = ''\n            mkdir -p src/bin/obscura\n            touch src/bin/obscura/main.rs src/lib.rs\n            cargo-about generate --format=json --fail >\"$out\"\n          '';\n          installPhase = \" \";\n        });\n\n        mkWeb = platform:\n          nodeDerivation {\n            name = \"web-${platform}\";\n\n            src = lib.fileset.toSource {\n              root = ./.;\n              fileset = lib.fileset.unions [ ./apple/client/Assets.xcassets ./obscura-ui ];\n            };\n\n            LICENSE_JSON = licenses;\n            OBS_WEB_PLATFORM = platform;\n\n            buildPhase = ''\n              pushd obscura-ui\n\n              npm run build\n\n              popd\n            '';\n\n            installPhase = ''\n              mv obscura-ui/build $out\n            '';\n          };\n\n        web-android = mkWeb \"android\";\n        web-ios = mkWeb \"iphoneos\";\n        web-macos = mkWeb \"macosx\";\n\n        # https://nixos.org/manual/nixpkgs/stable/#gradle\n        gradleDerivation = { name, task, appOutputs }@args:\n          pkgs.stdenv.mkDerivation (finalAttrs:\n            androidGradleEnv // {\n              name = \"obscura-${name}\";\n\n              src = (lib.fileset.toSource {\n                root = ./android;\n                fileset = lib.fileset.unions [\n                  android/app/build.gradle.kts\n                  android/app/google-services.json\n                  android/app/proguard-rules.pro\n                  android/app/src\n                  android/build.gradle.kts\n                  android/buildSrc/build.gradle.kts\n                  android/buildSrc/settings.gradle.kts\n                  android/buildSrc/src\n                  android/detekt.yml\n                  android/gradle.properties\n                  android/gradle/libs.versions.toml\n                  android/lib/billing/build.gradle.kts\n                  android/lib/billing/src\n                  android/lib/util/build.gradle.kts\n                  android/lib/util/src\n                  android/settings.gradle.kts\n                ];\n              });\n\n              nativeBuildInputs = [ pkgs.gradle ];\n\n              mitmCache = pkgs.gradle.fetchDeps {\n                pkg = finalAttrs.finalPackage;\n                data = android/gradle/mitm-cache/deps.json;\n              };\n\n              # Accounts for check-only dependencies + tools needed for building an APK/AAB\n              gradleUpdateTask = \"check extractReleaseAnnotations\";\n              # This is more robust than `nixDownloadDeps`, and will become the default once a Gradle bug is fixed that's only known to impact one project.\n              # https://github.com/NixOS/nixpkgs/issues/365086\n              # https://github.com/NixOS/nixpkgs/pull/383115\n              gradleUpdateScript = ''\n                runHook preBuild\n                gradle ${finalAttrs.gradleUpdateTask} --write-verification-metadata sha256\n              '';\n\n              ANDROID_USER_HOME = \"/tmp/\";\n              gradleBuildTask = task;\n              gradleFlags = gradleFlags;\n\n              patchPhase = ''\n                # TODO: Find a cleaner way to pass these inputs that works during dev as well.\n                ln -sfv ${rust-android}/lib/libobscuravpn_client.so app/src/main/jniLibs/arm64-v8a/\n                ln -sfv ${web-android} app/src/main/assets/web\n              '';\n\n              APP_OUTPUTS = toString (map lib.strings.escapeShellArg appOutputs);\n              installPhase = ''\n                mkdir $out\n                for output in $APP_OUTPUTS; do\n                  cp -v app/build/outputs/$output $out/\n                done\n              '';\n\n              doCheck = true;\n              # Checking a specific flavor is impossible:\n              # https://issuetracker.google.com/issues/63810920\n              gradleCheckTask = \"check\";\n            });\n\n        apks-foss = gradleDerivation {\n          name = \"apks-foss\";\n          task = \"assembleFoss\";\n          appOutputs = [ \"apk/foss/debug/app-foss-debug.apk\" \"apk/foss/release/app-foss-release-unsigned.apk\" ];\n        };\n        apks-play = gradleDerivation {\n          name = \"apks-play\";\n          task = \"assemblePlay\";\n          appOutputs = [ \"apk/play/debug/app-play-debug.apk\" \"apk/play/release/app-play-release-unsigned.apk\" ];\n        };\n        aab-play-debug = gradleDerivation {\n          name = \"aab-play-debug\";\n          task = \"bundlePlayDebug\";\n          appOutputs = [ \"bundle/playDebug/app-play-debug.aab\" ];\n        };\n        aab-play-release = gradleDerivation {\n          name = \"aab-play-release\";\n          task = \"bundlePlayRelease\";\n          appOutputs = [ \"bundle/playRelease/app-play-release.aab\" ];\n        };\n\n        nixFiles = lib.sources.sourceFilesBySuffices evaluatedSource [ \".nix\" ];\n        shellFiles = lib.sources.sourceFilesBySuffices evaluatedSource [ \".bash\" \".sh\" \".shellcheckrc\" ];\n\n        swiftFiles = lib.sources.sourceFilesBySuffices (lib.fileset.toSource {\n          root = ./.;\n          fileset = lib.fileset.unions [ ./.swiftformat apple/client ];\n        }) [ \".swift\" \".swiftformat\" ];\n      in {\n        apps = {\n          gradle-deps-update = {\n            type = \"app\";\n            program = toString apks-foss.mitmCache.updateScript;\n          };\n        };\n\n        checks = {\n          inherit apks-foss aab-play-release hash licenses rust rust-android web-android web-ios web-macos;\n          taplo = pkgs.runCommand \"taplo-check\" {\n            nativeBuildInputs = [ pkgs.taplo ];\n            src = lib.sources.cleanSourceWith {\n              src = self;\n              filter = path: type: type == \"directory\" || lib.hasSuffix \".toml\" path;\n            };\n          } ''\n            cd $src\n            taplo format --check\n            touch $out\n          '';\n        } // lib.optionalAttrs pkgs.stdenv.isLinux { inherit rust-static; } // {\n          clippy =\n            craneLib.cargoClippy (rustArgs // { cargoClippyExtraArgs = \"--all-features --all-targets -- -Dwarnings\"; });\n\n          shellcheck = pkgs.runCommand \"shellcheck\" { nativeBuildInputs = [ pkgs.shellcheck ]; } ''\n            shopt -s globstar\n            shellcheck -P ${shellFiles} -- ${shellFiles}/**/*.{bash,sh}\n            touch \"$out\"\n          '';\n\n          rustfmt = craneLib.cargoFmt rustArgs;\n\n          swiftformat = pkgs.runCommand \"swiftformat\" { nativeBuildInputs = [ pkgs.swiftformat ]; } ''\n            swiftformat --lint ${swiftFiles}\n            touch \"$out\"\n          '';\n\n          typescript = nodeDerivation {\n            name = \"typescript\";\n\n            src = ./obscura-ui;\n\n            buildPhase = ''\n              tsc --noEmit\n              touch \"$out\"\n            '';\n          };\n\n          nixfmt = pkgs.runCommand \"nixfmt\" { nativeBuildInputs = [ pkgs.nixfmt-classic ]; } ''\n            nixfmt --width=120 --check ${nixFiles}\n            touch \"$out\"\n          '';\n        };\n\n        devShells = {\n          default = pkgs.mkShellNoCC {\n            packages = [\n              pkgs.corepack_20\n              pkgs.gnused\n              pkgs.gradle\n              pkgs.just\n              pkgs.nixfmt-classic\n              pkgs.nodejs_20\n              pkgs.shellcheck\n              pkgs.swiftformat\n              pkgs.taplo\n              rustToolchain.passthru.availableComponents.rustfmt # Just rustfmt, nothing else\n            ] ++ rustArgs.nativeBuildInputs ++ lib.optionals pkgs.stdenv.isDarwin [ pkgs.create-dmg ];\n\n            shellHook = ''\n              export OBSCURA_MAGIC_IN_NIX_SHELL=1\n            '';\n          };\n\n          web = pkgs.mkShellNoCC {\n            packages = [ pkgs.just pkgs.nodejs_20 pkgs.pnpm ];\n\n            # This only changes when our dependencies or license config changes and is relatively slow.\n            # So build it once and cache it.\n            LICENSE_JSON = licenses;\n          };\n\n          android = pkgs.mkShellNoCC (androidGradleEnv // androidRustEnv // {\n            buildInputs = [ pkgs.libiconv pkgs.taplo ] ++ rustArgs-android.buildInputs;\n            nativeBuildInputs = [\n              android.cmake\n              android.emulator\n              android.platform-tools\n              rustToolchain\n              pkgs.firebase-tools\n              pkgs.gradle\n              pkgs.jdk21\n              pkgs.just\n              pkgs.ninja\n              pkgs.nodejs_20\n              pkgs.pkg-config\n              pkgs.pnpm\n            ] ++ rustArgs-android.nativeBuildInputs;\n\n            GRADLE_OPTS = lib.concatStringsSep \" \" gradleOpts; # Doesn't support spaces.\n            JAVA_HOME = pkgs.jdk21.home;\n\n            shellHook = ''\n              export PATH=\"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:${androidBuildTools}:$PATH\"\n            '';\n          });\n        };\n\n        packages = {\n          inherit apks-foss apks-play aab-play-debug aab-play-release hash licenses licenses-node licenses-rust rust\n            web-android web-ios web-macos;\n        } // lib.optionalAttrs pkgs.stdenv.isLinux { inherit rust-static; };\n      });\n}\n"
  },
  {
    "path": "justfile",
    "content": "# NOTE: Must be first recipe to be default\n@_default:\n\tjust --list\n\n@_check-in-obscura-nix-shell:\n\t./contrib/bin/check-in-obscura-nix-shell.bash\n\n# fix formatting\nformat-fix: _check-in-obscura-nix-shell\n\tcd android && gradle ktfmtFormat\n\tswiftformat .\n\tcd rustlib && cargo --offline fmt\n\t./contrib/bin/nixfmt-auto-files.bash\n\ttaplo format\n\n# lint checks\nlint: _check-in-obscura-nix-shell\n\t./contrib/bin/shellcheck-auto-files.bash\n\nweb-bundle-dir := \"./obscura-ui/\"\n\nweb-bundle-build:\n\tjust \"{{web-bundle-dir}}\"/build\n\nweb-bundle-start:\n\tjust \"{{web-bundle-dir}}\"/start\n\nxcode-open:\n\topen -a /Applications/Xcode.app apple/client.xcodeproj\n\n# build notarized .dmg in current directory from APP\nbuild-dmg: _check-in-obscura-nix-shell\n\t./contrib/bin/build-obscuravpn-dmg.bash\n"
  },
  {
    "path": "linux/arch_Dockerfile",
    "content": "FROM archlinux:latest\nRUN pacman -Sy --noconfirm base-devel namcap sudo && \\\n    useradd -m builder && \\\n    echo 'builder ALL=(ALL) NOPASSWD: /usr/bin/cp' >> /etc/sudoers\nUSER builder\n"
  },
  {
    "path": "linux/arch_PKGBUILD",
    "content": "pkgname=obscura\npkgver=0.0.1\npkgrel=1\npkgdesc='Privacy that'\\''s more than a promise'\narch=('x86_64')\nlicense=('LicenseRef-PolyForm-Noncommercial-1.0.0')\noptions=('!debug')\n\npackage() {\n    install -Dm755 \"$srcdir/obscura\" \"$pkgdir/usr/bin/obscura\"\n    install -Dm644 \"$srcdir/obscura.service\" \"$pkgdir/usr/lib/systemd/system/obscura.service\"\n    install -Dm644 \"$srcdir/obscura-sysusers.conf\" \"$pkgdir/usr/lib/sysusers.d/obscura.conf\"\n    install -Dm644 \"$srcdir/LICENSE.md\" \"$pkgdir/usr/share/licenses/obscura/LICENSE.md\"\n}\n"
  },
  {
    "path": "linux/deb_Dockerfile",
    "content": "FROM debian:trixie-slim\nRUN apt-get update && apt-get install -y --no-install-recommends debhelper build-essential lintian\n"
  },
  {
    "path": "linux/deb_control",
    "content": "Source: obscura\nMaintainer: obscura authors <support@obscura.net>\nBuild-Depends: debhelper-compat (= 13)\nSection: net\n\nPackage: obscura\nArchitecture: amd64\nDepends: systemd\nDescription: Obscura VPN client\n Privacy that's more than a promise.\n"
  },
  {
    "path": "linux/deb_install",
    "content": "obscura usr/bin\n"
  },
  {
    "path": "linux/deb_rules",
    "content": "#!/usr/bin/make -f\n%:\n\tdh $@ --with installsysusers\n"
  },
  {
    "path": "linux/obscura-preset.conf",
    "content": "enable obscura.service\n"
  },
  {
    "path": "linux/obscura-sysusers.conf",
    "content": "g obscura - -\n"
  },
  {
    "path": "linux/obscura.service",
    "content": "[Unit]\nDescription=Obscura VPN\nAfter=network.target\n\n[Service]\nExecStart=/usr/bin/obscura service\nGroup=obscura\nUMask=0007\nRestart=on-failure\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "linux/rpm_Dockerfile",
    "content": "FROM fedora:43\nRUN dnf install -y rpm-build systemd-rpm-macros rpmlint\n"
  },
  {
    "path": "linux/rpm_obscura.spec",
    "content": "Name:           obscura\nVersion:        0.0.1\nRelease:        1\nSummary:        Obscura VPN client\nLicense:        PolyForm-Noncommercial-1.0.0\nURL:            https://obscura.net\n\n%description\nPrivacy that's more than a promise.\n\n%install\ninstall -Dm755 %{_sourcedir}/obscura %{buildroot}%{_bindir}/obscura\ninstall -Dm644 %{_sourcedir}/obscura.service %{buildroot}%{_unitdir}/obscura.service\ninstall -Dm644 %{_sourcedir}/obscura-sysusers.conf %{buildroot}%{_sysusersdir}/obscura.conf\ninstall -Dm644 %{_sourcedir}/obscura-preset.conf %{buildroot}%{_presetdir}/80-obscura.preset\n\n%files\n%{_bindir}/obscura\n%{_unitdir}/obscura.service\n%{_sysusersdir}/obscura.conf\n%{_presetdir}/80-obscura.preset\n\n%pre\n%sysusers_create_package obscura %{_sourcedir}/obscura-sysusers.conf\n\n%post\n%systemd_post obscura.service\nif [ $1 -eq 1 ]; then\n    systemctl start obscura.service\nfi\n\n%preun\n%systemd_preun obscura.service\n\n%postun\n%systemd_postun_with_restart obscura.service\n\n%changelog\n* Thu Jan 01 1970 obscura authors <support@obscura.net> - 0.0.1-1\n- Release\n"
  },
  {
    "path": "linux/rpm_rpmlintrc",
    "content": "addFilter(\"statically-linked-binary\")\naddFilter(\"no-manual-page-for-binary\")\naddFilter(\"no-documentation\")\naddFilter(\"invalid-license\")\naddFilter(\"unstripped-binary-or-object\")\naddFilter(\"name-repeated-in-summary\")\naddFilter(\"no-changelogname-tag\")\n"
  },
  {
    "path": "linux/vm/archlinux-desktop-cloud-init/meta-data",
    "content": ""
  },
  {
    "path": "linux/vm/archlinux-desktop-cloud-init/user-data",
    "content": "#cloud-config\nwrite_files:\n  - path: /config.json\n    owner: root:root\n    permissions: '0644'\n    content: |\n      {\n          \"app_config\": {},\n          \"archinstall-language\": \"English\",\n          \"auth_config\": {},\n          \"bootloader_config\": {\n              \"bootloader\": \"Grub\",\n              \"removable\": false,\n              \"uki\": false\n          },\n          \"custom_commands\": [],\n          \"disk_config\": {\n              \"btrfs_options\": {\n                  \"snapshot_config\": null\n              },\n              \"config_type\": \"default_layout\",\n              \"device_modifications\": [\n                  {\n                      \"device\": \"/dev/vda\",\n                      \"partitions\": [\n                          {\n                              \"btrfs\": [],\n                              \"dev_path\": null,\n                              \"flags\": [\n                                  \"boot\"\n                              ],\n                              \"fs_type\": \"fat32\",\n                              \"mount_options\": [],\n                              \"mountpoint\": \"/boot\",\n                              \"obj_id\": \"8bef294b-98c6-40e1-a5c1-1db9e2bafe0a\",\n                              \"size\": {\n                                  \"sector_size\": {\n                                      \"unit\": \"B\",\n                                      \"value\": 512\n                                  },\n                                  \"unit\": \"GiB\",\n                                  \"value\": 1\n                              },\n                              \"start\": {\n                                  \"sector_size\": {\n                                      \"unit\": \"B\",\n                                      \"value\": 512\n                                  },\n                                  \"unit\": \"MiB\",\n                                  \"value\": 1\n                              },\n                              \"status\": \"create\",\n                              \"type\": \"primary\"\n                          },\n                          {\n                              \"btrfs\": [],\n                              \"dev_path\": null,\n                              \"flags\": [],\n                              \"fs_type\": \"btrfs\",\n                              \"mount_options\": [\n                                  \"compress=zstd\"\n                              ],\n                              \"mountpoint\": \"/\",\n                              \"obj_id\": \"dc3f91a8-c069-47c8-9ea6-542ec8a09eab\",\n                              \"size\": {\n                                  \"sector_size\": {\n                                      \"unit\": \"B\",\n                                      \"value\": 512\n                                  },\n                                  \"unit\": \"B\",\n                                  \"value\": 20400046080\n                              },\n                              \"start\": {\n                                  \"sector_size\": {\n                                      \"unit\": \"B\",\n                                      \"value\": 512\n                                  },\n                                  \"unit\": \"B\",\n                                  \"value\": 1074790400\n                              },\n                              \"status\": \"create\",\n                              \"type\": \"primary\"\n                          }\n                      ],\n                      \"wipe\": true\n                  }\n              ]\n          },\n          \"hostname\": \"unassigned-hostname\",\n          \"kernels\": [\n              \"linux\"\n          ],\n          \"locale_config\": {\n              \"kb_layout\": \"us\",\n              \"sys_enc\": \"UTF-8\",\n              \"sys_lang\": \"en_US.UTF-8\"\n          },\n          \"network_config\": {\n              \"type\": \"nm\"\n          },\n          \"ntp\": true,\n          \"packages\": [\n              \"curl\",\n              \"net-tools\",\n              \"firefox\"\n          ],\n          \"parallel_downloads\": 0,\n          \"profile_config\": {\n              \"gfx_driver\": \"All open-source\",\n              \"greeter\": \"gdm\",\n              \"profile\": {\n                  \"custom_settings\": {\n                      \"GNOME\": {}\n                  },\n                  \"details\": [\n                      \"GNOME\"\n                  ],\n                  \"main\": \"Desktop\"\n              }\n          },\n          \"script\": null,\n          \"services\": [],\n          \"swap\": false,\n          \"timezone\": \"UTC\",\n          \"version\": \"3.0.14\"\n      }\n  - path: /creds.json\n    owner: root:root\n    permissions: '0644'\n    content: |\n      {\n          \"users\": [\n              {\n                  \"enc_password\": \"$y$j9T$LBDvFqutcqkzsap08YCvv/$e4rn0cAHlrz/Bl1IL3ED5t4fmebsi6L68C.9pHo2rQC\",\n                  \"groups\": [],\n                  \"sudo\": true,\n                  \"username\": \"user\"\n              }\n          ]\n      }\n  - path: /etc/systemd/system/auto-install.service\n    owner: root:root\n    permissions: '0644'\n    content: |\n      [Unit]\n      After=network-online.target\n      Wants=network-online.target\n      Conflicts=getty@tty1.service\n      Before=getty@tty1.service\n\n      [Service]\n      Type=oneshot\n      TTYPath=/dev/tty1\n      StandardOutput=tty\n      Environment=TERM=dumb\n      Environment=LANG=C\n      ExecStart=/usr/bin/archinstall --config /config.json --creds /creds.json --silent\n      ExecStartPost=/usr/bin/arch-chroot /mnt /bin/bash -c \"systemctl enable sshd\"\n      ExecStartPost=/usr/bin/arch-chroot /mnt /bin/bash -c \"systemctl enable NetworkManager\"\n      ExecStartPost=/usr/bin/arch-chroot /mnt /bin/bash -c \"echo 'user ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/nopasswd && chmod 0440 /etc/sudoers.d/nopasswd\"\n      ExecStartPost=/usr/bin/poweroff\n\n      [Install]\n      WantedBy=multi-user.target\nruncmd:\n  - systemctl daemon-reload\n  - systemctl enable --now --no-block auto-install.service\n"
  },
  {
    "path": "linux/vm/debian-desktop.preseed.cfg",
    "content": "d-i debian-installer/locale string en_US.UTF-8\nd-i keyboard-configuration/xkb-keymap select us\nd-i netcfg/choose_interface select auto\n\nd-i netcfg/get_hostname string unassigned-hostname\nd-i netcfg/get_domain string unassigned-domain\n\nd-i mirror/country string manual\nd-i mirror/http/hostname string deb.debian.org\nd-i mirror/http/directory string /debian\nd-i mirror/http/proxy string\n\nd-i passwd/root-login boolean false\nd-i passwd/user-fullname string User\nd-i passwd/username string user\nd-i passwd/user-password password pw\nd-i passwd/user-password-again password pw\nd-i user-setup/allow-password-weak boolean true\n\nd-i clock-setup/utc boolean true\nd-i time/zone string UTC\nd-i clock-setup/ntp boolean true\n\nd-i partman-auto/method string regular\nd-i partman-auto/choose_recipe select atomic\nd-i partman-partitioning/confirm_write_new_label boolean true\nd-i partman/choose_partition select finish\nd-i partman/confirm boolean true\nd-i partman/confirm_nooverwrite boolean true\n\ntasksel tasksel/first multiselect standard, gnome-desktop\nd-i pkgsel/include string openssh-server curl net-tools\n\nd-i grub-installer/only_debian boolean true\nd-i grub-installer/bootdev string default\n\nd-i preseed/late_command string \\\n  echo 'user ALL=(ALL:ALL) NOPASSWD: ALL' > /target/etc/sudoers.d/user; \\\n  chmod 440 /target/etc/sudoers.d/user; \\\n  printf \"[logging]\\ndomains=ALL:DEBUG\\n\" > /target/etc/NetworkManager/conf.d/95-nm-debug.conf\n\nd-i finish-install/reboot_in_progress note\nd-i debian-installer/exit/poweroff boolean true\n"
  },
  {
    "path": "linux/vm/fedora43-desktop.ks",
    "content": "url --mirrorlist=http://mirrors.fedoraproject.org/mirrorlist?repo=fedora-43&arch=x86_64\n\ntext\nlang en_US.UTF-8\nkeyboard us\ntimezone UTC\nnetwork --bootproto=dhcp --activate\nrootpw pw --plaintext\nuser --name=user --password=pw --plaintext --groups=wheel\nservices --enabled=sshd\nclearpart --all --initlabel\nautopart\nreboot\n\n%packages\n@^workstation-product-environment\ncurl\nnet-tools  # Contains ifconfig and route\nopenssh-server\n%end\n\n%post\necho 'user ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/user\nchmod 0440 /etc/sudoers.d/user\n%end\n"
  },
  {
    "path": "linux/vm/ubuntu24.04-desktop-cloud-init/meta-data",
    "content": ""
  },
  {
    "path": "linux/vm/ubuntu24.04-desktop-cloud-init/user-data",
    "content": "#cloud-config\nautoinstall:\n  version: 1\n  shutdown: poweroff\n  identity:\n    hostname: unassigned-hostname\n    password: '$6$.3M3jqAKFfWngAem$XK/l4Mepy7Poe1Re8gmDzN3gSgdu/mGIs5slQKc909CEEZHaXpBhNf9kF5QXmdfnf50CM0MXSiaahx8VUnFHW1'\n    realname: user\n    username: user\n  locale: en_US.UTF-8\n  keyboard: {layout: us}\n  ssh:\n    install-server: true\n    allow-pw: true\n  storage:\n    layout:\n      name: direct\n  packages:\n    - net-tools\n    - curl\n  late-commands:\n    - \"echo 'user ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/user-nopasswd\"\n    - \"chmod 440 /target/etc/sudoers.d/user-nopasswd\"\n"
  },
  {
    "path": "obscura-ui/.gitattributes",
    "content": "* text=auto eol=lf\n"
  },
  {
    "path": "obscura-ui/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# production\n/build\n/build-pyi\n/dist\nvite.config.js.timestamp-*\nvite.config.ts.timestamp-*\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n/__pycache__\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nstats.html\n\n# icos\n/src-tauri/icons/SystemTray*/\n"
  },
  {
    "path": "obscura-ui/.vscode/settings.json",
    "content": "{\n    \"cSpell.words\": [\n        \"mantine\"\n    ]\n}\n"
  },
  {
    "path": "obscura-ui/README.md",
    "content": "# App UI for Obscura VPN\n\n## Libraries\n\n- [React Icons](https://react-icons.github.io/react-icons)\n- [Mantine Docs](https://mantine.dev/pages/basics/)\n- [Mantine Default Theme](https://github.com/mantinedev/mantine/blob/master/src/mantine-styles/src/theme/default-theme.ts)\n- [react-18next Trans Component](https://react.i18next.com/latest/trans-component)\n\n## Tips and Trouble Shooting\n\n- Broken npm sub-dependency? Use `resolutions: {subDependency: version}`\n- Use `pnpm upgrade --interactive` to upgrade package interactively\n  - use `npm install --package-lock-only` to update `package-lock.json` which is used to generate the license.json used by the UI\n\n### Media Queries\n\nFor adding new mobile styles, you can do the following\n\n```css\n@media screen and (max-width: $mantine-breakpoint-xs) {\n    padding-top: env(safe-area-inset-top) !important;\n}\n```\n"
  },
  {
    "path": "obscura-ui/index.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"theme-color\" content=\"#1a1b1e\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1, viewport-fit=cover\">\n  <link rel=\"stylesheet\" href=\"/src/index.css\" />\n  <title>Obscura VPN</title>\n</head>\n\n<body>\n  <noscript>JavaScript is required to use this app because this app is made with ReactJS</noscript>\n  <div id=\"root\"></div>\n  <!--\n      You can add webfonts, meta tags, or analytics to this file.\n      The build step will place the bundled scripts into the <body> tag.\n    -->\n  <script type=\"module\" src=\"/src/main.tsx\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "obscura-ui/justfile",
    "content": "setup:\n\tpnpm install --frozen-lockfile\n\nbuild: setup\n\tpnpm run build\n\nstart: setup\n\tpnpm start\n"
  },
  {
    "path": "obscura-ui/package.json",
    "content": "{\n  \"name\": \"obscura-vpn\",\n  \"version\": \"0.2.3\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@fontsource/open-sans\": \"^5.2.7\",\n    \"@mantine/core\": \"^7.17.8\",\n    \"@mantine/hooks\": \"^7.17.8\",\n    \"@mantine/modals\": \"^7.17.8\",\n    \"@mantine/notifications\": \"^7.17.8\",\n    \"@types/react\": \"^19.2.2\",\n    \"@types/react-dom\": \"^19.2.2\",\n    \"countries-list\": \"^3.2.0\",\n    \"framer-motion\": \"^12.23.24\",\n    \"i18next\": \"^25.6.0\",\n    \"i18next-browser-languagedetector\": \"^8.2.0\",\n    \"js-cookie\": \"^3.0.5\",\n    \"localforage\": \"^1.10.0\",\n    \"react\": \"^19.2.0\",\n    \"react-dom\": \"^19.2.0\",\n    \"react-error-boundary\": \"^6.0.0\",\n    \"react-i18next\": \"^16.2.4\",\n    \"react-icons\": \"^5.5.0\",\n    \"react-router-dom\": \"^7.9.5\"\n  },\n  \"scripts\": {\n    \"start\": \"vite --strictPort\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\",\n    \"serve\": \"vite preview\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"eslintConfig\": {\n    \"extends\": [\n      \"react-app\",\n      \"react-app/jest\"\n    ]\n  },\n  \"devDependencies\": {\n    \"@types/js-cookie\": \"^3.0.6\",\n    \"@types/node\": \"^20.19.24\",\n    \"@vitejs/plugin-react\": \"^5.1.0\",\n    \"@welldone-software/why-did-you-render\": \"^10.0.1\",\n    \"license-checker\": \"^25.0.1\",\n    \"postcss\": \"^8.5.6\",\n    \"postcss-preset-mantine\": \"^1.18.0\",\n    \"postcss-simple-vars\": \"^7.0.1\",\n    \"rollup-plugin-visualizer\": \"^6.0.5\",\n    \"terser\": \"^5.44.1\",\n    \"toml\": \"^3.0.0\",\n    \"typescript\": \"^5.9.3\",\n    \"vite\": \"^7.2.0\",\n    \"vite-plugin-svgr\": \"^4.5.0\"\n  },\n  \"resolutions\": {},\n  \"packageManager\": \"pnpm@9.0.6+sha1.648f6014eb363abb36618f2ba59282a9eeb3e879\"\n}\n"
  },
  {
    "path": "obscura-ui/postcss.config.js",
    "content": "export default {\n    plugins: {\n        'postcss-preset-mantine': {},\n        'postcss-simple-vars': {\n            variables: {\n                'mantine-breakpoint-xs': '36em',\n                'mantine-breakpoint-sm': '48em',\n                'mantine-breakpoint-md': '62em',\n                'mantine-breakpoint-lg': '75em',\n                'mantine-breakpoint-xl': '88em',\n            },\n        },\n    },\n};\n"
  },
  {
    "path": "obscura-ui/src/App.module.css",
    "content": ".navLink {\n    display: block;\n    width: 100%;\n    padding: var(--mantine-spacing-xs);\n    border-radius: var(--mantine-radius-md);\n    color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));\n    text-decoration: none;\n    will-change: transform;\n}\n\n.navLink:hover:active {\n    transform: translateY(2px);\n}\n\n.navLinkActive {\n    background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));\n}\n\n.navLinkInactive:hover {\n    background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));\n}\n\n.header {\n    display: flex;\n    align-items: center;\n    height: 100%;\n}\n\n.headerRightItems {\n    margin-left: auto;\n}\n\nbody {\n    background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));\n}\n\n.mediaQuery {\n    display: none;\n}\n\n.footer {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n}\n"
  },
  {
    "path": "obscura-ui/src/App.tsx",
    "content": "import { AppShell, AppShellMain } from '@mantine/core';\nimport { useHotkeys, useThrottledValue } from '@mantine/hooks';\nimport { notifications } from '@mantine/notifications';\nimport { ReactNode, useContext, useEffect, useRef, useState } from 'react';\nimport { ErrorBoundary } from 'react-error-boundary';\nimport { useTranslation } from 'react-i18next';\nimport { Navigate, Route, Routes, useNavigate } from 'react-router-dom';\n\nimport classes from './App.module.css';\nimport * as commands from './bridge/commands';\nimport { IS_HANDHELD_DEVICE, logReactError, PLATFORM, Platform, useSystemChecks } from './bridge/SystemProvider';\nimport { AppContext, AppStatus, ConnectionInProgress, connectionIsIdle, NEVPNStatus, OsStatus } from './common/appContext';\nimport { fmt } from './common/fmt';\nimport { NotificationId } from './common/notifIds';\nimport { useAsync } from './common/useAsync';\nimport { useLoadable } from './common/useLoadable';\nimport { MIN_LOAD_MS, normalizeError } from './common/utils';\nimport { CColorSchemeContext } from './components/CachedColorScheme';\nimport { ScrollableView } from './components/ScrollableView';\nimport { VpnError } from './components/VpnErrorFmt';\nimport { About, Account, Connection, DeveloperView, FallbackAppRender, Help, Location, LogIn, Settings, SplashScreen } from './views';\n\n// imported views need to be added to the `views` list variable\ninterface View {\n  component: () => ReactNode,\n  path: string,\n  exact?: boolean,\n  needsScroll: boolean,\n}\n\nexport default function () {\n  const { t } = useTranslation();\n  const navigate = useNavigate();\n  const colorScheme = useContext(CColorSchemeContext);\n\n  const toggleColorScheme = async () => {\n    const newColorScheme = colorScheme === 'dark' ? 'light' : 'dark';\n    try {\n      await commands.setColorScheme(newColorScheme);\n    } catch (e) {\n      console.error('Failed to set theme:', e);\n    }\n  };\n\n  useSystemChecks();\n  useHotkeys([[PLATFORM === Platform.macOS ? 'mod+J' : 'ctrl+J', toggleColorScheme]]);\n\n  // App State\n  const [vpnConnected, setVpnConnected] = useState(false);\n  // keep track of how the connection was initiated to show correct transitioning UI\n  const [initiatingExitSelector, setInitiatingExitSelector] = useState<commands.ExitSelector>();\n  const [connectionInProgress, setConnectionInProgress] = useState<ConnectionInProgress>(ConnectionInProgress.UNSET);\n  const [appStatus, setStatus] = useState<AppStatus | null>(null);\n  const [osStatus, setOsStatus] = useState<OsStatus | null>(null);\n  const [isProcessingPayment, setPaymentProcessing] = useState(false);\n  const ignoreConnectingErrors = useRef(false);\n\n  const views: View[] = [\n    { component: Connection, path: '/connection', needsScroll: false },\n    { component: DeveloperView, path: '/developer', needsScroll: true },\n    { component: Location, path: '/location', needsScroll: true },\n    { component: Account, path: '/account', needsScroll: false },\n    { component: Help, path: '/help', needsScroll: false },\n    { component: About, path: '/about', needsScroll: false },\n    { component: Settings, path: '/settings', needsScroll: true },\n  ];\n\n  const isLoggedIn = !!appStatus?.accountId;\n  const showAccountCreation = appStatus?.inNewAccountFlow;\n  const loading = appStatus === null || osStatus === null;\n\n  async function tryConnect(exit: commands.ExitSelector) {\n    setInitiatingExitSelector(exit);\n    if (vpnConnected) {\n      setConnectionInProgress(ConnectionInProgress.ChangingLocations);\n    } else {\n      setConnectionInProgress(ConnectionInProgress.Connecting);\n    }\n    ignoreConnectingErrors.current = false;\n    try {\n      await commands.connect(exit);\n    } catch (e) {\n      const error = normalizeError(e);\n      if (error.message === 'accountExpired') {\n        void pollAccount();\n      }\n      if (!ignoreConnectingErrors.current && error.message !== 'tunnelNotDisconnected') {\n        notifications.hide(NotificationId.VPN_ERROR);\n        notifications.show({ title: t('Error Connecting'), message: <VpnError errorEnum={error.message} />, color: 'red', id: NotificationId.VPN_ERROR, autoClose: false });\n        // see https://linear.app/soveng/issue/OBS-775/not-starting-tunnel-because-it-isnt-disconnected-connecting#comment-e98a7150\n        setConnectionInProgress(ConnectionInProgress.UNSET);\n      }\n    }\n  }\n\n  async function disconnectFromVpn() {\n    ignoreConnectingErrors.current = true;\n    setConnectionInProgress(ConnectionInProgress.Disconnecting);\n    setVpnConnected(false);\n    await commands.disconnect();\n  }\n\n  function notifyVpnError(errorEnum: string) {\n    // see enum JsVpnError in commands.swift\n    if (errorEnum !== null) {\n      notifications.hide(NotificationId.VPN_ERROR);\n      notifications.show({\n        id: NotificationId.VPN_ERROR,\n        withCloseButton: true,\n        color: 'red',\n        title: t('Error'),\n        message: <VpnError errorEnum={errorEnum} />,\n        autoClose: 15_000\n      });\n    }\n  }\n\n  function handleNewStatus(newStatus: AppStatus) {\n    const vpnStatus = newStatus.vpnStatus;\n    if (vpnStatus === undefined) return;\n\n    if (vpnStatus.connected !== undefined) {\n      setVpnConnected(true);\n      setConnectionInProgress(ConnectionInProgress.UNSET);\n      notifications.hide(NotificationId.VPN_ERROR);\n      notifications.update({\n        id: NotificationId.VPN_DISCONNECT_CONNECT,\n        message: undefined,\n        color: 'green',\n        autoClose: 1000\n      });\n    } else if (vpnStatus.connecting !== undefined) {\n      setVpnConnected(false);\n      const reconnecting = vpnStatus.connecting.reconnecting;\n      setConnectionInProgress(value => {\n        if (reconnecting) return ConnectionInProgress.Reconnecting;\n        if (value === ConnectionInProgress.ChangingLocations) return value;\n        return ConnectionInProgress.Connecting;\n      });\n      const connectError = vpnStatus.connecting?.connectError;\n      if (connectError !== undefined) {\n        if (reconnecting) {\n          console.error(`got error while reconnecting: ${connectError}`);\n        } else {\n          console.error(`got error while connecting: ${connectError}`);\n        }\n        console.log(fmt`vpnStatus = ${vpnStatus}`);\n        notifyVpnError(connectError);\n      }\n    }\n  }\n\n  // this code fetches the status of the VPN continuously\n  // getting the status is blocking and takes an ID such that if non-null, only new statuses will be returned\n  useEffect(() => {\n    let knownStatusId = null;\n    let keepAlive = true;\n    (async () => {\n      while (keepAlive) {\n        try {\n          let newStatus = await commands.status(knownStatusId);\n          knownStatusId = newStatus.version;\n          setStatus(newStatus);\n        } catch (error) {\n          const e = normalizeError(error);\n          console.error('command status failed', e.message);\n          notifications.show({ title: t('errorFetchingStatus'), message: e.message, color: 'red' });\n        }\n      }\n    })();\n    return () => { keepAlive = false; };\n  }, []);\n\n  useEffect(() => {\n    let knownOsStatusId = null;\n    let keepAlive = true;\n    (async () => {\n      while (keepAlive) {\n        try {\n          let newOsStatus = await commands.osStatus(knownOsStatusId);\n          knownOsStatusId = newOsStatus.version;\n          setOsStatus(newOsStatus);\n        } catch (error) {\n          const e = normalizeError(error);\n          console.error('command osStatus failed', e.message);\n          notifications.show({ title: t('errorFetchingOsStatus'), message: e.message, color: 'red' });\n        }\n      }\n    })();\n    return () => { keepAlive = false; };\n  }, []);\n\n  useEffect(() => {\n    if (appStatus !== null) handleNewStatus(appStatus);\n  }, [appStatus]);\n\n  useEffect(() => {\n    if (osStatus !== null) {\n      const { osVpnStatus } = osStatus;\n      switch (osVpnStatus) {\n        case NEVPNStatus.Disconnecting:\n          setConnectionInProgress(ConnectionInProgress.Disconnecting);\n          break;\n        case NEVPNStatus.Disconnected:\n          setConnectionInProgress(ConnectionInProgress.UNSET);\n          setVpnConnected(false);\n          setInitiatingExitSelector(undefined);\n          break;\n        case NEVPNStatus.Connected:\n          setInitiatingExitSelector(undefined);\n          break;\n      }\n    }\n  }, [osStatus]);\n\n  function resetState() {\n    if (window.location.pathname === '/connection') {\n      window.location.pathname = '/help';\n    } else {\n      window.location.pathname = '/';\n    }\n  }\n\n  // native driven navigation\n  useEffect(() => {\n    const onNavUpdate = (e: Event) => {\n      if (e instanceof CustomEvent) {\n        navigate(`/${e.detail}`);\n      } else {\n        console.error('expected custom event for navigation purposes, got generic Event');\n      }\n    };\n    window.addEventListener('navUpdate', onNavUpdate);\n    return () => window.removeEventListener('navUpdate', onNavUpdate);\n  }, []);\n\n  const onPaymentSucceeded = () => {\n    console.log(\"handling paymentSucceeded event\");\n    void pollAccount();\n    commands.setInNewAccountFlow(false);\n  }\n\n  // deep link payment succeeded\n  useEffect(() => {\n    window.addEventListener('paymentSucceeded', onPaymentSucceeded);\n    return () => window.removeEventListener('paymentSucceeded', onPaymentSucceeded);\n  }, []);\n\n  const {\n    lastSuccessfulValue: accountInfo,\n    error: accountInfoError,\n    refresh: pollAccount,\n    loading: accountLoading\n  } = useLoadable({\n    skip: !osStatus?.internetAvailable || !isLoggedIn,\n    load: commands.getAccount,\n    periodMs: isProcessingPayment ? 3000 : (showAccountCreation ? 60 * 1000 : 12 * 3600 * 1000),\n    returnError: true,\n  });\n  const accountLoadingDelayed = useThrottledValue(accountLoading, accountLoading ? MIN_LOAD_MS : 0);\n\n  useEffect(() => {\n    if (isProcessingPayment && accountInfo?.active) {\n      setPaymentProcessing(false);\n      commands.setInNewAccountFlow(false);\n      commands.resetOfferCodeRedemptionSuccess();\n    }\n  }, [accountInfo, isProcessingPayment]);\n\n  useEffect(() => {\n    if (accountInfoError) {\n      console.error(\"Failed to fetch account info\", accountInfoError);\n      // We just ignore errors, they will be shown if the user goes to the account page.\n    }\n  }, [accountInfoError]);\n\n  const _ = useAsync({\n    skip: osStatus === null || (!osStatus.internetAvailable || IS_HANDHELD_DEVICE),\n    load: commands.checkForUpdates,\n    returnError: true,\n  });\n\n  if (loading) return <SplashScreen text={t('appStatusLoading')} osStatus={osStatus} />;\n\n  const appContext = {\n    accountInfo: accountInfo ?? null,\n    appStatus,\n    connectionInProgress,\n    osStatus,\n    pollAccount,\n    showOfflineUI: !osStatus.internetAvailable && connectionIsIdle(connectionInProgress, appStatus.vpnStatus, osStatus.osVpnStatus),\n    accountLoading: accountLoadingDelayed,\n    vpnConnect: tryConnect,\n    vpnConnected,\n    vpnDisconnect: disconnectFromVpn,\n    initiatingExitSelector,\n    isProcessingPayment,\n    setPaymentProcessing\n  }\n\n  if (!isLoggedIn || showAccountCreation) {\n    return (\n      <AppContext.Provider value={appContext}>\n        <LogIn accountNumber={appStatus.accountId} accountActive={accountInfo?.active} />\n      </AppContext.Provider>\n    );\n  }\n\n\n  return <>\n    <AppShell\n      header={{ height: 0 }}\n      navbar={undefined}\n      className={classes.appShell}>\n      <AppShellMain>\n        <AppContext.Provider value={appContext}>\n          <ErrorBoundary FallbackComponent={FallbackAppRender} onReset={_details => resetState()} onError={logReactError}>\n            <Routes>\n              {views[0] !== undefined && <Route path='/' element={<Navigate to={views[0].path} />} />}\n              {views.map((view, index) => <Route key={index} path={view.path} element={<RenderView key={view.path} view={view} />} />)}\n            </Routes>\n          </ErrorBoundary>\n        </AppContext.Provider>\n      </AppShellMain>\n    </AppShell>\n  </>;\n}\n\nfunction RenderView({ view }: { view: View }) {\n  return (\n    view.needsScroll ?\n      <ScrollableView>\n        <view.component />\n      </ScrollableView> :\n      <view.component />\n  );\n}\n"
  },
  {
    "path": "obscura-ui/src/Providers.tsx",
    "content": "import '@fontsource/open-sans';\nimport { PropsWithChildren } from 'react';\nimport { MemoryRouter } from 'react-router-dom';\nimport Mantine from './components/Mantine';\n\nexport default function ({ children }: PropsWithChildren) {\n  return <>\n    {/* Cannot use Browser router for loading from file */}\n    <MemoryRouter>\n      <Mantine>\n        {children}\n      </Mantine>\n    </MemoryRouter>\n  </>;\n}\n"
  },
  {
    "path": "obscura-ui/src/bridge/SystemProvider.tsx",
    "content": "import { ErrorInfo, useEffect } from 'react';\n\nexport const PLATFORM = import.meta.env.OBS_WEB_PLATFORM as Platform;\n\n// Update translation files whenever Platform is updated\nexport enum Platform {\n  macOS = 'macosx',\n  iOS = 'iphoneos',\n  Android = 'android',\n}\n\nexport function systemName(): string {\n  switch (PLATFORM) {\n    case Platform.macOS:\n      return \"macOS\";\n    case Platform.iOS:\n      return \"iOS\";\n    case Platform.Android:\n      return \"Android\";\n  }\n}\n\nexport const IS_HANDHELD_DEVICE = PLATFORM === Platform.iOS ||\n  PLATFORM === Platform.Android;\nconst platformDefined = Object.values(Platform).includes(PLATFORM);\n\n// TODO: Can we remove iOS by preventing it from failing early?\n// https://linear.app/soveng/issue/OBS-3164/improve-feedback-during-connecting-state\nexport const CONNECT_REQUIRES_ONLINE = PLATFORM === Platform.iOS || PLATFORM === Platform.macOS;\n\nexport function useSystemChecks() {\n  useEffect(() => {\n    if (!platformDefined) {\n      const errMsg = `OBS_WEB_PLATFORM was unexpected, got \"${PLATFORM}\"`;\n      throw new Error(errMsg);\n    }\n  }, [platformDefined]);\n}\n\nexport async function logReactError(error: Error, info: ErrorInfo) {\n  console.error(`Render error \"${error.message}\"; ComponentStack = ${info.componentStack}`);\n}\n"
  },
  {
    "path": "obscura-ui/src/bridge/android.ts",
    "content": "import { PLATFORM, Platform } from \"./SystemProvider\";\n\nif (PLATFORM === Platform.Android) {\n  const MESSAGE_PREFIX = \"android/\";\n  const NAVIGATE_PREFIX = \"android-navigate/\";\n\n  let counter = 0;\n\n  const acceptFns = new Map<number, (data: string) => void>();\n  const rejectFns = new Map<number, (error: string) => void>();\n\n  window.addEventListener(\"message\", (event) => {\n    if (typeof event.data !== \"string\") {\n      return;\n    }\n\n    if (\n      event.data.startsWith(MESSAGE_PREFIX)\n    ) {\n      const message: { id: number; error?: string; data?: string } = JSON.parse(\n        event.data.substring(MESSAGE_PREFIX.length),\n      );\n\n      if (typeof message.error === \"string\") {\n        const reject = rejectFns.get(message.id);\n        if (reject) {\n          reject(message.error);\n        }\n      } else if (typeof message.data === \"string\") {\n        const accept = acceptFns.get(message.id);\n        if (accept) {\n          accept(message.data);\n        }\n      }\n    } else if (event.data.startsWith(NAVIGATE_PREFIX)) {\n      window.dispatchEvent(new CustomEvent('navUpdate', {\n        detail: event.data.substring(NAVIGATE_PREFIX.length),\n      }));\n    }\n  });\n\n  Object.defineProperty(window, \"webkit\", {\n    writable: false,\n    enumerable: false,\n    configurable: false,\n    value: Object.freeze({\n      messageHandlers: Object.freeze({\n        commandBridge: Object.freeze({\n          postMessage: (data: string) =>\n            new Promise((accept, reject) => {\n              const id = (counter += 1);\n\n              const cleanup = () => {\n                acceptFns.delete(id);\n                rejectFns.delete(id);\n              };\n\n              acceptFns.set(id, (value) => {\n                cleanup();\n                accept(value);\n              });\n              rejectFns.set(id, (error) => {\n                cleanup();\n                reject(new Error(error));\n              });\n\n              // obscuraAndroidCommandBridge is defined by the Android WebView\n              (window as any).obscuraAndroidCommandBridge.invoke(data, id);\n            }),\n        }),\n      }),\n    }),\n  });\n}\n"
  },
  {
    "path": "obscura-ui/src/bridge/commands.ts",
    "content": "import { useThrottledValue } from '@mantine/hooks';\nimport { notifications } from '@mantine/notifications';\nimport { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { AccountId } from '../common/accountUtils';\nimport { AccountInfo, Exit } from '../common/api';\nimport { AppStatus, DNSContentBlock, FeatureFlagKey, OsStatus, PinnedLocation, SubscriptionProductModel } from '../common/appContext';\nimport { normalizeError } from '../common/utils';\nimport { fmtErrorI18n } from '../translations/i18n';\nimport { Platform, PLATFORM } from './SystemProvider';\nimport './android';\n\nasync function WKWebViewInvoke(command: string, args: Object) {\n    const commandJson = JSON.stringify({ [command]: args });\n    if (command !== 'jsonFfiCmd') {\n      console.log(\"invoked non-FFI command\", command);\n    }\n    let resultJson;\n    try {\n        resultJson = await window.webkit.messageHandlers.commandBridge.postMessage(commandJson);\n    } catch (e) {\n        throw new CommandError(normalizeError(e).message);\n    }\n    return JSON.parse(resultJson);\n}\n\nasync function invoke(command: string, args: Object = {}): Promise<unknown> {\n    // all commands are logged for wkwebview according to ContentView.swift\n    try {\n        return await WKWebViewInvoke(command, args);\n    } catch (e) {\n        console.error(\"Command failed\", command, args, e);\n        throw e;\n    }\n}\n\nexport class CommandError extends Error {\n    code: string\n\n    constructor(code: string) {\n        // HACK: We should put some \"human readable\" message into the message field but lots of code currently just hopes to find specific error codes in the message field. So until we hunt down all of those just put the code in the message as well. Don't write new code that treats `message` as machine readable.\n        super(code);\n        this.code = code;\n    }\n\n    i18nKey() {\n        return `ipcError-${this.code}`;\n    }\n}\n\n// VPN Client Specific Commands\n\nexport async function jsonFfiCmd(cmd: string, arg = {}, timeoutMs: number | null = 10_000): Promise<unknown> {\n    let jsonCmd = JSON.stringify(({ [cmd]: arg }));\n    console.log(\"invoked FFI command\", cmd);\n    return await invoke('jsonFfiCmd', {\n        cmd: jsonCmd,\n        timeoutMs,\n    })\n}\n\nexport async function status(lastStatusId: string | null = null): Promise<AppStatus> {\n    return await jsonFfiCmd(\n        'getStatus',\n        { knownVersion: lastStatusId },\n        null,\n    ) as AppStatus;\n}\n\nexport async function osStatus(lastOsStatusId: string | null = null): Promise<OsStatus> {\n    return await invoke('getOsStatus', { knownVersion: lastOsStatusId }) as OsStatus;\n}\n\nexport function login(accountId: AccountId, validate = false) {\n    return jsonFfiCmd('login', { accountId, validate });\n}\n\nexport function logout() {\n    return jsonFfiCmd('logout');\n}\n\nexport async function setApiUrl(url: string | null): Promise<void> {\n    await jsonFfiCmd(\"setApiUrl\", { url });\n}\n\nexport async function setApiHostAlternate(host: string | null): Promise<void> {\n    await jsonFfiCmd('setApiHostAlternate', { host });\n}\n\nexport async function setSniRelay(host: string | null): Promise<void> {\n    await jsonFfiCmd('setSniRelay', { host });\n}\n\nexport async function setStrictLeakPrevention(enable: boolean): Promise<void> {\n    await invoke('setStrictLeakPrevention', { enable });\n}\n\nexport async function setColorScheme(value: 'dark' | 'light' | 'auto'): Promise<void> {\n    await invoke('setColorScheme', { value });\n}\n\n// See ../../../rustlib/src/manager.rs\nexport interface TunnelArgs {\n    exit: ExitSelector,\n}\n\nexport interface ExitSelectorId {\n  id: string;\n}\n\nexport interface ExitSelectorCity {\n  country_code: string,\n  city_code: string,\n}\n\nexport interface ExitSelectorCountry {\n  country_code: string,\n}\n\n// See ../../../rustlib/src/manager.rs\nexport type ExitSelector =\n  | { any: {} }\n  | { exit: ExitSelectorId }\n  | { city: ExitSelectorCity }\n  | { country: ExitSelectorCountry }\n;\n\nexport async function connect(exit: ExitSelector): Promise<void> {\n    let args: TunnelArgs = {\n      exit,\n    };\n    await invoke('startTunnel', {\n      tunnelArgs: JSON.stringify(args),\n    });\n}\n\nexport async function disconnect(): Promise<void> {\n    await invoke('stopTunnel');\n}\n\nexport async function debuggingArchive(userFeedback: string): Promise<String> {\n    return (await invoke('debuggingArchive', { userFeedback })) as String;\n}\n\nexport function revealItemInDir(path: String) {\n    return invoke('revealItemInDir', { path });\n}\n\nexport async function emailDebugArchive(path: String, subject: String, body: String): Promise<void> {\n    await invoke('emailDebugArchive', { path, subject, body });\n}\n\n// trigger native share dialog\nexport async function shareDebugArchive(path: String): Promise<void> {\n    await invoke('shareDebugArchive', { path });\n}\n\nexport interface Notice {\n  type: 'Error' | 'Warn' | 'Important',\n  content: string\n}\n\n\nexport async function registerAsLoginItem(): Promise<void> {\n  await invoke('registerAsLoginItem');\n}\n\nexport async function unregisterAsLoginItem(): Promise<void> {\n  await invoke('unregisterAsLoginItem');\n}\n\nexport async function developerResetUserDefaults(): Promise<void> {\n  await invoke('resetUserDefaults');\n}\n\nexport async function checkForUpdates(): Promise<void> {\n  await invoke('checkForUpdates');\n}\n\nexport async function installUpdate(): Promise<void> {\n  await invoke('installUpdate');\n}\n\nexport interface TrafficStats {\n    connectedMs: number,\n    connId: string,\n    txBytes: number,\n    rxBytes: number,\n    latestLatencyMs: number,\n}\n\nexport async function getTrafficStats(): Promise<TrafficStats> {\n    return await jsonFfiCmd('getTrafficStats') as TrafficStats;\n}\n\nexport interface CachedValue<T> {\n  version: string,\n  last_updated: number,\n  value: T,\n}\n\nexport interface ExitList {\n    exits: Exit[]\n}\n\nexport async function getExitList(version?: string): Promise<CachedValue<ExitList>> {\n  return await jsonFfiCmd(\n    'getExitList',\n    { knownVersion: version },\n    null\n  ) as CachedValue<ExitList>;\n}\n\nexport async function refreshExitList(freshnessS: number): Promise<void> {\n  await jsonFfiCmd('refreshExitList', {\n    freshness: freshnessS * 1000,\n  });\n}\n\nexport async function deleteAccount(): Promise<void> {\n    await jsonFfiCmd('apiDeleteAccount');\n}\n\nexport async function getAccount(): Promise<AccountInfo> {\n    /* see obscuravpn-api/src/types.rs:AccountInfo */\n    return await jsonFfiCmd('apiGetAccountInfo') as AccountInfo;\n}\n\nexport function setInNewAccountFlow(value: boolean) {\n    return jsonFfiCmd('setInNewAccountFlow', { value });\n}\n\nexport function setPinnedExits(newPinnedExits: PinnedLocation[]) {\n    return jsonFfiCmd('setPinnedExits', { exits: newPinnedExits });\n}\n\nexport function rotateWgKey() {\n    return jsonFfiCmd('rotateWgKey');\n}\n\nexport function setAutoConnect(enable: boolean) {\n  return jsonFfiCmd('setAutoConnect', { enable });\n}\n\nexport function setUseSystemDns(enable: boolean) {\n  return jsonFfiCmd('setUseSystemDns', { enable });\n}\n\nexport async function setFeatureFlag(flag: FeatureFlagKey, active: boolean) {\n  await jsonFfiCmd('setFeatureFlag', { flag, active });\n}\n\nexport async function setDnsContentBlock(value: DNSContentBlock): Promise<void> {\n  await jsonFfiCmd('setDnsContentBlock', { value });\n}\n\nexport async function getSubscriptionProductDisplay(): Promise<SubscriptionProductModel> {\n  return await invoke('getSubscriptionProduct') as SubscriptionProductModel;\n}\n\nexport async function storeKitAssociateAccount(): Promise<void> {\n  await invoke('associateAccount');\n}\n\nexport async function storeKitPurchaseSubscription(): Promise<boolean> {\n  return await invoke('purchaseSubscription', {}) as boolean;\n}\n\nexport async function storeKitRestorePurchases(): Promise<void> {\n  await invoke('restorePurchases', {});\n}\n\nexport async function showOfferCodeRedemption(): Promise<void> {\n  await invoke('showOfferCodeRedemption');\n}\n\nexport async function resetOfferCodeRedemptionSuccess(): Promise<void> {\n  if (PLATFORM === Platform.iOS) {\n    await invoke('resetOfferCodeRedemptionSuccess');\n  }\n}\n\nexport async function playPurchaseSubscription(): Promise<boolean> {\n  return await invoke('purchaseSubscription', {}) as boolean;\n}\n\nexport interface UseCommandOptions<CommandArgs extends any[]> {\n  command: (...args: CommandArgs) => Promise<void>;\n  /** Whether to show a notification on error. Default: false */\n  showNotification?: boolean;\n  /** Whether to re-throw the error after handling. Default: false */\n  rethrow?: boolean;\n}\n\n/**\n * Hook for calling non-return value bridge commands with loading and error state management.\n *\n * @returns Object containing:\n *   - loading: boolean indicating if command is in progress\n *   - showLoadingUI: boolean indicating whether caller should show a throttled loading UI\n *   - error: string with error message if command failed\n */\nexport function useCommand<CommandArgs extends any[]>({ command, showNotification = false, rethrow = false }: UseCommandOptions<CommandArgs>) {\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState<string>();\n  const { t } = useTranslation();\n  const showLoadingUI = useThrottledValue(loading, loading ? 200 : 0);\n\n  const execute = async (...args: CommandArgs) => {\n    if (loading) return;\n    setLoading(true);\n    setError(undefined);\n    try {\n      await command(...args);\n    } catch (err) {\n      const error = normalizeError(err);\n      const message = error instanceof CommandError\n        ? fmtErrorI18n(t, error) : error.message;\n\n      setError(message);\n\n      if (showNotification) {\n        notifications.show({\n          color: 'red',\n          title: t('Error'),\n          message\n        });\n      }\n\n      if (rethrow) {\n        throw error;\n      }\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return { loading, showLoadingUI, error, execute };\n}\n"
  },
  {
    "path": "obscura-ui/src/common/KeyedSet.ts",
    "content": "export class KeyedSet<V extends L, L = V, K = unknown> {\n  #key: (v: L) => K;\n  #map = new Map<K, V>;\n\n  constructor(\n    key: (v: L) => K,\n    entries?: Iterable<V>,\n  ) {\n    this.#key = key;\n    if (entries) {\n      this.extend(entries);\n    }\n  }\n\n  [Symbol.iterator](): Iterator<V> {\n    return this.#map.values();\n  }\n\n  /// Add an item to the set.\n  ///\n  /// Always updates the stored item to the new value.\n  add(v: V): V | undefined {\n    let k = this.#key(v);\n    let existing = this.#map.get(k);\n\n    // Note: Skip second lookup in common case where value is not undefined.\n    if (existing || this.#map.has(k)) {\n      return existing;\n    }\n\n    this.#map.set(k, v);\n  }\n\n  extend(values: Iterable<V>) {\n    for (let v of values) {\n      this.add(v);\n    }\n  }\n\n  get(v: L): V | undefined {\n    return this.getKey(this.#key(v));\n  }\n\n  getKey(k: K): V | undefined {\n    return this.#map.get(k);\n  }\n\n  has(v: L): boolean {\n    return this.hasKey(this.#key(v));\n  }\n\n  hasKey(k: K): boolean {\n    return this.#map.has(k);\n  }\n\n  get size(): number {\n    return this.#map.size\n  }\n}\n"
  },
  {
    "path": "obscura-ui/src/common/accountUtils.ts",
    "content": "import { err } from \"./fmt\";\n\nconst D = [\n  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],\n  [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],\n  [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],\n  [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],\n  [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],\n  [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],\n  [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],\n  [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],\n  [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],\n  [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]\n];\n\nconst P = [\n  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],\n  [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],\n  [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],\n  [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],\n  [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],\n  [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],\n  [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],\n  [7, 0, 4, 6, 9, 1, 3, 2, 5, 8]\n];\n\nconst INVERSE = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9];\n\nfunction rawChecksum(digits: string): number {\n  return digits.split(\"\").reduceRight((acc, char, i) => {\n    let index = P[(digits.length - 1 - i) % 8]![+char];\n    if (index === undefined) {\n      throw err`Invalid digit ${char}`;\n    }\n    return D[acc]![index]!;\n  }, 0);\n}\n\nexport function checkDigit(digits: string): number {\n  return INVERSE[rawChecksum(`${digits}0`)]!;\n}\n\nexport function validChecksum(digits: string): boolean {\n  return rawChecksum(digits) === 0;\n}\n\nconst ACCOUNT_ID_LENGTH = 19;\nconst MAX_ID = 10n ** BigInt(ACCOUNT_ID_LENGTH);\nconst USER_ACCOUNT_NUMBER_LEN = ACCOUNT_ID_LENGTH + 1;\nconst ACCOUNT_ID_DISPLAY_CHUNK_SIZE = 4;\nconst ACCOUNT_ID_CHUNK_RE = new RegExp(`.{${ACCOUNT_ID_DISPLAY_CHUNK_SIZE}}(?=.)`, \"g\");\n\nfunction generateAccountId(): BigInt {\n  let rand = new BigUint64Array(1);\n  while (1) {\n    window.crypto.getRandomValues(rand);\n    let n = rand[0]!;\n    if (n < MAX_ID) {\n      return n;\n    }\n  }\n  throw err`unreachable`;\n}\n\nexport function generateAccountNumber(): AccountId {\n  const accountID = generateAccountId().toString().padStart(ACCOUNT_ID_LENGTH, '0');\n  return accountID + String(checkDigit(accountID)) as any as AccountId;\n}\n\nexport interface AccountId {\n  readonly Type: unique symbol\n};\n\n/// The raw formatting of an account ID.\nexport function accountIdToString(id: AccountId): string {\n\treturn id as any as string;\n}\n\nconst enum ObscuraAccountErrorCode {\n  TOO_SHORT = \"tooShort\",\n  TOO_LONG = \"tooLong\",\n  INVALID_CHECKSUM = \"invalidChecksum\",\n};\n\nexport class ObscuraAccountIdError extends Error {\n  public readonly code: string;\n\n  constructor(code: ObscuraAccountErrorCode, message: string) {\n    super(message);\n    this.name = 'ObscuraAccountError';\n    this.code = code;\n  }\n\n  i18nKey() {\n    return `accountIdError-${this.code}`;\n  }\n}\n\n/// Parse a strictly integer account ID.\nexport function parseAccountIdInt(id: string): AccountId {\n  if (id.length < USER_ACCOUNT_NUMBER_LEN) {\n    throw new ObscuraAccountIdError(ObscuraAccountErrorCode.TOO_SHORT, \"Account ID is too short.\");\n  }\n  if (id.length > USER_ACCOUNT_NUMBER_LEN) {\n    throw new ObscuraAccountIdError(ObscuraAccountErrorCode.TOO_LONG, \"Account ID is too long.\");\n  }\n  if (!validChecksum(id)) {\n    throw new ObscuraAccountIdError(ObscuraAccountErrorCode.INVALID_CHECKSUM, \"Mistyped Account ID.\");\n  }\n  return id as any as AccountId;\n}\n\nexport function parseAccountIdInput(input: string): AccountId {\n  return parseAccountIdInt(normalizeAccountIdInput(input));\n}\n\nfunction normalizeAccountIdInput(id: string): string {\n  return id.replace(/[^\\d]/g, \"\");\n}\n\nexport function formatPartialAccountId(accountId: string): string {\n  accountId = normalizeAccountIdInput(accountId);\n  if (accountId.length >= USER_ACCOUNT_NUMBER_LEN) {\n    return `${accountId.slice(0, 4)} - ${accountId.slice(4, 8)} - ${accountId.slice(8, 12)} - ${accountId.slice(12, 16)} - ${accountId.slice(16)}`;\n  }\n  return accountId.replace(ACCOUNT_ID_CHUNK_RE, \"$& - \");\n}\n\nexport const OBSCURA_WEBPAGE = 'https://obscura.com';\nexport const CHECK_STATUS_WEBPAGE = `${OBSCURA_WEBPAGE}/check`;\nexport const LEGAL_WEBPAGE = `${OBSCURA_WEBPAGE}/legal`;\nexport const APP_ACCOUNT_TAB = 'obscuravpn:///account';\n\nexport const APP_MANAGE_SUBSCRIPTION = `obscuravpn:///manage-subscription`;\n\nexport function payUrl(accountId: AccountId): string {\n  return `${OBSCURA_WEBPAGE}/pay#account_id=${encodeURIComponent(accountIdToString(accountId))}`;\n}\n\nexport function subscriptionUrl(accountId: AccountId): string {\n  return `${OBSCURA_WEBPAGE}/subscription/stripe/checkout#account_id=${encodeURIComponent(accountIdToString(accountId))}`;\n}\n\nexport function tunnelsUrl(accountId: AccountId): string {\n  return `${OBSCURA_WEBPAGE}/account/tunnels#account_id=${encodeURIComponent(accountIdToString(accountId))}`;\n}\n"
  },
  {
    "path": "obscura-ui/src/common/api.ts",
    "content": "import { getCountryData, ICountryData, TContinentCode, TCountryCode } from \"countries-list\";\nimport { useEffect, useReducer } from 'react';\nimport { AccountId } from \"./accountUtils\";\nimport { AccountStatus } from './appContext';\n\nexport interface Exit {\n    id: string,\n    country_code: string, // lowercase TCountryCode\n    city_code: string,\n    city_name: string,\n    provider_id: string,\n    provider_url: string,\n    provider_name: string,\n    provider_homepage_url: string,\n}\n\nexport function getContinent(countryData: ICountryData): TContinentCode {\n  if (countryData.iso2 === 'MX') return 'SA';\n  return countryData.continent;\n}\n\nexport function getCountry(country_code: string): ICountryData {\n  return getCountryData(country_code.toUpperCase() as TCountryCode);\n}\n\nexport function getExitCountry(exit: Exit): ICountryData {\n  if (exit.country_code.length !== 2) {\n    console.warn(`Exit ${exit.id} (${exit.city_name}) does not have a country code of length 2 (got ${exit.country_code})`);\n  }\n  return getCountry(exit.country_code);\n}\n\nexport interface AccountInfo {\n    id: AccountId,\n    active: boolean,\n    top_up: TopUpInfo | null,\n    subscription: SubscriptionInfo | null,\n    apple_subscription: AppleSubscriptionInfo | null,\n    auto_renews: number | null,\n    current_expiry: number | null,\n}\n\nexport interface TopUpInfo {\n    credit_expires_at: number,\n}\n\nexport function hasCredit(accountInfo: AccountInfo | undefined): boolean {\n    const expires = accountInfo?.top_up?.credit_expires_at || 0;\n    return new Date(expires * 1000).getTime() > new Date().getTime();\n}\n\nexport interface SubscriptionInfo {\n    status: SubscriptionStatus,\n    current_period_start: number,\n    current_period_end: number,\n    cancel_at_period_end: boolean,\n}\n\n// returns if a subscription is active, regardless about renewal status\nexport function hasActiveSubscription(account: AccountInfo): boolean {\n    if (account.subscription?.status === SubscriptionStatus.ACTIVE\n        || account.subscription?.status === SubscriptionStatus.TRIALING) {\n        return true;\n    }\n    if (account.apple_subscription?.status === AppleSubscriptionStatus.ACTIVE\n        && account.apple_subscription.renewal_date > new Date().getTime()) {\n      return true;\n    }\n    return false;\n}\n\nexport function isRenewing(account: AccountInfo): boolean {\n  return account.auto_renews !== null;\n}\n\n/// Returns the end of the current payment period.\n///\n/// Note that if the account has a renewing subscription it can stay active for longer.\nexport function paidUntil(account: AccountInfo): Date | null {\n  const autoRenewDate = account.auto_renews || 0;\n  const currentExpiry = account.current_expiry || 0;\n  const maxExpiry = Math.max(autoRenewDate, currentExpiry);\n  return maxExpiry > 0 ? new Date(maxExpiry * 1000) : null;\n}\n\nexport function activeAppleSubscription(account: AccountInfo): boolean {\n  return (\n    account.active && account.apple_subscription !== null &&\n      (\n        account.apple_subscription.status === AppleSubscriptionStatus.ACTIVE ||\n        account.apple_subscription.status === AppleSubscriptionStatus.GRACE_PERIOD\n      )\n  );\n}\n\nexport function accountIsExpired(accountInfo: AccountInfo): boolean {\n  if (accountInfo.auto_renews) return false;\n  return (accountInfo.active && accountInfo.current_expiry) ?\n    new Date(accountInfo.current_expiry * 1000).getTime() < new Date().getTime() :\n    true;\n}\n\n// TimeRemaining is represented in parts of a whole\nexport interface TimeRemaining {\n    days: number;\n    hours: number;\n    minutes: number;\n}\n\n/// Returns a human representation of the time left on an account.\n///\n/// Note that there is funny rounding on this number, it MUST NOT be used for computation.\nexport function accountTimeRemaining(account: AccountInfo): TimeRemaining {\n  const expiry = paidUntil(account);\n  const remainingMs = expiry !== null ? expiry.getTime() - Date.now() : 0;\n  let remainingSeconds = Math.floor(remainingMs / 1000);\n\n  const days = Math.floor(remainingMs / 1000 / 3600 / 24);\n  remainingSeconds -= days * 86400;\n\n  const hours = Math.floor(remainingSeconds / 3600);\n  remainingSeconds -= hours * 3600;\n\n  const minutes = Math.floor(remainingSeconds / 60);\n\n  return { days, hours, minutes };\n}\n\n/// https://docs.stripe.com/api/subscriptions/object#subscription_object-status\nexport const enum SubscriptionStatus {\n    ACTIVE = \"active\",\n    CANCELED = \"canceled\",\n    INCOMPLETE = \"incomplete\",\n    INCOMPLETE_EXPIRED = \"incomplete_expired\",\n    PAST_DUE = \"past_due\",\n    PAUSED = \"paused\",\n    TRIALING = \"trialing\",\n    UNPAID = \"unpaid\",\n}\n\n// https://developer.apple.com/documentation/appstoreserverapi/status\nexport const enum AppleSubscriptionStatus {\n    ACTIVE = 1,\n    EXPIRED = 2,\n    BILLING_RETRY = 3,\n    GRACE_PERIOD = 4,\n    REVOKED = 5,\n}\n\nexport interface AppleSubscriptionInfo {\n    status: AppleSubscriptionStatus,\n    auto_renew_status: boolean,\n    renewal_date: number,\n}\n\nexport function hasAppleSubscription(accountInfo: AccountInfo | undefined): boolean {\n    const status = accountInfo?.apple_subscription?.status;\n    return status === AppleSubscriptionStatus.ACTIVE\n      || status === AppleSubscriptionStatus.GRACE_PERIOD;\n}\n\n/**\n * Force the component to re-render when an account is expected to expire\n */\nexport function useReRenderWhenExpired(account: AccountStatus | null) {\n  const [, forceUpdate] = useReducer(x => x + 1, 0);\n\n  useEffect(() => {\n    if (account !== null) {\n      const expiryDate = paidUntil(account.account_info);\n      if (expiryDate !== null && !accountIsExpired(account.account_info)) {\n        const timeoutId = setTimeout(forceUpdate, expiryDate.getTime() - (new Date()).getTime());\n        return () => clearTimeout(timeoutId);\n      }\n    }\n  }, [account?.last_updated_sec]);\n}\n"
  },
  {
    "path": "obscura-ui/src/common/appContext.ts",
    "content": "import { createContext, useContext } from 'react';\nimport { ExitSelector, ExitSelectorCity, TunnelArgs } from 'src/bridge/commands';\nimport { AccountId } from './accountUtils';\nimport { AccountInfo, Exit } from './api';\n\nexport enum NEVPNStatus {\n    Invalid = 'invalid',\n    Disconnected = 'disconnected',\n    Connecting = 'connecting',\n    Connected = 'connected',\n    Reasserting = 'reasserting',\n    Disconnecting = 'disconnecting'\n}\n\nexport enum UpdaterStatusType {\n    Uninitiated = 'uninitiated',\n    Initiated = 'initiated',\n    Available = 'available',\n    NotFound = 'notFound',\n    Error = 'error'\n}\n\nexport interface AppcastSummary {\n    date: string;\n    description: string;\n    version: string;\n    minSystemVersionOk: boolean;\n}\n\nexport interface UpdaterStatus {\n    type: UpdaterStatusType;\n    appcast?: AppcastSummary;\n    error?: string;\n    errorCode?: number;\n}\n\nexport interface OsStatus {\n    version: string,\n    internetAvailable: boolean,\n    osVpnStatus: NEVPNStatus,\n    srcVersion: string\n    strictLeakPrevention: boolean,\n    updaterStatus: UpdaterStatus,\n    debugBundleStatus: {\n        inProgress: boolean,\n        latestPath: string | null,\n        inProgressCounter: number,\n    },\n    canSendMail: boolean,\n    loginItemStatus?: {\n        registered: boolean,\n        error?: string\n    },\n    // iOS-specific\n    storeKit?: {\n      subscriptionProduct?: SubscriptionProductModel,\n      externalPaymentsAllowed: boolean,\n    },\n    offerCodeRedemptionSuccess?: boolean,\n    // Android-specific\n    playBilling?: boolean,\n}\n\nexport interface SubscriptionProductModel {\n  displayName: string,\n  description: string,\n  displayPrice: string,\n  renewalPrice?: string,\n  subscriptionPeriodFormatted: string,\n}\n\nexport enum TransportKind {\n    Quic = 'quic',\n    TcpTls = 'tcpTls',\n}\n\nexport interface VpnStatus {\n    connected?: {\n      exit: Exit,\n      clientPublicKey: string,\n      exitPublicKey: string,\n      transport: TransportKind,\n      tunnelArgs: TunnelArgs,\n    },\n    connecting?: {\n      connectError: string,\n      reconnecting: boolean\n      tunnelArgs: TunnelArgs,\n    },\n    disconnected?: {}\n}\n\nexport function getCityFromStatus(status: VpnStatus): ExitSelectorCity | undefined {\n  const tunnelArgs = getTunnelArgs(status);\n  return getCityFromArgs(tunnelArgs?.exit);\n}\n\nexport function getCityFromArgs(exitSelector: ExitSelector | undefined): ExitSelectorCity | undefined {\n  return exitSelector !== undefined && \"city\" in exitSelector ? exitSelector.city : undefined;\n}\n\nexport function getTunnelArgs(status: VpnStatus): TunnelArgs | undefined {\n  return status.connected?.tunnelArgs ?? status.connecting?.tunnelArgs;\n}\n\nexport interface PinnedLocation {\n    country_code: string,\n    city_code: string,\n\n    // Seconds since UNIX epoch.\n    pinned_at: number,\n}\n\nexport interface AccountStatus {\n    account_info: AccountInfo,\n    last_updated_sec: number\n}\n\n// See rustlib/src/config/feature_flags.rs\nexport enum KnownFeatureFlagKey {\n  QuicFramePadding = \"quicFramePadding\",\n  KillSwitch = \"killSwitch\",\n  ForceSmallMtu = \"forceSmallMtu\",\n  TcpTlsTunnel = \"tcpTlsTunnel\",\n}\n\nexport type FeatureFlagKey = KnownFeatureFlagKey | string;\n\nexport type FeatureFlagValue = boolean | null;\n\nexport function featureFlagEnabled(value: FeatureFlagValue | undefined): boolean {\n  return value === true;\n}\n\nexport interface DNSContentBlock {\n    ad: boolean,\n    tracker: boolean,\n    malware: boolean,\n    adult: boolean,\n    gambling: boolean,\n    socialMedia: boolean,\n}\n\nexport interface AppStatus {\n    version: string,\n    dnsContentBlock: DNSContentBlock,\n    vpnStatus: VpnStatus,\n    accountId: AccountId,\n    pinnedLocations: Array<PinnedLocation>,\n    lastChosenExit: ExitSelector,\n    inNewAccountFlow: boolean,\n    apiUrl: string,\n    account: AccountStatus | null,\n    autoConnect: boolean,\n    featureFlags: Record<FeatureFlagKey, FeatureFlagValue>,\n    featureFlagKeys: FeatureFlagKey[],\n    useSystemDns: boolean,\n}\n\ninterface IAppContext {\n    vpnConnected: boolean,\n    // the exitSelector used to initiate the connection\n    initiatingExitSelector?: ExitSelector,\n    vpnConnect: (exit: ExitSelector) => Promise<void>,\n    vpnDisconnect: () => Promise<void>,\n    pollAccount: () => Promise<void>,\n    accountLoading: boolean,\n    appStatus: AppStatus,\n    osStatus: OsStatus,\n    showOfflineUI: boolean,\n    accountInfo: AccountInfo | null,\n    connectionInProgress: ConnectionInProgress,\n    isProcessingPayment: boolean,\n    setPaymentProcessing: (value: boolean) => void\n}\n\nexport const AppContext = createContext(null as any as IAppContext);\n\nexport enum ConnectionInProgress {\n    Connecting = 'Connecting',\n    Reconnecting = 'Reconnecting',\n    Disconnecting = 'Disconnecting',\n    // UI exclusives:\n    ChangingLocations = 'Changing Locations',\n    UNSET = 'UNSET'\n}\n\n/**\n * State derived isConnecting hook\n */\nexport function useIsConnecting() {\n  const { connectionInProgress, osStatus, appStatus } = useContext(AppContext);\n  return osStatus.osVpnStatus === NEVPNStatus.Connecting\n    || osStatus.osVpnStatus === NEVPNStatus.Reasserting\n    || connectionInProgress === ConnectionInProgress.ChangingLocations\n    || appStatus.vpnStatus.connecting !== undefined;\n}\n\nexport function useIsTransitioning() {\n  const { connectionInProgress, osStatus, appStatus } = useContext(AppContext);\n  return osStatus.osVpnStatus === NEVPNStatus.Connecting\n    || osStatus.osVpnStatus === NEVPNStatus.Reasserting\n    || osStatus.osVpnStatus === NEVPNStatus.Disconnecting\n    || connectionInProgress === ConnectionInProgress.ChangingLocations\n    || appStatus.vpnStatus.connecting !== undefined;\n}\n\nexport function isConnecting(connectionInProgress: ConnectionInProgress) {\n    switch (connectionInProgress) {\n        case ConnectionInProgress.Connecting:\n        case ConnectionInProgress.Reconnecting:\n        case ConnectionInProgress.ChangingLocations:\n            return true;\n    }\n    return false;\n}\n\nexport function connectionIsIdle(connectionInProgress: ConnectionInProgress, vpnStatus: VpnStatus, osVpnStatus: NEVPNStatus) {\n  return connectionInProgress === ConnectionInProgress.UNSET\n    && vpnStatus.disconnected !== undefined\n    && (\n      osVpnStatus === NEVPNStatus.Disconnected ||\n      osVpnStatus === NEVPNStatus.Invalid\n    );\n}\n"
  },
  {
    "path": "obscura-ui/src/common/common.module.css",
    "content": ".chip {\n    border-radius: 5px;\n    border-width: 1px;\n    border-style: solid;\n    border-color: var(-mantine-color-teal-1);\n}\n\n.button {\n\n    &:disabled,\n    &[data-disabled] {\n        border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));\n        background-color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));\n        color: var(--mantine-color-white)\n    }\n}\n\n@media screen and (max-width: $mantine-breakpoint-xs) {\n    .desktopOnly {\n        display: none;\n    }\n}\n\n@media screen and (min-width: $mantine-breakpoint-xs) {\n    .mobileOnly {\n        display: none !important;\n    }\n}\n\n.elevatedSurface {\n    background-color: light-dark(var(--mantine-color-body), var(--mantine-color-dark-6)) !important;\n}\n\n.svgThemed {\n    fill: light-dark(black, white);\n}\n\n.wordmark {\n    fill: light-dark(black, var(--mantine-color-gray-4));\n}\n\n.secondaryColor {\n    color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-text)) !important;\n}\n"
  },
  {
    "path": "obscura-ui/src/common/debuggingArchiveHook.tsx",
    "content": "import { Anchor, Text } from '@mantine/core';\nimport { notifications } from '@mantine/notifications';\nimport { useState } from 'react';\nimport { Trans, useTranslation } from 'react-i18next';\nimport { CommandError, debuggingArchive, revealItemInDir } from '../bridge/commands';\nimport { IS_HANDHELD_DEVICE } from '../bridge/SystemProvider';\nimport { fmtErrorI18n } from '../translations/i18n';\nimport { normalizeError } from './utils';\n\ntype ArchiveState = { inProgress: boolean, error?: Error };\n\nexport function useDebuggingArchive(): (userFeedback: string) => Promise<void> {\n    const { t } = useTranslation();\n    const [_, setArchiveState] = useState<ArchiveState>({ inProgress: false });\n\n    const startCreatingArchive = async (userFeedback: string) => {\n        setArchiveState({ inProgress: true });\n        try {\n            const path = await debuggingArchive(userFeedback);\n            if (!IS_HANDHELD_DEVICE) {\n              notifications.show({\n                  title: t('Debugging Archive Created'),\n                  message: <Text><Trans i18nKey='findDebugBundleInFinder' components={[<Anchor onClick={() => revealItemInDir(path)} />]} /></Text >\n              });\n            }\n        } catch (e) {\n          const error = normalizeError(e);\n          const message = error instanceof CommandError\n              ? fmtErrorI18n(t, error) : error.message;\n          notifications.show({\n              title: t('Debugging Archive Failed'),\n              message,\n              color: 'red'\n          });\n        }\n    }\n    return startCreatingArchive;\n}\n"
  },
  {
    "path": "obscura-ui/src/common/exitUtils.ts",
    "content": "import { ExitSelectorCity } from '../bridge/commands';\nimport { PinnedLocation } from '../common/appContext';\nimport { Exit, getContinent, getExitCountry } from './api';\n\n/** returns a string containing the country flag emoji. */\nexport function getCountryFlag(countryCode: string): string {\n  return countryCode\n      .replace(/[A-Za-z]/g, char => {\n          let codePoint = char.toUpperCase().codePointAt(0)!\n              - \"A\".codePointAt(0)!\n              + \"🇦\".codePointAt(0)!;\n          return String.fromCodePoint(codePoint)\n      });\n}\n\nexport function getExitCountryFlag(exit: Exit): string {\n  return getCountryFlag(getExitCountry(exit).iso2);\n}\n\n/** returns a sort comparator for Exit[] given some parameters */\nexport function exitsSortComparator(left: Exit, right: Exit): number {\n  const leftCountry = getExitCountry(left);\n  const rightCountry = getExitCountry(right);\n\n  const leftContinent = getContinent(leftCountry);\n  const rightContinent = getContinent(rightCountry);\n\n  const leftCountryName = leftCountry.name;\n  const rightCountryName = rightCountry.name;\n\n  return continentCmp(leftContinent, rightContinent) || leftCountryName.localeCompare(rightCountryName) || left.city_name.localeCompare(right.city_name) || left.id.localeCompare(right.id);\n}\n\nconst continentRankings = [\n    'NA',\n    'EU',\n    'SA',\n    'AS',\n    'AF',\n    'OC',\n    'AN',\n];\n\nexport function continentCmp(left: string, right: string): number {\n    return continentRankings.indexOf(left) - continentRankings.indexOf(right);\n}\n\nexport function exitLocation(exit: Exit): PinnedLocation {\n  let {city_code, country_code} = exit;\n  return {\n    city_code,\n    country_code,\n    pinned_at: 0,\n  };\n}\n\nexport function exitCityEquals(left?: ExitSelectorCity, right?: ExitSelectorCity): boolean {\n  if (left === undefined || right === undefined) return false;\n  return left.country_code === right.country_code && left.city_code === right.city_code;\n}\n"
  },
  {
    "path": "obscura-ui/src/common/fmt.ts",
    "content": "export function fmt(lits: TemplateStringsArray, ...values: unknown[]): string {\n    let out: unknown[] = [];\n    for (let i = 0; ; i++) {\n        out.push(lits[i]);\n\n        if (i >= values.length) break;\n        let v = values[i];\n\n        let type = typeof v;\n        switch (type) {\n            case \"bigint\":\n            case \"boolean\":\n            case \"number\":\n            case \"symbol\":\n            case \"undefined\":\n                out.push(v);\n                break;\n            case \"object\":\n            case \"string\":\n                if (v instanceof Error) {\n                  out.push(`${v}`);\n                } else {\n                  out.push(JSON.stringify(v));\n                }\n                break;\n            case \"function\":\n                out.push(`[function ${(v as Function).name}]`);\n                break;\n            default:\n                let _: never = type;\n                void _;\n                out.push(JSON.stringify(v));\n        }\n    }\n\n    return out.join(\"\");\n}\n\nclass InterpolatedError extends Error {\n    constructor(\n        message: string,\n        readonly values: unknown[],\n    ) {\n        super(message);\n    }\n}\n\nexport function err(lits: TemplateStringsArray, ...values: unknown[]): Error {\n    return new InterpolatedError(fmt(lits, ...values), values);\n}\n"
  },
  {
    "path": "obscura-ui/src/common/links.ts",
    "content": "export const EMAIL = 'support@obscura.net';\nexport const DISCORD_SERVER = 'https://discord.gg/xsP2Fp7s6r';\nexport const MATRIX_SERVER = 'https://matrix.to/#/!CznDYbvmUUGxsJaWuW:matrix.social.obscuravpn.io?via=matrix.social.obscuravpn.io&via=matrix.org'\nexport const TWITTER = 'https://x.com/obscuravpn';\n"
  },
  {
    "path": "obscura-ui/src/common/localStorage.ts",
    "content": "export enum LocalStorageKey {\n    CustomApiUrls = \"customApiUrls\"\n}\n\nexport function getCustomApiUrls(): string[] {\n    const customApiUrlsExist = localStorageGet(LocalStorageKey.CustomApiUrls);\n    return JSON.parse(customApiUrlsExist ?? '[]');\n}\n\nexport function setCustomApiUrls(customApiUrls: string[]): string | null {\n  return localStorageSet(LocalStorageKey.CustomApiUrls, JSON.stringify(customApiUrls));\n}\n\nexport function localStorageGet(key: LocalStorageKey): string | null {\n    return window.localStorage.getItem(key)\n}\n\nexport function localStorageSet(key: LocalStorageKey, value: string): string | null {\n    let prev = localStorageGet(key);\n    window.localStorage.setItem(key, value);\n    return prev\n}\n\nexport function localStorageRemove(key: LocalStorageKey): string | null {\n    let prev = localStorageGet(key);\n    window.localStorage.removeItem(key)\n    return prev\n}\n"
  },
  {
    "path": "obscura-ui/src/common/notifIds.ts",
    "content": "export enum NotificationId {\n    VPN_DISCONNECT_CONNECT = 'vpnDisconnectConnect',\n    VPN_ERROR = 'vpnError',\n    OPEN_AT_LOGIN = 'openAtLogin'\n}\n"
  },
  {
    "path": "obscura-ui/src/common/useAsync.ts",
    "content": "import React, { useMemo, useState } from \"react\";\n\nimport { isPromise, normalizeError } from \"./utils\";\n\nconst NEVER_LOADED = 0;\n\nexport interface UseAsyncArgs<T> {\n    load: () => Promise<T> | T;\n    deps?: React.DependencyList;\n    returnError?: boolean;\n    skip?: boolean;\n}\n\ntype RefreshCallback<T> = (value?: T, error?: unknown) => void;\n\nexport class UseAsyncState<T> {\n    value: T | undefined = undefined;\n    lastSuccessfulValue: T | undefined = undefined;\n    error: Error | undefined = undefined;\n\n    loadVersion: number = NEVER_LOADED;\n    valueVersion: number = NEVER_LOADED;\n\n    refreshToken: unknown;\n    refreshCallbacks: RefreshCallback<T>[] | undefined;\n    refresh?: () => Promise<void>;\n\n    setValue(version: number, value: T, callbacks?: RefreshCallback<T>[]): boolean {\n        for (let callback of callbacks ?? []) {\n            try {\n                callback(value);\n            } catch (cbError) {\n                console.error(\"Refresh callback failed\", cbError);\n            }\n        }\n\n        if (version < this.valueVersion) {\n            return false;\n        }\n\n        this.value = value;\n        this.lastSuccessfulValue = value;\n        this.error = undefined;\n        this.valueVersion = version;\n\n        return true;\n    }\n\n    setError(version: number, error: unknown, callbacks?: RefreshCallback<T>[]): boolean {\n        for (let callback of callbacks ?? []) {\n            try {\n                callback(undefined, error);\n            } catch (cbError) {\n                console.error(\"Refresh callback failed\", cbError);\n            }\n        }\n\n        if (version < this.loadVersion) {\n            return false;\n        }\n\n        this.value = undefined;\n        this.error = normalizeError(error);\n        this.valueVersion = version;\n\n        return true;\n    }\n}\n\nexport interface UseAsyncResult<T> {\n    /// The loaded value.\n    ///\n    /// If `error` or `!everLoaded` this will be undefined.\n    ///\n    /// Note that this is the **most recently received response** not necessarily the **most recent requested data**. This can occur if multiple versions are requesting data. If you only want to show data from the latest request only show `value` if `!loading`. If you want to always show the most recent available data just show `value` (and maybe show a refreshing indicator if `loading`).\n    value: T | undefined,\n\n    /// The value for the current `deps` array.\n    ///\n    /// This value always corresponds to the most recently requested data and is never stale. If `deps` change this will immediately switch back to `undefined`. Exactly the same as `value` if `!loading`.\n    currentValue: T | undefined,\n\n    /// The last successful value if there ever was one.\n    lastSuccessfulValue: T | undefined,\n\n    /// An error if it occurred.\n    error: Error | undefined,\n\n    /// If this component has ever loaded, successfully or otherwise.\n    ///\n    /// If true either `value` or `error` will be set (but not necessarily up to date) and the other will be undefined. Note that both will be `undefined` iff `f` successfully returned `undefined`.\n    everLoaded: boolean,\n\n    /// The latest version dispatched.\n    loadVersion: number,\n\n    /// The version that the current `value` and `error` correspond to.\n    valueVersion: number,\n\n    /// If the current values of `value` and `error` do not yet reflect the current `deps`.\n    ///\n    /// For example the render after `deps` change `value` and `error` will remain the same and `loading` will become `true`. This allows you to either hide the state value, or continue to use it at your discretion.\n    loading: boolean,\n\n    refresh: () => Promise<void>,\n}\n\nexport function useAsync<T>({\n    deps = [],\n    load,\n    returnError = false,\n    skip = false,\n}: UseAsyncArgs<T>): UseAsyncResult<T> {\n    const [state, setState] = useState({\n        inner: new UseAsyncState<T>(),\n    });\n    state.inner.refresh ??= () => {\n        return new Promise((resolve, reject) => {\n            if (!state.inner.refreshCallbacks) {\n                state.inner.refreshToken = {};\n                state.inner.refreshCallbacks = [];\n            }\n            state.inner.refreshCallbacks.push((_, error) => {\n                if (error) reject(error);\n                else resolve();\n            });\n            setState({ inner: state.inner });\n        })\n    };\n\n    useMemo(() => {\n        const callbacks = state.inner.refreshCallbacks;\n        state.inner.refreshCallbacks = undefined;\n\n        if (skip && !callbacks) {\n            return;\n        }\n\n        const version = ++state.inner.loadVersion;\n\n        try {\n            let r = load();\n\n            if (isPromise(r)) {\n                r.then(\n                    (value) => {\n                        if (state.inner.setValue(version, value, callbacks)) {\n                            setState({\n                                inner: state.inner,\n                            });\n                        }\n                    },\n                    (error) => {\n                        if (state.inner.setError(version, error, callbacks)) {\n                            setState({\n                                inner: state.inner,\n                            });\n                        }\n                    },\n                );\n            } else {\n                state.inner.setValue(version, r, callbacks);\n            }\n        } catch (error) {\n            state.inner.setError(version, error, callbacks);\n        }\n    }, [skip, state.inner.refreshToken, ...deps]);\n\n    if (state.inner.error && !returnError) {\n        throw state.inner.error;\n    }\n\n    let loading = state.inner.valueVersion < state.inner.loadVersion;\n\n    return {\n        ...state.inner,\n        currentValue: loading ? undefined : state.inner.value,\n        everLoaded: state.inner.valueVersion > NEVER_LOADED,\n        loading,\n        refresh: state.inner.refresh!,\n    };\n}\n"
  },
  {
    "path": "obscura-ui/src/common/useExitList.ts",
    "content": "import { makeWatchable, useSharedWatchable } from \"./useSharedWatchable\";\nimport { getExitList, refreshExitList } from \"../bridge/commands\";\nimport { Exit } from \"./api\";\n\nexport interface UseExitListArgs {\n  periodS: number,\n}\n\nexport interface UseExitListResult {\n  exitList?: Exit[],\n  error?: Error,\n}\n\nconst EXIT_WATCHABLE = makeWatchable(refreshExitList, getExitList);\n\nexport function useExitList({\n  periodS,\n}: UseExitListArgs): UseExitListResult {\n  let r = useSharedWatchable(EXIT_WATCHABLE, periodS);\n\n  return {\n    exitList: r.value?.value.exits,\n    error: r.error,\n  };\n}\n"
  },
  {
    "path": "obscura-ui/src/common/useLoadable.ts",
    "content": "import { useEffect, useRef } from \"react\";\nimport { useAsync, UseAsyncArgs, UseAsyncResult } from \"./useAsync\";\n\nexport interface UseLoadableArgs<T> extends UseAsyncArgs<T> {\n    periodMs: number,\n}\n\nexport function useLoadable<T>({\n    periodMs,\n    ...args\n}: UseLoadableArgs<T>): UseAsyncResult<T> {\n    let failures = useRef(0);\n\n    let r = useAsync(args);\n\n    useEffect(() => {\n        // If the dependencies change reset the backoff.\n        failures.current = 0;\n    }, [args.skip, ...args.deps ?? []]);\n\n    useEffect(() => {\n        if (r.loading) {\n            // Should only happen for the initial load.\n            return;\n        }\n\n        let delayMs: number;\n        if (r.error) {\n            failures.current += 1;\n            delayMs = Math.min(\n                Math.random() * (500 * 2 ** failures.current),\n                60 * 1000,\n            );\n        } else {\n            failures.current = 0;\n            delayMs = periodMs;\n        }\n\n        let timer = setTimeout(r.refresh, delayMs);\n        return () => clearTimeout(timer);\n    }, [r.valueVersion])\n\n    return {\n        ...r,\n        refresh: () => {\n            failures.current = 0;\n            return r.refresh();\n        },\n    };\n}\n"
  },
  {
    "path": "obscura-ui/src/common/useMailto.ts",
    "content": "import { useTranslation } from 'react-i18next';\nimport { OsStatus } from './appContext';\nimport { EMAIL } from './links';\nimport { percentEncodeQuery } from './utils';\nimport { systemName } from '../bridge/SystemProvider';\n\n// this component may be used before appContext is created, and thus requires explicitly passing osStatus\nexport default function useMailto(osStatus: OsStatus) {\n  const { t } = useTranslation();\n\n  // \\r is important to ensure email clients do not trim newlines\n  const params = {\n    subject: t('emailSubject', { platform: systemName(), version: osStatus.srcVersion }),\n    body: t('emailBodyIntro') + ':\\n\\n\\r'\n  };\n  const queryString = percentEncodeQuery(params);\n  const mailto = `mailto:${EMAIL}?${queryString}`;\n  return mailto\n}\n"
  },
  {
    "path": "obscura-ui/src/common/useSharedWatchable.ts",
    "content": "import { useEffect, useState } from \"react\";\nimport { normalizeError, sleep } from \"./utils\";\n\ninterface Versioned {\n  version: unknown\n}\n\ninterface SharedWatchable<T extends Versioned> {\n  error: Error | undefined;\n  value: T | undefined;\n\n  load: (freshnessS: number) => Promise<void>;\n  watch: (version?: T[\"version\"]) => Promise<T>;\n\n  isWatcherRunning: boolean;\n  subscribers: Set<(v: UseWatchableResult<T>) => void>;\n}\n\nexport function makeWatchable<T extends Versioned>(\n  load: (freshnessS: number) => Promise<void>,\n  watch: (version?: T[\"version\"]) => Promise<T>,\n): SharedWatchable<T> {\n  return {\n    error: undefined,\n    value: undefined,\n\n    load,\n    watch,\n\n    isWatcherRunning: false,\n    subscribers: new Set,\n  };\n}\n\nexport interface UseWatchableResult<T> {\n  error?: Error,\n  value?: T,\n}\n\nexport function useSharedWatchable<T extends Versioned>(\n  shared: SharedWatchable<T>,\n  periodS: number,\n): UseWatchableResult<T> {\n  let [state, setState] = useState<UseWatchableResult<T>>({\n    value: shared.value,\n    error: shared.error,\n  });\n\n  useEffect(() => {\n    let timeout: ReturnType<typeof setTimeout> | undefined;\n    void (async function doLoad() {\n      timeout = undefined;\n      try {\n        await shared.load(periodS);\n      } catch (error) {\n        shared.error = normalizeError(error);\n        let r = {\n          value: shared.value,\n          error: shared.error,\n        };\n        for (let watcher of shared.subscribers) {\n          watcher(r);\n        }\n      } finally {\n        timeout = setTimeout(doLoad, periodS*1000);\n      }\n    })();\n\n    shared.subscribers.add(setState);\n\n    if (!shared.isWatcherRunning) {\n      shared.isWatcherRunning = true;\n      void (async () => {\n        while (shared.subscribers.size > 0) {\n          try {\n            let newValue = await shared.watch(shared.value?.version);\n            shared.value = newValue;\n            let r = {\n              value: newValue,\n              error: undefined,\n            };\n            for (let watcher of shared.subscribers) {\n              watcher(r);\n            }\n          } catch (error) {\n            console.error(\"Failure watching value:\", error);\n            // TODO: Should we report this in some way?\n            await sleep(1000);\n          }\n        }\n        shared.isWatcherRunning = false;\n      })();\n    }\n\n    return () => {\n      shared.subscribers.delete(setState);\n      if (timeout) {\n        clearTimeout(timeout);\n      }\n    }\n  }, []);\n\n  return state;\n}\n"
  },
  {
    "path": "obscura-ui/src/common/utils.ts",
    "content": "import { useMantineTheme } from '@mantine/core';\nimport Cookies from 'js-cookie';\nimport localforage from 'localforage';\nimport { Dispatch, ForwardedRef, RefCallback, SetStateAction, useEffect, useLayoutEffect, useState } from 'react';\nimport { fmt } from './fmt';\nexport { localforage };\n\nexport const HEADER_TITLE = 'Obscura VPN';\nexport const IS_DEVELOPMENT = import.meta.env.MODE === 'development';\nexport const MIN_LOAD_MS = 400;\n\nexport function useCookie(key: string, defaultValue: string, options: Cookies.CookieAttributes = { expires: 365000, sameSite: 'lax', path: '/' }): [string, Dispatch<SetStateAction<string>>] {\n    // cookie expires in a millenia\n    // sameSite != 'strict' because the cookie is not read for sensitive actions\n    // synchronous\n    const cookieValue = Cookies.get(key);\n    const [state, setState] = useState(cookieValue || defaultValue);\n    useEffect(() => {\n        Cookies.set(key, state, options);\n    }, [state]);\n    return [state, setState];\n}\n\n// show browser / native notification\nexport function notify(title: string, body?: string) {\n    new Notification(title, { body: body || \"\", });\n}\n\nexport function sleep(ms: number) {\n    return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nexport function downloadFile(filename: string, content: BlobPart, contentType = 'text/plain') {\n    const element = document.createElement('a');\n    const file = new Blob([content], { type: contentType });\n    element.href = URL.createObjectURL(file);\n    element.download = filename;\n    document.body.appendChild(element); // Required for this to work in FireFox\n    element.click();\n}\n\nexport function isPromise(v: unknown): v is PromiseLike<unknown> {\n    return !!v && (typeof v == \"object\" || typeof v == \"function\") && \"then\" in v;\n}\n\nexport function arraysEqual<T>(a: T[], b: T[]) {\n    if (a === b) return true;\n    if (a == null || b == null) return false;\n    if (a.length !== b.length) return false;\n\n    // If you don't care about the order of the elements inside\n    // the array, you should sort both arrays here.\n    // Please note that calling sort on an array will modify that array.\n    // you might want to clone your array first.\n\n    for (var i = 0; i < a.length; ++i) {\n        if (a[i] !== b[i]) return false;\n    }\n    return true;\n}\n\n// https://reactjs.org/docs/hooks-custom.html\nexport function useLocalForage<T>(key: string, defaultValue: T) {\n    // only supports primitives, arrays, and {} objects\n    const [state, setState] = useState(defaultValue);\n    const [loading, setLoading] = useState(true);\n\n    // useLayoutEffect will be called before DOM paintings and before useEffect\n    useLayoutEffect(() => {\n        let allow = true;\n        localforage.getItem(key)\n            .then(value => {\n                if (value === null) throw '';\n                if (allow) setState(value as T);\n            }).catch(() => localforage.setItem(key, defaultValue))\n            .then(() => {\n                if (allow) setLoading(false);\n            });\n        return () => { allow = false; }\n    }, []);\n    // useLayoutEffect does not like Promise return values.\n    useEffect(() => {\n        // do not allow setState to be called before data has even been loaded!\n        // this prevents overwriting\n        if (!loading) localforage.setItem(key, state);\n    }, [state]);\n    return [state, setState, loading];\n}\n\n/**\n * A hack to get the latest state value to be used in long running tasks\n * This function should not be made use of liberally\n * @param {A} setter the setState method of the state you want the latest value of\n * @returns the state which was passed to the setter's action\n */\nexport function getLatestState<S>(setter: Dispatch<SetStateAction<S>>) {\n    let v;\n    setter(value => {\n        v = value;\n        return value;\n    });\n    return v;\n}\n\nexport function percentEncodeQuery(params: Record<string, string>) {\n    return Object.entries(params)\n        .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)\n        .join('&');\n}\n\nconst DEFAULT_ERROR_MSG = \"An unexpected error has occurred.\";\n\nexport function errMsg(error: unknown): string {\n    if (error instanceof Error) {\n        return error.message;\n    }\n    console.warn(fmt`errMsg: error = ${error} is not an instance of Error`);\n    return DEFAULT_ERROR_MSG;\n}\n\nexport function normalizeError(error: unknown): Error {\n    if (error instanceof Error) {\n        return error;\n    }\n    console.warn(fmt`normalizeError: error = ${error} is not an instance of Error`);\n    return new Error(DEFAULT_ERROR_MSG, {\n        cause: error,\n    });\n}\n\nexport function multiRef<T>(...refs: ForwardedRef<T>[]): RefCallback<T> {\n  return value => {\n    return refs.forEach((ref) => {\n      if (ref !== null) {\n        if (typeof ref === 'function') {\n          ref(value);\n        } else {\n          ref.current = value\n        }\n      }\n    });\n  };\n}\n\nexport function randomChoice<T>(arr: T[]): T {\n  if (arr.length === 0) throw new Error('array length cannot be zero');\n  const randIdx = Math.floor(Math.random() * arr.length);\n  return arr[randIdx]!;\n}\n\n/**\n * Returns hh:mm:ss from ms\n * @param ms milliseconds\n */\nexport function fmtTime(ms: number) {\n  const totalSeconds = Math.floor(ms / 1000);\n  const seconds = totalSeconds % 60;\n  const minutes = Math.floor((totalSeconds % 3600) / 60);\n  const hours = Math.floor(totalSeconds / 3600);\n  return `${zeroPad(hours, 2)}:${zeroPad(minutes, 2)}:${zeroPad(seconds, 2)}`;\n}\n\nfunction zeroPad(num: number, width: number) {\n  return num.toString().padStart(width, '0');\n}\n\nexport function usePrimaryColorResolved() {\n  const theme = useMantineTheme();\n  return theme.variantColorResolver({color: theme.primaryColor, theme, variant: 'subtle'}).color;\n}\n\n// https://claritydev.net/blog/diacritic-insensitive-string-comparison-javascript\nexport function normalizeString(str: string): string {\n  return str\n  // canonical decomposition\n  .normalize('NFD')\n  // remove diacritic marks from string\n  .replace(/[\\u0300-\\u036f]/g, '')\n  .toLowerCase();\n};\n\n// case-insensitive and diacritic-insensitive search\nexport function normalizedIncludes(needle: string, haystack: string) {\n  return normalizeString(haystack).includes(normalizeString(needle));\n}\n"
  },
  {
    "path": "obscura-ui/src/components/AccountNumberSection.tsx",
    "content": "import { ActionIcon, Button, CopyButton, Group, Stack, Text, useMantineTheme } from '@mantine/core';\nimport { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { FaCopy } from 'react-icons/fa6';\nimport { IoCopy, IoLogOutOutline } from 'react-icons/io5';\nimport * as ObscuraAccount from '../common/accountUtils';\nimport commonClasses from '../common/common.module.css';\nimport { usePrimaryColorResolved } from '../common/utils';\nimport Eye from '../res/eye.fill.svg?react';\nimport EyeSlash from '../res/eye.slash.fill.svg?react';\nimport PersonBadgeKey from '../res/person.badge.key.svg?react';\n\nexport function AccountNumberSection({ accountId, logOut }: { accountId: ObscuraAccount.AccountId, logOut: () => void }) {\n  const { t } = useTranslation();\n  const theme = useMantineTheme();\n  const primaryColorResolved = usePrimaryColorResolved();\n  const [showAccountNumber, setShowAccountNumber] = useState(false);\n  const EyeIcon = showAccountNumber ? EyeSlash : Eye;\n\n  return (\n    <Stack align='start' w='100%' p='md' gap={0} style={{ borderRadius: theme.radius.md, boxShadow: theme.shadows.sm }} className={commonClasses.elevatedSurface}>\n      <Group w='100%' justify='space-between'>\n        <Group mb='xs' gap={5}>\n          <PersonBadgeKey width='1em' height='1em' className={commonClasses.svgThemed} />\n          <Text fw={500}>Account Number</Text>\n          <div className={commonClasses.desktopOnly}>\n            <ActionIcon variant='subtle' title={showAccountNumber ? t('hide account number') : t('show account number')} onClick={() => setShowAccountNumber(!showAccountNumber)}>\n              {<EyeIcon fill={primaryColorResolved} width='1em' height='1em' />}\n            </ActionIcon>\n            <CopyButton value={ObscuraAccount.accountIdToString(accountId)}>\n              {({ copied, copy }) => (\n                <ActionIcon c={copied ? 'green' : undefined} variant='subtle' title={t('copy account number')} onClick={copy}>\n                  <IoCopy size='1em' />\n                </ActionIcon>\n              )}\n            </CopyButton>\n          </div>\n        </Group>\n        <div className={commonClasses.desktopOnly}>\n          <Button fw='bolder' onClick={logOut} color='red.7' variant='subtle'>\n            <Group gap={5}>\n              <IoLogOutOutline size={19} />\n              <Text fw={550}>{t('logOut')}</Text>\n            </Group>\n          </Button>\n        </div>\n      </Group>\n      <Text ff='monospace'>\n        {showAccountNumber\n          ? ObscuraAccount.formatPartialAccountId(ObscuraAccount.accountIdToString(accountId))\n          : 'XXXX - XXXX - XXXX - XXXX - XXXX'}\n      </Text>\n      <Group className={commonClasses.mobileOnly} w='100%' mt='xs' grow>\n        <CopyButton value={ObscuraAccount.accountIdToString(accountId)}>\n          {({ copied, copy }) => (\n            <Button c={copied ? 'white' : undefined} bg={copied ? 'teal' : undefined} variant='light' title={t('copy account number')} onClick={copy}>\n              <Group gap='xs'>\n                <FaCopy />\n                {t('Copy')}\n              </Group>\n            </Button>\n          )}\n        </CopyButton>\n        <Button variant='light' title={showAccountNumber ? t('hide account number') : t('show account number')} onClick={() => setShowAccountNumber(!showAccountNumber)}>\n          <Group gap='xs' justify='center'>\n            <EyeIcon fill={primaryColorResolved} width='1em' height='1em' />\n            <Text miw='5ch'>{showAccountNumber ? t('Hide') : t('Reveal')}</Text>\n          </Group>\n        </Button>\n      </Group>\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "obscura-ui/src/components/AnimatedChevron.tsx",
    "content": "import { BsChevronDown } from 'react-icons/bs';\n\nexport default function AnimatedChevron({ rotated }: { rotated: Boolean }) {\n    return (\n        <BsChevronDown\n            size={16}\n            style={{\n                transform: rotated ? 'rotate(-180deg)' : undefined,\n                transition: 'transform 200ms ease-in-out'\n            }}\n        />\n    );\n}\n"
  },
  {
    "path": "obscura-ui/src/components/BoltBadgeAuto.tsx",
    "content": "import SvgFile from '../res/bolt.badge.automatic.fill.svg?react';\n\nexport default function BoltBadgeAuto({ height = '1.25em', fill = 'white' }) {\n    return <SvgFile fill={fill} height={height} />\n}\n"
  },
  {
    "path": "obscura-ui/src/components/ButtonLink.tsx",
    "content": "import { Button, ButtonProps } from '@mantine/core';\nimport { PropsWithChildren } from 'react';\nimport ExternalLinkIcon from './ExternalLinkIcon';\n\ninterface ButtonLinkProps extends PropsWithChildren {\n  href: string,\n  inline?: boolean,\n  size?: ButtonProps['size'],\n  onClick?: React.MouseEventHandler,\n  variant?: ButtonProps['variant'],\n}\n\nexport function ButtonLink({ children, href, onClick, variant, inline = false, size }: ButtonLinkProps) {\n  return (\n    <Button\n      component='a'\n      size={size}\n      onClick={onClick}\n      variant={variant}\n      w={inline ? 'auto' : { base: '100%', xs: 'auto' }}\n      display={inline ? 'inline-block' : undefined}\n      href={href}\n      target='_blank'\n    >\n      <span>{children} <ExternalLinkIcon size={11} /></span>\n    </Button>\n  );\n}\n"
  },
  {
    "path": "obscura-ui/src/components/CachedColorScheme.tsx",
    "content": "import { useComputedColorScheme } from '@mantine/core';\nimport { createContext, PropsWithChildren } from 'react';\n\n// useComputerColorScheme does not return instantly, it first returns the default value\n//  and then later returns the true value.\n// This causes flashes since the chance from light (default) to dark is noticeable\n// To avoid a flash, we can cache the value at a location of the tree where we know\n// useComputerColorScheme would be too slow\n\nexport const CColorSchemeContext = createContext('light' as 'light' | 'dark');\n\nexport default function CachedColorScheme({ children }: PropsWithChildren) {\n  const colorScheme = useComputedColorScheme();\n  return (\n    <CColorSchemeContext.Provider value={colorScheme}>\n      {children}\n    </CColorSchemeContext.Provider>\n  );\n}\n"
  },
  {
    "path": "obscura-ui/src/components/ConfirmationDialog.module.css",
    "content": ".drawerContent {\n    display: flex;\n    flex-direction: column;\n    height: min-content !important;\n    border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0 !important;\n    padding-bottom: env(safe-area-inset-bottom);\n}\n\n.drawerBody {\n    flex-grow: 1;\n}\n"
  },
  {
    "path": "obscura-ui/src/components/ConfirmationDialog.tsx",
    "content": "import { Drawer, DrawerProps, MantineSize, Modal } from '@mantine/core';\nimport { PropsWithChildren } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { IS_HANDHELD_DEVICE } from '../bridge/SystemProvider';\nimport classes from './ConfirmationDialog.module.css';\n\ninterface ConfirmationDialogProps extends PropsWithChildren {\n  opened: boolean;\n  onClose: () => void;\n  drawerSize?: MantineSize | (string & {}) | number;\n  title?: string;\n  drawerCloseButton?: boolean;\n  closeOnClickOutside?: boolean;\n  closeOnEscape?: boolean;\n  withCloseButton?: boolean;\n}\n\nexport function ConfirmationDialog({ opened, onClose, drawerSize = 'xs', title, children, drawerCloseButton, closeOnClickOutside, closeOnEscape, withCloseButton }: ConfirmationDialogProps) {\n  const { t } = useTranslation();\n  return (\n    IS_HANDHELD_DEVICE ?\n      <MobileDrawer\n        size={drawerSize}\n        opened={opened}\n        onClose={onClose}\n        title={title ?? t('Confirmation')}\n        withCloseButton={withCloseButton ?? drawerCloseButton}\n        closeOnClickOutside={closeOnClickOutside}\n        closeOnEscape={closeOnEscape}\n      >\n        {children}\n      </MobileDrawer> :\n      <Modal\n        opened={opened}\n        onClose={onClose}\n        title={title ?? t('Confirmation')}\n        centered\n        withCloseButton={withCloseButton}\n        closeOnClickOutside={closeOnClickOutside}\n        closeOnEscape={closeOnEscape}\n      >\n        {children}\n      </Modal>\n  );\n}\n\ntype MobileDrawerProps = Omit<DrawerProps, 'classNames' | 'styles' | 'position'>;\n\nexport function MobileDrawer({ size, title, opened, onClose, children, withCloseButton, ...others }: MobileDrawerProps) {\n  return (\n    <Drawer classNames={{ content: classes.drawerContent, body: classes.drawerBody }} size={size} position='bottom' opened={opened} onClose={onClose} title={title} withCloseButton={withCloseButton} {...others}>\n      {children}\n    </Drawer>\n  );\n}\n"
  },
  {
    "path": "obscura-ui/src/components/DebuggingArchive.module.css",
    "content": ".card {\n    background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));\n}\n\n.havingTroubleTitle {\n    color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-3));\n}\n\n.debugLabel {\n    background-color: light-dark(white, var(--mantine-color-dark-8));\n    position: absolute;\n    bottom: 16px;\n    right: 16px;\n    max-width: 155px;\n}\n\n.debugLabelHandheld {\n    @media (orientation: portrait) {\n        position: relative;\n        right: auto;\n        top: 50px;\n        bottom: auto;\n        max-width: 170px;\n        text-align: center;\n    }\n\n    @media (orientation: landscape) {\n        top: 16px;\n        right: 16px;\n        bottom: auto;\n    }\n}\n"
  },
  {
    "path": "obscura-ui/src/components/DebuggingArchive.tsx",
    "content": "import { Anchor, Button, Card, Group, Loader, Stack, Text, Textarea, Title } from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport { useState } from 'react';\nimport { Trans, useTranslation } from 'react-i18next';\nimport { IoIosMail, IoIosShare } from 'react-icons/io';\nimport * as commands from '../bridge/commands';\nimport { emailDebugArchive, revealItemInDir, shareDebugArchive } from '../bridge/commands';\nimport { IS_HANDHELD_DEVICE, systemName } from '../bridge/SystemProvider';\nimport { NEVPNStatus, OsStatus } from '../common/appContext';\nimport { useDebuggingArchive } from '../common/debuggingArchiveHook';\nimport { EMAIL } from '../common/links';\nimport useMailto from '../common/useMailto';\nimport { ConfirmationDialog } from './ConfirmationDialog';\nimport classes from './DebuggingArchive.module.css';\nimport { fmtErrorI18n } from '../translations/i18n';\nimport { normalizeError } from '../common/utils';\nimport { notifications } from '@mantine/notifications';\n\nconst ICON_SIZE = 20;\n\nexport enum DebuggingArchiveVariant {\n  Card = 'card',\n  LoginLabel = 'label'\n}\n\n// this component may be used before appContext is created, and thus requires explicitly passing osStatus\nexport default function DebuggingArchive({ osStatus, variant = DebuggingArchiveVariant.Card }: { osStatus: OsStatus, variant?: DebuggingArchiveVariant }) {\n  const { t } = useTranslation();\n  const createDebuggingArchive = useDebuggingArchive();\n  const [opened, { open, close }] = useDisclosure(false);\n  const { execute: disconnect } = commands.useCommand({ command: commands.disconnect, showNotification: false, rethrow: true });\n  const [disconnectInProgress, setDisableButtons] = useState(false);\n  const [userFeedback, setUserFeedback] = useState('');\n\n  const onContinue = () => {\n    setDisableButtons(false);\n    void createDebuggingArchive(userFeedback);\n    // For Label variant, keep modal open to show status\n    if (variant !== DebuggingArchiveVariant.LoginLabel) {\n      close();\n      setUserFeedback('');\n    }\n  }\n\n  const loadingSpinner = !!osStatus.debugBundleStatus.inProgress &&\n    <Group gap='sm' justify='center'><Text>{t('createDebugArchiveInProgress')}</Text><Loader size={ICON_SIZE} /></Group>;\n  const archiveAvailable = !osStatus.debugBundleStatus.inProgress && osStatus.debugBundleStatus.latestPath !== null;\n  const showStatus = osStatus.debugBundleStatus.inProgress || osStatus.debugBundleStatus.latestPath !== null;\n\n  const modal = (\n    <ConfirmationDialog title={t('Debugging Archive')} opened={opened} onClose={close}>\n      <Stack h='100%' justify='space-between' gap='xs'>\n        {\n          osStatus.osVpnStatus !== NEVPNStatus.Disconnected\n          && <>\n            <Text>{t('debugArchiveDisconnectPrompt')}</Text>\n          </>\n        }\n        <Textarea\n          data-autofocus\n          label={t('debugArchiveFeedbackLabel')}\n          placeholder={t('debugArchiveFeedbackPrompt')}\n          value={userFeedback}\n          onChange={(event) => setUserFeedback(event.currentTarget.value)}\n          minRows={3}\n          maxRows={6}\n        />\n        <Group w='100%' grow>\n          <Button disabled={disconnectInProgress || !!loadingSpinner} miw={130} onClick={onContinue} variant='light'>{\n            osStatus.osVpnStatus === NEVPNStatus.Disconnected ?\n              t('Continue') : t('Stay Connected')\n          }</Button>\n          {\n            osStatus.osVpnStatus !== NEVPNStatus.Disconnected &&\n            <Button disabled={disconnectInProgress} miw={130} onClick={async () => {\n              setDisableButtons(true);\n              try {\n                await disconnect();\n                let knownOsStatusId = osStatus.version;\n                const startTime = Date.now();\n                while (true) {\n                  if (Date.now() - startTime >= 60_000) {\n                    throw new Error(t('error-timeoutDisconnect'));\n                  }\n                  const newOsStatus = await commands.osStatus(knownOsStatusId);\n                  knownOsStatusId = newOsStatus.version;\n                  if (newOsStatus.osVpnStatus === NEVPNStatus.Disconnected) break;\n                }\n                onContinue();\n              } catch (err) {\n                console.error('failed to disconnect before creating debugging archive');\n                const error = normalizeError(err);\n                const message = error instanceof commands.CommandError\n                  ? fmtErrorI18n(t, error) : error.message;\n                notifications.show({\n                  color: 'red',\n                  title: t('Error'),\n                  message\n                });\n                setDisableButtons(false);\n              }\n            }}>{disconnectInProgress ? <Loader size={ICON_SIZE} /> : t('Disconnect')}</Button>\n          }\n        </Group>\n        {variant === DebuggingArchiveVariant.LoginLabel && showStatus && (\n          <>\n            {loadingSpinner ||\n              <Stack gap='sm'>\n                <SupportMessage osStatus={osStatus} size='sm' color='dimmed' />\n                <ArchiveActionButtons osStatus={osStatus} />\n              </Stack>\n            }\n          </>\n        )}\n      </Stack>\n    </ConfirmationDialog>\n  );\n\n  if (variant === DebuggingArchiveVariant.LoginLabel) {\n    /**\n     * on hand held, the decoration is always at the bottom, even in landscape\n     * we want the label just above the decoration in portrait, and at the top in landscape\n     * When the keyboard is shown, there isn't enough space for a label, so use a help icon instead\n     */\n    if (IS_HANDHELD_DEVICE) {\n      return (\n        <>\n          {modal}\n          <Text className={`${classes.debugLabel} ${classes.debugLabelHandheld}`} p='xs' size='xs' c='dimmed'>\n            <Trans i18nKey='experiencingIssues' components={[<wbr />, <Anchor component='button' type='button' c='orange' onClick={open} style={{ cursor: 'pointer' }} />]} />\n          </Text>\n        </>\n      );\n    }\n\n    return (\n      <>\n        {modal}\n        <Text className={classes.debugLabel} p='xs' size='xs' c='dimmed'>\n          <Trans i18nKey='experiencingIssues' components={[<wbr />, <Anchor component='button' type='button' c='orange' onClick={open} style={{ cursor: 'pointer' }} />]} />\n        </Text>\n      </>\n    );\n  }\n\n  const createArchiveBtn = (\n    <Button onClick={open} disabled={disconnectInProgress || !!osStatus.debugBundleStatus.inProgress} fullWidth={IS_HANDHELD_DEVICE}>\n      {t('createDebugArchive')}\n    </Button>\n  );\n  if (IS_HANDHELD_DEVICE) {\n    return (\n      <>\n        {modal}\n        <Card withBorder radius='lg' p='lg' className={classes.card}>\n          <Stack gap='md' align='center'>\n            <Title order={4} className={classes.havingTroubleTitle}>\n              {t('havingTrouble')}\n            </Title>\n            <SupportMessage osStatus={osStatus} color='gray' />\n            {createArchiveBtn}\n            {loadingSpinner}\n            {archiveAvailable && <Stack gap='sm' w='100%'><ArchiveActionButtons osStatus={osStatus} inProgress={!!osStatus.debugBundleStatus.inProgress} /></Stack>}\n          </Stack>\n        </Card>\n      </>\n    );\n  } else {\n    return (\n      <>\n        {modal}\n        <Group>\n          {createArchiveBtn}\n          {loadingSpinner}\n          {archiveAvailable && <ArchiveActionButtons osStatus={osStatus} inProgress={!!osStatus.debugBundleStatus.inProgress} />}\n        </Group>\n      </>\n    );\n  }\n}\n\ninterface SupportMessageProps {\n  osStatus: OsStatus;\n  size?: 'sm';\n  color?: string;\n}\n\nfunction SupportMessage({ osStatus, size, color }: SupportMessageProps) {\n  const mailto = useMailto(osStatus);\n  return (\n    <Text size={size} c={color} ta='center' component={size ? undefined : 'span'}>\n      <Trans i18nKey='supportMsgOrDebugArchive' values={{ email: EMAIL }} components={[<Anchor href={mailto} />]} />\n    </Text>\n  );\n};\n\ninterface ArchiveActionButtonsProps {\n  osStatus: OsStatus;\n  inProgress?: boolean;\n}\n\nfunction ArchiveActionButtons({ osStatus, inProgress = false }: ArchiveActionButtonsProps) {\n  const { t } = useTranslation();\n\n  if (IS_HANDHELD_DEVICE) {\n    return (\n      <>\n        <Button variant='light' onClick={() => shareDebugArchive(osStatus.debugBundleStatus.latestPath!)} data-disabled={inProgress} leftSection={<IoIosShare size={ICON_SIZE} />}>\n          {t('shareLatestDebugArchive')}\n        </Button>\n        <Button variant='light' onClick={() => emailDebugArchive(osStatus.debugBundleStatus.latestPath!, t('emailSubject', { platform: systemName(), version: osStatus.srcVersion }), t('emailBodyIntro'))} disabled={inProgress || !osStatus.canSendMail} leftSection={<IoIosMail size={ICON_SIZE} />}>\n          {t('emailLatestDebugArchive')}\n        </Button>\n        {!osStatus.canSendMail && <Text c='red.7' fw={500} size='sm' ta='center'>{t('emailServiceUnavailable')}</Text>}\n      </>\n    );\n  } else {\n    return (\n      <Button variant='light' onClick={() => revealItemInDir(osStatus.debugBundleStatus.latestPath!)} disabled={inProgress}>\n        {t('viewLatestDebugArchive')}\n      </Button>\n    );\n  }\n};\n"
  },
  {
    "path": "obscura-ui/src/components/DevSendCommand.tsx",
    "content": "import { Button, JsonInput, Title } from '@mantine/core';\nimport { useRef, useState } from 'react';\nimport { jsonFfiCmd } from \"../bridge/commands\";\n\nexport default function DevSendCommand() {\n    let [output, setOutput] = useState(\"\");\n    let inputRef = useRef<HTMLTextAreaElement>(null);\n\n    return <>\n        <Title order={4}>Send Command</Title>\n        <form\n            style={{ display: \"grid\" }}\n            onKeyDown={e => {\n                if (e.key === \"Enter\" && (e.ctrlKey || e.metaKey)) {\n                    e.preventDefault();\n                    e.currentTarget.requestSubmit();\n                }\n            }}\n            onSubmit={e => {\n                setOutput(\"Running...\");\n                e.preventDefault();\n                (async () => {\n                    try {\n                        let cmd = JSON.parse(inputRef.current!.value);\n                        let pairs = Object.entries(cmd);\n                        if (pairs.length !== 1) {\n                            throw new Error(\"Command must have one top-level property.\");\n                        }\n                        const [name, args] = pairs[0]!;\n                        let r = await jsonFfiCmd(name, args as {});\n                        setOutput(JSON.stringify(r, null, \"\\t\"));\n                    } catch (e) {\n                        setOutput(`${e}`);\n                    }\n                })()\n            }}\n        >\n            <JsonInput\n                ref={inputRef}\n                defaultValue={`{\"getStatus\": {}}`}\n                autosize\n                spellCheck={false} // Disable macOS pretty quotes.\n            />\n            <Button type=\"submit\">Run</Button>\n            {output && <textarea value={output} disabled rows={output.split(\"\\n\").length} />}\n        </form>\n    </>\n}\n"
  },
  {
    "path": "obscura-ui/src/components/DevSetApiUrl.tsx",
    "content": "import { Code, Stack, Title } from '@mantine/core';\nimport { useContext, useState } from 'react';\nimport { jsonFfiCmd, setApiUrl } from \"../bridge/commands\";\nimport { AppContext } from '../common/appContext';\nimport { getCustomApiUrls, setCustomApiUrls } from '../common/localStorage';\nimport { Choice, SelectCreatable } from './SelectCreatable';\n\nconst defaultApiUrls = new Set(['https://v1.api.prod.obscura.net/api', 'https://v1.api.staging.obscura.net/api', 'http://localhost:8080/api', '']);\n\nexport default function DevSetApiUrl() {\n  let [output, setOutput] = useState('');\n  let apiUrls = [...defaultApiUrls.values()];\n  const customApiUrls = new Set(getCustomApiUrls());\n\n  for (const customApiUrl of customApiUrls) {\n    if (!defaultApiUrls.has(customApiUrl) && !apiUrls.includes(customApiUrl)) {\n      apiUrls.push(customApiUrl);\n    }\n  }\n\n  const initialApiUrlOptions: Choice[] = apiUrls.map(value => ({ text: value === '' ? 'null' : value, value }));\n  let [apiUrlOptions, setApiUrlOptions] = useState(initialApiUrlOptions);\n  const { appStatus } = useContext(AppContext);\n\n  const onSubmit = (url: string | null) => {\n    setOutput('');\n    (async () => {\n      try {\n        if (url === '') {\n          url = null;\n        }\n        // add new urls to custom api urls\n        if (url !== null && !defaultApiUrls.has(url) && !customApiUrls.has(url)) {\n          setCustomApiUrls([url, ...customApiUrls]);\n          setApiUrlOptions([{ value: url, text: url }, ...apiUrlOptions]);\n        }\n        await setApiUrl(url);\n      } catch (e) {\n        setOutput(`${e}`);\n      }\n    })()\n  }\n\n  return <>\n    <Title order={4}>Set Backend URL</Title>\n    <Stack gap={0}>\n      <SelectCreatable defaultValue={appStatus.apiUrl} choices={apiUrlOptions} onSubmit={onSubmit} inputBaseProps={{ type: 'url' }} />\n      {output && <Code block c='red.6' style={{ whiteSpace: 'pre-wrap' }}>{output}</Code>}\n    </Stack>\n  </>;\n}\n"
  },
  {
    "path": "obscura-ui/src/components/ExternalLinkIcon.tsx",
    "content": "export { FaArrowUpRightFromSquare as default } from 'react-icons/fa6';\n"
  },
  {
    "path": "obscura-ui/src/components/Licenses.tsx",
    "content": "import { Accordion, Anchor, Code, List, Stack, Title } from '@mantine/core';\nimport React from 'react';\nimport { useTranslation } from 'react-i18next';\n// import resolved by vite. See vite.config.js\n// @ts-expect-error\nimport licensesJson from '$licenses.json';\n\nconst licenses = licensesJson as Licenses;\n// Generated by ../../../contrib/licenses.mjs\ninterface Licenses {\n    licenses: License[],\n    overview: LicenseSet[],\n}\n\ninterface LicenseCommon {\n    id: string,\n    name: string,\n}\n\ninterface License extends LicenseCommon {\n    text: string,\n    used_by: UsedBy[],\n}\n\ninterface LicenseSet extends LicenseCommon {\n    count: number,\n}\n\ninterface UsedBy {\n    name: string,\n    url: string,\n    version: string,\n}\n\nexport default function Licenses(): React.ReactNode {\n    const { t } = useTranslation();\n    return (\n        <>\n            <Title order={4}>{t('licensesOverview')}</Title>\n            <List>\n                {licenses.overview.map(license => (\n                    <List.Item key={license.id}> {license.name} ({license.count})</List.Item>\n                ))}\n            </List>\n            <Title order={4}>{t('fullLicenseTexts')}</Title>\n            <Accordion variant='separated'>\n                {licenses.licenses.map((license, i) => (\n                    <Accordion.Item key={i} value={`${i}`}>\n                        <Accordion.Control id={license.id}>{license.name} - <i>{license.used_by.map(u => u.name).join(', ')}</i></Accordion.Control>\n                        <Accordion.Panel>\n                            <Stack gap='xs'>\n                                <Title order={4}>{t('Packages')}</Title>\n                                <List>{license.used_by.map((pkg, i) => {\n                                    return <List.Item key={i}>\n                                        <Anchor href={pkg.url}>\n                                            {pkg.name} {pkg.version}\n                                        </Anchor>\n                                    </List.Item>;\n                                })}</List>\n                                <Code block style={{ whiteSpace: 'pre-wrap' }}>{license.text}</Code>\n                            </Stack>\n                        </Accordion.Panel>\n                    </Accordion.Item>\n                ))}\n            </Accordion>\n        </>\n    );\n}\n"
  },
  {
    "path": "obscura-ui/src/components/Mantine.tsx",
    "content": "// boilerplate components\n// core styles are required for all packages\nimport '@mantine/core/styles.css';\nimport '@mantine/notifications/styles.css';\n// other css files are required only if\n// you are using components from the corresponding package\n// import '@mantine/dates/styles.css';\n// import '@mantine/dropzone/styles.css';\n// import '@mantine/code-highlight/styles.css';\nimport { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core';\nimport { ModalsProvider } from '@mantine/modals';\nimport { Notifications } from '@mantine/notifications';\nimport { PropsWithChildren } from 'react';\nimport { IS_HANDHELD_DEVICE } from '../bridge/SystemProvider';\nimport CachedColorScheme from './CachedColorScheme';\n\nexport default function Mantine({ children }: PropsWithChildren) {\n    // override theme for Mantine (default props and styles)\n    // https://mantine.dev/theming/mantine-provider/\n    const theme = createTheme({\n        fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI Variable Text, Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji',\n        fontFamilyMonospace: 'source-code-pro, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace',\n        // for each component's mantine docs, \"Styles API\" contains the inner elements that are available to style\n        components: {\n            Checkbox: { styles: { input: { cursor: 'pointer' }, label: { cursor: 'pointer' } } },\n            TextInput: { styles: { label: { marginTop: '0.5rem' } } },\n            Select: { styles: { label: { marginTop: '0.5rem' } } },\n            Loader: { defaultProps: { size: 'xl' } },\n            Space: { defaultProps: { h: 'sm' } },\n            Anchor: { defaultProps: { target: '_blank' } },\n            Burger: { styles: { burger: { color: '--mantine-color-grey-6' } } },\n            CopyButton: { defaultProps: { timeout: 1100 } },\n            Switch: {\n                defaultProps: { labelPosition: 'left', size: IS_HANDHELD_DEVICE ? 'lg' : undefined },\n                styles: { body: { justifyContent: 'space-between' }, description: { fontSize: 'var(--mantine-font-size-sm)' } }\n            },\n            Alert: {\n              styles: {\n                message: { fontSize: 'var(--mantine-font-size-xs)' },\n                root: { padding: IS_HANDHELD_DEVICE ? undefined : 'var(--mantine-spacing-xs)' },\n                icon: {\n                  width: IS_HANDHELD_DEVICE ? '1.5rem' : undefined,\n                  height: IS_HANDHELD_DEVICE ? '1.5rem' : undefined,\n                  marginRight: 'var(--mantine-spacing-xs)',\n                }\n              }\n            },\n            Button: {\n                defaultProps: {\n                    radius: 'md',\n                    variant: 'gradient',\n                    size: IS_HANDHELD_DEVICE ? 'md' : undefined,\n                },\n            },\n            Modal: {\n              defaultProps: {\n                radius: 'md'\n              }\n            },\n            Accordion: {\n              defaultProps: {\n                radius: 'md'\n              },\n              styles: {\n                item: { transition: 'none' },\n                control: { transition: 'none' },\n                panel: { transition: 'none' }\n              }\n            }\n        },\n        primaryColor: 'orange',\n        // see figma design for buttons\n        defaultGradient: { from: '#FF7A49', to: '#FF6025', deg: 180 },\n        // Mantine v7 has ugly dark colors. Therefore, use colors from v6 (https://v6.mantine.dev/theming/colors/#default-colors)\n        colors: {\n            // dark.4 is the borderColor for dark appearance\n            dark: ['#C1C2C5', '#A6A7AB', '#909296', '#5c5f66', '#4F5156', '#393939', '#353535', '#313131', '#303030', '#222528'],\n        },\n        other: {\n            dimmed: 'var(--mantine-color-dimmed)',\n            buttonDisconnectProps: { variant: 'light', c: 'red.7', bg: 'red.1' },\n        }\n    });\n\n    return <>\n        <ColorSchemeScript defaultColorScheme='auto' />\n        <MantineProvider defaultColorScheme='auto' theme={theme}>\n            <ModalsProvider>\n                <Notifications />\n                <CachedColorScheme>\n                  {children}\n                </CachedColorScheme>\n            </ModalsProvider>\n        </MantineProvider>\n    </>\n}\n"
  },
  {
    "path": "obscura-ui/src/components/ObscuraChip.tsx",
    "content": "import { Text } from '@mantine/core';\nimport { PropsWithChildren } from 'react';\nimport commonClasses from '../common/common.module.css';\n\n/**\n * Unlike MantineChip, ObscuraChip is not a clickable element\n */\nexport default function ObscuraChip({ children }: PropsWithChildren) {\n    return <Text size='sm' c='teal' px={8} py={2} className={commonClasses.chip}>{children}</Text>;\n}\n"
  },
  {
    "path": "obscura-ui/src/components/ObscuraWordmark.tsx",
    "content": "import commonClasses from '../common/common.module.css';\nimport Wordmark from '../res/obscura-wordmark.svg?react';\n\nexport default function ObscuraWordmark() {\n  return <Wordmark className={commonClasses.wordmark} width={150} height='auto' />;\n}\n"
  },
  {
    "path": "obscura-ui/src/components/PaymentManagementSheet.tsx",
    "content": "import { Anchor, Box, Button, Divider, Group, Loader, Stack, Text, UnstyledButton } from '@mantine/core';\nimport { notifications } from '@mantine/notifications';\nimport { useContext, useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport * as commands from '../bridge/commands';\nimport * as ObscuraAccount from '../common/accountUtils';\nimport { AccountInfo, activeAppleSubscription, AppleSubscriptionStatus, hasAppleSubscription, SubscriptionStatus } from '../common/api';\nimport { AppContext, SubscriptionProductModel } from '../common/appContext';\nimport { fmtErrorI18n, TranslationKey } from '../translations/i18n';\nimport { ButtonLink } from './ButtonLink';\nimport { ConfirmationDialog } from './ConfirmationDialog';\nimport { normalizeError } from '../common/utils';\nimport { CommandError } from '../bridge/commands';\nimport { TFunction } from 'i18next';\n\ninterface PaymentManagementSheetProps {\n  opened: boolean;\n  onClose: () => void;\n}\n\nexport function PaymentManagementSheet({ opened, onClose }: PaymentManagementSheetProps) {\n  const { t } = useTranslation();\n  const { appStatus, accountLoading, pollAccount, isProcessingPayment, osStatus, setPaymentProcessing } = useContext(AppContext);\n\n  useEffect(() => {\n    void pollAccount();\n  }, []);\n\n  // When the iOS offer code redemption sheet is closed,\n  // there is a return value of success or failure;\n  // `offerCodeRedemptionSuccess` is set to true in success.\n  // For a brief period of time after a successful redemption,\n  // the account info will show expired which will confuse new users.\n  // When this field is set to true, we know to show the processing UI.\n  useEffect(() => {\n    if (osStatus.offerCodeRedemptionSuccess === true && !appStatus.account?.account_info.active) {\n      setPaymentProcessing(true);\n    }\n  }, [osStatus.offerCodeRedemptionSuccess, appStatus.account?.account_info.active, setPaymentProcessing]);\n\n  if (isProcessingPayment) {\n    return <ProcessingPaymentSheet opened={true} />;\n  }\n\n  const externalPaymentsAllowed = osStatus.storeKit?.externalPaymentsAllowed || osStatus.playBilling === false\n\n  return (\n    <ConfirmationDialog\n      opened={opened}\n      onClose={onClose}\n      drawerSize='lg'\n      title={t('accountManagement')}\n      drawerCloseButton\n    >\n      <Stack h='100%' justify='space-between' gap='md'>\n        {appStatus.account?.account_info ? (\n          <>\n            <AccountInfoOverview accountInfo={appStatus.account.account_info} />\n            {!activeAppleSubscription(appStatus.account.account_info)\n              && (externalPaymentsAllowed || appStatus.account.account_info.active)\n              && <ButtonLink href={ObscuraAccount.payUrl(appStatus.accountId)}>{t(appStatus.account.account_info.active ? 'manageOnWeb' : 'payOnWeb')}</ButtonLink>}\n          </>\n        ) : (accountLoading ? (\n          < Stack align='center' justify='center' h={200} >\n            <Loader size='sm' />\n            <Text c='dimmed'>{t('account-loading')}</Text>\n          </Stack >\n        ) :\n          (\n            <Stack align='center' justify='center' h={200}>\n              <Text c='dimmed'>{t('account-InfoUnavailable')}</Text>\n            </Stack>\n          ))}\n      </Stack >\n    </ConfirmationDialog >\n  );\n}\n\nfunction ProcessingPaymentSheet({ opened }: { opened: boolean }) {\n  const { t } = useTranslation();\n\n  return (\n    <ConfirmationDialog\n      opened={opened}\n      onClose={() => { }}\n      drawerSize='lg'\n      title={t('processingPaymentTitle')}\n      drawerCloseButton={false}\n      closeOnClickOutside={false}\n      closeOnEscape={false}\n      withCloseButton={false}\n    >\n      <Stack align='center' justify='center' h={300} gap='md'>\n        <Loader size='lg' />\n        <Text ta=\"center\">{t('processingPaymentTitleMessage')}</Text>\n      </Stack>\n    </ConfirmationDialog>\n  );\n}\n\ninterface AccountInfoOverviewProps {\n  accountInfo: AccountInfo;\n}\n\nfunction AccountInfoOverview({ accountInfo }: AccountInfoOverviewProps) {\n  const sections = useBuildSections(accountInfo);\n\n  return (\n    <Stack gap='md'>\n      {sections.map((section, sectionIndex) => (\n        <Box key={sectionIndex}>\n          <Stack gap='xs'>\n            {section}\n          </Stack>\n          {sectionIndex < sections.length - 1 && <Divider mt='md' />}\n        </Box>\n      ))}\n    </Stack>\n  );\n}\n\nfunction InfoRow({ title, importance, dataBolded, data, dataColor }: RowProps) {\n  return (\n    <Group justify='space-between' wrap='nowrap'>\n      <Text\n        size={importance === 'high' ? 'md' : 'sm'}\n        fw={importance === 'high' ? 700 : importance === 'medium' ? 500 : 400}\n        c={importance === 'high' ? undefined : 'dimmed'}\n      >\n        {title}\n      </Text>\n      <Text\n        size={importance === 'high' ? 'md' : 'sm'}\n        fw={dataBolded ? 700 : importance === 'medium' ? 500 : 400}\n        c={dataColor || 'dimmed'}\n        ta='right'\n      >\n        {data}\n      </Text>\n    </Group>\n  );\n}\n\ntype Importance = 'high' | 'medium' | 'low';\n\ninterface RowProps {\n  title: string;\n  importance: Importance;\n  data?: React.ReactElement | string;\n  dataBolded?: boolean;\n  dataColor?: string;\n}\n\ntype Section = React.ReactElement[];\n\ninterface SubscriptionProductCardProps {\n  product: SubscriptionProductModel;\n  subscribed: boolean;\n}\n\nfunction AppleSubscriptionProductCard({ product, subscribed }: SubscriptionProductCardProps) {\n  const { t } = useTranslation();\n  const { pollAccount, setPaymentProcessing } = useContext(AppContext);\n  const { execute: storeKitAssociateAccount } = commands.useCommand({ command: commands.storeKitAssociateAccount, showNotification: true, rethrow: true });\n  const [preparingToRedeem, setPreparingToRedeem] = useState(false);\n\n  const handlePurchase = async () => {\n    try {\n      const purchaseSuccessful = await commands.storeKitPurchaseSubscription();\n      if (purchaseSuccessful) {\n        console.log('Purchase flow completed, show payment processing UI.');\n        setPaymentProcessing(true);\n        await pollAccount();\n      } else {\n        return; // user dismissed payment sheet\n      }\n    } catch (e) {\n      showErrorNotification(t, e);\n    }\n  }\n\n  return (\n    <Stack ta='center' p='0'>\n      <Stack gap='0'>\n        <Text fw={700}>\n          {product.displayName}\n        </Text>\n        <Text c='dimmed'>\n          {product.description}\n        </Text>\n        <Group justify='center' wrap='nowrap' gap='xs'>\n          <Text c='dimmed' fw={700}>\n            {product.subscriptionPeriodFormatted}:\n          </Text>\n          <Text fw={600}>\n            {product.renewalPrice ?? product.displayPrice}\n          </Text>\n        </Group>\n      </Stack>\n      <Button component='a'\n        onClick={subscribed ? undefined : handlePurchase}\n        href={subscribed ? ObscuraAccount.APP_MANAGE_SUBSCRIPTION : undefined}>\n        {subscribed ? t('Manage Subscription') : t('Subscribe In-app')}</Button>\n      {!subscribed && (\n        <Stack gap='0'>\n          <Group justify='center' gap='xs'>\n            <Text c='dimmed'>\n              {t('Have a promo code?')}\n            </Text>\n            <UnstyledButton disabled={preparingToRedeem} td='underline' c='blue' fw='normal' onClick={\n              async () => {\n                setPreparingToRedeem(true);\n                try {\n                  await storeKitAssociateAccount();\n                  // if successfully associated account, show the redemption sheet\n                  await commands.showOfferCodeRedemption();\n                } finally {\n                  setPreparingToRedeem(false);\n                }\n              }\n            }>{t('Redeem Code')}</UnstyledButton>\n          </Group>\n          <Anchor size='xs' td='underline' c='blue' onClick={commands.storeKitRestorePurchases}>{t('Restore Purchases')}</Anchor>\n        </Stack>\n      )}\n    </Stack>\n  );\n}\n\nfunction AndroidSubscriptionProductCard() {\n  const { t } = useTranslation();\n  const { pollAccount, setPaymentProcessing } = useContext(AppContext);\n\n  const handlePurchase = async () => {\n    try {\n      const purchaseSuccessful = await commands.playPurchaseSubscription();\n      if (purchaseSuccessful) {\n        console.log('Purchase flow completed, show payment processing UI.');\n        setPaymentProcessing(true);\n        await pollAccount();\n      } else {\n        return; // user dismissed payment sheet\n      }\n    } catch (e) {\n      showErrorNotification(t, e);\n    }\n  }\n\n  return (\n    <Stack ta='center' p='0'>\n      <Button onClick={handlePurchase}>\n        {t('Subscribe In-app')}\n      </Button>\n    </Stack>\n  );\n}\n\nfunction showErrorNotification(t: TFunction, e: unknown) {\n  const error = normalizeError(e);\n  const message = error instanceof CommandError\n    ? fmtErrorI18n(t, error) : error.message;\n  notifications.show({\n    color: 'red',\n    title: t('purchaseFailed'),\n    message,\n  });\n}\n\nfunction useBuildSections(accountInfo: AccountInfo): Section[] {\n  const { t } = useTranslation();\n  const { osStatus } = useContext(AppContext);\n  const appleSubscriptionProduct = osStatus.storeKit?.subscriptionProduct;\n  const sections: Section[] = [];\n\n  const formattedId = ObscuraAccount.formatPartialAccountId(ObscuraAccount.accountIdToString(accountInfo.id));\n  sections.push([<InfoRow title={t('Account ID')} importance='high' data={formattedId} />,],);\n  sections.push([<InfoRow title={t('Status')} importance='high' data={accountInfo.active ? t('Active') : t('Inactive')} dataColor={accountInfo.active ? 'green' : 'red'} />]);\n\n  // Top Up Section\n  if (accountInfo.top_up) {\n    const topUpDate = new Date(accountInfo.top_up.credit_expires_at * 1000);\n    sections.push(\n      [\n        <InfoRow title={t('Top Up')} importance='high' />,\n        <InfoRow title={t('Expiration Date')} importance='medium' data={topUpDate.toLocaleDateString()} />\n      ]);\n  }\n\n  // Stripe Subscription Section\n  const sub = accountInfo.subscription;\n  if (sub && sub.status !== SubscriptionStatus.CANCELED) {\n    sections.push([\n      <InfoRow title={t('subscribedOnWeb')} importance='high' />,\n      <InfoRow title={t('Status')} importance='medium' data={t(`stripeStatus-${sub.status}`)} dataColor={getStripeStatusColor(sub.status)} />,\n      <InfoRow title={t('Source')} importance='medium' data={new URL(ObscuraAccount.OBSCURA_WEBPAGE).hostname} />,\n      <InfoRow title={t('Period Start')} importance='medium' data={new Date(sub.current_period_start * 1000).toLocaleDateString()} />,\n      <InfoRow title={t('Period End')} importance='medium' data={new Date(sub.current_period_end * 1000).toLocaleDateString()} />,\n      <InfoRow title={t('cancelAtEnd')} importance='medium' data={sub.cancel_at_period_end ? t('Yes') : t('No')} />,\n    ]);\n  }\n\n  // Apple Subscription Section\n  if (accountInfo.apple_subscription) {\n    const appleSub = accountInfo.apple_subscription;\n    const section = [];\n    if (appleSubscriptionProduct) {\n      section.push(<AppleSubscriptionProductCard product={appleSubscriptionProduct} subscribed={hasAppleSubscription(accountInfo)} />);\n    }\n    section.push(\n      <InfoRow title={t('Status')} importance='medium' data={t(appleStatusToTranslationKey(appleSub.status))} dataColor={getAppleSubscriptionStatusColor(appleSub.status)} />,\n      <InfoRow title={t('Source')} importance='medium' data={t('App Store')} />,\n      <InfoRow title={t('Auto-Renewal')} importance='medium' data={appleSub.auto_renew_status ? t('Enabled') : t('Disabled')} />,\n    )\n    if (appleSub.auto_renew_status) {\n      section.push(<InfoRow title={t('Renewal Date')} importance='medium' data={new Date(appleSub.renewal_date * 1000).toLocaleDateString()} />);\n    }\n    sections.push(section);\n  } else if (appleSubscriptionProduct && !accountInfo.active) {\n    // Show subscription product if account is inactive\n    sections.push([\n      <AppleSubscriptionProductCard product={appleSubscriptionProduct} subscribed={false} />,\n    ]);\n  }\n\n  if (osStatus.playBilling) {\n    sections.push([\n      <AndroidSubscriptionProductCard />,\n    ]);\n  }\n\n  return sections;\n}\n\nfunction getStripeStatusColor(status: SubscriptionStatus): string {\n  switch (status) {\n    case SubscriptionStatus.ACTIVE:\n    case SubscriptionStatus.TRIALING:\n      return 'green';\n    case SubscriptionStatus.PAST_DUE:\n    case SubscriptionStatus.INCOMPLETE:\n    case SubscriptionStatus.PAUSED:\n      return 'yellow';\n    case SubscriptionStatus.CANCELED:\n    case SubscriptionStatus.UNPAID:\n    case SubscriptionStatus.INCOMPLETE_EXPIRED:\n      return 'red';\n    default:\n      return 'gray';\n  }\n}\n\nfunction getAppleSubscriptionStatusColor(status: AppleSubscriptionStatus): string {\n  switch (status) {\n    case AppleSubscriptionStatus.ACTIVE:\n      return 'green';\n    case AppleSubscriptionStatus.GRACE_PERIOD:\n      return 'orange';\n    case AppleSubscriptionStatus.BILLING_RETRY:\n    case AppleSubscriptionStatus.EXPIRED:\n    case AppleSubscriptionStatus.REVOKED:\n      return 'red';\n    default:\n      return 'gray';\n  }\n}\n\nfunction appleStatusToTranslationKey(status: AppleSubscriptionStatus): TranslationKey {\n  switch (status) {\n    case AppleSubscriptionStatus.ACTIVE:\n      return 'appleStatus-active' as TranslationKey;\n    case AppleSubscriptionStatus.EXPIRED:\n      return 'appleStatus-expired' as TranslationKey;\n    case AppleSubscriptionStatus.BILLING_RETRY:\n      return 'appleStatus-billingRetry' as TranslationKey;\n    case AppleSubscriptionStatus.GRACE_PERIOD:\n      return 'appleStatus-gracePeriod' as TranslationKey;\n    case AppleSubscriptionStatus.REVOKED:\n      return 'appleStatus-revoked' as TranslationKey;\n    default:\n      return 'appleStatus-unknown' as TranslationKey;\n  }\n}\n"
  },
  {
    "path": "obscura-ui/src/components/ScrollableView.module.css",
    "content": ".scrollbar {\n  &[data-orientation='vertical'] .thumb {\n    background-color: light-dark(var(--mantine-color-dark-3), var(--mantine-color-dark-2));\n  }\n}\n\n.corner {\n  background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));\n  opacity: 1;\n}\n\n::-webkit-scrollbar-thumb {\n    background-color: light-dark(var(--mantine-color-dark-3), var(--mantine-color-dark-2));\n}\n"
  },
  {
    "path": "obscura-ui/src/components/ScrollableView.tsx",
    "content": "import { ActionIcon, Affix, ScrollArea, Transition } from '@mantine/core';\nimport { useWindowScroll } from '@mantine/hooks';\nimport { PropsWithChildren, useRef } from 'react';\nimport { IoArrowUp } from 'react-icons/io5';\nimport { IS_HANDHELD_DEVICE } from '../bridge/SystemProvider';\nimport classes from './ScrollableView.module.css';\n\nexport function ScrollableView({ children }: PropsWithChildren) {\n  const viewport = useRef<HTMLDivElement>(null);\n\n  return (\n    <ScrollArea h='100vh' type='always' scrollbarSize={IS_HANDHELD_DEVICE ? 2 : 12} classNames={classes} viewportRef={viewport}>\n      {children}\n      <ScrollToTop />\n    </ScrollArea>\n  );\n}\n\nfunction ScrollToTop() {\n  const [scroll, scrollTo] = useWindowScroll();\n\n  return (\n    <Affix position={{ bottom: 20, right: 20 }}>\n      <Transition transition='slide-up' mounted={scroll.y > 50}>\n        {transitionStyles =>\n          <ActionIcon style={transitionStyles} size='lg' variant='gradient'\n            onClick={() => scrollTo!({ y: 0 })}>\n            <IoArrowUp size={25} />\n          </ActionIcon>\n        }\n      </Transition>\n    </Affix>\n  );\n}\n"
  },
  {
    "path": "obscura-ui/src/components/SecondaryButton.tsx",
    "content": "import { Button } from '@mantine/core';\nimport { PropsWithChildren } from 'react';\nimport commonClasses from '../common/common.module.css';\n\nexport function SecondaryButton({ children, onClick }: PropsWithChildren & { onClick: () => void }) {\n  return <Button onClick={onClick} variant='light' color='gray' className={commonClasses.secondaryColor}>{children}</Button>;\n}\n"
  },
  {
    "path": "obscura-ui/src/components/SelectCreatable.tsx",
    "content": "import { Combobox, InputBase, InputBaseProps, PolymorphicComponentProps, useCombobox } from '@mantine/core';\nimport { useRef, useState } from 'react';\n\nexport interface Choice {\n  value: string,\n  text: string\n}\n\nexport function SelectCreatable({ defaultValue, choices, onSubmit, inputBaseProps = {} }: { defaultValue?: string, choices: Choice[], onSubmit: (value: string) => void, inputBaseProps?: PolymorphicComponentProps<'input', InputBaseProps> }) {\n  const combobox = useCombobox({\n    onDropdownClose: () => combobox.resetSelectedOption(),\n  });\n\n  const filterByValue = (query?: string) => {\n    return choices.filter(choice => choice.value === query);\n  }\n\n  const [data, setData] = useState(choices);\n  const [value, setValue] = useState<string | null>(defaultValue || null);\n  const defaultSearch = filterByValue(defaultValue)[0]?.text;\n  const [search, setSearch] = useState(defaultSearch || '');\n\n  const exactOptionMatch = data.some(item => item.text === search);\n  const filteredOptions = exactOptionMatch\n    ? data\n    : data.filter(item => item.text.toLowerCase().includes(search.toLowerCase().trim()));\n\n  const options = filteredOptions.map((item) => (\n    <Combobox.Option value={item.value} key={item.value}>\n      {item.text}\n    </Combobox.Option>\n  ));\n\n  const inputRef = useRef<HTMLInputElement | null>(null);\n\n  return (\n    <Combobox\n      store={combobox}\n      withinPortal={false}\n      onOptionSubmit={val => {\n        if (val === '$create') {\n          if (inputRef.current?.reportValidity()) {\n            setData(current => [...current, { text: search, value: search }]);\n            setValue(search);\n            onSubmit(search);\n          }\n        } else {\n          setValue(val);\n          setSearch(filterByValue(val)[0]?.text || val);\n          onSubmit(val);\n        }\n        combobox.closeDropdown();\n      }}\n    >\n      <Combobox.Target>\n        <InputBase\n          ref={inputRef}\n          rightSection={<Combobox.Chevron />}\n          value={search}\n          onChange={event => {\n            combobox.openDropdown();\n            combobox.updateSelectedOptionIndex();\n            setSearch(event.currentTarget.value);\n          }}\n          onClick={() => combobox.openDropdown()}\n          onFocus={() => combobox.openDropdown()}\n          onBlur={() => {\n            combobox.closeDropdown();\n            if (value === null) {\n              setSearch('');\n            } else {\n              const choiceAvailable = filterByValue(value)[0];\n              setSearch(choiceAvailable?.text || value);\n            }\n          }}\n          placeholder='Search value'\n          rightSectionPointerEvents='none'\n          {...inputBaseProps}\n        />\n      </Combobox.Target>\n\n      <Combobox.Dropdown>\n        <Combobox.Options>\n          {options}\n          {!exactOptionMatch && search.trim().length > 0 && (\n            <Combobox.Option value=\"$create\">+ {search}</Combobox.Option>\n          )}\n        </Combobox.Options>\n      </Combobox.Dropdown>\n    </Combobox>\n  );\n}\n"
  },
  {
    "path": "obscura-ui/src/components/Socials.tsx",
    "content": "import { ActionIcon, Group, Text, Tooltip } from '@mantine/core';\nimport { useTranslation } from 'react-i18next';\nimport { SiDiscord, SiMatrix, SiX } from 'react-icons/si';\nimport commonClasses from '../common/common.module.css';\nimport { DISCORD_SERVER, MATRIX_SERVER, TWITTER } from '../common/links';\n\nexport function Socials() {\n  const { t } = useTranslation();\n\n  return <>\n    <Text c='dimmed' ta='center'>\n      {t('ConnectWithUs')}\n    </Text>\n    <Group gap='xl' justify='center'>\n      <Tooltip label='Discord'>\n        <ActionIcon component='a' href={DISCORD_SERVER} color='#5865f2' size='xl' variant='transparent'>\n          <SiDiscord size='100%' />\n        </ActionIcon>\n      </Tooltip>\n      <Tooltip label='Matrix'>\n        <ActionIcon component='a' href={MATRIX_SERVER} size='xl' variant='transparent'>\n          <SiMatrix className={commonClasses.svgThemed} size='100%' />\n        </ActionIcon>\n      </Tooltip>\n      <Tooltip label='X'>\n        <ActionIcon component='a' href={TWITTER} size={50} radius='md' variant='transparent'>\n          <SiX className={commonClasses.svgThemed} size={24} />\n        </ActionIcon>\n      </Tooltip>\n    </Group>\n  </>\n}\n"
  },
  {
    "path": "obscura-ui/src/components/VpnErrorFmt.tsx",
    "content": "import { Translation } from \"react-i18next\";\nimport { PLATFORM } from \"../bridge/SystemProvider\";\n\nexport function VpnError({ errorEnum }: { errorEnum: string }) {\n  return (\n    <Translation>\n      {(t,) => t(`vpnError-${errorEnum}`, { context: PLATFORM } as any)}\n    </Translation>\n  );\n}\n"
  },
  {
    "path": "obscura-ui/src/index.css",
    "content": "body {\n  /* show eye pleasing background color instead of white when app has not rendered */\n  background-color: #313131;\n  margin: 0;\n  /* disable text interaction by default */\n  -webkit-user-select: none; /* Safari */\n  user-select: none; /* Standard  */\n  cursor: default;\n  height: 100%;\n}\n\nhtml, #root {\n    height: 100%;\n}\n\n/* always show scrollbar thumb */\n::-webkit-scrollbar {\n    -webkit-appearance: none;\n    width: 7px;\n}\n\n::-webkit-scrollbar-thumb {\n    border-radius: 4px;\n}\n\n.row {\n  display: flex;\n  align-items: flex-end;\n  & > div {\n    flex-grow: 1;\n  }\n}\n\n.rowCenter {\n  display: flex;\n  align-items: center;\n  & > div {\n    flex-grow: 1;\n  }\n}\n\n.embeddedInput {\n  display: inline-block;\n  margin: auto 5px;\n}\n"
  },
  {
    "path": "obscura-ui/src/main.tsx",
    "content": "import './wdyr';\nimport React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport { ErrorBoundary } from 'react-error-boundary';\nimport App from './App';\nimport Providers from './Providers';\nimport { logReactError } from './bridge/SystemProvider';\nimport './translations/i18n'; // for internationalization (translations)\nimport { FallbackAppRender } from './views';\n\nconst root = createRoot(document.getElementById('root')!);\nroot.render(\n  <React.StrictMode>\n    <Providers>\n      <ErrorBoundary\n        FallbackComponent={FallbackAppRender}\n        // Reset the state of your app so the error doesn't happen again\n        onReset={details => {\n          location.pathname = '/';\n        }}\n        onError={logReactError}>\n        <App />\n      </ErrorBoundary>\n    </Providers>\n  </React.StrictMode>\n);\n"
  },
  {
    "path": "obscura-ui/src/translations/en.json",
    "content": "{\n  \"About\": \"About\",\n  \"Account\": \"Account\",\n  \"Account Error\": \"Account Error\",\n  \"Account ID\": \"Account ID\",\n  \"Account is active\": \"Account is active\",\n  \"account-AutoRenewalInfo\": \"Browse worry-free. Your VPN subscription will renew automatically.\",\n  \"account-DaysRemaining_one\": \"{{ count }} day remains\",\n  \"account-DaysRemaining_other\": \"{{ count }} days remaining\",\n  \"account-DaysUntilExpiry_one\": \"{{ count }} day until expiry\",\n  \"account-DaysUntilExpiry_other\": \"{{ count }} days until expiry\",\n  \"account-Expired\": \"Your account has expired\",\n  \"account-ExpiresInHours\": \"Your account expires in {{ count }} hour on {{ endDate }}.\",\n  \"account-ExpiresInHours_other\": \"Your account expires in {{ count }} hours on {{ endDate }}.\",\n  \"account-ExpiresInMinutes\": \"Your account expires in {{ count }} minute on {{ endDate }}.\",\n  \"account-ExpiresInMinutes_other\": \"Your account expires in {{ count }} minutes on {{ endDate }}.\",\n  \"account-ExpiresOn\": \"Your account will expire in {{ count }} day on <0>{{ endDate }}</0>.\",\n  \"account-ExpiresOn_0\": \"Your account wil expire today, <0>{{ endDate }}</0>.\",\n  \"account-ExpiresOn_other\": \"Your account will expire in {{ count }} days on <0>{{ endDate }}</0>.\",\n  \"account-ExpiresSoon\": \"Your account expires soon on {{ endDate }}.\",\n  \"account-ExpiresVerySoon\": \"Your account expires very soon on {{ endDate }}.\",\n  \"account-GoToPayment\": \"Make a payment to continue enjoying seamless privacy\",\n  \"account-HoursUntilExpiry_one\": \"{{ count }} hour until expiry\",\n  \"account-HoursUntilExpiry_other\": \"{{ count }} hours until expiry\",\n  \"account-InfoUnavailable\": \"Account Info Unavailable\",\n  \"account-loading\": \"Loading account information...\",\n  \"account-MinutesUntilExpiry_one\": \"{{ count }} minute until expiry\",\n  \"account-MinutesUntilExpiry_other\": \"{{ count }} minutes until expiry\",\n  \"account-PaidUp\": \"Paid Up\",\n  \"account-SubscriptionActive\": \"Subscription Active\",\n  \"account-SubscriptionAutoRenewSubtitle\": \"You've paused your subscription. Your account will remain active until {{ endDate }}. Top-up or resume your subscription to avoid interruptions.\",\n  \"account-SubscriptionExpiresOn\": \"Expiring in {{ count }} day on {{ endDate }}\",\n  \"account-SubscriptionExpiresOn_0\": \"Expiring today, {{ endDate }}\",\n  \"account-SubscriptionExpiresOn_other\": \"Expiring in {{ count }} days on {{ endDate }}\",\n  \"account-SubscriptionPaused\": \"Subscription Paused\",\n  \"account-SubscriptionRenewsOn\": \"Your subscription will renew automatically in {{ count }} day on {{ endDate }}.\",\n  \"account-SubscriptionRenewsOn_0\": \"Your subscription will renew automatically today, {{ endDate }}.\",\n  \"account-SubscriptionRenewsOn_other\": \"Your subscription will renew automatically in {{ count }} days on {{ endDate }}.\",\n  \"account-SubscriptionWillStart\": \"Your subscription will start automatically after your top-up credit is used up on {{ endDate }}.\",\n  \"accountIdError-invalidChecksum\": \"Mistyped Account Number\",\n  \"accountIdError-tooLong\": \"Account Number is too long (should be 20)\",\n  \"accountIdError-tooShort\": \"Account Number is too short (should be 20)\",\n  \"accountManagement\": \"Account Management\",\n  \"accountNumberStoredConfirmation\": \"Please confirm that you have stored your account number somewhere secure\",\n  \"Active\": \"Active\",\n  \"apiError\": \"API request failed. Please make sure you are connected to the internet and no other VPN or firewall is running.\",\n  \"apiInvalidAccountId\": \"Account Number is invalid\",\n  \"apiRateLimitExceeded\": \"API rate-limit exceeded. Please try again later.\",\n  \"apiSignupLimitExceeded\": \"Sign up limit exceeded. Please try again in a few hours.\",\n  \"apiUnreachable\": \"Unable to connect to the Obscura API. You may be offline or something is interfering with your connection.\",\n  \"App Store\": \"App Store\",\n  \"Appearance\": \"Appearance\",\n  \"appleStatus-active\": \"Active\",\n  \"appleStatus-billingRetry\": \"Billing Retry\",\n  \"appleStatus-expired\": \"Expired\",\n  \"appleStatus-gracePeriod\": \"Grace Period\",\n  \"appleStatus-revoked\": \"Revoked\",\n  \"appleStatus-unknown\": \"Unknown\",\n  \"appStatusLoading\": \"Loading initial app status\",\n  \"Auto-Renewal\": \"Auto-Renewal\",\n  \"autoConnectStartup\": \"Automatically connect at launch\",\n  \"autoConnectStartup-behavior\": \"If you last chose \\\"Quick Connect\\\", Obscura will \\\"Quick Connect\\\" at next launch. If you last chose a specific location, Obscura will connect to that location at next launch.\",\n  \"Available Locations\": \"Available Locations\",\n  \"busyConnection\": \"Connection is Busy\",\n  \"Cancel\": \"Cancel\",\n  \"Cancel Connecting\": \"Cancel Connecting\",\n  \"cancelAtEnd\": \"Cancel at Period End\",\n  \"cancelSignUp\": \"I have an Account ID already\",\n  \"Changing Locations\": \"Changing Locations\",\n  \"checkForUpdates\": \"Check for updates\",\n  \"checkMyConnection\": \"Check my Connection\",\n  \"clickToConnect\": \"Click to connect\",\n  \"configSaveError\": \"Could not save config\",\n  \"Confirmation\": \"Confirmation\",\n  \"Connect\": \"Connect\",\n  \"Connected\": \"Connected\",\n  \"connectedTo\": \"Connected to\",\n  \"connectedToLocation\": \"Connected to {{ location }}\",\n  \"connectedToObscura\": \"Connected to Obscura\",\n  \"ConnectFailed\": \"Failed to connect\",\n  \"Connecting\": \"Connecting\",\n  \"connectingTo\": \"Connecting to {{ location }}\",\n  \"connectingToCity\": \"Now connecting to {{ city }}\",\n  \"Connection\": \"Connection\",\n  \"connectToEnjoy\": \"Connect to enjoy seamless privacy protection\",\n  \"connectToInternet\": \"Connect to the internet to enable Obscura\",\n  \"ConnectWithUs\": \"Connect with us via\",\n  \"contactUsText\": \"If you have any questions or requests, feel free to send an email to {{ email }}\",\n  \"ContinentAF\": \"Africa\",\n  \"ContinentAN\": \"Antarctica\",\n  \"ContinentAS\": \"Asia\",\n  \"ContinentEU\": \"Europe\",\n  \"ContinentNA\": \"North America\",\n  \"ContinentOC\": \"Oceania\",\n  \"ContinentSA\": \"Latin America\",\n  \"Continue\": \"Continue\",\n  \"Continue to payment\": \"Continue to payment\",\n  \"continueUsingObscura\": \"Top-up or subscribe to continue using Obscura.\",\n  \"Copied Account Number\": \"Copied Account Number\",\n  \"copiedUrl\": \"Copied!\",\n  \"Copy\": \"Copy\",\n  \"copy account number\": \"copy account number\",\n  \"Copy Account Number\": \"Copy Account Number\",\n  \"copyPubKey\": \"Copy public key\",\n  \"copyright\": \"© 2025 Sovereign Engineering Inc. All rights reserved.\",\n  \"Create an Account\": \"Create an Account\",\n  \"createDebugArchive\": \"Create Debugging Archive\",\n  \"createDebugArchiveInProgress\": \"This will take a few minutes.\",\n  \"currentSession\": \"Current Session\",\n  \"daemonStatusFailed\": \"Could not get daemon status\",\n  \"daemonStatusStreamRecheck\": \"Will recheck daemon status in 5 minutes\",\n  \"Dark\": \"Dark\",\n  \"Debug Archive\": \"Debug Archive\",\n  \"debugArchiveDisconnectPrompt\": \"For the best diagnostics, we recommend creating a debugging archive while disconnected. How do you want to create the debugging archive?\",\n  \"debugArchiveFeedbackLabel\": \"Feedback (Optional)\",\n  \"debugArchiveFeedbackPrompt\": \"Please describe the issue you are experiencing...\",\n  \"Debugging Archive\": \"Debugging Archive\",\n  \"Debugging Archive Created\": \"Debugging Archive Created\",\n  \"Debugging Archive Failed\": \"Debugging Archive Failed\",\n  \"deleteAccount\": \"Delete Account\",\n  \"deleteAccountConfirmationAppleSubscription\": \"You must manually cancel your Apple subscription.\",\n  \"deleteAccountConfirmationCredit\": \"You will lose your existing credit.\",\n  \"deleteAccountConfirmationEnd\": \"If you log in again, a new account will be created with the same ID.\",\n  \"deleteAccountConfirmationStart\": \"Account deletion is permanent.\",\n  \"deleteAccountFailed\": \"Failed to delete account\",\n  \"Developer\": \"Developer\",\n  \"Disabled\": \"Disabled\",\n  \"Disconnect\": \"Disconnect\",\n  \"disconnected\": \"Disconnected\",\n  \"Disconnected\": \"Disconnected\",\n  \"DisconnectFailed\": \"Failed to disconnect\",\n  \"Disconnecting\": \"Disconnecting\",\n  \"Dismiss\": \"Dismiss\",\n  \"dnsBlockAds\": \"Block ads\",\n  \"dnsBlockAdult\": \"Block adult content\",\n  \"dnsBlockGambling\": \"Block gambling\",\n  \"dnsBlockMalware\": \"Block malware\",\n  \"dnsBlockSocialMedia\": \"Block social media\",\n  \"dnsBlockTrackers\": \"Block trackers\",\n  \"dnsModeObscura\": \"Use Obscura DNS\",\n  \"dnsModeSystem\": \"Use installed custom system DNS profile\",\n  \"dnsModeSystemDescription\": \"If you have a custom system DNS profile installed (e.g. NextDNS), enabling this option will make your connections use the custom DNS profile instead of Obscura's default DNS servers.\",\n  \"dnsSetting\": \"DNS Server\",\n  \"Done\": \"Done\",\n  \"downgradeAvailable\": \"Version {{ VERSION }} has been yanked. Please downgrade to v{{ v }}.\",\n  \"emailBodyIntro\": \"Please describe the problem you're seeing and what you expected to happen below\",\n  \"emailLatestDebugArchive\": \"Email Archive\",\n  \"emailServiceUnavailable\": \"Email isn't configured on your device.\",\n  \"emailSubject\": \"Help needed for Obscura VPN ({{ platform }}, {{ version }})\",\n  \"Enabled\": \"Enabled\",\n  \"enjoyObscura\": \"Enjoy the free and open internet!\",\n  \"Error\": \"Error\",\n  \"Error Connecting\": \"Error Connecting\",\n  \"Error Logging In\": \"Error Logging In\",\n  \"error-timeoutDisconnect\": \"Timed out waiting for disconnect\",\n  \"errorFetchingOsStatus\": \"Error Fetching OsStatus\",\n  \"errorFetchingStatus\": \"Error Fetching Status\",\n  \"exitInfoLoading\": \"Exit info loading\",\n  \"exitPubKeyTooltip\": \"Matches published key for\",\n  \"exitServerFetchResolution\": \"Make sure you are connected to the internet then try again.\",\n  \"experiencingIssues\": \"Experiencing issues? <0></0><1>Submit a debug archive</1>\",\n  \"Experimental\": \"Experimental\",\n  \"Expiration\": \"Expiration\",\n  \"Expiration Date\": \"Expiration Date\",\n  \"Failed\": \"Failed\",\n  \"failedToFetchExitServers\": \"Failed to fetch exit servers\",\n  \"featureFlag-forceSmallMtu-Description\": \"Assume the underlying network has a small MTU. This may help on networks with MTU issues.\",\n  \"featureFlag-forceSmallMtu-Label\": \"Small MTU compatibility\",\n  \"featureFlag-forceSmallMtu-Warning\": \"This may slightly reduce speed and reliability\",\n  \"featureFlag-killSwitch-Description\": \"A kill switch automatically blocks all internet traffic if the VPN connection drops unexpectedly, preventing data leaks and ensuring your IP address remains hidden until the connection is restored.\",\n  \"featureFlag-killSwitch-Label\": \"On demand kill switch\",\n  \"featureFlag-quicFramePadding-BandwidthWarning\": \"Enabling this feature may slightly increase bandwidth usage\",\n  \"featureFlag-quicFramePadding-Description\": \"Make network packets a uniform size to mitigate against size-based packet correlation by global network observers.\",\n  \"featureFlag-quicFramePadding-Label\": \"Enable packet padding\",\n  \"featureFlag-tcpTlsTunnel-BandwidthWarning\": \"This may reduce performance and slightly increase bandwidth usage\",\n  \"featureFlag-tcpTlsTunnel-Description\": \"Use TCP/TLS instead of QUIC (which is UDP-based) for the tunnel to Obscura servers. This may allow you to use Obscura on networks that restrict UDP traffic.\",\n  \"featureFlag-tcpTlsTunnel-Label\": \"Use TCP/TLS tunnel\",\n  \"findDebugBundleInFinder\": \"A Finder window should have opened with the debugging archive file selected. <0>Reveal in finder</0>\",\n  \"footerAlpha\": \"Thanks for trying out the Alpha\",\n  \"fullLicenseTexts\": \"Full License Texts\",\n  \"fundYourAccount\": \"Fund Your Account\",\n  \"General\": \"General\",\n  \"Have a promo code?\": \"Have a promo code?\",\n  \"havingTrouble\": \"Having trouble?\",\n  \"Help\": \"Help\",\n  \"Hide\": \"Hide\",\n  \"hide account number\": \"hide account number\",\n  \"IMPORTANT NOTICE_one\": \"IMPORTANT NOTICES\",\n  \"IMPORTANT NOTICE_other\": \"IMPORTANT NOTICES\",\n  \"Inactive\": \"Inactive\",\n  \"installingUpdate\": \"Installing update v{{ v }}\",\n  \"installUpdate\": \"Install update\",\n  \"InternalError\": \"An unexpected error has occurred. Please consider sending a Debugging Archive\",\n  \"Internet\": \"Internet\",\n  \"ipcError-apiError\": \"API request failed. Please make sure you are connected to the internet and no other VPN or firewall is running.\",\n  \"ipcError-apiNoLongerSupported\": \"This client is too old, please update to the latest version for this functionality.\",\n  \"ipcError-apiRateLimitExceeded\": \"API rate-limit exceeded. Please try again later.\",\n  \"ipcError-apiSignupLimitExceeded\": \"Sign up limit exceeded. Please try again in a few hours.\",\n  \"ipcError-apiUnreachable\": \"Unable to connect to the Obscura API. You may be offline or something is interfering with your connection.\",\n  \"ipcError-errorUnsupportedOnOS\": \"Unexpectedly tried to do something unsupported on the current OS. Please consider sending us a Debugging Archive.\",\n  \"ipcError-failedToAssociateAccount\": \"Failed to associate Apple account with Obscura account\",\n  \"ipcError-other\": \"An unexpected error occurred. Please consider sending us a Debugging Archive.\",\n  \"ipcError-purchaseFailed\": \"Failed to initiate purchase. Are you connected to the internet?\",\n  \"ipcError-purchaseFailedAlreadyOwned\": \"Failed to initiate purchase. You're already subscribed!\",\n  \"ipcError-updaterFailedToCheck\": \"Failed to check for updates\",\n  \"ipcError-updaterFailedToStartInstall\": \"Failed to initiate update. Is one in progress already?\",\n  \"lastChosen\": \"Last chosen\",\n  \"latestVersion\": \"latest version\",\n  \"legalNotice\": \"By creating an account, you agree to our <0>Terms of Service and Privacy Policy</0>.\",\n  \"licensesOverview\": \"Overview of Licenses\",\n  \"Light\": \"Light\",\n  \"locatePrefs\": \"Locate preferences file\",\n  \"Location\": \"Location\",\n  \"Log In\": \"Log In\",\n  \"loginError-unknown\": \"Failed to login; Reason is unknown\",\n  \"LoginFailed\": \"Failed to login. Most likely an invalid account number\",\n  \"logOut\": \"Log out\",\n  \"logOutFailed\": \"Failed to log out\",\n  \"logOutPrompt_connected\": \"You are currently connected to Obscura VPN. Logging out will <b>disconnect you</b> and <b>reset preferences</b>.\",\n  \"logOutPrompt_disconnected\": \"Logging out will <b>reset preferences</b>.\",\n  \"Manage\": \"Manage\",\n  \"Manage Configurations\": \"Manage Configurations\",\n  \"Manage Payments\": \"Manage Payments\",\n  \"Manage Subscription\": \"Manage Subscription\",\n  \"ManageAccount\": \"Manage Account\",\n  \"manageOnWeb\": \"Manage on obscura.com\",\n  \"mostRecent\": \"most recent\",\n  \"Network\": \"Network\",\n  \"networkInformation\": \"Network Information\",\n  \"No\": \"No\",\n  \"Node\": \"Node\",\n  \"noExitServers\": \"No exit servers found\",\n  \"noExitsFoundMatching\": \"no exits matching country {{ country }} and city {{ city }} were found\",\n  \"noInternet\": \"No internet connection\",\n  \"noLocationsFound\": \"No locations found\",\n  \"notConnected\": \"Not connected to Obscura\",\n  \"Obscura Account Number\": \"Obscura Account Number\",\n  \"Offline\": \"Offline\",\n  \"openAtLoginDisabled\": \"Obscura VPN will no longer open at login\",\n  \"openAtLoginEnabled\": \"Obscura VPN will open at login\",\n  \"openAtLoginFailedToDisable\": \"Could not remove Obscura VPN from opening at login. Please go to Login Items in System Settings to manually remove Obscura VPN\",\n  \"openAtLoginFailedToEnable\": \"Could not register Obscura VPN as login item. Please go to Login Items in System Settings to manually add Obscura VPN from the Applications directory\",\n  \"openAtLoginRegister\": \"Launch Obscura VPN at login\",\n  \"openSourceLicenses\": \"Open Source Licenses\",\n  \"or connect to\": \"or connect to\",\n  \"Packages\": \"Packages\",\n  \"Payment\": \"Payment\",\n  \"payOnWeb\": \"Pay on obscura.com\",\n  \"Period End\": \"Period End\",\n  \"Period Start\": \"Period Start\",\n  \"Pinned\": \"Pinned\",\n  \"pleaseCheckAgain\": \"Please check again\",\n  \"pleaseCopyAccountNumber\": \"Please write down your account number or store it in an encrypted vault\",\n  \"pleaseCopyAccountNumber_iphoneos\": \"Please <b>copy and save</b> your account number safely or <b>take a screenshot</b> to proceed\",\n  \"pleaseReportError\": \"Please report this error\",\n  \"pleaseWaitAMoment\": \"Please wait a moment...\",\n  \"proceedToPayment\": \"Proceed to Payment\",\n  \"processingPaymentTitle\": \"Activating Subscription\",\n  \"processingPaymentTitleMessage\": \"Your purchase was successful! Please wait a moment while we update your account status.\",\n  \"Provider\": \"Provider\",\n  \"publicKey\": \"Public key\",\n  \"purchaseFailed\": \"Purchase Failed\",\n  \"QuickConnect\": \"Quick Connect\",\n  \"Reconnecting\": \"Reconnecting\",\n  \"reconnectingObscura\": \"Reconnecting to Obscura\",\n  \"Redeem Code\": \"Redeem Code\",\n  \"refetchExitList\": \"Fetch list of exits\",\n  \"Refresh\": \"Refresh\",\n  \"relaunchNow\": \"Relaunch now\",\n  \"releaseNotes\": \"Release Notes\",\n  \"Renewal Date\": \"Renewal Date\",\n  \"Restore Purchases\": \"Restore Purchases\",\n  \"Reveal\": \"Reveal\",\n  \"revealFile\": \"Show {{ item }} in file system\",\n  \"revealFile_darwin\": \"Reveal {{ item }} in Finder\",\n  \"Rotated\": \"Rotated\",\n  \"rotateWgKey\": \"Rotate WireGuard® key\",\n  \"searchLocations\": \"Search locations\",\n  \"securedBy\": \"Secured by <0>{{ provider }}</0>\",\n  \"selectLocation\": \"Select Location\",\n  \"serverInfoHide\": \"Hide server info\",\n  \"serverInfoReveal\": \"Reveal server info\",\n  \"Settings\": \"Settings\",\n  \"settings.Version\": \"Version: {{ VERSION }}\",\n  \"shareLatestDebugArchive\": \"Share Archive\",\n  \"Show\": \"Show\",\n  \"show account number\": \"show account number\",\n  \"socials\": \"Socials\",\n  \"Source\": \"Source\",\n  \"Status\": \"Status\",\n  \"Stay Connected\": \"Stay Connected\",\n  \"strictLeakPreventionDescription\": \"Protects against routing table attacks from your local network\",\n  \"strictLeakPreventionLabel\": \"[Unstable] Enable Strict Leak Prevention\",\n  \"strictLeakPreventionLanWarning\": \"Enabling this feature also disables LAN access\",\n  \"strictLeakPreventionReliabilityWarning\": \"Keep this feature turned off for a reliable connection. You may encounter (re)connection issues on macOS and iOS when this is turned on.\",\n  \"strictLeakPreventionTooltip\": \"You must disconnect from Obscura VPN to disable this option\",\n  \"strictLeakPreventionWarning\": \"This feature protects against attacks from your local network, but there are known macOS and iOS bugs that sometimes cause (re)connection issues while this is turned on. For a reliable connection, we recommend you keep this feature turned off.\",\n  \"stripeStatus-active\": \"Active\",\n  \"stripeStatus-canceled\": \"Canceled\",\n  \"stripeStatus-incomplete\": \"Incomplete\",\n  \"stripeStatus-incomplete_expired\": \"Incomplete Expired\",\n  \"stripeStatus-past_due\": \"Past Due\",\n  \"stripeStatus-paused\": \"Paused\",\n  \"stripeStatus-trialing\": \"Trialing\",\n  \"stripeStatus-unpaid\": \"Unpaid\",\n  \"Subscribe In-app\": \"Subscribe In-app\",\n  \"subscribedOnWeb\": \"Subscribed on Obscura.net\",\n  \"Subscription\": \"Subscription\",\n  \"Success\": \"Success\",\n  \"supportMsg\": \"Reach out to <0>{{ email }}</0>\",\n  \"supportMsgOrDebugArchive\": \"Reach out or send debugging archive to <0>{{ email }}</0>\",\n  \"synchronizing\": \"Synchronizing\",\n  \"System\": \"System\",\n  \"toggleTheme\": \"Toggle theme\",\n  \"Top Up\": \"Top Up\",\n  \"tosAndPrivacyPolicy\": \"Terms of Service and Privacy Policy\",\n  \"trafficProtected\": \"Traffic is protected\",\n  \"trafficSuspended\": \"Traffic is suspended\",\n  \"trafficVulnerable\": \"Traffic is vulnerable\",\n  \"unresponsiveDaemonError\": \"Daemon is Unresponsive ({{ timeStamp }})\",\n  \"UNSET\": \"\",\n  \"updateAvailable\": \"update available: v{{ version }}\",\n  \"usedBy\": \"Used by\",\n  \"validatingAccount\": \"Validating Account...\",\n  \"viewLatestDebugArchive\": \"View Latest Debug Archive\",\n  \"viewServerInfo\": \"View server info\",\n  \"visitObscura\": \"visit <0>obscura.com</0>\",\n  \"VPN\": \"VPN\",\n  \"vpnError-accountExpired\": \"Your account has expired\",\n  \"vpnError-apiCallFailed\": \"API request failed. Please make sure you are connected to the internet and no other VPN or firewall is running.\",\n  \"vpnError-apiError\": \"API request failed. Please make sure you are connected to the internet and no other VPN or firewall is running.\",\n  \"vpnError-apiUnreachable\": \"Unable to connect to the Obscura API. You may be offline or something is interfering with your connection.\",\n  \"vpnError-deviceOffline\": \"Device is offline\",\n  \"vpnError-errorLegacyAlwaysOn\": \"Unable to request permission to run as a VPN on your device. Do you have a Legacy VPN profile with Always-On enabled in your device Settings?\",\n  \"vpnError-errorOtherAppAlwaysOn\": \"Unable to request permission to run as a VPN on your device. Do you have another VPN app with Always-On enabled in your device Settings?\",\n  \"vpnError-errorPermissionNotGranted\": \"Permission must be granted to run as a VPN on your device. Make another connection attempt to try again.\",\n  \"vpnError-invalidAccountId\": \"Account Number invalid (checksum failed)\",\n  \"vpnError-noInternet\": \"Not connected to the internet\",\n  \"vpnError-noLongerSupported\": \"This client is too old, please update to the latest version to connect\",\n  \"vpnError-noNetworkAvailable\": \"Not connected to a network\",\n  \"vpnError-noSlotsLeft\": \"No more than three tunnels may be active\",\n  \"vpnError-other\": \"An unknown error occurred, please send us a debugging archive which can be created from the status menu, Help tab, or About tab\",\n  \"vpnError-other_iphoneos\": \"An unknown error occurred, please send us a debugging archive which can be created from the About tab\",\n  \"vpnError-other_macosx\": \"An unknown error occurred, please send us a debugging archive which can be created from the status menu or the Help tab\",\n  \"vpnError-timeout\": \"The connection timed out. Is there another VPN or firewall running?\",\n  \"vpnError-tunnelConnectFailed\": \"Tunnel failed to connect\",\n  \"vpnError-tunnelLimitExceeded\": \"Too many simultaneous connections. Disconnect from another device to connect from this one.\",\n  \"wantExistingAccount\": \"Want to use an existing account instead? <0>Go back</0>\",\n  \"WARNING_one\": \"WARNING\",\n  \"WARNING_other\": \"WARNINGS\",\n  \"warningNotice_one\": \"{{ notice }}\",\n  \"warningNotice_other\": \"- {{ notice }}\",\n  \"WGConfigs\": \"WireGuard® Configurations\",\n  \"WGTrademark\": \"WireGuard® is a registered trademark of <0>Jason A. Donenfeld.</0>\",\n  \"willRelaunch\": \"GUI will close automatically. Please start the GUI manually if it does not relaunch automatically.\",\n  \"Yes\": \"Yes\",\n  \"yourDevice\": \"Your device\"\n}\n"
  },
  {
    "path": "obscura-ui/src/translations/i18n.ts",
    "content": "import i18n, { TFunction } from 'i18next';\nimport LanguageDetector from 'i18next-browser-languagedetector';\nimport { initReactI18next } from 'react-i18next';\nimport { CommandError } from '../bridge/commands';\nimport en from './en.json';\n\nexport type TranslationKey = keyof typeof en;\nexport const defaultNS = 'translations';\nexport const resources = {\n  en: {\n    [defaultNS]: en\n  }\n};\n\ni18n\n  .use(LanguageDetector)\n  .use(initReactI18next)\n  .init({\n    // we init with resources\n    resources,\n    fallbackLng: 'en',\n    debug: false,\n    ns: [defaultNS],\n    defaultNS: defaultNS,\n    // by default \".\". \"if working with a flat JSON, it's recommended to set this to false\"\n    keySeparator: false,\n    interpolation: {\n      escapeValue: false\n    }\n  });\n\nexport default i18n;\n\n// all errors over the bridge are CommandError's, see \"ipcError-*\" keys\nexport function fmtErrorI18n(t: TFunction, error: CommandError): string {\n  return t(error.i18nKey() as TranslationKey);\n}\n"
  },
  {
    "path": "obscura-ui/src/translations/i18next.d.ts",
    "content": "import { defaultNS, resources } from './i18n';\n\ndeclare module 'i18next' {\n  interface CustomTypeOptions {\n    defaultNS: typeof defaultNS;\n    resources: typeof resources.en;\n  }\n}\n"
  },
  {
    "path": "obscura-ui/src/views/About.module.css",
    "content": ".container {\n    padding-top: calc(20px + env(safe-area-inset-top, -20px));\n    padding-left: env(safe-area-inset-left);\n    padding-right: env(safe-area-inset-right);\n}\n\n@media screen and (max-width: $mantine-breakpoint-xs) {\n    .ossLicensesGroup {\n        flex-direction: column;\n        text-align: center;\n    }\n}\n"
  },
  {
    "path": "obscura-ui/src/views/About.tsx",
    "content": "import { Anchor, Button, Center, Flex, Group, Image, Loader, Modal, Space, Stack, Text, ThemeIcon, Title } from '@mantine/core';\nimport { useThrottledValue } from '@mantine/hooks';\nimport { lazy, Suspense, useContext, useEffect, useState } from 'react';\nimport { Trans, useTranslation } from 'react-i18next';\nimport { FaCheckCircle, FaExclamationTriangle } from 'react-icons/fa';\nimport AppIcon from '../../../apple/client/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x.png';\nimport * as commands from '../bridge/commands';\nimport { IS_HANDHELD_DEVICE } from '../bridge/SystemProvider';\nimport { LEGAL_WEBPAGE, OBSCURA_WEBPAGE } from '../common/accountUtils';\nimport { AppContext, UpdaterStatusType } from '../common/appContext';\nimport { MIN_LOAD_MS } from '../common/utils';\nimport DebuggingArchive from '../components/DebuggingArchive';\nimport ObscuraWordmark from '../components/ObscuraWordmark';\nimport { Socials } from '../components/Socials';\nimport classes from './About.module.css';\nimport { useNavigate } from 'react-router-dom';\n\nconst Licenses = lazy(() => import('../components/Licenses'));\n\nexport default function About() {\n  const { t } = useTranslation();\n  const { osStatus } = useContext(AppContext);\n  const { updaterStatus } = osStatus;\n  const [showLicenses, setShowLicenses] = useState(false);\n  const { execute: checkForUpdates } = commands.useCommand({ command: commands.checkForUpdates, showNotification: true, rethrow: false });\n  const { execute: installUpdate } = commands.useCommand({ command: commands.installUpdate, showNotification: true, rethrow: false });\n  const navigate = useNavigate();\n  const [_, setVersionClicks] = useState(0);\n\n  const handleVersionClick = () => {\n    setVersionClicks(clicks => {\n      if (clicks === 4) {\n        navigate('/developer');\n        return 0;\n      }\n      return clicks + 1;\n    });\n  }\n\n  useEffect(() => {\n    // Intentionally run only on mount (recheck once if update not already available)\n    if (updaterStatus?.type !== UpdaterStatusType.Available && !IS_HANDHELD_DEVICE) {\n      checkForUpdates();\n    }\n  }, []);\n  const updaterStatusDelayed = useThrottledValue(updaterStatus, updaterStatus.type === UpdaterStatusType.Initiated ? MIN_LOAD_MS : 0);\n  const isLatest = errorCodeIsLatestVersion(updaterStatusDelayed.errorCode);\n  return (\n    <Flex className={classes.container} gap='md' direction='column' justify='space-between' h='100vh'>\n      <Stack align='center' style={{ flexGrow: '1' }} justify='space-around'>\n        <Stack align='center'>\n          <Image src={AppIcon} w={120} />\n          <ObscuraWordmark />\n          <Group gap={0}>\n            {isLatest && <ThemeIcon variant='transparent' c='green.8'><FaCheckCircle /></ThemeIcon>}\n            {updaterStatusDelayed.type === UpdaterStatusType.Available && <ThemeIcon variant='transparent' c='yellow'><FaExclamationTriangle /></ThemeIcon>}\n            {updaterStatusDelayed.type === UpdaterStatusType.Initiated && <Loader size='xs' mr='xs' />}\n            <Text>\n              <span onClick={handleVersionClick}>{osStatus.srcVersion}</span>\n              {isLatest && <> ({t('latestVersion')})</>}\n              {updaterStatusDelayed.type === UpdaterStatusType.Available && <> ({t('updateAvailable', { version: updaterStatusDelayed.appcast!.version })})</>}\n            </Text>\n          </Group>\n          {(updaterStatusDelayed.type === UpdaterStatusType.NotFound || updaterStatusDelayed.type == UpdaterStatusType.Error) && (\n            <UpdaterError errorCode={updaterStatusDelayed.errorCode} error={updaterStatusDelayed.error!} />\n          )}\n          <Group>\n            {!IS_HANDHELD_DEVICE && <> {\n              updaterStatusDelayed?.type === UpdaterStatusType.Available ? (\n                <Button onClick={installUpdate}>{t('installUpdate')}</Button>\n              ) : (\n                <Button onClick={checkForUpdates}>{t('checkForUpdates')}</Button>\n              )\n            }</>}\n          </Group>\n        </Stack>\n        {IS_HANDHELD_DEVICE && <Stack gap='lg' p='md' pt={0} w='100%'>\n          <DebuggingArchive osStatus={osStatus} />\n          <Socials />\n        </Stack>}\n      </Stack>\n      <Stack pb={10} align='center' ta='center'>\n        <Text c='dimmed'>{t('copyright')}</Text>\n        <Modal opened={showLicenses} onClose={() => setShowLicenses(false)} size={IS_HANDHELD_DEVICE ? 'md' : 'lg'} mt={IS_HANDHELD_DEVICE ? 20 : undefined} title={<Title order={3}>{t('openSourceLicenses')}</Title>} centered>\n          <Suspense fallback={<Center><Loader type='bars' size='md' /></Center>}>\n            <Stack>\n              <Licenses />\n            </Stack>\n          </Suspense>\n        </Modal>\n        <Text><Trans i18nKey='visitObscura' components={[<Anchor href={OBSCURA_WEBPAGE} />]} /></Text>\n        <Flex className={classes.ossLicensesGroup} gap='md'>\n          <Anchor onClick={() => setShowLicenses(o => !o)}>\n            {t('openSourceLicenses')}\n          </Anchor>\n          <Anchor href={LEGAL_WEBPAGE}>{t('tosAndPrivacyPolicy')}</Anchor>\n        </Flex>\n        <Text c='dimmed'>{<Trans i18nKey='WGTrademark' components={[<Text component='span' display='inline-block' />]} />}</Text>\n        {IS_HANDHELD_DEVICE && <Space h='md' />}\n      </Stack>\n    </Flex>\n  );\n}\n\ninterface UpdaterErrorProps {\n  errorCode?: number,\n  error: string\n}\n\nfunction errorCodeIsLatestVersion(errorcode: number | undefined) {\n  return errorcode === 1 || errorcode == 2;\n}\n\nfunction UpdaterError({ errorCode, error }: UpdaterErrorProps) {\n  switch (errorCode) {\n    // Project version matches\n    case 1:\n    // Project version exceeds the update\n    case 2:\n      return null;\n    default:\n      return <Text c='red'>{error}</Text>;\n  }\n}\n"
  },
  {
    "path": "obscura-ui/src/views/AccountView.module.css",
    "content": ".container {\n    padding: 20px 60px;\n    gap: var(--mantine-spacing-xl) !important;\n}\n\n.accountStatusCardBox {\n    flex-basis: 0;\n}\n\n@media screen and (max-width: $mantine-breakpoint-xs) {\n    .container {\n        padding-top: calc(20px + env(safe-area-inset-top));\n        padding-bottom: 0px;\n        padding-left: 20px;\n        padding-right: 20px;\n        gap: var(--mantine-spacing-md) !important;\n    }\n}\n"
  },
  {
    "path": "obscura-ui/src/views/AccountView.tsx",
    "content": "import { Anchor, Box, Button, Center, Code, Group, Loader, Paper, Stack, Text, ThemeIcon, useMantineTheme } from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport { notifications } from '@mantine/notifications';\nimport React, { useContext, useEffect } from 'react';\nimport { Trans, useTranslation } from 'react-i18next';\nimport { BsQuestionSquareFill } from 'react-icons/bs';\nimport { FaRotateRight } from 'react-icons/fa6';\nimport { IoLogOutOutline, IoNuclear } from 'react-icons/io5';\nimport { MdOutlineWifiOff } from 'react-icons/md';\nimport * as commands from '../bridge/commands';\nimport { IS_HANDHELD_DEVICE } from '../bridge/SystemProvider';\nimport * as ObscuraAccount from '../common/accountUtils';\nimport { AccountInfo, accountIsExpired, accountTimeRemaining, hasActiveSubscription, hasAppleSubscription, hasCredit, isRenewing, paidUntil, useReRenderWhenExpired } from '../common/api';\nimport { AppContext, NEVPNStatus } from '../common/appContext';\nimport commonClasses from '../common/common.module.css';\nimport { normalizeError } from '../common/utils';\nimport { AccountNumberSection } from '../components/AccountNumberSection';\nimport { ButtonLink } from '../components/ButtonLink';\nimport { ConfirmationDialog } from '../components/ConfirmationDialog';\nimport { PaymentManagementSheet } from '../components/PaymentManagementSheet';\nimport AccountExpiredBadge from '../res/account-expired.svg?react';\nimport PaidUpExpiringSoonBadge from '../res/paid-up-expiring-soon.svg?react';\nimport PaidUpExpiringVerySoonBadge from '../res/paid-up-expiring-very-soon.svg?react';\nimport PaidUpSubscriptionActive from '../res/paid-up-subscription-active.svg?react';\nimport PaidUpBadge from '../res/paid-up.svg?react';\nimport SubscriptionActiveBadge from '../res/subscription-active.svg?react';\nimport SubscriptionPausedBadge from '../res/subscription-paused.svg?react';\nimport { fmtErrorI18n } from '../translations/i18n';\nimport classes from './AccountView.module.css';\n\nexport default function Account() {\n  const { t } = useTranslation();\n  const { appStatus, pollAccount, osStatus } = useContext(AppContext);\n  const [confirmDeleteAccount, { open: openDeleteAccount, close: closeDeleteAccount }] = useDisclosure(false);\n  const [confirmLogOut, { open: openLogOutConfirm, close: closeLogOutConfirm }] = useDisclosure(false);\n  const osVpnConnected = osStatus.osVpnStatus === NEVPNStatus.Connected;\n\n  useEffect(() => {\n    // Ensure account info is up-to-date when the user is viewing the account page.\n    void pollAccount();\n  }, []);\n\n  const logOut = async () => {\n    try {\n      await commands.disconnect();\n      await commands.logout();\n      await commands.resetOfferCodeRedemptionSuccess();\n    } catch (e) {\n      const error = normalizeError(e);\n      notifications.show({ title: t('logOutFailed'), message: <Text>{t('pleaseReportError')}<br /><Code>{error.message}</Code></Text> });\n    } finally {\n      closeLogOutConfirm();\n    }\n  }\n\n  const deleteAccount = async () => {\n    try {\n      await commands.deleteAccount();\n      await logOut();\n    } catch (e) {\n      const error = normalizeError(e);\n      notifications.show({ title: t('deleteAccountFailed'), message: <Text>{t('pleaseReportError')}<br /><Code>{error.message}</Code></Text>, color: 'red' });\n    }\n  }\n\n  // vpnStatus is used because accountInfo will be null if pollAccount fails\n  const accountId = appStatus.accountId;\n  const accountInfo = appStatus.account?.account_info;\n  return <>\n    <ConfirmationDialog opened={confirmDeleteAccount} onClose={closeDeleteAccount} drawerSize='md'>\n      <Stack h='100%' justify='space-between'>\n        <Stack p={IS_HANDHELD_DEVICE ? 'xl' : undefined} ta={IS_HANDHELD_DEVICE ? 'center' : undefined}>\n          <Text>{t('deleteAccountConfirmationStart')}</Text>\n          {hasCredit(accountInfo) && <Text>{t('deleteAccountConfirmationCredit')}</Text>}\n          {hasAppleSubscription(accountInfo) && <Text>{t('deleteAccountConfirmationAppleSubscription')}</Text>}\n          <Text>{t('deleteAccountConfirmationEnd')}</Text>\n        </Stack>\n        <DeleteAccount onClick={deleteAccount} />\n      </Stack>\n    </ConfirmationDialog>\n    <ConfirmationDialog opened={confirmLogOut} onClose={closeLogOutConfirm} title={t('logOut')} drawerCloseButton={false}>\n      <Stack h='100%' justify='space-between'>\n        <Text><Trans i18nKey='logOutPrompt' context={osStatus.osVpnStatus === NEVPNStatus.Connected ? 'connected' : 'disconnected'} components={{ b: <b /> }} /></Text>\n        <Group justify='flex-end' gap='sm' w='100%' grow={IS_HANDHELD_DEVICE}>\n          <Button variant='default' onClick={closeLogOutConfirm}>{t('Cancel')}</Button>\n          <Button color='red.7' onClick={logOut}>\n            <Group gap={5} ml={0}>\n              <IoLogOutOutline size={19} />\n              <Text fw={550}>{t('logOut')}</Text>\n            </Group>\n          </Button>\n        </Group>\n      </Stack>\n    </ConfirmationDialog>\n    <Stack align='center' className={classes.container}>\n      <AccountStatusCard />\n      <AccountNumberSection accountId={accountId} logOut={openLogOutConfirm} />\n      <WGConfigurations />\n      <DeleteAccount onClick={openDeleteAccount} />\n      <MobileLogOut logOut={openLogOutConfirm} />\n    </Stack>\n  </>;\n}\n\ninterface AccountStatusProps {\n  accountInfo: AccountInfo,\n}\n\nfunction AccountStatusCard() {\n  const { appStatus } = useContext(AppContext);\n  const { account } = appStatus;\n\n  useReRenderWhenExpired(account);\n\n  if (account === null) return <AccountInfoUnavailable />;\n\n  const accountInfo = account.account_info;\n  const creditExpiresAt = accountInfo.top_up?.credit_expires_at;\n  const topupExpires = creditExpiresAt !== undefined ? new Date(creditExpiresAt * 1000) : undefined;\n  const topUpActive = topupExpires !== undefined && topupExpires.getTime() > new Date().getTime();\n  if (accountIsExpired(accountInfo)) {\n    return <AccountExpired />\n  } else if (isRenewing(accountInfo) && topUpActive) {\n    return <AccountPaidUpSubscriptionActive accountInfo={accountInfo} />\n  } else if (isRenewing(accountInfo)) {\n    return <SubscriptionActive accountInfo={accountInfo} />\n  } else if (hasActiveSubscription(accountInfo)) {\n    return <SubscriptionPaused accountInfo={accountInfo} />\n  }\n  const { days: daysLeft } = accountTimeRemaining(accountInfo);\n  if (daysLeft < 10)\n    return <AccountExpiringSoon accountInfo={accountInfo} />;\n  return <AccountPaidUp accountInfo={accountInfo} />\n}\n\nfunction AccountInfoUnavailable() {\n  const { t } = useTranslation();\n  const {\n    osStatus\n  } = useContext(AppContext);\n  const { internetAvailable } = osStatus;\n  return (\n    <AccountStatusCardTemplate\n      icon={<ThemeIcon c='red.7' variant='transparent'>{internetAvailable ? <BsQuestionSquareFill size={26} /> : <MdOutlineWifiOff size={26} />}</ThemeIcon>}\n      heading={t('account-InfoUnavailable')}\n      subtitle={<Text size='sm' c='dimmed'>{internetAvailable ? t('pleaseCheckAgain') : t('noInternet')}</Text>}\n    />\n  );\n}\n\nfunction AccountPaidUpSubscriptionActive({ accountInfo }: AccountStatusProps) {\n  const { t } = useTranslation();\n  const topupExpires = new Date(accountInfo.top_up!.credit_expires_at * 1000);\n  const endDate = topupExpires.toLocaleDateString();\n  return (\n    <AccountStatusCardTemplate\n      icon={<PaidUpSubscriptionActive />}\n      heading={t('account-SubscriptionActive')}\n      subtitle={<Text size='sm' c='dimmed'>{t('account-SubscriptionWillStart', { endDate })}</Text>}\n    />\n  );\n}\n\nfunction SubscriptionActive({ accountInfo }: AccountStatusProps) {\n  const { t } = useTranslation();\n  const accountPaidUntil = paidUntil(accountInfo);\n  const { days: daysLeft } = accountTimeRemaining(accountInfo);\n  const tOptions = {\n    count: daysLeft,\n    endDate: accountPaidUntil!.toLocaleDateString(),\n    context: `${daysLeft}`\n  };\n  return (\n    <AccountStatusCardTemplate\n      icon={<SubscriptionActiveBadge />}\n      heading={t('account-SubscriptionActive')}\n      subtitle={<Text size='sm' c='dimmed'>{t('account-SubscriptionRenewsOn', tOptions)}</Text>}\n    />\n  );\n}\n\nfunction SubscriptionPaused({ accountInfo }: AccountStatusProps) {\n  const { t } = useTranslation();\n  const accountPaidUntil = paidUntil(accountInfo);\n  const endDate = accountPaidUntil!.toLocaleDateString();\n  return (\n    <AccountStatusCardTemplate\n      icon={<SubscriptionPausedBadge />}\n      heading={t('account-SubscriptionPaused')}\n      subtitle={<Text size='sm' c='dimmed'>{t('account-SubscriptionAutoRenewSubtitle', { endDate })}</Text>}\n    />\n  );\n}\n\nfunction AccountExpired() {\n  const { t } = useTranslation();\n  return (\n    <AccountStatusCardTemplate\n      icon={<AccountExpiredBadge />}\n      heading={t('account-Expired')}\n      subtitle={<Text size='sm' c='dimmed'>{t('continueUsingObscura')}</Text>}\n    />\n  );\n}\n\nfunction AccountPaidUp({ accountInfo }: AccountStatusProps) {\n  const { t } = useTranslation();\n  const accountPaidUntil = paidUntil(accountInfo);\n  const { days: daysLeft } = accountTimeRemaining(accountInfo);\n  const tOptions = {\n    count: daysLeft,\n    endDate: accountPaidUntil!.toLocaleDateString(),\n    context: `${daysLeft}`\n  };\n  return (\n    <AccountStatusCardTemplate\n      icon={<PaidUpBadge />}\n      heading={t('account-PaidUp')}\n      subtitle={<Text size='sm' c='dimmed'><Trans i18nKey='account-ExpiresOn' values={tOptions} components={[<Text component='span' display='inline-block' fw='bold' />]} /></Text>}\n    />\n  );\n}\n\nfunction AccountExpiringSoon({ accountInfo }: AccountStatusProps) {\n  const { t } = useTranslation();\n  const accountPaidUntil = paidUntil(accountInfo);\n  const timeRemaining = accountTimeRemaining(accountInfo);\n  const { days, hours, minutes } = timeRemaining;\n\n  const { heading, subtitle } = useExpiryMessages(\n    days,\n    hours,\n    minutes,\n    accountPaidUntil!\n  );\n\n  return (\n    <AccountStatusCardTemplate\n      icon={days < 5 ? <PaidUpExpiringVerySoonBadge /> : <PaidUpExpiringSoonBadge />}\n      heading={heading}\n      subtitle={\n        <Stack gap={0}>\n          <Text size='sm'>{subtitle}</Text>\n          <Text size='sm' c='dimmed'>{t('continueUsingObscura')}</Text>\n        </Stack>\n      }\n    />\n  );\n}\n\ninterface ExpiryMessages {\n  heading: string;\n  subtitle: string;\n}\n\nfunction useExpiryMessages(\n  days: number,\n  hours: number,\n  minutes: number,\n  accountPaidUntil: Date\n): ExpiryMessages {\n  const { t } = useTranslation();\n  let heading: string;\n  let subtitle: string;\n  let expiryInfo: { count: number; endDate: string };\n\n  if (days === 0 && hours === 0) {\n    // Show minutes when less than 1 hour remains\n    expiryInfo = {\n      count: minutes,\n      endDate: accountPaidUntil.toLocaleDateString(),\n    };\n    heading = t('account-MinutesUntilExpiry', expiryInfo);\n    subtitle = t('account-ExpiresInMinutes', expiryInfo);\n  } else if (days === 0) {\n    // Show hours when less than 1 day but at least 1 hour remains\n    expiryInfo = {\n      count: hours,\n      endDate: accountPaidUntil.toLocaleDateString(),\n    };\n    heading = t('account-HoursUntilExpiry', expiryInfo);\n    subtitle = t('account-ExpiresInHours', expiryInfo);\n  } else {\n    // Show days when 1 or more days remain\n    expiryInfo = {\n      count: days,\n      endDate: accountPaidUntil.toLocaleDateString(),\n    };\n    const verySoon = days < 5;\n    heading = t('account-DaysUntilExpiry', expiryInfo);\n    subtitle = t(verySoon ? 'account-ExpiresVerySoon' : 'account-ExpiresSoon', expiryInfo);\n  }\n\n  return { heading, subtitle };\n}\n\ninterface AccountStatusCardTemplateProps {\n  icon: React.ReactNode,\n  heading: string,\n  subtitle: React.ReactNode,\n}\n\nfunction AccountStatusCardTemplate({\n  icon,\n  heading,\n  subtitle\n}: AccountStatusCardTemplateProps) {\n  const { t } = useTranslation();\n  const { appStatus } = useContext(AppContext);\n  return (\n    <Paper w='100%' p='md' radius='md' shadow='sm' className={commonClasses.elevatedSurface}>\n      <Group grow preventGrowOverflow={false}>\n        <Box maw='min-content'>\n          {icon}\n        </Box>\n        <Box className={classes.accountStatusCardBox}>\n          <Text fw={500}>{heading}</Text>\n          {subtitle}\n        </Box>\n        <Stack maw='min-content' visibleFrom='xs'>\n          <Group justify='right'>\n            <AccountRefreshButton smallerSize />\n          </Group>\n          <ManagePaymentsButton />\n        </Stack>\n      </Group>\n      <Group grow mt='xs' hiddenFrom='xs'>\n        <Group justify='center'>\n          <AccountRefreshButton />\n        </Group>\n        <ManagePaymentsButton mobile />\n      </Group>\n    </Paper>\n  );\n}\n\nfunction AccountRefreshButton({ smallerSize = false }: { smallerSize?: boolean }) {\n  const { t } = useTranslation();\n  const { pollAccount, accountLoading } = useContext(AppContext);\n  const theme = useMantineTheme();\n\n  const onRefresh = async () => {\n    try {\n      await pollAccount();\n    } catch (e) {\n      const error = normalizeError(e);\n      const message = error instanceof commands.CommandError\n        ? fmtErrorI18n(t, error) : error.message;\n      notifications.show({\n        title: t('Account Error'),\n        message: message,\n        color: 'red',\n      });\n    }\n  }\n\n  const smallerRefresh = smallerSize && !IS_HANDHELD_DEVICE;\n\n  return (\n    <Anchor onClick={onRefresh} fw={550} c={theme.primaryColor}\n      size={smallerRefresh ? 'sm' : 'md'}>\n      {accountLoading ? (\n        <Center w={{ base: undefined, xs: 60 }}>\n          {/* set height of loader to avoid layout shifts */}\n          <Loader h='1.25rem' size={smallerRefresh ? 'xs' : 'sm'} />\n        </Center>\n      ) :\n        <Group gap={5}><FaRotateRight size={smallerRefresh ? 11 : 13} /> {t('Refresh')}</Group>\n      }\n    </Anchor>\n  );\n}\n\nfunction WGConfigurations() {\n  const { t } = useTranslation();\n  const theme = useMantineTheme();\n  const { appStatus } = useContext(AppContext);\n  return <>\n    <Stack align='start' w='100%' p='md' style={{ borderRadius: theme.radius.md, boxShadow: theme.shadows.sm }} className={commonClasses.elevatedSurface}>\n      <Group w='100%' justify='space-between'>\n        <Text fw={500}>{t('WGConfigs')}</Text>\n        <ButtonLink href={ObscuraAccount.tunnelsUrl(appStatus.accountId)}>{t('Manage Configurations')}</ButtonLink>\n      </Group>\n    </Stack>\n  </>\n}\n\nfunction DeleteAccount({ onClick }: { onClick: () => void }) {\n  const { t } = useTranslation();\n  return <Group align='start' w='100%'>\n    <Button onClick={onClick} variant='light' color='red.7' w={{ base: '100%', xs: 'auto' }}>\n      <Group gap={5} ml={0}>\n        <IoNuclear size={19} />\n        <Text fw={550}>{t('deleteAccount')}</Text>\n      </Group>\n    </Button>\n  </Group>;\n}\n\nfunction ManagePaymentsButton({ mobile = false }: { mobile?: boolean }) {\n  const { t } = useTranslation();\n  const { appStatus } = useContext(AppContext);\n  const [paymentSheetOpened, { open: openPaymentSheet, close: closePaymentSheet }] = useDisclosure(false);\n\n  if (IS_HANDHELD_DEVICE) {\n    return <>\n      <PaymentManagementSheet opened={paymentSheetOpened} onClose={closePaymentSheet} />\n      <Button onClick={openPaymentSheet} w={{ base: '100%', xs: 'auto' }}>\n        {mobile ? t('Manage') : t('Manage Payments')}\n      </Button>\n    </>;\n  }\n\n  return (\n    <ButtonLink\n      href={ObscuraAccount.payUrl(appStatus.accountId)}\n    >{mobile ? t('Manage') : t('Manage Payments')}</ButtonLink>\n  );\n}\n\nfunction MobileLogOut({ logOut }: { logOut: () => void }) {\n  const { t } = useTranslation();\n  return <>\n    <Button className={commonClasses.mobileOnly} fw='bolder' onClick={logOut} color='red.7' variant='outline' w='100%'>\n      <Group gap={5} w='100%'>\n        <IoLogOutOutline size={19} />\n        <Text fw={550}>{t('logOut')}</Text>\n      </Group>\n    </Button>\n  </>;\n}\n"
  },
  {
    "path": "obscura-ui/src/views/ConnectionView.module.css",
    "content": ".container {\n    background-repeat: no-repeat;\n    background-size: contain;\n    background-position: bottom;\n}\n\n.connectionProgressBarContainer {\n    width: 80%;\n    min-width: fit-content;\n    margin-bottom: var(--mantine-spacing-lg);\n    background-color: light-dark(var(--mantine-color-dark-8), var(--mantine-color-dark-6)) !important;\n}\n\n.progress, .trafficVulnerableProgressBar {\n    background-color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-3)) !important;\n}\n\n.connectionProgressBarGroup {\n    justify-content: center !important;\n\n    @media (min-width: 600px) {\n        justify-content: space-between !important;\n    }\n}\n\n.trafficVulnerableProgressBar {\n    width: calc(100vw * 0.33);\n\n    @media (min-width: 700px) {\n        width: 325px;\n    }\n}\n\n.locationDividerCaption {\n    width: 28rem;\n}\n\n.subtitle {\n    color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-2));\n}\n\n.checkMyConnection {\n    color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-5));\n}\n\n@media screen and (max-width: $mantine-breakpoint-xs) {\n    .container {\n        padding-top: env(safe-area-inset-top);\n        padding-left: env(safe-area-inset-left);\n        padding-right: env(safe-area-inset-right);\n    }\n\n    .connectionProgressBarContainer {\n        width: 85%;\n    }\n\n    .connectionProgressBarGroup {\n        gap: 0 !important;\n    }\n\n    .trafficVulnerable {\n        padding-left: var(--mantine-spacing-lg) !important;\n        padding-right: var(--mantine-spacing-lg) !important;\n    }\n\n    .locationDividerCaption {\n        width: 320px;\n    }\n}\n\n@media screen and (max-height: $mantine-breakpoint-xs) {\n    .connectionProgressBarContainer {\n        margin-bottom: var(--mantine-spacing-sm);\n    }\n}\n"
  },
  {
    "path": "obscura-ui/src/views/ConnectionView.tsx",
    "content": "import { Anchor, Button, Combobox, Divider, Flex, Group, Image, Paper, Progress, ProgressRootProps, ScrollArea, Space, Stack, Text, ThemeIcon, Title, useCombobox, useMantineTheme } from '@mantine/core';\nimport { useFocusTrap, useInterval, useToggle } from '@mantine/hooks';\nimport { notifications } from '@mantine/notifications';\nimport { ReactNode, useContext, useEffect, useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { BsChevronDown, BsPinFill } from 'react-icons/bs';\nimport { IoIosEyeOff } from 'react-icons/io';\nimport { MdLanguage, MdLaptopMac, MdOutlineWifiOff } from 'react-icons/md';\nimport { ExitSelector, ExitSelectorCity } from 'src/bridge/commands';\nimport { CONNECT_REQUIRES_ONLINE } from '../bridge/SystemProvider';\nimport * as ObscuraAccount from '../common/accountUtils';\nimport { accountIsExpired, Exit, getContinent, getExitCountry, useReRenderWhenExpired } from '../common/api';\nimport { AppContext, ConnectionInProgress, getCityFromStatus, isConnecting, NEVPNStatus, PinnedLocation, useIsConnecting, useIsTransitioning } from '../common/appContext';\nimport commonClasses from '../common/common.module.css';\nimport { exitCityEquals, exitLocation, exitsSortComparator, getCountryFlag, getExitCountryFlag } from '../common/exitUtils';\nimport { KeyedSet } from '../common/KeyedSet';\nimport { useExitList } from '../common/useExitList';\nimport { useCookie } from '../common/utils';\nimport BoltBadgeAuto from '../components/BoltBadgeAuto';\nimport { CColorSchemeContext } from '../components/CachedColorScheme';\nimport ExternalLinkIcon from '../components/ExternalLinkIcon';\nimport ObscuraChip from '../components/ObscuraChip';\nimport DecoConnected from '../res/deco/deco-connected.svg';\nimport DecoConnectingDark1 from '../res/deco/deco-connecting-dark-1.svg';\nimport DecoConnectingDark2 from '../res/deco/deco-connecting-dark-2.svg';\nimport DecoConnectingDark3 from '../res/deco/deco-connecting-dark-3.svg';\nimport DecoConnectingLight1 from '../res/deco/deco-connecting-light-1.svg';\nimport DecoConnectingLight2 from '../res/deco/deco-connecting-light-2.svg';\nimport DecoConnectingLight3 from '../res/deco/deco-connecting-light-3.svg';\nimport DecoDisconnectedDark from '../res/deco/deco-disconnected-dark.svg';\nimport DecoDisconnectedLight from '../res/deco/deco-disconnected-light.svg';\nimport DecoOfflineDark from '../res/deco/deco-offline-dark.svg';\nimport DecoOfflineLight from '../res/deco/deco-offline-light.svg';\nimport MascotConnectedFirstTime from '../res/mascots/connected-first-time-mascot.svg';\nimport MascotConnected from '../res/mascots/connected-mascot.svg';\nimport MascotConnecting1 from '../res/mascots/connecting-1-mascot.svg';\nimport MascotConnecting2 from '../res/mascots/connecting-2-mascot.svg';\nimport MascotConnecting3 from '../res/mascots/connecting-3-mascot.svg';\nimport MascotConnecting4 from '../res/mascots/connecting-4-mascot.svg';\nimport MascotDead from '../res/mascots/dead-mascot.svg';\nimport MascotNotConnected from '../res/mascots/not-connected-mascot.svg';\nimport MascotValidating from '../res/mascots/validating-mascot.svg';\nimport ObscuraIconHappy from '../res/obscura-icon-happy.svg';\nimport classes from './ConnectionView.module.css';\n\n// Los Angeles, CA\nconst BUTTON_WIDTH = 320;\n\nexport default function Connection() {\n    const { t } = useTranslation();\n    const { vpnConnected, initiatingExitSelector, connectionInProgress, osStatus, appStatus, showOfflineUI } = useContext(AppContext);\n    const connectionTransition = useIsTransitioning();\n\n    const { account } = appStatus;\n    const { exitList } = useExitList({\n        periodS: 3600,\n    });\n    const accountInfo = account?.account_info ?? null;\n\n    useReRenderWhenExpired(account);\n\n    const targetCity = getCityFromStatus(appStatus.vpnStatus);\n    const specificInitiation = !(initiatingExitSelector === undefined || 'any' in initiatingExitSelector);\n    const isCityConnect = (targetCity !== undefined || specificInitiation);\n    const accountHasExpired = accountInfo !== null && accountIsExpired(accountInfo);\n\n    /* If quick connect is used, don't show the combobox while connecting to avoid confusion.\n      This is because we don't want the user to think they are connecting to the last chosen location.\n      It's possible in the future that we can propagate which location is being connected to while connecting.\n      Additionally, we don't want to show the dropdown when not connected AND a connection pre-requisite is missing.\n      If we're already connected and the account expires, we still want to show the disconnect.\n      Only when we're no longer connected should the pre-requisite take precedence.\n    */\n    const showLocationSelect = (isCityConnect || osStatus.osVpnStatus !== NEVPNStatus.Connecting) && (vpnConnected || accountInfo !== null && !accountHasExpired);\n    /* The primary button is hidden in the following scenarios:\n      1) connected\n      2) not connecting via quick connect (i.e. connecting to a user-selected location)\n      3) disconnecting\n    */\n    const primaryButtonShown = !(vpnConnected || (isCityConnect && osStatus.osVpnStatus !== NEVPNStatus.Disconnected) || osStatus.osVpnStatus === NEVPNStatus.Disconnecting);\n\n    const getTitle = () => {\n        if (accountHasExpired) return t('account-Expired');\n        if (connectionTransition) {\n            const sampleExit = targetCity && exitList?.find(e => e.city_code == targetCity.city_code && e.country_code == targetCity.country_code);\n\n            switch (connectionInProgress) {\n                case ConnectionInProgress.Connecting:\n                case ConnectionInProgress.ChangingLocations:\n                    return t('connectingTo', { location: sampleExit?.city_name ?? 'Obscura' });\n                case ConnectionInProgress.Reconnecting:\n                    return t('reconnectingObscura');\n                case ConnectionInProgress.Disconnecting:\n                    return t('Disconnecting');\n            }\n        }\n        if (vpnConnected) return t('connectedToObscura');\n        if (showOfflineUI) return t('disconnected');\n        if (accountInfo === null) return t('validatingAccount')\n        return t('notConnected');\n    }\n\n    const Subtitle = () => {\n        if (vpnConnected && !connectionTransition) return t('enjoyObscura');\n        if (accountHasExpired) return t('continueUsingObscura');\n        if (connectionTransition) return t('pleaseWaitAMoment');\n        if (showOfflineUI) return t('connectToInternet');\n        if (accountInfo === null) return '';\n        return t('connectToEnjoy');\n    }\n\n    return (\n        <Stack align='center' h='100vh' gap={0} className={classes.container} style={{ backgroundImage: `url(\"${Deco()}\")`}}>\n            <Space h={40} />\n            <Mascot />\n            <Stack align='center' gap={primaryButtonShown ? 0 : 20} mt={primaryButtonShown ? 0 : 20} justify='space-around'>\n                <Title order={2} fw={600}>{getTitle()}</Title>\n                <Title order={4} ta='center' mt={5} mih='xl' fw={350} className={classes.subtitle}>{Subtitle()}</Title>\n            </Stack>\n            <Space h='xs' />\n            <PrimaryConnectButton />\n            <Space h='xs' />\n            {showLocationSelect && <LocationSelect />}\n            {\n                vpnConnected && connectionInProgress === ConnectionInProgress.UNSET && <div className={classes.checkMyConnection}>\n                    <Space />\n                    <Text className={classes.checkMyConnection}><Anchor c='inherit' href={ObscuraAccount.CHECK_STATUS_WEBPAGE} underline='always'>{t('checkMyConnection')}</Anchor> <ExternalLinkIcon size={12} style={{ marginBottom: -1 }} /></Text>\n                </div>\n            }\n            <div style={{ flexGrow: 1 }} />\n            <ConnectionProgressBar />\n            <Space />\n        </Stack >\n    );\n}\n\n/**\n * One of: Quick Connect, Manage Account, Cancel Connecting, Disconnecting (disabled)\n */\nfunction PrimaryConnectButton() {\n  const { t } = useTranslation();\n  const theme = useMantineTheme();\n  const { vpnConnected, connectionInProgress, initiatingExitSelector, osStatus, vpnConnect, vpnDisconnect, appStatus } = useContext(AppContext);\n  const { internetAvailable } = osStatus;\n  const { account } = appStatus;\n  const connectionTransition = connectionInProgress !== ConnectionInProgress.UNSET;\n  const inConnectingState = useIsConnecting();\n\n  const accountInfo = account?.account_info ?? null;\n  const accountHasExpired = accountInfo !== null && accountIsExpired(accountInfo);\n\n  const targetCity = getCityFromStatus(appStatus.vpnStatus);\n  const specificInitiation = !(initiatingExitSelector === undefined || 'any' in initiatingExitSelector);\n  const connectingToCity = (targetCity !== undefined || specificInitiation) && (osStatus.osVpnStatus !== NEVPNStatus.Disconnected || appStatus.vpnStatus.connecting !== undefined);\n\n  if (inConnectingState && !connectingToCity && osStatus.osVpnStatus !== NEVPNStatus.Disconnecting) {\n    return <>\n      <Space h='lg' />\n      <Button w={BUTTON_WIDTH} {...theme.other.buttonDisconnectProps} mt={5} onClick={vpnDisconnect}>{t('Cancel Connecting')}</Button>\n    </>\n  }\n\n  if (!vpnConnected && accountInfo !== null && accountHasExpired) {\n    return <Button component='a' href={ObscuraAccount.APP_ACCOUNT_TAB}>{t('ManageAccount')}</Button>;\n  }\n\n  const showQuickConnect = !vpnConnected && !connectingToCity && accountInfo !== null && !appStatus.vpnStatus.connecting && osStatus.osVpnStatus !== NEVPNStatus.Disconnecting;\n  const qcBtnAction = (_: MouseEvent) => vpnConnected ? vpnDisconnect() : vpnConnect({ any: {} });\n  const qcBtnDisabled = (!internetAvailable && CONNECT_REQUIRES_ONLINE) || connectionTransition;\n  const primaryBtnDisconnectProps = (vpnConnected && connectionInProgress !== ConnectionInProgress.Reconnecting) ? theme.other.buttonDisconnectProps : {};\n\n  if (showQuickConnect) {\n    const buttonContent = connectionTransition ? t(connectionInProgress) + '...' : <Group gap={5}><BoltBadgeAuto />{t('QuickConnect')}</Group>;\n    return <Button size='md' className={commonClasses.button} onClick={qcBtnAction} w={BUTTON_WIDTH} disabled={qcBtnDisabled} {...primaryBtnDisconnectProps}>{buttonContent}</Button>\n  }\n}\n\nfunction ConnectionProgressBar() {\n    const { t } = useTranslation();\n    const {\n        vpnConnected,\n        connectionInProgress,\n        showOfflineUI\n    } = useContext(AppContext);\n\n    const progressWidth = { base: 40, xs: 50 };\n    const offlineProgressWidth = { base: 50, xs: 80 };\n\n    const connectingProgressBars = usePulsingProgress({ activated: isConnecting(connectionInProgress), bars: 2, w: progressWidth });\n    return (\n        <Paper shadow='xl' withBorder className={classes.connectionProgressBarContainer} maw={600} p={{base: 'xs', xs: 'md'}} pt={5} pb='xs' radius='lg'>\n            <Group mih={50} className={classes.connectionProgressBarGroup} align='center'>\n                <Stack gap='0' align='center'>\n                    <ThemeIcon variant='transparent' c='white'>\n                        <MdLaptopMac size={20} />\n                    </ThemeIcon>\n                    <Text size='xs' c='white'>{t('yourDevice')}</Text>\n                </Stack>\n                {\n                    showOfflineUI &&\n                    <>\n                        <Progress className={classes.progress} w={offlineProgressWidth} value={0} h={2} />\n                        <Stack gap='0' align='center'>\n                            <ThemeIcon variant='transparent' c='red.6'>\n                                <MdOutlineWifiOff size={22} />\n                            </ThemeIcon>\n                            <Text size='xs' c='red'>{t('noInternet')}</Text>\n                        </Stack>\n                        <Progress className={classes.progress} w={offlineProgressWidth} value={0} h={2} />\n                    </>\n                }\n                {\n                    !showOfflineUI && (connectionInProgress === ConnectionInProgress.Disconnecting || (!vpnConnected && connectionInProgress === ConnectionInProgress.UNSET)) &&\n                    <Stack gap='xs' align='center' justify='flex-end' h={50} className={classes.trafficVulnerable}>\n                        <Progress className={classes.trafficVulnerableProgressBar} value={100} color='red.6' h={2} />\n                        <Text size='xs' c='red.6'>\n                            {t('trafficVulnerable')}\n                        </Text>\n                    </Stack>\n                }\n                {\n                    ((vpnConnected && connectionInProgress === ConnectionInProgress.UNSET) || isConnecting(connectionInProgress)) && <>\n                    {connectingProgressBars[0]}\n                    <Stack gap='0' align='center'>\n                        <ThemeIcon variant='transparent' c='white'>\n                            <Image src={ObscuraIconHappy} w={20} />\n                        </ThemeIcon>\n                        <Text size='xs' c='white'>Obscura</Text>\n                    </Stack>\n                    {connectingProgressBars[1]}\n                    <Stack gap='0' align='center'>\n                        <ThemeIcon variant='transparent' c={(vpnConnected && connectionInProgress !== ConnectionInProgress.ChangingLocations) ? 'white' : 'dimmed'}>\n                            <IoIosEyeOff size={20} />\n                        </ThemeIcon>\n                        <Text size='xs' c={(vpnConnected && connectionInProgress !== ConnectionInProgress.ChangingLocations) ? 'white' : 'dimmed'}>Blind Relay</Text>\n                    </Stack>\n                    <Progress className={classes.progress} w={progressWidth} value={(vpnConnected && connectionInProgress !== ConnectionInProgress.ChangingLocations) ? 100 : 0} h={2} />\n                </>}\n                <Stack gap='0' align='center'>\n                    <ThemeIcon variant='transparent' c={showOfflineUI ? 'red.6' : (connectionInProgress === ConnectionInProgress.UNSET ? 'white' : 'dimmed')}>\n                        <MdLanguage size={20} />\n                    </ThemeIcon>\n                    <Text size='xs' c={showOfflineUI ? 'red.6' : (connectionInProgress === ConnectionInProgress.UNSET ? 'white' : 'dimmed')}>{t('Internet')}</Text>\n                </Stack>\n            </Group>\n        </Paper>\n    );\n}\n\ninterface PulsingProgressProps extends ProgressRootProps {\n  activated: boolean,\n  bars: number,\n}\n\nfunction usePulsingProgress({ activated, bars = 2, w }: PulsingProgressProps) {\n    const activeLength = 50;\n    const segmentSize = activeLength / 2;\n    const values = Array.from({ length: (bars + 1) * (100 / activeLength * bars) }, (_, i) => i * segmentSize);\n    // show a pause for more natural feeling\n    values.push(...Array(4).fill(values.at(-1)));\n\n    const [value, toggleValue] = useToggle(values);\n\n    const { start, stop } = useInterval(() => {\n        toggleValue();\n    }, 40);\n\n    useEffect(() => {\n        if (activated) {\n            start();\n        } else {\n            stop();\n        }\n        return () => stop();\n    }, [activated, start, stop]);\n\n    const progressComponents: ReactNode[] = [];\n\n    const ProgressSection = ({ value, threshold }: { value: number, threshold: number }) => {\n        return <Progress.Section className={!activated || (value >= threshold - activeLength && value <= threshold) ? undefined : classes.progress} value={25} />\n    }\n\n    for (let index = 0; index < bars; index++) {\n        progressComponents.push(\n            <Progress.Root h={2} w={w} className={classes.progress} transitionDuration={50}>\n                <ProgressSection value={value} threshold={activeLength + 100 * index} />\n                <ProgressSection value={value} threshold={(activeLength + segmentSize) + 100 * index} />\n                <ProgressSection value={value} threshold={(activeLength * 2) + (100 * index)} />\n                <ProgressSection value={value} threshold={(segmentSize * 5) + (100 * index)} />\n            </Progress.Root>\n        );\n    }\n    progressComponents.push(values);\n    return progressComponents;\n}\n\nconst DECO_CONNECTING_ARRAY = {\n    light: [DecoConnectingLight1, DecoConnectingLight2, DecoConnectingLight3],\n    dark: [DecoConnectingDark1, DecoConnectingDark2, DecoConnectingDark3]\n};\nconst DEC_LAST_IDX = DECO_CONNECTING_ARRAY.light.length - 1;\n\nfunction Deco() {\n    const {\n        vpnConnected,\n        connectionInProgress,\n        showOfflineUI,\n        osStatus\n    } = useContext(AppContext);\n    const colorScheme = useContext(CColorSchemeContext);\n    const [connectingIndex, toggleConnectingDeco] = useToggle([0, 1, 2, 2]);\n\n    const { start, stop } = useInterval(() => {\n      toggleConnectingDeco();\n    }, osStatus.osVpnStatus === NEVPNStatus.Disconnecting ? 750 : 500);\n\n    useEffect(() => {\n        if (connectionInProgress !== ConnectionInProgress.UNSET) {\n            start();\n        } else {\n            if (connectingIndex !== 0) {\n              toggleConnectingDeco(0);\n            }\n            stop();\n        }\n        return () => stop();\n    }, [connectionInProgress, start, stop]);\n\n    if (showOfflineUI) return colorScheme === 'light' ? DecoOfflineLight : DecoOfflineDark;\n\n    if (connectionInProgress !== ConnectionInProgress.UNSET) {\n        // reverse the animation when disconnecting\n        const adjustedIdx = osStatus.osVpnStatus === NEVPNStatus.Disconnecting ? DEC_LAST_IDX - connectingIndex : connectingIndex;\n        const connectionDeco = DECO_CONNECTING_ARRAY[colorScheme][adjustedIdx];\n        if (connectionDeco === undefined) {\n            console.error(`adjustedIdx/connectingIndex (${adjustedIdx} or ${connectingIndex}) longer than DECO_CONNECTING_ARRAY`);\n            return DECO_CONNECTING_ARRAY[colorScheme][0];\n        }\n        return connectionDeco;\n    };\n\n    if (vpnConnected) return DecoConnected;\n    return colorScheme === 'light' ? DecoDisconnectedLight : DecoDisconnectedDark;\n}\n\nconst MASCOT_CONNECTING = [\n    MascotConnecting1,\n    MascotConnecting2,\n    MascotConnecting3,\n    MascotConnecting4\n];\n\nconst ConnectedBefore = {\n    NEVER: '0',\n    FIRST_CONNECT: '1',\n    YES: '2',\n}\n\nfunction Mascot() {\n    const {\n        vpnConnected,\n        connectionInProgress,\n        appStatus,\n        showOfflineUI\n    } = useContext(AppContext);\n    const accountInfo = appStatus.account?.account_info ?? null;\n    // tuned to show ... for 3 extra cycles\n    const [connectingIndex, toggleConnectingDeco] = useToggle([0, 1, 2, 3, 3, 3, 3]);\n    // want to show celebratory mascot the first time the user uses the app\n    const isTransitioning = useIsTransitioning();\n    const [connectedBefore, setConnectedBefore] = useCookie('connected-before', ConnectedBefore.NEVER);\n\n    useEffect(() => {\n        if (vpnConnected) {\n            if (connectedBefore === ConnectedBefore.NEVER) {\n                setConnectedBefore(ConnectedBefore.FIRST_CONNECT);\n            }\n        } else if (!vpnConnected && connectedBefore === ConnectedBefore.FIRST_CONNECT) {\n            setConnectedBefore(ConnectedBefore.YES);\n        }\n    }, [vpnConnected]);\n\n    const { start, stop } = useInterval(() => {\n        toggleConnectingDeco();\n    }, 120);\n\n    useEffect(() => {\n        if (connectionInProgress !== ConnectionInProgress.UNSET) {\n            start();\n        } else {\n            if (connectingIndex !== 0) {\n              toggleConnectingDeco(0);\n            }\n            console.log('stopped');\n            stop();\n        }\n        return () => stop();\n    }, [connectionInProgress]);\n\n    const getMascot = () => {\n        if (isTransitioning) {\n            const mascotConnecting = MASCOT_CONNECTING[connectingIndex];\n            if (mascotConnecting === undefined) {\n                console.error(`unexpected mascot connectingIndex value ${connectingIndex}`);\n                return MascotConnecting3;\n            }\n            return mascotConnecting;\n        }\n        if (showOfflineUI) return MascotDead;\n        if (vpnConnected) return connectedBefore === ConnectedBefore.FIRST_CONNECT ? MascotConnectedFirstTime : MascotConnected;\n        if (accountInfo === null) return MascotValidating;\n        if (accountIsExpired(accountInfo)) return MascotDead;\n        return MascotNotConnected;\n    };\n    return <Image src={getMascot()} maw={90} />;\n}\n\nfunction LocationSelect(): ReactNode {\n    const { t } = useTranslation();\n\n    const { exitList } = useExitList({\n        periodS: 60,\n    });\n    const locations = useMemo(() => {\n      return new KeyedSet(\n        (exit: {country_code: string, city_code: string}) => JSON.stringify([exit.country_code, exit.city_code]),\n        exitList,\n      );\n    }, [exitList]);\n\n    const { appStatus, vpnConnect, vpnConnected, connectionInProgress, osStatus, showOfflineUI } = useContext(AppContext);\n    const { internetAvailable } = osStatus;\n    const { lastChosenExit, pinnedLocations } = appStatus;\n    const connectedExit = appStatus.vpnStatus.connected?.exit;\n    const pinnedLocationSet = new KeyedSet(\n      (loc: {city_code: string, country_code: string}) => JSON.stringify([loc.country_code, loc.city_code]),\n      pinnedLocations,\n    );\n\n    const [selectedCity, setSelectedCity] = useState<ExitSelectorCity | null>(null);\n    const selectedExampleExit = selectedCity && (locations.get(selectedCity) || connectedExit);\n    const isTransitioning = useIsTransitioning();\n\n    useEffect(() => {\n      if (connectedExit !== undefined) {\n        setSelectedCity(connectedExit);\n      } else if (isTransitioning) {\n        const cityExit = getCityFromStatus(appStatus.vpnStatus);\n        if (cityExit) {\n          setSelectedCity(cityExit);\n        }\n        return;\n      } else if (lastChosenExit && \"city\" in lastChosenExit) {\n        setSelectedCity(lastChosenExit.city);\n      } else if (pinnedLocations.length > 0) {\n        let loc = pinnedLocations[0]!;\n        setSelectedCity({\n          city_code: loc.city_code,\n          country_code: loc.country_code,\n        });\n      }\n    }, [isTransitioning, appStatus, connectedExit, lastChosenExit, pinnedLocations]);\n\n    // need to disable both combo (forces a collapsed dropdown) and button (non-clickable)\n    const comboDisabled = (!internetAvailable && CONNECT_REQUIRES_ONLINE) || connectionInProgress !== ConnectionInProgress.UNSET;\n    const showLastChosenLabel = lastChosenExit\n      && \"city\" in lastChosenExit\n      && selectedCity?.city_code === lastChosenExit.city.city_code\n      && selectedCity?.country_code === lastChosenExit.city.country_code\n      && !!exitList\n      && !vpnConnected\n      && !isConnecting(connectionInProgress);\n    const showPinned = selectedCity !== null\n      && pinnedLocationSet.has(selectedCity)\n      && (vpnConnected || isConnecting(connectionInProgress));\n\n    const combobox = useCombobox({\n        onDropdownClose: () => combobox.resetSelectedOption(),\n    });\n    const focusRef = useFocusTrap(combobox.dropdownOpened);\n\n    return (\n        <>\n            <LocationConnectTopCaption />\n            <Space />\n            <Flex gap='xs' direction={{base: 'column', xs: 'row'}}>\n                <Combobox\n                    store={combobox}\n                    position='bottom-start'\n                    withArrow={false}\n                    shadow='md'\n                    disabled={comboDisabled}\n                    size='lg'\n                >\n                    <Combobox.Target>\n                        <Group ref={focusRef} gap={0} style={{ minWidth: BUTTON_WIDTH }}>\n                            <Button\n                                disabled={comboDisabled}\n                                size='lg'\n                                variant='default'\n                                justify='space-between'\n                                onClick={() => combobox.toggleDropdown()}\n                                flex={1}\n                                rightSection={<Group gap='xs'>\n                                    {showLastChosenLabel && <ObscuraChip>{t('lastChosen')}</ObscuraChip>}\n                                    {showPinned && <ThemeIcon variant='transparent' size={16} c='dimmed'><BsPinFill /></ThemeIcon>}\n                                    <BsChevronDown\n                                        size={16}\n                                        style={{\n                                            transform: combobox.dropdownOpened ? 'rotate(-180deg)' : undefined,\n                                            transition: 'transform 200ms ease-in-out'\n                                        }}\n                                    />\n                                </Group>}\n                            >\n                                {\n                                  selectedCity === null\n                                  ? <Text>{showOfflineUI ? t('noInternet') : t('selectLocation')}</Text>\n                                  : <Group gap='xs'>\n                                    <Text size='lg'>{getCountryFlag(selectedCity.country_code)} {selectedExampleExit?.city_name}</Text>\n                                  </Group>\n                                }\n                            </Button>\n                        </Group>\n                    </Combobox.Target>\n\n                    <Combobox.Dropdown>\n                        <Combobox.Options pt={10}>\n                            <ScrollArea.Autosize mah={250} type='always'>\n                                <CityOptions locations={locations} onExitSelect={(country_code: string, city_code: string) => {\n                                    combobox.closeDropdown();\n                                    if (connectedExit?.country_code === country_code && connectedExit?.city_code === city_code) {\n                                      return;\n                                    }\n                                    let location = locations.get({\n                                        country_code,\n                                        city_code,\n                                    });\n                                    if (!location) {\n                                        console.error(\"No exit matching selected location\", country_code, city_code);\n                                        notifications.show({\n                                            title: t('Error'),\n                                            message: t('InternalError'),\n                                            color: 'red',\n                                        });\n                                        return;\n                                    }\n                                    let city = {\n                                        country_code,\n                                        city_code,\n                                    };\n                                    setSelectedCity(city);\n                                    vpnConnect({\n                                      city,\n                                    });\n                                }} lastChosenExit={lastChosenExit} pinnedLocationSet={pinnedLocationSet} />\n                            </ScrollArea.Autosize>\n                        </Combobox.Options>\n                    </Combobox.Dropdown>\n                </Combobox>\n                <LocationConnectRightButton dropdownOpened={combobox.dropdownOpened} selectedCity={selectedCity} />\n            </Flex>\n        </>\n    );\n}\n\nfunction LocationConnectTopCaption() {\n    const { t } = useTranslation();\n    const { vpnConnected, connectionInProgress } = useContext(AppContext);\n    if (vpnConnected && connectionInProgress === ConnectionInProgress.UNSET)\n        return <Divider className={classes.locationDividerCaption} my={0} label={<Text c='green.8' fw={550}>{t('connectedTo')}</Text>} labelPosition='center' />;\n\n    if (connectionInProgress === ConnectionInProgress.UNSET && !vpnConnected)\n        return <Divider className={classes.locationDividerCaption} my={0} label={<Text c='gray'>{t('or connect to')}</Text>} labelPosition='center' />;\n\n    return <Space h='1.5rem' />;\n}\n\ninterface LocationConnectRightButtonProps {\n  dropdownOpened: boolean,\n  selectedCity: ExitSelectorCity | null,\n}\n\nfunction LocationConnectRightButton({ dropdownOpened, selectedCity }: LocationConnectRightButtonProps) {\n    const { t } = useTranslation();\n    const theme = useMantineTheme();\n    const { vpnConnect, vpnDisconnect, vpnConnected, connectionInProgress, osStatus, showOfflineUI } = useContext(AppContext);\n    const inConnectingState = useIsConnecting();\n    const buttonText = inConnectingState ? 'Cancel' : (vpnConnected ? 'Disconnect' : 'Connect');\n    const btnDisabled = selectedCity === null\n      || (dropdownOpened && buttonText === 'Connect')\n      || showOfflineUI\n      || osStatus.osVpnStatus === NEVPNStatus.Disconnecting;\n    // don't want to use color and background props when disabled since they override the disabled styles\n    const disconnectVariantProps = !btnDisabled && (isConnecting(connectionInProgress) || vpnConnected) ? theme.other.buttonDisconnectProps : {};\n    return (\n        <Button miw={130} size='lg' fz='sm' variant='light' disabled={btnDisabled} {...disconnectVariantProps}\n            onClick={() => {\n                if (vpnConnected || connectionInProgress === ConnectionInProgress.Connecting) {\n                    vpnDisconnect();\n                } else if (selectedCity !== null) {\n                    vpnConnect({\n                      city: selectedCity,\n                    });\n                }\n            }}>\n            {t(buttonText)}\n        </Button>\n    );\n}\n\ninterface CityOptionsProps {\n  locations: KeyedSet<Exit>,\n  pinnedLocationSet: KeyedSet<PinnedLocation>,\n  lastChosenExit: ExitSelector | undefined,\n  onExitSelect: (country_code: string, city_code: string) => void\n}\n\ninterface ItemRightSectionProps {\n    exit: Exit,\n    hoverKey: string,\n    showIconIfPinned?: boolean\n}\n\nfunction CityOptions({ locations, pinnedLocationSet, lastChosenExit, onExitSelect }: CityOptionsProps) {\n    const { t } = useTranslation();\n    const [hoveredOption, setHoveredKey] = useState<string | null>(null);\n    const { appStatus } = useContext(AppContext);\n    const connectedExit = appStatus.vpnStatus.connected?.exit;\n\n    if (locations.size === 0) return;\n\n    let lastCity = lastChosenExit && \"city\" in lastChosenExit && lastChosenExit.city || undefined;\n\n    const ItemRightSection = ({ exit, hoverKey, showIconIfPinned = false }: ItemRightSectionProps) => {\n        if (exitCityEquals(connectedExit, exit))\n            return <Text size='sm' c='green.8' fw={550}>{t('Connected')}</Text>;\n\n        if (!!hoverKey && hoveredOption === hoverKey)\n            return <Text size='sm' c='gray'>{t('clickToConnect')}</Text>;\n\n        if (lastCity?.city_code === exit.city_code && lastCity?.country_code === exit.country_code)\n            return <ObscuraChip>{t('lastChosen')}</ObscuraChip>;\n\n        if (showIconIfPinned && pinnedLocationSet.has(exitLocation(exit)))\n            return <ThemeIcon variant='transparent' c='dimmed'><BsPinFill /></ThemeIcon>;\n    }\n\n    const resetHoverKey = (itemKey: string) => {\n        setHoveredKey(value => {\n            // avoid any render race condition (confirmed it's possible without this check)\n            if (value === itemKey) return null;\n            return value;\n        })\n    }\n\n    const getMouseHoverProps = (itemKey: string) => {\n        return { onMouseEnter: () => setHoveredKey(itemKey), onMouseLeave: () => resetHoverKey(itemKey) };\n    }\n\n    let orderedLocations = [...locations];\n    orderedLocations.sort(exitsSortComparator);\n\n    const result: ReactNode[] = [];\n\n    for (const exit of orderedLocations) {\n      if (!pinnedLocationSet.has(exitLocation(exit))) continue;\n\n      if (result.length === 0) {\n        result.push(<Text key='pinned-heading' size='sm' c='gray' ml='md' fw={400}><BsPinFill size={11} /> {t('Pinned')}</Text>);\n      }\n\n      const key = JSON.stringify(['pinned', exit.country_code, exit.city_code]);\n      result.push(\n        <Combobox.Option\n          className={classes.fixedHoverColor}\n          key={key}\n          value={key}\n          onClick={() => onExitSelect(exit.country_code, exit.city_code)}\n          {...getMouseHoverProps(key)}>\n          <Group gap='xs' justify='space-between'>\n            <Text size='lg'>{getExitCountryFlag(exit)} {exit.city_name}</Text>\n            <ItemRightSection exit={exit} hoverKey={key} />\n          </Group >\n        </Combobox.Option >\n      );\n    }\n    if (result.length > 0) {\n      result.push(<Divider key='divider-pinned' my={10} />);\n    }\n\n    const insertedContinents = new Set();\n\n    for (const exit of orderedLocations) {\n      const continent = getContinent(getExitCountry(exit));\n      if (!insertedContinents.has(continent)) {\n        if (insertedContinents.size > 0) {\n          result.push(<Divider key={`divider-${continent}`} my={10} />);\n        }\n        insertedContinents.add(continent);\n        result.push(<Text key={`continent-${continent}`} size='sm' c='gray' ml='sm' fw={400}>{t(`Continent${continent}`)}</Text>);\n      }\n      const key = JSON.stringify([exit.country_code, exit.city_code]);\n\n      result.push(\n        <Combobox.Option\n          className={classes.fixedHoverColor}\n          key={key}\n          value={key}\n          onClick={() => onExitSelect(exit.country_code, exit.city_code)}\n          {...getMouseHoverProps(key)}>\n          <Group gap='xs' justify='space-between'>\n              <Text size='lg'>{getExitCountryFlag(exit)} {exit.city_name}</Text>\n              <ItemRightSection exit={exit} hoverKey={key} showIconIfPinned />\n          </Group >\n        </Combobox.Option >\n      );\n    }\n\n    return result;\n}\n"
  },
  {
    "path": "obscura-ui/src/views/DeveloperView.tsx",
    "content": "import { Accordion, Autocomplete, Button, Group, JsonInput, Stack, Text, TextInput, Title } from '@mantine/core';\nimport { useInterval } from '@mantine/hooks';\nimport { notifications } from '@mantine/notifications';\nimport Cookies from 'js-cookie';\nimport { useContext, useEffect, useRef, useState } from 'react';\nimport * as commands from '../bridge/commands';\nimport { jsonFfiCmd } from \"../bridge/commands\";\nimport { PLATFORM } from '../bridge/SystemProvider';\nimport { AppContext } from '../common/appContext';\nimport { localStorageGet, LocalStorageKey } from '../common/localStorage';\nimport { errMsg } from '../common/utils';\nimport DevSendCommand from '../components/DevSendCommand';\nimport DevSetApiUrl from '../components/DevSetApiUrl';\n\nexport default function DeveloperViewer() {\n    const { vpnConnected, connectionInProgress, appStatus, osStatus } = useContext(AppContext);\n    const [trafficStats, setTrafficStats] = useState({});\n    const cookieToDeleteRef = useRef<HTMLInputElement | null>(null);\n    const apiHostAlternate = useRef<HTMLInputElement | null>(null);\n    const sniRelayRef = useRef<HTMLInputElement | null>(null);\n\n    const trafficStatsInterval = useInterval(async () => {\n        setTrafficStats(await commands.getTrafficStats());\n    }, 1000);\n\n    useEffect(() => {\n        trafficStatsInterval.start();\n        return () => {\n            trafficStatsInterval.stop();\n        };\n    }, []);\n\n    const [localStorageKey, setLocalStorageKey] = useState('');\n    const [localStorageValue, setLocalStorageValue] = useState<string | null>(null);\n\n    const [accordionValues, setAccordionValues] = useState<string[]>([]);\n\n    return <Stack p={20} mb={50}>\n        <Title order={3}>Developer View</Title>\n        <Title order={4}>Statuses</Title>\n        <Accordion multiple value={accordionValues} onChange={setAccordionValues}>\n            <Accordion.Item key='appStatus' value='appStatus'>\n                <Accordion.Control>App Status</Accordion.Control>\n                <Accordion.Panel>\n                    <JsonInput value={JSON.stringify(appStatus, null, 4)} contentEditable={false} rows={10} />\n                </Accordion.Panel>\n            </Accordion.Item>\n            <Accordion.Item key='osStatus' value='osStatus'>\n                <Accordion.Control>OsStatus</Accordion.Control>\n                <Accordion.Panel>\n                    <JsonInput value={JSON.stringify(osStatus, null, 4)} contentEditable={false} rows={10} />\n                </Accordion.Panel>\n            </Accordion.Item>\n            <Accordion.Item key='systemInfo' value='systemInfo'>\n                <Accordion.Control>Build Time Info</Accordion.Control>\n                <Accordion.Panel>\n                    PLATFORM = {PLATFORM}\n                </Accordion.Panel>\n            </Accordion.Item>\n        </Accordion>\n        <Title order={4}>React variables</Title>\n        <Text>vpn is connected: <b>{vpnConnected ? 'Yes' : 'No'}</b></Text>\n        <Text>connection in progress: <b>{connectionInProgress ?? 'No'}</b></Text>\n        <Button title='Preferences such as whether the user has been onboarded or if the app has tried to register as a login item' onClick={() => commands.developerResetUserDefaults().then(() => notifications.show({ title: 'Successfully Removed UserDefault Keys', color: 'green', message: '' }))}>Reset app UserDefaults</Button>\n        <DevSetApiUrl />\n        <Title order={4}>Debug Configuration</Title>\n        <Group>\n            <TextInput ref={apiHostAlternate} placeholder='api.example.com' style={{ flex: 1 }} />\n            <Button\n              onClick={async () => {\n                try {\n                  await commands.setApiHostAlternate(apiHostAlternate.current?.value || null);\n                  notifications.show({\n                    title: 'Alternate API Host Updated',\n                    color: 'green',\n                    message: \"\",\n                  });\n                } catch (e) {\n                  notifications.show({\n                    title: 'Failed to set Alternate API Host',\n                    color: 'red',\n                    message: errMsg(e),\n                  });\n                }\n              }}\n            >\n              Set Alternate API Host\n            </Button>\n        </Group>\n        <Group>\n            <TextInput ref={sniRelayRef} placeholder='relay.example.com' style={{ flex: 1 }} />\n            <Button\n              onClick={async () => {\n                try {\n                  await commands.setSniRelay(sniRelayRef.current?.value || null);\n                  notifications.show({\n                    title: 'Relay SNI Updated',\n                    color: 'green',\n                    message: \"\",\n                  });\n                } catch (e) {\n                  notifications.show({\n                    title: 'Failed to set Relay SNI',\n                    color: 'red',\n                    message: errMsg(e),\n                  });\n                }\n              }}\n            >\n              Set Relay SNI\n            </Button>\n        </Group>\n        <Title order={4}>Traffic Stats</Title>\n        <Text>Since this is cumulative, to get the average bandwidth speed, you must do a slope calculation between the time of two captures (recommended gap of 500ms to 1000ms). See code in the <code>apple/client/StatusItem</code> directory for reference</Text>\n        <JsonInput value={JSON.stringify(trafficStats, null, 4)} contentEditable={false} rows={6} />\n        <Title order={4}>Exit Servers</Title>\n        <DevSendCommand />\n        <Button onClick={() => commands.setInNewAccountFlow(true)}>setInNewAccountFlow</Button>\n        <Title order={4}>Cookies</Title>\n        <Text>{JSON.stringify(Cookies.get(), null, 4)}</Text>\n        <Group>\n            <TextInput ref={cookieToDeleteRef} placeholder='cookieName' />\n            <Button onClick={() => {\n                if (cookieToDeleteRef.current !== null) Cookies.remove(cookieToDeleteRef.current.value)\n            }}>Delete Cookie</Button>\n        </Group>\n        <Title order={4}>Local Storage</Title>\n        <Group align='end'>\n          <Autocomplete onChange={setLocalStorageKey} label='local storage key' data={Object.values(LocalStorageKey)} />\n          <Button onClick={() => setLocalStorageValue(localStorageGet(localStorageKey as LocalStorageKey))}>Get</Button>\n        </Group>\n        <JsonInput value={localStorageValue ?? 'null'} contentEditable={false} />\n        <Button onClick={ () => jsonFfiCmd(\"terminateProcess\", {})}>kill tunnel manager process</Button>\n    </Stack>;\n}\n"
  },
  {
    "path": "obscura-ui/src/views/HelpView.tsx",
    "content": "import { Anchor, Image, Stack, Text, Title } from '@mantine/core';\nimport { useContext } from 'react';\nimport { Trans, useTranslation } from 'react-i18next';\nimport { AppContext } from '../common/appContext';\nimport { EMAIL } from '../common/links';\nimport useMailto from '../common/useMailto';\nimport DebuggingArchive from '../components/DebuggingArchive';\nimport { Socials } from '../components/Socials';\nimport MascotThinking from '../res/mascots/thinking-mascot.svg';\n\nexport default function Help() {\n    const { t } = useTranslation();\n    const { osStatus } = useContext(AppContext);\n    const mailto = useMailto(osStatus);\n\n    return <Stack pl={60} pt={40} align='flex-start'>\n        <Image src={MascotThinking} w={100} />\n        <Title>{t('havingTrouble')}</Title>\n        <Text c='gray' component='span'><Trans i18nKey='supportMsg' values={{ email: EMAIL }} components={[<Anchor href={mailto} />]} /></Text>\n        <DebuggingArchive osStatus={osStatus} />\n        <Title order={3}>{t('socials')}</Title>\n        <Socials />\n    </Stack>\n}\n"
  },
  {
    "path": "obscura-ui/src/views/Location.module.css",
    "content": ".locationCardConnected, .locationCardNotConnected {\n    cursor: pointer;\n    border: 1px solid transparent;\n}\n\n@media (hover: hover) {\n    .locationCardNotConnected:hover:not(:has(.favoriteBtn:hover)) {\n        background-color: light-dark(#f5f5f5, var(--mantine-color-dark-4));\n\n        @mixin light {\n            border-color: var(--mantine-color-teal-5) !important;\n            border-width: 1px !important;\n        }\n    }\n}\n\n.locationCardNotConnected:active:not(:has(.favoriteBtn:active)) {\n    transform: translateY(2px);\n}\n\n.locationCardConnected {\n    border-color: var(--mantine-primary-color-filled) !important;\n    border-width: 2px !important;\n}\n\n.locationCardConnected, .locationCardDisabled, .connectingAnimation {\n    cursor: not-allowed;\n}\n\n\n.item {\n    &[data-active] {\n        z-index: 1;\n        background-color: var(--mantine-color-body);\n        border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));\n        box-shadow: var(--mantine-shadow-md);\n        border-radius: var(--mantine-radius-md);\n    }\n}\n\n.container {\n    padding: 20px 75px;\n}\n\n@media screen and (max-width: $mantine-breakpoint-xs) {\n    .container {\n        padding-top: calc(20px + env(safe-area-inset-top));\n        padding-bottom: 70px;\n        padding-left: 20px;\n        padding-right: 20px;\n    }\n\n    .serverInfoText {\n        display: none !important;\n    }\n}\n\n@media screen and (min-width: $mantine-breakpoint-xs) {\n    .currentSession {\n        flex-direction: column;\n    }\n}\n\n.connectingAnimation {\n    border: 2px solid transparent !important;\n    border-radius: var(--mantine-radius-md) !important;\n    animation: wave 2s linear infinite;\n    background-size: 200% 50% !important;\n    @mixin light {\n        background: linear-gradient(90deg, rgba(246, 152, 85, 0.487), #FFFFFFFF, rgba(246, 152, 85, 0.487));\n    }\n    @mixin dark {\n        background: linear-gradient(90deg, rgba(181, 75, 0, 0.75), #00000000, rgba(181, 75, 0, 0.75));\n    }\n}\n\n@keyframes wave {\n    0% {\n        background-position: 200% 50%;\n    }\n    100% {\n        background-position: 0% 50%;\n    }\n}\n"
  },
  {
    "path": "obscura-ui/src/views/LocationView.tsx",
    "content": "import { Accordion, ActionIcon, Anchor, Button, Card, Divider, Flex, Grid, Group, Loader, Space, Stack, Text, TextInput, ThemeIcon, Title, useMantineTheme } from '@mantine/core';\nimport { useInterval } from '@mantine/hooks';\nimport { notifications } from '@mantine/notifications';\nimport { t } from 'i18next';\nimport { MouseEvent, useContext, useMemo, useState } from 'react';\nimport { Trans, useTranslation } from 'react-i18next';\nimport { BsPin, BsPinFill, BsSearch, BsShieldFillCheck, BsShieldFillExclamation } from 'react-icons/bs';\nimport * as commands from '../bridge/commands';\nimport { CONNECT_REQUIRES_ONLINE, IS_HANDHELD_DEVICE } from '../bridge/SystemProvider';\nimport { accountIsExpired, Exit, getContinent, getExitCountry } from '../common/api';\nimport { AppContext, ConnectionInProgress, getCityFromArgs, getCityFromStatus, NEVPNStatus } from '../common/appContext';\nimport commonClasses from '../common/common.module.css';\nimport { exitCityEquals, exitLocation, exitsSortComparator, getExitCountryFlag } from '../common/exitUtils';\nimport { KeyedSet } from '../common/KeyedSet';\nimport { NotificationId } from '../common/notifIds';\nimport { useAsync } from '../common/useAsync';\nimport { useExitList } from '../common/useExitList';\nimport { fmtTime, normalizedIncludes } from '../common/utils';\nimport BoltBadgeAuto from '../components/BoltBadgeAuto';\nimport { MobileDrawer } from '../components/ConfirmationDialog';\nimport ExternalLinkIcon from '../components/ExternalLinkIcon';\nimport ObscuraChip from '../components/ObscuraChip';\nimport { SecondaryButton } from '../components/SecondaryButton';\nimport CheckMarkCircleFill from '../res/checkmark.circle.fill.svg?react';\nimport Eye from '../res/eye.fill.svg?react';\nimport EyeSlash from '../res/eye.slash.fill.svg?react';\nimport OrangeCheckedShield from '../res/orange-checked-shield.svg?react';\nimport { fmtErrorI18n } from '../translations/i18n';\nimport classes from './Location.module.css';\n\nexport default function LocationView() {\n    const { t } = useTranslation();\n    const { vpnConnected, vpnConnect, connectionInProgress, appStatus } = useContext(AppContext);\n    const [searchQuery, setSearchQuery] = useState('');\n    const searchQueryClean = searchQuery.trim();\n    const theme = useMantineTheme();\n\n    const { exitList } = useExitList({\n      periodS: 60,\n    });\n\n    const onExitSelect = async (exit: Exit) => {\n        // connected already to the desired exit\n        const connectedExit = appStatus.vpnStatus.connected?.exit;\n        if (\n          exit.country_code === connectedExit?.country_code\n          && exit.city_name === connectedExit.city_name\n        ) {\n            return;\n        }\n        if (vpnConnected || connectionInProgress !== ConnectionInProgress.UNSET) {\n            notifications.hide(NotificationId.VPN_DISCONNECT_CONNECT);\n            notifications.show({\n                title: t('connectingToCity', { city: exit.city_name }),\n                message: '',\n                autoClose: 15_000,\n                color: 'yellow.4',\n                id: NotificationId.VPN_DISCONNECT_CONNECT\n            });\n        }\n        await vpnConnect({\n          city: {\n            city_code: exit.city_code,\n            country_code: exit.country_code,\n          }\n        });\n    };\n\n    const toggleExitPin = (exit: Exit) => {\n      if (!exitList) {\n        console.error(\"Toggling pin with no exit list\")\n        return;\n      }\n\n      let removed = appStatus.pinnedLocations.filter(loc =>\n        !(loc.city_code == exit.city_code\n        && loc.country_code == loc.country_code)\n      );\n      if (removed.length !== appStatus.pinnedLocations.length) {\n        commands.setPinnedExits(removed);\n      } else {\n        commands.setPinnedExits([\n          ...appStatus.pinnedLocations,\n          {\n            country_code: exit.country_code,\n            city_code: exit.city_code,\n            pinned_at: Math.floor(Date.now()/1000),\n          },\n        ]);\n      }\n    };\n\n    const connectedExit = appStatus.vpnStatus.connected?.exit;\n    const locations = exitList ?? [];\n    const pinnedLocationSet = new KeyedSet(\n      (loc: {country_code: string, city_code: string}) => JSON.stringify([loc.country_code, loc.city_code]),\n      appStatus.pinnedLocations,\n    );\n\n    // Filter locations based on search query\n    const filteredLocations = useMemo(() => {\n      if (!searchQueryClean) return locations;\n      return locations.filter(exit => {\n        return (\n          normalizedIncludes(searchQueryClean, exit.city_name) ||\n          normalizedIncludes(searchQueryClean, exit.city_code) ||\n          normalizedIncludes(searchQueryClean, exit.country_code) ||\n          normalizedIncludes(searchQueryClean, getExitCountry(exit).name)\n        );\n      });\n    }, [locations, searchQueryClean]);\n\n    const pinnedExits = filteredLocations.filter(exit => pinnedLocationSet.has(exitLocation(exit)));\n\n    let lastChosenJsx = null;\n    const insertedCities = new Set(); // [COUNTRY_CODE, CITY]\n    if (appStatus.lastChosenExit && \"city\" in appStatus.lastChosenExit) {\n        let lastCity = appStatus.lastChosenExit.city;\n        const exit = filteredLocations.find(l => l.city_code == lastCity.city_code && l.country_code == lastCity.country_code);\n        if (exit !== undefined) {\n            const key = JSON.stringify([exit.country_code, exit.city_name]);\n            if (searchQueryClean) {\n              insertedCities.add(key);\n            }\n            const isConnected = exitCityEquals(lastCity, connectedExit);\n            const isPinned = pinnedLocationSet.has(lastCity);\n            lastChosenJsx = <>\n                {!searchQueryClean && <Text ta='left' w='100%' size='sm' c='green.7' ml='md' fw={600}>{t('lastChosen')}</Text>}\n                <LocationCard exit={exit} togglePin={toggleExitPin} showLastChosen={!!searchQueryClean}\n                    onSelect={() => onExitSelect(exit)} connected={isConnected} pinned={isPinned} />\n            </>;\n        }\n    }\n\n    const pinnedExitsRender = [];\n    if (pinnedExits.length > 0) {\n        if (!searchQueryClean) {\n          pinnedExitsRender.push(<Text key='pinned-heading' ta='left' w='100%' size='sm' c='gray' ml='md' fw={700}>{t('Pinned')}</Text>);\n        }\n        for (const exit of pinnedExits) {\n            const key = JSON.stringify([exit.country_code, exit.city_name]);\n            if (!insertedCities.has(key)) {\n              insertedCities.add(key);\n              const isConnected = exitCityEquals(exit, connectedExit);\n              const isPinned = pinnedLocationSet.has(exitLocation(exit));\n              pinnedExitsRender.push(<LocationCard key={key} exit={exit} togglePin={toggleExitPin}\n                  onSelect={() => onExitSelect(exit)} connected={isConnected} pinned={isPinned} />);\n            }\n        }\n    }\n\n    const exitListRender = [];\n    const insertedContinents = new Set();\n    if (!searchQueryClean) {\n      insertedCities.clear();\n    }\n\n    filteredLocations.sort(exitsSortComparator);\n    for (const exit of filteredLocations.filter((e) => !insertedCities.has(JSON.stringify([e.country_code, e.city_name])))) {\n        const continent = getContinent(getExitCountry(exit));\n        if (!insertedContinents.has(continent)) {\n            exitListRender.push(<Text key={`continent-${continent}`} ta='left' w='100%' size='sm' c='gray' ml='sm' fw={600}>{t(`Continent${continent}`)}</Text>);\n            insertedContinents.add(continent);\n        }\n        const key = JSON.stringify([exit.country_code, exit.city_name]);\n        if (!insertedCities.has(key)) {\n          insertedCities.add(key);\n          const isConnected = exitCityEquals(exit, connectedExit);\n          const isPinned = pinnedLocationSet.has(exitLocation(exit));\n          exitListRender.push(<LocationCard key={key} exit={exit} togglePin={toggleExitPin}\n              onSelect={() => onExitSelect(exit)} connected={isConnected} pinned={isPinned} />);\n        }\n    }\n\n    return (\n        <Stack align='center' gap={10} className={classes.container}>\n            <VpnStatusCard />\n            {locations.length === 0 ? <NoExitServers /> :\n                <>\n                  <Group w='100%' justify='space-between'>\n                    <Title order={3} ta='left' fw={600}>\n                      {t('Available Locations')}\n                    </Title>\n                    <TextInput\n                      w={{ base: '100%', xs: 'auto' }}\n                      placeholder={t('searchLocations')}\n                      leftSection={<BsSearch />}\n                      value={searchQuery}\n                      onChange={(e) => setSearchQuery(e.currentTarget.value)}\n                      radius='md'\n                      style={{ boxShadow: theme.shadows.sm }}\n                    />\n                  </Group>\n                  <Divider my='xs' w='100%' />\n\n                  {filteredLocations.length === 0 ? (\n                    <Text c='dimmed' size='sm' mt='md'>{t('noLocationsFound')}</Text>\n                  ) : (\n                    <>\n                      {lastChosenJsx}\n                      {pinnedExitsRender}\n                      {exitListRender}\n                    </>\n                  )}\n                </>}\n        </Stack>\n    );\n}\n\ninterface LocationCarProps {\n  exit: Exit,\n  connected: boolean,\n  showLastChosen?: boolean,\n  onSelect: () => void,\n  togglePin: (exit: Exit) => void,\n  pinned: boolean\n}\n\nfunction LocationCard({ exit, connected, showLastChosen = false, onSelect, togglePin, pinned }: LocationCarProps) {\n    const { t } = useTranslation();\n    const { osStatus, showOfflineUI, appStatus, initiatingExitSelector, accountInfo } = useContext(AppContext);\n\n    const onPinClick = (e: MouseEvent) => {\n        e.stopPropagation();\n        togglePin(exit);\n    };\n\n    const isAccountExpired = accountInfo ? accountIsExpired(accountInfo) : false;\n    const disableClick = osStatus.osVpnStatus === NEVPNStatus.Disconnecting || (showOfflineUI && CONNECT_REQUIRES_ONLINE) || isAccountExpired;\n    const cardClasses = [];\n    if (connected) cardClasses.push(classes.locationCardConnected);\n    else if (!connected && osStatus.osVpnStatus !== NEVPNStatus.Disconnecting && (exitCityEquals(getCityFromStatus(appStatus.vpnStatus), exit) || exitCityEquals(getCityFromArgs(initiatingExitSelector), exit))) {\n      cardClasses.push(classes.connectingAnimation);\n    }\n    else if (disableClick) cardClasses.push(classes.locationCardDisabled);\n    else if (!connected) cardClasses.push(classes.locationCardNotConnected);\n    const cardTitle = (!connected && !disableClick) ? t('clickToConnect') : (isAccountExpired ? t('account-Expired') : undefined);\n\n    return (\n        <Card shadow='xs' title={cardTitle} className={cardClasses.join(' ')} withBorder padding='xs' radius='md' w='100%' onClick={(connected || disableClick) ? undefined : onSelect}>\n            <Group justify='space-between'>\n                <Group>\n                    <Text size='2rem'>{getExitCountryFlag(exit)}</Text>\n                    <Flex direction='column'>\n                        <Text size='md'>{exit.city_name}</Text>\n                        <Text c='dimmed' size='sm'>{getExitCountry(exit).name}</Text>\n                    </Flex>\n                </Group>\n                <Group>\n                    {connected && <ObscuraChip>{t('Connected')}</ObscuraChip>}\n                    {!connected && showLastChosen && <ObscuraChip>{t('lastChosen')}</ObscuraChip>}\n                    <ActionIcon className={classes.favoriteBtn} variant={pinned ? 'gradient' : 'outline'} title={pinned ? 'unpin exit' : 'pin exit'} color={pinned ? 'orange' : 'gray'} onClick={onPinClick}>\n                        {pinned ? <BsPinFill size='1rem' /> : <BsPin size='1rem' />}\n                    </ActionIcon>\n                </Group>\n            </Group>\n        </Card>\n    );\n}\n\nfunction NoExitServers() {\n    const { t } = useTranslation();\n    const [isLoading, setIsLoading] = useState(false);\n\n    if (isLoading) {\n        return <Loader mt={10} type='dots' />;\n    }\n\n    async function refetch() {\n        try {\n            setIsLoading(true);\n            console.log(\"Fetching exits\");\n            await commands.refreshExitList(0);\n        } catch (error) {\n          let message = error instanceof commands.CommandError\n            ? fmtErrorI18n(t, error)\n            : t('exitServerFetchResolution');\n\n          notifications.show({\n            id: 'failedToFetchExitServers',\n            title: t('failedToFetchExitServers'),\n            message,\n            color: 'red',\n          });\n        } finally {\n            setIsLoading(false);\n        }\n    }\n\n    return (\n        <Card shadow='sm' padding='lg' radius='md' withBorder w='100%'>\n            <Group justify='space-between' >\n                <Group align='center' gap={5}>\n                    <Text size='xl' fw={700} c='red.7'>\n                        {t('noExitServers')}\n                    </Text>\n                </Group>\n                <Button onClick={refetch} color='green.7' radius='md' variant='filled'>\n                    {t('refetchExitList')}\n                </Button>\n            </Group>\n        </Card>\n    );\n}\n\nfunction VpnStatusCard() {\n    const theme = useMantineTheme();\n    const { t } = useTranslation();\n    const { appStatus, vpnConnected, showOfflineUI, osStatus, connectionInProgress, vpnDisconnect, vpnConnect, accountInfo } = useContext(AppContext);\n\n    const getStatusTitle = () => {\n        if (connectionInProgress !== ConnectionInProgress.UNSET) return t(connectionInProgress) + '...';\n        if (showOfflineUI) return t('Offline');\n        const selectedLocation = appStatus.vpnStatus.connected?.exit.city_name;\n        // vpnConnected <-> vpnStatus.connected.exit defined\n        if (selectedLocation !== undefined) return t('connectedToLocation', { location: selectedLocation });\n        return t('Disconnected');\n    };\n\n    const getStatusSubtitle = () => {\n        if (connectionInProgress === ConnectionInProgress.ChangingLocations) {\n          return t('trafficSuspended');\n        }\n        if (vpnConnected) return t('trafficProtected');\n        if (connectionInProgress !== ConnectionInProgress.UNSET) return t('trafficVulnerable');\n        return showOfflineUI ? t('connectToInternet') : t('trafficVulnerable');\n    };\n\n    const isAccountExpired = accountInfo ? accountIsExpired(accountInfo) : false;\n    const allowCancel = connectionInProgress === ConnectionInProgress.Connecting || connectionInProgress === ConnectionInProgress.Reconnecting;\n    const btnDisabled = !allowCancel && (connectionInProgress === ConnectionInProgress.Disconnecting || connectionInProgress === ConnectionInProgress.ChangingLocations || (showOfflineUI && CONNECT_REQUIRES_ONLINE) || isAccountExpired);\n    const buttonDisconnectProps = ((allowCancel || vpnConnected) && !btnDisabled) ? theme.other.buttonDisconnectProps : {};\n\n    const getButtonContent = () => {\n        if (allowCancel) return t('Cancel Connecting');\n        if (connectionInProgress !== ConnectionInProgress.UNSET) return t(connectionInProgress) + '...';\n        if (vpnConnected) return t('Disconnect');\n        return <Group gap={5} ml={0}><BoltBadgeAuto />{t('QuickConnect')}</Group>;\n    };\n\n    const btnTitle = () => {\n        if (!btnDisabled) return;\n        if (showOfflineUI) return t('noInternet');\n        if (isAccountExpired) return t('account-Expired');\n        return t('busyConnection');\n    };\n\n    const statusColor = (vpnConnected && connectionInProgress === ConnectionInProgress.UNSET) ? 'green.7'\n        : (connectionInProgress === ConnectionInProgress.ChangingLocations ? 'gray' : 'red.7');\n\n    return (\n        <Card shadow='sm' padding='lg' radius='md' withBorder w='100%' mb='xs'>\n            <Grid justify='space-between' grow>\n                <Grid.Col span='content'>\n                  <Stack gap={0}>\n                      <Group align='center' gap={5}>\n                          <ThemeIcon color={statusColor} variant='transparent'>\n                              {connectionInProgress === ConnectionInProgress.ChangingLocations\n                              ? <Loader size='xs' color='gray.5' /> : (vpnConnected && connectionInProgress === ConnectionInProgress.UNSET)\n                              ? <BsShieldFillCheck size={25} />\n                              : <BsShieldFillExclamation size={25} />}\n                          </ThemeIcon>\n                      <Title order={4} fw={600} c={statusColor}>{getStatusTitle()}</Title>\n                      </Group>\n                      <Text c='gray' size='sm' ml={34}>{getStatusSubtitle()}</Text>\n                  </Stack>\n                </Grid.Col>\n                <Grid.Col span='auto'>\n                  <Button\n                    fullWidth\n                    className={commonClasses.button}\n                    miw={190}\n                    onClick={() => {\n                      if (vpnConnected || allowCancel) {\n                        vpnDisconnect();\n                      } else {\n                        vpnConnect({ any: {} });\n                      }\n                    }}\n                    disabled={btnDisabled}\n                    title={btnTitle()}\n                    px={10}\n                    radius='md'\n                    {...buttonDisconnectProps}\n                  >\n                    {getButtonContent()}\n                  </Button>\n                </Grid.Col>\n            </Grid>\n            {osStatus.osVpnStatus !== NEVPNStatus.Disconnected && osStatus.osVpnStatus !== NEVPNStatus.Disconnecting &&\n              <>\n                <Divider my='md' />\n                <Stack justify='space-between' w='100%'>\n                  <CurrentSession />\n                  {appStatus.vpnStatus.connected === undefined ? <Group justify='center' p='sm'><Loader size='sm' /> {t('exitInfoLoading')}</Group>\n                    : <ExitInfo exitPubKey={appStatus.vpnStatus.connected.exitPublicKey} connectedExit={appStatus.vpnStatus.connected.exit} />}\n                </Stack>\n              </>\n            }\n        </Card>\n    );\n}\n\nfunction CurrentSession() {\n  const { t } = useTranslation();\n  const { value: trafficStats, refresh: pollTrafficStats, loading } = useAsync({ deps: [], load: commands.getTrafficStats });\n  const { osStatus } = useContext(AppContext);\n  useInterval(pollTrafficStats, 1000, { autoInvoke: true });\n  if (trafficStats === undefined && !loading) return;\n  return (\n    <Flex gap={5} className={classes.currentSession}>\n      <Text c='gray' size='sm'>{t('currentSession')}:</Text>\n      {\n        trafficStats === undefined ?\n          <Text size='sm'>&nbsp;</Text> :\n          <Text size='sm'>{fmtTime(osStatus.osVpnStatus === NEVPNStatus.Connected && trafficStats !== undefined ? trafficStats.connectedMs : 0)}</Text>\n      }\n    </Flex>\n  );\n}\n\nfunction ExitInfo({ exitPubKey, connectedExit }: { exitPubKey: string, connectedExit: Exit }) {\n  const exitProviderId = connectedExit.provider_id;\n  const exitProviderURL = connectedExit.provider_url;\n  const provider = connectedExit.provider_name;\n  const providerUrl = connectedExit.provider_homepage_url;\n\n  const exitInfoProps = {\n    exitPubKey,\n    connectedExit,\n    exitProviderId,\n    exitProviderURL,\n    provider,\n    providerUrl,\n  };\n  return IS_HANDHELD_DEVICE ? <ExitInfoDrawer {...exitInfoProps} /> : <ExitInfoCollapse {...exitInfoProps} />;\n}\n\ninterface ExitInfoProps {\n  exitProviderId: string,\n  connectedExit: Exit,\n  exitPubKey: string,\n  provider: string,\n  providerUrl: string,\n  exitProviderURL: string,\n}\n\nfunction ExitInfoCollapse({ exitProviderId, exitPubKey, connectedExit, provider, providerUrl, exitProviderURL }: ExitInfoProps) {\n  const theme = useMantineTheme();\n  const { t } = useTranslation();\n  const [showExitInfo, setShowExitInfo] = useState(false);\n  return (\n    <Accordion classNames={{ item: classes.item }} variant='contained' value={showExitInfo ? '0' : null} onChange={() => setShowExitInfo(!showExitInfo)} chevron={null} disableChevronRotation>\n      <Accordion.Item key='0' value='0'>\n        <Accordion.Control icon={<OrangeCheckedShield height='1em' width='1em' />}>\n          <Group justify='space-between'>\n            <Text size='sm' c='gray'><Trans i18nKey='securedBy' values={{ provider }} components={[<Anchor onClick={e => e.stopPropagation()} c='orange' href={providerUrl} />]} /></Text>\n            <Group style={{ alignItems: 'center' }} gap={5}>\n              {showExitInfo ?\n                <EyeSlash fill={theme.other.dimmed} width='1em' height='1em' /> :\n                <Eye fill={theme.other.dimmed} width='1em' height='1em' />}\n              <Text size='xs' c='dimmed' className={classes.serverInfoText}>{t(showExitInfo ? 'serverInfoHide' : 'serverInfoReveal')}</Text>\n            </Group>\n          </Group>\n        </Accordion.Control>\n        <Accordion.Panel>\n          <Stack gap={2}>\n            <Stack gap={5}>\n              <Text c='gray' size='sm'>{t('Node')}</Text>\n              <Text size='sm'>\n                {exitProviderId}\n                <Text span c='dimmed' size='sm'> ({connectedExit.city_name}, {getExitCountry(connectedExit).name})</Text>\n              </Text>\n            </Stack>\n            <Space h='xs' />\n            <Stack gap={5}>\n              <Text c='gray' size='sm'>{t('publicKey')}</Text>\n              <Group gap={3}>\n                <Text ff='monospace' size='sm'>{exitPubKey}</Text>\n              </Group>\n              <MatchesPublicKey exitProviderId={exitProviderId} exitProviderURL={exitProviderURL} />\n            </Stack>\n          </Stack>\n        </Accordion.Panel>\n      </Accordion.Item>\n    </Accordion>\n  );\n}\n\nfunction ExitInfoDrawer({ exitProviderId, exitPubKey, connectedExit, provider, providerUrl, exitProviderURL }: ExitInfoProps) {\n  const { t } = useTranslation();\n  const [showExitInfo, setShowExitInfo] = useState(false);\n  return <>\n    <SecondaryButton onClick={() => setShowExitInfo(true)}>{t('viewServerInfo')}</SecondaryButton>\n    <MobileDrawer size='sm' title={t('networkInformation')} opened={showExitInfo} onClose={() => setShowExitInfo(false)} withCloseButton={false}>\n      <Stack justify='space-between' h='100%'>\n        <Stack gap='md'>\n          <Group justify='space-between'>\n            <Text c='dimmed'>{t('VPN')}</Text>\n            <Anchor href={providerUrl}>{provider}</Anchor>\n          </Group>\n          <Stack gap={0}>\n            <Group justify='space-between'>\n              <Text c='dimmed'>{t('Node')}</Text>\n              <Text>{exitProviderId}</Text>\n            </Group>\n            <Text size='sm' ta='right' className={commonClasses.secondaryColor}>{connectedExit.city_name}, {getExitCountry(connectedExit).name}</Text>\n          </Stack>\n          <Stack gap={5}>\n            <Group justify='space-between' gap={5}>\n              <Text c='dimmed'>{t('publicKey')}</Text>\n              <Text ff='monospace' style={{ wordBreak: 'break-all' }}>{exitPubKey}</Text>\n            </Group>\n            <MatchesPublicKey exitProviderId={exitProviderId} exitProviderURL={exitProviderURL} />\n          </Stack>\n        </Stack>\n        <SecondaryButton onClick={() => setShowExitInfo(false)}>{t('Dismiss')}</SecondaryButton>\n      </Stack>\n    </MobileDrawer>\n  </>;\n}\n\ninterface MatchesPublicKeyProp {\n  exitProviderId: string,\n  exitProviderURL: string,\n}\n\nfunction MatchesPublicKey({ exitProviderId, exitProviderURL }: MatchesPublicKeyProp) {\n  const theme = useMantineTheme();\n  return (\n    <Text ta={IS_HANDHELD_DEVICE ? 'center' : undefined} size={IS_HANDHELD_DEVICE ? 'xs' : 'sm'} c='green.7' fw={500}>\n      <CheckMarkCircleFill height={11} width={11} fill={theme.colors.green[7]} style={{ marginBottom: -1 }} />{' '}\n      {t('exitPubKeyTooltip')}{' '}\n      <span style={{ display: 'inline-block' }}><Anchor underline='always' href={exitProviderURL}>{exitProviderId}</Anchor>{' '}\n        <ThemeIcon variant='transparent' size={12}><ExternalLinkIcon style={{ height: '100%' }} /></ThemeIcon></span>\n    </Text>\n  );\n}\n"
  },
  {
    "path": "obscura-ui/src/views/LogInView.tsx",
    "content": "import { Anchor, Button, Card, Code, CopyButton, Group, Image, Loader, Space, Stack, Text, TextInput, Title, Transition } from '@mantine/core';\nimport { useDisclosure } from '@mantine/hooks';\nimport { notifications } from '@mantine/notifications';\nimport { motion, MotionValue, useSpring, useTransform } from 'framer-motion';\nimport { ChangeEvent, FormEvent, ForwardedRef, forwardRef, ReactNode, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport { Trans, useTranslation } from 'react-i18next';\nimport { IoArrowForward, IoCard, IoCopy } from 'react-icons/io5';\n\nimport AppIcon from '../../../apple/client/Assets.xcassets/AppIcon.appiconset/icon_128x128.png';\nimport * as commands from '../bridge/commands';\nimport { IS_HANDHELD_DEVICE, PLATFORM } from '../bridge/SystemProvider';\nimport * as ObscuraAccount from '../common/accountUtils';\nimport { AppContext } from '../common/appContext';\nimport { HEADER_TITLE, multiRef, normalizeError } from '../common/utils';\nimport { ButtonLink } from '../components/ButtonLink';\nimport { ConfirmationDialog } from '../components/ConfirmationDialog';\nimport DebuggingArchive, { DebuggingArchiveVariant } from '../components/DebuggingArchive';\nimport { PaymentManagementSheet } from '../components/PaymentManagementSheet';\nimport DecoOrangeTop from '../res/deco/deco-orange-top.svg';\nimport DecoOrangeBottom from '../res/deco/deco-signup-mobile.svg';\nimport { fmtErrorI18n, TranslationKey } from '../translations/i18n';\nimport classes from './LoginView.module.css';\n\ninterface LogInProps {\n  accountNumber: ObscuraAccount.AccountId,\n  accountActive?: boolean\n}\n\nconst COPY_ACCOUNT_WIDTH = IS_HANDHELD_DEVICE ? 300 : '24ch';\nconst BACKGROUND_IMAGE = IS_HANDHELD_DEVICE ? DecoOrangeBottom : DecoOrangeTop;\nconst BACKGROUND_POSITION = IS_HANDHELD_DEVICE ? 'bottom' : 'top';\nconst TOP_SPACING = IS_HANDHELD_DEVICE ? '16vh' : '28vh';\n\nexport default function LogIn({ accountNumber, accountActive }: LogInProps) {\n  const { t } = useTranslation();\n  const { osStatus } = useContext(AppContext);\n  const [loginWaiting, setLoginWaiting] = useState(false);\n  const [awaitingAccountCreation, setCreatingWaiting] = useState(false);\n  const [apiError, setApiError] = useState<string | null>(null);\n  const inputRef = useRef<HTMLInputElement | null>(null);\n\n  useEffect(() => {\n    if (!!apiError) {\n      const timeoutSeconds = apiError === 'apiSignupLimitExceeded' ? 12 * 3600 : 9;\n      setTimeout(() => { setApiError(null) }, timeoutSeconds * 1000)\n    }\n  }, [apiError]);\n\n  const loginErrorTimeout = useRef<number>(undefined);\n  // clear timeout on component dismount\n  useEffect(() => {\n    return () => clearTimeout(loginErrorTimeout.current);\n  }, []);\n\n  const handleSubmit = async (e: FormEvent) => {\n    // prevent refresh\n    e.preventDefault();\n\n    if (!loginWaiting && inputRef.current !== null) {\n      setLoginWaiting(true);\n      try {\n        const accountId = ObscuraAccount.parseAccountIdInput(inputRef.current.value);\n        await commands.setInNewAccountFlow(false);\n        await commands.login(accountId, true);\n        loginErrorTimeout.current = window.setTimeout(() => {\n          setLoginWaiting(false);\n          notifications.show({\n            title: t('Error'),\n            message: t('loginError-unknown'),\n            color: 'red'\n          });\n        }, 10_000);\n      } catch (e) {\n        const error = normalizeError(e);\n        const message = error instanceof commands.CommandError\n          ? fmtErrorI18n(t, error)\n          : error instanceof ObscuraAccount.ObscuraAccountIdError\n            ? fmtErrorI18n(t, error)\n            : error.message;\n\n        notifications.show({\n          title: t('Error Logging In'),\n          message,\n          color: 'red'\n        });\n        setTimeout(() => setLoginWaiting(false), 500);\n      }\n    }\n  }\n\n  const initiateAccountCreation = async () => {\n    setCreatingWaiting(true);\n    const newAccountNumber = ObscuraAccount.generateAccountNumber();\n    try {\n      // show new account funding flow\n      await commands.setInNewAccountFlow(true);\n      await commands.login(newAccountNumber, true);\n    } catch (e) {\n      const error = normalizeError(e);\n      if (error instanceof commands.CommandError) {\n        setApiError(error.code.startsWith('api') ? error.i18nKey() : ('vpnError-' + error.message));\n      } else {\n        setApiError(error.message);\n      }\n    } finally {\n      setTimeout(() => setCreatingWaiting(false), 200);\n    }\n  }\n\n  let loginContainerClasses = `${classes.loginContainer} ${classes.backgroundImage}`;\n  if (IS_HANDHELD_DEVICE) {\n    loginContainerClasses = `${loginContainerClasses} ${classes.loginContainerHandheld}`;\n  }\n\n  return (\n    <Stack className={loginContainerClasses} style={{backgroundImage: `url(\"${BACKGROUND_IMAGE}\")`}}>\n        <Space h={TOP_SPACING} />\n        {\n          (!!accountNumber || awaitingAccountCreation) ? <AccountGeneration loading={awaitingAccountCreation} generatedAccountId={accountNumber} accountActive={accountActive} />\n            :\n            <Stack gap={20} component='form' onSubmit={handleSubmit} align='center'>\n              <Group>\n                <Image src={AppIcon} w={64} />\n                <Title>{HEADER_TITLE}</Title>\n              </Group>\n              <Stack maw='min-content' className={classes.sectionContainer}>\n                <Text size='sm' ta='center'>\n                  <Trans\n                    i18nKey='legalNotice'\n                    components={[<Anchor href={ObscuraAccount.LEGAL_WEBPAGE} />]}\n                  />\n                </Text>\n                <Button onClick={initiateAccountCreation}>{t('Create an Account')}</Button>\n                {\n                  apiError &&\n                  <Card shadow='sm' padding='lg' my={0} m={0} radius='md'>\n                    <Text c='red'>{t(apiError as TranslationKey)}</Text>\n                  </Card>\n                }\n                <AccountNumberInput ref={inputRef} />\n                <Button disabled={loginWaiting} type='submit' variant='outline'>{loginWaiting ? <Loader size='sm' /> : t('Log In')}</Button>\n              </Stack>\n              <DebuggingArchive osStatus={osStatus} variant={DebuggingArchiveVariant.LoginLabel} />\n            </Stack >\n        }\n    </Stack>\n  );\n}\n\nconst SPINNING_DURATION = 900;\nconst ANIMATION_HEIGHT = 20;\n\ninterface AccountGenerationProps {\n  generatedAccountId: ObscuraAccount.AccountId,\n  accountActive?: boolean,\n  loading: boolean\n}\n\nfunction AccountGeneration({ generatedAccountId, accountActive, loading }: AccountGenerationProps) {\n  const { t } = useTranslation();\n  const [value, setValue] = useState(ObscuraAccount.generateAccountNumber());\n  const [confirmAccountSecured, { open, close }] = useDisclosure(false);\n  const [paymentPressed, userPressOnPayment] = useState(false);\n  const [accountNumberCopied, setAccountNumberCopied] = useState(false);\n  const timeoutRef = useRef<number>(undefined);\n  const [paymentSheetOpened, { open: openPaymentSheet, close: closePaymentSheet }] = useDisclosure(false);\n\n  const rollAccountValue = (tries: number) => {\n    if (tries === 0) return setValue(generatedAccountId);\n    else setValue(ObscuraAccount.generateAccountNumber())\n    timeoutRef.current = window.setTimeout(() => rollAccountValue(loading ? tries : tries - 1), SPINNING_DURATION);\n  }\n\n  useEffect(() => {\n    rollAccountValue(2);\n    return () => clearTimeout(timeoutRef.current);\n  }, [loading]);\n\n  useEffect(() => {\n    const onScreenshotDetected = () => {\n      console.log(\"Screenshot detected, enabling payment button\");\n      setAccountNumberCopied(true);\n    };\n\n    window.addEventListener('screenshotDetected', onScreenshotDetected);\n    return () => window.removeEventListener('screenshotDetected', onScreenshotDetected);\n  }, []);\n\n  const showDoneButton = accountActive || paymentPressed;\n\n  const cancelSignUp = async () => {\n    try {\n      await commands.logout();\n      await commands.setInNewAccountFlow(false);\n    } catch (e) {\n      const error = normalizeError(e);\n      notifications.show({ title: t('logOutFailed'), message: <Text>{t('pleaseReportError')}<br /><Code>{error.message}</Code></Text> });\n    }\n  }\n\n  return (\n    <>\n      {IS_HANDHELD_DEVICE && <PaymentManagementSheet opened={paymentSheetOpened} onClose={closePaymentSheet} />}\n      <ConfirmationDialog opened={confirmAccountSecured} onClose={close}>\n        <Stack p={IS_HANDHELD_DEVICE ? 'xl' : undefined} ta={IS_HANDHELD_DEVICE ? 'center' : undefined}>\n          <Text>{t('accountNumberStoredConfirmation')}</Text>\n          {\n            IS_HANDHELD_DEVICE ?\n              (\n                <>\n                  <Button onClick={() => {\n                    userPressOnPayment(true);\n                    close();\n                    openPaymentSheet();\n                  }}>{t('Continue to payment')}</Button>\n                </>\n              ) : (\n                <ButtonLink onClick={() => {\n                  userPressOnPayment(true);\n                  close();\n                }} href={ObscuraAccount.payUrl(generatedAccountId)}>{t('Continue to payment')}</ButtonLink>\n              )\n          }\n        </Stack>\n      </ConfirmationDialog>\n      <Stack maw={400} mx='auto' justify='center' align='center' style={{ overflow: 'hidden' }}>\n        <Image src={AppIcon} w={64} />\n        <AccountId accountId={value} />\n        <Transition mounted={value === generatedAccountId} transition='fade-up' duration={600}>\n          {styles => <Stack style={styles} justify='space-between' align='center' className={`${classes.sectionContainer} ${classes.copyAccountStack}`}>\n            <CopyButton value={ObscuraAccount.accountIdToString(generatedAccountId)}>\n              {({ copied, copy }) => (\n                <Button variant={copied ? 'filled' : undefined} color={copied ? 'teal' : undefined} miw={COPY_ACCOUNT_WIDTH}\n                  onClick={() => {\n                    setAccountNumberCopied(true);\n                    copy();\n                  }} leftSection={<IoCopy size='1em' />}>\n                  {copied ? t('Copied Account Number') : t('Copy Account Number')}\n                </Button>\n              )}\n            </CopyButton>\n            <Stack align='center' gap='lg'>\n              {!accountNumberCopied &&\n                <Text ta='center' size='sm' ml='xs' mr='xs'>\n                  <Trans i18nKey='pleaseCopyAccountNumber' values={{ context: PLATFORM }} components={{ b: <b /> }} />\n                </Text>\n              }\n              <Group preventGrowOverflow={false} grow miw={COPY_ACCOUNT_WIDTH} justify='center'>\n                <Button\n                  miw={showDoneButton ? undefined : COPY_ACCOUNT_WIDTH}\n                  variant={(!showDoneButton && IS_HANDHELD_DEVICE) ? 'outline' : undefined}\n                  disabled={!accountNumberCopied}\n                  onClick={open}\n                  leftSection={showDoneButton ? <IoCard /> : <IoArrowForward />}\n                >\n                  {showDoneButton ? t('Payment') : t('proceedToPayment')}\n                </Button>\n                {\n                  showDoneButton &&\n                  <Button leftSection={<IoArrowForward />} variant='outline' disabled={!showDoneButton} onClick={() => commands.setInNewAccountFlow(false)}>{t('Done')}</Button>\n                }\n              </Group>\n              {\n                !showDoneButton &&\n                <Text size='sm' ta='center'>\n                  <Trans\n                    i18nKey='wantExistingAccount'\n                    components={[<Anchor onClick={cancelSignUp} c='blue' />]}\n                  />\n                </Text>\n              }\n            </Stack>\n          </Stack>}\n        </Transition>\n      </Stack>\n    </>\n  );\n}\n\nfunction AccountId({ accountId }: { accountId: ObscuraAccount.AccountId }) {\n  const result = [];\n  const accountIdStr = ObscuraAccount.accountIdToString(accountId);\n  for (let i = 0; i < accountIdStr.length; i += 1) {\n    result.push(<DigitsWheel key={i} digit={accountIdStr.charAt(i)} />)\n    if (i % 4 === 3 && i !== accountIdStr.length - 1) {\n      result.push(<span key={`span-${i}`}>&nbsp;-&nbsp;</span>);\n    }\n  }\n\n  return (\n    <Card radius='md' withBorder w={300}>\n      <div className={classes.animatedAccountId}>\n        {result}\n      </div>\n    </Card>\n  );\n}\n\n// modified https://buildui.com/recipes/animated-counter\nfunction DigitsWheel({ digit }: { digit: string }) {\n  const int = parseInt(digit);\n  const mv = useSpring(int, { bounce: 0, duration: SPINNING_DURATION });\n\n  useEffect(() => {\n    mv.set(int);\n  }, [mv, digit]);\n\n  return (\n    <div className={classes.digitsWheel}>\n      {[...Array(10).keys()].map((i) => (\n        <Digit key={i} mv={mv} number={i} />\n      ))}\n    </div>\n  );\n}\n\ninterface DigitProps {\n  mv: MotionValue<number>,\n  number: number\n}\n\nfunction Digit({ mv, number }: DigitProps) {\n  let y = useTransform(mv, latest => {\n    let placeValue = latest % 10;\n    let offset = (10 + number - placeValue) % 10;\n\n    let memo = offset * ANIMATION_HEIGHT;\n\n    if (offset > 5) {\n      memo -= 10 * ANIMATION_HEIGHT;\n    }\n\n    return memo;\n  });\n\n  return (\n    <motion.span\n      style={{ y }}\n      className={classes.digit}\n      transition={{ delay: 1 }}\n    >\n      {number}\n    </motion.span>\n  );\n}\n\nconst AccountNumberInput = forwardRef(function AccountNumberInput(props: {}, ref: ForwardedRef<HTMLInputElement>) {\n  // maintaining cursor index while editing is improved on top of https://stackoverflow.com/a/68928267/7732434\n  const { t } = useTranslation();\n\n  const internalRef = useRef<HTMLInputElement | null>(null);\n  const [error, setError] = useState<ReactNode>();\n  const [value, setValue] = useState<string>();\n  const [cursorIdx, setCursorIdx] = useState<number | null>(null);\n\n  useLayoutEffect(() => {\n    const inputElem = internalRef.current;\n    if (inputElem !== null) inputElem.setSelectionRange(cursorIdx, cursorIdx);\n  }, [cursorIdx, value]);\n\n  const validateAccountNumber = (value: string) => {\n    try {\n      ObscuraAccount.parseAccountIdInput(value);\n    } catch (e) {\n      const error = normalizeError(e);\n      return t((error instanceof ObscuraAccount.ObscuraAccountIdError ? error.i18nKey() : error.message) as TranslationKey);\n    }\n    return null;\n  }\n\n  const onChange = (e: ChangeEvent<HTMLInputElement>) => {\n    const newValue = ObscuraAccount.formatPartialAccountId(e.currentTarget.value);\n    if (e.currentTarget.value.length === e.currentTarget.selectionStart) {\n      // if appending to the value, set cursor to the end of the formatted value\n      setCursorIdx(newValue.length);\n    } else {\n      setCursorIdx(e.currentTarget.selectionStart);\n    }\n    setValue(newValue);\n    setError(newValue.length === 0 ? null : validateAccountNumber(e.currentTarget.value));\n  }\n\n  return <TextInput autoComplete='username' name='username' inputMode='numeric' ref={multiRef(internalRef, ref)} value={value} onChange={onChange} error={error} required miw={270} label={t('Obscura Account Number')} placeholder='XXXX - XXXX - XXXX - XXXX - XXXX' />;\n});\n"
  },
  {
    "path": "obscura-ui/src/views/LoginView.module.css",
    "content": ".animatedAccountId {\n    justify-content: center;\n    display: flex;\n    overflow: hidden;\n    font-variant: tabular-nums;\n}\n\n.digitsWheel {\n    position: relative;\n    width: 1ch;\n    font-variant: tabular-nums;\n}\n\n.digit {\n    position: absolute;\n    inset: 0;\n    display: flex;\n    align-items: center;\n    justify-items: center;\n}\n\n.backgroundImage {\n    background-repeat: no-repeat;\n    background-size: contain;\n}\n\n.loginContainer {\n    min-height: 100vh;\n    gap: 20px !important;\n    background-position: top;\n    @mixin dark {\n        background-color: var(--mantine-color-dark-8);\n    }\n}\n\n.loginContainerHandheld {\n    background-position: bottom;\n    @media (orientation: portrait) {\n        /* when the soft keyboard is open,\n           this padding ensures that the background image\n           is below the debugging archive label\n         */\n        padding-bottom: 120px;\n    }\n}\n\n.sectionContainer {\n    background-color: light-dark(white, var(--mantine-color-dark-8));\n    padding: 0 0.5em 0.5em 0.5em;\n}\n\n.copyAccountStack {\n    min-height: 32vh;\n    max-height: 42vh;\n}\n"
  },
  {
    "path": "obscura-ui/src/views/Settings.module.css",
    "content": ".container {\n    padding-left: 60px;\n    padding-right: 60px;\n    padding-top: 20px;\n    padding-bottom: 40px;\n}\n\n.experimentalAccordionControl {\n    box-shadow: var(--mantine-shadow-xs);\n}\n\n@media screen and (max-width: $mantine-breakpoint-xs) {\n    .container {\n        padding-top: calc(20px + env(safe-area-inset-top));\n        padding-left: 20px;\n        padding-right: 20px;\n    }\n}\n"
  },
  {
    "path": "obscura-ui/src/views/Settings.tsx",
    "content": "import { Accordion, ActionIcon, Alert, Button, Card, Checkbox, Divider, Group, Radio, Stack, Switch, Text, Title, useMantineColorScheme } from '@mantine/core';\nimport { notifications } from '@mantine/notifications';\nimport React, { ReactNode, useContext, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { BsCircleHalf } from 'react-icons/bs';\nimport { IoCheckmark, IoInformationCircleOutline, IoMoon, IoSunnySharp } from 'react-icons/io5';\nimport { MdBlock, MdWarning } from 'react-icons/md';\nimport * as commands from '../bridge/commands';\nimport { PLATFORM, Platform } from '../bridge/SystemProvider';\nimport { AppContext, DNSContentBlock, featureFlagEnabled, FeatureFlagKey, KnownFeatureFlagKey } from '../common/appContext';\nimport commonClasses from '../common/common.module.css';\nimport { NotificationId } from '../common/notifIds';\nimport { normalizeError } from '../common/utils';\nimport { fmtErrorI18n, TranslationKey } from '../translations/i18n';\nimport classes from './Settings.module.css';\n\nconst APPLE_PLATFORMS = new Set([Platform.macOS, Platform.iOS]);\nconst IS_APPLE = APPLE_PLATFORMS.has(PLATFORM);\n\nconst CUSTOM_DNS_PLATFORMS_EXCLUDED = new Set([Platform.Android]);\nconst CUSTOM_DNS_SUPPORTED = !CUSTOM_DNS_PLATFORMS_EXCLUDED.has(PLATFORM);\n\nexport default function Settings() {\n  return (\n    <Stack mb='xl' gap='lg' align='flex-start' className={classes.container}>\n      <GeneralSettings />\n      {CUSTOM_DNS_SUPPORTED && <DnsSettings />}\n      <ExperimentalSettings />\n      <NetworkSettings />\n      <AppearanceSettings />\n    </Stack>\n  );\n}\n\nfunction DnsSettings() {\n  const { t } = useTranslation();\n  const { appStatus } = useContext(AppContext);\n  const { dnsContentBlock, useSystemDns } = appStatus;\n\n  const handleModeChange = (val: string) => {\n    commands.setUseSystemDns(val === 'system');\n  };\n\n  const onBlockChange = (key: keyof DNSContentBlock, e: React.ChangeEvent<HTMLInputElement>) => {\n    const checked = e.currentTarget.checked;\n    const newBlock = { ...dnsContentBlock, [key]: checked };\n    commands.setDnsContentBlock(newBlock);\n  };\n\n  return (\n    <Card padding='md' radius='md' w='100%' shadow='xs'>\n      <Stack gap='xs'>\n        <Group gap='xs'>\n          <MdBlock size='1.5em' style={{ color: 'var(--mantine-color-dimmed)' }} />\n          <Title order={4}>{t('dnsSetting')}</Title>\n        </Group>\n\n        <Radio.Group value={useSystemDns ? 'system' : 'obscura'} onChange={handleModeChange}>\n          <Stack gap='sm'>\n            <Radio value=\"obscura\" label={t('dnsModeObscura')} />\n\n            <Stack gap='xs' ml='xl'>\n              <Checkbox disabled={useSystemDns} checked={dnsContentBlock.ad} onChange={(e) => onBlockChange('ad', e)} label={t('dnsBlockAds')} />\n              <Checkbox disabled={useSystemDns} checked={dnsContentBlock.tracker} onChange={(e) => onBlockChange('tracker', e)} label={t('dnsBlockTrackers')} />\n              <Checkbox disabled={useSystemDns} checked={dnsContentBlock.malware} onChange={(e) => onBlockChange('malware', e)} label={t('dnsBlockMalware')} />\n              <Checkbox disabled={useSystemDns} checked={dnsContentBlock.gambling} onChange={(e) => onBlockChange('gambling', e)} label={t('dnsBlockGambling')} />\n              <Checkbox disabled={useSystemDns} checked={dnsContentBlock.adult} onChange={(e) => onBlockChange('adult', e)} label={t('dnsBlockAdult')} />\n              <Checkbox disabled={useSystemDns} checked={dnsContentBlock.socialMedia} onChange={(e) => onBlockChange('socialMedia', e)} label={t('dnsBlockSocialMedia')} />\n            </Stack>\n\n            <Radio value=\"system\" label={t('dnsModeSystem')} description={t('dnsModeSystemDescription')} />\n          </Stack>\n        </Radio.Group>\n      </Stack>\n    </Card>\n  );\n}\n\nfunction GeneralSettings() {\n  const { t } = useTranslation();\n  const { appStatus, osStatus } = useContext(AppContext);\n  const loginItemStatus = osStatus.loginItemStatus;\n  const loginItemRegistered = loginItemStatus?.registered;\n  const loginItemError = loginItemStatus?.error;\n\n  const registerAtLogin = async () => {\n    let success = true;\n    try {\n      await commands.registerAsLoginItem();\n    } catch {\n      success = false;\n    }\n    notifications.hide(NotificationId.OPEN_AT_LOGIN);\n    notifications.show({\n      id: NotificationId.OPEN_AT_LOGIN,\n      title: success ? t('Success') : t('Failed'),\n      message: success ? t('openAtLoginEnabled') : t('openAtLoginFailedToEnable'),\n      loading: false,\n      color: success ? 'green' : 'red'\n    });\n  }\n\n  const unregisterAtLogin = async () => {\n    let success = true;\n    try {\n      await commands.unregisterAsLoginItem();\n    } catch {\n      success = false;\n    }\n    notifications.hide(NotificationId.OPEN_AT_LOGIN);\n    notifications.show({\n      id: NotificationId.OPEN_AT_LOGIN,\n      title: success ? t('Success') : t('Failed'),\n      message: success ? t('openAtLoginDisabled') : t('openAtLoginFailedToDisable'),\n      loading: false,\n      color: success ? 'green' : 'red'\n    });\n  }\n\n  return (\n    <Card padding='md' radius='md' w='100%' shadow='xs'>\n      <Stack gap='xs'>\n        <Title order={4}>{t('General')}</Title>\n        {\n          loginItemStatus &&\n          <Switch error={loginItemError === undefined ? undefined : loginItemError} disabled={loginItemError !== undefined || loginItemRegistered === undefined} checked={loginItemRegistered} onChange={event => event.currentTarget.checked ? registerAtLogin() : unregisterAtLogin()} label={t('openAtLoginRegister')} />\n        }\n        <Divider w='100%' />\n        <Stack gap={2} w='100%'>\n          <Switch checked={appStatus.autoConnect} onChange={event => commands.setAutoConnect(event.currentTarget.checked)} label={t('autoConnectStartup')} description={t('autoConnectStartup-behavior')} />\n        </Stack>\n      </Stack>\n    </Card>\n  );\n}\n\nfunction NetworkSettings() {\n  const { t } = useTranslation();\n  const [wgRotated, setWgRotated] = useState(false);\n  const [wgRotatedTimeout, setWGRotateTimeout] = useState<number | null>(null);\n\n  const rotateWgKey = async () => {\n    try {\n      await commands.rotateWgKey();\n      window.clearTimeout(wgRotatedTimeout!);\n      setWgRotated(true);\n      setWGRotateTimeout(window.setTimeout(() => setWgRotated(false), 2000));\n    } catch (e) {\n      const error = normalizeError(e);\n      const message = error instanceof commands.CommandError\n        ? fmtErrorI18n(t, error) : error.message;\n      notifications.show({\n        title: t('Error'),\n        message: message,\n        color: 'red',\n      });\n    }\n  }\n\n  return (\n    <Card padding='md' radius='md' w='100%' shadow='xs'>\n      <Stack gap='xs' align='flex-start'>\n        <Title order={4}>{t('Network')}</Title>\n        <Button onClick={rotateWgKey} bg={wgRotated ? 'teal' : undefined} rightSection={wgRotated ? <IoCheckmark /> : undefined} miw={200}>\n          {wgRotated ? t('Rotated') : t('rotateWgKey')}\n        </Button>\n      </Stack>\n    </Card>\n  );\n}\n\nfunction ExperimentalSettings() {\n  const { t } = useTranslation();\n  const { appStatus } = useContext(AppContext);\n\n  return (\n    <Accordion variant='separated' w='100%' classNames={{ item: `${commonClasses.elevatedSurface} ${classes.experimentalAccordionControl}` }}>\n      <Accordion.Item value='experimental'>\n        <Accordion.Control>\n          <Title order={4}>{t('Experimental')}</Title>\n        </Accordion.Control>\n        <Accordion.Panel style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>\n          <Stack gap='lg' align='flex-start' my='xs'>\n            {appStatus.featureFlagKeys.map(featureFlagKey => {\n              if (featureFlagKey === KnownFeatureFlagKey.KillSwitch && !IS_APPLE) {\n                return null;\n              }\n              return (\n                <React.Fragment key={featureFlagKey}>\n                  <FeatureFlagToggle featureFlagKey={featureFlagKey} />\n                  <Divider w='100%' />\n                </React.Fragment>\n              );\n            })}\n            {IS_APPLE && <StrictLeakPreventionSwitch />}\n          </Stack>\n        </Accordion.Panel>\n      </Accordion.Item>\n    </Accordion>\n  );\n}\n\nfunction AppearanceSettings() {\n  const { t } = useTranslation();\n  const { setColorScheme } = useMantineColorScheme();\n  const resetMantineColorScheme = () => setColorScheme('auto');\n\n  return (\n    <Card padding='md' radius='md' w='100%' shadow='xs' pb='lg'>\n      <Stack gap='lg'>\n        <Title order={4}>{t('Appearance')}</Title>\n        <Group gap='0' maw='25em' justify='space-around'>\n          {colorSchemeOptions.map(({ colorScheme, i18nKey, icon }) => (\n            <ActionIcon\n              key={colorScheme}\n              variant='default'\n              onClick={async () => {\n                resetMantineColorScheme();\n                try {\n                  await commands.setColorScheme(colorScheme);\n                } catch (e) {\n                  console.error('Failed to set theme:', e);\n                }\n              }}\n              h={80}\n              w={100}\n            >\n              <Stack align='center' gap='xs'>\n                {icon}\n                <Text size='sm'>{t(i18nKey)}</Text>\n              </Stack>\n            </ActionIcon>\n          ))}\n        </Group>\n      </Stack>\n    </Card>\n  );\n}\n\n\nfunction StrictLeakPreventionSwitch() {\n  const { t } = useTranslation();\n  const { vpnConnected, osStatus } = useContext(AppContext);\n  const { strictLeakPrevention } = osStatus;\n  const { showLoadingUI, error, execute: setStrictLeakPrevention } = commands.useCommand({ command: commands.setStrictLeakPrevention});\n\n  const disabled = strictLeakPrevention && vpnConnected;\n\n  return (\n    <Stack gap='xs' w='100%'>\n      <Switch\n        error={error}\n        checked={strictLeakPrevention}\n        onChange={(event) => setStrictLeakPrevention(event.currentTarget.checked)}\n        disabled={disabled || showLoadingUI}\n        label={t('strictLeakPreventionLabel')}\n        description={t('strictLeakPreventionDescription')}\n      />\n      {disabled &&\n        <Alert icon={<IoInformationCircleOutline />} color='blue' variant='light'>\n          {t('strictLeakPreventionTooltip')}\n        </Alert>\n      }\n      <Alert icon={<MdWarning />} color='orange' variant='light'>\n        {t('strictLeakPreventionLanWarning')}\n      </Alert>\n      <Alert icon={<MdWarning />} color='red' variant='light'>\n        {t('strictLeakPreventionReliabilityWarning')}\n      </Alert>\n    </Stack>\n  );\n}\n\nfunction FeatureFlagToggle({ featureFlagKey }: { featureFlagKey: FeatureFlagKey }) {\n  const { t, i18n } = useTranslation();\n  const { appStatus } = useContext(AppContext);\n  const { showLoadingUI, error, execute: setFeatureFlag } = commands.useCommand({ command: commands.setFeatureFlag });\n\n  const onChange = (checked: boolean) => setFeatureFlag(featureFlagKey, checked);\n\n  const labelKey = `featureFlag-${featureFlagKey}-Label`;\n  const descriptionKey = `featureFlag-${featureFlagKey}-Description`;\n\n  const label = i18n.exists(labelKey) ? t(labelKey as TranslationKey) : featureFlagKey;\n  const description = i18n.exists(descriptionKey) ? t(descriptionKey as TranslationKey) : undefined;\n\n  const additionalComponents = FEATURE_FLAG_CUSTOM_UI[featureFlagKey]?.(t);\n\n  return (\n    <Stack gap='xs' w='100%'>\n      <Switch\n        error={error}\n        checked={featureFlagEnabled(appStatus.featureFlags[featureFlagKey])}\n        onChange={(event) => onChange(event.currentTarget.checked)}\n        disabled={showLoadingUI}\n        label={label}\n        description={description}\n      />\n      {additionalComponents}\n    </Stack>\n  );\n}\n\nconst FEATURE_FLAG_CUSTOM_UI: Partial<Record<FeatureFlagKey, (t: ReturnType<typeof useTranslation>['t']) => ReactNode>> = {\n  [KnownFeatureFlagKey.QuicFramePadding]: (t) => (\n    <Alert icon={<MdWarning />} color='orange' variant='light'>\n      {t('featureFlag-quicFramePadding-BandwidthWarning')}\n    </Alert>\n  ),\n  [KnownFeatureFlagKey.ForceSmallMtu]: (t) => (\n    <Alert icon={<MdWarning />} color='orange' variant='light'>\n      {t('featureFlag-forceSmallMtu-Warning')}\n    </Alert>\n  ),\n  [KnownFeatureFlagKey.TcpTlsTunnel]: (t) => (\n    <Alert icon={<MdWarning />} color='orange' variant='light'>\n      {t('featureFlag-tcpTlsTunnel-BandwidthWarning')}\n    </Alert>\n  ),\n};\n\nconst colorSchemeOptions = [\n  { colorScheme: 'light', i18nKey: 'Light', icon: <IoSunnySharp size='1.5em' /> },\n  { colorScheme: 'dark', i18nKey: 'Dark', icon: <IoMoon size='1.25em' /> },\n  { colorScheme: 'auto', i18nKey: 'System', icon: <BsCircleHalf style={{ transform: 'rotate(180deg)' }} size='1.25em' /> }\n] as const;\n"
  },
  {
    "path": "obscura-ui/src/views/SplashScreen.tsx",
    "content": "import { Group, Image, Loader, Stack, Text } from '@mantine/core';\nimport { useThrottledValue } from '@mantine/hooks';\nimport AppIcon from '../../../apple/client/Assets.xcassets/AppIcon.appiconset/icon_128x128.png';\nimport { IS_HANDHELD_DEVICE } from '../bridge/SystemProvider';\nimport { OsStatus } from '../common/appContext';\nimport DebuggingArchive from '../components/DebuggingArchive';\nimport ObscuraWordmark from '../components/ObscuraWordmark';\n\ninterface SplashScreenProps {\n  text: string;\n  osStatus: OsStatus | null\n}\n\nexport default function SplashScreen({ text = '', osStatus }: SplashScreenProps) {\n  // only show help UI if loading for a prolonged period of time\n  const osStatusThrottled = useThrottledValue(osStatus, 5000);\n  return (\n    <Stack h='100vh' align='center' justify='center' gap='xl'>\n      <Image src={AppIcon} w={64} />\n      <ObscuraWordmark />\n      <Group>\n        {text.length > 0 && <Text>{text}</Text>}\n        <Loader size='xl' type='bars' />\n      </Group>\n      {\n        osStatusThrottled !== null && osStatus !== null &&\n        <DebuggingArchive osStatus={osStatus} />\n      }\n    </Stack>\n  );\n}\n"
  },
  {
    "path": "obscura-ui/src/views/index.ts",
    "content": "export { default as About } from './About';\nexport { default as Account } from './AccountView';\nexport { default as Connection } from './ConnectionView';\nexport { default as DeveloperView } from './DeveloperView';\nexport { default as Help } from './HelpView';\nexport { default as Location } from './LocationView';\nexport { default as LogIn } from './LogInView';\nexport { default as FallbackAppRender } from './render-fallbacks/FallbackAppRender';\nexport { default as Settings } from './Settings';\nexport { default as SplashScreen } from './SplashScreen';\n"
  },
  {
    "path": "obscura-ui/src/views/render-fallbacks/FallbackAppRender.tsx",
    "content": "import { FallbackProps } from 'react-error-boundary';\nimport { normalizeError } from '../../common/utils';\n\n// NOTE: this component CANNOT USE HOOKS\nexport default function FallbackAppRender({ error, resetErrorBoundary }: FallbackProps) {\n  // call resetErrorBoundary() to reset the error boundary and retry the render.\n  return (\n    <div role='alert' style={{ margin: 10 }}>\n      <h2>Fatal Error While Rendering</h2>\n      <p>Click the Obscura VPN status icon in the status menu (the area with the battery icon), and then click \"Create Debugging Archive.\" A finder window should spawn with the zip file selected. Please send this file to us as well a screenshot of the following error.</p>\n      <h3>What went wrong</h3>\n      <pre style={{ color: 'red', fontWeight: 'bold', whiteSpace: 'break-spaces', marginBottom: '1.5em', marginLeft: '1.5em' }}>{normalizeError(error).message}</pre>\n      <button style={{ background: '#ff5f25', borderRadius: 5 }} onClick={resetErrorBoundary}>Refresh</button>\n      <p>After creating a debugging archive, you can try to get back to a usable state by pressing the \"Refresh\" button above. If this message is seen immediately after reload, an app update may also be needed.</p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "obscura-ui/src/wdyr.ts",
    "content": "import React from 'react';\n\nif (process.env.NODE_ENV === 'development') {\n  const whyDidYouRender = (await import('@welldone-software/why-did-you-render')).default;\n  whyDidYouRender(React, {\n    // use `Component.whyDidYouRender = true` instead\n    trackAllPureComponents: false,\n  });\n}\n"
  },
  {
    "path": "obscura-ui/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"allowJs\": false,\n    \"baseUrl\": \".\",\n    \"esModuleInterop\": true,\n    \"jsx\": \"react-jsx\",\n    \"jsxImportSource\": \"react\",\n    \"lib\": [\n      \"es2022\",\n      \"dom\"\n    ],\n    \"module\": \"ES2022\",\n    \"moduleResolution\": \"bundler\",\n    \"noUncheckedIndexedAccess\": true,\n    \"strict\": true,\n    \"target\": \"ES2021\",\n    \"types\": [\n      \"vite/client\",\n      \"vite-plugin-svgr/client\",\n      \"@types/node\"\n    ],\n    \"skipLibCheck\": true\n  }\n}\n"
  },
  {
    "path": "obscura-ui/vite-env.d.ts",
    "content": "interface Window {\n  webkit: {\n    messageHandlers: {\n      commandBridge: {\n        postMessage(commandJson: string): Promise<string>\n      },\n      logBridge: {\n        postMessage: {\n          level: 'log' | 'info' | 'warn' | 'error' | 'debug',\n          message: string\n        }\n      },\n      errorBridge: {\n        postMessage({\n          message,\n          source,\n          lineno,\n          colno,\n        }: {\n          message: string,\n          source: string,\n          lineno: number,\n          colno: number\n        }): void\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "obscura-ui/vite.config.ts",
    "content": "import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\nimport svgr from 'vite-plugin-svgr';\nimport { visualizer } from 'rollup-plugin-visualizer';\n// https://vitejs.dev/config/\nexport default defineConfig({\n  // WkWebkitWebview specifics\n  base: '',\n  // Don't serve public static assets, have vite process all assets\n  publicDir: false,\n\n  plugins: [\n    svgr(),\n    react(),\n    visualizer(),\n  ],\n  // prevent vite from obscuring rust errors\n  clearScreen: false,\n  server: {\n    port: 1420,\n    strictPort: true,\n    // WK_WEB_VIEW will be defined when using the Dev Client scheme in XCode\n    open: process.env.WK_WEB_VIEW === undefined\n  },\n  // env variables\n  envPrefix: ['VITE_', 'OBS_WEB_'],\n\n  build: {\n    target: ['es2021', 'safari14'],\n    minify: 'esbuild',\n    // produce sourcemaps for debug builds\n    sourcemap: false,\n    outDir: 'build',\n  },\n\n  resolve: {\n    alias: {\n      \"$licenses.json\": process.env.LICENSE_JSON!\n    }\n  },\n})\n"
  },
  {
    "path": "rustlib/.cargo/config.toml",
    "content": "[target.'cfg(target_os = \"android\")']\nrustflags = [\n    \"-C\",\n    \"link-arg=-Wl,-z,max-page-size=16384\",\n    \"-C\",\n    \"link-arg=-Wl,-z,common-page-size=16384\",\n]\n\n[target.x86_64-apple-darwin]\nrustflags = [\"-C\", \"link-arg=-mmacosx-version-min=13.0\"]\n"
  },
  {
    "path": "rustlib/.gitignore",
    "content": "/target/\n/config.json\n/config-backup-*.json\n"
  },
  {
    "path": "rustlib/Cargo.toml",
    "content": "[package]\nname = \"obscuravpn-client\"\nversion = \"0.1.0\"\nedition = \"2024\"\nlicense = \"PolyForm-Noncommercial-1.0.0\"\n\n[[bin]]\nname = \"obscura\"\n\n[lib]\ncrate-type = [\"cdylib\", \"lib\", \"staticlib\"]\n\n[profile.release]\ncodegen-units = 1\ndebug = \"line-tables-only\"\nlto = true\npanic = \"abort\"\n\n[profile.dev]\npanic = \"abort\"\n\n[lints.clippy]\nlarge_enum_variant = \"allow\"\ntoo_many_arguments = \"allow\"\ntype_complexity = \"allow\"\n\n[dependencies]\nanyhow = { version = \"1.0.98\", features = [\"backtrace\"] }\nbase64 = \"0.21.7\"\nbytes = \"1\"\ncamino = \"1.2.2\"\nchrono = \"0.4.44\"\nclap = { version = \"4.5.53\", features = [\"derive\"] }\nconst_format = \"0.2.35\"\nderive_more = { version = \"2.0.1\", features = [\"full\"] }\ndiva = \"0.1.0\"\netherparse = \"0.17.0\"\nflume = { version = \"0.11.1\", features = [\"async\"] }\nfutures = \"0.3.29\"\nipnetwork = { version = \"0.21.1\", features = [\"serde\"] }\nlibc = \"0.2.178\"\nrand = \"0.8.5\"\nreqwest = { version = \"0.13.2\", default-features = false }\nring = \"0.17.14\"\nrustls = \"0.23.28\"\nsemver = \"1.0.27\"\nserde = { version = \"1.0.197\", features = [\"derive\", \"rc\"] }\nserde_json = \"1.0.114\"\nserde_with = { version = \"3.12\", features = [\"base64\"] }\nsocket2 = { version = \"0.6.0\", features = [\"all\"] }\nstatic_assertions = \"1.1.0\"\nstrum = { version = \"0.26.2\", features = [\"derive\"] }\ntempfile = \"3.10.1\"\nthiserror = \"1.0.56\"\ntokio = { version = \"1.44\", features = [\"full\"] }\ntokio-rustls = \"0.26.2\"\ntokio-util = \"0.7.13\"\ntracing = \"0.1.40\"\ntracing-appender = \"0.2.3\"\ntracing-subscriber = { version = \"0.3.18\", features = [\n    \"env-filter\",\n    \"fmt\",\n    \"json\",\n] }\ntun-rs = { version = \"2.7.5\", features = [\"async\"] }\nuuid = { version = \"1.11.0\", features = [\"v4\", \"serde\"] }\nx25519-dalek = { version = \"2.0.1\" }\nzip = { version = \"7.2.0\", default-features = false, features = [\n    \"deflate\",\n    \"time\",\n] }\n\n[dependencies.boringtun]\ngit = \"https://github.com/Sovereign-Engineering/boringtun.git\"\npackage = \"neptun\"\nrev = \"ff3b5555b5b1c4f85ae23d8d4aaf00bea2cd2bf0\"\n\n[dependencies.obscuravpn-api]\ngit = \"https://github.com/Sovereign-Engineering/obscuravpn-api.git\"\nrev = \"53d25d0ea86190ce815f7db3e6b299442623cf2d\"\n\n[dependencies.quinn]\ngit = \"https://github.com/Sovereign-Engineering/quinn.git\"\nrev = \"8f690a22535dcf2d76c369cadd3f0bec93667a4d\"\n\n[target.'cfg(any(target_os = \"macos\", target_os = \"ios\"))'.dependencies]\naws-lc-rs = { version = \"*\", features = [\n    \"bindgen\",\n] } # We don't use directly, but need to enable this feature.\noslog = \"0.2.0\"\ntracing-oslog = \"0.3.0\"\n\n[target.'cfg(target_os = \"linux\")'.dependencies]\nnix = { version = \"0.31.1\", features = [\"user\"] }\nrtnetlink = \"0.18.1\"\nshlex = \"1.3.0\"\nzbus = \"5.12.0\"\nzbus_systemd = { version = \"0.25900.0\", features = [\"resolve1\"] }\n\n[target.'cfg(target_os = \"android\")'.dependencies]\ncesu8 = \"1.1.0\"\njni = { version = \"0.21.1\", default-features = false }\nnix = \"0.30.1\"\ntracing-android = \"0.2.0\"\n\n[target.'cfg(target_os = \"windows\")'.dependencies]\nstandard_paths = \"2.1.0\"\nwindows = { version = \"0.62\", features = [\n    \"Win32_NetworkManagement_IpHelper\",\n    \"Win32_NetworkManagement_Ndis\",\n    \"Win32_Networking_WinSock\",\n    \"Win32_System_Com\",\n    \"Win32_System_IO\",\n    \"Win32_System_Threading\",\n    \"Win32_Security\",\n    \"Win32_UI_Shell\",\n] }\nwinreg = \"0.55\"\nwintun = \"0.5\"\n\n[target.'cfg(any(target_os = \"android\", target_os = \"ios\"))'.dependencies]\nlogroller = { version = \"0.1.10\", features = [\"xz\"] }\n\n[build-dependencies]\ncbindgen = \"0.28.0\"\n\n[target.'cfg(target_os = \"windows\")'.build-dependencies]\nring = \"0.17.14\"\n"
  },
  {
    "path": "rustlib/about.toml",
    "content": "accepted = [\n    \"Apache-2.0\",\n    \"BSD-2-Clause\",\n    \"BSD-3-Clause\",\n    \"CDLA-Permissive-2.0\",\n    \"ISC\",\n    \"MIT\",\n    \"MPL-2.0\",\n    \"OpenSSL\",\n    \"Unicode-3.0\",\n    \"Unicode-DFS-2016\",\n    \"WTFPL\",\n    \"Zlib\",\n]\n\n# DO NOT attempt to lookup licensing information from https://clearlydefined.io\nno-clearly-defined = true\n\n[obscuravpn-api]\naccepted = [\"PolyForm-Noncommercial-1.0.0\"]\n\n[obscuravpn-client]\naccepted = [\"PolyForm-Noncommercial-1.0.0\"]\n"
  },
  {
    "path": "rustlib/build.rs",
    "content": "extern crate cbindgen;\n\nuse std::env;\n#[cfg(target_os = \"windows\")]\nuse std::path::{Path, PathBuf};\n\nconst OUTPUT_HEADER_PATH_ENVVAR: &str = \"OBSCURA_CLIENT_RUSTLIB_CBINDGEN_OUTPUT_HEADER_PATH\";\nconst CBINDGEN_CONFIG_PATH_ENVVAR: &str = \"OBSCURA_CLIENT_RUSTLIB_CBINDGEN_CONFIG_PATH\";\n\nfn main() {\n    // NOTE: DO NOT emit any `cargo:rerun-if-*` instructions.\n    //\n    //       When there are `cargo:rerun-if-*` instructions, `cargo` relies on these instructions\n    //       to be fully accurate for change detection and WILL NOT rerun build scripts if files\n    //       not listed in the instructions change.\n    //\n    //       If there are no `cargo:rerun-if-*` instructions, `cargo` will \"always re-running the\n    //       build script if any file within the package is changed (or the list of files\n    //       controlled by the exclude and include fields)\". Which is what we want for `cbindgen`.\n    //\n    //       Also note that `cbindgen` itself does not emit any `cargo:rerun-if-*` instructions.\n    //\n    //       Source: https://doc.rust-lang.org/cargo/reference/build-scripts.html#change-detection\n\n    // Get the crate directory where our source code lives\n    let crate_dir = env::var(\"CARGO_MANIFEST_DIR\").unwrap();\n\n    #[cfg(target_os = \"windows\")]\n    {\n        let dll_src = get_wintun_dll_src(&crate_dir);\n        copy_to_bin_dir(&dll_src, \"wintun.dll\");\n        emit_wintun_dll_hash(&dll_src);\n    }\n\n    // Use var_os instead of var to isolate env var presence from Unicode parsing\n    let Some(cbindgen_config_path) = env::var_os(CBINDGEN_CONFIG_PATH_ENVVAR) else {\n        println!(\n            \"cargo::warning=NOT generating bindings! Environment variable '{}' not set\",\n            CBINDGEN_CONFIG_PATH_ENVVAR\n        );\n        return;\n    };\n\n    let Some(output_header_path) = env::var_os(OUTPUT_HEADER_PATH_ENVVAR) else {\n        println!(\n            \"cargo::warning=NOT generating bindings! Environment variable '{}' not set\",\n            OUTPUT_HEADER_PATH_ENVVAR\n        );\n        return;\n    };\n\n    let config = cbindgen::Config::from_file(cbindgen_config_path).expect(\"Unable to load cbindgen config file\");\n\n    // Generate the bindings\n    cbindgen::Builder::new()\n        .with_crate(crate_dir)\n        .with_config(config)\n        .generate()\n        .unwrap_or_else(|e| panic!(\"cbingen failed to generate bindings: {e:?}\"))\n        .write_to_file(output_header_path);\n}\n\n#[cfg(target_os = \"windows\")]\nfn copy_to_bin_dir(src: &Path, file_name: &str) {\n    let out_dir = std::env::var(\"OUT_DIR\").unwrap();\n    let profile = std::env::var(\"PROFILE\").unwrap();\n    let binary_dir = std::path::Path::new(&out_dir)\n        .ancestors()\n        .find(|p| p.file_name().and_then(|n| n.to_str()) == Some(profile.as_str()))\n        .expect(\"could not find target binary dir (debug/release) in OUT_DIR ancestors\");\n    let dst = binary_dir.join(file_name);\n\n    std::fs::copy(src, &dst).unwrap_or_else(|e| {\n        panic!(\"Failed to copy {src:?} to {dst:?}: {e}\");\n    });\n}\n\n/// SECURITY: Calculate the SHA-256 hash of the wintun.dll at build time and expose it as a\n/// compile-time environment variable `WINTUN_DLL_SHA256`. This allows the runtime code to verify\n/// the DLL's integrity before loading it, protecting against DLL replacement attacks.\n#[cfg(target_os = \"windows\")]\nfn emit_wintun_dll_hash(dll_path: &Path) {\n    let dll_bytes = std::fs::read(dll_path).unwrap_or_else(|e| panic!(\"Failed to read {dll_path:?} for hashing: {e}\"));\n    let hash = ring::digest::digest(&ring::digest::SHA256, &dll_bytes);\n    let hash_hex = hash.as_ref().iter().map(|b| format!(\"{b:02x}\")).collect::<String>();\n\n    println!(\"cargo:rustc-env=WINTUN_DLL_SHA256={hash_hex}\");\n}\n\n#[cfg(target_os = \"windows\")]\nconst WINTUN_VERSION: &str = \"0.14.1\";\n\n#[cfg(target_os = \"windows\")]\nfn get_wintun_dll_src(manifest_dir: &String) -> PathBuf {\n    let target_arch = env::var(\"CARGO_CFG_TARGET_ARCH\")\n        .or_else(|_| env::var(\"TARGET\"))\n        .unwrap_or_else(|_| std::env::consts::ARCH.to_string());\n    let arch = match target_arch.as_str() {\n        \"x86\" => \"x86\",\n        \"x86_64\" => \"amd64\",\n        \"arm\" => \"arm\",\n        \"aarch64\" => \"arm64\",\n        arch => panic!(\"Unsupported architecture: {arch}\"),\n    };\n    let dll_path = format!(\"windows/wintun-{WINTUN_VERSION}/bin/{arch}/wintun.dll\");\n    PathBuf::from(manifest_dir)\n        .parent()\n        .expect(\"Manifest directory has no parent\")\n        .join(dll_path)\n}\n"
  },
  {
    "path": "rustlib/examples/connect.rs",
    "content": "use clap::Parser;\nuse obscuravpn_api::types::AccountId;\nuse obscuravpn_client::client_state::ClientState;\nuse obscuravpn_client::config::feature_flags::FeatureFlagKey;\nuse obscuravpn_client::exit_selection::{ExitSelectionState, ExitSelector};\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::time::sleep;\n\n#[derive(Parser, Debug, PartialEq)]\n#[command(author, version, about, long_about = None)]\n#[command(propagate_version = true)]\nstruct Args {\n    #[clap(long)]\n    base_url: Option<String>,\n    #[clap(long)]\n    account_no: Option<String>,\n    #[clap(long)]\n    force_tcp_tls: bool,\n}\n\n#[tokio::main]\nasync fn main() -> Result<(), Box<dyn std::error::Error>> {\n    tracing_subscriber::fmt::init();\n\n    rustls::crypto::aws_lc_rs::default_provider()\n        .install_default()\n        .expect(\"Failed to install aws-lc crypto provider\");\n\n    let args = Args::parse();\n\n    let client_state = Arc::new(ClientState::new(\".\".into(), None, \"list-relays\".into(), None, true)?);\n    client_state.set_api_url(args.base_url);\n    client_state.set_feature_flag(FeatureFlagKey::TcpTlsTunnel.into(), args.force_tcp_tls);\n    if let Some(account_no) = args.account_no {\n        let account_id = AccountId::from_string_unchecked(account_no);\n        client_state.set_account_id(Some((account_id, None)))?;\n    }\n\n    let mut exit_selection_state = ExitSelectionState::default();\n    let conn = loop {\n        match client_state.connect(&ExitSelector::Any {}, None, &mut exit_selection_state).await {\n            Ok((conn, ..)) => break conn,\n            Err(error) => tracing::error!(\"connection attempt failed: {error}\"),\n        }\n        sleep(Duration::from_secs(1)).await;\n    };\n\n    tracing::info!(\"connected\");\n    loop {\n        let packet = conn.receive().await?;\n        tracing::info!(\"received packet with {} bytes\", packet.len());\n    }\n}\n"
  },
  {
    "path": "rustlib/examples/list-relay-rtts.rs",
    "content": "use clap::Parser;\nuse obscuravpn_api::cmd::ListRelays;\nuse obscuravpn_api::types::AccountId;\nuse obscuravpn_client::client_state::ClientState;\nuse obscuravpn_client::relay_selection::race_relay_handshakes;\nuse std::sync::Arc;\n\n#[derive(Parser, Debug, PartialEq)]\n#[command(author, version, about, long_about = None)]\n#[command(propagate_version = true)]\nstruct Args {\n    #[clap(long)]\n    base_url: Option<String>,\n    #[clap(long)]\n    account_no: Option<String>,\n}\n\n#[tokio::main]\nasync fn main() -> Result<(), Box<dyn std::error::Error>> {\n    tracing_subscriber::fmt::init();\n\n    let args = Args::parse();\n\n    let client_state = Arc::new(ClientState::new(\".\".into(), None, \"list-relays\".into(), None, true)?);\n    client_state.set_api_url(args.base_url);\n    if let Some(account_no) = args.account_no {\n        let account_id = AccountId::from_string_unchecked(account_no);\n        client_state.set_account_id(Some((account_id, None)))?;\n    }\n    let relays = client_state.api_request(ListRelays {}).await?;\n\n    let connection_stream = race_relay_handshakes(None, relays, \"relay.example\".into(), true, true, false, None)?;\n    while let Ok((relay, port, rtt, handshaking)) = connection_stream.recv_async().await {\n        println!(\"{}:{:03} rtt={:03}ms\", relay.id, port, rtt.as_millis());\n        handshaking.abandon().await;\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "rustlib/rust-toolchain.toml",
    "content": "[toolchain]\nchannel = \"1.89.0\"\ncomponents = [\"rustfmt\"]\ntargets = [\"aarch64-linux-android\", \"x86_64-unknown-linux-musl\"]\n"
  },
  {
    "path": "rustlib/rustfmt.toml",
    "content": "max_width = 150\nstruct_lit_width = 100\n"
  },
  {
    "path": "rustlib/src/android/class_cache.rs",
    "content": "use jni::{\n    JNIEnv,\n    objects::{GlobalRef, JClass},\n};\nuse std::sync::Arc;\n\n#[derive(Debug)]\npub struct ClassCache {\n    ffi_handle: GlobalRef,\n    error_code_exception: GlobalRef,\n    vpn_service: GlobalRef,\n}\n\nimpl ClassCache {\n    /// Looking up app-specific Java classes from native threads isn't possible, so we cache all the app-specific classes we need.\n    /// Must be called on a Java thread.\n    /// https://developer.android.com/ndk/guides/jni-tips#faq:-why-didnt-findclass-find-my-class\n    pub fn new(env: &mut JNIEnv) -> anyhow::Result<Arc<Self>> {\n        let ffi_handle = env.find_class(\"net/obscura/vpnclientapp/client/ObscuraLibrary$FfiHandle\")?;\n        let ffi_handle = env.new_global_ref(ffi_handle)?;\n        let error_code_exception = env.find_class(\"net/obscura/vpnclientapp/client/ErrorCodeException\")?;\n        let error_code_exception = env.new_global_ref(error_code_exception)?;\n        let vpn_service = env.find_class(\"net/obscura/vpnclientapp/services/ObscuraVpnService\")?;\n        let vpn_service = env.new_global_ref(vpn_service)?;\n        Ok(Arc::new(Self { ffi_handle, error_code_exception, vpn_service }))\n    }\n\n    pub fn ffi_handle(&self) -> &JClass<'static> {\n        self.ffi_handle.as_obj().into()\n    }\n\n    pub fn error_code_exception(&self) -> &JClass<'static> {\n        self.error_code_exception.as_obj().into()\n    }\n\n    pub fn vpn_service(&self) -> &JClass<'static> {\n        self.vpn_service.as_obj().into()\n    }\n}\n"
  },
  {
    "path": "rustlib/src/android/ffi.rs",
    "content": "use super::{\n    class_cache::ClassCache,\n    future::signal_json_ffi_future,\n    os_impl::AndroidOsImpl,\n    util::{Utf8JavaStr, throw_runtime_exception},\n};\nuse crate::{manager::Manager, manager_cmd::ManagerCmd, net::NetworkInterface, positive_u31::PositiveU31};\nuse anyhow::Context as _;\nuse jni::{\n    JNIEnv, JavaVM,\n    objects::{JClass, JObject, JString, JValue},\n    sys::{jint, jobject},\n};\nuse std::{\n    ffi::c_void,\n    os::fd::{FromRawFd as _, OwnedFd},\n    sync::Arc,\n    sync::OnceLock,\n};\nuse tokio::runtime::Runtime;\n\npub struct Global {\n    pub manager: Arc<Manager>,\n    pub os_impl: Arc<AndroidOsImpl>,\n    pub class_cache: Arc<ClassCache>,\n    pub runtime: Runtime,\n}\n\nstatic GLOBAL: OnceLock<Global> = OnceLock::new();\n\n/// Get global from handle.\n///\n/// Note: The handle is not actually used, just proof of construction. We rely on the Java type of FFI functions to enforce its existence.\nfn global_from_handle(_handle: &JObject) -> &'static Global {\n    GLOBAL.get().expect(\"global not initialized\")\n}\n\nconst RUST_LOG_DIR_NAME: &str = \"rust-log\";\n\n/// cbindgen:ignore\n#[unsafe(no_mangle)]\npub extern \"C\" fn JNI_OnLoad(_vm: *mut jni::sys::JavaVM, _reserved: *mut c_void) -> jint {\n    jni::sys::JNI_VERSION_1_6\n}\n\nfn initialize(env: &mut JNIEnv, j_config_dir: &JString, j_user_agent: &JString, class_cache: Arc<ClassCache>) -> anyhow::Result<Global> {\n    let runtime = Runtime::new().expect(\"Failed to create tokio runtime\");\n    let _runtime_guard = runtime.enter();\n    let config_dir = Utf8JavaStr::new(env, j_config_dir, \"j_config_dir\", \"INsGbyhM\")?;\n    let user_agent = Utf8JavaStr::new(env, j_user_agent, \"j_user_agent\", \"NXCS11u3\")?;\n    let log_dir = config_dir.as_path().join(RUST_LOG_DIR_NAME);\n    let log_persistence = crate::logging::init(tracing_android::layer(\"ObscuraNative\")?, Some(&log_dir));\n    rustls::crypto::aws_lc_rs::default_provider()\n        .install_default()\n        .map_err(|_| anyhow::format_err!(\"failed to install crypto provider\"))?;\n    let jvm = Arc::new(env.get_java_vm().context(\"failed to get JavaVM\")?);\n    let os_impl = Arc::new(AndroidOsImpl::new(jvm, class_cache.clone()));\n    let manager = Manager::new(\n        config_dir.as_path().into(),\n        None, // TODO: https://linear.app/soveng/issue/OBS-2699/android-keychain-equivalent\n        user_agent.as_str().into(),\n        os_impl.clone(),\n        None, // TODO: https://linear.app/soveng/issue/OBS-2699/android-keychain-equivalent\n        log_persistence,\n        true,\n    )?;\n    Ok(Global { manager, os_impl, runtime, class_cache })\n}\n\n/// cbindgen:ignore\n/// Must be called on a Java thread\n#[unsafe(no_mangle)]\npub extern \"C\" fn Java_net_obscura_vpnclientapp_client_ObscuraLibrary_initialize(\n    mut env: JNIEnv,\n    _: JClass,\n    j_config_dir: JString,\n    j_user_agent: JString,\n) -> jobject {\n    tracing::info!(message_id = \"PRXlxa85\", \"starting ffi initialization\");\n    let mut first_init = false;\n    let global = GLOBAL.get_or_init(|| {\n        first_init = true;\n        let class_cache = ClassCache::new(&mut env).expect(\"creating class cache failed\");\n        let global = initialize(&mut env, &j_config_dir, &j_user_agent, class_cache).expect(\"`initialize` failed\");\n        tracing::info!(message_id = \"Y6cNkZXW\", \"ffi initialized\");\n        global\n    });\n    if !first_init {\n        tracing::error!(message_id = \"sxsEyRKH\", \"ffi was already initialized\")\n    }\n    env.new_object(global.class_cache.ffi_handle(), \"()V\", &[])\n        .expect(\"failed to create FfiHandle\")\n        .into_raw()\n}\n\nfn json_ffi(global: &'static Global, env: &mut JNIEnv, j_json_cmd: &JString, j_future: &JObject) -> anyhow::Result<()> {\n    let json_cmd = Utf8JavaStr::new(env, j_json_cmd, \"j_json_cmd\", \"3ZxXYd09\")?;\n    let cmd = serde_json::from_str::<ManagerCmd>(json_cmd.as_str())?;\n    // This extends the Java object's lifetime until dropped.\n    let j_future = env.new_global_ref(&j_future)?;\n    let _runtime_guard = global.runtime.enter();\n    let jvm = env.get_java_vm()?;\n    tokio::spawn(async move {\n        let result = cmd.run(&global.manager).await;\n        // This attaches the current thread to the JVM for the entire life of\n        // the thread, which is significantly more performant than\n        // attaching/detaching on each use. This will be a no-op if already\n        // attached.\n        //\n        // Since it's attached as a \"daemon thread\", the life of this thread\n        // won't extend the life of the JVM.\n        match jvm.attach_current_thread_as_daemon() {\n            Ok(mut env) => {\n                if let Err(error) = signal_json_ffi_future(&global.class_cache, &mut env, j_future.as_obj(), result) {\n                    tracing::error!(message_id = \"OY0SMEhn\", ?error, \"failed to signal Java future\");\n                }\n            }\n            Err(error) => {\n                tracing::error!(message_id = \"Wg0053Pz\", ?error, \"failed to attach thread to JVM\");\n                // We can't interact with the JVM to throw an exception or\n                // call methods on the Java future, so we have to give up.\n            }\n        };\n    });\n    Ok(())\n}\n\n/// cbindgen:ignore\n#[unsafe(no_mangle)]\npub extern \"C\" fn Java_net_obscura_vpnclientapp_client_ObscuraLibrary_jsonFfi(\n    mut env: JNIEnv,\n    _: JClass,\n    handle: JObject,\n    j_json_cmd: JString,\n    j_future: JObject,\n) {\n    let global = global_from_handle(&handle);\n    if let Err(error) = json_ffi(global, &mut env, &j_json_cmd, &j_future) {\n        tracing::error!(message_id = \"jmx2DBFz\", ?error, \"`json_ffi` failed\");\n        throw_runtime_exception(&mut env, error);\n    }\n}\n\nfn set_network_interface(env: &mut JNIEnv, global: &'static Global, j_name: &JString, j_index: jint) -> anyhow::Result<()> {\n    let name = Utf8JavaStr::new(env, j_name, \"j_name\", \"Quz8O0qu\")?.to_string();\n    let index = u32::try_from(j_index).and_then(PositiveU31::try_from).with_context(|| {\n        tracing::error!(message_id = \"qvDcd36g\", \"network interface index wasn't a positive u32\");\n        \"network interface index wasn't a positive u32\"\n    })?;\n    global.os_impl.set_network_interface(Some(NetworkInterface { name, index }));\n    Ok(())\n}\n\n/// cbindgen:ignore\n#[unsafe(no_mangle)]\npub extern \"C\" fn Java_net_obscura_vpnclientapp_client_ObscuraLibrary_setNetworkInterface(\n    mut env: JNIEnv,\n    _: JClass,\n    handle: JObject,\n    j_name: JString,\n    j_index: jint,\n) {\n    let global = global_from_handle(&handle);\n    if let Err(error) = set_network_interface(&mut env, global, &j_name, j_index) {\n        tracing::error!(message_id = \"OOorBpQJ\", ?error, \"failed to set network interface: {error}\");\n    }\n}\n\n/// cbindgen:ignore\n#[unsafe(no_mangle)]\npub extern \"C\" fn Java_net_obscura_vpnclientapp_client_ObscuraLibrary_unsetNetworkInterface(_env: JNIEnv, _: JClass, handle: JObject) {\n    let global = global_from_handle(&handle);\n    global.os_impl.set_network_interface(None)\n}\n\n// We'd need to use `getStackTrace` to get more information than this, but that\n// seems relatively expensive, has a fiddly API, and still isn't exactly what we\n// want (i.e. line numbers are for `return` statements).\nfn forward_log(\n    env: &mut JNIEnv,\n    j_level: jint,\n    j_tag: &JString,\n    j_message: &JString,\n    j_message_id: &JString,\n    j_throwable_string: &JString,\n) -> anyhow::Result<()> {\n    let tag = Utf8JavaStr::new(env, j_tag, \"j_tag\", \"Nfw9yJpe\")?;\n    let tag = tag.as_str();\n    let message = Utf8JavaStr::new(env, j_message, \"j_message\", \"gOpofoUs\")?;\n    let message = message.as_str();\n    let message_id = Utf8JavaStr::new(env, j_message_id, \"j_message_id\", \"vYRZ3DPv\")?;\n    let message_id = message_id.as_str();\n    let throwable_string = Utf8JavaStr::from_nullable(env, j_throwable_string, \"j_throwable_string\", \"5BNd8Tn1\")?;\n    let throwable_string = throwable_string.as_ref().map(Utf8JavaStr::as_str);\n    // https://github.com/tokio-rs/tracing/issues/372\n    match j_level {\n        0 => tracing::event!(target: \"java\", tracing::Level::TRACE, message_id, tag, throwable_string, message),\n        1 => tracing::event!(target: \"java\", tracing::Level::DEBUG, message_id, tag, throwable_string, message),\n        2 => tracing::event!(target: \"java\", tracing::Level::INFO, message_id, tag, throwable_string, message),\n        3 => tracing::event!(target: \"java\", tracing::Level::WARN, message_id, tag, throwable_string, message),\n        4 => tracing::event!(target: \"java\", tracing::Level::ERROR, message_id, tag, throwable_string, message),\n        _ => anyhow::bail!(\"invalid log level: {j_level}\"),\n    }\n    Ok(())\n}\n\n/// cbindgen:ignore\n#[unsafe(no_mangle)]\npub extern \"C\" fn Java_net_obscura_vpnclientapp_client_ObscuraLibrary_forwardLog(\n    mut env: JNIEnv,\n    _: JClass,\n    j_level: jint,\n    j_tag: JString,\n    j_message: JString,\n    j_message_id: JString,\n    j_throwable_string: JString,\n) {\n    if let Err(error) = forward_log(&mut env, j_level, &j_tag, &j_message, &j_message_id, &j_throwable_string) {\n        tracing::error!(message_id = \"Cgb1qGM7\", ?error, \"failed to forward Java logging\");\n    }\n}\n\npub(super) async fn call_set_network_config(class_cache: Arc<ClassCache>, jvm: Arc<JavaVM>, json: String) -> Result<OwnedFd, ()> {\n    tokio::task::spawn_blocking(move || {\n        let mut env = jvm\n            .attach_current_thread_as_daemon()\n            .map_err(|error| tracing::error!(message_id = \"c5B2cENp\", ?error, \"failed to attach thread to JVM: {error}\"))?;\n        let json_str = env\n            .new_string(json)\n            .map_err(|error| tracing::error!(message_id = \"H6mOZNvn\", ?error, \"failed to create JNI string: {error}\"))?;\n        let j_fd = env\n            .call_static_method(\n                class_cache.vpn_service(),\n                \"ffiSetNetworkConfig\",\n                \"(Ljava/lang/String;)I\",\n                &[JValue::Object(&json_str.into())],\n            )\n            .map_err(|error| tracing::error!(message_id = \"oP7aSb3t\", ?error, \"failed to call ffiSetNetworkConfig: {error}\"))?\n            .i()\n            .map_err(|error| tracing::error!(message_id = \"orT0EU1k\", ?error, \"ffiSetNetworkConfig did not return an int: {error}\"))?;\n        if j_fd >= 0 {\n            // SAFETY: `detachFd` surrendered ownership of the FD on the Kotlin side. No cleanup required besides `close`.\n            Ok(unsafe { OwnedFd::from_raw_fd(j_fd) })\n        } else {\n            Err(())\n        }\n    })\n    .await\n    .expect(\"spawn_blocking panicked\")\n}\n"
  },
  {
    "path": "rustlib/src/android/future.rs",
    "content": "use super::class_cache::ClassCache;\nuse crate::manager_cmd::{ManagerCmdErrorCode, ManagerCmdOk};\nuse anyhow::Context as _;\nuse jni::{\n    JNIEnv,\n    objects::{JObject, JValue},\n};\n\npub fn signal_json_ffi_future(\n    class_cache: &ClassCache,\n    env: &mut JNIEnv,\n    j_future: &JObject,\n    result: Result<ManagerCmdOk, ManagerCmdErrorCode>,\n) -> anyhow::Result<()> {\n    match result.and_then(|ok| {\n        serde_json::to_string(&ok).map_err(|error| {\n            tracing::error!(message_id = \"hP0R8zXa\", ?error, \"failed to serialize successful cmd result\");\n            ManagerCmdErrorCode::Other\n        })\n    }) {\n        Ok(ok) => {\n            let j_ok = env.new_string(&ok).map(JObject::from).unwrap_or_else(|error| {\n                tracing::error!(message_id = \"eeAQzxl1\", ?error, \"failed to convert `ok` to `JString`\");\n                JObject::null()\n            });\n            env.call_method(j_future, \"complete\", \"(Ljava/lang/Object;)Z\", &[JValue::Object(&j_ok)])\n                .context(\"failed to call `complete`\")?;\n        }\n        Err(error) => {\n            let j_error = env.new_string(error.as_static_str()).context(\"failed to convert `error` to `JString`\")?;\n            let j_exception = env\n                .new_object(\n                    class_cache.error_code_exception(),\n                    \"(Ljava/lang/String;)V\",\n                    &[JValue::Object(&j_error.into())],\n                )\n                .context(\"failed to create `ErrorCodeException`\")?;\n            env.call_method(\n                j_future,\n                \"completeExceptionally\",\n                \"(Ljava/lang/Throwable;)Z\",\n                &[JValue::Object(&j_exception)],\n            )\n            .context(\"failed to call `completeExceptionally`\")?;\n        }\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "rustlib/src/android/mod.rs",
    "content": "mod class_cache;\nmod ffi;\nmod future;\nmod os_impl;\nmod tunnel;\nmod util;\n"
  },
  {
    "path": "rustlib/src/android/os_impl.rs",
    "content": "use super::{class_cache::ClassCache, tunnel::Tun};\nuse crate::quicwg::QuicWgConnPacketSender;\nuse crate::{net::NetworkInterface, network_config::OsNetworkConfig, os::os_trait::Os};\nuse bytes::Bytes;\nuse jni::JavaVM;\nuse std::sync::{Arc, Mutex};\nuse tokio::sync::watch;\n\npub struct AndroidOsImpl {\n    tun: Mutex<Option<Tun>>,\n    network_interface: watch::Sender<Option<NetworkInterface>>,\n    jvm: Arc<JavaVM>,\n    class_cache: Arc<ClassCache>,\n}\n\nimpl AndroidOsImpl {\n    pub fn new(jvm: Arc<JavaVM>, class_cache: Arc<ClassCache>) -> Self {\n        Self { tun: Mutex::new(None), network_interface: watch::channel(None).0, jvm, class_cache }\n    }\n\n    pub fn set_network_interface(&self, network_interface: Option<NetworkInterface>) {\n        self.network_interface.send_replace(network_interface);\n    }\n}\n\nimpl Os for AndroidOsImpl {\n    fn network_interface(&self) -> watch::Receiver<Option<NetworkInterface>> {\n        self.network_interface.subscribe()\n    }\n\n    async fn set_os_network_config(&self, network_config: OsNetworkConfig, tunnel: QuicWgConnPacketSender) -> Result<(), ()> {\n        let json = serde_json::to_string(&network_config).map_err(|error| {\n            tracing::error!(message_id = \"dK2xNm3q\", ?error, \"failed to serialize OsNetworkConfig: {error}\");\n        })?;\n\n        let fd = super::ffi::call_set_network_config(self.class_cache.clone(), self.jvm.clone(), json).await?;\n\n        let (tun, result) = match Tun::spawn(fd, tunnel) {\n            Ok(tun) => {\n                tracing::info!(\n                    message_id = \"mLrplF1x\",\n                    ?network_config,\n                    \"successfully set network config and spawned TUN device\"\n                );\n                (tun, Ok(()))\n            }\n            Err(tun) => {\n                tracing::error!(message_id = \"rS9cUd5v\", \"failed to spawn TUN reader\");\n                (tun, Err(()))\n            }\n        };\n        *self.tun.lock().unwrap() = Some(tun);\n        result\n    }\n\n    async fn unset_os_network_config(&self) -> Result<(), ()> {\n        *self.tun.lock().unwrap() = None;\n        Ok(())\n    }\n\n    fn packet_for_os(&self, packet: Bytes) {\n        if let Some(tun) = &*self.tun.lock().unwrap() {\n            tun.write(&packet);\n        }\n    }\n}\n"
  },
  {
    "path": "rustlib/src/android/tunnel.rs",
    "content": "use crate::{\n    quicwg::{QuicWgConnPacketSender, TUNNEL_MTU},\n    tokio::AbortOnDrop,\n};\nuse nix::{errno::Errno, unistd};\nuse std::{os::fd::OwnedFd, sync::Arc};\nuse tokio::io::unix::AsyncFd;\n\npub struct Tun {\n    fd: Arc<OwnedFd>,\n    _read_loop_task: Option<AbortOnDrop>,\n}\n\nimpl Tun {\n    pub fn spawn(fd: OwnedFd, tunnel: QuicWgConnPacketSender) -> Result<Self, Self> {\n        let fd = Arc::new(fd);\n        let fd_watcher = match AsyncFd::new(fd.clone()) {\n            Ok(fd_watcher) => fd_watcher,\n            Err(error) => {\n                tracing::error!(message_id = \"sPCZix4J\", ?error, \"failed to create fd watcher for tun reader: {error}\");\n                return Err(Self { fd, _read_loop_task: None });\n            }\n        };\n        let read_loop_task = tokio::spawn(async move {\n            let mut buf = Box::new([0; TUNNEL_MTU as _]);\n            loop {\n                match fd_watcher.readable().await {\n                    Ok(mut guard) => match unistd::read(&fd_watcher, &mut buf[..]) {\n                        Ok(n) => {\n                            if n > 0 {\n                                tunnel.send(std::iter::once(&buf[..n]))\n                            }\n                        }\n                        Err(Errno::EAGAIN) => {\n                            guard.clear_ready();\n                        }\n                        Err(error) => {\n                            tracing::error!(message_id = \"eagh6Noh\", ?error, \"failed to read from tun\");\n                            break;\n                        }\n                    },\n                    Err(error) => {\n                        tracing::error!(message_id = \"r5N6izcO\", ?error, \"failed to wait for tun to become readable\");\n                        break;\n                    }\n                }\n            }\n        });\n        Ok(Self { fd, _read_loop_task: Some(read_loop_task.into()) })\n    }\n\n    pub fn write(&self, packet: &[u8]) {\n        if packet.len() > TUNNEL_MTU as usize {\n            tracing::warn!(message_id = \"Yc1WxQBY\", packet_len = packet.len(), \"packet larger than MTU\",);\n        }\n        if let Err(error) = unistd::write(&self.fd, packet) {\n            tracing::error!(message_id = \"W0sOhigq\", ?error, \"writing packet to tun failed\");\n        }\n    }\n}\n"
  },
  {
    "path": "rustlib/src/android/util.rs",
    "content": "use anyhow::Context as _;\nuse camino::Utf8Path;\nuse jni::{JNIEnv, objects::JString, strings::JavaStr};\nuse std::{borrow::Cow, ffi::CStr, fmt::Display};\n\npub fn throw_runtime_exception(env: &mut JNIEnv, msg: impl Display) {\n    let msg = msg.to_string();\n    if let Err(error) = env.throw_new(\"java/lang/RuntimeException\", &msg) {\n        tracing::error!(message_id = \"bxCfsHAC\", ?error, msg, \"failed to throw `RuntimeException`\");\n    }\n}\n\n/// RAII handle that provides a UTF-8 view into a `java.lang.String`.\npub struct Utf8JavaStr<'a, 'b> {\n    s: Cow<'a, str>,\n    obj: &'a JString<'a>,\n    env: JNIEnv<'b>,\n}\n\nimpl<'a, 'b> Utf8JavaStr<'a, 'b> {\n    /// `name` is only used for error messages.\n    pub fn new(env: &mut JNIEnv<'b>, obj: &'a JString<'a>, name: &str, message_id: &'static str) -> anyhow::Result<Self> {\n        // We unfortunately can't safely use `get_string_unchecked`, since the\n        // Java/Kotlin build will still succeed even if we're passed an argument\n        // of the wrong type for our function signatures.\n        //\n        // Either method is really just `GetStringUTFChars`, which converts from\n        // UTF-16 to Modified UTF-8 (this is the only unavoidable alloc):\n        // https://developer.android.com/ndk/guides/jni-tips#utf-8-and-utf-16-strings\n        let java_str = env.get_string(obj).with_context(|| {\n            tracing::error!(message_id, ?name, \"not a `java.lang.String`\");\n            format!(\"{message_id}: {name:?} wasn't a `java.lang.String`\")\n        })?;\n        // Leak the result of `GetStringUTFChars`\n        let ptr = java_str.into_raw();\n        // SAFETY: We've taken ownership of the result of `GetStringUTFChars`,\n        // and it's null-terminated\n        // (This uses the same lifetime as obj, since the obj is needed to\n        // release the underlying memory later)\n        let c_str = unsafe { CStr::from_ptr(ptr) };\n        // The Modified UTF-8 returned by `GetStringUTFChars` will be valid\n        // UTF-8 for anything in the Basic Multilingual Plane, so this will\n        // almost never need to allocate:\n        // https://en.wikipedia.org/wiki/CESU-8\n        let s = cesu8::from_java_cesu8(c_str.to_bytes()).with_context(|| {\n            tracing::error!(message_id, ?name, \"Java string could not be converted to UTF-8\");\n            format!(\"{message_id}: {name:?} could not be converted to UTF-8\")\n        })?;\n        // SAFETY: Only used to release refs\n        let env = unsafe { env.unsafe_clone() };\n        Ok(Self { s, obj, env })\n    }\n\n    pub fn from_nullable(env: &mut JNIEnv<'b>, obj: &'a JString<'a>, name: &str, message_id: &'static str) -> anyhow::Result<Option<Self>> {\n        (!obj.as_raw().is_null()).then(|| Self::new(env, obj, name, message_id)).transpose()\n    }\n\n    pub fn as_str(&self) -> &str {\n        self.s.as_ref()\n    }\n\n    pub fn as_path(&self) -> &Utf8Path {\n        Utf8Path::new(self.as_str())\n    }\n}\n\nimpl<'a, 'b> Drop for Utf8JavaStr<'a, 'b> {\n    fn drop(&mut self) {\n        // Release the result of `GetStringUTFChars`\n        // SAFETY: ptr came from `JavaStr::into_raw` and this is the same obj\n        // used to construct that `JavaStr`\n        unsafe { JavaStr::from_raw(&self.env, self.obj, self.s.as_ptr() as *const _) };\n    }\n}\n\nimpl<'a, 'b> std::fmt::Display for Utf8JavaStr<'a, 'b> {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        self.as_str().fmt(f)\n    }\n}\n"
  },
  {
    "path": "rustlib/src/apple/ffi.rs",
    "content": "use std::sync::{Arc, OnceLock};\nuse tokio::runtime::Runtime;\n\nuse super::os_impl::AppleOsImpl;\nuse crate::config::KeychainSetSecretKeyFn;\nuse crate::ffi_helpers::*;\nuse crate::logging::LogPersistence;\nuse crate::manager::Manager;\nuse crate::manager_cmd::ManagerCmd;\nuse crate::manager_cmd::ManagerCmdErrorCode;\nuse crate::net::NetworkInterface;\nuse crate::positive_u31::PositiveU31;\n\n/// cbindgen:ignore\nstatic APPLE_LOG_INIT: std::sync::Once = std::sync::Once::new();\n\n/// To view logs info, or debug logs with `log` tool you must pass `--level info|debug`.\n/// To filter logs at the rust level you can set the `RUST_LOG` environment variable.\n///\n/// On iOS, returns the pointer for the drop guard that flushes log writes.\n///\n/// SAFETY:\n/// - there is no other global function of this name\n#[unsafe(no_mangle)]\npub extern \"C\" fn initialize_apple_system_logging(log_dir: FfiStr) -> *mut LogPersistence {\n    let mut log_persistence: Option<LogPersistence> = None;\n    APPLE_LOG_INIT.call_once(|| {\n        log_persistence = crate::logging::init(\n            tracing_oslog::OsLogger::new(\"net.obscura.rust-apple\", \"default\"),\n            cfg!(target_os = \"ios\").then(|| log_dir.as_str().as_ref()),\n        )\n    });\n    log_persistence.map(Box::new).map(Box::into_raw).unwrap_or(std::ptr::null_mut())\n}\n\npub struct Global {\n    manager: Arc<Manager>,\n    os_impl: Arc<AppleOsImpl>,\n    runtime: Runtime,\n}\n\n/// cbindgen:ignore\nstatic GLOBAL: OnceLock<Global> = OnceLock::new();\n\n/// SAFETY:\n/// - `log_persistence` must be a pointer returned by `initialize_apple_system_logging`\n/// - there is no other global function of this name\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn initialize(\n    config_dir: FfiStr,\n    user_agent: FfiStr,\n    keychain_wg_secret_key: FfiBytes,\n    receive_cb: extern \"C\" fn(FfiBytes),\n    set_network_config_cb: super::os_impl::SetNetworkConfigCb,\n    keychain_set_wg_secret_key: extern \"C\" fn(FfiBytes) -> bool,\n    log_persistence: *mut LogPersistence,\n) -> *const Global {\n    tracing::info!(message_id = \"PRXlxa85\", \"starting ffi initialization\");\n    let mut first_init = false;\n    let global: &'static Global = GLOBAL.get_or_init(|| {\n        rustls::crypto::aws_lc_rs::default_provider()\n            .install_default()\n            .expect(\"Failed to install aws-lc crypto provider\");\n\n        let runtime = Runtime::new().expect(\"Failed to create tokio runtime\");\n        let _runtime_guard = runtime.enter();\n\n        let config_dir = config_dir.to_string().into();\n        let user_agent = user_agent.to_string();\n        let keychain_wg_sk = Some(keychain_wg_secret_key.to_vec()).filter(|v| !v.is_empty());\n        let keychain_set_wg_secret_key: KeychainSetSecretKeyFn = Box::new(move |sk: &[u8; 32]| keychain_set_wg_secret_key(sk.ffi()));\n        let log_persistence = std::ptr::NonNull::new(log_persistence).map(|log_persistence|\n            // SAFETY:\n            // - `log_persistence` was checked to be non-null\n            // - Caller guarantees that `log_persistence` originates from a\n            //   matching `into_raw` call\n            *unsafe { Box::from_raw(log_persistence.as_ptr()) });\n        let os_impl = Arc::new(AppleOsImpl::new(receive_cb, set_network_config_cb));\n        match Manager::new(\n            config_dir,\n            keychain_wg_sk.as_deref(),\n            user_agent,\n            os_impl.clone(),\n            Some(keychain_set_wg_secret_key),\n            log_persistence,\n            true, // persistent tunnel activation must be handled by the on-demand OS feature on Apple platforms\n        ) {\n            Ok(manager) => {\n                first_init = true;\n                tracing::info!(message_id = \"Y6cNkZXW\", \"ffi initialized\");\n                Global { manager, os_impl, runtime }\n            }\n            Err(err) => panic!(\"ffi initialization failed: could not load config: {}\", err),\n        }\n    });\n    if !first_init {\n        tracing::error!(message_id = \"GQRW1s5V\", \"ffi was already initialized\")\n    }\n    std::ptr::from_ref(global)\n}\n\n#[allow(dead_code)]\n#[repr(u8)]\npub enum LogLevel {\n    Trace,\n    Debug,\n    Info,\n    Warn,\n    Error,\n}\n\n#[unsafe(no_mangle)]\npub extern \"C\" fn forward_log(level: LogLevel, message: FfiStr, file_id: FfiStr, function: FfiStr, line: isize) {\n    let message = message.as_str();\n    let file_id = file_id.as_str();\n    let function = function.as_str();\n    // https://github.com/tokio-rs/tracing/issues/372\n    match level {\n        LogLevel::Trace => tracing::event!(tracing::Level::TRACE, message, file_id, function, line),\n        LogLevel::Debug => tracing::event!(tracing::Level::DEBUG, message, file_id, function, line),\n        LogLevel::Info => tracing::event!(tracing::Level::INFO, message, file_id, function, line),\n        LogLevel::Warn => tracing::event!(tracing::Level::WARN, message, file_id, function, line),\n        LogLevel::Error => tracing::event!(tracing::Level::ERROR, message, file_id, function, line),\n    }\n}\n\n/// SAFETY:\n/// - `global` must be a pointer returned by `initialize`\n/// - there is no other global function of this name\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn send_packet(global: *const Global, packet: FfiBytes) {\n    // SAFETY: `global` was created by a matching call to `std::ptr::from_ref`\n    let global = unsafe { &*global };\n    global.os_impl.send_packet(packet.as_slice());\n}\n\n/// Set the network interface to use by index and name.\n///\n/// Index 0 means no interface (do not try connecting).\n/// Name is ignored if index is 0.\n///\n/// SAFETY:\n/// - `global` must be a pointer returned by `initialize`\n/// - there is no other global function of this name\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn set_network_interface(global: *const Global, index: u32, name: FfiStr) {\n    // SAFETY: `global` was created by a matching call to `std::ptr::from_ref`\n    let global = unsafe { &*global };\n    let network_interface = match index {\n        0 => None,\n        index => PositiveU31::try_from(index)\n            .map_err(|_| tracing::error!(message_id = \"Y8aRUbjp\", index, \"network interface index out of range\"))\n            .ok(),\n    }\n    .map(|index| NetworkInterface { index, name: name.as_str().to_string() });\n    global.os_impl.set_network_interface(network_interface);\n}\n\n/// Call after wake.\n///\n/// SAFETY:\n/// - `global` must be a pointer returned by `initialize`\n/// - there is no other global function of this name\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn wake(global: *const Global) {\n    // SAFETY: `global` was created by a matching call to `std::ptr::from_ref`\n    let global = unsafe { &*global };\n    global.manager.wake();\n}\n\n/// SAFETY:\n/// - `global` must be a pointer returned by `initialize`\n/// - there is no other global function of this name\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn json_ffi_cmd(\n    global: *const Global,\n    context: usize,\n    json_cmd: FfiBytes,\n    cb: extern \"C\" fn(context: usize, json_ret: FfiStr, json_err: FfiStr),\n) {\n    // SAFETY: `global` was created by a matching call to `std::ptr::from_ref`\n    let global = unsafe { &*global };\n    let json_cmd = json_cmd.to_vec();\n\n    global.runtime.spawn(async move {\n        let manager = &global.manager;\n\n        let json_result: Result<String, ManagerCmdErrorCode> = async move {\n            let ok = ManagerCmd::from_json(&json_cmd)?.run(manager).await?;\n            serde_json::to_string(&ok).map_err(|error| {\n                tracing::error!(message_id = \"TFqFKASM\", ?error, \"could not serialize successful json cmd result: {error}\");\n                ManagerCmdErrorCode::Other\n            })\n        }\n        .await;\n\n        match json_result {\n            Ok(ok) => cb(context, ok.ffi_str(), \"\".ffi_str()),\n            Err(err) => cb(context, \"\".ffi_str(), err.as_static_str().ffi_str()),\n        }\n    });\n}\n"
  },
  {
    "path": "rustlib/src/apple/mod.rs",
    "content": "mod ffi;\nmod os_impl;\n"
  },
  {
    "path": "rustlib/src/apple/os_impl.rs",
    "content": "use std::ffi::c_void;\nuse std::sync::Mutex;\n\nuse bytes::Bytes;\nuse tokio::sync::{oneshot, watch};\n\nuse crate::ffi_helpers::*;\nuse crate::net::NetworkInterface;\nuse crate::network_config::OsNetworkConfig;\nuse crate::os::os_trait::Os;\nuse crate::quicwg::QuicWgConnPacketSender;\n\npub type SetNetworkConfigCb =\n    extern \"C\" fn(network_config_json: FfiBytes, context: *mut c_void, done: extern \"C\" fn(context: *mut c_void, success: bool));\n\n/// Callback invoked by Swift when the async `setNetworkConfig` operation completes.\n/// Resolves the oneshot sender stored at `context`.\n///\n/// SAFETY:\n/// - `context` must be a value previously provided via `SetNetworkConfigCb`\n/// - Must be called exactly once per invocation\npub extern \"C\" fn set_network_config_done(context: *mut c_void, success: bool) {\n    // SAFETY: context was created via Box::into_raw of a Box<oneshot::Sender<bool>>\n    let sender = unsafe { Box::from_raw(context as *mut oneshot::Sender<bool>) };\n    let _ = sender.send(success);\n}\n\npub struct AppleOsImpl {\n    receive_cb: extern \"C\" fn(FfiBytes),\n    set_network_config_cb: SetNetworkConfigCb,\n    network_interface: watch::Sender<Option<NetworkInterface>>,\n    tunnel: Mutex<QuicWgConnPacketSender>,\n}\n\nimpl AppleOsImpl {\n    pub fn new(receive_cb: extern \"C\" fn(FfiBytes), set_network_config_cb: SetNetworkConfigCb) -> Self {\n        Self {\n            receive_cb,\n            set_network_config_cb,\n            network_interface: watch::channel(None).0,\n            tunnel: Mutex::new(QuicWgConnPacketSender::new(None)),\n        }\n    }\n\n    pub fn set_network_interface(&self, network_interface: Option<NetworkInterface>) {\n        self.network_interface.send_replace(network_interface);\n    }\n\n    pub fn send_packet(&self, packet: &[u8]) {\n        self.tunnel.lock().unwrap().send(std::iter::once(packet));\n    }\n}\n\nimpl Os for AppleOsImpl {\n    fn network_interface(&self) -> watch::Receiver<Option<NetworkInterface>> {\n        self.network_interface.subscribe()\n    }\n\n    async fn set_os_network_config(&self, network_config: OsNetworkConfig, tunnel: QuicWgConnPacketSender) -> Result<(), ()> {\n        *self.tunnel.lock().unwrap() = tunnel;\n\n        let json = serde_json::to_vec(&network_config).map_err(|error| {\n            tracing::error!(message_id = \"aP7xKm2q\", ?error, \"failed to serialize OsNetworkConfig\");\n        })?;\n\n        let (tx, rx) = oneshot::channel();\n        let context = Box::into_raw(Box::new(tx)) as *mut c_void;\n        (self.set_network_config_cb)(json.ffi(), context, set_network_config_done);\n\n        match rx.await {\n            Ok(true) => Ok(()),\n            Ok(false) => Err(()),\n            Err(_) => {\n                tracing::error!(\n                    message_id = \"bR3vNw8x\",\n                    \"set_network_config done callback was dropped without being called\"\n                );\n                Err(())\n            }\n        }\n    }\n\n    async fn unset_os_network_config(&self) -> Result<(), ()> {\n        // Nothing to do. On Apple platform the OS manages this, not the PacketTunnelProvider implementation.\n        Ok(())\n    }\n\n    fn packet_for_os(&self, packet: Bytes) {\n        (self.receive_cb)(packet.as_ref().ffi());\n    }\n}\n"
  },
  {
    "path": "rustlib/src/backoff.rs",
    "content": "use std::time::Duration;\n\nuse rand::Rng;\nuse tokio::time::sleep;\n\n#[derive(Clone, Debug)]\npub struct Backoff {\n    base: Duration,\n    max: Duration,\n}\n\nimpl Backoff {\n    pub const BACKGROUND: Self = Backoff { base: Duration::from_secs(1), max: Duration::from_secs(60) };\n}\n\nimpl Backoff {\n    pub fn take(&self, attempts: usize) -> BackoffIter {\n        BackoffIter { backoff: self.clone(), attempts, next: Duration::ZERO }\n    }\n}\n\npub struct BackoffIter {\n    backoff: Backoff,\n    attempts: usize,\n    next: Duration,\n}\n\nimpl BackoffIter {\n    pub async fn wait(&mut self) -> bool {\n        match self.next() {\n            Some(d) => {\n                sleep(d).await;\n                true\n            }\n            None => false,\n        }\n    }\n}\n\nimpl Iterator for BackoffIter {\n    type Item = Duration;\n\n    fn next(&mut self) -> Option<Self::Item> {\n        if self.attempts == 0 {\n            return None;\n        }\n        self.attempts -= 1;\n\n        if self.next.is_zero() {\n            self.next = self.backoff.base;\n            return Some(Duration::ZERO);\n        }\n\n        let current = self.next;\n        self.next = std::cmp::min(current.saturating_mul(2), self.backoff.max);\n        Some(rand::thread_rng().gen_range((current / 2)..=current))\n    }\n\n    fn size_hint(&self) -> (usize, Option<usize>) {\n        (self.attempts, Some(self.attempts))\n    }\n}\n"
  },
  {
    "path": "rustlib/src/backoff_test.rs",
    "content": "use std::time::Duration;\n\nuse crate::backoff::Backoff;\n\n#[test]\nfn test() {\n    for _ in 0..100 {\n        let delays = Backoff::BACKGROUND.take(10).collect::<Vec<_>>();\n        assert_eq!(delays[0], Duration::ZERO);\n        assert!(delays[1] > Duration::ZERO);\n        assert!(delays[1] <= Duration::from_secs(1));\n        assert!(delays[2] > Duration::from_secs(1));\n        assert!(delays[2] <= Duration::from_secs(2));\n        assert!(delays[3] > Duration::from_secs(2));\n        assert!(delays[3] <= Duration::from_secs(4));\n        assert!(delays[4] > Duration::from_secs(4));\n        assert!(delays[4] <= Duration::from_secs(8));\n        assert!(delays[5] > Duration::from_secs(8));\n        assert!(delays[5] <= Duration::from_secs(16));\n        assert!(delays[6] > Duration::from_secs(16));\n        assert!(delays[6] <= Duration::from_secs(32));\n        assert!(delays[7] > Duration::from_secs(30));\n        assert!(delays[7] <= Duration::from_secs(60));\n        assert!(delays[8] > Duration::from_secs(30));\n        assert!(delays[8] <= Duration::from_secs(60));\n        assert!(delays[9] > Duration::from_secs(30));\n        assert!(delays[9] <= Duration::from_secs(60));\n    }\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/add_operator.rs",
    "content": "use std::env::var;\nuse std::process::exit;\n\npub async fn run_add_operator(users: Vec<String>) -> ! {\n    tokio::task::spawn_blocking(move || add_operator_impl(users)).await.unwrap()\n}\n\nfn add_operator_impl(mut users: Vec<String>) -> ! {\n    if users.is_empty() {\n        let Ok(user) =\n            var(\"USER\").inspect_err(|error| tracing::error!(message_id = \"vo2NOhH3\", ?error, \"failed to read $USER environment variable: {error}\"))\n        else {\n            eprintln!(\"Could not determine the current user. Please specify a user explicitly:\");\n            eprintln!(\"obscura add-operator <user>\");\n            exit(1);\n        };\n        users.push(user);\n    }\n\n    let mut failed_any = false;\n    for user in &users {\n        let command = [\"sudo\", \"usermod\", \"-a\", \"-G\", \"obscura\", user.as_str()];\n        let failed = std::process::Command::new(command[0])\n            .args(&command[1..])\n            .status()\n            .map_err(|error| tracing::error!(message_id = \"uHdEDIlq\", ?error, \"failed to run {}: {error}\", command[0]))\n            .and_then(|status| status.success().then_some(()).ok_or(()))\n            .is_err();\n        failed_any |= failed;\n        if failed {\n            match shlex::try_join(command) {\n                Ok(quoted_command) => eprintln!(\"Failed to add '{user}' to 'obscura' group using:\\n    {quoted_command}\"),\n                Err(_) => eprintln!(\"Failed to add {user}\"),\n            }\n        } else {\n            eprintln!(\"Added {user} to 'obscura' group.\")\n        }\n    }\n    // Unlock the group (removes `!*` or `!` from `/etc/gshadow`). Otherwise, `sg` will ask for the non-existent group password on some systems\n    let command = [\"sudo\", \"gpasswd\", \"-r\", \"obscura\"];\n    let failed = std::process::Command::new(command[0])\n        .args(&command[1..])\n        .status()\n        .map_err(|error| tracing::error!(message_id = \"d2vw10pw\", ?error, \"failed to run {}: {error}\", command[0]))\n        .and_then(|status| status.success().then_some(()).ok_or(()))\n        .is_err();\n    failed_any |= failed;\n    if failed {\n        match shlex::try_join(command) {\n            Ok(quoted_command) => eprintln!(\"Failed to unlock 'obscura' group using:\\n    {quoted_command}\"),\n            Err(_) => {\n                eprintln!(\"Failed to unlock 'obscura' group. You may have to log out and log in once before using the obscura command without sudo.\")\n            }\n        }\n    }\n\n    if failed_any { exit(1) } else { exit(0) }\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/client/client_error.rs",
    "content": "use obscuravpn_client::manager_cmd::ManagerCmdErrorCode;\n\n#[derive(thiserror::Error, Debug)]\npub enum ClientError {\n    #[error(\"The Obscura API is unreachable.\")]\n    ApiUnreachable,\n    #[error(\"Insufficient permissions to connect to service. Use sudo or the `obscura add-operator` command.\")]\n    InsufficientPermissions,\n    #[error(\"Unexpected error. Details: {0:#}\")]\n    Unexpected(#[from] anyhow::Error),\n    #[error(\"The Obscura VPN service is not running.\")]\n    NoService,\n    #[error(\"Malformed account ID.\")]\n    MalformedAccountId,\n}\n\nimpl From<ManagerCmdErrorCode> for ClientError {\n    fn from(error: ManagerCmdErrorCode) -> ClientError {\n        match error {\n            ManagerCmdErrorCode::ApiInvalidAccountId => ClientError::MalformedAccountId,\n            ManagerCmdErrorCode::ApiUnreachable => ClientError::ApiUnreachable,\n            ManagerCmdErrorCode::ApiError\n            | ManagerCmdErrorCode::ApiNoLongerSupported\n            | ManagerCmdErrorCode::ApiRateLimitExceeded\n            | ManagerCmdErrorCode::ApiSignupLimitExceeded\n            | ManagerCmdErrorCode::ConfigSaveError\n            | ManagerCmdErrorCode::Other => anyhow::Error::msg(error.as_static_str()).into(),\n        }\n    }\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/client/ipc.rs",
    "content": "use crate::ClientIpcTestArgs;\nuse crate::client::client_error::ClientError;\nuse crate::service::os::linux::ipc::SOCKET_PATH;\nuse anyhow::anyhow;\nuse nix::unistd::Gid;\nuse obscuravpn_client::manager_cmd::{ManagerCmd, ManagerCmdErrorCode};\nuse serde::de::DeserializeOwned;\nuse std::io::ErrorKind;\nuse std::iter::once;\nuse std::os::unix::fs::MetadataExt;\nuse tokio::io::{AsyncReadExt, AsyncWriteExt};\nuse tokio::net::UnixStream;\n\npub async fn run_command<O: DeserializeOwned>(cmd: ManagerCmd) -> Result<Result<O, ManagerCmdErrorCode>, ClientError> {\n    let mut stream = UnixStream::connect(SOCKET_PATH).await.map_err(|error| {\n        tracing::warn!(message_id = \"RJEP2IV5\", ?error, \"failed to connect to socket: {}\", error);\n        match error.kind() {\n            ErrorKind::NotFound => ClientError::NoService,\n            ErrorKind::PermissionDenied => ClientError::InsufficientPermissions,\n            ErrorKind::ConnectionRefused => ClientError::NoService,\n            _ => anyhow::Error::new(error).context(\"failed to connect to socket\").into(),\n        }\n    })?;\n\n    let json_cmd = serde_json::to_vec(&cmd).map_err(|error| {\n        tracing::error!(message_id = \"AdBGoG5S\", ?error, \"failed to serialize command: {error}\");\n        ManagerCmdErrorCode::Other\n    })?;\n    let len: u32 = json_cmd.len().try_into().map_err(|_| {\n        tracing::error!(message_id = \"Vq8mXpL2\", \"command too large to send\");\n        ManagerCmdErrorCode::Other\n    })?;\n    stream.write_all(&len.to_be_bytes()).await.map_err(|error| {\n        tracing::error!(message_id = \"GYCVPD3t\", ?error, \"failed to write length of json command: {error}\");\n        ManagerCmdErrorCode::Other\n    })?;\n    stream.write_all(json_cmd.as_slice()).await.map_err(|error| {\n        tracing::error!(message_id = \"FGduR73M\", ?error, \"failed to send json command: {error}\");\n        ManagerCmdErrorCode::Other\n    })?;\n    let mut response = Vec::new();\n    stream.read_to_end(&mut response).await.map_err(|error| {\n        tracing::error!(message_id = \"pdkSRS95\", ?error, \"failed to receive json command response: {error}\");\n        ManagerCmdErrorCode::Other\n    })?;\n    stream.shutdown().await.map_err(|error| {\n        tracing::error!(message_id = \"SqVcXJe4\", ?error, \"failed to close write end of socket stream: {error}\");\n        ManagerCmdErrorCode::Other\n    })?;\n\n    Ok(serde_json::from_slice(&response).map_err(|error| {\n        tracing::error!(\n            message_id = \"2TVuEG5e\",\n            ?error,\n            response = &*String::from_utf8_lossy(&response),\n            \"failed to parse json command response: {error}\"\n        );\n        ManagerCmdErrorCode::Other\n    })?)\n}\n\npub async fn ipc_test(_: ClientIpcTestArgs) -> Result<(), ClientError> {\n    if let Err(error) = run_command::<()>(ManagerCmd::Ping {}).await? {\n        tracing::error!(message_id = \"my5QZfPB\", ?error, \"IPC ping returned error\");\n        return Err(anyhow!(\"IPC ping returned error\").into());\n    }\n    Ok(())\n}\n\n// Tests if IPC fails due to insufficient permissions and if this can be resolved by refreshing a group membership. If that's the case, the process is replaced by a new one, which first updates the group memberships and then reruns the current command.\npub async fn try_group_refresh_fix() {\n    match ipc_test(ClientIpcTestArgs {}).await {\n        Err(ClientError::InsufficientPermissions) => tracing::debug!(\n            message_id = \"t4O1pv8K\",\n            \"insufficient permissions for IPC commands, check if IPC works in a new shell\"\n        ),\n        Ok(_) => {\n            tracing::debug!(message_id = \"ZA5DS6pc\", \"IPC test succeeded, group refresh not necessary\");\n            return;\n        }\n        Err(err) => {\n            tracing::debug!(\n                message_id = \"EP7be96J\",\n                ?err,\n                \"IPC test failed, but not due to insufficient permissions, not attempting group refresh: {err}\"\n            );\n            return;\n        }\n    }\n\n    tokio::task::spawn_blocking(|| {\n        use nix::unistd::{Group, User, getuid};\n        use std::env::{args, current_exe};\n        use std::os::unix::process::CommandExt;\n\n        let user = match User::from_uid(getuid()) {\n            Ok(Some(user)) => user,\n            Err(error) => {\n                tracing::error!(message_id = \"YBoOFOh1\", ?error, \"failed to resolve uid to user: {error}\");\n                return;\n            }\n            Ok(None) => {\n                tracing::error!(message_id = \"ccq4YLw9\", \"current user does not exist\");\n                return;\n            }\n        };\n\n        let group = match std::fs::metadata(SOCKET_PATH) {\n            Ok(meta) => match Group::from_gid(Gid::from_raw(meta.gid())) {\n                Ok(Some(group)) => group,\n                Err(error) => {\n                    tracing::error!(message_id = \"bm2pO7u5\", ?error, \"failed to resolve socket gid to group: {error}\");\n                    return;\n                }\n                Ok(None) => {\n                    tracing::error!(message_id = \"UyCY58ay\", \"socket group does not exist\");\n                    return;\n                }\n            },\n            Err(error) => {\n                tracing::error!(message_id = \"iZaf0n3l\", ?error, \"failed to look up socket metadata: {error}\");\n                return;\n            }\n        };\n\n        // sg may ask for a password interactively if the user is not a member of the group, so we check manually\n        if group.mem.iter().all(|membership| *membership != user.name) {\n            tracing::error!(message_id = \"7PswELBV\", \"user is not a member of {:?}\", group.name);\n            return;\n        }\n\n        let Ok(current_exe) = current_exe()\n            .inspect_err(|error| tracing::error!(message_id = \"NR6Vra8m\", ?error, \"failed to identify current executable path: {error}\"))\n        else {\n            return;\n        };\n        let Some(current_exe) = current_exe.to_str() else {\n            tracing::error!(message_id = \"xhz9ATa6\", \"current executable path is not valid UTF8\");\n            return;\n        };\n\n        // adding this sentinel flag to all invocations make sure this logic never triggers recursively\n        const NO_PERMISSION_FIX_ARG: &str = \"--no-group-refresh\";\n\n        let Ok(mut command) = build_sg_exec_cmd(&group.name, current_exe, [NO_PERMISSION_FIX_ARG, \"ipc-test\"]).inspect_err(|error| {\n            tracing::error!(\n                message_id = \"TSjQoNIW\",\n                ?error,\n                \"failed to quote ipc test command for execution in new shell: {error}\"\n            )\n        }) else {\n            return;\n        };\n        match command.status() {\n            Ok(exit_status) => {\n                if exit_status.success() {\n                    tracing::debug!(message_id = \"RYFtF944\", \"IPC succeeded in new shell\");\n                } else {\n                    tracing::debug!(message_id = \"GnTSEqyU\", \"IPC failed in new shell\");\n                    return;\n                }\n            }\n            Err(error) => {\n                tracing::error!(message_id = \"hdrXBHqC\", ?error, \"failed to run ipc test in new shell: {error}\");\n            }\n        }\n\n        tracing::info!(message_id = \"6I3WIrPh\", \"group refresh required, restarting process in a new shell\");\n        let current_args: Vec<String> = args().skip(1).collect();\n        let new_args_iter = once(NO_PERMISSION_FIX_ARG).chain(current_args.iter().map(String::as_str));\n        let Ok(mut command) = build_sg_exec_cmd(&group.name, current_exe, new_args_iter).inspect_err(|error| {\n            tracing::error!(\n                message_id = \"TSjQoNIW\",\n                ?error,\n                \"failed to quote current command for execution in new shell: {error}\"\n            )\n        }) else {\n            return;\n        };\n        let error = command.exec();\n        tracing::error!(\n            message_id = \"u8h0TXml\",\n            ?error,\n            \"failed to replace current process with same command in new shell: {error}\"\n        );\n    })\n    .await\n    .unwrap()\n}\n\n// sg takes the command as a single argument. To make sure the command survives the subsequent splitting unharmed, this function ensures the command and its arguments are correctly quoted and escaped.\nfn build_sg_exec_cmd<'a>(\n    group_name: &str,\n    exe: &'a str,\n    args: impl IntoIterator<Item = &'a str>,\n) -> Result<std::process::Command, shlex::QuoteError> {\n    let exec_cmd = once(\"exec\").chain(once(exe)).chain(args);\n    let sg_command_arg = shlex::try_join(exec_cmd)?;\n    let mut cmd = std::process::Command::new(\"sg\");\n    cmd.arg(group_name);\n    cmd.arg(\"-c\");\n    cmd.arg(sg_command_arg);\n    Ok(cmd)\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/client/mod.rs",
    "content": "mod client_error;\nmod ipc;\n\nuse crate::client::client_error::ClientError;\nuse crate::client::ipc::{ipc_test, run_command, try_group_refresh_fix};\nuse crate::{ClientCommand, ClientLoginArgs, ClientStatusArgs, GlobalArgs};\nuse anyhow::Context;\nuse chrono::{MappedLocalTime, TimeZone};\nuse obscuravpn_api::types::{AccountId, AccountInfo};\nuse obscuravpn_client::exit_selection::ExitSelector;\nuse obscuravpn_client::manager::{Status, TunnelArgs, VpnStatus};\nuse obscuravpn_client::manager_cmd::ManagerCmd;\n\npub async fn run(global_args: GlobalArgs, cmd: ClientCommand) -> Result<(), ClientError> {\n    // Group memberships changes do not automatically propagate into existing sessions. If we detect that launching the process with updated group memberships is necessary to do IPC, we replace the current process with a new one launched in a context with updated group memberships.\n    if global_args.no_group_refresh {\n        tracing::debug!(message_id = \"jpjl9cI9\", \"skipping group refresh fix due to CLI flag\");\n    } else {\n        try_group_refresh_fix().await;\n    }\n    match cmd {\n        ClientCommand::Login(args) => login(args).await,\n        ClientCommand::Start(_args) => go_to_target_state(Some(TunnelArgs { exit: ExitSelector::Any {} })).await,\n        ClientCommand::Stop(_args) => go_to_target_state(None).await,\n        ClientCommand::Status(args) => status(args).await,\n        ClientCommand::IpcTest(args) => ipc_test(args).await,\n    }\n}\n\nasync fn status(args: ClientStatusArgs) -> Result<(), ClientError> {\n    let get_account_info_result: Result<AccountInfo, _> = run_command(ManagerCmd::ApiGetAccountInfo {}).await?;\n    match get_account_info_result {\n        Ok(account_info) => {\n            if !args.json {\n                println!(\"Account is {}.\", account_info_summary(&account_info))\n            }\n        }\n        Err(error) => eprintln!(\"Failed to update account info: {}\", ClientError::from(error)),\n    }\n    let mut known_version = None;\n    loop {\n        let status: Status = run_command(ManagerCmd::GetStatus { known_version }).await??;\n        known_version = Some(status.version);\n        if args.json {\n            let json = serde_json::to_string_pretty(&status)\n                .map_err(anyhow::Error::new)\n                .context(\"JSON encoding failed\")?;\n            println!(\"{json}\");\n        } else {\n            println!(\"VPN is {}.\", vpn_status_summary(&status.vpn_status));\n        }\n        if !args.follow {\n            break Ok(());\n        }\n    }\n}\n\nasync fn login(args: ClientLoginArgs) -> Result<(), ClientError> {\n    let _: () = run_command(ManagerCmd::Login { account_id: AccountId::from_string_unchecked(args.account), validate: !args.offline }).await??;\n    if !args.offline {\n        eprintln!(\"successfully logged in\");\n    } else {\n        eprintln!(\"set account number in config without checking validity (offline mode)\");\n    }\n    Ok(())\n}\n\nasync fn go_to_target_state(target_state: Option<TunnelArgs>) -> Result<(), ClientError> {\n    run_command::<()>(ManagerCmd::SetTunnelArgs { args: target_state.clone(), active: Some(target_state.is_some()) }).await??;\n    eprintln!(\"updated target state\");\n    let mut known_version = None;\n    loop {\n        let status: Status = run_command(ManagerCmd::GetStatus { known_version }).await??;\n        known_version = Some(status.version);\n        eprintln!(\"{}\", vpn_status_summary(&status.vpn_status));\n        match (&status.vpn_status, &target_state) {\n            (VpnStatus::Connected { exit, .. }, Some(TunnelArgs { exit: exit_selector })) if exit_selector.matches(exit) => break,\n            (VpnStatus::Disconnected {}, None) => break,\n            _ => {}\n        }\n    }\n    eprintln!(\"reached target state\");\n    Ok(())\n}\n\nfn vpn_status_summary(vpn_status: &VpnStatus) -> String {\n    match vpn_status {\n        VpnStatus::Connecting { connect_error: Some(error_code), .. } => {\n            format!(\"connecting (error: \\\"{}\\\")\", error_code.as_static_str())\n        }\n        VpnStatus::Connecting { connect_error: None, .. } => \"connecting\".to_string(),\n        VpnStatus::Connected { exit, .. } => format!(\n            \"connected to {} in {} ({})\",\n            exit.id,\n            exit.city_name,\n            exit.city_code.country_code.0.to_uppercase()\n        ),\n        VpnStatus::Disconnected { .. } => \"disconnected\".to_string(),\n    }\n}\n\nfn account_info_summary(account_info: &AccountInfo) -> String {\n    let mut summary = String::new();\n    if account_info.active {\n        if let Some(expiry) = account_info.current_expiry {\n            summary += \"active\";\n            if let MappedLocalTime::Single(timestamp) = chrono::Local.timestamp_opt(expiry, 0) {\n                summary += &format!(\" until {}\", timestamp)\n            };\n        } else {\n            summary += \"active and subscribed\";\n        }\n    } else {\n        summary += \"expired (top-up or subscribe to activate)\";\n    }\n    summary\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/main.rs",
    "content": "use clap::{Args, Parser, Subcommand};\nuse derive_more::From;\nuse std::process::exit;\nuse strum::EnumIs;\nuse tracing_subscriber::EnvFilter;\n\n#[cfg(target_os = \"linux\")]\nmod add_operator;\n#[cfg(target_os = \"linux\")]\nmod client;\n#[cfg(any(target_os = \"windows\", target_os = \"linux\"))]\nmod service;\n\n#[cfg(not(target_os = \"windows\"))]\nfn get_data_dir() -> String {\n    \"/var/lib/obscura\".to_string()\n}\n\n#[cfg(target_os = \"windows\")]\nfn get_data_dir() -> String {\n    use standard_paths::{LocationType, StandardPaths};\n\n    let sp = StandardPaths::new(\"Obscura\", \"\");\n    sp.writable_location(LocationType::AppDataLocation)\n        .expect(\"failed to determine config directory\")\n        .to_string_lossy()\n        .into_owned()\n}\n\n#[derive(Args, Debug)]\npub struct ServiceArgs {\n    #[clap(long, default_value_t = get_data_dir())]\n    pub config_dir: String,\n    #[cfg(target_os = \"linux\")]\n    #[arg(long, value_enum, default_value_t = service::os::linux::dns::DnsManagerArg::Auto)]\n    pub dns: service::os::linux::dns::DnsManagerArg,\n}\n\n#[derive(Args, Debug)]\npub struct ClientLoginArgs {\n    /// Account number (20 decimal digits without dashes or spaces).\n    pub account: String,\n    #[clap(long)]\n    /// Don't validate the account number, which would require internet access.\n    pub offline: bool,\n}\n\n#[derive(Args, Debug)]\npub struct ClientStartArgs {}\n\n#[derive(Args, Debug)]\npub struct ClientStopArgs {}\n\n#[derive(Args, Debug)]\npub struct ClientStatusArgs {\n    #[arg(long, short)]\n    /// Continuously print new status updates as they are published by the service.\n    pub follow: bool,\n    #[arg(long)]\n    /// Print full JSON status instead of summary.\n    pub json: bool,\n}\n\n#[derive(Args, Debug)]\npub struct ClientIpcTestArgs {}\n\n#[derive(From, EnumIs)]\npub enum ClientCommand {\n    Login(ClientLoginArgs),\n    Start(ClientStartArgs),\n    Stop(ClientStopArgs),\n    Status(ClientStatusArgs),\n    IpcTest(ClientIpcTestArgs),\n}\n\n#[derive(Subcommand, Debug)]\npub enum Command {\n    #[cfg(target_os = \"linux\")]\n    /// Grant operator privileges by adding the specified users to the 'obscura' group. Defaults to the current user.\n    AddOperator {\n        users: Vec<String>,\n    },\n    Service(ServiceArgs),\n    Login(ClientLoginArgs),\n    Start(ClientStartArgs),\n    Stop(ClientStopArgs),\n    Status(ClientStatusArgs),\n    #[command(hide = true)]\n    IpcTest(ClientIpcTestArgs),\n}\n\n#[derive(Parser)]\npub struct Cli {\n    #[command(subcommand)]\n    command: Command,\n    #[command(flatten)]\n    pub global_args: GlobalArgs,\n}\n\n#[derive(Args, Debug)]\npub struct GlobalArgs {\n    #[clap(long, hide = true)]\n    no_group_refresh: bool,\n}\n\n#[tokio::main]\nasync fn main() {\n    tracing_subscriber::fmt()\n        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| \"info\".into()))\n        .init();\n\n    rustls::crypto::aws_lc_rs::default_provider()\n        .install_default()\n        .expect(\"Failed to install aws-lc crypto provider\");\n\n    let cli = Cli::parse();\n    let client_command: ClientCommand = match cli.command {\n        #[cfg(target_os = \"linux\")]\n        Command::AddOperator { users } => add_operator::run_add_operator(users).await,\n        Command::Service(args) => run_service(args).await,\n        Command::Start(args) => args.into(),\n        Command::Stop(args) => args.into(),\n        Command::Status(args) => args.into(),\n        Command::Login(args) => args.into(),\n        Command::IpcTest(args) => args.into(),\n    };\n    run_client(cli.global_args, client_command).await\n}\n\n#[cfg(any(target_os = \"windows\", target_os = \"linux\"))]\nasync fn run_service(args: ServiceArgs) -> ! {\n    let Err(error) = service::run(args).await;\n    eprintln!(\"failed to start service: {}\", error);\n    exit(1)\n}\n\n#[cfg(not(any(target_os = \"windows\", target_os = \"linux\")))]\nasync fn run_service(_args: ServiceArgs) -> ! {\n    eprintln!(\"unsupported OS\");\n    exit(1)\n}\n\n#[cfg(target_os = \"linux\")]\nasync fn run_client(global_args: GlobalArgs, args: ClientCommand) {\n    if let Err(error) = client::run(global_args, args).await {\n        eprintln!(\"{}\", error);\n        exit(1)\n    }\n}\n\n#[cfg(not(target_os = \"linux\"))]\nasync fn run_client(_global_args: GlobalArgs, _args: ClientCommand) {\n    eprintln!(\"unsupported OS\");\n    exit(1)\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/mod.rs",
    "content": "pub mod os;\n\nuse crate::ServiceArgs;\nuse std::convert::Infallible;\n\nuse anyhow::Context;\nuse obscuravpn_client::manager::Manager;\nuse std::error::Error;\nuse std::sync::Arc;\n\npub async fn run(args: ServiceArgs) -> Result<Infallible, Box<dyn Error>> {\n    tracing::info!(message_id = \"MNqPkSTH\", \"starting service\");\n\n    #[cfg(target_os = \"linux\")]\n    let os_impl = os::linux::LinuxOsImpl::new(args.dns).await?;\n    #[cfg(target_os = \"windows\")]\n    let os_impl = os::windows::WindowsOsImpl::new().await?;\n\n    let os_impl = Arc::new(os_impl);\n\n    let manager = Manager::new(\n        args.config_dir.into(),\n        None,\n        format!(\"obscura.net/{}/v0.0-alpha\", std::env::consts::OS),\n        os_impl.clone(),\n        None,\n        None,\n        false,\n    )\n    .context(\"failed to create manager\")?;\n\n    loop {\n        let (cmd, response_fn) = os_impl.next_manager_command().await;\n        let manager = manager.clone();\n        tokio::spawn(async move { response_fn(cmd.run(&manager).await) });\n    }\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/linux/dns/mod.rs",
    "content": "use crate::service::os::linux::network_manager;\nuse clap::ValueEnum;\n\npub mod resolved;\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\npub enum DnsManagerArg {\n    Auto,\n    Disabled,\n    NetworkManager,\n    Resolved,\n}\n\n#[derive(Debug, Copy, Clone, Eq, PartialEq, strum::EnumIs)]\npub enum DnsManager {\n    Disabled,\n    Resolved,\n    NetworkManager,\n}\n\npub async fn choose_dns_manager(dns_manager_arg: DnsManagerArg) -> Result<DnsManager, ()> {\n    let network_manager = network_manager::detect().await;\n    let resolved = resolved::detect().await;\n\n    let choice = match dns_manager_arg {\n        DnsManagerArg::Disabled => Ok(DnsManager::Disabled),\n        DnsManagerArg::Auto => {\n            if resolved {\n                Ok(DnsManager::Resolved)\n            } else if network_manager {\n                Ok(DnsManager::NetworkManager)\n            } else {\n                tracing::error!(message_id = \"ltV4egoX\", \"no supported DNS manager detected\");\n                Err(())\n            }\n        }\n        DnsManagerArg::NetworkManager if network_manager => Ok(DnsManager::NetworkManager),\n        DnsManagerArg::Resolved if resolved => Ok(DnsManager::Resolved),\n        dns_manager_arg => {\n            tracing::error!(message_id = \"bJO46yTy\", ?dns_manager_arg, \"requested DNS manager not detected\");\n            Err(())\n        }\n    };\n    tracing::info!(\n        message_id = \"PsaY3ZPO\",\n        ?dns_manager_arg,\n        network_manager,\n        resolved,\n        ?choice,\n        \"DNS manager detection\"\n    );\n    choice\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/linux/dns/resolved.rs",
    "content": "use obscuravpn_client::net::NetworkInterface;\nuse std::net::IpAddr;\nuse zbus_systemd::zbus;\n\nasync fn zbus_connect() -> Result<zbus_systemd::resolve1::ManagerProxy<'static>, ()> {\n    let conn = zbus::Connection::system()\n        .await\n        .map_err(|error| tracing::error!(message_id = \"SX4gJ91O\", ?error, \"failed to create DBUS system connection: {}\", error))?;\n    zbus_systemd::resolve1::ManagerProxy::new(&conn)\n        .await\n        .map_err(|error| tracing::error!(message_id = \"AucCE8My\", ?error, \"failed to create resolved zbus proxy: {}\", error))\n        .map(|proxy| proxy.to_owned())\n}\n\n// Returns true if resolved is running and in stub mode\npub async fn detect() -> bool {\n    let Ok(proxy) = zbus_connect().await else {\n        return false;\n    };\n    match proxy.resolv_conf_mode().await {\n        Ok(mode) => {\n            tracing::info!(message_id = \"0TsSfY4K\", mode, \"resolved is running\");\n            mode == \"stub\"\n        }\n        Err(error) => {\n            tracing::error!(message_id = \"DDMvhHf4\", ?error, \"failed to query resolved mode: {}\", error);\n            false\n        }\n    }\n}\n\npub async fn set_dns(tun: &NetworkInterface, dns: &[IpAddr]) -> Result<(), ()> {\n    let dns = dns\n        .iter()\n        .map(|entry| match entry {\n            IpAddr::V4(entry) => (libc::AF_INET, entry.octets().to_vec()),\n            IpAddr::V6(entry) => (libc::AF_INET6, entry.octets().to_vec()),\n        })\n        .collect();\n    // Equivalent to `resolvectl dns obscura <DNS IP>`\n    let proxy = zbus_connect().await?;\n    proxy\n        .set_link_dns(tun.index.into(), dns)\n        .await\n        .map_err(|error| tracing::error!(message_id = \"H7vih0nS\", ?error, \"failed to set tun DNS IPs: {}\", error))?;\n    // Equivalent to `resolvectl domain obscuravpn ~.`. The `~` (or `true`) below, indicates a routing-only domain (not search domain)\n    proxy\n        .set_link_domains(tun.index.into(), vec![(\".\".to_string(), true)])\n        .await\n        .map_err(|error| tracing::error!(message_id = \"92tR6ndT\", ?error, \"failed to set tun DNS domain: {}\", error))?;\n    Ok(())\n}\n\npub async fn reset_dns(tun: &NetworkInterface) -> Result<(), ()> {\n    zbus_connect()\n        .await?\n        .revert_link(tun.index.into())\n        .await\n        .map_err(|error| tracing::error!(message_id = \"MV4oVXSy\", ?error, \"failed to revert DNS: {}\", error))\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/linux/ipc.rs",
    "content": "use crate::service::os::linux::service_lock::ServiceLock;\nuse crate::service::os::linux::start_error::LinuxServiceStartError;\nuse flume::{Receiver, Sender, bounded};\nuse std::fs;\nuse std::io::ErrorKind;\nuse tokio::io::{AsyncReadExt, AsyncWriteExt};\nuse tokio::net::{UnixListener, UnixStream};\n\npub const SOCKET_PATH: &str = \"/run/obscura.sock\";\n\npub struct ServiceIpc {\n    receiver: Receiver<(Vec<u8>, Box<dyn FnOnce(Vec<u8>) + Send>)>,\n}\n\nimpl ServiceIpc {\n    pub async fn new(_lock: &ServiceLock) -> Result<Self, LinuxServiceStartError> {\n        fs::remove_file(SOCKET_PATH).or_else(|error| match error.kind() {\n            ErrorKind::NotFound => Ok(()),\n            kind => {\n                tracing::error!(message_id = \"GTtsZsdU\", ?error, \"failed to remove stale socket file: {error}\");\n                Err(match kind {\n                    ErrorKind::PermissionDenied => LinuxServiceStartError::InsufficientPermissions,\n                    _ => anyhow::Error::new(error).context(\"failed to remove stale socket file\").into(),\n                })\n            }\n        })?;\n\n        let socket = UnixListener::bind(SOCKET_PATH).map_err(|error| {\n            tracing::error!(message_id = \"1WXBW1gj\", ?error, \"failed to bind socket: {error}\");\n            match error.kind() {\n                ErrorKind::PermissionDenied => LinuxServiceStartError::InsufficientPermissions,\n                _ => anyhow::Error::new(error).context(\"failed to create IPC socket\").into(),\n            }\n        })?;\n        // ensure that `Self::next()` is cancel safe by decoupling it from the incremental progress on socket streams.\n        let (sender, receiver) = bounded::<(Vec<u8>, Box<dyn FnOnce(Vec<u8>) + Send>)>(0);\n        tokio::spawn(async move {\n            while !sender.is_disconnected() {\n                let Ok((stream, _)) = socket.accept().await.map_err(|error| {\n                    tracing::error!(message_id = \"Y3lClT6m\", ?error, \"socket accept failed: error\");\n                    panic!(\"socket accept errors are not recoverable: {error}\");\n                });\n\n                let sender = sender.clone();\n                tokio::spawn(async move {\n                    let _: Result<(), ()> = Self::handle_stream(stream, sender).await;\n                });\n            }\n            tracing::info!(message_id = \"dYp5Tr25\", \"stop listening for IPC connections\");\n        });\n        Ok(Self { receiver })\n    }\n\n    pub async fn next(&self) -> (Vec<u8>, Box<dyn FnOnce(Vec<u8>) + Send>) {\n        self.receiver.recv_async().await.expect(\"uds task death is not recoverable\")\n    }\n\n    async fn handle_stream(mut stream: UnixStream, sender: Sender<(Vec<u8>, Box<dyn FnOnce(Vec<u8>) + Send>)>) -> Result<(), ()> {\n        tracing::info!(message_id = \"M0sAFoC7\", \"handling new socket stream\");\n\n        // TODO: send a build identifier to allow the client to ensure it is the same binary (command protocol has no stability guarantees)\n\n        let mut len = [0u8; 4];\n        stream.read_exact(&mut len).await.map_err(|error| {\n            tracing::error!(\n                message_id = \"hfdWDTcp\",\n                ?error,\n                \"failed to read message length from socket stream: {error}\"\n            );\n        })?;\n        let len = u32::from_be_bytes(len);\n        if len > 1_000_000 {\n            tracing::error!(message_id = \"k9XmPq2R\", len, \"message on socket stream too long\");\n            return Err(());\n        }\n        let mut message: Vec<u8> = vec![0; len as usize];\n        stream.read_exact(&mut message).await.map_err(|error| {\n            tracing::error!(message_id = \"GFf8wiV3\", ?error, \"failed to read message from socket stream: {error}\");\n        })?;\n        let response_fn = move |response: Vec<u8>| {\n            tokio::spawn(async move {\n                stream.write_all(&response).await.map_err(|error| {\n                    tracing::error!(message_id = \"XijfChPl\", ?error, \"failed to write response to socket stream: {error}\");\n                })?;\n                stream.shutdown().await.map_err(|error| {\n                    tracing::error!(message_id = \"RRCdeq0M\", ?error, \"failed to close socket write stream: {error}\");\n                })?;\n                // Sockets closed for writing on both sides don't linger, even if there's unread data, so we need to wait for the client to signal it's done reading.\n                let n = stream.read(&mut [0u8; 1]).await.map_err(|error| {\n                    tracing::error!(message_id = \"g90YsnwQ\", ?error, \"failed to read clean EOF from socket stream: {error}\");\n                })?;\n                if n == 0 {\n                    tracing::info!(message_id = \"CiLg0uHK\", \"client closed socket stream as expected\");\n                } else {\n                    tracing::error!(message_id = \"MldiAfVK\", \"client sent {n} more bytes than announced on socket stream\");\n                }\n                Result::<(), ()>::Ok(())\n            });\n        };\n        _ = sender.send_async((message, Box::new(response_fn))).await;\n        tracing::info!(message_id = \"lx2Z8pCr\", \"finished handling socket stream\");\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/linux/mod.rs",
    "content": "pub mod dns;\npub mod ipc;\nmod network_manager;\nmod routes;\nmod service_lock;\npub mod start_error;\npub mod tun;\n\nuse crate::service::os::ROUTES;\nuse crate::service::os::linux::dns::{DnsManager, DnsManagerArg, choose_dns_manager, resolved};\nuse crate::service::os::linux::ipc::ServiceIpc;\nuse crate::service::os::linux::routes::netlink;\nuse crate::service::os::linux::service_lock::ServiceLock;\nuse crate::service::os::linux::tun::Tun;\nuse bytes::Bytes;\nuse obscuravpn_client::manager_cmd::{ManagerCmd, ManagerCmdErrorCode, ManagerCmdOk};\nuse obscuravpn_client::net::NetworkInterface;\nuse obscuravpn_client::network_config::OsNetworkConfig;\nuse obscuravpn_client::os::os_trait::Os;\nuse obscuravpn_client::quicwg::QuicWgConnPacketSender;\npub use start_error::LinuxServiceStartError;\nuse tokio::sync::watch::Receiver;\n\npub struct LinuxOsImpl {\n    tun: Tun,\n    preferred_network_interface: Receiver<Option<NetworkInterface>>,\n    current_network_config: tokio::sync::Mutex<Result<Option<OsNetworkConfig>, ()>>,\n    dns_manager_arg: DnsManagerArg,\n    ipc: ServiceIpc,\n    _lock: ServiceLock,\n}\n\nimpl LinuxOsImpl {\n    pub async fn new(dns_manager_arg: DnsManagerArg) -> Result<Self, LinuxServiceStartError> {\n        let lock = ServiceLock::new()?;\n        choose_dns_manager(dns_manager_arg)\n            .await\n            .map_err(|()| LinuxServiceStartError::NoDnsManager)?;\n        let ipc = ServiceIpc::new(&lock).await?;\n        Ok(Self {\n            _lock: lock,\n            ipc,\n            tun: Tun::create().await?,\n            preferred_network_interface: netlink::watch_preferred_network_interface().await,\n            current_network_config: Ok(None).into(),\n            dns_manager_arg,\n        })\n    }\n}\n\nimpl Os for LinuxOsImpl {\n    fn network_interface(&self) -> Receiver<Option<NetworkInterface>> {\n        self.preferred_network_interface.clone()\n    }\n\n    async fn set_os_network_config(&self, network_config: OsNetworkConfig, tunnel: QuicWgConnPacketSender) -> Result<(), ()> {\n        let mut current_network_config = self.current_network_config.lock().await;\n        let tun = self.tun.interface();\n\n        // Attempt all config steps regardless of individual failures to minimize leaks until intentionally disconnecting. E.g. DNS queries shouldn't leak because route setup failed.\n        let mut result = Ok(());\n        match choose_dns_manager(self.dns_manager_arg).await? {\n            DnsManager::NetworkManager => result = result.and(network_manager::set_dns_and_routes(&tun, &network_config, &ROUTES).await),\n            dns_manager => {\n                result = result.and(netlink::add_routes(&tun, &ROUTES).await);\n                if dns_manager.is_resolved() {\n                    if network_config.use_system_dns {\n                        result = result.and(resolved::reset_dns(&tun).await);\n                    } else {\n                        result = result.and(resolved::set_dns(&tun, &network_config.dns).await);\n                    }\n                }\n            }\n        }\n        result = result.and(self.tun.set_config(network_config.mtu, network_config.ipv4, network_config.ipv6));\n        *current_network_config = result.map(|_| Some(network_config));\n\n        self.tun.spawn_read_task(tunnel);\n        result\n    }\n\n    async fn unset_os_network_config(&self) -> Result<(), ()> {\n        let mut current_network_config = self.current_network_config.lock().await;\n        let tun = self.tun.interface();\n        let mut result = Ok(());\n        match choose_dns_manager(self.dns_manager_arg).await? {\n            DnsManager::NetworkManager => result = result.and(network_manager::reset_dns_and_routes(&tun).await),\n            dns_manager => {\n                result = result.and(netlink::del_routes(&tun, &ROUTES).await);\n                if dns_manager.is_resolved() {\n                    result = result.and(resolved::reset_dns(&tun).await);\n                }\n            }\n        }\n        *current_network_config = result.map(|_| None);\n        result\n    }\n\n    fn packet_for_os(&self, packet: Bytes) {\n        self.tun.send(packet)\n    }\n}\n\nimpl LinuxOsImpl {\n    /// Returns next manager command. Blocks until a command is available. The response function is called with the command result.\n    pub async fn next_manager_command(&self) -> (ManagerCmd, Box<dyn FnOnce(Result<ManagerCmdOk, ManagerCmdErrorCode>) + Send>) {\n        loop {\n            let (json_cmd, response_fn) = self.ipc.next().await;\n            let response_fn = move |result: Result<ManagerCmdOk, ManagerCmdErrorCode>| {\n                let json_response = serde_json::to_vec(&result)\n                    .map_err(|error| {\n                        tracing::error!(message_id = \"8Jj0yWQt\", ?error, \"failed to encode command result: {}\", error);\n                        ManagerCmdErrorCode::Other\n                    })\n                    .unwrap_or(JSON_OTHER_ERROR.into());\n                response_fn(json_response)\n            };\n            match ManagerCmd::from_json(&json_cmd) {\n                Ok(cmd) => return (cmd, Box::new(response_fn)),\n                Err(error) => response_fn(Err(error)),\n            }\n        }\n    }\n}\n\nconst JSON_OTHER_ERROR: &str = r#\"{\"Err\":\"other\"}\"#;\n\n#[test]\nfn test_other_error_json() {\n    assert_eq!(\n        serde_json::to_string(&Result::<ManagerCmdOk, ManagerCmdErrorCode>::Err(ManagerCmdErrorCode::Other)).unwrap(),\n        JSON_OTHER_ERROR\n    )\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/linux/network_manager.rs",
    "content": "use ipnetwork::IpNetwork;\nuse obscuravpn_client::net::NetworkInterface;\nuse obscuravpn_client::network_config::{DnsContentBlock, OsNetworkConfig};\nuse semver::Version;\nuse std::collections::HashMap;\nuse std::net::{IpAddr, Ipv4Addr};\n\n/// Minimum supported NetworkManager version. Some NetworkManager versions behave in various unexpected ways, especially with respect to IPv6 route maintenance.\n/// Version 1.42.4 on Debian 12 both clears externally set IPv6 routes (despite preserve-external-ip flag) and does not apply provided IPv6 routes.\n/// The first version that is confirmed to correctly interact with externally managed routes (with the preserve-external-ip flag) is 1.54.0-2.fc43 (tested on Fedora 43).\nconst MIN_VERSION: Version = Version::new(1, 52, 1);\n\n/// See https://networkmanager.dev/docs/api/latest/gdbus-org.freedesktop.NetworkManager.html\n#[zbus::proxy(\n    interface = \"org.freedesktop.NetworkManager\",\n    default_service = \"org.freedesktop.NetworkManager\",\n    default_path = \"/org/freedesktop/NetworkManager\"\n)]\ntrait NetworkManager {\n    fn get_device_by_ip_iface(&self, iface: &str) -> zbus::Result<zbus::zvariant::OwnedObjectPath>;\n    #[zbus(property)]\n    fn version(&self) -> zbus::Result<String>;\n}\n\n/// See https://networkmanager.dev/docs/api/latest/gdbus-org.freedesktop.NetworkManager.Device.html\n#[zbus::proxy(interface = \"org.freedesktop.NetworkManager.Device\", default_service = \"org.freedesktop.NetworkManager\")]\npub trait Device {\n    fn get_applied_connection(&self, flags: u32) -> zbus::Result<(HashMap<String, HashMap<String, zbus::zvariant::OwnedValue>>, u64)>;\n    fn reapply(&self, connection: HashMap<String, HashMap<String, zbus::zvariant::OwnedValue>>, version_id: u64, flags: u32) -> zbus::Result<()>;\n    #[zbus(property)]\n    fn state(&self) -> zbus::Result<u32>;\n}\n\nimpl NetworkManagerProxy<'_> {\n    async fn connect() -> Result<(NetworkManagerProxy<'static>, Version), ()> {\n        let conn = zbus::Connection::system()\n            .await\n            .map_err(|error| tracing::error!(message_id = \"xawuPraW\", ?error, \"failed to create DBUS system connection: {}\", error))?;\n        let proxy = NetworkManagerProxy::new(&conn)\n            .await\n            .map_err(|error| tracing::error!(message_id = \"glChFAF5\", ?error, \"failed to create network manager zbus proxy: {}\", error))\n            .map(|proxy| proxy.to_owned())?;\n        let version = proxy\n            .version()\n            .await\n            .map_err(|error| tracing::error!(message_id = \"WKUw8Oww\", ?error, \"failed to get network manager version: {}\", error))?;\n        let version = Version::parse(&version)\n            .map_err(|error| tracing::error!(message_id = \"WKUw8Oww\", ?error, \"failed to parse network manager version: {}\", error))?;\n        Ok((proxy, version))\n    }\n    pub async fn device_proxy(self, interface: &NetworkInterface) -> Result<DeviceProxy<'static>, ()> {\n        let device_path = self.get_device_by_ip_iface(&interface.name).await.map_err(|error| {\n            tracing::error!(\n                message_id = \"WKUw8Oww\",\n                ?error,\n                interface.name,\n                \"failed to get network manager device path: {}\",\n                error\n            )\n        })?;\n        DeviceProxy::new(self.inner().connection(), device_path).await.map_err(|error| {\n            tracing::error!(\n                message_id = \"aVyeWXo6\",\n                ?error,\n                \"failed to create network manager device proxy: {}\",\n                error\n            )\n        })\n    }\n}\n\n/// Returns true if network manager is running and fulfills our minimal version requirement\npub async fn detect() -> bool {\n    let Ok((_proxy, version)) = NetworkManagerProxy::connect().await else {\n        return false;\n    };\n    tracing::info!(message_id = \"9nMAwOYu\", %version, \"network manager is running\");\n    version >= MIN_VERSION\n}\n\npub async fn set_dns_and_routes(tun: &NetworkInterface, network_config: &OsNetworkConfig, routes: &[IpNetwork]) -> Result<(), ()> {\n    let (nm_proxy, _nm_version) = NetworkManagerProxy::connect().await?;\n    let proxy = nm_proxy.device_proxy(tun).await?;\n    apply_device_settings(tun, &proxy, network_config, routes, true).await\n}\n\npub async fn reset_dns_and_routes(tun: &NetworkInterface) -> Result<(), ()> {\n    let (nm_proxy, _nm_version) = NetworkManagerProxy::connect().await?;\n    let proxy = nm_proxy.device_proxy(tun).await?;\n    let network_config = OsNetworkConfig::dummy(DnsContentBlock::default(), false);\n    apply_device_settings(tun, &proxy, &network_config, &[], false).await\n}\n\nasync fn apply_device_settings(\n    tun: &NetworkInterface,\n    proxy: &DeviceProxy<'static>,\n    network_config: &OsNetworkConfig,\n    routes: &[IpNetwork],\n    enable_dns: bool,\n) -> Result<(), ()> {\n    /// Setting this flag on Device.Reapply prevents removal of externally added IP addresses and routes. This does not seem to be respected if the ipv4 or ipv6 section of the applied settings are removed entirely or if the method is changed. See https://networkmanager.dev/docs/api/latest/gdbus-org.freedesktop.NetworkManager.Device.html#gdbus-method-org-freedesktop-NetworkManager-Device.Reapply\n    /// Some versions of NetworkManager remove all IPs and move the device to unmanaged otherwise (e.g. 1.52.1 on Debian 13).\n    const PRESERVE_EXTERNAL_IP: u32 = 0x1;\n\n    let settings = build_device_settings(tun, network_config, routes, enable_dns).map_err(|error| {\n        tracing::error!(\n            message_id = \"jj3NwH49\",\n            ?error,\n            \"failed to change network manager DNS settings: {}\",\n            error\n        )\n    })?;\n\n    proxy.reapply(settings, 0, PRESERVE_EXTERNAL_IP).await.map_err(|error| {\n        tracing::error!(\n            message_id = \"EgcIC6PF\",\n            ?error,\n            \"failed to apply DNS config changes to network manager device: {}\",\n            error\n        )\n    })\n}\n\nfn build_device_settings(\n    tun: &NetworkInterface,\n    network_config: &OsNetworkConfig,\n    routes: &[IpNetwork],\n    enable_dns: bool,\n) -> Result<HashMap<String, HashMap<String, zbus::zvariant::OwnedValue>>, zbus::zvariant::Error> {\n    // See\n    // - https://networkmanager.dev/docs/api/latest/nm-settings-nmcli.html\n    // - https://networkmanager.dev/docs/api/1.44.4/NetworkManager.conf.html\n    // for (incomplete) lists of supported properties.\n\n    use zbus::zvariant::{Str, Value};\n\n    let method = Str::from_static(\"manual\");\n\n    let ipv4_address_data: Vec<HashMap<String, zbus::zvariant::OwnedValue>> = vec![HashMap::from([\n        (\"address\".into(), Value::from(network_config.ipv4.to_string()).try_into()?),\n        (\"prefix\".into(), Value::from(32u32).try_into()?),\n    ])];\n\n    let ipv4_route_data: Vec<HashMap<String, zbus::zvariant::OwnedValue>> = routes\n        .iter()\n        .cloned()\n        .filter(IpNetwork::is_ipv4)\n        .map(route_to_dbus_hashmap)\n        .collect::<Result<Vec<_>, _>>()?;\n\n    let mut ipv4_settings = HashMap::from([\n        (\"address-data\".into(), Value::from(ipv4_address_data).try_into()?),\n        (\"route-data\".into(), Value::from(ipv4_route_data).try_into()?),\n        (\"method\".into(), method.clone().into()),\n        (\"never-default\".into(), true.into()),\n        (\"may-fail\".into(), true.into()),\n    ]);\n\n    let ipv6_address_data: Vec<HashMap<String, zbus::zvariant::OwnedValue>> = vec![HashMap::from([\n        (\"address\".into(), Value::from(network_config.ipv6.ip().to_string()).try_into()?),\n        (\"prefix\".into(), Value::from(u32::from(network_config.ipv6.prefix())).try_into()?),\n    ])];\n\n    let ipv6_route_data: Vec<HashMap<String, zbus::zvariant::OwnedValue>> = routes\n        .iter()\n        .cloned()\n        .filter(IpNetwork::is_ipv6)\n        .map(route_to_dbus_hashmap)\n        .collect::<Result<Vec<_>, _>>()?;\n\n    let mut ipv6_settings = HashMap::from([\n        (\"address-data\".into(), Value::from(ipv6_address_data).try_into()?),\n        (\"route-data\".into(), Value::from(ipv6_route_data).try_into()?),\n        (\"method\".into(), method.into()),\n        (\"never-default\".into(), true.into()),\n        (\"may-fail\".into(), true.into()),\n    ]);\n\n    let connection_settings = HashMap::from([\n        (\"type\".into(), Str::from_static(\"tun\").into()),\n        (\"id\".into(), Value::from(&tun.name).try_into()?),\n        (\"interface-name\".into(), Value::from(&tun.name).try_into()?),\n        (\"autoconnect\".into(), true.into()),\n    ]);\n\n    //  NetworkManager 1.52.1 on Debian 13 will generate an empty /etc/resolv.conf if these settings are specified (after previously applying a non-empty tunnel DNS configuration correctly), but don't contain any DNS server addresses. Both some older and newer versions do not have this problem.\n    if enable_dns && !network_config.use_system_dns {\n        let dns_search = vec![\"~\"];\n        let mut dns_addresses_v4 = vec![];\n        let mut dns_addresses_v6 = vec![];\n        for &dns_ip in &network_config.dns {\n            match dns_ip {\n                IpAddr::V4(dns_ip) => dns_addresses_v4.push(ipv4_to_u32(dns_ip)),\n                IpAddr::V6(dns_ip) => dns_addresses_v6.push(dns_ip.octets().to_vec()),\n            }\n        }\n        for ipvx_settings in [&mut ipv4_settings, &mut ipv6_settings] {\n            ipvx_settings.insert(\"dns-priority\".into(), i32::MIN.into());\n            ipvx_settings.insert(\"dns-search\".into(), Value::from(&dns_search).try_into()?);\n        }\n        ipv4_settings.insert(\"dns\".into(), Value::from(dns_addresses_v4).try_into()?);\n        ipv6_settings.insert(\"dns\".into(), Value::from(dns_addresses_v6).try_into()?);\n    }\n\n    Ok(HashMap::from([\n        (\"connection\".into(), connection_settings),\n        (\"ipv4\".into(), ipv4_settings),\n        (\"ipv6\".into(), ipv6_settings),\n    ]))\n}\n\nfn ipv4_to_u32(ip: Ipv4Addr) -> u32 {\n    // NetworkManager IPs are the octets as u32, in their original byte order.\n    u32::from_ne_bytes(ip.octets())\n}\n\nfn route_to_dbus_hashmap(net: IpNetwork) -> Result<HashMap<String, zbus::zvariant::OwnedValue>, zbus::zvariant::Error> {\n    Ok(HashMap::from([\n        (\"dest\".into(), zbus::zvariant::Value::from(net.ip().to_string()).try_into()?),\n        (\"prefix\".into(), u32::from(net.prefix()).into()),\n    ]))\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/linux/routes/mod.rs",
    "content": "pub mod netlink;\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/linux/routes/netlink.rs",
    "content": "use anyhow::{Context, anyhow, bail};\nuse futures::{StreamExt, TryStreamExt};\nuse ipnetwork::IpNetwork;\nuse obscuravpn_client::net::NetworkInterface;\nuse obscuravpn_client::positive_u31::PositiveU31;\nuse obscuravpn_client::tokio::AbortOnDrop;\nuse rtnetlink::RouteMessageBuilder;\nuse rtnetlink::constants::RTMGRP_IPV4_ROUTE;\nuse rtnetlink::packet_route::link::{LinkAttribute, LinkMessage};\nuse rtnetlink::packet_route::route::{RouteAttribute, RouteHeader, RouteMessage};\nuse rtnetlink::sys::{AsyncSocket, SocketAddr};\nuse std::convert::Infallible;\nuse std::net::{IpAddr, Ipv4Addr};\nuse std::num::NonZeroI32;\nuse std::time::Duration;\nuse tokio;\nuse tokio::select;\nuse tokio::sync::watch::{Receiver, Sender, channel};\nuse tokio::time::sleep;\n\nconst PREFERRED_INTERFACE_WATCH_ERROR_BACKOFF: Duration = Duration::from_secs(1);\n\npub async fn netlink_connect() -> Result<rtnetlink::Handle, ()> {\n    let (connection, handle) = match rtnetlink::new_connection() {\n        Ok((connection, handle, _)) => (connection, handle),\n        Err(error) => {\n            tracing::error!(message_id = \"kuk3xhql\", ?error, \"failed to create netlink connection\");\n            return Err(());\n        }\n    };\n    tokio::spawn(connection);\n    Ok(handle)\n}\n\npub async fn add_routes(tun: &NetworkInterface, routes: &[IpNetwork]) -> Result<(), ()> {\n    let handle = netlink_connect().await?;\n    let route_messages = build_route_messages(tun, routes)?;\n    for route_message in route_messages {\n        handle.route().add(route_message.clone()).replace().execute().await.map_err(|error| {\n            tracing::error!(message_id = \"a1Uk0dKX\", ?error, ?route_message, \"failed to add route\");\n        })?\n    }\n    Ok(())\n}\n\npub async fn del_routes(tun: &NetworkInterface, routes: &[IpNetwork]) -> Result<(), ()> {\n    let handle = netlink_connect().await?;\n    let route_messages = build_route_messages(tun, routes)?;\n    for route_message in route_messages {\n        handle\n            .route()\n            .del(route_message.clone())\n            .execute()\n            .await\n            .or_else(|error| {\n                if let rtnetlink::Error::NetlinkError(error) = &error\n                    && error.code == NonZeroI32::new(-3)\n                {\n                    tracing::info!(message_id = \"WGD6Z6xL\", \"tried to delete non-existent route\");\n                    return Ok(());\n                }\n                Err(error)\n            })\n            .map_err(|error| {\n                tracing::error!(message_id = \"aDETR9ur\", ?error, ?route_message, \"failed to delete route\");\n            })?\n    }\n    Ok(())\n}\n\n/// Build route messages for tun device.\nfn build_route_messages(tun: &NetworkInterface, routes: &[IpNetwork]) -> Result<Vec<RouteMessage>, ()> {\n    routes\n        .iter()\n        .map(|net| {\n            Ok(RouteMessageBuilder::<IpAddr>::new()\n                .destination_prefix(net.ip(), net.prefix())\n                .map_err(|error| tracing::error!(message_id = \"wtAo1gKj\", ?error, \"failed to build route message\"))?\n                .output_interface(tun.index.into())\n                .build())\n        })\n        .collect()\n}\n\npub async fn watch_preferred_network_interface() -> Receiver<Option<NetworkInterface>> {\n    let (sender, receiver) = channel(None);\n    tokio::spawn(async move {\n        loop {\n            select! {\n                _ = sender.closed() => {\n                    tracing::warn!(message_id = \"Vj07sMT5\", \"preferred network interface receiver dropped/closed\");\n                    return\n                }\n                Err(error) = watch_preferred_network_interface_one(&sender) => {\n                    tracing::warn!(message_id = \"evDsKFvw\", ?error, \"preferred network interface watcher encountered error\");\n                    sleep(PREFERRED_INTERFACE_WATCH_ERROR_BACKOFF).await;\n                }\n            }\n        }\n    });\n    receiver\n}\n\nasync fn watch_preferred_network_interface_one(sender: &Sender<Option<NetworkInterface>>) -> anyhow::Result<Infallible> {\n    let (mut connection, handle, mut messages) = rtnetlink::new_connection().context(\"failed to create netlink connection\")?;\n    connection\n        .socket_mut()\n        .socket_mut()\n        .bind(&SocketAddr::new(0, RTMGRP_IPV4_ROUTE))\n        .context(\"netlink socket bind failed\")?;\n    connection.forward_unsolicited_messages();\n    let abort_handle = AbortOnDrop::from(tokio::spawn(connection).abort_handle());\n    loop {\n        tracing::info!(message_id = \"lryydLmq\", \"routing table changed\");\n        let new = get_preferred_network_interface(&handle).await?;\n        sender.send_if_modified(|current| {\n            if *current != new {\n                tracing::info!(message_id = \"EWxPiQFh\", ?current, ?new, \"preferred network interface changed\");\n                *current = new;\n                true\n            } else {\n                false\n            }\n        });\n        if messages.next().await.is_none() {\n            break;\n        }\n    }\n    drop(abort_handle);\n    Err(anyhow!(\"netlink route event stream closed\"))\n}\n\nasync fn get_preferred_network_interface(handle: &rtnetlink::Handle) -> anyhow::Result<Option<NetworkInterface>> {\n    let mut highest_priority_default_route: Option<DefaultRoute> = None;\n    let mut routes = handle.route().get(RouteMessageBuilder::<Ipv4Addr>::new().build()).execute();\n    while let Some(route) = routes.next().await {\n        let route: RouteMessage = route?;\n        if route.header.destination_prefix_length != 0 || route.header.table != RouteHeader::RT_TABLE_MAIN {\n            continue;\n        }\n        let (mut interface_index, mut metric) = (None, None);\n        for attr in route.attributes {\n            match attr {\n                RouteAttribute::Oif(v) => interface_index = Some(PositiveU31::try_from(v).context(\"interface index out of range\")?),\n                RouteAttribute::Priority(v) => metric = Some(v),\n                _ => {}\n            }\n        }\n        let (Some(interface_index), Some(metric)) = (interface_index, metric) else {\n            bail!(\"default route with missing oif={interface_index:?} or priority={metric:?}\")\n        };\n        let link_message: LinkMessage = handle\n            .link()\n            .get()\n            .match_index(interface_index.into())\n            .execute()\n            .try_next()\n            .await?\n            .context(\"no matches for interface index\")?;\n        let interface_name = link_message\n            .attributes\n            .into_iter()\n            .filter_map(|attr| if let LinkAttribute::IfName(name) = attr { Some(name) } else { None })\n            .next()\n            .context(\"no name attribute for interface\")?;\n        let default_route = DefaultRoute { network_interface: NetworkInterface { index: interface_index, name: interface_name }, metric };\n        tracing::info!(message_id = \"IBDybsCC\", \"found default route: {default_route:?}\");\n        if highest_priority_default_route.as_ref().is_none_or(|current| metric < current.metric) {\n            highest_priority_default_route = Some(default_route)\n        }\n    }\n    Ok(highest_priority_default_route.map(|r| r.network_interface))\n}\n\n#[derive(Debug)]\nstruct DefaultRoute {\n    network_interface: NetworkInterface,\n    metric: u32,\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/linux/service_lock.rs",
    "content": "use crate::service::os::linux::start_error::LinuxServiceStartError;\nuse std::fs::{File, TryLockError};\nuse std::io::ErrorKind;\n\nconst LOCK_PATH: &str = \"/run/obscura.lock\";\n\npub struct ServiceLock {\n    _file: File,\n}\n\nimpl ServiceLock {\n    pub fn new() -> Result<Self, LinuxServiceStartError> {\n        let mut options = File::options();\n        options.create(true).write(true).read(true);\n        let file = options.open(LOCK_PATH).map_err(|error| {\n            tracing::error!(message_id = \"muFNujy4\", ?error, \"failed to create or open lock file: {error}\");\n            match error.kind() {\n                ErrorKind::PermissionDenied => LinuxServiceStartError::InsufficientPermissions,\n                _ => anyhow::Error::new(error).context(\"failed to create or open lock file\").into(),\n            }\n        })?;\n        file.try_lock().map_err(|error| {\n            tracing::error!(message_id = \"wwkKzjFi\", ?error, \"failed to take exclusive lock on lock file: {error}\");\n            match error {\n                TryLockError::WouldBlock => LinuxServiceStartError::AlreadyRunning,\n                error => anyhow::Error::new(error).context(\"failed to take exclusive lock on lock file\").into(),\n            }\n        })?;\n        Ok(Self { _file: file })\n    }\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/linux/start_error.rs",
    "content": "#[derive(thiserror::Error, Debug)]\npub enum LinuxServiceStartError {\n    #[error(\"Insufficient permissions to start service. Usually requires root.\")]\n    InsufficientPermissions,\n    #[error(\"Another instance of Obscura VPN is already running.\")]\n    AlreadyRunning,\n    #[error(\"No supported DNS manager detected.\")]\n    NoDnsManager,\n    #[error(\"Unexpected error. Details: {0}\")]\n    Unexpected(#[from] anyhow::Error),\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/linux/tun.rs",
    "content": "use bytes::Bytes;\nuse ipnetwork::Ipv6Network;\nuse obscuravpn_client::net::NetworkInterface;\nuse obscuravpn_client::network_config::{DnsContentBlock, OsNetworkConfig};\nuse obscuravpn_client::os::packet_buffer::PacketBuffer;\nuse obscuravpn_client::positive_u31::PositiveU31;\nuse obscuravpn_client::rate_limited_log;\nuse std::io::ErrorKind::{AlreadyExists, WouldBlock};\nuse std::net::{IpAddr, Ipv4Addr};\nuse std::sync::{Arc, Mutex};\n\nuse obscuravpn_client::quicwg::QuicWgConnPacketSender;\nuse obscuravpn_client::tokio::AbortOnDrop;\nuse std::time::Duration;\n\nconst TUN_MIN_LOG_SILENCE: Duration = Duration::from_secs(5);\nconst TUN_NAME: &str = \"obscuravpn\";\n\npub struct Tun {\n    dev: Arc<tun_rs::AsyncDevice>,\n    interface_index: PositiveU31,\n    read_task: Mutex<Option<AbortOnDrop>>,\n}\n\nimpl Tun {\n    pub async fn create() -> anyhow::Result<Self> {\n        let network_config = OsNetworkConfig::dummy(DnsContentBlock::default(), false);\n        let dev = Arc::new(\n            tun_rs::DeviceBuilder::new()\n                // NetworkManager classifies new TUN devices without assigned IPs as `NM_DEVICE_STATE_UNMANAGED` instead of just externally connected and refuses all device configuration interactions. As initial state this is harmless in tested versions, but avoiding the state is simpler and may be safer.\n                .ipv4(network_config.ipv4, 32u8, None)\n                .ipv6(network_config.ipv6.network(), network_config.ipv6.prefix())\n                .mtu(network_config.mtu)\n                .name(TUN_NAME.to_string())\n                .build_async()?,\n        );\n        let interface_index = dev.if_index()?.try_into()?;\n        Ok(Self { dev, interface_index, read_task: Mutex::new(None) })\n    }\n\n    pub fn interface(&self) -> NetworkInterface {\n        NetworkInterface { name: TUN_NAME.to_string(), index: self.interface_index }\n    }\n\n    pub fn send(&self, packet: Bytes) {\n        if let Err(error) = self.dev.try_send(&packet)\n            && error.kind() != WouldBlock\n        {\n            rate_limited_log!(\n                TUN_MIN_LOG_SILENCE,\n                tracing::error!(message_id = \"4nG6rvr3\", ?error, \"failed to send packet on tun device\")\n            );\n        }\n    }\n\n    async fn receive(dev: &tun_rs::AsyncDevice, packet_buffer: &mut PacketBuffer) {\n        if let Err(error) = dev.readable().await {\n            rate_limited_log!(\n                TUN_MIN_LOG_SILENCE,\n                tracing::error!(message_id = \"YRah33os\", ?error, \"failed to wait for packet on tun device\")\n            )\n        }\n        while let Some(buffer) = packet_buffer.buffer() {\n            match dev.try_recv(buffer) {\n                Ok(n) => match u16::try_from(n) {\n                    Ok(n) => packet_buffer.commit(n),\n                    Err(_) => rate_limited_log!(\n                        TUN_MIN_LOG_SILENCE,\n                        tracing::error!(message_id = \"A1s4jdil\", \"ignoring oversized packet from tun device\")\n                    ),\n                },\n                Err(error) if error.kind() == WouldBlock => return,\n                Err(error) => rate_limited_log!(\n                    TUN_MIN_LOG_SILENCE,\n                    tracing::error!(message_id = \"uGIH5zSb\", ?error, \"failed to receive from tun device\")\n                ),\n            }\n        }\n    }\n\n    pub fn set_config(&self, mtu: u16, ipv4: Ipv4Addr, ipv6: Ipv6Network) -> Result<(), ()> {\n        let mut result = Ok(());\n\n        // Add new IPs before removing the current ones. This prevents having no addresses on the device temporarily, which may trigger automatic network manager device state changes with unintended side effects on DNS and routes.\n\n        if let Err(error) = self.dev.set_mtu(mtu) {\n            tracing::error!(message_id = \"qPppmh83\", ?error, \"failed to set tun mtu\");\n            result = Err(());\n        }\n        if let Err(error) = self.dev.add_address_v4(ipv4, 32u8)\n            && error.kind() != AlreadyExists\n        {\n            tracing::error!(message_id = \"cY11X3I6\", ?error, address = ?ipv4, \"failed to add IPv4 tun address\");\n            result = Err(());\n        }\n        if let Err(error) = self.dev.add_address_v6(ipv6.network(), ipv6.prefix())\n            && error.kind() != AlreadyExists\n        {\n            tracing::error!(message_id = \"wHod6P2h\", ?error, address = ?ipv6, \"failed to add IPv6 tun address\");\n            result = Err(());\n        }\n\n        match self.dev.addresses() {\n            Ok(addresses) => {\n                for address in addresses {\n                    let keep = match address {\n                        IpAddr::V4(address) => address == ipv4,\n                        IpAddr::V6(address) => ipv6.contains(address),\n                    };\n                    if keep {\n                        continue;\n                    }\n                    if let Err(error) = self.dev.remove_address(address) {\n                        tracing::error!(message_id = \"qPppmh83\", ?error, ?address, \"failed to remove tun address\");\n                        result = Err(());\n                    }\n                }\n            }\n            Err(error) => {\n                tracing::error!(message_id = \"1SDywPMm\", ?error, \"failed to retrieve tun addresses\");\n                result = Err(());\n            }\n        }\n        result\n    }\n\n    pub fn spawn_read_task(&self, tunnel: QuicWgConnPacketSender) {\n        let mut read_task = self.read_task.lock().unwrap();\n        let dev = self.dev.clone();\n        *read_task = Some(AbortOnDrop::spawn(async move {\n            let mut packet_buffer = PacketBuffer::default();\n            loop {\n                Self::receive(&dev, &mut packet_buffer).await;\n                tunnel.send(packet_buffer.take_iter());\n            }\n        }));\n    }\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/mod.rs",
    "content": "#[cfg(target_os = \"linux\")]\npub mod linux;\n#[cfg(target_os = \"windows\")]\npub mod windows;\n\nuse ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network};\nuse std::net::{Ipv4Addr, Ipv6Addr};\n\n/// The individual routes cover half of the respective address space, which gives them priority over the default route without replacing it. We don't want to replace the default route, because:\n/// - We use the default route for preferred network interface discovery\n/// - We wouldn't know what the set it to when the tunnel is disabled\n/// - Network management services like network manager tend to overwrite it.\npub const ROUTES: [IpNetwork; 4] = [\n    IpNetwork::V4(Ipv4Network::new_checked(Ipv4Addr::new(000, 0, 0, 0), 1).unwrap()),\n    IpNetwork::V4(Ipv4Network::new_checked(Ipv4Addr::new(128, 0, 0, 0), 1).unwrap()),\n    IpNetwork::V6(Ipv6Network::new_checked(Ipv6Addr::new(0x0000, 0, 0, 0, 0, 0, 0, 0), 1).unwrap()),\n    IpNetwork::V6(Ipv6Network::new_checked(Ipv6Addr::new(0x8000, 0, 0, 0, 0, 0, 0, 0), 1).unwrap()),\n];\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/windows/adapters.rs",
    "content": "use obscuravpn_client::net::NetworkInterface;\nuse obscuravpn_client::positive_u31::PositiveU31;\nuse std::net::{IpAddr, Ipv4Addr};\nuse std::time::Duration;\nuse tokio::sync::watch::{Receiver, Sender, channel};\nuse windows::Win32::Foundation::CloseHandle;\nuse windows::Win32::Foundation::{ERROR_IO_PENDING, HANDLE};\nuse windows::Win32::NetworkManagement::IpHelper::{\n    CancelIPChangeNotify, GetIfEntry2, IF_TYPE_ETHERNET_CSMACD, IF_TYPE_IEEE80211, MIB_IF_ROW2, NotifyAddrChange,\n};\nuse windows::Win32::NetworkManagement::Ndis::IfOperStatusUp;\nuse windows::Win32::Networking::WinSock::{AF_INET, SOCKADDR_IN};\nuse windows::Win32::System::IO::OVERLAPPED;\nuse windows::Win32::System::Threading::{CreateEventW, INFINITE, ResetEvent, WaitForSingleObject};\n\nuse crate::service::os::windows::gaa::GAABufferInit;\n\nconst WATCH_ERROR_BACKOFF: Duration = Duration::from_secs(1);\n\npub fn watch_active_adapter() -> Receiver<Option<NetworkInterface>> {\n    let (sender, receiver) = channel(None);\n    std::thread::spawn(move || watch_active_adapter_thread(&sender));\n    receiver\n}\n\nfn watch_active_adapter_thread(sender: &Sender<Option<NetworkInterface>>) {\n    loop {\n        watch_addr_changes(sender);\n        std::thread::sleep(WATCH_ERROR_BACKOFF);\n    }\n}\n\n/// Creates an event handle, watches for address changes, and cleans up. Returns when an error occurs.\nfn watch_addr_changes(sender: &Sender<Option<NetworkInterface>>) {\n    // SAFETY: `CreateEventW` with all-default/null parameters creates an anonymous,\n    // manual-reset event. No unsafe preconditions beyond a valid call.\n    let event_handle = match unsafe { CreateEventW(None, true, false, None) } {\n        Err(error) => {\n            tracing::error!(message_id = \"aB3kW9xP\", ?error, \"CreateEventW failed\");\n            return;\n        }\n        Ok(event_handle) => event_handle,\n    };\n\n    let overlapped = OVERLAPPED { hEvent: event_handle, ..Default::default() };\n    let mut notify_handle = HANDLE::default();\n\n    loop {\n        // SAFETY: `event_handle` is a valid event handle created by `CreateEventW`.\n        if let Err(error) = unsafe { ResetEvent(event_handle) } {\n            tracing::error!(message_id = \"pvY8miRB\", ?error, \"ResetEvent failed\");\n            break;\n        }\n\n        // SAFETY: `overlapped.hEvent` is the same valid event handle. `notify_handle` is\n        // an out-parameter that receives the notification handle; \"Warning Do not close this handle\"\n        let ret = unsafe { NotifyAddrChange(&mut notify_handle, &overlapped) };\n        // The return value would only be zero if both params are NULL\n        if ret != ERROR_IO_PENDING.0 {\n            tracing::error!(message_id = \"x0HwRYyz\", ret, \"NotifyAddrChange failed\");\n            break;\n        }\n\n        // Get adapter AFTER subscribing but before waiting to avoid race conditions\n        let Ok(adapter) = get_active_physical_adapter() else {\n            // SAFETY: overlapped is on the stack\n            if !unsafe { CancelIPChangeNotify(&overlapped) }.as_bool() {\n                // Should not occur. Indicates missing notification, invalid overlapped, or\n                // insufficient error handling of NotifyAddrChange\n                tracing::error!(message_id = \"1uP30TS8\", \"could not deregister change notification\");\n            }\n            break;\n        };\n        sender.send_if_modified(|current| {\n            if *current != adapter {\n                tracing::info!(message_id = \"3vxyU7ra\", ?current, ?adapter, \"preferred network interface changed\");\n                *current = adapter;\n                true\n            } else {\n                false\n            }\n        });\n\n        // SAFETY: `event_handle` is a valid event handle;\n        // `INFINITE` timeout means this blocks until the event is signalled by `NotifyAddrChange`.\n        let event = unsafe { WaitForSingleObject(event_handle, INFINITE) };\n        if event.0 != 0 {\n            tracing::error!(message_id = \"538dQYke\", event = event.0, \"WaitForSingleObject failed\");\n            break;\n        }\n    }\n\n    if let Err(error) = unsafe { CloseHandle(event_handle) } {\n        tracing::warn!(message_id = \"oLmLePMW\", ?error, \"failed to close event handle\");\n    }\n}\n\npub struct PhysicalAdapter {\n    name: String,\n    index: u32,\n    ip: IpAddr,\n    pub mtu: u32,\n}\n\n/// Walk the adapter list returned by `GetAdaptersAddresses` and return the\n/// first active, hardware, ethernet/wifi adapter that has a default gateway\n/// and an IPv4 address.\npub fn find_active_physical_adapter() -> Result<Option<PhysicalAdapter>, ()> {\n    let Some(gaa) = GAABufferInit::new()? else {\n        return Ok(None);\n    };\n\n    let mut current = gaa.first;\n\n    while !current.is_null() {\n        // SAFETY: `current` points into pre-allocated `buffer`\n        // The buffer was populated by `GetAdaptersAddresses` which guarantees a\n        // valid linked list of `IP_ADAPTER_ADDRESSES_LH` structs.\n        let adapter = unsafe { &*current };\n\n        let if_type = adapter.IfType;\n        // ethernet or wifi\n        let is_media_type_ok = if_type == IF_TYPE_ETHERNET_CSMACD || if_type == IF_TYPE_IEEE80211;\n        let is_up = adapter.OperStatus == IfOperStatusUp;\n\n        let mut is_hardware = false;\n        if is_media_type_ok {\n            // SAFETY: Although these are union layers, it is a Microsoft pattern for memory alignment and \"bulk copying.\"\n            // Rather than \"one or the other\" field being defined, the intended field (e.g. IfIndex) is always defined and the\n            // \"Alignment\" field exists to align the structure as well as for copying data at once.\n            // https://learn.microsoft.com/windows/win32/api/iptypes/ns-iptypes-ip_adapter_addresses_lh\n            let mut row = MIB_IF_ROW2 { InterfaceIndex: unsafe { adapter.Anonymous1.Anonymous.IfIndex }, ..Default::default() };\n            // SAFETY: `row.InterfaceIndex` was set above; `GetIfEntry2` populates the\n            // remaining fields of the struct.\n            let res = unsafe { GetIfEntry2(&mut row) };\n            // https://learn.microsoft.com/windows/win32/api/netioapi/ns-netioapi-mib_if_row2\n            is_hardware = res.0 == 0 && (row.InterfaceAndOperStatusFlags._bitfield & 1/* HardwareInterface */) != 0;\n        }\n\n        if is_media_type_ok && is_hardware && is_up && !adapter.FirstGatewayAddress.is_null() {\n            let mut unicast = adapter.FirstUnicastAddress;\n\n            while !unicast.is_null() {\n                // SAFETY: `unicast` is non-null (checked above) and points into the adapter\n                // linked list populated by `GetAdaptersAddresses`.\n                let ua = unsafe { &*unicast };\n                // SAFETY: `lpSockaddr` is a valid pointer set by `GetAdaptersAddresses` for\n                // each unicast address entry.\n                let sockaddr = unsafe { &*ua.Address.lpSockaddr };\n\n                if sockaddr.sa_family == AF_INET {\n                    // SAFETY: `FriendlyName` is a valid null-terminated wide string set by\n                    // `GetAdaptersAddresses`.\n                    let name = match unsafe { adapter.FriendlyName.to_string() } {\n                        Ok(name) => name,\n                        Err(error) => {\n                            tracing::error!(message_id = \"cNWuYmcV\", ?error, \"failed to read adapter friendly name\");\n                            return Err(());\n                        }\n                    };\n                    // SAFETY: Union access — see the SAFETY comment on the identical access above.\n                    let index = unsafe { adapter.Anonymous1.Anonymous.IfIndex };\n                    // SAFETY: We verified `sa_family == AF_INET`, so the sockaddr can be casted to a `SOCKADDR_IN`.\n                    // The pointer is valid for the lifetime of `buffer`.\n                    // Read more: https://learn.microsoft.com/windows/win32/api/ws2def/ns-ws2def-socket_address\n                    let sa_in = unsafe { &*(ua.Address.lpSockaddr as *const SOCKADDR_IN) };\n                    // SAFETY: Union access — `S_addr` is the raw u32 representation of the IPv4\n                    // address, which is always valid to read when the family is `AF_INET`.\n                    let ipv4 = Ipv4Addr::from_bits(u32::from_be(unsafe { sa_in.sin_addr.S_un.S_addr }));\n                    return Ok(Some(PhysicalAdapter { name, index, ip: IpAddr::V4(ipv4), mtu: adapter.Mtu }));\n                }\n\n                unicast = ua.Next;\n            }\n        }\n\n        current = adapter.Next;\n    }\n    Ok(None)\n}\n\nfn get_active_physical_adapter() -> Result<Option<NetworkInterface>, ()> {\n    let Some(adapter) = find_active_physical_adapter()? else {\n        tracing::info!(message_id = \"E7scsGZH\", \"did not find an active adapter\");\n        return Ok(None);\n    };\n\n    let index = PositiveU31::try_from(adapter.index).map_err(|error| {\n        tracing::error!(\n            message_id = \"KRgY0doM\",\n            ?error,\n            index = adapter.index,\n            \"adapter index out of range for PositiveU31\"\n        )\n    })?;\n    let mtu: i32 = adapter\n        .mtu\n        .try_into()\n        .map_err(|error| tracing::error!(message_id = \"TDYf7bGF\", ?error, mtu = adapter.mtu, \"adapter MTU out of range for i32\"))?;\n    Ok(Some(NetworkInterface { name: adapter.name, index, ip: adapter.ip, mtu }))\n}\n\n#[test]\nfn test_not_wsl_vethernet() {\n    let physical_adapter = get_active_physical_adapter().unwrap().unwrap();\n    assert!(!physical_adapter.name.contains(\"vEthernet\"));\n    println!(\"{physical_adapter:?}\");\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/windows/gaa.rs",
    "content": "use std::mem::MaybeUninit;\n\nuse windows::Win32::{\n    Foundation::{ERROR_BUFFER_OVERFLOW, ERROR_NO_DATA},\n    NetworkManagement::IpHelper::{GAA_FLAG_INCLUDE_GATEWAYS, GAA_FLAG_INCLUDE_PREFIX, GetAdaptersAddresses, IP_ADAPTER_ADDRESSES_LH},\n    Networking::WinSock::AF_INET,\n};\n\npub struct GAABufferInit {\n    _buffer: Box<[MaybeUninit<IP_ADAPTER_ADDRESSES_LH>]>,\n    pub first: *const IP_ADAPTER_ADDRESSES_LH,\n}\n\nimpl GAABufferInit {\n    pub fn new() -> Result<Option<Self>, ()> {\n        let family = AF_INET.0 as u32;\n        // See all GAA_FLAG's here\n        // https://microsoft.github.io/windows-docs-rs/doc/windows/Win32/NetworkManagement/IpHelper/\n        let flags = GAA_FLAG_INCLUDE_PREFIX | GAA_FLAG_INCLUDE_GATEWAYS;\n\n        // It's recommended to pre-allocate a 15KB working buffer\n        // https://learn.microsoft.com/windows/win32/api/iphlpapi/nf-iphlpapi-getadaptersaddresses#remarks\n        let mut buf_len = 15_000u32;\n        let struct_size = std::mem::size_of::<IP_ADAPTER_ADDRESSES_LH>();\n        let mut buffer: Box<[MaybeUninit<IP_ADAPTER_ADDRESSES_LH>]>;\n\n        let first = loop {\n            let capacity = (buf_len as usize / struct_size) + 1;\n            buffer = Box::new_uninit_slice(capacity);\n            let start_of_buffer = buffer.as_mut_ptr() as *mut IP_ADAPTER_ADDRESSES_LH;\n\n            // SAFETY: `buffer` has size `capacity` which in bytes is greater than `buf_len`\n            // `GetAdaptersAddresses` will only write within buf_len and otherwise reports `ERROR_BUFFER_OVERFLOW` (and sets `buf_len`)\n            let ret = unsafe { GetAdaptersAddresses(family, flags, None, Some(start_of_buffer), &mut buf_len) };\n\n            if ret == ERROR_BUFFER_OVERFLOW.0 {\n                // `buf_len` has been updated to the required size; retry with a larger buffer.\n                continue;\n            }\n\n            if ret == ERROR_NO_DATA.0 {\n                return Ok(None);\n            }\n\n            if ret != 0 {\n                tracing::error!(message_id = \"p5n35MKN\", ret, \"GetAdaptersAddresses failed\");\n                return Err(());\n            }\n\n            break start_of_buffer;\n        };\n        Ok(Some(Self { _buffer: buffer, first }))\n    }\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/windows/iphelper.rs",
    "content": "use crate::service::os::ROUTES;\nuse ipnetwork::{IpNetwork, Ipv6Network};\nuse std::mem::MaybeUninit;\nuse std::net::{IpAddr, Ipv4Addr, Ipv6Addr};\nuse windows::Win32::Foundation::{ERROR_NOT_FOUND, ERROR_OBJECT_ALREADY_EXISTS, NO_ERROR};\nuse windows::Win32::NetworkManagement::IpHelper::{CreateIpForwardEntry2, DeleteIpForwardEntry2, InitializeIpForwardEntry, MIB_IPFORWARD_ROW2};\nuse windows::Win32::NetworkManagement::IpHelper::{\n    DNS_INTERFACE_SETTINGS, DNS_INTERFACE_SETTINGS_VERSION1, DNS_SETTING_NAMESERVER, SetInterfaceDnsSettings,\n};\nuse windows::Win32::NetworkManagement::IpHelper::{GetIpInterfaceEntry, MIB_IPINTERFACE_ROW, SetIpInterfaceEntry};\nuse windows::Win32::Networking::WinSock::{ADDRESS_FAMILY, AF_INET, AF_INET6, IN_ADDR, IN6_ADDR, IN6_ADDR_0, SOCKADDR_IN, SOCKADDR_IN6};\nuse windows::Win32::UI::Shell::SHGetKnownFolderPath;\n\npub fn add_routes(adapter: &wintun::Adapter) -> Result<(), ()> {\n    let if_index = adapter\n        .get_adapter_index()\n        .map_err(|error| tracing::error!(message_id = \"Xt7kR2mN\", ?error, \"failed to get adapter index for adding routes\"))?;\n\n    let mut result = Ok(());\n    for route in &ROUTES {\n        result = result.and(add_route(if_index, route));\n    }\n    result\n}\n\npub fn remove_routes(adapter: &wintun::Adapter) -> Result<(), ()> {\n    let if_index = adapter\n        .get_adapter_index()\n        .map_err(|error| tracing::error!(message_id = \"Yt8lS3nP\", ?error, \"failed to get adapter index for removing routes\"))?;\n\n    let mut result = Ok(());\n    for route in &ROUTES {\n        result = result.and(remove_route(if_index, route));\n    }\n    result\n}\n\n/// Build a `MIB_IPFORWARD_ROW2` for the given interface, destination, and prefix length.\n/// The next hop is set to unspecified (all zeros) — traffic goes directly to the tunnel interface.\nfn build_forward_row(if_index: u32, dest: IpAddr, prefix_len: u8) -> MIB_IPFORWARD_ROW2 {\n    let mut row = MIB_IPFORWARD_ROW2::default();\n    // SAFETY: `row` is an OUT param; zeroed defensively.\n    unsafe { InitializeIpForwardEntry(&mut row) };\n\n    row.InterfaceIndex = if_index;\n    row.ValidLifetime = u32::MAX;\n    row.PreferredLifetime = u32::MAX;\n    row.DestinationPrefix.PrefixLength = prefix_len;\n\n    match dest {\n        IpAddr::V4(addr) => {\n            row.DestinationPrefix.Prefix.Ipv4 = make_sockaddr_in(addr);\n            row.NextHop.Ipv4 = make_sockaddr_in(Ipv4Addr::UNSPECIFIED);\n        }\n        IpAddr::V6(addr) => {\n            row.DestinationPrefix.Prefix.Ipv6 = make_sockaddr_in6(addr);\n            row.NextHop.Ipv6 = make_sockaddr_in6(Ipv6Addr::UNSPECIFIED);\n        }\n    }\n\n    row\n}\n\nfn add_route(if_index: u32, route: &IpNetwork) -> Result<(), ()> {\n    let row = build_forward_row(if_index, route.ip(), route.prefix());\n    // SAFETY: `row` is a valid, fully initialized `MIB_IPFORWARD_ROW2` built by\n    // `build_forward_row`. `CreateIpForwardEntry2` only reads the pointed-to struct.\n    let win32_error = unsafe { CreateIpForwardEntry2(&row) };\n    if win32_error.is_ok() {\n        tracing::info!(message_id = \"VoLYoYNn\", route = %route, \"added route\");\n        Ok(())\n    } else if win32_error == ERROR_OBJECT_ALREADY_EXISTS {\n        Ok(())\n    } else {\n        tracing::error!(\n            message_id = \"Qv3bW8cJ\",\n            ?win32_error,\n            route = %route,\n            \"failed to add route\"\n        );\n        Err(())\n    }\n}\n\nfn remove_route(if_index: u32, route: &IpNetwork) -> Result<(), ()> {\n    let row = build_forward_row(if_index, route.ip(), route.prefix());\n    // SAFETY: `row` is a valid, fully initialized `MIB_IPFORWARD_ROW2` built by\n    // `build_forward_row`. `DeleteIpForwardEntry2` only reads the pointed-to struct.\n    let win32_error = unsafe { DeleteIpForwardEntry2(&row) };\n    if win32_error.is_ok() {\n        tracing::info!(message_id = \"UUNppVZg\", route = %route, \"removed route\");\n        Ok(())\n    } else if win32_error == ERROR_NOT_FOUND {\n        Ok(())\n    } else {\n        tracing::error!(\n            message_id = \"Rv4cW9dK\",\n            ?win32_error,\n            route = %route,\n            \"failed to remove route\"\n        );\n        Err(())\n    }\n}\n\nfn make_sockaddr_in(addr: Ipv4Addr) -> SOCKADDR_IN {\n    SOCKADDR_IN {\n        sin_family: ADDRESS_FAMILY(AF_INET.0),\n        sin_addr: IN_ADDR { S_un: windows::Win32::Networking::WinSock::IN_ADDR_0 { S_addr: addr.to_bits().to_be() } },\n        ..Default::default()\n    }\n}\n\nfn make_sockaddr_in6(addr: Ipv6Addr) -> SOCKADDR_IN6 {\n    SOCKADDR_IN6 {\n        sin6_family: ADDRESS_FAMILY(AF_INET6.0),\n        sin6_addr: IN6_ADDR { u: IN6_ADDR_0 { Byte: addr.octets() } },\n        ..Default::default()\n    }\n}\n\nfn family_name(family: ADDRESS_FAMILY) -> &'static str {\n    if family == AF_INET {\n        \"IPv4\"\n    } else if family == AF_INET6 {\n        \"IPv6\"\n    } else {\n        \"unknown\"\n    }\n}\n\nfn set_metric(adapter: &wintun::Adapter, automatic: bool, metric: u32) -> Result<(), ()> {\n    let luid = adapter.get_luid();\n    let mut success = Ok(());\n    for family in [AF_INET, AF_INET6] {\n        let mut row = MIB_IPINTERFACE_ROW {\n            Family: family,\n            InterfaceLuid: {\n                // SAFETY: Accessing `Value` field of a union to copy the raw 64-bit LUID\n                windows::Win32::NetworkManagement::Ndis::NET_LUID_LH { Value: unsafe { luid.Value } }\n            },\n            ..Default::default()\n        };\n\n        // SAFETY: `row` is a properly initialized `MIB_IPINTERFACE_ROW` with `Family` and\n        // `InterfaceLuid` set. `GetIpInterfaceEntry` reads those fields and fills the rest.\n        let result = unsafe { GetIpInterfaceEntry(&mut row) };\n        if result != NO_ERROR {\n            tracing::error!(\n                message_id = \"nVRsbT3w\",\n                error_code = result.0,\n                family = family_name(family),\n                \"GetIpInterfaceEntry failed\"\n            );\n            success = Err(());\n            continue;\n        }\n\n        // https://learn.microsoft.com/windows/win32/api/netioapi/ns-netioapi-mib_ipinterface_row\n        // For IPv4, SitePrefixLength is set to 64 by GetIpInterfaceEntry.\n        // This is illegal as the max value is 32 for IPv4.\n        // This hiccup has been documented by Wireguard and on StackOverflow\n        // https://github.com/WireGuard/wireguard-windows/blob/0f52c8d37528e2a768a2f63472656bc93bc4546f/tunnel/winipcfg/types.go#L666C5-L666C114\n        // For IPv4, SitePrefixLength \"must be set to 0\".\n        row.SitePrefixLength = if family == AF_INET { 0 } else { 128 };\n        row.UseAutomaticMetric = automatic;\n        row.Metric = metric;\n\n        // SAFETY: `row` was successfully populated by `GetIpInterfaceEntry` and we have only\n        // modified documented fields. `SetIpInterfaceEntry` writes the updated row back.\n        // https://learn.microsoft.com/windows/win32/api/netioapi/nf-netioapi-setipinterfaceentry#remarks\n        let result = unsafe { SetIpInterfaceEntry(&mut row) };\n        if result != NO_ERROR {\n            tracing::error!(\n                message_id = \"Viabd7Fj\",\n                interfaceLuid = unsafe { row.InterfaceLuid.Value },\n                error_code = result.0,\n                family = family_name(family),\n                metric,\n                \"SetIpInterfaceEntry failed\"\n            );\n            success = Err(());\n        }\n    }\n    success\n}\n\npub fn set_low_metric(adapter: &wintun::Adapter) -> Result<(), ()> {\n    set_metric(adapter, false, 1)?;\n    tracing::info!(message_id = \"frfGU26w\", \"Successfully set interface metric to 1\");\n    Ok(())\n}\n\npub fn reset_interface_metric(adapter: &wintun::Adapter) -> Result<(), ()> {\n    set_metric(adapter, true, 0)?;\n    tracing::info!(message_id = \"5EdQ1ti3\", \"Successfully reset interface metric to automatic\");\n    Ok(())\n}\n\nfn get_system_directory() -> std::path::PathBuf {\n    // SAFETY: `SHGetKnownFolderPath` is called with valid parameters. The returned PWSTR\n    // is a COM-allocated wide string that we convert and then free with `CoTaskMemFree`.\n    let result = unsafe {\n        SHGetKnownFolderPath(\n            &windows::Win32::UI::Shell::FOLDERID_System,\n            windows::Win32::UI::Shell::KNOWN_FOLDER_FLAG::default(),\n            None,\n        )\n    };\n    if let Ok(pwstr) = result {\n        let wide = unsafe { pwstr.to_string() };\n        unsafe { windows::Win32::System::Com::CoTaskMemFree(Some(pwstr.0 as *const _)) };\n        if let Ok(s) = wide {\n            return std::path::PathBuf::from(s);\n        }\n    }\n    tracing::warn!(message_id = \"Jw2xN5qR\", \"SHGetKnownFolderPath failed, falling back to SystemRoot env\");\n    std::path::PathBuf::from(std::env::var(\"SystemRoot\").unwrap_or_else(|_| r\"C:\\Windows\".to_string())).join(\"System32\")\n}\n\nasync fn run_command(cmd: &mut tokio::process::Command, friendly_name: &str, message_id: &str) -> Result<(), ()> {\n    let output = cmd.output().await.map_err(|error| {\n        tracing::error!(message_id = \"tFgqHB7v\", ?error, friendly_name, \"failed to spawn command\");\n    })?;\n    if output.status.success() {\n        Ok(())\n    } else {\n        let stdout = String::from_utf8_lossy(&output.stdout);\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        tracing::error!(message_id = message_id, %stdout, %stderr, friendly_name, \"command failed\");\n        Err(())\n    }\n}\n\npub async fn set_ipv4_address(adapter: &wintun::Adapter, ipv4: Ipv4Addr) -> Result<(), ()> {\n    let name = adapter\n        .get_name()\n        .map_err(|error| tracing::error!(message_id = \"fUqTnRC8\", ?error, \"failed to get adapter name\"))?;\n    let netsh = get_system_directory().join(\"netsh.exe\");\n    let mut cmd = tokio::process::Command::new(&netsh);\n    cmd.args([\"interface\", \"ipv4\", \"set\", \"address\", &name, \"source=static\"])\n        .arg(format!(\"address={ipv4}\"))\n        .arg(\"mask=255.255.255.255\");\n    run_command(&mut cmd, \"netsh for IPv4\", \"2Yndy7Y5\").await\n}\n\npub async fn set_ipv6_address(adapter: &wintun::Adapter, ipv6: Ipv6Network) -> Result<(), ()> {\n    let name = adapter\n        .get_name()\n        .map_err(|error| tracing::error!(message_id = \"k3mRv8wQ\", ?error, \"failed to get adapter name\"))?;\n    let netsh = get_system_directory().join(\"netsh.exe\");\n    let mut cmd = tokio::process::Command::new(&netsh);\n    cmd.args([\"interface\", \"ipv6\", \"set\", \"address\", &name])\n        .arg(format!(\"address={}/{}\", ipv6.ip(), ipv6.prefix()));\n    run_command(&mut cmd, \"netsh for IPv6\", \"p7nWx2kF\").await\n}\n\npub async fn set_mtu(adapter: &wintun::Adapter, mtu: u16) -> Result<(), ()> {\n    let name = adapter\n        .get_name()\n        .map_err(|error| tracing::error!(message_id = \"gHUMlkA6\", ?error, \"failed to get adapter name for MTU\"))?;\n    let netsh = get_system_directory().join(\"netsh.exe\");\n\n    let mut result = Ok(());\n    for ip_str in [\"ipv4\", \"ipv6\"] {\n        let mut cmd = tokio::process::Command::new(&netsh);\n        cmd.args([\"interface\", ip_str, \"set\", \"subinterface\", &name])\n            .arg(format!(\"mtu={mtu}\"))\n            .arg(\"store=persistent\");\n        tracing::info!(message_id = \"HJLqy3YD\", mtu, \"setting mtu via netsh\");\n        result = result.and(run_command(&mut cmd, \"netsh set MTU\", \"2NrLhFYu\").await);\n    }\n    result\n}\n\n/// Set DNS servers on the adapter.\n/// First attempts the Windows API (`SetInterfaceDnsSettings`); Falls back to netsh.\npub async fn set_dns_servers(adapter: &wintun::Adapter, dns: &[IpAddr]) -> Result<(), ()> {\n    let guid = windows::core::GUID::from_u128(adapter.get_guid());\n    if let Err(error) = set_interface_dns_settings(guid, dns) {\n        tracing::warn!(message_id = \"OhrWluk1\", ?error, \"SetInterfaceDnsSettings failed, falling back to netsh\");\n        set_dns_servers_netsh(adapter, dns).await?;\n    }\n    Ok(())\n}\n\n// Available: Windows 10 Build 19041\nfn set_interface_dns_settings(interface: windows::core::GUID, dns: &[IpAddr]) -> Result<(), ()> {\n    let dns_str: String = dns.iter().map(|ip| ip.to_string()).collect::<Vec<_>>().join(\",\");\n    let dns_wide: Vec<u16> = dns_str.encode_utf16().chain(std::iter::once(0)).collect();\n\n    let settings = DNS_INTERFACE_SETTINGS {\n        Version: DNS_INTERFACE_SETTINGS_VERSION1,\n        Flags: DNS_SETTING_NAMESERVER as _,\n        NameServer: windows::core::PWSTR(dns_wide.as_ptr() as *mut _),\n        ..Default::default()\n    };\n\n    // SAFETY: `interface` is a valid GUID from the adapter. `settings` is a properly initialized\n    // `DNS_INTERFACE_SETTINGS` with a valid null-terminated UTF-16 `NameServer` pointer.\n    let result = unsafe { SetInterfaceDnsSettings(interface, &settings) };\n    if result == NO_ERROR {\n        Ok(())\n    } else {\n        tracing::error!(message_id = \"60E01Rf2\", error_code = result.0, \"SetInterfaceDnsSettings failed\");\n        Err(())\n    }\n}\n\nasync fn set_dns_servers_netsh(adapter: &wintun::Adapter, dns: &[IpAddr]) -> Result<(), ()> {\n    let mut result = Ok(());\n\n    let name = adapter\n        .get_name()\n        .map_err(|error| tracing::error!(message_id = \"MxyYv5ln\", ?error, \"failed to get adapter name for DNS\"))?;\n    let netsh = get_system_directory().join(\"netsh.exe\");\n\n    if dns.is_empty() {\n        for ip_str in [\"ipv4\", \"ipv6\"] {\n            let mut cmd = tokio::process::Command::new(&netsh);\n            cmd.args([\"interface\", ip_str, \"set\", \"dnsservers\", &name, \"static\", \"none\", \"register=both\"]);\n            result = result.and(run_command(&mut cmd, \"netsh set dns servers none\", \"YpBklPNr\").await);\n        }\n        return result;\n    }\n\n    let ip_str = if dns[0].is_ipv4() { \"ipv4\" } else { \"ipv6\" };\n\n    let mut cmd = tokio::process::Command::new(&netsh);\n    cmd.args([\"interface\", ip_str, \"set\", \"dnsservers\", &name, \"static\"])\n        .arg(format!(\"{}\", dns[0]))\n        .arg(\"register=both\")\n        .arg(\"validate=no\");\n    result = result.and(run_command(&mut cmd, \"netsh set dns servers\", \"jYNNDpN6\").await);\n\n    for (_, nameserver) in dns.iter().skip(1).enumerate() {\n        let ip_str = if nameserver.is_ipv4() { \"ipv4\" } else { \"ipv6\" };\n        let mut cmd = tokio::process::Command::new(&netsh);\n        cmd.args([\"interface\", ip_str, \"add\", \"dnsservers\", &name])\n            .arg(nameserver.to_string())\n            .arg(\"validate=no\");\n        result = result.and(run_command(&mut cmd, \"netsh add dns server\", \"920fvXuP\").await);\n    }\n\n    result\n}\n\npub async fn flush_dns_cache() -> Result<(), ()> {\n    let ipconfig = get_system_directory().join(\"ipconfig.exe\");\n    let mut cmd = tokio::process::Command::new(&ipconfig);\n    cmd.arg(\"/flushdns\");\n    run_command(&mut cmd, \"ipconfig /flushdns\", \"Gx5tL9nB\").await?;\n    tracing::info!(message_id = \"SnUcIehf\", \"successfully flushed DNS resolver cache\");\n    Ok(())\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/windows/mod.rs",
    "content": "pub mod nrpt;\nmod start_error;\npub mod tun;\n\nuse bytes::Bytes;\nuse obscuravpn_client::manager_cmd::{ManagerCmd, ManagerCmdErrorCode, ManagerCmdOk};\nuse obscuravpn_client::net::NetworkInterface;\nuse obscuravpn_client::network_config::OsNetworkConfig;\nuse obscuravpn_client::os::os_trait::Os;\nuse obscuravpn_client::quicwg::QuicWgConnPacketSender;\npub use start_error::WindowsServiceStartError;\nuse std::future::pending;\nuse std::sync::atomic::{AtomicBool, Ordering};\nuse tokio::sync::watch::Receiver;\nuse tun::Tun;\nmod adapters;\nmod gaa;\nmod iphelper;\n\npub struct WindowsOsImpl {\n    tun: Tun,\n    sent_start_command: AtomicBool,\n    active_adapter_watcher: Receiver<Option<NetworkInterface>>,\n}\n\nimpl WindowsOsImpl {\n    pub async fn new() -> Result<Self, WindowsServiceStartError> {\n        let tun = Tun::create().await?;\n        Ok(Self {\n            tun,\n            sent_start_command: Default::default(),\n            active_adapter_watcher: adapters::watch_active_adapter(),\n        })\n    }\n\n    pub async fn next_manager_command(&self) -> (ManagerCmd, Box<dyn FnOnce(Result<ManagerCmdOk, ManagerCmdErrorCode>) + Send>) {\n        if self.sent_start_command.swap(true, Ordering::SeqCst) {\n            pending().await\n        } else {\n            let cmd = ManagerCmd::SetTunnelArgs { args: None, active: Some(true) };\n            let result_callback = |result: Result<ManagerCmdOk, ManagerCmdErrorCode>| {\n                tracing::info!(message_id = \"ZuqhHDfS\", \"manager called result callback: {:?}\", result);\n            };\n            (cmd, Box::new(result_callback))\n        }\n    }\n}\n\nimpl Os for WindowsOsImpl {\n    fn network_interface(&self) -> Receiver<Option<NetworkInterface>> {\n        self.active_adapter_watcher.clone()\n    }\n\n    async fn set_os_network_config(&self, network_config: OsNetworkConfig, tunnel: QuicWgConnPacketSender) -> Result<(), ()> {\n        tracing::info!(message_id = \"HSSPAPbp\", \"manager called set_tunnel_network_config: {:?}\", network_config);\n        let result = self\n            .tun\n            .set_config(network_config.mtu, network_config.ipv4, network_config.ipv6, Some(network_config.dns))\n            .await;\n        self.tun.spawn_read_task(tunnel);\n        result\n    }\n\n    async fn unset_os_network_config(&self) -> Result<(), ()> {\n        tracing::info!(message_id = \"fPjdNl3o\", \"manager called unset_tunnel_network_config\");\n        self.tun.shutdown().await\n    }\n\n    fn packet_for_os(&self, packet: Bytes) {\n        self.tun.send(packet);\n    }\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/windows/nrpt.rs",
    "content": "use std::net::IpAddr;\nuse winreg::{RegKey, enums::HKEY_LOCAL_MACHINE, types::ToRegValue};\n\n// A fixed GUID ensures NRPT rules are cleaned up between runs without relying on the hardcoded Comment, Display arbitrary metadata.\nconst NRPT_RULE_GUID: &str = \"fb157da8-6578-4f53-81ea-0a9168e96c1f\";\n\nconst NRPT_COMMENT_VALUE: &str = \"Redirect all DNS queries to nameservers provided by Obscura VPN\";\nconst NRPT_DISPLAY_NAME_VALUE: &str = \"Obscura VPN Rule\";\n\nconst NRPT_RULES_PATH: &str = r\"SYSTEM\\CurrentControlSet\\Services\\Dnscache\\Parameters\\DnsPolicyConfig\";\n\nfn get_rule_path() -> String {\n    format!(r\"{NRPT_RULES_PATH}\\{{{NRPT_RULE_GUID}}}\")\n}\n\nfn set_nrpt_value(key: &RegKey, name: &str, value: &impl ToRegValue) -> Result<(), ()> {\n    key.set_value(name, value)\n        .map_err(|error| tracing::error!(message_id = \"KhjZ3ZuG\", ?error, name, \"failed to set NRPT registry value\"))\n}\n\n/// Creates an NRPT rule that forces all DNS queries (domain \".\") through the specified name servers.\npub fn create_rule(name_servers: &[IpAddr]) -> Result<(), ()> {\n    if name_servers.is_empty() {\n        tracing::warn!(message_id = \"lvns9NsX\", \"no name_servers provided\");\n        return Err(());\n    }\n    let name_servers_str = name_servers.iter().map(|ip| ip.to_string()).collect::<Vec<_>>().join(\";\");\n\n    let rule_path = get_rule_path();\n\n    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);\n    let (rule_key, _disposition) = hklm\n        .create_subkey(&rule_path)\n        .map_err(|error| tracing::error!(message_id = \"zKKMdYSA\", ?error, rule_path, \"failed to create NRPT registry subkey\"))?;\n    tracing::debug!(?rule_key, rule_path, \"opened NRPT registry subkey\");\n    set_nrpt_value(&rule_key, \"Comment\", &NRPT_COMMENT_VALUE)?;\n    set_nrpt_value(&rule_key, \"DisplayName\", &NRPT_DISPLAY_NAME_VALUE)?;\n    // See Section 2.2.2.2 of MS-GPNRPT,\n    // https://learn.microsoft.com/openspecs/windows_protocols/ms-gpnrpt/8cc31cb9-20cb-4140-9e85-3e08703b4745\n    set_nrpt_value(&rule_key, \"ConfigOptions\", &8u32)?;\n    set_nrpt_value(&rule_key, \"GenericDNSServers\", &name_servers_str)?;\n    set_nrpt_value(&rule_key, \"IPSECCARestriction\", &\"\")?;\n    let all_domains = vec![\".\".to_string()];\n    set_nrpt_value(&rule_key, \"Name\", &all_domains)?;\n    set_nrpt_value(&rule_key, \"Version\", &2u32)?;\n\n    tracing::info!(\n        message_id = \"pXtmTXfo\",\n        name_servers = %name_servers_str,\n        \"created NRPT rule\"\n    );\n\n    Ok(())\n}\n\n/// Deletes the NRPT rule we created, identified by our fixed GUID.\n///\n/// Returns `Ok(true)` if the rule was deleted, `Ok(false)` if it didn't exist.\npub fn delete_rules() -> Result<bool, ()> {\n    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);\n    let rule_path = get_rule_path();\n\n    match hklm.delete_subkey(&rule_path) {\n        Ok(()) => {\n            tracing::info!(message_id = \"YkowItk3\", \"deleted NRPT rule\");\n            Ok(true)\n        }\n        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),\n        Err(error) => {\n            tracing::error!(message_id = \"9X2pcgLU\", ?error, rule_path, \"failed to delete NRPT rule subkey\");\n            Err(())\n        }\n    }\n}\n\n#[test]\nfn test_rule_path() {\n    let path = get_rule_path();\n    assert!(path.contains(&NRPT_RULE_GUID));\n    println!(\"{path}\");\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/windows/start_error.rs",
    "content": "use std::path::PathBuf;\n\n#[derive(thiserror::Error, Debug)]\npub enum WindowsServiceStartError {\n    #[error(\"Failed to get current exe path: {0}\")]\n    CurrentExePath(#[source] std::io::Error),\n    #[error(\"wintun.dll hash mismatch (location {dll_path}, expected {expected}, got {actual})\")]\n    WintunDllHashMismatch { dll_path: PathBuf, expected: String, actual: String },\n    #[error(\"Failed to read wintun.dll for hash verification: {0}\")]\n    WintunDllRead(#[source] std::io::Error),\n    #[error(\"Failed to load wintun dll: {0}\")]\n    LoadWintunDll(#[source] wintun::Error),\n    #[error(\"Failed to create wintun adapter: {0}\")]\n    CreateWintunAdapter(#[source] wintun::Error),\n    #[error(\"Failed to start wintun session: {0}\")]\n    StartWintunSession(#[source] wintun::Error),\n    #[error(\"Unexpected error. Details: {0}\")]\n    Unexpected(#[from] anyhow::Error),\n}\n"
  },
  {
    "path": "rustlib/src/bin/obscura/service/os/windows/tun.rs",
    "content": "use bytes::Bytes;\nuse ipnetwork::Ipv6Network;\nuse obscuravpn_client::os::packet_buffer::PacketBuffer;\nuse obscuravpn_client::quicwg::QuicWgConnPacketSender;\nuse obscuravpn_client::rate_limited_log;\nuse obscuravpn_client::tokio::AbortOnDrop;\nuse ring::digest;\nuse std::io::Read;\nuse std::time::Duration;\nuse tokio::task::spawn_blocking;\nuse wintun::Wintun;\n\nuse crate::service::os::windows::WindowsServiceStartError;\nuse crate::service::os::windows::iphelper;\nuse crate::service::os::windows::iphelper::flush_dns_cache;\nuse crate::service::os::windows::nrpt;\nuse std::net::IpAddr;\nuse std::net::Ipv4Addr;\nuse std::sync::{Arc, Mutex};\n\n/// SHA-256 hash of the authentic wintun.dll, calculated at build time.\nconst WINTUN_DLL_SHA256: &str = env!(\"WINTUN_DLL_SHA256\");\n\nconst TUN_MIN_LOG_SILENCE: Duration = Duration::from_secs(5);\n\n// If the adapter name contains spaces, setting the wintun adapter address does not work\n//  due to an escape bug when calling netsh\nconst TUN_NAME: &str = \"ObscuraVPN\";\n\npub struct Tun {\n    adapter: Arc<wintun::Adapter>,\n    session: Arc<wintun::Session>,\n    read_task: Mutex<Option<AbortOnDrop>>,\n}\n\nimpl Tun {\n    pub async fn create() -> Result<Self, WindowsServiceStartError> {\n        // cleanup DNS routing in case of unclean disconnect\n        if let Ok(true) = nrpt::delete_rules() {\n            let _ = flush_dns_cache().await;\n        }\n        // SECURITY: To protect against privilege escalation when loading the relative wintun.dll,\n        // verify that the file matches the pre-calculated SHA-256 hash from build time before loading.\n        let wintun = verify_and_load_wintun()?;\n        let adapter = match wintun::Adapter::open(&wintun, TUN_NAME) {\n            Ok(a) => a,\n            Err(error) => {\n                tracing::warn!(message_id = \"5ImYKHdv\", ?error, \"could not load wintun adapter, will try to create one\");\n                // If loading fails (e.g. doesn't exist), create one\n                // THIS REQUIRES Administrator privileges\n                // The GUID can be hard coded and provided\n                wintun::Adapter::create(&wintun, TUN_NAME, \"QUICWG\", None).map_err(WindowsServiceStartError::CreateWintunAdapter)?\n            }\n        };\n        let session = adapter\n            .start_session(wintun::MAX_RING_CAPACITY)\n            .map_err(WindowsServiceStartError::StartWintunSession)?;\n        Ok(Tun { adapter, session: session.into(), read_task: Mutex::new(None) })\n    }\n\n    pub fn send(&self, packet: Bytes) {\n        match u16::try_from(packet.len()) {\n            Ok(packet_size) => {\n                let packet_res = self.session.allocate_send_packet(packet_size);\n                match packet_res {\n                    Ok(mut packet_to_send) => {\n                        let bytes: &mut [u8] = packet_to_send.bytes_mut();\n                        bytes.copy_from_slice(&packet);\n                        self.session.send_packet(packet_to_send);\n                    }\n                    Err(error) => {\n                        rate_limited_log!(\n                            TUN_MIN_LOG_SILENCE,\n                            tracing::error!(message_id = \"s1G0fKYL\", ?error, \"could not allocate packet on wintun adapter\")\n                        );\n                    }\n                }\n            }\n            Err(_) => {\n                rate_limited_log!(\n                    TUN_MIN_LOG_SILENCE,\n                    tracing::error!(\n                        message_id = \"C3y8mGrT\",\n                        packet_size = packet.len(),\n                        \"cannot send packet: size exceeds u16::MAX\"\n                    )\n                );\n            }\n        }\n    }\n\n    fn put_packet_in_buffer(packet: wintun::Packet, buffer: &mut [u8]) -> Result<u16, ()> {\n        let packet_bytes = packet.bytes();\n        match u16::try_from(packet_bytes.len()) {\n            Ok(n) => {\n                // Check if packet fits in the available buffer space\n                if packet_bytes.len() <= buffer.len() {\n                    buffer[..packet_bytes.len()].copy_from_slice(packet_bytes);\n                    return Ok(n);\n                } else {\n                    tracing::error!(\n                        message_id = \"B2x9kFpQ\",\n                        packet_size = packet_bytes.len(),\n                        buffer_size = buffer.len(),\n                        \"packet too large for available buffer space, dropping packet\"\n                    );\n                }\n            }\n            Err(_) => {\n                tracing::error!(\n                    message_id = \"A1s4jdil\",\n                    packet_size = packet_bytes.len(),\n                    \"ignoring oversized packet from tun device (exceeds u16::MAX)\"\n                );\n            }\n        }\n        Err(())\n    }\n\n    async fn receive(session: &Arc<wintun::Session>, packet_buffer: &mut PacketBuffer) {\n        if let Some(buffer) = packet_buffer.buffer() {\n            let packet = loop {\n                let session = session.clone();\n                match spawn_blocking(move || session.receive_blocking()).await {\n                    Ok(Ok(packet)) => break packet,\n                    Ok(Err(error)) => {\n                        rate_limited_log!(\n                            TUN_MIN_LOG_SILENCE,\n                            tracing::error!(message_id = \"UD939201\", ?error, \"failed to receive from wintun\")\n                        );\n                    }\n                    Err(error) => {\n                        rate_limited_log!(\n                            TUN_MIN_LOG_SILENCE,\n                            tracing::error!(message_id = \"qlEOseOs\", ?error, \"failed to join wintun receive\")\n                        );\n                    }\n                }\n            };\n            if let Ok(n) = Self::put_packet_in_buffer(packet, buffer) {\n                packet_buffer.commit(n);\n            }\n        }\n\n        while let Some(buffer) = packet_buffer.buffer() {\n            let result = session.try_receive();\n            match result {\n                Ok(None) => return,\n                Ok(Some(packet)) => {\n                    if let Ok(n) = Self::put_packet_in_buffer(packet, buffer) {\n                        packet_buffer.commit(n);\n                    }\n                }\n                Err(error) => {\n                    rate_limited_log!(\n                        TUN_MIN_LOG_SILENCE,\n                        tracing::error!(message_id = \"WXll4YJu\", ?error, \"failed to receive from wintun\")\n                    );\n                }\n            }\n        }\n    }\n\n    pub async fn set_config(&self, mtu: u16, ipv4: Ipv4Addr, ipv6: Ipv6Network, dns: Option<Vec<IpAddr>>) -> Result<(), ()> {\n        // Attempt all config steps regardless of individual failures to minimize leaks until intentionally disconnecting.\n        // E.g. DNS queries shouldn't leak because route setup failed.\n        let mut result = Ok(());\n        result = result\n            .and(iphelper::set_mtu(&self.adapter, mtu).await)\n            .and(iphelper::set_ipv4_address(&self.adapter, ipv4).await)\n            .and(iphelper::set_ipv6_address(&self.adapter, ipv6).await)\n            .and(iphelper::set_dns_servers(&self.adapter, dns.as_deref().unwrap_or_default()).await)\n            .and(iphelper::set_low_metric(&self.adapter))\n            .and(iphelper::add_routes(&self.adapter));\n\n        // Avoid DNS outage by redirecting after adding routes\n        if let Some(dns) = dns {\n            result = result.and(nrpt::create_rule(&dns).or_else(|_| nrpt::delete_rules().map(drop)));\n        } else {\n            result = result.and(nrpt::delete_rules().map(drop));\n        }\n        result = result.and(flush_dns_cache().await);\n\n        result\n    }\n\n    pub fn spawn_read_task(&self, tunnel: QuicWgConnPacketSender) {\n        let mut read_task = self.read_task.lock().unwrap();\n        let session = self.session.clone();\n        *read_task = Some(AbortOnDrop::spawn(async move {\n            let mut packet_buffer = PacketBuffer::default();\n            loop {\n                Self::receive(&session, &mut packet_buffer).await;\n                tunnel.send(packet_buffer.take_iter());\n            }\n        }));\n    }\n\n    pub async fn shutdown(&self) -> Result<(), ()> {\n        let mut result = Ok(());\n        result = result.and(nrpt::delete_rules().map(drop));\n        result = result.and(flush_dns_cache().await);\n        result = result.and(iphelper::reset_interface_metric(&self.adapter));\n        result = result.and(iphelper::remove_routes(&self.adapter));\n        result\n    }\n}\n\n/// SECURITY: Verify that the wintun.dll at `dll_path` matches the SHA-256 hash calculated at\n/// build time. This should prevent using a tampered or replaced DLL, which could lead to privilege\n/// escalation since the service runs with elevated privileges.\nfn verify_and_load_wintun() -> Result<Wintun, WindowsServiceStartError> {\n    let dll_path = std::env::current_exe()\n        .map_err(WindowsServiceStartError::CurrentExePath)?\n        .with_file_name(\"wintun.dll\");\n\n    // SECURITY: Keep the file handle open through hash verification and DLL loading to prevent\n    // TOCTOU attacks where the DLL could be replaced between verification and loading.\n    // On Windows, an open file handle prevents the file from being written to or deleted.\n    let _dll_file = std::fs::File::open(&dll_path).map_err(WindowsServiceStartError::WintunDllRead)?;\n    let mut dll_bytes = Vec::new();\n    (&_dll_file)\n        .read_to_end(&mut dll_bytes)\n        .map_err(WindowsServiceStartError::WintunDllRead)?;\n\n    let actual_hash = digest::digest(&digest::SHA256, &dll_bytes);\n    let actual_hex: String = actual_hash.as_ref().iter().map(|b| format!(\"{b:02x}\")).collect();\n\n    (&actual_hex == WINTUN_DLL_SHA256)\n        .then_some(())\n        .ok_or_else(|| WindowsServiceStartError::WintunDllHashMismatch {\n            dll_path: dll_path.clone(),\n            expected: WINTUN_DLL_SHA256.to_string(),\n            actual: actual_hex,\n        })?;\n\n    tracing::info!(message_id = \"mtxTbQFv\", \"wintun.dll hash verified successfully\");\n    // `_dll_file` is still open here, preventing the DLL from being replaced before loading.\n    unsafe { wintun::load_from_path(&dll_path) }.map_err(WindowsServiceStartError::LoadWintunDll)\n    // `_dll_file` is dropped here, releasing the read lock on the DLL.\n}\n"
  },
  {
    "path": "rustlib/src/cached_value.rs",
    "content": "use std::time::SystemTime;\n\nuse base64::prelude::*;\nuse serde::Deserialize;\nuse serde::Serialize;\n\n#[serde_with::serde_as]\n#[derive(derive_more::Debug, Deserialize, Serialize)]\npub struct CachedValue<T> {\n    #[debug(\"{:?}\", BASE64_STANDARD.encode(version))]\n    #[serde_as(as = \"serde_with::base64::Base64\")]\n    pub version: Vec<u8>,\n    #[serde_as(as = \"serde_with::TimestampSeconds\")]\n    pub last_updated: SystemTime,\n    pub value: T,\n}\n"
  },
  {
    "path": "rustlib/src/client_state.rs",
    "content": "use super::{\n    errors::{ApiError, TunnelConnectError},\n    network_config::TunnelNetworkConfig,\n};\nuse crate::debug_archive::{dns::debug_dns, info::DebugInfo, task::debug_panic_error};\nuse crate::dns::DnsResolver;\nuse crate::errors::ConfigDirty;\nuse crate::manager::TunnelArgs;\nuse crate::network_config::DnsContentBlock;\nuse crate::tunnel_state::TargetState;\nuse crate::{config::ConfigHandle, net::interface_mtu};\nuse crate::{config::KeychainSetSecretKeyFn, net::NetworkInterface, network_config::DnsConfig, quicwg::QuicWgConnHandshaking};\nuse crate::{config::PinnedLocation, exit_selection::ExitSelectionState};\nuse crate::{config::cached::ConfigCached, exit_selection::ExitSelector};\nuse crate::{\n    config::{self, Config, ConfigLoadError},\n    errors::RelaySelectionError,\n    quicwg::QuicWgConn,\n};\nuse crate::{\n    constants::{DEFAULT_API_BACKUP_DOMAIN, DEFAULT_API_URL, DEFAULT_RELAY_SNI},\n    debug_archive::http::debug_http,\n};\nuse crate::{quicwg::TUNNEL_MTU, relay_selection::race_relay_handshakes};\nuse boringtun::x25519::{PublicKey, StaticSecret};\nuse chrono::Utc;\nuse obscuravpn_api::cmd::{CacheWgKey, ETagCmd, ExitList, ListExits2};\nuse obscuravpn_api::types::{AccountId, AccountInfo, AuthToken, OneExit};\nuse obscuravpn_api::{\n    Client, ClientError,\n    cmd::{ApiErrorKind, Cmd, CreateTunnel, DeleteTunnel, ListRelays, ListTunnels},\n    types::{ObfuscatedTunnelConfig, OneRelay, TunnelConfig, WgPubkey},\n};\nuse rand::{seq::SliceRandom, thread_rng};\nuse serde::{Deserialize, Serialize};\nuse std::collections::BTreeSet;\nuse std::net::SocketAddr;\nuse std::sync::{Arc, Weak};\nuse std::{cmp::min, path::PathBuf, time::Instant};\nuse std::{\n    mem,\n    time::{Duration, SystemTime, UNIX_EPOCH},\n};\nuse tokio::sync::watch::{Receiver, Sender};\nuse tokio::{spawn, time::timeout_at};\nuse uuid::Uuid;\n\n// A convenience wrapper to act as message receiver (reevaluate when https://rust-lang.github.io/rfcs//3519-arbitrary-self-types-v2.html is stable)\n#[derive(Clone)]\npub struct ClientStateHandle(Arc<Sender<ClientState>>);\n\npub struct ClientState {\n    this: WeakClientStateHandle,\n    cached_api_client: Option<Arc<Client>>,\n    config: ConfigHandle,\n    exit_update_lock: Arc<tokio::sync::Mutex<()>>,\n    mtu: Option<u16>,\n    network_interface: Option<NetworkInterface>,\n    set_keychain_wg_sk: Option<KeychainSetSecretKeyFn>,\n    user_agent: String,\n}\n\n#[derive(Serialize, Deserialize, Clone, Debug)]\npub struct AccountStatus {\n    pub account_info: AccountInfo, // API\n    pub last_updated_sec: u64,\n}\n\nimpl Eq for AccountStatus {}\n\nimpl PartialEq for AccountStatus {\n    fn eq(&self, other: &Self) -> bool {\n        self.last_updated_sec == other.last_updated_sec\n    }\n}\n\nimpl ClientState {\n    #[allow(clippy::new_ret_no_self)]\n    pub fn new(\n        config_dir: PathBuf,\n        keychain_wg_sk: Option<&[u8]>,\n        user_agent: String,\n        set_keychain_wg_sk: Option<KeychainSetSecretKeyFn>,\n        force_init_inactive: bool,\n    ) -> Result<ClientStateHandle, ConfigLoadError> {\n        let mut config = ConfigHandle::new(config_dir, keychain_wg_sk)?;\n        if force_init_inactive {\n            config.change(|config| config.tunnel_active = false)\n        }\n        Ok(ClientStateHandle(Arc::new_cyclic(|weak| {\n            tokio::sync::watch::channel(ClientState {\n                this: WeakClientStateHandle(weak.clone()),\n                config,\n                cached_api_client: None,\n                set_keychain_wg_sk,\n                mtu: None,\n                network_interface: None,\n                exit_update_lock: Default::default(),\n                user_agent,\n            })\n            .0\n        })))\n    }\n\n    pub fn target_state(&self) -> TargetState {\n        TargetState {\n            tunnel_args: self.config.tunnel_active.then_some(self.config.tunnel_args.clone()),\n            network_interface: self.network_interface.clone(),\n            dns_content_block: self.config.dns_content_block,\n            use_system_dns: match self.config.dns {\n                DnsConfig::Default => false,\n                DnsConfig::System => true,\n            },\n        }\n    }\n\n    pub fn config(&self) -> &Config {\n        &self.config\n    }\n\n    pub fn base_url(&self) -> String {\n        self.config.api_url.clone().unwrap_or(DEFAULT_API_URL.to_string())\n    }\n\n    fn make_api_client(&self, account_id: AccountId) -> Result<Client, ApiError> {\n        let base_url = self.base_url();\n        let network_interface = self.network_interface.clone();\n        let alternative_hosts = vec![self.config.api_host_alternate.clone().unwrap_or_else(|| DEFAULT_API_BACKUP_DOMAIN.into())];\n        tracing::info!(\n            message_id = \"By9iMtd5\",\n            ?network_interface,\n            ?base_url,\n            ?alternative_hosts,\n            \"creating new API client\"\n        );\n        Client::new(\n            base_url,\n            alternative_hosts,\n            account_id,\n            &self.user_agent,\n            #[cfg(not(any(target_os = \"android\", target_os = \"windows\")))]\n            network_interface.as_ref().map(|i| i.name.as_str()),\n            #[cfg(target_os = \"windows\")]\n            network_interface.as_ref().map(|i| i.ip),\n            #[cfg(target_os = \"android\")]\n            None,\n            Some(DnsResolver::new(self.this.clone())),\n        )\n        .map_err(ClientError::from)\n        .map_err(ApiError::from)\n    }\n}\n\nimpl ClientStateHandle {\n    pub fn borrow(&self) -> tokio::sync::watch::Ref<'_, ClientState> {\n        self.0.borrow()\n    }\n\n    pub fn subscribe(&self) -> Receiver<ClientState> {\n        self.0.subscribe()\n    }\n\n    fn change_config(&self, f: impl FnOnce(&mut Config)) {\n        self.change(|inner| {\n            inner.config.change(|config| {\n                f(config);\n            })\n        });\n    }\n\n    fn change<T>(&self, f: impl FnOnce(&mut ClientState) -> T) -> T {\n        let mut ret: Option<T> = None;\n        self.0.send_modify(|inner| {\n            ret = Some(f(inner));\n        });\n        ret.unwrap()\n    }\n\n    /// Log in or out.\n    pub fn set_account_id(&self, account_id_and_auth_token: Option<(AccountId, Option<AuthToken>)>) -> Result<(), ConfigDirty> {\n        let (account_id, auth_token) = match account_id_and_auth_token {\n            Some((account_id, auth_token)) => (Some(account_id), auth_token),\n            None => (None, None),\n        };\n        self.change(|inner| {\n            inner.config.change(|config| {\n                if account_id != config.account_id {\n                    // Log-out / Change User\n\n                    let mut old_account_ids = mem::take(&mut config.old_account_ids);\n                    if let Some(old_account_id) = &config.account_id\n                        && !old_account_ids.contains(old_account_id)\n                    {\n                        old_account_ids.push(old_account_id.clone());\n                    }\n\n                    *config = Config {\n                        api_url: config.api_url.take(),\n                        account_id,\n                        cached_auth_token: auth_token.map(Into::into),\n                        old_account_ids,\n                        in_new_account_flow: config.in_new_account_flow,\n                        // see https://linear.app/soveng/issue/OBS-1171\n                        local_tunnels_ids: config.local_tunnels_ids.clone(),\n                        ..Default::default()\n                    }\n                } else {\n                    tracing::warn!(message_id = \"shia4Eph\", \"Setting auth token for logged in account. This isn't expected.\");\n                    config.cached_auth_token = auth_token.map(Into::into);\n                }\n            });\n            inner.cached_api_client = None;\n        });\n        self.borrow().config.check_persisted()\n    }\n\n    pub fn get_exit_list(&self) -> Option<ConfigCached<Arc<ExitList>>> {\n        self.borrow().config.cached_exits.clone()\n    }\n\n    pub fn set_pinned_exits(&self, pinned_locations: Vec<PinnedLocation>) {\n        self.change_config(|config| {\n            config.pinned_locations = pinned_locations;\n        })\n    }\n\n    pub fn set_feature_flag(&self, flag: &str, active: bool) {\n        self.change_config(|config| {\n            config.feature_flags.set(flag, active);\n        })\n    }\n\n    pub fn set_tunnel_target_state(&self, tunnel_args: Option<TunnelArgs>, active: Option<bool>) {\n        self.change_config(|config| {\n            if let Some(tunnel_args) = tunnel_args {\n                config.tunnel_args = tunnel_args\n            }\n            if let Some(active) = active {\n                config.tunnel_active = active\n            }\n        });\n    }\n\n    pub fn set_api_host_alternate(&self, value: Option<String>) {\n        self.change(|inner| {\n            inner.config.change(|config| {\n                tracing::info!(\n                    message_id = \"jee1ieWa\",\n                    api_host_alternate_new = value,\n                    api_host_alternate_old = config.api_host_alternate,\n                    \"Changing API alternate host.\",\n                );\n                config.api_host_alternate = value;\n            });\n            inner.cached_api_client = None;\n        })\n    }\n\n    pub fn set_sni_relay(&self, value: Option<String>) {\n        self.change_config(|config| {\n            tracing::info!(\n                message_id = \"jee1ieWa\",\n                sni_relay_new = value,\n                sni_relay_old = config.sni_relay,\n                \"Changing Relay SNI.\",\n            );\n            config.sni_relay = value;\n        })\n    }\n\n    pub fn set_in_new_account_flow(&self, value: bool) {\n        self.change_config(|config| {\n            config.in_new_account_flow = value;\n        })\n    }\n\n    pub fn set_api_url(&self, url: Option<String>) {\n        self.change(|inner| {\n            inner.config.change(|config| {\n                config.api_url = url;\n                config.wireguard_key_cache.rotate_now(inner.set_keychain_wg_sk.as_ref());\n            });\n            inner.cached_api_client = None;\n        })\n    }\n\n    pub fn set_dns_content_block(&self, value: DnsContentBlock) {\n        self.change_config(move |config| config.dns_content_block = value)\n    }\n\n    pub fn set_network_interface(&self, network_interface: Option<NetworkInterface>) {\n        let mtu = if let Some(interface) = &network_interface {\n            match interface_mtu(interface) {\n                Ok(mtu) => {\n                    tracing::info!(\n                        message_id = \"eePai0oh\",\n                        network_interface.mtu = mtu,\n                        network_interface.name = interface.name,\n                        \"Interface MTU.\",\n                    );\n                    Some(mtu)\n                }\n                Err(error) => {\n                    tracing::warn!(\n                        message_id = \"kah4Ifoh\",\n                        ?error,\n                        network_interface.name = interface.name,\n                        \"Failed to get interface MTU.\",\n                    );\n                    None\n                }\n            }\n        } else {\n            None\n        };\n\n        self.change(|inner| {\n            inner.mtu = mtu.and_then(|mtu| {\n                u16::try_from(mtu)\n                    .inspect_err(|_| tracing::warn!(message_id = \"uKFfXGSc\", mtu, \"MTU out of range\"))\n                    .ok()\n            });\n            inner.network_interface = network_interface;\n            inner.cached_api_client = None;\n        })\n    }\n\n    pub fn set_auto_connect(&self, enable: bool) {\n        self.change_config(|config| {\n            config.auto_connect = enable;\n        })\n    }\n\n    pub fn set_use_system_dns(&self, enable: bool) {\n        self.change_config(|config| config.dns = if enable { DnsConfig::System } else { DnsConfig::Default })\n    }\n\n    pub async fn connect(\n        &self,\n        exit_selector: &ExitSelector,\n        network_interface: Option<&NetworkInterface>,\n        selection_state: &mut ExitSelectionState,\n    ) -> Result<(QuicWgConn, TunnelNetworkConfig, OneExit, OneRelay), TunnelConnectError> {\n        let (token, tunnel_config, wg_sk, exit, relay, handshaking) = self.new_tunnel(exit_selector, network_interface, selection_state).await?;\n        let network_config = TunnelNetworkConfig::new(&tunnel_config, TUNNEL_MTU)?;\n        let client_ip_v4 = network_config.ipv4;\n        tracing::info!(\n            tunnel.id =% token,\n            exit.pubkey =? tunnel_config.exit_pubkey,\n            \"finishing tunnel connection\");\n        let remote_pk = PublicKey::from(tunnel_config.exit_pubkey.0);\n        let ping_keepalive_ip = tunnel_config.gateway_ip_v4;\n        let conn = QuicWgConn::connect(handshaking, wg_sk.clone(), remote_pk, client_ip_v4, ping_keepalive_ip, token).await?;\n        tracing::info!(\"tunnel connected\");\n        let exit_id = exit.id.clone();\n\n        self.change_config(|config| {\n            if *exit_selector != (ExitSelector::Any {}) {\n                config.last_chosen_exit = Some(exit_id);\n                config.last_chosen_exit_selector = exit_selector.clone();\n            };\n            config.last_exit_selector = exit_selector.clone();\n        });\n        Ok((conn, network_config, exit, relay))\n    }\n\n    fn choose_exit(&self, selector: &ExitSelector, relay: &OneRelay, selection_state: &mut ExitSelectionState) -> Option<String> {\n        let Some(exit_list) = self.get_exit_list() else {\n            tracing::warn!(message_id = \"Iu1ahnge\", \"No exit list, choosing random preferred exit.\");\n            return relay.preferred_exits.choose(&mut thread_rng()).map(|e| e.id.clone());\n        };\n        selection_state\n            .select_next_exit(selector, &exit_list.value.exits, relay)\n            .map(|e| e.id.clone())\n    }\n\n    async fn new_tunnel(\n        &self,\n        exit_selector: &ExitSelector,\n        network_interface: Option<&NetworkInterface>,\n        selection_state: &mut ExitSelectionState,\n    ) -> anyhow::Result<(Uuid, ObfuscatedTunnelConfig, StaticSecret, OneExit, OneRelay, QuicWgConnHandshaking), TunnelConnectError> {\n        // Ideally we would avoid return a failure immediately if the relay selection fails and continue the exit update in the background but we currently have no ability to execute tasks in the background for this type. The downside of a slight delay in the failure case is suboptimal but minor.\n\n        let (select_relay, update_exits) = tokio::join!(self.select_relay(network_interface), self.maybe_update_exits(Duration::from_secs(60)),);\n        match update_exits {\n            Ok(()) => {}\n            Err(error) => {\n                tracing::warn!(message_id = \"oH5aigha\", ?error, \"Ignoring failure to update exit list: {}\", error,);\n            }\n        };\n        let (closest_relay, handshaking) = select_relay?;\n\n        let Some(exit) = self.choose_exit(exit_selector, &closest_relay, selection_state) else {\n            tracing::error!(\n                message_id = \"naiThei6\",\n                exit_selector =? exit_selector,\n                \"No exits matching selector.\"\n            );\n            return Err(TunnelConnectError::NoExit);\n        };\n\n        tracing::info!(\n            message_id = \"eiR8ixoh\",\n            exit.id = exit,\n            exit_selector =? exit_selector,\n            \"Selected exit\"\n        );\n\n        let (tunnel_info, sk, tunnel_id) = loop {\n            if let Err(err) = self.remove_local_tunnels().await {\n                tracing::warn!(\"error removing unused local tunnels: {}\", err);\n            }\n\n            let tunnel_id = Uuid::new_v4();\n            let (sk, pk) = self.change(|inner| {\n                inner.config.change(|config| {\n                    config.local_tunnels_ids.push(tunnel_id.to_string());\n                    config.wireguard_key_cache.use_key_pair(inner.set_keychain_wg_sk.as_ref())\n                })\n            });\n            let wg_pubkey = WgPubkey(pk.to_bytes());\n            tracing::info!(\n                    %tunnel_id,\n                    client.pubkey =? wg_pubkey,\n                    exit.id = exit,\n                    relay.id =? &closest_relay.id,\n                    relay.ip_v4 =% closest_relay.ip_v4,\n                    \"creating tunnel\");\n\n            let cmd = CreateTunnel::Obfuscated {\n                id: Some(tunnel_id),\n                label: None,\n                wg_pubkey,\n                relay: Some(closest_relay.id.clone()),\n                exit: Some(exit.clone()),\n            };\n            let error = match self.api_request(cmd.clone()).await {\n                Ok(t) => break (t, sk, tunnel_id),\n                Err(error) => match error.api_error_kind() {\n                    Some(ApiErrorKind::TunnelLimitExceeded {}) => error,\n                    Some(ApiErrorKind::WgKeyRotationRequired {}) => {\n                        tracing::warn!(?error, \"server indicated that key rotation is required immediately\");\n                        self.change(|inner| {\n                            inner\n                                .config\n                                .change(|config| config.wireguard_key_cache.rotate_now(inner.set_keychain_wg_sk.as_ref()))\n                        });\n                        continue;\n                    }\n                    _ => return Err(error.into()),\n                },\n            };\n            tracing::warn!(?error, \"no tunnel slots left, trying to delete an unused one\");\n            let last_used_threshold = Utc::now().timestamp() - 300;\n            let mut tunnels: Vec<(String, i64)> = self\n                .api_request(ListTunnels {})\n                .await?\n                .into_iter()\n                .filter_map(|t| match &t.config {\n                    TunnelConfig::Obfuscated(_) => {\n                        use obscuravpn_api::types::TunnelStatus::*;\n                        let (Created { when } | Connected { when } | Disconnected { when }) = t.status;\n                        (when < last_used_threshold).then_some((t.id, when))\n                    }\n                    _ => None,\n                })\n                .collect();\n            tunnels.sort_by_key(|t| t.1);\n            let Some(id) = tunnels.into_iter().next().map(|t| t.0) else {\n                tracing::warn!(\"no unused obfuscated tunnel found\");\n                return Err(error.into());\n            };\n            tracing::warn!(\"deleting unused tunnel {}\", &id);\n            self.api_request(DeleteTunnel { id }).await?;\n        };\n\n        if tunnel_info.relay.id != closest_relay.id {\n            return Err(TunnelConnectError::UnexpectedRelay);\n        }\n        let TunnelConfig::Obfuscated(config) = tunnel_info.config else {\n            return Err(TunnelConnectError::UnexpectedTunnelKind);\n        };\n        Ok((tunnel_id, config, sk, tunnel_info.exit, tunnel_info.relay, handshaking))\n    }\n\n    pub async fn remove_local_tunnels(&self) -> Result<(), ApiError> {\n        loop {\n            let Some(local_tunnel_id) = self.borrow().config.local_tunnels_ids.first().cloned() else {\n                return Ok(());\n            };\n            tracing::info!(\"removing previously used tunnel {}\", &local_tunnel_id);\n            self.api_request(DeleteTunnel { id: local_tunnel_id.clone() }).await?;\n            self.0\n                .send_modify(|inner| inner.config.change(|config| config.local_tunnels_ids.retain(|x| x != &local_tunnel_id)))\n        }\n    }\n\n    pub async fn select_relay(&self, network_interface: Option<&NetworkInterface>) -> Result<(OneRelay, QuicWgConnHandshaking), TunnelConnectError> {\n        let relays = self.api_request(ListRelays {}).await?;\n        let sni = self.0.borrow().config.sni_relay.clone().unwrap_or_else(|| DEFAULT_RELAY_SNI.into());\n\n        tracing::info!(\n            message_id = \"eech6Ier\",\n            relays =? relays,\n            sni = sni,\n            \"Racing relays\",\n        );\n        let (use_tcp_tls, quic_frame_padding, force_small_mtu, mtu) = {\n            let this = self.borrow();\n            (\n                this.config.feature_flags.tcp_tls_tunnel.unwrap_or(false),\n                this.config.feature_flags.quic_frame_padding.unwrap_or(false),\n                this.config.feature_flags.force_small_mtu.unwrap_or(false),\n                this.mtu,\n            )\n        };\n        let racing_handshakes = race_relay_handshakes(network_interface, relays, sni, use_tcp_tls, quic_frame_padding, force_small_mtu, mtu)?;\n\n        let start = Instant::now();\n        let mut deadline = start + Duration::from_secs(30);\n\n        let mut relays_connected_successfully = BTreeSet::new();\n        let mut best_candidate = None;\n\n        loop {\n            let next = timeout_at(deadline.into(), racing_handshakes.recv_async()).await;\n            let (relay, port, rtt, handshaking) = match next {\n                Ok(Ok(n)) => n,\n                Ok(Err(error)) => {\n                    tracing::info!(message_id = \"aeY9Acha\", ?error, \"relay selection channel ended\",);\n                    break;\n                }\n                Err(error) => {\n                    tracing::info!(\n                        message_id = \"Eixooph8\",\n                        ?error,\n                        deadline_s = (deadline - start).as_secs_f32(),\n                        \"relay selection deadline reached\",\n                    );\n                    break;\n                }\n            };\n            relays_connected_successfully.insert(relay.id.clone());\n\n            let rejected = if best_candidate.as_ref().is_some_and(|(_, _, best_rtt, _)| *best_rtt < rtt) {\n                Some(handshaking)\n            } else {\n                // Only wait for 3x the time it took to find the best candidate. The chance that future relays have better RTT is minimal and it wastes time and increases the chance that we hang for a long time waiting on unreachable relays.\n                deadline = start + min(start.elapsed() * 3, Duration::from_secs(5));\n\n                best_candidate\n                    .replace((relay, port, rtt, handshaking))\n                    .map(|(_, _, _, replaced)| replaced)\n            };\n            if let Some(rejected) = rejected {\n                spawn(rejected.abandon());\n            }\n\n            if relays_connected_successfully.len() >= 5 {\n                // With the 5 unique relays we have a high probability of having a very good candidate. Waiting for more responses just slows down connection for very minimal benefit.\n                tracing::info!(message_id = \"YeiNgo7k\", \"relay count limit reached\",);\n                break;\n            }\n        }\n\n        let Some((relay, port, rtt, handshaking)) = best_candidate else {\n            return Err(RelaySelectionError::NoSuccess.into());\n        };\n        tracing::info!(relay.id, port, rtt = rtt.as_millis(), \"selected relay\");\n        Ok((relay, handshaking))\n    }\n\n    pub fn make_api_client(&self, account_id: AccountId) -> Result<Client, ApiError> {\n        self.borrow().make_api_client(account_id)\n    }\n\n    fn api_client(&self) -> Result<Arc<Client>, ApiError> {\n        let Some(account_id) = self.borrow().config.account_id.clone() else {\n            return Err(ApiError::NoAccountId);\n        };\n\n        self.change(|inner| {\n            if let Some(api_client) = inner.cached_api_client.clone() {\n                Ok(api_client)\n            } else {\n                let api_client = Arc::new(inner.make_api_client(account_id)?);\n                if let Some(auth_token) = inner.config.cached_auth_token.clone() {\n                    api_client.set_auth_token(Some(auth_token.into()));\n                }\n                Ok(inner.cached_api_client.insert(api_client).clone())\n            }\n        })\n    }\n\n    fn cache_auth_token(&self) {\n        self.change(|inner| {\n            let auth_token = inner.cached_api_client.as_ref().and_then(|c| c.get_auth_token());\n            inner.config.change(|config| {\n                config.cached_auth_token = auth_token.map(Into::into);\n            });\n        })\n    }\n\n    pub async fn api_request<C: Cmd>(&self, cmd: C) -> Result<C::Output, ApiError> {\n        let api_client = self.api_client()?;\n        let result = api_client.run(cmd).await;\n        self.cache_auth_token();\n        Ok(result?)\n    }\n\n    pub async fn cached_api_request<C: ETagCmd>(&self, cmd: C, etag: Option<&[u8]>) -> Result<obscuravpn_api::Response<C::Output>, ApiError> {\n        let api_client = self.api_client()?;\n        let result = api_client.run_with_etag(cmd, etag).await?;\n        self.cache_auth_token();\n        Ok(result)\n    }\n\n    pub fn base_url(&self) -> String {\n        self.borrow().base_url()\n    }\n\n    pub fn user_agent(&self) -> String {\n        self.borrow().user_agent.clone()\n    }\n\n    pub async fn maybe_update_exits(&self, freshness: Duration) -> Result<(), ApiError> {\n        // Outstanding borrows should not be held over .await\n        let exit_update_lock = self.borrow().exit_update_lock.clone();\n        let _exit_update_guard = exit_update_lock.lock().await;\n\n        let prev = self.borrow().config.cached_exits.clone();\n        let prev = prev.as_ref();\n        if prev.is_some_and(|c| c.staleness() < freshness) {\n            tracing::info!(message_id = \"fao5ciJu\", \"Exit list is already up to date.\");\n            return Ok(());\n        }\n\n        let res = self.cached_api_request(ListExits2 {}, prev.as_ref().and_then(|p| p.etag())).await?;\n\n        let etag = res.etag().map(|e| e.to_vec());\n\n        let Some(body) = res.into_body() else { return Ok(()) };\n\n        let version = match etag {\n            Some(b) => config::cached::Version::ETag(b),\n            None => {\n                tracing::warn!(message_id = \"meequa8P\", \"Exit list had not ETag.\");\n                config::cached::Version::artificial()\n            }\n        };\n        let cached_exits = ConfigCached::new(Arc::new(body), version);\n        self.change_config(|config| config.cached_exits = Some(cached_exits.clone()));\n        Ok(())\n    }\n\n    pub fn update_account_info(&self, account_info: &AccountInfo) {\n        let response_time = SystemTime::now();\n        let last_updated_sec = response_time.duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_secs();\n        let account = Some(AccountStatus { account_info: account_info.clone(), last_updated_sec });\n        self.change_config(|config| config.cached_account_status = account);\n    }\n\n    // Only intended to be called after use (on disconnect). Rotation schedules are fairly arbitrary, so using the key one more time is fine. The benefit is that we don't trigger rotation if the user stops using the client, but the client is still auto-starting. This does not imply the effect of `Self::register_cached_wireguard_key_if_new`. It's the callers responsibility to ensure that registration is triggered asap.\n    pub fn rotate_wireguard_key_if_required(&self) {\n        self.change(|inner| {\n            inner.config.change(|config| {\n                config.wireguard_key_cache.rotate_if_required(inner.set_keychain_wg_sk.as_ref());\n            })\n        })\n    }\n\n    // Registers the current wireguard key via the API server if it has not been registered yet. Because this function is a NOOP after first successful use (until key rotation), it may be called frequently. Most importantly it should be called after disconnecting (due to possible key rotation) and after observing that the user paid.\n    pub async fn register_cached_wireguard_key_if_new(&self) -> Result<(), ApiError> {\n        let key_pair = self.change(|inner| {\n            inner\n                .config\n                .change(|config| config.wireguard_key_cache.need_registration(inner.set_keychain_wg_sk.as_ref()))\n        });\n        let Some((current_public_key, old_public_keys)) = key_pair else {\n            tracing::info!(\"public wireguard key already registered\");\n            return Ok(());\n        };\n        let cmd = CacheWgKey {\n            public_key: WgPubkey(current_public_key.to_bytes()),\n            previous_public_keys: old_public_keys.iter().map(|p| WgPubkey(p.to_bytes())).collect(),\n        };\n        match self.api_request(cmd).await {\n            Ok(()) => {\n                self.change_config(|config| config.wireguard_key_cache.registered(current_public_key, &old_public_keys));\n                tracing::info!(\"successfully registered public wireguard key\");\n                Ok(())\n            }\n            Err(error) => {\n                if matches!(error.api_error_kind(), Some(ApiErrorKind::WgKeyRotationRequired {})) {\n                    tracing::warn!(?error, \"server indicated that key rotation is required immediately\");\n                    self.change(|inner| {\n                        inner.config.change(|config| {\n                            config.wireguard_key_cache.rotate_now(inner.set_keychain_wg_sk.as_ref());\n                        })\n                    })\n                }\n                Err(error)\n            }\n        }\n    }\n\n    pub fn rotate_wg_key(&self) {\n        self.change(|inner| {\n            inner.config.change(|config| {\n                config.wireguard_key_cache.rotate_now(inner.set_keychain_wg_sk.as_ref());\n            })\n        })\n    }\n\n    pub async fn get_debug_info(&self) -> DebugInfo {\n        let config;\n        let network_interface;\n        let network_interface_mtu;\n        {\n            let this = self.borrow();\n            config = this.config().clone().into();\n            network_interface = this.network_interface.clone();\n            network_interface_mtu = this.network_interface.as_ref().and_then(|interface| interface_mtu(interface).ok());\n        }\n\n        let dns_obscura = tokio::spawn(debug_dns(\"v1.api.prod.obscura.net:443\"));\n\n        let dns_apple = tokio::spawn(debug_dns(\"www.apple.com:443\"));\n        let dns_google = tokio::spawn(debug_dns(\"google.com:443\"));\n\n        let dns_obscura = dns_obscura.await.unwrap_or_else(debug_panic_error);\n\n        let http_apple = tokio::spawn(debug_http(\"https://apple.com/api/ping\", dns_obscura.result.get().cloned(), true));\n        let http_google = tokio::spawn(debug_http(\"https://google.com/api/ping\", dns_obscura.result.get().cloned(), true));\n        let http_nosni = tokio::spawn(debug_http(\n            \"https://v1.api.prod.obscura.net/api/ping\",\n            dns_obscura.result.get().cloned(),\n            false,\n        ));\n        let http_obscura = tokio::spawn(debug_http(\n            \"https://v1.api.prod.obscura.net/api/ping\",\n            dns_obscura.result.get().cloned(),\n            true,\n        ));\n\n        DebugInfo {\n            config,\n            dns_apple: dns_apple.await.unwrap_or_else(debug_panic_error),\n            dns_google: dns_google.await.unwrap_or_else(debug_panic_error),\n            dns_obscura,\n            http_apple: http_apple.await.unwrap_or_else(debug_panic_error),\n            http_google: http_google.await.unwrap_or_else(debug_panic_error),\n            http_nosni: http_nosni.await.unwrap_or_else(debug_panic_error),\n            http_obscura: http_obscura.await.unwrap_or_else(debug_panic_error),\n            network_interface,\n            network_interface_mtu,\n        }\n    }\n\n    pub fn update_dns_cache(&self, name: &str, addrs: &[SocketAddr]) {\n        self.change_config(|config| {\n            config.dns_cache.set(name, addrs);\n        })\n    }\n}\n\n#[derive(Clone)]\npub struct WeakClientStateHandle(Weak<Sender<ClientState>>);\n\nimpl WeakClientStateHandle {\n    pub fn upgrade(&self) -> Option<ClientStateHandle> {\n        self.0.upgrade().map(ClientStateHandle)\n    }\n}\n"
  },
  {
    "path": "rustlib/src/config/cached.rs",
    "content": "use std::sync::Arc;\nuse std::time::Duration;\nuse std::time::SystemTime;\n\nuse serde::Deserialize;\nuse serde::Serialize;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct ConfigCached<T> {\n    version: Version,\n    pub last_updated: SystemTime,\n    pub value: T,\n}\n\nimpl<T> ConfigCached<T> {\n    pub fn new(value: T, version: Version) -> Self {\n        ConfigCached { version, last_updated: SystemTime::now(), value }\n    }\n\n    pub fn etag(&self) -> Option<&[u8]> {\n        match &self.version {\n            Version::Artificial(_) => None,\n            Version::ETag(items) => Some(items),\n        }\n    }\n\n    pub fn staleness(&self) -> Duration {\n        self.last_updated.elapsed().unwrap_or(Duration::ZERO)\n    }\n\n    pub fn version(&self) -> &[u8] {\n        match &self.version {\n            Version::Artificial(uuid) => uuid.as_bytes(),\n            Version::ETag(items) => items,\n        }\n    }\n}\n\nimpl<T> PartialEq for ConfigCached<Arc<T>> {\n    fn eq(&self, other: &Self) -> bool {\n        Arc::as_ptr(&self.value) == Arc::as_ptr(&other.value)\n    }\n}\n\nimpl<T> Eq for ConfigCached<Arc<T>> {}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub enum Version {\n    Artificial(Uuid),\n    ETag(Vec<u8>),\n}\n\nimpl Version {\n    pub fn artificial() -> Self {\n        Version::Artificial(Uuid::new_v4())\n    }\n}\n"
  },
  {
    "path": "rustlib/src/config/dns_cache.rs",
    "content": "use crate::constants::DNS_CACHE_SEED;\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse std::net::SocketAddr;\n\n#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\npub struct DnsCache {\n    entries: HashMap<String, Vec<SocketAddr>>,\n}\n\nimpl DnsCache {\n    pub fn get(&self, name: &str) -> Vec<SocketAddr> {\n        self.entries.get(name).cloned().unwrap_or_default()\n    }\n\n    pub fn set(&mut self, name: &str, addr: &[SocketAddr]) {\n        self.entries.insert(name.to_string(), addr.to_vec());\n    }\n}\n\nimpl Default for DnsCache {\n    fn default() -> Self {\n        Self {\n            entries: HashMap::from_iter(DNS_CACHE_SEED.iter().map(|(name, addrs)| (name.to_string(), addrs.to_vec()))),\n        }\n    }\n}\n"
  },
  {
    "path": "rustlib/src/config/feature_flags.rs",
    "content": "use std::str::FromStr;\n\nuse serde::{Deserialize, Serialize};\nuse serde_json::{Map, Value};\nuse serde_with::skip_serializing_none;\nuse strum::{EnumString, IntoStaticStr, VariantNames};\n\n#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq, Debug)]\n#[skip_serializing_none]\n#[serde(rename_all = \"camelCase\", default)]\npub struct FeatureFlags {\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub quic_frame_padding: Option<bool>,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub kill_switch: Option<bool>,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub force_small_mtu: Option<bool>,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub tcp_tls_tunnel: Option<bool>,\n    #[serde(flatten)]\n    other: Map<String, Value>,\n}\n\nimpl FeatureFlags {\n    pub const KEYS: &'static [&'static str] = FeatureFlagKey::VARIANTS;\n\n    pub fn set(&mut self, flag: &str, active: bool) {\n        self.change(flag, active.then_some(true));\n    }\n\n    fn change(&mut self, flag: &str, value: Option<bool>) {\n        let Ok(flag) = FeatureFlagKey::from_str(flag) else {\n            tracing::error!(\"unknown feature flag: {:?}\", flag);\n            return;\n        };\n        match flag {\n            FeatureFlagKey::QuicFramePadding => self.quic_frame_padding = value,\n            FeatureFlagKey::KillSwitch => self.kill_switch = value,\n            FeatureFlagKey::ForceSmallMtu => self.force_small_mtu = value,\n            FeatureFlagKey::TcpTlsTunnel => self.tcp_tls_tunnel = value,\n        }\n    }\n}\n\n#[derive(VariantNames, Clone, Copy, EnumString, IntoStaticStr)]\n#[strum(serialize_all = \"camelCase\")]\npub enum FeatureFlagKey {\n    QuicFramePadding,\n    KillSwitch,\n    ForceSmallMtu,\n    TcpTlsTunnel,\n}\n\n#[cfg(test)]\nmod test {\n    use super::FeatureFlags;\n\n    #[test]\n    fn check_flag_list() {\n        let _: FeatureFlags = serde_json::from_str(\"{}\").unwrap();\n        for flag in FeatureFlags::KEYS {\n            let feature_flags: FeatureFlags = serde_json::from_str(&format!(r#\"{{ \"{flag}\": true }}\"#)).unwrap();\n            assert_eq!(feature_flags.other.len(), 0)\n        }\n    }\n}\n"
  },
  {
    "path": "rustlib/src/config/mod.rs",
    "content": "mod persistence;\n\npub mod cached;\nmod dns_cache;\npub mod feature_flags;\n#[cfg(test)]\nmod persistence_test;\n\nuse crate::errors::ConfigDirty;\npub use persistence::*;\nuse std::path::PathBuf;\n\npub struct ConfigHandle {\n    config_dir: PathBuf,\n    config: Config,\n    dirty: bool,\n}\n\nimpl ConfigHandle {\n    pub fn new(config_dir: PathBuf, keychain_wg_sk: Option<&[u8]>) -> Result<Self, ConfigLoadError> {\n        let mut config = load(&config_dir, keychain_wg_sk)?;\n        config.migrate();\n        Ok(Self { dirty: false, config_dir, config })\n    }\n    pub fn change<T>(&mut self, f: impl FnOnce(&mut Config) -> T) -> T {\n        let mut new_config = self.config.clone();\n        let ret = f(&mut new_config);\n        self.dirty |= self.config != new_config;\n        if self.dirty {\n            // Config save errors are usually not recoverable and don't influence desired behavior, so we try, log and move on without returning the error\n            match save(&self.config_dir, &new_config) {\n                Ok(_) => self.dirty = false,\n                Err(error) => tracing::error!(message_id = \"C4t7uMUX\", ?error, \"error saving config: {error}\"),\n            }\n        }\n        self.config = new_config;\n        ret\n    }\n\n    pub fn check_persisted(&self) -> Result<(), ConfigDirty> {\n        (!self.dirty).then_some(()).ok_or(ConfigDirty)\n    }\n}\n\nimpl std::ops::Deref for ConfigHandle {\n    type Target = Config;\n\n    fn deref(&self) -> &Self::Target {\n        &self.config\n    }\n}\n"
  },
  {
    "path": "rustlib/src/config/persistence.rs",
    "content": "//! Atomically load, migrate and save configurations\n\nuse std::fs;\nuse std::fs::create_dir_all;\nuse std::io::{ErrorKind, Write};\nuse std::path::Path;\nuse std::sync::Arc;\nuse std::time::SystemTime;\n\nuse boringtun::x25519::StaticSecret;\nuse chrono::Utc;\nuse obscuravpn_api::cmd::ExitList;\nuse obscuravpn_api::types::{AccountId, WgPubkey};\nuse rand::rngs::OsRng;\nuse serde::{Deserialize, Serialize};\nuse std::time::Duration;\nuse tempfile::{NamedTempFile, PersistError};\nuse thiserror::Error;\nuse x25519_dalek::PublicKey;\n\nuse crate::client_state::AccountStatus;\nuse crate::config::cached::ConfigCached;\nuse crate::config::dns_cache::DnsCache;\nuse crate::config::feature_flags::FeatureFlags;\nuse crate::exit_selection::ExitSelector;\nuse crate::manager::TunnelArgs;\nuse crate::network_config::{DnsConfig, DnsContentBlock};\n\npub(super) const CONFIG_FILE: &str = \"config.json\";\n\n#[derive(Debug, Error)]\npub enum ConfigSaveError {\n    #[error(\"could not serialize config: {0}\")]\n    SerializeError(serde_json::Error),\n    #[error(\"could not create directory: {0}\")]\n    CreateDirError(std::io::Error),\n    #[error(\"could not create temporary file: {0}\")]\n    CreateTempFileError(std::io::Error),\n    #[error(\"could not write to temporary file: {0}\")]\n    TempFileWriteError(std::io::Error),\n    #[error(\"could not persist temporary file: {0}\")]\n    TempFilePersistError(PersistError),\n}\n\n#[derive(Debug, Error)]\npub enum ConfigLoadError {\n    #[error(\"could not read config: {0}\")]\n    ReadError(std::io::Error),\n    #[error(\"could not deserialize config: {0}\")]\n    DeserializeError(serde_json::Error),\n    #[error(\"config not save config: {0}\")]\n    SaveEror(ConfigSaveError),\n}\n\nfn try_load(path: &Path) -> Result<Option<Config>, ConfigLoadError> {\n    let file = match fs::File::open(path) {\n        Ok(f) => f,\n        Err(err) => {\n            if err.kind() == ErrorKind::NotFound {\n                return Ok(None);\n            }\n            return Err(ConfigLoadError::ReadError(err));\n        }\n    };\n\n    match serde_json::from_reader(file) {\n        Ok(c) => Ok(Some(c)),\n        Err(err) => Err(ConfigLoadError::DeserializeError(err)),\n    }\n}\n\n/// Load a single config path.\n///\n/// TODO: Remove after some migration period.\npub fn load(config_dir: &Path, keychain_wg_sk: Option<&[u8]>) -> Result<Config, ConfigLoadError> {\n    let path = Path::new(config_dir).join(CONFIG_FILE);\n\n    let err = match try_load(&path) {\n        Ok(config) => {\n            let mut config = config.unwrap_or_default();\n            config.wireguard_key_cache.try_set_secret_key_from_keychain(keychain_wg_sk);\n            tracing::info!(\n                config.dir =? config_dir,\n                message_id = \"q9XZcBvj\",\n                \"config::load successfully loaded the config\",\n            );\n            return Ok(config);\n        }\n        Err(error) => match error {\n            ConfigLoadError::ReadError(_) => return Err(error),\n            ConfigLoadError::DeserializeError(_) => {\n                tracing::error!(\n                    ?error,\n                    config.path =? path,\n                    message_id = \"Voosh7sa\",\n                    \"Failed to parse config, resetting.\");\n                error\n            }\n            ConfigLoadError::SaveEror(_) => {\n                tracing::warn!(\n                    ?error,\n                    config.path =? path,\n                    message_id = \"Voosh7sa\",\n                    \"Reading config file returned save error.\");\n                return Err(error);\n            }\n        },\n    };\n\n    // This may collide if failing in a tight loop, that is fine. Possibly even a feature.\n    let backup_path = Path::new(config_dir).join(format!(\"config-backup-{}.json\", Utc::now().to_rfc3339()));\n\n    // TODO: Do we want to try to clean up old backup configs?\n\n    match fs::rename(&path, &backup_path) {\n        Ok(()) => {}\n        Err(error) => {\n            tracing::error!(\n                config.path =? path,\n                config.backup_path =? backup_path,\n                ?error,\n                \"Failed to move broken config.\");\n            return Err(err);\n        }\n    }\n\n    let default_config = Default::default();\n\n    // Ensure that we can write the config. Otherwise we may just crash when the user logs in if the disk is full or some other endemic issue.\n    save(config_dir, &default_config).map_err(ConfigLoadError::SaveEror)?;\n\n    Ok(default_config)\n}\n\npub fn save(config_dir: &Path, config: &Config) -> Result<(), ConfigSaveError> {\n    let config = config.clone();\n    let json = match serde_json::to_vec_pretty(&config) {\n        Ok(json) => json,\n        Err(error) => {\n            tracing::error!(\n                ?error,\n                config.dir =? config_dir,\n                message_id = \"Chuzoe3k\",\n                \"config::save could not encode config\"\n            );\n            return Err(ConfigSaveError::SerializeError(error));\n        }\n    };\n\n    if let Err(error) = create_dir_all(config_dir) {\n        tracing::error!(\n                ?error,\n                config.dir =? config_dir,\n                message_id = \"kohLaih0\",\n                \"config::save could not create config directory\"\n        );\n        return Err(ConfigSaveError::CreateDirError(error));\n    }\n\n    let mut file = match NamedTempFile::new_in(config_dir) {\n        Ok(f) => f,\n        Err(error) => {\n            tracing::error!(\n                ?error,\n                config.dir =? config_dir,\n                message_id = \"oPie5quu\",\n                \"config::save could not create temporary file\"\n            );\n            return Err(ConfigSaveError::CreateTempFileError(error));\n        }\n    };\n\n    if let Err(error) = file.write_all(&json).and_then(|_| file.flush()) {\n        tracing::error!(\n            ?error,\n            config.dir =? config_dir,\n            message_id = \"Ua7oosei\",\n            \"config::save could not write to temporary file\"\n        );\n        return Err(ConfigSaveError::TempFileWriteError(error));\n    }\n\n    if let Err(error) = file.as_file_mut().sync_data() {\n        tracing::error!(\n            ?error,\n            config.dir =? config_dir,\n            message_id = \"Mahd5hei\",\n            \"config::save could not sync the temporary file\"\n        );\n        return Err(ConfigSaveError::TempFileWriteError(error));\n    }\n\n    let path = config_dir.join(CONFIG_FILE);\n    if let Err(error) = file.persist(path) {\n        tracing::error!(\n            ?error,\n            config.dir =? config_dir,\n            message_id = \"Ohquahj4\",\n            \"config::save could not persist the temporary file\"\n        );\n        return Err(ConfigSaveError::TempFilePersistError(error));\n    }\n\n    tracing::info!(\n        config.dir =? config_dir,\n        message_id = \"QFVysa6j\",\n        \"config::save successfully wrote the config\",\n    );\n\n    Ok(())\n}\n\n/// This is the configuration structure as stored to disk.\n///\n/// TL;DR is that you must consider both forwards and backwards compatibility when modifying this type.\n///\n/// Please follow these rules:\n/// - Never remove a field, instead remove `pub`, change the type to `()` and add `#[serde(skip)]`.\n/// - Never change a field type. Add a new field with a different name instead.\n///     - Consider reading and writing both fields for a few releases to preserve data on rollback.\n/// - Fields must never fail to parse. The best way to do this is the `#[serde(deserialize_with = \"crate::serde_safe::deserialize\")]` attribute which resets to `Default` if the fields fails to parse.\n///     - If the field value is complex you may want to make the field support partial parse failure internally as well.\n/// - The more important some data is the simpler its type should be. Consider breaking important data out of complex types into simple top-level ones to reduce the risk of it getting reset to the default value..\n/// - If the field's value needs to persist on logout, ensure so by updating `ClientState::set_account_id`\n#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]\n#[allow(clippy::manual_non_exhaustive)]\n#[serde(default)]\npub struct Config {\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub api_host_alternate: Option<String>,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub api_url: Option<String>,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub account_id: Option<AccountId>,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub auto_connect: bool,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub dns_content_block: DnsContentBlock,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub dns_cache: DnsCache,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub old_account_ids: Vec<AccountId>,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub local_tunnels_ids: Vec<String>,\n    #[serde(skip)]\n    pub exit: (), // Removed\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub feature_flags: FeatureFlags,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub in_new_account_flow: bool,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub cached_auth_token: Option<String>,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub cached_exits: Option<ConfigCached<Arc<ExitList>>>,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub pinned_locations: Vec<PinnedLocation>,\n\n    // Deprecated, left in for migration only.\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub last_chosen_exit: Option<String>,\n\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub last_chosen_exit_selector: ExitSelector,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub last_exit_selector: ExitSelector,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub tunnel_active: bool,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub tunnel_args: TunnelArgs,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub sni_relay: Option<String>,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub wireguard_key_cache: WireGuardKeyCache,\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub dns: DnsConfig,\n    #[serde(skip)]\n    pub use_wireguard_key_cache: (), // Removed\n    #[serde(deserialize_with = \"crate::serde_safe::deserialize\")]\n    pub cached_account_status: Option<AccountStatus>,\n    #[serde(skip)]\n    pub force_tcp_tls_relay_transport: (), // Removed\n}\n\nimpl Config {\n    pub fn migrate(&mut self) {\n        if self.last_chosen_exit_selector == (ExitSelector::Any {})\n            && let Some(exit) = &self.last_chosen_exit\n        {\n            self.last_chosen_exit_selector = ExitSelector::Exit { id: exit.clone() };\n        }\n    }\n}\n\n// Redact sensitive fields by default\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\npub struct ConfigDebug {\n    pub api_host_alternate: Option<String>,\n    pub api_url: Option<String>,\n    pub cached_exits: Option<ConfigCached<Arc<ExitList>>>,\n    pub dns_cache: DnsCache,\n    pub dns_content_block: DnsContentBlock,\n    pub local_tunnels_ids: Vec<String>,\n    pub feature_flags: FeatureFlags,\n    pub in_new_account_flow: bool,\n    pub pinned_locations: Vec<PinnedLocation>,\n    pub last_chosen_exit: Option<String>,\n    pub last_chosen_exit_selector: ExitSelector,\n    pub last_exit_selector: ExitSelector,\n    pub sni_relay: Option<String>,\n    pub tunnel_active: bool,\n    pub tunnel_args: TunnelArgs,\n    pub dns: DnsConfig,\n    pub has_account_id: bool,\n    pub has_cached_auth_token: bool,\n    pub auto_connect: bool,\n}\n\nimpl From<Config> for ConfigDebug {\n    fn from(config: Config) -> Self {\n        let Config {\n            api_host_alternate,\n            api_url,\n            account_id,\n            dns_content_block,\n            dns_cache,\n            old_account_ids: _,\n            local_tunnels_ids,\n            exit: (),\n            feature_flags,\n            in_new_account_flow,\n            cached_auth_token,\n            cached_exits,\n            pinned_locations,\n            last_chosen_exit,\n            last_chosen_exit_selector,\n            last_exit_selector,\n            sni_relay,\n            wireguard_key_cache: _,\n            dns,\n            use_wireguard_key_cache: (),\n            cached_account_status: _,\n            auto_connect,\n            force_tcp_tls_relay_transport: (),\n            tunnel_active,\n            tunnel_args,\n        } = config;\n        Self {\n            api_url,\n            cached_exits,\n            dns_content_block,\n            dns_cache,\n            local_tunnels_ids,\n            feature_flags,\n            in_new_account_flow,\n            pinned_locations,\n            last_chosen_exit,\n            last_chosen_exit_selector,\n            last_exit_selector,\n            api_host_alternate,\n            sni_relay,\n            dns,\n            has_account_id: account_id.is_some(),\n            has_cached_auth_token: cached_auth_token.is_some(),\n            auto_connect,\n            tunnel_active,\n            tunnel_args,\n        }\n    }\n}\n\n#[serde_with::serde_as]\n#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]\npub struct PinnedLocation {\n    pub country_code: String,\n    pub city_code: String,\n\n    #[serde_as(as = \"serde_with::TimestampSeconds\")]\n    pub pinned_at: SystemTime,\n}\n\n#[serde_with::serde_as]\n#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Default)]\npub struct WireGuardKeyCache {\n    #[serde(flatten)]\n    key_pair: Option<WireGuardKeyCacheKeyPair>,\n    #[serde_as(as = \"Option<serde_with::TimestampSeconds>\")]\n    first_use: Option<SystemTime>,\n    #[serde_as(as = \"Option<serde_with::TimestampSeconds>\")]\n    registered_at: Option<SystemTime>,\n    #[serde_as(as = \"Vec<serde_with::base64::Base64>\")]\n    old_public_keys: Vec<[u8; 32]>,\n}\n\n#[serde_with::serde_as]\n#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)]\n#[serde(untagged)]\npub enum WireGuardKeyCacheKeyPair {\n    // secret key is stored in plain text in config\n    Config {\n        #[serde_as(as = \"serde_with::base64::Base64\")]\n        secret_key: [u8; 32],\n    },\n    // secret key is stored in keychain (only public key is stored in plain text in config)\n    Keychain {\n        #[serde_as(as = \"serde_with::base64::Base64\")]\n        public_key: [u8; 32],\n        #[serde(skip)]\n        secret_key: Option<[u8; 32]>,\n    },\n}\n\npub type KeychainSetSecretKeyFn = Box<dyn (Fn(&[u8; 32]) -> bool) + Sync + Send>;\n\nimpl core::fmt::Debug for WireGuardKeyCache {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        let Self { key_pair, old_public_keys, first_use, registered_at } = self;\n        let (secret_key_exists, public_key) = match key_pair {\n            Some(WireGuardKeyCacheKeyPair::Config { secret_key }) => {\n                (true, Some(WgPubkey(PublicKey::from(&StaticSecret::from(*secret_key)).to_bytes())))\n            }\n            Some(WireGuardKeyCacheKeyPair::Keychain { public_key, secret_key }) => (secret_key.is_some(), Some(WgPubkey(*public_key))),\n            None => (false, None),\n        };\n        let old_public_keys: Vec<WgPubkey> = old_public_keys.iter().map(|b| WgPubkey(*b)).collect();\n        f.debug_struct(\"WireGuardKeyCache\")\n            .field(\"secret_key\", &secret_key_exists.then_some(\"redacted\"))\n            .field(\"public_key\", &public_key)\n            .field(\"first_use\", first_use)\n            .field(\"registered_at\", registered_at)\n            .field(\"old_public_keys\", &old_public_keys)\n            .finish()\n    }\n}\n\nimpl WireGuardKeyCache {\n    /// Sets secret key to provided value if it matches the known public key.\n    pub fn try_set_secret_key_from_keychain(&mut self, keychain_secret_key: Option<&[u8]>) {\n        let Some(keychain_secret_key) = keychain_secret_key else {\n            tracing::info!(message_id = \"0wth7DUt\", \"no secret key from keychain provided\");\n            return;\n        };\n        let Ok(keychain_secret_key): Result<[u8; 32], _> = keychain_secret_key.try_into() else {\n            tracing::error!(\n                message_id = \"qEGlqS8N\",\n                \"provided secret key from keychain has wrong length: {}\",\n                keychain_secret_key.len()\n            );\n            return;\n        };\n        match self.key_pair {\n            Some(WireGuardKeyCacheKeyPair::Config { secret_key: _ } | WireGuardKeyCacheKeyPair::Keychain { public_key: _, secret_key: Some(_) }) => {\n                tracing::error!(message_id = \"9BXU4iWo\", \"secret already set ignoring secret key from keychain\");\n            }\n            Some(WireGuardKeyCacheKeyPair::Keychain { public_key, secret_key: None }) => {\n                let keychain_secret_key = StaticSecret::from(keychain_secret_key);\n                let keychain_public_key = PublicKey::from(&keychain_secret_key);\n                if keychain_public_key.as_bytes() == &public_key {\n                    tracing::info!(message_id = \"5ZRaCxBA\", \"secret key from keychain matches public key\");\n                    self.key_pair = Some(WireGuardKeyCacheKeyPair::Keychain { secret_key: Some(keychain_secret_key.to_bytes()), public_key });\n                } else {\n                    tracing::error!(\n                        message_id = \"SzJPkoJA\",\n                        \"public key does not match secret key from keychain, ignoring secret key from keychain\"\n                    );\n                }\n            }\n            None => tracing::error!(message_id = \"S6uSP4ql\", \"no key pair set, ignoring secret key from keychain\"),\n        }\n    }\n    fn ensure_key_pair(&mut self, set_keychain_wg_sk: Option<&KeychainSetSecretKeyFn>) -> (StaticSecret, PublicKey) {\n        match self.key_pair {\n            Some(WireGuardKeyCacheKeyPair::Config { secret_key })\n            | Some(WireGuardKeyCacheKeyPair::Keychain { public_key: _, secret_key: Some(secret_key) }) => {\n                let secret_key = StaticSecret::from(secret_key);\n                let public_key = PublicKey::from(&secret_key);\n                (secret_key, public_key)\n            }\n            Some(WireGuardKeyCacheKeyPair::Keychain { public_key: _, secret_key: None }) => {\n                tracing::error!(\n                    message_id = \"804Y3Qdi\",\n                    \"only public wireguard key is known, initialization from keychain failed at load\"\n                );\n                self.rotate_now_internal(set_keychain_wg_sk)\n            }\n            None => {\n                tracing::info!(message_id = \"RbSiOlzl\", \"no wireguard key pair exists yet\");\n                self.rotate_now_internal(set_keychain_wg_sk)\n            }\n        }\n    }\n    pub fn use_key_pair(&mut self, set_keychain_wg_sk: Option<&KeychainSetSecretKeyFn>) -> (StaticSecret, PublicKey) {\n        let (secret_key, public_key) = self.ensure_key_pair(set_keychain_wg_sk);\n        let now = SystemTime::now();\n        self.first_use.get_or_insert(now);\n        (secret_key, public_key)\n    }\n    pub fn rotate_now(&mut self, set_keychain_wg_sk: Option<&KeychainSetSecretKeyFn>) {\n        self.rotate_now_internal(set_keychain_wg_sk);\n    }\n    fn rotate_now_internal(&mut self, set_keychain_wg_sk: Option<&KeychainSetSecretKeyFn>) -> (StaticSecret, PublicKey) {\n        tracing::info!(\"rotating wireguard key pair\");\n        let mut old_public_keys = std::mem::take(&mut self.old_public_keys);\n        let current_public_key = match self.key_pair {\n            Some(WireGuardKeyCacheKeyPair::Config { secret_key }) => Some(PublicKey::from(&StaticSecret::from(secret_key)).to_bytes()),\n            Some(WireGuardKeyCacheKeyPair::Keychain { public_key, secret_key: _ }) => Some(public_key),\n            None => None,\n        };\n        if let Some(current_public_key) = current_public_key {\n            old_public_keys.push(current_public_key);\n        }\n\n        let secret_key = StaticSecret::random_from_rng(OsRng);\n        let public_key = PublicKey::from(&secret_key);\n        let key_pair = if let Some(set_keychain_wg_sk) = set_keychain_wg_sk {\n            if !set_keychain_wg_sk(&secret_key.to_bytes()) {\n                tracing::error!(message_id = \"WuqX5xSE\", \"failed to set secret key in keychain\");\n            }\n            WireGuardKeyCacheKeyPair::Keychain { public_key: public_key.to_bytes(), secret_key: Some(secret_key.to_bytes()) }\n        } else {\n            WireGuardKeyCacheKeyPair::Config { secret_key: secret_key.to_bytes() }\n        };\n\n        *self = Self { key_pair: Some(key_pair), first_use: None, registered_at: None, old_public_keys };\n        (secret_key, public_key)\n    }\n    pub fn rotate_if_required(&mut self, set_keychain_wg_sk: Option<&KeychainSetSecretKeyFn>) {\n        const MAX_AGE: Duration = Duration::from_secs(60 * 60 * 24 * 30); // 30 days\n        if self.first_use.is_some_and(|t| t.elapsed().is_ok_and(|age| age > MAX_AGE)) {\n            self.rotate_now(set_keychain_wg_sk);\n        } else {\n            tracing::info!(\"no wireguard key pair rotation required\");\n        }\n    }\n    pub fn need_registration(&mut self, set_keychain_wg_sk: Option<&KeychainSetSecretKeyFn>) -> Option<(PublicKey, Vec<PublicKey>)> {\n        let (_, public_key) = self.ensure_key_pair(set_keychain_wg_sk);\n        if self.registered_at.is_none() {\n            let old_public_keys = self.old_public_keys.iter().copied().map(Into::into).collect();\n            return Some((public_key, old_public_keys));\n        }\n        None\n    }\n    pub fn registered(&mut self, registered_public_key: PublicKey, removed_public_keys: &[PublicKey]) {\n        let current_public_key = match self.key_pair {\n            Some(WireGuardKeyCacheKeyPair::Config { secret_key }) => Some(PublicKey::from(&StaticSecret::from(secret_key))),\n            Some(WireGuardKeyCacheKeyPair::Keychain { public_key, secret_key: _ }) => Some(PublicKey::from(public_key)),\n            None => None,\n        };\n        if Some(registered_public_key) == current_public_key {\n            self.registered_at = Some(SystemTime::now());\n        }\n        self.old_public_keys.retain(|b| !removed_public_keys.contains(&PublicKey::from(*b)));\n    }\n}\n"
  },
  {
    "path": "rustlib/src/config/persistence_test.rs",
    "content": "use std::fs;\nuse std::os::unix::fs::PermissionsExt;\nuse std::path::Path;\nuse std::sync::Arc;\nuse std::time::SystemTime;\n\nuse obscuravpn_api::cmd::ExitList;\nuse obscuravpn_api::types::AccountId;\nuse obscuravpn_api::types::CityCode;\nuse obscuravpn_api::types::CountryCode;\nuse obscuravpn_api::types::OneExit;\nuse tempfile::tempdir;\nuse uuid::Uuid;\n\nuse crate::config::CONFIG_FILE;\nuse crate::config::Config;\nuse crate::config::PinnedLocation;\nuse crate::config::cached::ConfigCached;\nuse crate::config::load;\nuse crate::config::save;\nuse crate::exit_selection::ExitSelector;\n\nfn random_config() -> Config {\n    Config {\n        api_url: Some(Uuid::new_v4().to_string()),\n        account_id: Some(AccountId::from_string_unchecked(Uuid::new_v4().to_string())),\n        old_account_ids: vec![AccountId::from_string_unchecked(Uuid::new_v4().to_string())],\n        local_tunnels_ids: vec![Uuid::new_v4().to_string()],\n        ..Default::default()\n    }\n}\n\n#[test]\nfn load_no_config() {\n    let dir = Path::new(\"/var/empty/\");\n    assert_eq!(load(dir, None).unwrap(), Default::default());\n}\n\n#[test]\nfn load_config() {\n    let config = random_config();\n\n    let dir = tempdir().unwrap();\n\n    save(dir.as_ref(), &config).unwrap();\n\n    assert_eq!(load(dir.as_ref(), None).unwrap(), config);\n}\n\n#[test]\nfn load_invalid_json() {\n    let dir = tempdir().unwrap();\n    let file = dir.as_ref().join(CONFIG_FILE);\n\n    let corrupted = \"{\";\n    fs::write(&file, corrupted).unwrap();\n\n    // Load returns a default config.\n    assert_eq!(load(dir.as_ref(), None).unwrap(), Default::default());\n\n    let backup_files = fs::read_dir(&dir)\n        .unwrap()\n        .map(|entry| entry.unwrap())\n        .filter(|entry| entry.file_name().to_string_lossy().starts_with(\"config-backup-\"))\n        .collect::<Vec<_>>();\n\n    assert_eq!(backup_files.len(), 1);\n    let backup = &backup_files[0];\n\n    // Original file was saved.\n    let backup_contents = fs::read_to_string(backup.path()).unwrap();\n    assert_eq!(backup_contents, corrupted);\n\n    // Config file was eagerly updated to the default.\n    let config_contents = fs::read_to_string(&file).unwrap();\n    assert_ne!(config_contents, corrupted);\n}\n\n#[test]\nfn load_empty() {\n    let dir = tempdir().unwrap();\n    let file = dir.as_ref().join(CONFIG_FILE);\n\n    let empty = r#\"{\"removed_field\":\"abc\"}\"#;\n    fs::write(&file, empty).unwrap();\n\n    // Load returns a default config.\n    assert_eq!(load(dir.as_ref(), None).unwrap(), Default::default());\n\n    let backup_files = fs::read_dir(&dir)\n        .unwrap()\n        .map(|entry| entry.unwrap())\n        .filter(|entry| entry.file_name().to_string_lossy().starts_with(\"config-backup-\"))\n        .collect::<Vec<_>>();\n\n    assert_eq!(backup_files.len(), 0);\n}\n\n#[test]\nfn load_no_permission() {\n    let config = random_config();\n\n    let dir = tempdir().unwrap();\n\n    save(dir.as_ref(), &config).unwrap();\n\n    let file = dir.as_ref().join(CONFIG_FILE);\n\n    // Mess up the file permissions so that reads fail.\n    let original_permissions = fs::metadata(&file).unwrap().permissions();\n    let mut permissions = original_permissions.clone();\n    permissions.set_readonly(true);\n    permissions.set_mode(0o0);\n    fs::set_permissions(&file, permissions).unwrap();\n\n    assert!(load(dir.as_ref(), None).is_err());\n}\n\n#[test]\nfn test_ignore_invalid_fields() {\n    let example_config = Config {\n        api_host_alternate: Some(\"relay.example\".into()),\n        api_url: Some(\"myapi\".into()),\n        account_id: Some(AccountId::from_string_unchecked(\"myaccount\".into())),\n        old_account_ids: vec![AccountId::from_string_unchecked(\"oldaccount\".into())],\n        local_tunnels_ids: vec![\"oldtunnel\".into()],\n        exit: (),\n        in_new_account_flow: true,\n        cached_auth_token: Some(\"myauth\".into()),\n        cached_exits: Some(ConfigCached::new(\n            Arc::new(ExitList {\n                exits: vec![OneExit {\n                    id: \"ABC-123\".into(),\n                    city_code: CityCode { country_code: CountryCode(\"ca\".into()), city_code: \"yyz\".into() },\n                    city_name: \"Toronto\".into(),\n                    datacenter_id: 34,\n                    provider_id: \"foo123\".into(),\n                    provider_url: \"https://servers.example/foo123\".into(),\n                    provider_name: \"Cheap Server Rentals\".into(),\n                    provider_homepage_url: \"https://example.com\".into(),\n                    tier: 0,\n                }],\n            }),\n            super::cached::Version::artificial(),\n        )),\n        pinned_locations: vec![PinnedLocation { country_code: \"CA\".into(), city_code: \"yyz\".into(), pinned_at: SystemTime::UNIX_EPOCH }],\n        last_chosen_exit: Some(\"mylastexit\".into()),\n        last_chosen_exit_selector: ExitSelector::City { city_code: CityCode { country_code: CountryCode(\"ca\".into()), city_code: \"yyz\".into() } },\n        last_exit_selector: ExitSelector::City { city_code: CityCode { country_code: CountryCode(\"ca\".into()), city_code: \"yyz\".into() } },\n        tunnel_active: false,\n        tunnel_args: Default::default(),\n        sni_relay: Some(\"relay.obscura.net\".into()),\n        wireguard_key_cache: Default::default(),\n        dns: Default::default(),\n        dns_cache: Default::default(),\n        use_wireguard_key_cache: (),\n        cached_account_status: Default::default(),\n        auto_connect: true,\n        feature_flags: Default::default(),\n        force_tcp_tls_relay_transport: (),\n        dns_content_block: Default::default(),\n    };\n    let example_json = match serde_json::to_value(&example_config).unwrap() {\n        serde_json::Value::Object(m) => m,\n        other => panic!(\"Expected map, got {:?}\", other),\n    };\n\n    let corrupt_values = [\n        serde_json::Value::Object(Default::default()),\n        serde_json::Value::Number(7.into()),\n        serde_json::Value::Null,\n        serde_json::Value::Array(vec![serde_json::Value::Bool(true)]),\n    ];\n\n    for (field, _) in &example_json {\n        let mut mutated = example_json.clone();\n        for corrupt in &corrupt_values {\n            eprintln!(\"Setting {field:?} = {corrupt:?}\");\n            mutated[field] = corrupt.clone();\n\n            let reserialized = serde_json::to_string_pretty(&mutated).unwrap();\n            let parsed = serde_json::from_str::<Config>(&reserialized).unwrap();\n            let json_repr = match serde_json::to_value(&parsed).unwrap() {\n                serde_json::Value::Object(m) => m,\n                other => panic!(\"Expected map, got {:?}\", other),\n            };\n            for (k, v) in json_repr {\n                if &k == field {\n                    continue;\n                }\n                assert_eq!(v, example_json[&k], \"Field {k:?} doesn't match.\");\n            }\n        }\n\n        eprintln!(\"Removing {field:?}\");\n        mutated.remove(field);\n        let reserialized = serde_json::to_string_pretty(&mutated).unwrap();\n        let _ = serde_json::from_str::<Config>(&reserialized).unwrap();\n    }\n}\n"
  },
  {
    "path": "rustlib/src/constants.rs",
    "content": "// This file contains constants, which may need to be updated at some point\n\nuse const_format::formatcp;\nuse std::net::{IpAddr, Ipv4Addr, SocketAddr};\n\npub const DEFAULT_API_DOMAIN: &str = \"v1.api.prod.obscura.net\";\npub const DEFAULT_API_URL: &str = formatcp!(\"https://{DEFAULT_API_DOMAIN}/api\");\npub const DNS_CACHE_SEED: &[(&str, &[SocketAddr])] = &[(DEFAULT_API_DOMAIN, &[SocketAddr::new(IpAddr::V4(Ipv4Addr::new(66, 42, 95, 12)), 0)])];\n\npub const DEFAULT_API_BACKUP_DOMAIN: &str = \"crimsonlance.net\";\npub const DEFAULT_RELAY_SNI: &str = \"example.com\";\n"
  },
  {
    "path": "rustlib/src/debug_archive/builder.rs",
    "content": "use super::zipper::Zipper;\nuse anyhow::Context as _;\nuse camino::{Utf8Path, Utf8PathBuf};\nuse chrono::{SecondsFormat, Utc};\nuse serde::Serialize;\nuse std::fmt::{Debug, Display};\n\n#[derive(Debug)]\npub struct DebugArchiveBuilder {\n    zipper: Zipper,\n}\n\nimpl DebugArchiveBuilder {\n    pub fn new() -> anyhow::Result<Self> {\n        let dst_parent = Utf8PathBuf::try_from(std::env::temp_dir())\n            .context(\"temp dir path wasn't valid UTF-8\")?\n            .join(\"debug-archives\");\n        std::fs::create_dir_all(&dst_parent).with_context(|| format!(\"failed to create dirs for {dst_parent:?}\"))?;\n        let zipper = Zipper::new(\n            &dst_parent,\n            format!(\n                \"Obscura Debugging Archive {}\",\n                // On Android, colons can't be used in user data directories.\n                // Remove them in case users try to save the archive to one of these locations (and the saving app doesn't cleanse the name).\n                Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true).replace(':', \"_\")\n            ),\n        )?;\n        Ok(Self { zipper })\n    }\n\n    fn write_error(&mut self, name: &str, error: impl Debug + Display) {\n        tracing::error!(message_id = \"dezX8SLf\", ?error, \"failed to archive {name:?}\");\n        if let Err(error) = self.zipper.write_file(format!(\"archive-error-{name}.txt\"), error.to_string().as_bytes()) {\n            tracing::error!(message_id = \"eWLYHIG7\", ?error, \"failed to archive error for {name:?}\");\n        }\n    }\n\n    fn add(&mut self, name: &str, f: impl FnOnce(&mut Zipper) -> anyhow::Result<()>) {\n        if let Err(error) = f(&mut self.zipper) {\n            self.write_error(name, error);\n        }\n    }\n\n    pub fn add_bytes(&mut self, name: &str, ext: &str, data: &[u8]) {\n        self.add(name, |zipper| zipper.write_file(format!(\"{name}.{ext}\"), data));\n    }\n\n    pub fn add_txt(&mut self, name: &str, text: &str) {\n        self.add_bytes(name, \"txt\", text.as_bytes());\n    }\n\n    #[allow(unused)]\n    pub fn add_json(&mut self, name: &str, value: &impl Serialize) {\n        self.add(name, |zipper| {\n            zipper.write_file(format!(\"{name}.json\"), &serde_json::to_vec_pretty(value)?)\n        });\n    }\n\n    pub fn add_path(&mut self, name: &str, ext: Option<&str>, path: &Utf8Path) {\n        self.add(name, |zipper| {\n            if let Some(ext) = ext {\n                zipper.copy_from_fs(path, format!(\"{name}.{ext}\").as_ref())\n            } else {\n                zipper.copy_from_fs(path, name.as_ref())\n            }\n        });\n    }\n\n    pub fn add_cmd(&mut self, name: &str, ext: &str, mut cmd: diva::Command) {\n        self.add(name, |zipper| {\n            let output = cmd.run_and_wait_for_output()?;\n            zipper.write_file(format!(\"{name}-stdout.{ext}\"), output.stdout())?;\n            zipper.write_file(format!(\"{name}-stderr.txt\"), output.stderr())?;\n            if !output.success()\n                && let Some(code) = output.status().code()\n            {\n                zipper.write_file(format!(\"{name}-status.txt\"), &code.to_le_bytes())?;\n            }\n            Ok(())\n        });\n    }\n\n    pub fn finish(self) -> anyhow::Result<Utf8PathBuf> {\n        self.zipper.finish()\n    }\n}\n"
  },
  {
    "path": "rustlib/src/debug_archive/dns.rs",
    "content": "use crate::debug_archive::task::DebugTask;\nuse crate::debug_archive::task::run_debug_task;\nuse serde::Deserialize;\nuse serde::Serialize;\nuse std::net::IpAddr;\nuse tokio::net::lookup_host;\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct DnsTask {\n    pub addrs: Vec<IpAddr>,\n}\n\npub async fn debug_dns(host_port: &'static str) -> DebugTask<DnsTask> {\n    run_debug_task(async { Ok(DnsTask { addrs: lookup_host(host_port).await?.map(|socket_addr| socket_addr.ip()).collect() }) }).await\n}\n"
  },
  {
    "path": "rustlib/src/debug_archive/http.rs",
    "content": "use std::net::IpAddr;\nuse std::net::SocketAddr;\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse crate::debug_archive::dns::DnsTask;\nuse crate::debug_archive::task::DebugTask;\nuse crate::debug_archive::task::run_debug_task;\nuse reqwest::dns::Resolve;\nuse reqwest::dns::Resolving;\nuse serde::Deserialize;\nuse serde::Serialize;\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct HttpTask {\n    body: Option<String>,\n    error: Option<String>,\n    header_content_type: Option<String>,\n    header_date: Option<String>,\n    http_version: Option<String>,\n    status_code: Option<u16>,\n}\n\npub async fn debug_http(url: &'static str, dns: Option<DnsTask>, sni: bool) -> DebugTask<HttpTask> {\n    run_debug_task(async {\n        let mut result = HttpTask {\n            body: None,\n            error: None,\n            header_content_type: None,\n            header_date: None,\n            http_version: None,\n            status_code: None,\n        };\n\n        let dns = dns.ok_or(\"No DNS available.\")?;\n\n        let client = reqwest::Client::builder()\n            .danger_accept_invalid_certs(true)\n            .dns_resolver(Arc::new(FixedResolver(dns.addrs)))\n            .min_tls_version(reqwest::tls::Version::TLS_1_0)\n            .timeout(Duration::from_secs(55))\n            .tls_sni(sni)\n            .build()?;\n\n        let res = match client.get(url).send().await {\n            Ok(r) => r,\n            Err(err) => {\n                result.error = Some(err.to_string());\n                return Ok(result);\n            }\n        };\n        result.http_version = Some(format!(\"{:?}\", res.version()));\n        result.status_code = Some(res.status().as_u16());\n\n        // TODO: Get certificate info. Reqwest doesn't seem to make this readily available.\n        // This probably isn't a big deal because if there is a mismatch the regular logs would make it clear but it would be interesting to see what cert we get.\n\n        let headers = res.headers();\n        let header_str = |name| headers.get(name)?.to_str().ok().map(|s| s.to_string());\n        result.header_content_type = header_str(reqwest::header::CONTENT_TYPE);\n        result.header_date = header_str(reqwest::header::DATE);\n\n        result.body = Some(match res.text().await {\n            Ok(t) => t,\n            Err(err) => {\n                result.error = Some(err.to_string());\n                return Ok(result);\n            }\n        });\n\n        Ok(result)\n    })\n    .await\n}\n\nstruct FixedResolver(Vec<IpAddr>);\n\nimpl Resolve for FixedResolver {\n    fn resolve(&self, _: reqwest::dns::Name) -> Resolving {\n        let ips = self.0.clone();\n        Box::pin(async move { Ok(Box::new(ips.into_iter().map(|ip| SocketAddr::new(ip, 0))) as _) })\n    }\n}\n"
  },
  {
    "path": "rustlib/src/debug_archive/info.rs",
    "content": "use crate::{\n    config::ConfigDebug,\n    debug_archive::{dns::DnsTask, http::HttpTask, task::DebugTask},\n    net::NetworkInterface,\n};\nuse serde::{Deserialize, Serialize};\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct DebugInfo {\n    pub config: ConfigDebug,\n    pub dns_apple: DebugTask<DnsTask>,\n    pub dns_google: DebugTask<DnsTask>,\n    pub dns_obscura: DebugTask<DnsTask>,\n    pub http_apple: DebugTask<HttpTask>,\n    pub http_google: DebugTask<HttpTask>,\n    pub http_nosni: DebugTask<HttpTask>,\n    pub http_obscura: DebugTask<HttpTask>,\n    pub network_interface: Option<NetworkInterface>,\n    pub network_interface_mtu: Option<i32>,\n}\n"
  },
  {
    "path": "rustlib/src/debug_archive/mod.rs",
    "content": "mod builder;\npub mod dns;\npub mod http;\npub mod info;\npub mod task;\nmod zipper;\nuse self::builder::DebugArchiveBuilder;\nuse crate::debug_archive::info::DebugInfo;\nuse camino::{Utf8Path, Utf8PathBuf};\n\n// TODO: https://linear.app/soveng/issue/OBS-3095/cross-platform-debug-archive-story\npub fn create_debug_archive(user_feedback: Option<&str>, debug_info: DebugInfo, rust_log_dir: Option<&Utf8Path>) -> anyhow::Result<Utf8PathBuf> {\n    let mut archive = DebugArchiveBuilder::new()?;\n    archive.add_json(\"ne-debug-info\", &debug_info);\n    if let Some(user_feedback) = user_feedback {\n        archive.add_txt(\"user-feedback\", user_feedback);\n    }\n    if let Some(rust_log_dir) = rust_log_dir {\n        archive.add_path(\"rust-log\", None, rust_log_dir);\n    }\n    if cfg!(target_os = \"android\") {\n        // This isn't guaranteed to work, but Android unfortunately doesn't\n        // provide a proper API for this.\n        archive.add_cmd(\"logcat\", \"txt\", diva::Command::parse(\"logcat -d\"));\n    }\n    archive.finish()\n}\n"
  },
  {
    "path": "rustlib/src/debug_archive/task.rs",
    "content": "use serde::Deserialize;\nuse serde::Serialize;\nuse std::error::Error;\nuse std::time::Duration;\nuse std::time::Instant;\nuse tokio::task::JoinError;\nuse tokio::time::timeout;\n\nconst TASK_TIMEOUT: Duration = Duration::from_secs(60);\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct DebugTask<T> {\n    total_s: f32,\n    pub result: TaskResult<T>,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub enum TaskResult<T> {\n    Failure(String),\n    Panic(String),\n    Success(T),\n    Timeout,\n}\n\nimpl<T> TaskResult<T> {\n    pub fn get(&self) -> Option<&T> {\n        match self {\n            TaskResult::Success(v) => Some(v),\n            _ => None,\n        }\n    }\n}\n\npub async fn run_debug_task<T>(task: impl Future<Output = Result<T, Box<dyn Error>>>) -> DebugTask<T> {\n    let start = Instant::now();\n    let result = match timeout(TASK_TIMEOUT, task).await {\n        Ok(Ok(r)) => TaskResult::Success(r),\n        Ok(Err(err)) => TaskResult::Failure(format!(\"{:?}\", err)),\n        Err(_) => TaskResult::Timeout,\n    };\n\n    DebugTask { result, total_s: start.elapsed().as_secs_f32() }\n}\n\npub fn debug_panic_error<T>(error: JoinError) -> DebugTask<T> {\n    tracing::error!(message_id = \"lai6Ok9e\", ?error, \"Debug bundle task failed\",);\n    DebugTask { total_s: -1.0, result: TaskResult::Panic(error.to_string()) }\n}\n"
  },
  {
    "path": "rustlib/src/debug_archive/zipper.rs",
    "content": "use anyhow::Context;\nuse camino::{Utf8Path, Utf8PathBuf};\nuse std::{\n    fs::File,\n    io::{Read as _, Write as _},\n};\nuse zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};\n\n#[derive(Debug)]\npub struct Zipper {\n    name: String,\n    dst: Utf8PathBuf,\n    writer: ZipWriter<File>,\n    options: SimpleFileOptions,\n    buf: Vec<u8>,\n}\n\nimpl Zipper {\n    pub fn new(dst_parent: &Utf8Path, name: String) -> anyhow::Result<Self> {\n        let mut dst = dst_parent.join(&name);\n        dst.set_extension(\"zip\");\n        tracing::debug!(message_id = \"tpKqzGkz\", ?dst, \"starting archive\");\n        let writer = ZipWriter::new(File::create(&dst)?);\n        Ok(Self {\n            name,\n            dst,\n            writer,\n            options: SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),\n            buf: Vec::new(),\n        })\n    }\n\n    fn root(&self) -> &Utf8Path {\n        self.name.as_ref()\n    }\n\n    /// `dst` is relative to the archive root.\n    /// Parent directories must be created using `create_dir` first.\n    #[allow(unused)]\n    pub fn create_dir(&mut self, dst: impl AsRef<Utf8Path>) -> anyhow::Result<()> {\n        self.writer\n            .add_directory(Utf8PathBuf::from_iter([self.root(), dst.as_ref()]), self.options)\n            .map_err(Into::into)\n    }\n\n    /// `dst` is relative to the archive root.\n    /// Parent directories must be created using `create_dir` first.\n    pub fn write_file(&mut self, dst: impl AsRef<Utf8Path>, data: &[u8]) -> anyhow::Result<()> {\n        self.writer\n            .start_file(Utf8PathBuf::from_iter([self.root(), dst.as_ref()]), self.options)?;\n        let result = self.writer.write_all(data);\n        if result.is_err() {\n            self.writer.abort_file()?;\n        }\n        result.map_err(Into::into)\n    }\n\n    /// Recursively copies existing files into the archive. If any errors are\n    /// encountered while recursing, that path is skipped.\n    ///\n    /// `dst` is relative to the archive root.\n    /// Parent directories must be created using `create_dir` first.\n    pub fn copy_from_fs(&mut self, src: &Utf8Path, dst: &Utf8Path) -> anyhow::Result<()> {\n        let dst_abs = Utf8PathBuf::from_iter([self.root(), dst]);\n        let metadata = src.metadata()?;\n        tracing::debug!(message_id = \"r9oFrh5g\", ?src, ?dst, ?metadata, \"started copy\");\n        if metadata.is_dir() {\n            self.writer.add_directory(dst_abs.as_str(), self.options)?;\n            for entry in src.read_dir_utf8()? {\n                let entry = entry?;\n                let entry_path = entry.path();\n                let file_name = entry_path\n                    .file_name()\n                    .with_context(|| format!(\"entry path {entry_path:?} had no file name\"))?;\n                if let Err(error) = self.copy_from_fs(entry_path, &dst.join(file_name)) {\n                    tracing::error!(message_id = \"3MrmBtrD\", ?error, ?entry, \"failed to copy entry; skipping\");\n                }\n            }\n        } else if metadata.is_file() {\n            let mut file = File::open(src)?;\n            file.read_to_end(&mut self.buf)?;\n            self.writer.start_file(&dst_abs, self.options)?;\n            let result = self.writer.write_all(&self.buf);\n            self.buf.clear();\n            if result.is_err() {\n                self.writer.abort_file()?;\n            }\n            result?;\n        } else {\n            anyhow::bail!(\"neither a dir nor a regular file: {src:?}\");\n        }\n        tracing::debug!(message_id = \"UfZicz2T\", ?src, ?dst, ?metadata, \"finished copy\");\n        Ok(())\n    }\n\n    pub fn finish(self) -> anyhow::Result<Utf8PathBuf> {\n        self.writer.finish()?;\n        tracing::debug!(message_id = \"1HFVjluv\", dst =? self.dst, \"finished archive\");\n        Ok(self.dst)\n    }\n}\n"
  },
  {
    "path": "rustlib/src/dns.rs",
    "content": "use crate::client_state::WeakClientStateHandle;\nuse obscuravpn_api::reexports::reqwest::dns::{Addrs, Name, Resolve, Resolving};\nuse std::net::SocketAddr;\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::time::timeout;\n\npub struct DnsResolver {\n    client_state: WeakClientStateHandle,\n}\n\nimpl DnsResolver {\n    pub fn new(client_state: WeakClientStateHandle) -> Arc<Self> {\n        Arc::new(Self { client_state })\n    }\n}\n\nimpl Resolve for DnsResolver {\n    fn resolve(&self, name: Name) -> Resolving {\n        let resolving = tokio::spawn(resolve_and_cache(self.client_state.clone(), name.as_str().to_string()));\n\n        let cached = match self.client_state.upgrade() {\n            None => {\n                tracing::warn!(message_id = \"F0ZR7dTm\", \"can't read from DNS cache of dropped client state\");\n                Vec::new()\n            }\n            Some(client_state) => client_state.borrow().config().dns_cache.get(name.as_str()),\n        };\n        if !cached.is_empty() {\n            let addrs: Addrs = Box::new(cached.into_iter());\n            return Box::pin(std::future::ready(Ok(addrs)));\n        }\n\n        tracing::warn!(message_id = \"ooPh8ahc\", name = name.as_str(), \"DNS cache miss, wait for resolver\");\n        Box::pin(async move {\n            let addrs = resolving.await.expect(\"DNS resolution task panicked\")?;\n            let addrs: Addrs = Box::new(addrs.into_iter());\n            Ok(addrs)\n        })\n    }\n}\n\nasync fn resolve_and_cache(client_state: WeakClientStateHandle, name: String) -> Result<Vec<SocketAddr>, Box<dyn std::error::Error + Send + Sync>> {\n    const TIMEOUT: Duration = Duration::from_secs(60);\n\n    let name = name.as_str();\n    match timeout(TIMEOUT, tokio::net::lookup_host((name, 0u16))).await {\n        Ok(Ok(addrs)) => {\n            let addrs: Vec<_> = addrs.collect();\n            if !addrs.is_empty() {\n                tracing::info!(message_id = \"ea1Ooquu\", name, ?addrs, \"DNS resolution succeeded\");\n                match client_state.upgrade() {\n                    None => tracing::warn!(message_id = \"2aZU1KWD\", \"can't write to DNS cache of dropped client state\"),\n                    Some(client_state) => client_state.update_dns_cache(name, &addrs),\n                }\n            } else {\n                tracing::warn!(message_id = \"Uu3ohPh4\", name, \"DNS resolution returned no addresses\");\n            }\n            Ok(addrs)\n        }\n        Ok(Err(error)) => {\n            tracing::warn!(message_id = \"ieC5ahv3\", name, ?error, \"DNS resolution failed: {error}\");\n            Err(error.into())\n        }\n        Err(error) => {\n            tracing::warn!(message_id = \"RwX9EdwE\", name, ?error, \"DNS resolution timed out: {error}\");\n            Err(error.into())\n        }\n    }\n}\n"
  },
  {
    "path": "rustlib/src/errors.rs",
    "content": "use std::fmt::Formatter;\nuse std::io;\nuse std::time::Instant;\n\nuse obscuravpn_api::{ClientError, cmd::ApiErrorKind};\nuse serde::{Deserialize, Serialize};\nuse strum::IntoStaticStr;\nuse thiserror::Error;\n\nuse crate::config::ConfigSaveError;\nuse crate::quicwg::QuicWgConnectError;\n\nuse crate::network_config::NetworkConfigError;\n\n/// High-level connection error codes, which are actionable for frontends.\n/// Actionable means any of:\n/// - Useful to trigger specific frontend behavior (e.g. control flow branches)\n/// - Correlates with specific error messages shown to users\n///\n/// All remaining errors are mapped to the `Other` variant.\n/// Make sure `obscura-ui/src/translations/en.json` contains an entry for each variant.\n#[derive(Debug, Clone, Copy, Serialize, Deserialize, IntoStaticStr, PartialEq, Eq)]\n#[serde(rename_all = \"camelCase\")]\n#[strum(serialize_all = \"camelCase\")]\npub enum ConnectErrorCode {\n    AccountExpired,\n    ApiError,\n    ApiRateLimitExceeded,\n    ApiUnreachable,\n    InvalidAccountId,\n    NoInternet,\n    NoLongerSupported,\n    NoSlotsLeft,\n    Other,\n}\n\nimpl ConnectErrorCode {\n    pub fn as_static_str(&self) -> &'static str {\n        self.into()\n    }\n}\n\nimpl From<&TunnelConnectError> for ConnectErrorCode {\n    fn from(err: &TunnelConnectError) -> Self {\n        use ApiErrorKind::*;\n        tracing::info!(\"deriving connect error code for {}\", err);\n        match err {\n            TunnelConnectError::ApiError(err) => match err {\n                ApiError::NoAccountId => Self::Other,\n                ApiError::ApiClient(err) => match err {\n                    ClientError::ApiError(err) => match err.body.error {\n                        AccountExpired {} => Self::AccountExpired,\n                        InvalidAccountId {} => Self::InvalidAccountId,\n                        NoLongerSupported {} => Self::NoLongerSupported,\n                        TunnelLimitExceeded {} => Self::NoSlotsLeft,\n                        RateLimitExceeded {} => Self::ApiRateLimitExceeded,\n                        AlreadyExists {}\n                        | AlreadyReferred {}\n                        | BadRequest {}\n                        | IneligibleForReferral {}\n                        | InternalError {}\n                        | InvalidReferralCode {}\n                        | MiscUnauthorized {}\n                        | MissingOrInvalidAuthToken {}\n                        | MoneroTopUpNotFound {}\n                        | NoApiRoute {}\n                        | NoMatchingExit {}\n                        | SaleNotFound {}\n                        | SignupLimitExceeded {}\n                        | WgKeyRotationRequired {}\n                        | Unknown(_) => Self::ApiError,\n                    },\n                    ClientError::RequestExecError(_) => Self::ApiUnreachable,\n                    ClientError::ResponseTooLarge | ClientError::InvalidHeaderValue | ClientError::Other(_) | ClientError::ProtocolError(_) => {\n                        Self::Other\n                    }\n                },\n            },\n            TunnelConnectError::NoInternet => Self::NoInternet,\n            TunnelConnectError::NetworkConfig(_)\n            | TunnelConnectError::NoExit\n            | TunnelConnectError::SetOsNetworkConfig\n            | TunnelConnectError::TunnelConnect(_)\n            | TunnelConnectError::InvalidTunnelId\n            | TunnelConnectError::UnexpectedRelay\n            | TunnelConnectError::UnexpectedTunnelKind\n            | TunnelConnectError::UnexpectedInternalTunnelLifecycleState\n            | TunnelConnectError::RelaySelection(_)\n            | TunnelConnectError::ConfigSave(_) => Self::Other,\n        }\n    }\n}\n\n#[derive(Debug, Error)]\npub enum TunnelConnectError {\n    #[error(\"tunnel creation: {0}\")]\n    ApiError(#[from] ApiError),\n    #[error(\"failed to save config file\")]\n    ConfigSave(#[from] ConfigSaveError),\n    #[error(\"api returned invalid tunnel id\")]\n    InvalidTunnelId,\n    #[error(\"could not construct network config: {0}\")]\n    NetworkConfig(#[from] NetworkConfigError),\n    #[error(\"No matching exit.\")]\n    NoExit,\n    #[error(\"No internet.\")]\n    NoInternet,\n    #[error(\"relay selection failed: {0}\")]\n    RelaySelection(#[from] RelaySelectionError),\n    #[error(\"failed to set os network config\")]\n    SetOsNetworkConfig,\n    #[error(\"tunnel connect: {0}\")]\n    TunnelConnect(#[from] QuicWgConnectError),\n    #[error(\"tunnel is in unexpected internal lifecycle state\")]\n    UnexpectedInternalTunnelLifecycleState,\n    #[error(\"api returned unexpected relay\")]\n    UnexpectedRelay,\n    #[error(\"api returned unexpected tunnel kind\")]\n    UnexpectedTunnelKind,\n}\n\n#[derive(Debug, Error)]\npub enum ApiError {\n    #[error(transparent)]\n    ApiClient(#[from] ClientError),\n    #[error(\"no account id\")]\n    NoAccountId,\n}\n\n#[derive(Debug, Error)]\npub enum ConfigDirtyOrApiError {\n    #[error(transparent)]\n    ApiError(#[from] ApiError),\n    #[error(transparent)]\n    ConfigDirty(#[from] ConfigDirty),\n}\n\n#[derive(Debug, Error)]\npub struct ConfigDirty;\n\nimpl std::fmt::Display for ConfigDirty {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"config not saved to file\")\n    }\n}\n\nimpl ApiError {\n    pub fn api_error_kind(&self) -> Option<&obscuravpn_api::cmd::ApiErrorKind> {\n        if let Self::ApiClient(ClientError::ApiError(error)) = self {\n            return Some(&error.body.error);\n        }\n        None\n    }\n}\n\n#[derive(Debug, Error)]\npub enum RelaySelectionError {\n    #[error(\"all relay connections failed\")]\n    NoSuccess,\n    #[error(\"quic setup: {0}\")]\n    QuicSetup(anyhow::Error),\n    #[error(\"udp socket setup: {0}\")]\n    UdpSetup(io::Error),\n}\n\n#[derive(Debug, Error)]\npub struct ErrorAt<T: std::error::Error> {\n    pub error: T,\n    pub at: Instant,\n}\n\nimpl<T: std::error::Error> From<T> for ErrorAt<T> {\n    fn from(error: T) -> Self {\n        Self { error, at: Instant::now() }\n    }\n}\n"
  },
  {
    "path": "rustlib/src/exit_selection.rs",
    "content": "use std::{\n    collections::{HashMap, HashSet},\n    num::Saturating,\n};\n\nuse obscuravpn_api::types::{CityCode, CountryCode, OneExit, OneRelay, RelayPreferredExit};\nuse serde::{Deserialize, Serialize};\n\n#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub enum ExitSelector {\n    Any {},\n    Exit {\n        id: String,\n    },\n    Country {\n        country_code: CountryCode,\n    },\n    City {\n        #[serde(flatten)]\n        city_code: CityCode,\n    },\n}\n\nimpl ExitSelector {\n    pub fn matches(&self, candidate: &OneExit) -> bool {\n        match self {\n            ExitSelector::Any {} => true,\n            ExitSelector::Exit { id } => candidate.id == *id,\n            ExitSelector::Country { country_code } => candidate.city_code.country_code == *country_code,\n            ExitSelector::City { city_code } => candidate.city_code == *city_code,\n        }\n    }\n}\n\nimpl Default for ExitSelector {\n    fn default() -> Self {\n        ExitSelector::Any {}\n    }\n}\n\n#[derive(Debug, Default)]\npub struct ExitSelectionState {\n    selected_exit_ids: HashSet<String>,\n    selected_datacenters: HashMap<u32, Saturating<u8>>,\n    selected_cities: HashMap<CityCode, Saturating<u8>>,\n    selected_countries: HashMap<CountryCode, Saturating<u8>>,\n}\n\nimpl ExitSelectionState {\n    pub fn select_next_exit<'a>(&mut self, selector: &ExitSelector, exits: &'a [OneExit], relay: &OneRelay) -> Option<&'a OneExit> {\n        let selected = exits\n            .iter()\n            .filter(|candidate| selector.matches(candidate))\n            .filter(|candidate| !self.exclude(candidate))\n            .max_by_key(|candidate| Self::rank(candidate, &relay.city_code, &relay.preferred_exits));\n\n        if let Some(selected) = selected {\n            self.selected_exit_ids.insert(selected.id.clone());\n            *self.selected_datacenters.entry(selected.datacenter_id).or_insert(Saturating(0)) += 1;\n            *self.selected_cities.entry(selected.city_code.clone()).or_insert(Saturating(0)) += 1;\n            *self\n                .selected_countries\n                .entry(selected.city_code.country_code.clone())\n                .or_insert(Saturating(0)) += 1;\n        } else {\n            tracing::warn!(\"no exits left to select, clearing adaptive filters\");\n            *self = Self::default();\n        }\n\n        selected\n    }\n\n    fn rank(candidate: &OneExit, relay_city_code: &CityCode, relay_preferred_exits: &[RelayPreferredExit]) -> (bool, bool, bool, u8, u32) {\n        let is_preferred = relay_preferred_exits.iter().any(|e| e.id == candidate.id);\n        let same_country = relay_city_code.country_code == candidate.city_code.country_code;\n        let same_city = relay_city_code == &candidate.city_code;\n        (is_preferred, same_city, same_country, candidate.tier, rand::random())\n    }\n\n    fn exclude(&self, candidate: &OneExit) -> bool {\n        self.selected_exit_ids.contains(&candidate.id)\n            || self.selected_datacenters.get(&candidate.datacenter_id) >= Some(&Saturating(2))\n            || self.selected_cities.get(&candidate.city_code) >= Some(&Saturating(4))\n            || self.selected_countries.get(&candidate.city_code.country_code) >= Some(&Saturating(6))\n    }\n}\n"
  },
  {
    "path": "rustlib/src/ffi_helpers.rs",
    "content": "#![cfg_attr(target_os = \"android\", allow(dead_code))]\n\nuse std::{ffi::c_void, marker::PhantomData};\n\n#[repr(C)]\npub struct FfiBytes<'a> {\n    buffer: *const c_void,\n    len: usize,\n    phantom: PhantomData<&'a [u8]>,\n}\n\nimpl<'a> FfiBytes<'a> {\n    pub fn as_slice(&self) -> &'a [u8] {\n        if self.len == 0 {\n            // catch zero sized early, to allow empty null pointer buffers\n            &[]\n        } else {\n            // SAFETY: This type must be constructed such that this is safe\n            unsafe { std::slice::from_raw_parts(self.buffer as *const u8, self.len) }\n        }\n    }\n}\n\nimpl FfiBytes<'_> {\n    pub fn to_vec(&self) -> Vec<u8> {\n        self.as_slice().to_vec()\n    }\n}\n\nimpl<'a, T: AsRef<[u8]>> From<&'a T> for FfiBytes<'a> {\n    fn from(b: &'a T) -> Self {\n        let b = b.as_ref();\n        FfiBytes { buffer: b.as_ptr() as *const c_void, len: b.len(), phantom: PhantomData }\n    }\n}\n\npub trait FfiBytesExt {\n    fn ffi(&self) -> FfiBytes<'_>;\n}\n\nimpl<T: AsRef<[u8]>> FfiBytesExt for T {\n    fn ffi(&self) -> FfiBytes<'_> {\n        self.into()\n    }\n}\n\n#[repr(C)]\npub struct FfiStr<'a> {\n    bytes: FfiBytes<'a>,\n}\n\nimpl<'a> FfiStr<'a> {\n    pub fn as_str(&self) -> &'a str {\n        std::str::from_utf8(self.bytes.as_slice()).expect(\"ffi buffer does not contain valid utf8\")\n    }\n}\n\nimpl std::fmt::Display for FfiStr<'_> {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        self.as_str().fmt(f)\n    }\n}\n\nimpl<'a, T: AsRef<str>> From<&'a T> for FfiStr<'a> {\n    fn from(s: &'a T) -> Self {\n        let s = s.as_ref();\n        FfiStr { bytes: FfiBytes { buffer: s.as_ptr() as *const c_void, len: s.len(), phantom: PhantomData } }\n    }\n}\n\npub trait FfiStrExt {\n    fn ffi_str(&self) -> FfiStr<'_>;\n}\n\nimpl<T: AsRef<str>> FfiStrExt for T {\n    fn ffi_str(&self) -> FfiStr<'_> {\n        self.into()\n    }\n}\n"
  },
  {
    "path": "rustlib/src/lib.rs",
    "content": "#[macro_use]\npub mod rate_limited_log;\n\npub mod backoff;\npub mod client_state;\npub mod config;\npub mod errors;\npub mod exit_selection;\npub mod ffi_helpers;\npub mod manager;\npub mod manager_cmd;\npub mod net;\npub mod network_config;\npub mod quicwg;\npub mod relay_selection;\nmod serde_safe;\npub mod tokio;\npub mod tunnel_state;\n\n#[cfg(test)]\nmod backoff_test;\n\n#[cfg(target_os = \"android\")]\npub mod android;\n#[cfg(any(target_os = \"macos\", target_os = \"ios\"))]\npub mod apple;\nmod cached_value;\nmod constants;\nmod debug_archive;\nmod dns;\nmod liveness;\nmod logging;\npub mod os;\npub mod positive_u31;\n"
  },
  {
    "path": "rustlib/src/liveness.rs",
    "content": "use etherparse::{IcmpEchoHeader, Icmpv4Type, PacketBuilder, SlicedPacket, TransportSlice};\nuse rand::{RngCore, thread_rng};\nuse static_assertions::{const_assert, const_assert_ne};\nuse std::cmp::min;\nuse std::collections::VecDeque;\nuse std::net::Ipv4Addr;\nuse std::time::{Duration, Instant};\n\nconst MAX_ALLOWED_LOST_PROBES: usize = 4;\nconst MAX_ALLOWED_LOST_PROBES_AFTER_SLEEP: usize = 1;\nconst BUSY_PING_PERIOD: Duration = Duration::from_secs(1);\nconst IDLE_PING_PERIOD: Duration = Duration::from_secs(55);\nconst_assert_ne!(\n    0,\n    crate::quicwg::QUIC_IDLE_TIMEOUT\n        .saturating_sub(IDLE_PING_PERIOD)\n        .saturating_sub(Duration::from_millis(4999))\n        .as_millis()\n);\nconst MIN_PROBE_LOST_PERIOD: Duration = Duration::from_secs(1);\nconst MAX_PROBE_LOST_PERIOD: Duration = Duration::from_secs(30);\nconst SLOW_PONG_WINDOW: u32 = 100;\n\n// Randomly generated value to reliably distinguish our probes from other pings\nconst PROBE_PREFIX: &[u8; 32] = b\"obs-ping\\x75\\xf8\\xb9\\x47\\x4b\\xe1\\x61\\xeb\\x1c\\xb1\\xeb\\x5e\\xc0\\x6c\\xde\\xb7\\xa1\\x1b\\x7b\\xe5\\x85\\xca\\x3a\\x95\";\n\npub struct LivenessChecker {\n    next_id_seq: u32,\n    mtu: u16,\n    src_ip: Ipv4Addr,\n    dst_ip: Ipv4Addr,\n    sent_user_traffic_since_last_ping: bool,\n    is_waking: bool,\n    outstanding_pongs: VecDeque<SentPing>,\n    last_ping_sent_at: Option<Instant>,\n    slowest_pongs: VecDeque<ReceivedPong>,\n}\n\nstruct SentPing {\n    sent_at: Instant,\n    id_seq: u32,\n    payload: Vec<u8>,\n}\n\nimpl LivenessChecker {\n    pub fn new(mtu: u16, client_ip: Ipv4Addr, ping_target_ip: Ipv4Addr) -> Self {\n        Self {\n            next_id_seq: 0,\n            mtu,\n            src_ip: client_ip,\n            dst_ip: ping_target_ip,\n            sent_user_traffic_since_last_ping: false,\n            is_waking: false,\n            outstanding_pongs: Default::default(),\n            last_ping_sent_at: None,\n            slowest_pongs: Default::default(),\n        }\n    }\n\n    fn probe_lost_period(&self) -> Duration {\n        self.slowest_pongs\n            .front()\n            .map(|pong| pong.rtt * 2)\n            .unwrap_or(MIN_PROBE_LOST_PERIOD)\n            .clamp(MIN_PROBE_LOST_PERIOD, MAX_PROBE_LOST_PERIOD)\n    }\n\n    // returns the number of likely lost probes, as well as when the next one would be considered lost\n    fn lost_probe_count_and_time_of_next_loss(&self, now: Instant) -> (usize, Option<Instant>) {\n        let probe_lost_period = self.probe_lost_period();\n        for (i, outstanding_pong) in self.outstanding_pongs.iter().enumerate() {\n            let expires_at = outstanding_pong.sent_at + probe_lost_period;\n            if expires_at > now {\n                return (i, Some(expires_at));\n            }\n        }\n        (self.outstanding_pongs.len(), None)\n    }\n\n    // Call when sending a packet that does not originate from the liveness checker. May return a packet for sending.\n    #[must_use = \"may return a packet, which needs to be sent\"]\n    pub fn sent_traffic(&mut self) -> Option<Vec<u8>> {\n        let now = Instant::now();\n        if self.last_ping_sent_at.is_none_or(|last_ping| now > last_ping + BUSY_PING_PERIOD) {\n            // Ping is overdue. Don't wait for next poll call.\n            tracing::info!(message_id = \"k5jg6f3w\", \"liveness checker sent_traffic returning packet\");\n            return Some(self.send_ping(now));\n        }\n        self.sent_user_traffic_since_last_ping = true;\n        None\n    }\n\n    // Call after sleep. Reduces the number of lost probes needed to classify as dead until a probe succeeded. Returns a packet for sending.\n    #[must_use = \"the returned packet needs to be sent\"]\n    pub fn wake(&mut self) -> Vec<u8> {\n        tracing::info!(message_id = \"OsZ6HBJO\", \"liveness checker wake called\");\n        let now = Instant::now();\n        // Instants may or may not continue ticking during system sleep. Reset the whole state.\n        *self = Self::new(self.mtu, self.src_ip, self.dst_ip);\n        self.is_waking = true;\n        // Immediately test connection after wake.\n        self.send_ping(now)\n    }\n\n    pub fn poll(&mut self) -> LivenessCheckerPoll {\n        let now = Instant::now();\n\n        let (lost_probes, next_probe_loss) = self.lost_probe_count_and_time_of_next_loss(now);\n        let max_lost_probes = if self.is_waking {\n            MAX_ALLOWED_LOST_PROBES_AFTER_SLEEP\n        } else {\n            MAX_ALLOWED_LOST_PROBES\n        };\n        if lost_probes > max_lost_probes {\n            tracing::error!(\n                message_id = \"2sonYhc2\",\n                lost_probes,\n                max_lost_probes,\n                \"liveness checker poll returning Dead\"\n            );\n            return LivenessCheckerPoll::Dead;\n        }\n\n        let ping_period = if self.sent_user_traffic_since_last_ping || lost_probes != 0 {\n            BUSY_PING_PERIOD\n        } else {\n            IDLE_PING_PERIOD\n        };\n        tracing::info!(\n            message_id = \"KZjNGhxu\",\n            lost_probes,\n            max_lost_probes,\n            since_last_ping_ms = ?self.last_ping_sent_at.map(|i| now.saturating_duration_since(i).as_millis()),\n            until_next_probe_loss_ms = ?next_probe_loss.map(|i| i.saturating_duration_since(now).as_millis()),\n            ping_period_ms = ping_period.as_millis(),\n            \"liveness checker probe loss ok\"\n        );\n\n        if self.last_ping_sent_at.is_none_or(|last_ping| last_ping + ping_period <= now) {\n            tracing::info!(message_id = \"7UnUaqos\", \"liveness checker poll returning SendPacket\",);\n            return LivenessCheckerPoll::SendPacket(self.send_ping(now));\n        }\n\n        const_assert!(BUSY_PING_PERIOD.as_nanos() <= IDLE_PING_PERIOD.as_nanos());\n        let mut next_poll = now + BUSY_PING_PERIOD;\n        if let Some(next_probe_loss) = next_probe_loss {\n            next_poll = min(next_poll, next_probe_loss)\n        }\n        if let Some(last_ping_sent_at) = self.last_ping_sent_at {\n            next_poll = min(next_poll, last_ping_sent_at + ping_period)\n        }\n        tracing::info!(\n            message_id = \"Yd79pARH\",\n            until_next_poll_ms = next_poll.saturating_duration_since(now).as_millis(),\n            \"liveness checker poll returning AliveUntil\",\n        );\n        LivenessCheckerPoll::AliveUntil(next_poll)\n    }\n\n    // Checks if a packet is an expected probe response and returns the probe latency if it is.\n    pub fn process_potential_probe_response(&mut self, packet: &[u8]) -> Option<Duration> {\n        let now = Instant::now();\n        let ip = SlicedPacket::from_ip(packet).ok()?;\n        let Some(TransportSlice::Icmpv4(icmp)) = ip.transport else { return None };\n        let pong_id_seq = {\n            let Icmpv4Type::EchoReply(IcmpEchoHeader { id, seq }) = icmp.icmp_type() else {\n                return None;\n            };\n            let id = id.to_be_bytes();\n            let seq = seq.to_be_bytes();\n            u32::from_be_bytes([id[0], id[1], seq[0], seq[1]])\n        };\n        if !icmp.payload().starts_with(PROBE_PREFIX) {\n            return None;\n        }\n        let last_sent_id_seq = self.next_id_seq.wrapping_sub(1);\n        let mut matched_pong_index = None;\n        for (i, SentPing { payload, id_seq, .. }) in self.outstanding_pongs.iter().enumerate() {\n            if payload == icmp.payload() && *id_seq == pong_id_seq {\n                matched_pong_index = Some(i);\n                break;\n            }\n        }\n        if let Some(matched_pong_index) = matched_pong_index {\n            let sent_at = self.outstanding_pongs[matched_pong_index].sent_at;\n            let probe_rtt = now.checked_duration_since(sent_at).unwrap_or_default();\n            self.update_slowest_pongs_list(pong_id_seq, probe_rtt);\n            self.outstanding_pongs.drain(0..=matched_pong_index);\n            tracing::info!(\n                message_id = \"ETUFSKaF\",\n                pong_id_seq,\n                last_sent_id_seq,\n                ?probe_rtt,\n                outstanding_pongs_len = self.outstanding_pongs.len(),\n                slowest_pongs_len = self.slowest_pongs.len(),\n                slowest_pong_rtt = ?self.slowest_pongs.front().map(|p|p.rtt),\n                \"received liveness checker pong\"\n            );\n            self.is_waking = false;\n            Some(probe_rtt)\n        } else {\n            tracing::info!(\n                message_id = \"tDMDB46X\",\n                pong_id_seq,\n                last_sent_id_seq,\n                \"ignoring liveness checker pong with unrecognized payload\"\n            );\n            None\n        }\n    }\n\n    // Maintain list of the slow pongs (high rtt) with these requirements:\n    // - must correspond to any of the last SLOW_PONG_WINDOW sent pings\n    // - no more recent pong was slower\n    // - in order of respective sent ping (oldest first)\n    fn update_slowest_pongs_list(&mut self, id_seq: u32, rtt: Duration) {\n        while self\n            .slowest_pongs\n            .front()\n            .is_some_and(|oldest| id_seq.wrapping_sub(oldest.id_seq) > SLOW_PONG_WINDOW)\n        {\n            self.slowest_pongs.pop_front();\n        }\n        while self.slowest_pongs.back().is_some_and(|newest| newest.rtt <= rtt) {\n            self.slowest_pongs.pop_back();\n        }\n        self.slowest_pongs.push_back(ReceivedPong { id_seq, rtt });\n    }\n\n    fn send_ping(&mut self, now: Instant) -> Vec<u8> {\n        self.last_ping_sent_at = Some(now);\n        self.sent_user_traffic_since_last_ping = false;\n\n        let id_seq = self.next_id_seq;\n        self.next_id_seq += 1;\n        let id_seq_bytes = id_seq.to_be_bytes();\n        let id = u16::from_be_bytes(id_seq_bytes[0..2].try_into().unwrap());\n        let seq = u16::from_be_bytes(id_seq_bytes[2..4].try_into().unwrap());\n        let builder = PacketBuilder::ipv4(self.src_ip.octets(), self.dst_ip.octets(), 255).icmpv4_echo_request(id, seq);\n        let overhead = builder.size(0);\n        debug_assert_eq!(overhead, 28);\n        let mut payload: Vec<u8> = vec![0; self.mtu as usize - overhead];\n        payload[0..32].copy_from_slice(PROBE_PREFIX);\n        thread_rng().fill_bytes(&mut payload[32..]);\n        let total_size = builder.size(payload.len());\n        debug_assert_eq!(total_size, self.mtu as usize);\n        let mut packet = Vec::<u8>::with_capacity(total_size);\n        builder.write(&mut packet, &payload).unwrap();\n\n        self.outstanding_pongs.push_back(SentPing { sent_at: now, id_seq, payload });\n        packet\n    }\n}\n\n#[must_use = \"this `LivenessCheckerPoll` may need to be handled\"]\n#[derive(Debug)]\npub enum LivenessCheckerPoll {\n    Dead,\n    AliveUntil(Instant),\n    SendPacket(Vec<u8>),\n}\n\nstruct ReceivedPong {\n    id_seq: u32,\n    rtt: Duration,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_probe_packet_size() {\n        const MTU: u16 = 100;\n        let mut checker = LivenessChecker::new(MTU, Ipv4Addr::LOCALHOST, Ipv4Addr::LOCALHOST);\n        assert_eq!(checker.send_ping(Instant::now()).len(), usize::from(MTU));\n    }\n}\n"
  },
  {
    "path": "rustlib/src/logging.rs",
    "content": "#![cfg_attr(target_os = \"linux\", allow(unused))]\n\nuse camino::{Utf8Path, Utf8PathBuf};\nuse tracing_appender::non_blocking::{NonBlocking, WorkerGuard};\nuse tracing_subscriber::{\n    Layer, Registry,\n    filter::{EnvFilter, LevelFilter},\n    layer::SubscriberExt as _,\n    registry,\n};\n\n#[derive(Debug)]\npub struct LogPersistence {\n    path: Utf8PathBuf,\n    _flush_guard: WorkerGuard,\n}\n\nimpl LogPersistence {\n    pub fn log_dir(&self) -> &Utf8Path {\n        &self.path\n    }\n}\n\n#[cfg(any(target_os = \"android\", target_os = \"ios\"))]\nfn build_log_roller(log_dir: &Utf8Path) -> anyhow::Result<(NonBlocking, LogPersistence)> {\n    use logroller::{Compression, LogRollerBuilder, Rotation, RotationSize, TimeZone};\n\n    static LOG_FILE_NAME: &str = \"rust-log.ndjson\";\n    const MAX_LOG_FILES: u64 = 24;\n    const MAX_LOG_SIZE: u64 = 10_000_000;\n\n    if log_dir.as_str().is_empty() {\n        anyhow::bail!(\"no log dir specified\");\n    }\n    LogRollerBuilder::new(log_dir, LOG_FILE_NAME.as_ref())\n        // The rotation often runs behind a bit, but at low log pressure\n        // (i.e. not TRACE) it's good enough\n        .rotation(Rotation::SizeBased(RotationSize::Bytes(MAX_LOG_SIZE)))\n        .max_keep_files(MAX_LOG_FILES)\n        .time_zone(TimeZone::UTC)\n        // https://linux.die.net/man/1/xz\n        .compression(Compression::XZ(2))\n        .build()\n        .map(NonBlocking::new)\n        .map(|(writer, guard)| (writer, LogPersistence { path: log_dir.to_owned(), _flush_guard: guard }))\n        .map_err(Into::into)\n}\n\n#[cfg(not(any(target_os = \"android\", target_os = \"ios\")))]\nfn build_log_roller(log_dir: &Utf8Path) -> anyhow::Result<(NonBlocking, LogPersistence)> {\n    anyhow::bail!(\"specified log dir on a platform that doesn't support log persistence: {log_dir}\")\n}\n\n// `EnvFilter` doesn't impl `Clone`\nfn filter() -> EnvFilter {\n    EnvFilter::from_default_env().add_directive(LevelFilter::INFO.into())\n}\n\npub fn init(base_layer: impl Layer<Registry> + Send + Sync, persistence_dir: Option<&Utf8Path>) -> Option<LogPersistence> {\n    let registry = registry().with(base_layer.with_filter(filter()));\n    let persistence = if let Some((writer, persistence)) = persistence_dir.and_then(|log_dir| {\n        build_log_roller(log_dir)\n            .inspect_err(|error| {\n                tracing::error!(message_id = \"RlVghVYB\", ?error, \"failed to initialize log persistence\");\n            })\n            .ok()\n    }) {\n        let fs_layer = tracing_subscriber::fmt::Layer::default().json().with_writer(writer).with_filter(filter());\n        tracing::subscriber::set_global_default(registry.with(fs_layer)).expect(\"failed to set global subscriber\");\n        Some(persistence)\n    } else {\n        tracing::subscriber::set_global_default(registry).expect(\"failed to set global subscriber\");\n        None\n    };\n    tracing::info!(message_id = \"rrnKY3lZ\", \"logging initialized\");\n    std::panic::set_hook(Box::new(|panic_info| {\n        tracing::error!(message_id = \"W6fhvnSf\", \"{panic_info}\\n{:#}\", std::backtrace::Backtrace::force_capture());\n    }));\n    tracing::info!(message_id = \"o0PqqebH\", \"panic logging hook set\");\n    persistence\n}\n"
  },
  {
    "path": "rustlib/src/manager.rs",
    "content": "use std::{path::PathBuf, sync::Arc, time::Duration};\n\nuse obscuravpn_api::cmd::ExitList;\nuse obscuravpn_api::{\n    cmd::{AppleAssociateAccount, AppleAssociateAccountOutput, Cmd, DeleteAccount, DeleteAccountOutput, GetAccountInfo},\n    types::{AccountId, AccountInfo, OneExit, OneRelay, WgPubkey},\n};\nuse serde::{Deserialize, Serialize};\nuse tokio::select;\nuse tokio::sync::watch::{Receiver, Sender, channel};\nuse uuid::Uuid;\n\nuse crate::client_state::ClientStateHandle;\nuse crate::errors::{ConfigDirty, ConfigDirtyOrApiError};\nuse crate::manager_cmd::{ManagerCmdErrorCode, ManagerCmdOk};\nuse crate::os::os_trait::Os;\nuse crate::{\n    backoff::Backoff,\n    client_state::{AccountStatus, ClientState},\n    config::{Config, ConfigLoadError, KeychainSetSecretKeyFn, PinnedLocation, feature_flags::FeatureFlags},\n    debug_archive::create_debug_archive,\n    errors::{ApiError, ConnectErrorCode},\n    exit_selection::ExitSelector,\n    logging::LogPersistence,\n    net::NetworkInterface,\n    network_config::DnsContentBlock,\n    quicwg::TransportKind,\n    tunnel_state::TunnelState,\n};\nuse crate::{cached_value::CachedValue, debug_archive::info::DebugInfo};\n\npub struct Manager {\n    client_state: ClientStateHandle,\n    tunnel_state: Receiver<TunnelState>,\n    status_watch: Sender<Status>,\n    log_persistence: Option<LogPersistence>,\n}\n\n// Keep synchronized with ../../apple/shared/NetworkExtensionIpc.swift\n#[derive(Debug, Serialize, PartialEq, Eq, Clone, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct Status {\n    pub version: Uuid,\n    pub vpn_status: VpnStatus,\n    pub account_id: Option<AccountId>,\n    pub in_new_account_flow: bool,\n    pub pinned_locations: Vec<PinnedLocation>,\n    pub last_chosen_exit: ExitSelector,\n    pub last_exit: ExitSelector,\n    pub api_url: String,\n    pub account: Option<AccountStatus>,\n    pub auto_connect: bool,\n    pub feature_flags: FeatureFlags,\n    pub feature_flag_keys: Vec<String>,\n    pub use_system_dns: bool,\n    pub dns_content_block: DnsContentBlock,\n}\n\nimpl Status {\n    fn new(version: Uuid, vpn_status: VpnStatus, client_state: &ClientState) -> Self {\n        let Config {\n            account_id,\n            in_new_account_flow,\n            pinned_locations,\n            last_chosen_exit_selector,\n            last_exit_selector,\n            cached_account_status,\n            auto_connect,\n            feature_flags,\n            dns,\n            dns_content_block,\n            ..\n        } = client_state.config();\n        let api_url = client_state.base_url();\n        Self {\n            version,\n            vpn_status,\n            account_id: account_id.clone(),\n            in_new_account_flow: *in_new_account_flow,\n            pinned_locations: pinned_locations.clone(),\n            last_chosen_exit: last_chosen_exit_selector.clone(),\n            last_exit: last_exit_selector.clone(),\n            api_url,\n            account: cached_account_status.clone(),\n            auto_connect: *auto_connect,\n            feature_flags: feature_flags.clone(),\n            feature_flag_keys: FeatureFlags::KEYS.iter().map(ToString::to_string).collect(),\n            use_system_dns: dns.is_system(),\n            dns_content_block: *dns_content_block,\n        }\n    }\n}\n\n// Keep synchronized with ../../apple/shared/NetworkExtensionIpc.swift\n#[derive(Debug, Serialize, PartialEq, Eq, Clone, Deserialize)]\n#[serde(rename_all = \"camelCase\", rename_all_fields = \"camelCase\")]\npub enum VpnStatus {\n    Connecting {\n        tunnel_args: TunnelArgs,\n        connect_error: Option<ConnectErrorCode>,\n        reconnecting: bool,\n    },\n    Connected {\n        tunnel_args: TunnelArgs,\n        exit: OneExit,\n        relay: OneRelay,\n        client_public_key: WgPubkey,\n        exit_public_key: WgPubkey,\n        transport: TransportKind,\n    },\n    Disconnected {},\n}\n\n#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Default)]\n#[serde(rename_all = \"camelCase\")]\npub struct TunnelArgs {\n    pub exit: ExitSelector,\n}\n\nimpl VpnStatus {\n    fn from_tunnel_state(tunnel_state: &TunnelState) -> Self {\n        match tunnel_state {\n            TunnelState::Disconnected => VpnStatus::Disconnected {},\n            TunnelState::Connecting { args, connect_error, disconnect_reason, offset_traffic_stats: _, network_interface: _ } => {\n                VpnStatus::Connecting {\n                    tunnel_args: args.clone(),\n                    connect_error: connect_error.as_ref().map(|error_at| ConnectErrorCode::from(&error_at.error)),\n                    reconnecting: disconnect_reason.is_some(),\n                }\n            }\n            TunnelState::Connected { args, conn, relay, exit, network_config: _, offset_traffic_stats: _, network_interface: _ } => {\n                VpnStatus::Connected {\n                    tunnel_args: args.clone(),\n                    relay: relay.clone(),\n                    exit: exit.clone(),\n                    client_public_key: WgPubkey(conn.client_public_key().to_bytes()),\n                    exit_public_key: WgPubkey(conn.exit_public_key().to_bytes()),\n                    transport: conn.transport(),\n                }\n            }\n        }\n    }\n}\n\nimpl Manager {\n    /// The constructed `Arc<Manager>` can not be dropped due to spawned tasks, which hold references.\n    pub fn new(\n        config_dir: PathBuf,\n        keychain_wg_sk: Option<&[u8]>,\n        user_agent: String,\n        os_impl: Arc<impl Os>,\n        set_keychain_wg_sk: Option<KeychainSetSecretKeyFn>,\n        log_persistence: Option<LogPersistence>,\n        force_init_inactive: bool,\n    ) -> Result<Arc<Self>, ConfigLoadError> {\n        let client_state = ClientState::new(config_dir, keychain_wg_sk, user_agent, set_keychain_wg_sk, force_init_inactive)?;\n        let tunnel_state = TunnelState::new(client_state.clone(), os_impl.clone());\n        let initial_status = Status::new(Uuid::new_v4(), VpnStatus::Disconnected {}, &client_state.borrow());\n        let this = Arc::new(Self { tunnel_state, client_state, status_watch: channel(initial_status).0, log_persistence });\n        tokio::spawn(Self::wireguard_key_registraction_task(this.clone(), ()));\n        tokio::spawn(Self::propagate_updates_to_status_task(this.clone(), ()));\n        tokio::spawn(Self::preferred_network_interface_task(this.clone(), os_impl.network_interface()));\n        Ok(this)\n    }\n\n    pub async fn maybe_update_exits(&self, freshness: Duration) -> Result<(), ApiError> {\n        self.client_state.maybe_update_exits(freshness).await\n    }\n\n    pub fn subscribe(&self) -> Receiver<Status> {\n        self.status_watch.subscribe()\n    }\n\n    pub fn traffic_stats(&self) -> ManagerTrafficStats {\n        self.tunnel_state.borrow().traffic_stats()\n    }\n\n    pub async fn login(&self, account_id: AccountId, validate: bool) -> Result<(), ConfigDirtyOrApiError> {\n        let mut auth_token = None;\n        if validate {\n            const MAX_ATTEMPTS: usize = 10;\n            for _ in 0..MAX_ATTEMPTS {\n                let api_client = self.client_state.make_api_client(account_id.clone())?;\n                let output = api_client.acquire_auth_token().await.map_err(ApiError::from)?;\n                if let Some(url_override) = output.url_override {\n                    // TODO: https://linear.app/soveng/issue/OBS-2268/override-web-url-for-apple-demo-accounts\n                    self.set_api_url(Some(url_override.api));\n                } else {\n                    auth_token = Some(output.auth_token.into());\n                    break;\n                }\n            }\n            if auth_token.is_none() {\n                return Err(ApiError::ApiClient(anyhow::format_err!(\"exceeded {MAX_ATTEMPTS} URL overrides\").into()).into());\n            }\n        }\n        self.client_state.set_account_id(Some((account_id, auth_token)))?;\n        Ok(())\n    }\n\n    pub fn logout(&self) -> Result<(), ConfigDirty> {\n        self.client_state.set_account_id(None)\n    }\n\n    pub fn set_api_url(&self, value: Option<String>) {\n        self.client_state.set_api_url(value);\n    }\n\n    pub async fn api_request<C: Cmd>(&self, cmd: C) -> Result<C::Output, ApiError> {\n        self.client_state.api_request(cmd).await\n    }\n\n    pub async fn apple_associate_account(&self, app_transaction_jws: String) -> Result<AppleAssociateAccountOutput, ApiError> {\n        self.api_request(AppleAssociateAccount { app_transaction_jws }).await\n    }\n\n    pub async fn delete_account(&self) -> Result<DeleteAccountOutput, ApiError> {\n        self.api_request(DeleteAccount {}).await\n    }\n\n    pub async fn get_account_info(&self) -> Result<AccountInfo, ApiError> {\n        let account_info = self.api_request(GetAccountInfo()).await?;\n        self.client_state.update_account_info(&account_info);\n        Ok(account_info)\n    }\n\n    async fn propagate_updates_to_status_task(this: Arc<Self>, _: ()) {\n        let mut tunnel_state_recv = this.tunnel_state.clone();\n        let mut client_state_recv = this.client_state.subscribe();\n        tunnel_state_recv.mark_changed();\n        loop {\n            let cont = select! {\n                res = tunnel_state_recv.changed() => res.is_ok(),\n                res = client_state_recv.changed() => res.is_ok(),\n            };\n            if !cont {\n                break;\n            };\n            this.status_watch.send_if_modified(|status| {\n                let vpn_status = VpnStatus::from_tunnel_state(&tunnel_state_recv.borrow_and_update());\n                let client_state = client_state_recv.borrow_and_update();\n                let mut new_status = Status::new(status.version, vpn_status, &client_state);\n                if new_status == *status {\n                    return false;\n                }\n                new_status.version = Uuid::new_v4();\n                *status = new_status;\n                true\n            });\n        }\n        tracing::info!(message_id = \"NUeloeKe\", \"propagate_updates_to_status_task stops\")\n    }\n\n    async fn wireguard_key_registraction_task(this: Arc<Self>, _: ()) {\n        let mut status_subscription = this.subscribe();\n        let mut last_status_version = None;\n        loop {\n            {\n                let status_result = status_subscription\n                    .wait_for(|status| {\n                        let changed = Some(status.version) != last_status_version;\n                        let active = status.account.as_ref().is_some_and(|account_status| account_status.account_info.active);\n                        let disconnected = matches!(status.vpn_status, VpnStatus::Disconnected {});\n                        changed && active && disconnected\n                    })\n                    .await;\n                let Ok(status) = status_result else {\n                    tracing::info!(\"status subscription closed unexpectedly\");\n                    return;\n                };\n                last_status_version = Some(status.version);\n            }\n            let mut backoff = Backoff::BACKGROUND.take(10);\n            while backoff.wait().await {\n                let Err(error) = this.client_state.register_cached_wireguard_key_if_new().await else {\n                    continue;\n                };\n                tracing::warn!(?error, \"failed attempt to register cached wireguard key\");\n            }\n        }\n    }\n\n    pub async fn preferred_network_interface_task(this: Arc<Self>, mut network_interface_watch: Receiver<Option<NetworkInterface>>) {\n        loop {\n            let preferred_network_interface = network_interface_watch.borrow_and_update().clone();\n            this.client_state.set_network_interface(preferred_network_interface);\n            if network_interface_watch.changed().await.is_err() {\n                tracing::error!(message_id = \"ybeBsPfE\", \"status subscription closed unexpectedly\");\n                return;\n            }\n        }\n    }\n\n    pub async fn create_debug_archive(&self, user_feedback: Option<&str>) -> anyhow::Result<String> {\n        let user_feedback = user_feedback.map(ToOwned::to_owned);\n        let log_dir = self.log_persistence.as_ref().map(LogPersistence::log_dir).map(ToOwned::to_owned);\n        let debug_info = self.get_debug_info().await;\n        tokio::task::spawn_blocking(move || create_debug_archive(user_feedback.as_deref(), debug_info, log_dir.as_deref()).map(Into::into)).await?\n    }\n\n    pub async fn get_debug_info(&self) -> DebugInfo {\n        self.client_state.get_debug_info().await\n    }\n\n    pub fn wake(&self) {\n        if let Some(conn) = self.tunnel_state.borrow().get_conn() {\n            conn.wake();\n        }\n    }\n\n    pub async fn get_exit_list(&self, known_version: Option<Vec<u8>>) -> Result<CachedValue<Arc<ExitList>>, ManagerCmdErrorCode> {\n        let mut watch = self.client_state.subscribe();\n        let client_state = watch\n            .wait_for(|client_state| {\n                client_state\n                    .config()\n                    .cached_exits\n                    .clone()\n                    .is_some_and(|e| Some(e.version()) != known_version.as_deref())\n            })\n            .await\n            .map_err(|error| {\n                tracing::error!(?error, message_id = \"ahcieM1h\", \"exit list subscription channel closed: {}\", error,);\n                ManagerCmdErrorCode::Other\n            })?;\n        let cached = client_state.config().cached_exits.clone().unwrap();\n        Ok(CachedValue { version: cached.version().to_vec(), last_updated: cached.last_updated, value: cached.value.clone() })\n    }\n\n    pub fn run_on_client_state(&self, f: impl FnOnce(&ClientStateHandle)) -> Result<ManagerCmdOk, ManagerCmdErrorCode> {\n        f(&self.client_state);\n        Ok(ManagerCmdOk::Empty)\n    }\n}\n\n#[derive(Debug, Copy, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct ManagerTrafficStats {\n    pub connected_ms: u64,\n    pub conn_id: Uuid,\n    pub tx_bytes: u64,\n    pub rx_bytes: u64,\n    pub latest_latency_ms: u16,\n}\n"
  },
  {
    "path": "rustlib/src/manager_cmd.rs",
    "content": "// Command interface for commands, whose arguments and return values can be serialized and deserialized. You should usually prefer other methods unless you are implementing an FFI interface. All commands map more or less directly to another method.\n\nuse std::{sync::Arc, time::Duration};\n\nuse base64::prelude::*;\nuse obscuravpn_api::{\n    ClientError,\n    cmd::{ApiErrorKind, AppleAssociateAccountOutput, DeleteAccountOutput, ExitList},\n    types::{AccountId, AccountInfo},\n};\nuse serde::{Deserialize, Serialize};\nuse strum::IntoStaticStr;\nuse tokio::spawn;\nuse uuid::Uuid;\n\nuse crate::errors::ApiError;\nuse crate::errors::{ConfigDirty, ConfigDirtyOrApiError};\nuse crate::network_config::DnsContentBlock;\nuse crate::{\n    cached_value::CachedValue,\n    manager::{Manager, ManagerTrafficStats, Status},\n};\nuse crate::{client_state::ClientStateHandle, debug_archive::info::DebugInfo};\nuse crate::{config::PinnedLocation, manager::TunnelArgs};\n\n/// High-level json command error codes, which are actionable for frontends.\n/// Actionable means any of:\n/// - Useful to trigger specific frontend behavior (e.g. control flow branches)\n/// - Correlates with specific error messages shown to users\n///\n/// All remaining errors are mapped to the `Other` variant.\n/// Make sure `obscura-ui/src/translations/en.json` contains an entry for each variant.\n///\n/// Do not use outside of code processing `ManagerCmd` processing.\n#[derive(Debug, Clone, Copy, Serialize, Deserialize, IntoStaticStr, PartialEq, Eq)]\n#[serde(rename_all = \"camelCase\")]\n#[strum(serialize_all = \"camelCase\")]\npub enum ManagerCmdErrorCode {\n    ApiError,\n    ApiInvalidAccountId,\n    ApiNoLongerSupported,\n    ApiRateLimitExceeded,\n    ApiSignupLimitExceeded,\n    ApiUnreachable,\n    ConfigSaveError,\n    Other,\n}\n\nimpl ManagerCmdErrorCode {\n    pub fn as_static_str(&self) -> &'static str {\n        self.into()\n    }\n}\n\nimpl From<&ConfigDirty> for ManagerCmdErrorCode {\n    fn from(error: &ConfigDirty) -> Self {\n        tracing::info!(message_id = \"7YMEQ3ac\", ?error, \"deriving json cmd error code for: {}\", &error);\n        Self::ConfigSaveError\n    }\n}\n\nimpl From<&ConfigDirtyOrApiError> for ManagerCmdErrorCode {\n    fn from(error: &ConfigDirtyOrApiError) -> Self {\n        tracing::info!(message_id = \"7oPu26ad\", ?error, \"deriving json cmd error code for: {}\", &error);\n        match error {\n            ConfigDirtyOrApiError::ApiError(error) => error.into(),\n            ConfigDirtyOrApiError::ConfigDirty(error) => error.into(),\n        }\n    }\n}\n\nimpl From<&ApiError> for ManagerCmdErrorCode {\n    fn from(error: &ApiError) -> Self {\n        tracing::info!(message_id = \"ch2a5Sp5\", ?error, \"deriving json cmd error code for: {}\", &error);\n        match error {\n            ApiError::ApiClient(err) => match err {\n                ClientError::ApiError(err) => match err.body.error {\n                    ApiErrorKind::NoLongerSupported {} => Self::ApiNoLongerSupported,\n                    ApiErrorKind::RateLimitExceeded {} => Self::ApiRateLimitExceeded,\n                    ApiErrorKind::SignupLimitExceeded {} => Self::ApiSignupLimitExceeded,\n                    ApiErrorKind::InvalidAccountId {} => Self::ApiInvalidAccountId,\n                    ApiErrorKind::AccountExpired {}\n                    | ApiErrorKind::AlreadyExists {}\n                    | ApiErrorKind::AlreadyReferred {}\n                    | ApiErrorKind::BadRequest {}\n                    | ApiErrorKind::IneligibleForReferral {}\n                    | ApiErrorKind::InternalError {}\n                    | ApiErrorKind::InvalidReferralCode {}\n                    | ApiErrorKind::MissingOrInvalidAuthToken {}\n                    | ApiErrorKind::MiscUnauthorized {}\n                    | ApiErrorKind::MoneroTopUpNotFound {}\n                    | ApiErrorKind::NoApiRoute {}\n                    | ApiErrorKind::NoMatchingExit {}\n                    | ApiErrorKind::SaleNotFound {}\n                    | ApiErrorKind::TunnelLimitExceeded {}\n                    | ApiErrorKind::WgKeyRotationRequired {}\n                    | ApiErrorKind::Unknown(_) => Self::ApiError,\n                },\n                ClientError::RequestExecError(_) => Self::ApiUnreachable,\n                ClientError::ResponseTooLarge | ClientError::InvalidHeaderValue | ClientError::Other(_) | ClientError::ProtocolError(_) => {\n                    Self::ApiError\n                }\n            },\n            ApiError::NoAccountId => Self::ApiError,\n        }\n    }\n}\n\n// Keep synchronized with ../../apple/shared/NetworkExtensionIpc.swift\n#[serde_with::serde_as]\n#[derive(derive_more::Debug, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\", rename_all_fields = \"camelCase\")]\npub enum ManagerCmd {\n    ApiAppleAssociateAccount {\n        app_transaction_jws: String,\n    },\n    ApiDeleteAccount {},\n    ApiGetAccountInfo {},\n    CreateDebugArchive {\n        user_feedback: Option<String>,\n    },\n    GetDebugInfo {},\n    GetExitList {\n        #[debug(\"{:?}\", known_version.as_ref().map(|b| BASE64_STANDARD.encode(b)))]\n        #[serde_as(as = \"Option<serde_with::base64::Base64>\")]\n        known_version: Option<Vec<u8>>,\n    },\n    GetStatus {\n        known_version: Option<Uuid>,\n    },\n    GetTrafficStats {},\n    TerminateProcess {},\n    Login {\n        account_id: AccountId,\n        validate: bool,\n    },\n    Logout {},\n    Ping {},\n    RefreshExitList {\n        #[serde_as(as = \"serde_with::DurationMilliSeconds\")]\n        freshness: Duration,\n    },\n    RotateWgKey {},\n    SetApiHostAlternate {\n        host: Option<String>,\n    },\n    SetApiUrl {\n        url: Option<String>,\n    },\n    SetAutoConnect {\n        enable: bool,\n    },\n    SetDnsContentBlock {\n        value: DnsContentBlock,\n    },\n    SetFeatureFlag {\n        flag: String,\n        active: bool,\n    },\n    SetInNewAccountFlow {\n        value: bool,\n    },\n    SetPinnedExits {\n        exits: Vec<PinnedLocation>,\n    },\n    SetSniRelay {\n        host: Option<String>,\n    },\n    SetTunnelArgs {\n        args: Option<TunnelArgs>,\n        active: Option<bool>,\n    },\n    SetUseSystemDns {\n        enable: bool,\n    },\n}\n\n#[derive(Debug, derive_more::From, Serialize)]\n#[serde(untagged)]\npub enum ManagerCmdOk {\n    #[from]\n    ApiAppleAssociateAccount(AppleAssociateAccountOutput),\n    #[from]\n    ApiDeleteAccount(DeleteAccountOutput),\n    #[from]\n    ApiGetAccountInfo(AccountInfo),\n    CreateDebugArchive(String),\n    Empty,\n    GetDebugInfo(DebugInfo),\n    GetExitList(CachedValue<Arc<ExitList>>),\n    GetStatus(Status),\n    GetTrafficStats(ManagerTrafficStats),\n}\n\nimpl From<()> for ManagerCmdOk {\n    fn from((): ()) -> Self {\n        Self::Empty\n    }\n}\n\nfn map_result<T, E>(result: Result<T, E>) -> Result<ManagerCmdOk, ManagerCmdErrorCode>\nwhere\n    T: Into<ManagerCmdOk>,\n    for<'r> &'r E: Into<ManagerCmdErrorCode>,\n{\n    result.map(Into::into).map_err(|err| (&err).into())\n}\n\nimpl ManagerCmd {\n    pub fn from_json(json_cmd: &[u8]) -> Result<Self, ManagerCmdErrorCode> {\n        // apple frameworks log IPC message SHA1\n        let hash = ring::digest::digest(&ring::digest::SHA1_FOR_LEGACY_USE_ONLY, json_cmd);\n\n        let cmd: ManagerCmd = serde_json::from_slice(json_cmd).map_err(|error| {\n            tracing::error!(\n                ?error,\n                cmd =? String::from_utf8_lossy(json_cmd),\n                hash =? hash,\n                message_id = \"ahsh9Aec\",\n                \"could not decode json command: {error}\",\n            );\n            ManagerCmdErrorCode::Other\n        })?;\n\n        tracing::info!(\n            cmd = format!(\"{:#?}\", cmd),\n            hash =? hash,\n            message_id = \"JumahFi5\",\n            \"decoded json cmd\",\n        );\n\n        Ok(cmd)\n    }\n\n    pub async fn run(self, manager: &Manager) -> Result<ManagerCmdOk, ManagerCmdErrorCode> {\n        match self {\n            Self::ApiAppleAssociateAccount { app_transaction_jws } => map_result(manager.apple_associate_account(app_transaction_jws).await),\n            Self::ApiDeleteAccount {} => map_result(manager.delete_account().await),\n            Self::ApiGetAccountInfo {} => map_result(manager.get_account_info().await),\n            Self::SetFeatureFlag { flag, active } => manager.run_on_client_state(|c| c.set_feature_flag(&flag, active)),\n            Self::CreateDebugArchive { user_feedback } => manager\n                .create_debug_archive(user_feedback.as_deref())\n                .await\n                .map(ManagerCmdOk::CreateDebugArchive)\n                .map_err(|error| {\n                    tracing::error!(?error, \"failed to create debug archive\");\n                    ManagerCmdErrorCode::Other\n                }),\n            Self::GetDebugInfo {} => Ok(ManagerCmdOk::GetDebugInfo(manager.get_debug_info().await)),\n            Self::GetExitList { known_version } => manager.get_exit_list(known_version).await.map(ManagerCmdOk::GetExitList),\n            Self::GetStatus { known_version } => manager\n                .subscribe()\n                .wait_for(|s| Some(s.version) != known_version)\n                .await\n                .map(|status| ManagerCmdOk::GetStatus(status.clone()))\n                .map_err(|_err| {\n                    tracing::error!(\"status subscription channel closed\");\n                    ManagerCmdErrorCode::Other\n                }),\n            Self::GetTrafficStats {} => Ok(ManagerCmdOk::GetTrafficStats(manager.traffic_stats())),\n            Self::TerminateProcess {} => {\n                const WAIT: Duration = Duration::from_secs(3);\n                tracing::error!(message_id = \"i5BA5bOA\", \"received termination command, exiting in {}ms\", WAIT.as_millis());\n                spawn(async {\n                    tokio::time::sleep(Duration::from_secs(3)).await;\n                    tracing::error!(message_id = \"eCoVnCI6\", \"executing scheduled termination\");\n                    std::process::exit(1);\n                });\n                Ok(ManagerCmdOk::Empty)\n            }\n            Self::Login { account_id, validate } => map_result(manager.login(account_id, validate).await),\n            Self::Logout {} => map_result(manager.logout()),\n            Self::Ping {} => Ok(ManagerCmdOk::Empty),\n            Self::RefreshExitList { freshness } => map_result(manager.maybe_update_exits(freshness).await),\n            Self::RotateWgKey {} => manager.run_on_client_state(ClientStateHandle::rotate_wg_key),\n            Self::SetAutoConnect { enable } => manager.run_on_client_state(|c| c.set_auto_connect(enable)),\n            Self::SetApiHostAlternate { host } => manager.run_on_client_state(|c| c.set_api_host_alternate(host)),\n            Self::SetApiUrl { url } => manager.run_on_client_state(|c| c.set_api_url(url)),\n            Self::SetDnsContentBlock { value } => manager.run_on_client_state(|c| c.set_dns_content_block(value)),\n            Self::SetInNewAccountFlow { value } => manager.run_on_client_state(|c| c.set_in_new_account_flow(value)),\n            Self::SetPinnedExits { exits } => manager.run_on_client_state(|c| c.set_pinned_exits(exits)),\n            Self::SetSniRelay { host } => manager.run_on_client_state(|c| c.set_sni_relay(host)),\n            Self::SetTunnelArgs { args, active } => manager.run_on_client_state(|c| c.set_tunnel_target_state(args, active)),\n            Self::SetUseSystemDns { enable } => manager.run_on_client_state(|c| c.set_use_system_dns(enable)),\n        }\n    }\n}\n"
  },
  {
    "path": "rustlib/src/net.rs",
    "content": "use crate::positive_u31::PositiveU31;\nuse crate::quicwg::{DEFAULT_UDP_PAYLOAD_SIZE, IPV4_UDP_OVERHEAD};\nuse anyhow::Context;\nuse serde::{Deserialize, Serialize};\nuse socket2::{Domain, Protocol, Socket, Type};\nuse std::io;\nuse std::net::{Ipv4Addr, SocketAddrV4};\n#[cfg(not(target_os = \"windows\"))]\nuse std::os::fd::AsRawFd;\n#[cfg(not(target_os = \"windows\"))]\nuse std::ptr::addr_of_mut;\n#[cfg(not(target_os = \"windows\"))]\nuse std::{mem, ptr};\n\n#[derive(PartialEq, Eq, Clone, Debug, Deserialize, Serialize)]\npub struct NetworkInterface {\n    pub name: String,\n    pub index: PositiveU31,\n    #[cfg(target_os = \"windows\")]\n    pub ip: std::net::IpAddr,\n    #[cfg(target_os = \"windows\")]\n    pub mtu: i32,\n}\n\npub fn new_udp(network_interface: Option<&NetworkInterface>) -> io::Result<std::net::UdpSocket> {\n    let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;\n    #[cfg(not(any(target_os = \"android\", target_os = \"windows\")))]\n    if let Some(network_interface) = network_interface {\n        socket.bind_device_by_index_v4(Some(network_interface.index.into()))?;\n    }\n    #[allow(unused_mut)]\n    let mut bind_addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0).into();\n    #[cfg(target_os = \"windows\")]\n    if let Some(interface) = network_interface {\n        bind_addr = std::net::SocketAddr::new(interface.ip, 0).into();\n    }\n    #[cfg(target_os = \"android\")]\n    {\n        _ = network_interface;\n    }\n    socket.bind(&bind_addr)?;\n    Ok(socket.into())\n}\n\n#[cfg(not(any(target_os = \"ios\", target_os = \"macos\", target_os = \"windows\")))]\nconst SIOCGIFMTU: libc::Ioctl = libc::SIOCGIFMTU as libc::Ioctl;\n\n#[cfg(any(target_os = \"ios\", target_os = \"macos\"))]\nconst SIOCGIFMTU: libc::c_ulong = 3223349555; // From sys/sockio.h.\n\n#[cfg(not(target_os = \"windows\"))]\npub fn interface_mtu(interface: &NetworkInterface) -> anyhow::Result<i32> {\n    let name = &interface.name;\n    let socket = Socket::new_raw(Domain::IPV4, Type::DGRAM, None)?;\n\n    let mut name_buf: [u8; libc::IFNAMSIZ] = [0; _];\n    // Note: It isn't clear if the name needs to be null terminated if it is the maximum length but we just assume so.\n    anyhow::ensure!(name_buf.len() > name.len(), \"Interface name too long.\");\n    name_buf[..name.len()].copy_from_slice(name.as_bytes());\n    let name_buf: [libc::c_char; libc::IFNAMSIZ] = unsafe { mem::transmute(name_buf) };\n\n    unsafe {\n        let mut ifreq = mem::MaybeUninit::<libc::ifreq>::uninit();\n        ptr::write(addr_of_mut!((*ifreq.as_mut_ptr()).ifr_name), name_buf);\n\n        let r = libc::ioctl(socket.as_raw_fd(), SIOCGIFMTU, ifreq.as_mut_ptr());\n        if r < 0 {\n            Err(io::Error::last_os_error().into())\n        } else {\n            Ok(ifreq.assume_init().ifr_ifru.ifru_mtu)\n        }\n    }\n}\n\n#[cfg(target_os = \"windows\")]\npub fn interface_mtu(interface: &NetworkInterface) -> anyhow::Result<i32> {\n    Ok(interface.mtu)\n}\n\npub fn new_quic(udp: std::net::UdpSocket, mtu: Option<u16>, force_small_mtu: bool) -> anyhow::Result<quinn::Endpoint> {\n    let runtime = quinn::default_runtime().context(\"no quinn runtime found\")?;\n    let mut endpoint_config = quinn::EndpointConfig::default();\n    if mtu.is_some_and(|mtu| mtu < DEFAULT_UDP_PAYLOAD_SIZE + IPV4_UDP_OVERHEAD) || force_small_mtu {\n        match force_small_mtu {\n            true => tracing::info!(\n                message_id = \"kq0AuTsT\",\n                \"forcing relay to use small UDP payload due to small MTU experimental flag being set\"\n            ),\n            false => tracing::info!(\n                message_id = \"TF51QUHb\",\n                mtu,\n                \"forcing relay to use small UDP payload due to low network MTU\"\n            ),\n        }\n        // TODO: Remove once relays does MTU discovery https://linear.app/soveng/issue/OBS-3201/replace-client-side-max-udp-payload-size-constraint-with-relay-side\n        endpoint_config\n            // A less conservative udp payload size could be calculated as `mtu - IPV4_UDP_OVERHEAD`, but:\n            // - this is an uncommon case (for networks with very low MTU)\n            // - packet size distribution tends to be bimodal, the exact fragmentation threshold doesn't matter much\n            // - technically QUIC and IP overhead aren't fixed\n            // - this will be removed once the relay supports MTU discovery\n            // - 1200 is the hard lower limit for QUIC and easily fits WG fragments and has the best compatibility with low-MTU network environments\n            .max_udp_payload_size(1200)\n            .context(\"invalid max_udp_payload_size\")?;\n    }\n    let endpoint = quinn::Endpoint::new(endpoint_config, None, udp, runtime)?;\n    Ok(endpoint)\n}\n"
  },
  {
    "path": "rustlib/src/network_config.rs",
    "content": "use ipnetwork::Ipv6Network;\nuse obscuravpn_api::types::ObfuscatedTunnelConfig;\nuse serde::{Deserialize, Serialize};\nuse std::net::{IpAddr, Ipv4Addr, Ipv6Addr};\nuse strum::EnumIs;\nuse thiserror::Error;\n\nconst MULLVAD_EXIT_PROVIDER_NAME: &str = \"Mullvad VPN\";\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct TunnelNetworkConfig {\n    pub dns: Vec<IpAddr>,\n    pub ipv4: Ipv4Addr,\n    pub ipv6: Ipv6Network,\n    pub mtu: u16,\n}\n\nimpl TunnelNetworkConfig {\n    pub fn new(tunnel_config: &ObfuscatedTunnelConfig, mtu: u16) -> Result<Self, NetworkConfigError> {\n        let dns = tunnel_config.dns.clone();\n        if dns.is_empty() {\n            return Err(NetworkConfigError::NoDns);\n        }\n\n        let Some(ipv4) = tunnel_config.client_ips_v4.first().map(|net| net.ip()) else {\n            return Err(NetworkConfigError::NoIpv4Ip);\n        };\n\n        let Some(ipv6) = tunnel_config.client_ips_v6.first().cloned() else {\n            return Err(NetworkConfigError::NoIpv6Ip);\n        };\n\n        Ok(Self { dns, ipv4, ipv6, mtu })\n    }\n\n    fn dummy() -> Self {\n        Self {\n            dns: vec![IpAddr::V4(Ipv4Addr::new(10, 64, 0, 99))],\n            ipv4: Ipv4Addr::new(10, 75, 76, 77),\n            ipv6: Ipv6Network::new(Ipv6Addr::new(0xfc00, 0xbbbb, 0xbbbb, 0xbb01, 0, 0, 0xc, 0x4c4d), 128).unwrap(),\n            mtu: 1280,\n        }\n    }\n}\n\n#[derive(Clone, Debug, Error)]\npub enum NetworkConfigError {\n    #[error(\"no ipv4 ip\")]\n    NoIpv4Ip,\n    #[error(\"no ipv6 ip\")]\n    NoIpv6Ip,\n    #[error(\"no dns\")]\n    NoDns,\n}\n\n#[derive(Clone, Copy, Debug, Default, EnumIs, PartialEq, Eq, Serialize, Deserialize)]\npub enum DnsConfig {\n    #[default]\n    Default,\n    System,\n}\n\n#[derive(Default, Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct DnsContentBlock {\n    ad: bool,\n    tracker: bool,\n    malware: bool,\n    adult: bool,\n    gambling: bool,\n    social_media: bool,\n}\n\nimpl DnsContentBlock {\n    fn mullvad_dns_ip(self) -> Option<Ipv4Addr> {\n        let bitset = u8::from(self.ad)\n            | (u8::from(self.tracker) << 1)\n            | (u8::from(self.malware) << 2)\n            | (u8::from(self.adult) << 3)\n            | (u8::from(self.gambling) << 4)\n            | (u8::from(self.social_media) << 5);\n        (bitset != 0).then_some(Ipv4Addr::new(100, 64, 0, bitset))\n    }\n}\n\n// Keep synchronized with:\n// - android/app/src/main/java/net/obscura/vpnclientapp/services/OsNetworkConfig.kt\n// - apple/shared/NetworkExtensionIpc.swift\n//\n// Avoid adding information with high-frequency of change to this type, to prevent triggering frequent changes OS network configuration, which can't be deduplicated by checking for changes.\n#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct OsNetworkConfig {\n    pub dns: Vec<IpAddr>,\n    pub ipv4: Ipv4Addr,\n    pub ipv6: Ipv6Network,\n    pub mtu: u16,\n    pub use_system_dns: bool,\n}\n\nimpl OsNetworkConfig {\n    pub fn new(\n        tunnel_network_config: &TunnelNetworkConfig,\n        exit_provider_name: &str,\n        dns_content_block: DnsContentBlock,\n        use_system_dns: bool,\n    ) -> Self {\n        let dns = if exit_provider_name == MULLVAD_EXIT_PROVIDER_NAME\n            && let Some(dns) = dns_content_block.mullvad_dns_ip()\n        {\n            vec![IpAddr::from(dns)]\n        } else {\n            tunnel_network_config.dns.clone()\n        };\n\n        Self {\n            dns,\n            ipv4: tunnel_network_config.ipv4,\n            ipv6: tunnel_network_config.ipv6,\n            mtu: tunnel_network_config.mtu,\n            use_system_dns,\n        }\n    }\n\n    /// Dummy OS network config. May be used if valid values are needed by an API before the real values are known. The values are picked from ranges we expect for our tunnels.\n    pub fn dummy(dns_content_block: DnsContentBlock, use_system_dns: bool) -> Self {\n        Self::new(\n            &TunnelNetworkConfig::dummy(),\n            MULLVAD_EXIT_PROVIDER_NAME,\n            dns_content_block,\n            use_system_dns,\n        )\n    }\n}\n"
  },
  {
    "path": "rustlib/src/os/mod.rs",
    "content": "pub mod os_trait;\npub mod packet_buffer;\n"
  },
  {
    "path": "rustlib/src/os/os_trait.rs",
    "content": "use crate::net::NetworkInterface;\nuse crate::network_config::OsNetworkConfig;\nuse crate::quicwg::QuicWgConnPacketSender;\nuse bytes::Bytes;\n\npub trait Os: Sync + Send + 'static {\n    /// Watcher for the network interface API requests and tunnels should use. This method may be called multiple times. All returned watchers must receive updates until dropped.\n    fn network_interface(&self) -> tokio::sync::watch::Receiver<Option<NetworkInterface>>;\n\n    /// Set the network state. Returning `Ok()` implies that the OS will route traffic to the tunnel. May be called repeatedly before the tunnel is functional or after the tunnel started relaying traffic to reflect changing IP Address or DNS configuration. Regardless of errors that may occur, the implementation should set up as much routing/filtering as possible to avoid leaking traffic.\n    /// Will not be called concurrently with itself or `unset_os_network_config`.\n    // TODO: Consider moving this to its own trait with `&mut` receiver and remove sentence above.\n    fn set_os_network_config(&self, network_config: OsNetworkConfig, tunnel: QuicWgConnPacketSender) -> impl Future<Output = Result<(), ()>> + Send;\n\n    /// Reset the network state. Returning `Ok()` implies that the OS will stop routing traffic to the tunnel soon.\n    fn unset_os_network_config(&self) -> impl Future<Output = Result<(), ()>> + Send;\n\n    /// Will be called when a packet from the relay is received on the tunnel, which should be emitted on the tunnel device.\n    fn packet_for_os(&self, packet: Bytes);\n}\n"
  },
  {
    "path": "rustlib/src/os/packet_buffer.rs",
    "content": "const PACKET_CAPACITY: usize = 100;\nconst BUFFER_CAPACITY: usize = 1500 * 100 + u16::MAX as usize;\npub struct PacketBuffer {\n    buffer: Box<[u8; BUFFER_CAPACITY]>,\n    buffer_len: usize,\n    lengths: Box<[u16; PACKET_CAPACITY]>,\n    lengths_len: usize,\n}\n\nimpl PacketBuffer {\n    pub fn buffer(&mut self) -> Option<&mut [u8]> {\n        let remainder = &mut self.buffer[self.buffer_len..];\n        (self.lengths_len < self.lengths.len() && remainder.len() > usize::from(u16::MAX)).then_some(remainder)\n    }\n    pub fn commit(&mut self, size: u16) {\n        self.buffer_len += usize::from(size);\n        self.lengths[self.lengths_len] = size;\n        self.lengths_len += 1;\n    }\n    pub fn take_iter(&mut self) -> PacketBufferIter<'_> {\n        let iter = PacketBufferIter { buffer: &self.buffer[..self.buffer_len], lengths: &self.lengths[..self.lengths_len] };\n        self.buffer_len = 0;\n        self.lengths_len = 0;\n        iter\n    }\n}\n\nimpl Default for PacketBuffer {\n    fn default() -> Self {\n        PacketBuffer {\n            buffer: [0u8; BUFFER_CAPACITY].into(),\n            buffer_len: 0,\n            lengths: [0u16; PACKET_CAPACITY].into(),\n            lengths_len: 0,\n        }\n    }\n}\n\npub struct PacketBufferIter<'a> {\n    buffer: &'a [u8],\n    lengths: &'a [u16],\n}\n\nimpl<'a> Iterator for PacketBufferIter<'a> {\n    type Item = &'a [u8];\n\n    fn next(&mut self) -> Option<Self::Item> {\n        let packet_len = usize::from(*self.lengths.first()?);\n        self.lengths = &self.lengths[1..];\n        let packet;\n        (packet, self.buffer) = self.buffer.split_at(packet_len);\n        Some(packet)\n    }\n\n    fn size_hint(&self) -> (usize, Option<usize>) {\n        (self.lengths.len(), Some(self.lengths.len()))\n    }\n}\n"
  },
  {
    "path": "rustlib/src/positive_u31.rs",
    "content": "use std::num::{NonZeroI32, NonZeroU32, TryFromIntError};\n\nuse serde::{Deserialize, Serialize};\n\n// Non-zero, positive integer below `1<<31`: [1..i32::MAX].\n#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(into = \"u32\")]\n#[serde(try_from = \"u32\")]\npub struct PositiveU31 {\n    value: u32,\n}\n\nimpl TryFrom<u32> for PositiveU31 {\n    type Error = TryFromIntError;\n    fn try_from(value: u32) -> Result<Self, Self::Error> {\n        NonZeroI32::try_from(NonZeroU32::try_from(value)?)?;\n        Ok(Self { value })\n    }\n}\n\nimpl From<PositiveU31> for u32 {\n    fn from(value: PositiveU31) -> Self {\n        value.value\n    }\n}\n\nimpl From<PositiveU31> for i32 {\n    fn from(value: PositiveU31) -> Self {\n        value.value as i32\n    }\n}\n\nimpl From<PositiveU31> for NonZeroU32 {\n    fn from(value: PositiveU31) -> Self {\n        Self::new(value.value).unwrap()\n    }\n}\n\nimpl From<PositiveU31> for NonZeroI32 {\n    fn from(value: PositiveU31) -> Self {\n        Self::new(value.value as i32).unwrap()\n    }\n}\n"
  },
  {
    "path": "rustlib/src/quicwg.rs",
    "content": "use boringtun::noise::{Tunn, TunnResult};\nuse boringtun::x25519::{PublicKey, StaticSecret};\nuse bytes::Bytes;\nuse futures::Stream;\nuse futures::StreamExt;\nuse futures::stream::unfold;\nuse obscuravpn_api::relay_protocol::{MessageCode, MessageContext, MessageHeader, PROTOCOL_IDENTIFIER, RelayOpCode, RelayResponseCode};\nuse obscuravpn_api::wg_fragment::merge::{ReassembleResult, WgFragmentBuffer};\nuse obscuravpn_api::wg_fragment::split::WgMessageFragmenter;\nuse quinn::crypto::rustls::QuicClientConfig;\nuse quinn::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};\nuse quinn::rustls::crypto::{CryptoProvider, verify_tls12_signature, verify_tls13_signature};\nuse quinn::rustls::pki_types::{CertificateDer, ServerName, UnixTime};\nuse quinn::rustls::{CertificateError, DigitallySignedStruct, SignatureScheme};\nuse quinn::{ClientConfig, MtuDiscoveryConfig, rustls};\nuse rand::random;\nuse serde::{Deserialize, Serialize};\nuse std::collections::VecDeque;\nuse std::iter::once;\nuse std::mem;\nuse std::net::{Ipv4Addr, SocketAddr};\nuse std::num::{NonZeroU32, Saturating};\nuse std::ops::ControlFlow;\nuse std::pin::Pin;\nuse std::sync::{Arc, Mutex, Weak};\nuse strum::Display;\nuse thiserror::Error;\nuse tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadHalf};\nuse tokio::net::TcpStream;\nuse tokio::sync::watch;\nuse tokio::time::{Duration, Instant};\nuse tokio::time::{sleep_until, timeout};\nuse tokio::{io, select, spawn};\nuse tokio_rustls::TlsConnector;\nuse tokio_rustls::client::TlsStream;\nuse uuid::Uuid;\n\nuse crate::liveness::LivenessChecker;\nuse crate::tokio::AbortOnDrop;\n\nconst WG_FIRST_HANDSHAKE_RESENDS: usize = 25; // 2.5s per handshake.\nconst WG_FIRST_HANDSHAKE_TIMEOUT: Duration = Duration::from_millis(100);\n\n/// Ideally we would have a shorter QUIC idle timeout at the beginning and no timeout once the connection starts but this is not supported by quinn.\npub const QUIC_IDLE_TIMEOUT: Duration = Duration::from_secs(60);\n\nconst QUIC_STEP_TIMEOUT: Duration = Duration::from_secs(30);\n\n/// How fast to call `update_timers`.\n///\n/// In the boringtun repo they call it at 4Hz, however we have traditionally called it at 1Hz and doesn't seem to have any problems.\nconst WG_TIMER_TICK: Duration = Duration::from_secs(1);\npub const TUNNEL_MTU: u16 = 1280;\n\n/// Max UDP payload size used by default if network MTU allows for it\npub const DEFAULT_UDP_PAYLOAD_SIZE: u16 = 1350;\n\npub const IPV4_UDP_OVERHEAD: u16 = 20 + 8;\n\nconst LIVENESS_MTU: u16 = 100;\n\n/// Maximum number of fragments in the WireGuard fragment buffer.\n///\n/// 1Gb/s (not counting tunnel overhead) at 1350B per message results in a message frequency below 100k/s.\n/// To cover 100ms of jitter (on top of regular latency) at this speed, the fragment buffer needs to span 10k consecutively sent messages.\n/// At 1kB per fragment (half of a message, including a generous margin for allocation and tunnel overhead), this results in a peak memory consumption of 10MB.\n///\n/// The time span covered (max jitter without packet loss) is inversely proportional to the bandwidth (e.g. at 100Mb/s the 100ms max jitter grows to 1s).\nconst WG_FRAGMENT_BUFFER_LEN: NonZeroU32 = NonZeroU32::new(10_000).unwrap();\n\n/// Maximum WireGuard fragment size, to prevent fragment buffer bloat due to malicious large packets.\n///\n/// A 1540B fragment, can hold a 1532B WireGuard message.\n/// With 32B overhead per WireGuard message, this allows a single fragment to hold a 1500B IP packet without requiring the second fragment to carry any data.\nconst WG_FRAGMENT_MAX_SIZE: u16 = 1540;\n\n#[derive(Debug, Error)]\npub enum QuicWgReceiveError {\n    #[error(\"tunnel is dead\")]\n    TunnelDead,\n    #[error(\"quic receive error: {0}\")]\n    QuicReceiveError(io::Error),\n}\n\n#[derive(Debug, Error)]\npub enum QuicWgConnectError {\n    #[error(\"crypto config: {0}\")]\n    CryptoConfig(anyhow::Error),\n    #[error(\"quic config: {0}\")]\n    QuicConfig(quinn::ConnectError),\n    #[error(\"quic connect: {0}\")]\n    TransportConnect(io::Error),\n    #[error(\"relay handshake: {0}\")]\n    RelayHandshake(#[from] QuicWgRelayHandshakeError),\n    #[error(\"wireguard handshake: {0}\")]\n    WireguardHandshake(QuicWgWireguardHandshakeError),\n}\n\n#[derive(Debug, Error)]\npub enum QuicWgRelayHandshakeError {\n    #[error(\"could not open control stream: {0}\")]\n    ControlStreamInitError(quinn::ConnectionError),\n    #[error(\"could not receive message from control stream: {0}\")]\n    ControlStreamMessageReceiveError(io::Error),\n    #[error(\"could not read protocol identifier from control stream: {0}\")]\n    ProtocolIdentifierReceiveFailed(io::Error),\n    #[error(\"timeout {0}\")]\n    Timeout(&'static str),\n    #[error(\"relay sent unexpected protocol indentifier: {0:#034x}\")]\n    UnexpectedProtocolIdentifierReceived(u128),\n    #[error(\"could not write to control stream: {0}\")]\n    ControlStreamWriteError(io::Error),\n    #[error(\"received {0}\")]\n    ReceivedErrorResponse(RelayErrorResponse),\n}\n\n#[derive(Debug, Error)]\n#[error(\"relay error response code {error_code}: {message}\")]\npub struct RelayErrorResponse {\n    error_code: NonZeroU32,\n    message: String,\n}\n\n#[derive(Debug, Error)]\n#[error(\"unexpected relay op code {0:?}\")]\npub struct UnexpectedOpCode(RelayOpCode);\n\nimpl RelayErrorResponse {\n    pub fn new(error_code: NonZeroU32, message: &[u8]) -> Self {\n        Self { error_code, message: String::from_utf8_lossy(message).into() }\n    }\n}\n\n#[derive(Debug, Error)]\npub enum QuicWgWireguardHandshakeError {\n    #[error(\"could not construct inititialization message\")]\n    InitMessageConstructError,\n    #[error(\"could not send inititialization message: {0}\")]\n    InitMessageSendError(io::Error),\n    #[error(\"could not receive response message: {0}\")]\n    RespMessageReceiveError(io::Error),\n    #[error(\"response timeout\")]\n    RespMessageTimeout,\n}\n\npub struct QuicWgConn {\n    wg_state: Mutex<WgState>,\n    wg_sender: WgSender,\n    wg_receiver: WgReceiver,\n    client_public_key: PublicKey,\n    exit_public_key: PublicKey,\n    _tcp_tls_sender_abort: Option<AbortOnDrop>,\n    _quic_control_stream: Option<(quinn::SendStream, quinn::RecvStream)>,\n}\n\n#[derive(Clone, Copy, Debug)]\npub struct QuicWgTrafficStats {\n    pub connected_at: Instant,\n    pub tx_bytes: u64,\n    pub rx_bytes: u64,\n    pub latest_latency_ms: u16,\n}\n\nstruct WgState {\n    buffer: Vec<u8>,\n    next_wg_timers_tick: Instant,\n    next_liveness_poll: Instant,\n    tick_stats: TickStats,\n    traffic_stats: QuicWgTrafficStats,\n    wg: Tunn,\n    liveness_checker: LivenessChecker,\n    fragmenter: WgMessageFragmenter,\n    fragment_buffer: WgFragmentBuffer,\n}\n\n#[derive(Clone, Copy, Debug, Default)]\nstruct TickStats {\n    ip_tx_count: Saturating<u64>,\n    wg_tx_count: Saturating<u64>,\n    wg_tx_fragmented_count: Saturating<u64>,\n    ip_rx_count: Saturating<u64>,\n    wg_rx_count: Saturating<u64>,\n    wg_rx_fragment_buffered_count: Saturating<u64>,\n    wg_rx_fragment_reassembled_count: Saturating<u64>,\n    wg_rx_fragment_max_message_size: Option<u16>,\n    min_ip_tx_size: Option<usize>,\n    max_ip_tx_size: Option<usize>,\n    min_ip_rx_size: Option<usize>,\n    max_ip_rx_size: Option<usize>,\n}\n\n#[derive(Debug, Display, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\", rename_all_fields = \"camelCase\")]\npub enum TransportKind {\n    Quic,\n    TcpTls,\n}\n\n#[derive(Clone)]\npub struct QuicWgConnPacketSender(Weak<QuicWgConn>);\n\nimpl QuicWgConnPacketSender {\n    pub fn new(conn: Option<&Arc<QuicWgConn>>) -> Self {\n        Self(conn.map(Arc::downgrade).unwrap_or_default())\n    }\n\n    pub fn send<'a>(&self, packets: impl Iterator<Item = &'a [u8]>) {\n        if let Some(conn) = self.0.upgrade() {\n            conn.send(packets)\n        }\n    }\n}\n\nimpl QuicWgConn {\n    pub async fn connect(\n        relay_handshaking: QuicWgConnHandshaking,\n        client_secret_key: StaticSecret,\n        exit_public_key: PublicKey,\n        client_ip_v4: Ipv4Addr,\n        ping_target_ip_v4: Ipv4Addr,\n        token: Uuid,\n    ) -> Result<Self, QuicWgConnectError> {\n        let client_public_key = PublicKey::from(&client_secret_key);\n        let (mut wg_sender, mut wg_receiver, quic_control_stream, tcp_tls_sender_abort) =\n            relay_handshaking.authenticate(token).await?.into_wg_send_recv();\n        tracing::info!(message_id = \"UROUZerU\", \"completed handshake with relay\");\n\n        let index = random();\n        let mut wg = Tunn::new(client_secret_key, exit_public_key, None, None, index, None).unwrap();\n        Self::first_wg_handshake(&mut wg, &mut wg_sender, &mut wg_receiver, WG_FIRST_HANDSHAKE_RESENDS)\n            .await\n            .map_err(QuicWgConnectError::WireguardHandshake)?;\n        tracing::info!(message_id = \"TJ4nH30h\", \"connected to exit\");\n        let now = Instant::now();\n        let wg_state = Mutex::new(WgState {\n            wg,\n            traffic_stats: QuicWgTrafficStats { connected_at: now, tx_bytes: 0, rx_bytes: 0, latest_latency_ms: 0 },\n            buffer: vec![0u8; u16::MAX as usize],\n            next_wg_timers_tick: now + WG_TIMER_TICK,\n            next_liveness_poll: now,\n            liveness_checker: LivenessChecker::new(LIVENESS_MTU, client_ip_v4, ping_target_ip_v4),\n            tick_stats: Default::default(),\n            fragmenter: Default::default(),\n            fragment_buffer: WgFragmentBuffer::new(WG_FRAGMENT_BUFFER_LEN, WG_FRAGMENT_MAX_SIZE),\n        });\n        Ok(Self {\n            wg_receiver,\n            wg_sender,\n            wg_state,\n            client_public_key,\n            exit_public_key,\n            _tcp_tls_sender_abort: tcp_tls_sender_abort,\n            _quic_control_stream: quic_control_stream,\n        })\n    }\n\n    fn build_first_wg_handshake_init(wg: &mut Tunn) -> Result<Bytes, QuicWgWireguardHandshakeError> {\n        let mut buf = vec![0u8; u16::MAX as usize];\n        let data = match wg.format_handshake_initiation(&mut buf, true) {\n            TunnResult::WriteToNetwork(data) => Bytes::copy_from_slice(data),\n            _ => return Err(QuicWgWireguardHandshakeError::InitMessageConstructError),\n        };\n        Ok(data)\n    }\n\n    async fn wait_for_first_handshake_response(\n        wg: &mut Tunn,\n        wg_receiver: &mut WgReceiver,\n        wg_sender: &WgSender,\n    ) -> Result<(), QuicWgWireguardHandshakeError> {\n        let mut buf = vec![0u8; u16::MAX as usize];\n        timeout(WG_FIRST_HANDSHAKE_TIMEOUT, async {\n            while wg.time_since_last_handshake().is_none() {\n                let mut datagram = wg_receiver\n                    .receive_wg_message()\n                    .await\n                    .map_err(QuicWgWireguardHandshakeError::RespMessageReceiveError)?;\n                loop {\n                    let res = wg.decapsulate(None, &datagram, &mut buf);\n                    match Self::handle_result(wg_sender, res) {\n                        ControlFlow::Continue(()) => {\n                            datagram.truncate(0);\n                            continue;\n                        }\n                        ControlFlow::Break(Some(_)) => tracing::warn!(message_id = \"d8pzbt5Z\", \"unexpected packet during first WG handshake\"),\n                        ControlFlow::Break(None) => break,\n                    }\n                }\n            }\n            Ok(())\n        })\n        .await\n        .map_err(|_| QuicWgWireguardHandshakeError::RespMessageTimeout)?\n    }\n\n    async fn first_wg_handshake(\n        wg: &mut Tunn,\n        wg_sender: &mut WgSender,\n        wg_receiver: &mut WgReceiver,\n        resends: usize,\n    ) -> Result<(), QuicWgWireguardHandshakeError> {\n        let handshake_init = Self::build_first_wg_handshake_init(wg)?;\n        let mut resends = resends;\n        loop {\n            resends -= 1;\n            wg_sender.send_wg_message(handshake_init.clone());\n            match Self::wait_for_first_handshake_response(wg, wg_receiver, wg_sender).await {\n                Ok(()) => return Ok(()),\n                Err(err) => match err {\n                    QuicWgWireguardHandshakeError::RespMessageTimeout => {\n                        tracing::info!(message_id = \"dHFvpzvl\", \"exit handshake timeout, packet may have gotten lost\");\n                        if resends == 0 {\n                            tracing::info!(\n                                message_id = \"ZvQp8VQQ\",\n                                \"too many exit handshake resend attempts, exit may not be configured\"\n                            );\n                            break;\n                        }\n                    }\n                    err => return Err(err),\n                },\n            }\n        }\n        Err(QuicWgWireguardHandshakeError::RespMessageTimeout)\n    }\n\n    fn handle_result(wg_sender: &WgSender, res: TunnResult<'_>) -> ControlFlow<Option<Bytes>> {\n        match res {\n            TunnResult::Done => ControlFlow::Break(None),\n            TunnResult::WriteToNetwork(wg_message) => {\n                wg_sender.send_wg_message(Bytes::copy_from_slice(wg_message));\n                ControlFlow::Continue(())\n            }\n            TunnResult::WriteToTunnelV4(packet, ..) | TunnResult::WriteToTunnelV6(packet, ..) => {\n                ControlFlow::Break(Some(Bytes::copy_from_slice(packet)))\n            }\n            TunnResult::Err(error) => {\n                tracing::warn!(message_id = \"uQ0xQcPP\", ?error, \"wireguard error\");\n                ControlFlow::Break(None)\n            }\n        }\n    }\n\n    fn send<'a>(&self, packets: impl Iterator<Item = &'a [u8]>) {\n        let mut wg_state = self.wg_state.lock().unwrap();\n        if let Some(packet) = wg_state.liveness_checker.sent_traffic() {\n            self.send_single_packet(&mut wg_state, &packet);\n        }\n        for packet in packets {\n            wg_state.traffic_stats.tx_bytes += packet.len() as u64;\n            wg_state.tick_stats.ip_tx_count += 1;\n            wg_state.tick_stats.min_ip_tx_size = Some(wg_state.tick_stats.min_ip_tx_size.unwrap_or(usize::MAX).min(packet.len()));\n            wg_state.tick_stats.max_ip_tx_size = Some(wg_state.tick_stats.max_ip_tx_size.unwrap_or(0).max(packet.len()));\n            self.send_single_packet(&mut wg_state, packet);\n        }\n    }\n\n    pub fn wake(&self) {\n        let mut wg_state = self.wg_state.lock().unwrap();\n        let packet = wg_state.liveness_checker.wake();\n        self.send_single_packet(&mut wg_state, &packet);\n    }\n\n    fn send_single_packet(&self, wg_state: &mut WgState, packet: &[u8]) {\n        match wg_state.wg.encapsulate(packet, &mut wg_state.buffer) {\n            TunnResult::Done => tracing::error!(message_id = \"10g8g1D1\", \"WG encapsulate did not yield a datagram to send\"),\n            TunnResult::Err(error) => tracing::warn!(message_id = \"MAvGA9tf\", ?error, \"wireguard error\"),\n            TunnResult::WriteToNetwork(wg_message) => {\n                wg_state.tick_stats.wg_tx_count += 1;\n                let wg_message = Bytes::copy_from_slice(wg_message);\n                let (first, second) = match self.wg_sender.max_wg_message_size() {\n                    Some(max_size) => wg_state.fragmenter.fragment(wg_message, max_size),\n                    None => (wg_message, None),\n                };\n                wg_state.tick_stats.wg_tx_fragmented_count += u64::from(second.is_some());\n                for msg in once(first).chain(second) {\n                    self.wg_sender.send_wg_message(msg);\n                }\n            }\n            TunnResult::WriteToTunnelV4(_, _) | TunnResult::WriteToTunnelV6(_, _) => {\n                tracing::error!(message_id = \"mOwsH8Eu\", \"WG encapsulate yielded a received ip packet\")\n            }\n        }\n    }\n\n    pub async fn receive(&self) -> Result<Bytes, QuicWgReceiveError> {\n        loop {\n            let next_liveness_poll;\n            let next_wg_timers_tick;\n            {\n                let wg_state = &mut *self.wg_state.lock().unwrap();\n                next_liveness_poll = wg_state.next_liveness_poll;\n                next_wg_timers_tick = wg_state.next_wg_timers_tick;\n            }\n\n            select! {\n                biased;\n                _ = sleep_until(next_wg_timers_tick) => {\n                    let wg_state = &mut *self.wg_state.lock().unwrap();\n                    tracing::info!(\n                        message_id = \"WKqFjXMA\",\n                        tick_stats =? wg_state.tick_stats,\n                    );\n                    wg_state.tick_stats = TickStats::default();\n                    loop {\n                        let timer_result = wg_state.wg.update_timers(&mut wg_state.buffer);\n                        match Self::handle_result(&self.wg_sender, timer_result) {\n                            ControlFlow::Continue(()) => continue,\n                            ControlFlow::Break(Some(_)) => tracing::warn!(message_id = \"nmuKdNnr\", \"unexpected packet during update_timers\"),\n                            ControlFlow::Break(None) => break,\n                        }\n                    }\n                    wg_state.next_wg_timers_tick = Instant::now() + WG_TIMER_TICK;\n                }\n                result = self.wg_receiver.receive_wg_message() => {\n                    let wg_message = result.map_err(QuicWgReceiveError::QuicReceiveError)?;\n                    let mut wg_state = self.wg_state.lock().unwrap();\n                    let WgState {buffer, wg, traffic_stats, tick_stats, liveness_checker, fragment_buffer, .. } = &mut *wg_state;\n                    let mut wg_message = match fragment_buffer.reassemble(wg_message) {\n                        ReassembleResult::NotFragmented(msg) => msg,\n                        ReassembleResult::Reassembled(msg) => {\n                            tick_stats.wg_rx_fragment_reassembled_count += 1;\n                            msg\n                        }\n                        ReassembleResult::UnmatchedFragment { max_message_size } => {\n                            tick_stats.wg_rx_fragment_buffered_count += 1;\n                            tick_stats.wg_rx_fragment_max_message_size = Some(max_message_size);\n                            continue;\n                        }\n                    };\n                    tick_stats.wg_rx_count += 1;\n                    loop {\n                        let res = wg.decapsulate(None, &wg_message, buffer);\n                        if let TunnResult::WriteToNetwork(..) = &res {\n                            tick_stats.wg_tx_count += 1;\n                        }\n                        match Self::handle_result(&self.wg_sender, res) {\n                            ControlFlow::Continue(()) => {\n                                wg_message.truncate(0);\n                                continue\n                            }\n                            ControlFlow::Break(Some(packet)) => {\n                                tick_stats.ip_rx_count += 1;\n                                tick_stats.min_ip_rx_size = Some(tick_stats.min_ip_rx_size.unwrap_or(usize::MAX).min(packet.len()));\n                                tick_stats.max_ip_rx_size = Some(tick_stats.max_ip_rx_size.unwrap_or(0).max(packet.len()));\n                                traffic_stats.rx_bytes += packet.len() as u64;\n                                if let Some(latest_latency) = liveness_checker.process_potential_probe_response(&packet) {\n                                    traffic_stats.latest_latency_ms = u16::try_from(latest_latency.as_millis()).unwrap_or(u16::MAX);\n                                    break\n                                }\n                                return Ok(packet)\n                            },\n                            ControlFlow::Break(None) => break,\n                        }\n                    }\n                }\n                _ = sleep_until(next_liveness_poll) => {\n                    let wg_state = &mut*self.wg_state.lock().unwrap();\n                    wg_state.next_liveness_poll = loop {\n                        match wg_state.liveness_checker.poll() {\n                            crate::liveness::LivenessCheckerPoll::Dead => break Err(QuicWgReceiveError::TunnelDead),\n                            crate::liveness::LivenessCheckerPoll::AliveUntil(pending_until) => break Ok(pending_until),\n                            crate::liveness::LivenessCheckerPoll::SendPacket(packet) => self.send_single_packet(wg_state, &packet),\n                        }\n                    }?.into();\n                }\n            }\n        }\n    }\n\n    pub fn traffic_stats(&self) -> QuicWgTrafficStats {\n        self.wg_state.lock().unwrap().traffic_stats\n    }\n\n    pub fn exit_public_key(&self) -> PublicKey {\n        self.exit_public_key\n    }\n\n    pub fn client_public_key(&self) -> PublicKey {\n        self.client_public_key\n    }\n\n    pub fn transport(&self) -> TransportKind {\n        match self.wg_sender {\n            WgSender::Quic { .. } => TransportKind::Quic,\n            WgSender::TcpTls { .. } => TransportKind::TcpTls,\n        }\n    }\n}\n\npub struct QuicWgConnHandshaking {\n    relay_id: String,\n    port: u16,\n    transport: Transport,\n}\n\nimpl QuicWgConnHandshaking {\n    pub async fn start_quic(\n        relay_id: String,\n        quic_endpoint: &quinn::Endpoint,\n        relay_addr: SocketAddr,\n        relay_cert: CertificateDer<'static>,\n        relay_sni: &str,\n        quic_frame_padding: bool,\n        force_small_mtu: bool,\n        network_interface_mtu: Option<u16>,\n    ) -> Result<Self, QuicWgConnectError> {\n        let port = relay_addr.port();\n        tracing::info!(\n            message_id = \"AYsfThUG\",\n            network_interface.mtu = network_interface_mtu,\n            port = port,\n            relay.id = relay_id,\n            \"starting quic wg relay handshake\",\n        );\n        let quic_config =\n            Self::quic_config(relay_cert, quic_frame_padding, network_interface_mtu, force_small_mtu).map_err(QuicWgConnectError::CryptoConfig)?;\n        let connecting = quic_endpoint\n            .connect_with(quic_config.clone(), relay_addr, relay_sni)\n            .map_err(QuicWgConnectError::QuicConfig)?;\n        let connection = connecting.await.map_err(io::Error::other).map_err(QuicWgConnectError::TransportConnect)?;\n        let (send, recv) = connection.open_bi().await.map_err(QuicWgRelayHandshakeError::ControlStreamInitError)?;\n        let mut this = Self { relay_id, port, transport: Transport::Quic { conn: connection, send, recv } };\n        this.exchange_protocol_identifiers().await?;\n        Ok(this)\n    }\n\n    pub async fn start_tcp_tls(\n        relay_id: String,\n        relay_addr: SocketAddr,\n        relay_cert: CertificateDer<'static>,\n        relay_sni: &str,\n    ) -> Result<Self, QuicWgConnectError> {\n        let tcp_stream = TcpStream::connect(relay_addr).await.map_err(QuicWgConnectError::TransportConnect)?;\n        if let Err(error) = tcp_stream.set_nodelay(true) {\n            tracing::warn!(message_id = \"k9KRCm3G\", ?error, \"failed to set tcp nodelay\");\n        }\n        let tls_connector = Self::tcp_tls_config(relay_cert).map_err(QuicWgConnectError::CryptoConfig)?;\n        let server_name = relay_sni\n            .to_string()\n            .try_into()\n            .map_err(Into::into)\n            .map_err(QuicWgConnectError::CryptoConfig)?;\n        let tls_stream = tls_connector\n            .connect(server_name, tcp_stream)\n            .await\n            .map_err(QuicWgConnectError::TransportConnect)?;\n        let mut this = Self { relay_id, port: relay_addr.port(), transport: Transport::TcpTls(tls_stream) };\n        this.exchange_protocol_identifiers().await?;\n        Ok(this)\n    }\n\n    async fn exchange_protocol_identifiers(&mut self) -> Result<(), QuicWgRelayHandshakeError> {\n        let mut buffer = PROTOCOL_IDENTIFIER.to_be_bytes();\n        match &mut self.transport {\n            Transport::Quic { send, recv, .. } => {\n                send.write_all(&buffer)\n                    .await\n                    .map_err(io::Error::other)\n                    .map_err(QuicWgRelayHandshakeError::ControlStreamWriteError)?;\n                recv.read_exact(&mut buffer)\n                    .await\n                    .map_err(io::Error::other)\n                    .map_err(QuicWgRelayHandshakeError::ProtocolIdentifierReceiveFailed)?;\n            }\n            Transport::TcpTls(tls_stream) => {\n                tls_stream\n                    .write_all(&buffer)\n                    .await\n                    .map_err(QuicWgRelayHandshakeError::ControlStreamWriteError)?;\n                tls_stream.flush().await.map_err(QuicWgRelayHandshakeError::ControlStreamWriteError)?;\n                tls_stream\n                    .read_exact(&mut buffer)\n                    .await\n                    .map_err(QuicWgRelayHandshakeError::ProtocolIdentifierReceiveFailed)?;\n            }\n        }\n        let relay_protocol_identifier = u128::from_be_bytes(buffer);\n        if relay_protocol_identifier != PROTOCOL_IDENTIFIER {\n            return Err(QuicWgRelayHandshakeError::UnexpectedProtocolIdentifierReceived(relay_protocol_identifier));\n        }\n        Ok(())\n    }\n\n    pub async fn measure_rtt(&mut self) -> Result<Duration, QuicWgConnectError> {\n        let mut start_time = Instant::now();\n        let mut min_rtt = Duration::MAX;\n        for _ in 0..3 {\n            self.send_op(RelayOpCode::Ping, &[]).await?;\n            self.recv_ok_resp().await?;\n            let end_time = Instant::now();\n            if let Some(last_rtt) = end_time.checked_duration_since(start_time) {\n                min_rtt = min_rtt.min(last_rtt);\n            }\n            start_time = end_time;\n        }\n        tracing::info!(\n            message_id = \"CyF9avyp\",\n            \"relay {} port {} min rtt is {}ms\",\n            &self.relay_id,\n            self.port,\n            min_rtt.as_millis()\n        );\n        Ok(min_rtt)\n    }\n\n    async fn authenticate(mut self, token: Uuid) -> Result<Transport, QuicWgConnectError> {\n        self.send_op(RelayOpCode::Token, token.as_bytes()).await?;\n        self.recv_ok_resp().await?;\n        tracing::info!(message_id = \"3rOUXFti\", \"relay confirmed token\");\n        let Self { transport, .. } = self;\n        Ok(transport)\n    }\n\n    async fn stop(&mut self) -> Result<(), QuicWgRelayHandshakeError> {\n        tracing::info!(message_id = \"eTR2QPCB\", \"sending stop op to relay {} port {}\", &self.relay_id, &self.port,);\n        self.send_op(RelayOpCode::Stop, &[]).await?;\n        self.recv_ok_resp().await?;\n        tracing::info!(message_id = \"3BwlgMb7\", \"relay {} port {} confirmed stop\", &self.relay_id, self.port);\n        Ok(())\n    }\n\n    pub async fn abandon(mut self) {\n        if let Err(error) = self.stop().await {\n            tracing::warn!(message_id = \"b0UeytEt\", ?error, \"error while abandoning handshake\")\n        } else {\n            match &mut self.transport {\n                Transport::Quic { send, .. } => {\n                    _ = send.finish();\n                    _ = send.stopped().await;\n                }\n                Transport::TcpTls(tls_stream) => {\n                    _ = tls_stream.shutdown().await;\n                }\n            }\n            drop(self);\n        }\n    }\n\n    fn rustls_config(relay_cert: CertificateDer<'static>) -> Result<rustls::ClientConfig, anyhow::Error> {\n        let default_provider = Arc::new(rustls::crypto::ring::default_provider());\n        let crypto = rustls::ClientConfig::builder_with_provider(default_provider.clone())\n            .with_safe_default_protocol_versions()?\n            .dangerous()\n            .with_custom_certificate_verifier(Arc::new(VerifyVpnServerCert { cert: relay_cert, provider: default_provider }))\n            .with_no_client_auth();\n        Ok(crypto)\n    }\n\n    fn tcp_tls_config(relay_cert: CertificateDer<'static>) -> Result<TlsConnector, anyhow::Error> {\n        let mut crypto = Self::rustls_config(relay_cert)?;\n        crypto.alpn_protocols = vec![b\"h2\".to_vec()];\n        Ok(TlsConnector::from(Arc::new(crypto)))\n    }\n\n    fn quic_config(\n        relay_cert: CertificateDer<'static>,\n        quic_frame_padding: bool,\n        network_interface_mtu: Option<u16>,\n        force_small_mtu: bool,\n    ) -> Result<ClientConfig, anyhow::Error> {\n        let mut crypto = Self::rustls_config(relay_cert)?;\n        crypto.alpn_protocols = vec![b\"h3\".to_vec()];\n        let crypto = QuicClientConfig::try_from(crypto)?;\n        let mut client_cfg = ClientConfig::new(Arc::new(crypto));\n        let mut transport_config = quinn::TransportConfig::default();\n        transport_config.max_concurrent_uni_streams(0u8.into());\n        transport_config.max_concurrent_bidi_streams(0u8.into());\n        let mut mtu_discovery_config = MtuDiscoveryConfig::default();\n        if force_small_mtu {\n            tracing::info!(\n                message_id = \"To1eYEO2\",\n                \"constraining outgoing UDP payload size due to small MTU experimental flag being set\"\n            );\n            mtu_discovery_config.upper_bound(1200);\n        } else if network_interface_mtu.is_some_and(|network_interface_mtu| network_interface_mtu < DEFAULT_UDP_PAYLOAD_SIZE + IPV4_UDP_OVERHEAD) {\n            tracing::info!(\n                message_id = \"7XXBAv2f\",\n                network_interface_mtu,\n                \"not setting fixed outgoing max UDP payload size, because network MTU is too low\"\n            );\n        } else {\n            transport_config.initial_mtu(DEFAULT_UDP_PAYLOAD_SIZE);\n            transport_config.min_mtu(DEFAULT_UDP_PAYLOAD_SIZE);\n            mtu_discovery_config.upper_bound(DEFAULT_UDP_PAYLOAD_SIZE);\n        }\n        mtu_discovery_config.black_hole_cooldown(Duration::from_secs(10));\n        transport_config.mtu_discovery_config(Some(mtu_discovery_config));\n        transport_config.max_idle_timeout(Some(QUIC_IDLE_TIMEOUT.try_into()?));\n        transport_config.congestion_controller_factory(Arc::new(quinn::congestion::BbrConfig::default()));\n        transport_config.pad_to_mtu(quic_frame_padding);\n        client_cfg.transport_config(Arc::new(transport_config));\n        Ok(client_cfg)\n    }\n\n    async fn recv_ok_resp(&mut self) -> Result<(), QuicWgRelayHandshakeError> {\n        let inner = self.recv_ok_resp_no_timeout();\n        timeout(QUIC_STEP_TIMEOUT, inner)\n            .await\n            .map_err(|_| QuicWgRelayHandshakeError::Timeout(\"awaiting op response\"))\n            .flatten()\n    }\n\n    async fn recv_ok_resp_no_timeout(&mut self) -> Result<(), QuicWgRelayHandshakeError> {\n        loop {\n            let (message_code, context_id, arg) = match &mut self.transport {\n                Transport::Quic { recv, .. } => recv_message(recv).await,\n                Transport::TcpTls(tls_stream) => recv_message(tls_stream).await,\n            }\n            .map_err(QuicWgRelayHandshakeError::ControlStreamMessageReceiveError)?;\n            let MessageCode::Response(response_code) = message_code else {\n                tracing::warn!(\n                    message_id = \"xfums1F8\",\n                    ?message_code,\n                    \"ignoring unexpected relay initiated message during handshake\"\n                );\n                continue;\n            };\n            if context_id != MessageContext::MIN_CLIENT_INITIATED {\n                tracing::warn!(message_id = \"EfCIJy4z\", ?context_id, \"ignoring response with non-zero context id\",);\n                continue;\n            }\n            match response_code {\n                RelayResponseCode::Ok => {\n                    if !arg.is_empty() {\n                        tracing::warn!(message_id = \"xd0PY4bH\", \"ignoring {} additional payload bytes on ok response\", arg.len());\n                    }\n                }\n                RelayResponseCode::Error(error_code) => {\n                    return Err(QuicWgRelayHandshakeError::ReceivedErrorResponse(RelayErrorResponse::new(\n                        error_code, &arg,\n                    )));\n                }\n            }\n            break Ok(());\n        }\n    }\n\n    async fn send_op(&mut self, op: RelayOpCode, arg: &[u8]) -> Result<(), QuicWgRelayHandshakeError> {\n        let inner = self.send_op_no_timeout(op, arg);\n        timeout(QUIC_STEP_TIMEOUT, inner)\n            .await\n            .map_err(|_| QuicWgRelayHandshakeError::Timeout(\"sending op\"))\n            .flatten()\n    }\n\n    async fn send_op_no_timeout(&mut self, op: RelayOpCode, arg: &[u8]) -> Result<(), QuicWgRelayHandshakeError> {\n        let op = MessageCode::Op(op);\n        let context_id = MessageContext::MIN_CLIENT_INITIATED;\n        match &mut self.transport {\n            Transport::Quic { send, .. } => send_message(send, op, context_id, arg).await,\n            Transport::TcpTls(tls_stream) => send_message(tls_stream, op, context_id, arg).await,\n        }\n        .map_err(QuicWgRelayHandshakeError::ControlStreamWriteError)\n    }\n\n    pub fn transport_kind(self) -> TransportKind {\n        match self.transport {\n            Transport::Quic { .. } => TransportKind::Quic,\n            Transport::TcpTls(..) => TransportKind::TcpTls,\n        }\n    }\n}\n\nenum Transport {\n    Quic {\n        conn: quinn::Connection,\n        send: quinn::SendStream,\n        recv: quinn::RecvStream,\n    },\n    TcpTls(TlsStream<TcpStream>),\n}\n\nimpl Transport {\n    fn into_wg_send_recv(self) -> (WgSender, WgReceiver, Option<(quinn::SendStream, quinn::RecvStream)>, Option<AbortOnDrop>) {\n        let tls_stream = match self {\n            Transport::Quic { conn, send, recv } => {\n                return (WgSender::Quic { conn: conn.clone() }, WgReceiver::Quic(conn), Some((send, recv)), None);\n            }\n            Transport::TcpTls(tls_stream) => tls_stream,\n        };\n        let (relay_read, mut relay_write) = io::split(tls_stream);\n\n        let (traffic_state, mut traffic_state_watch) = watch::channel(WgTrafficState::default());\n        let sender = WgSender::TcpTls { traffic_state: traffic_state.clone() };\n        let receiver = WgReceiver::new_tcp_tls(traffic_state.clone(), relay_read);\n\n        let abort_handle = spawn(async move {\n            let mut oks_in_progress: VecDeque<MessageContext> = VecDeque::new();\n            let mut packet_in_progress: Option<Vec<u8>> = None;\n            loop {\n                loop {\n                    traffic_state.send_if_modified(|traffic_state| {\n                        if !traffic_state.queued_oks.is_empty() {\n                            mem::swap(&mut traffic_state.queued_oks, &mut oks_in_progress);\n                            true\n                        } else if !traffic_state.queued_packets.is_empty() {\n                            packet_in_progress = traffic_state.queued_packets.pop_front();\n                            true\n                        } else {\n                            false\n                        }\n                    });\n                    if let Some(packet) = packet_in_progress.take() {\n                        if let Err(error) = send_message(\n                            &mut relay_write,\n                            MessageCode::Op(RelayOpCode::WireGuard),\n                            MessageContext::MIN_CLIENT_INITIATED,\n                            &packet,\n                        )\n                        .await\n                        {\n                            tracing::error!(\n                                message_id = \"xKBeN0Jb\",\n                                ?error,\n                                \"ending tcp tls relay send loop due to WG packet send error\"\n                            );\n                            return;\n                        }\n                    } else if !oks_in_progress.is_empty() {\n                        while let Some(context_id) = oks_in_progress.pop_front() {\n                            if let Err(error) = send_message(&mut relay_write, MessageCode::Response(RelayResponseCode::Ok), context_id, &[]).await {\n                                tracing::error!(\n                                    message_id = \"RfElrp6D\",\n                                    ?error,\n                                    \"ending tcp tls relay send loop due to response send error\"\n                                );\n                                return;\n                            }\n                        }\n                    } else {\n                        break;\n                    }\n                }\n                _ = traffic_state_watch.changed().await;\n            }\n        })\n        .abort_handle()\n        .into();\n\n        (sender, receiver, None, Some(abort_handle))\n    }\n}\n\nenum WgSender {\n    Quic { conn: quinn::Connection },\n    TcpTls { traffic_state: watch::Sender<WgTrafficState> },\n}\n\nimpl WgSender {\n    fn max_wg_message_size(&self) -> Option<u16> {\n        match self {\n            WgSender::Quic { conn, .. } => conn.max_datagram_size().and_then(|s| u16::try_from(s).ok()),\n            WgSender::TcpTls { .. } => None,\n        }\n    }\n\n    fn send_wg_message(&self, wg_message: Bytes) {\n        match self {\n            WgSender::Quic { conn } => {\n                if let Err(error) = conn.send_datagram(wg_message) {\n                    rate_limited_log!(\n                        Duration::from_secs(1),\n                        tracing::error!(message_id = \"8EkAaj9z\", ?error, \"error while sending quic datagram packet\")\n                    );\n                }\n            }\n            WgSender::TcpTls { traffic_state } => {\n                traffic_state.send_modify(|traffic_state| {\n                    if traffic_state.queued_packets.len() < 1000 {\n                        traffic_state.queued_packets.push_back(wg_message.into());\n                    }\n                });\n            }\n        }\n    }\n}\n\nenum WgReceiver {\n    Quic(quinn::Connection),\n    TcpTls {\n        traffic_state: watch::Sender<WgTrafficState>,\n        #[allow(clippy::type_complexity)]\n        recv_message_stream: tokio::sync::Mutex<Pin<Box<dyn Stream<Item = Result<(MessageCode, MessageContext, Vec<u8>), io::Error>> + Send>>>,\n    },\n}\n\nimpl WgReceiver {\n    fn new_tcp_tls(traffic_state: watch::Sender<WgTrafficState>, relay_read: ReadHalf<TlsStream<TcpStream>>) -> Self {\n        let recv_message_stream = Box::pin(unfold(relay_read, |mut relay_read| async move {\n            let item = recv_message(&mut relay_read).await;\n            Some((item, relay_read))\n        }));\n        Self::TcpTls { traffic_state, recv_message_stream: tokio::sync::Mutex::new(recv_message_stream) }\n    }\n\n    async fn receive_wg_message(&self) -> io::Result<Bytes> {\n        loop {\n            return match self {\n                WgReceiver::Quic(conn) => conn.read_datagram().await.map_err(io::Error::other),\n                WgReceiver::TcpTls { traffic_state, recv_message_stream } => {\n                    let (code, context_id, arg) = recv_message_stream.lock().await.next().await.unwrap()?;\n                    let op_code = match code {\n                        MessageCode::Op(op_code) => {\n                            traffic_state.send_modify(|traffic_state| traffic_state.queued_oks.push_back(context_id));\n                            op_code\n                        }\n                        MessageCode::Response(RelayResponseCode::Ok) => continue,\n                        MessageCode::Response(RelayResponseCode::Error(error_code)) => {\n                            return Err(io::Error::other(RelayErrorResponse::new(error_code, &arg)));\n                        }\n                    };\n                    match op_code {\n                        RelayOpCode::WireGuard => Ok(arg.into()),\n                        op_code => Err(io::Error::other(UnexpectedOpCode(op_code))),\n                    }\n                }\n            };\n        }\n    }\n}\n\n#[derive(Default)]\nstruct WgTrafficState {\n    queued_oks: VecDeque<MessageContext>,\n    queued_packets: VecDeque<Vec<u8>>,\n}\n\nasync fn send_message<T: AsyncWrite + Unpin>(transport: &mut T, code: MessageCode, context_id: MessageContext, arg: &[u8]) -> Result<(), io::Error> {\n    let code = code.to_bytes();\n    let msg_header: [u8; 8] = MessageHeader { context_id, payload_length: 4 + arg.len() as u32 }.into();\n    transport.write_all(&msg_header).await?;\n    transport.write_all(&code).await?;\n    transport.write_all(arg).await?;\n    transport.flush().await\n}\n\nasync fn recv_skip<T: AsyncRead + Unpin>(transport: &mut T, mut n: usize) -> Result<(), io::Error> {\n    let mut buffer = vec![0u8; u16::MAX.into()];\n    while n >= buffer.len() {\n        transport.read_exact(&mut buffer).await?;\n        n -= buffer.len();\n    }\n    if n > 0 {\n        transport.read_exact(&mut buffer[0..n]).await?;\n    }\n    Ok(())\n}\n\nasync fn recv_message<T: AsyncRead + Unpin>(transport: &mut T) -> Result<(MessageCode, MessageContext, Vec<u8>), io::Error> {\n    loop {\n        let header = MessageHeader::from(recv_fixed::<8, _>(transport).await?);\n        let len = header.payload_length_usize();\n        if len < 4 || len > u16::MAX as usize + 4 {\n            tracing::warn!(message_id = \"1gPHoHdA\", len, \"ignoring relay message with payload too small or large\");\n            recv_skip(transport, len).await?;\n        }\n        let mut payload = vec![0u8; len];\n        transport.read_exact(&mut payload).await?;\n        let (code, arg) = payload.split_at_checked(4).unwrap();\n        let code = code.try_into().unwrap_or([u8::MAX; 4]);\n        let Some(code) = MessageCode::from_bytes(code, header.context_id, true) else {\n            // Forward compatibility with future relay protocol changes\n            tracing::warn!(message_id = \"OK8fVfBL\", \"ignoring relay message with unknown op code\");\n            continue;\n        };\n        return Ok((code, header.context_id, arg.to_vec()));\n    }\n}\n\nasync fn recv_fixed<const N: usize, T: AsyncRead + Unpin>(transport: &mut T) -> Result<[u8; N], io::Error> {\n    let mut buf = [0u8; N];\n    transport.read_exact(&mut buf[..]).await?;\n    Ok(buf)\n}\n\n#[derive(Debug)]\nstruct VerifyVpnServerCert {\n    cert: CertificateDer<'static>,\n    provider: Arc<CryptoProvider>,\n}\n\nimpl ServerCertVerifier for VerifyVpnServerCert {\n    fn verify_server_cert(\n        &self,\n        end_entity: &CertificateDer<'_>,\n        _intermediates: &[CertificateDer<'_>],\n        _server_name: &ServerName<'_>,\n        _ocsp_response: &[u8],\n        _now: UnixTime,\n    ) -> Result<ServerCertVerified, rustls::Error> {\n        match self.cert == *end_entity {\n            true => Ok(ServerCertVerified::assertion()),\n            false => Err(rustls::Error::InvalidCertificate(CertificateError::ApplicationVerificationFailure)),\n        }\n    }\n    fn verify_tls12_signature(\n        &self,\n        message: &[u8],\n        cert: &CertificateDer<'_>,\n        dss: &DigitallySignedStruct,\n    ) -> Result<HandshakeSignatureValid, rustls::Error> {\n        verify_tls12_signature(message, cert, dss, &self.provider.signature_verification_algorithms)\n    }\n    fn verify_tls13_signature(\n        &self,\n        message: &[u8],\n        cert: &CertificateDer<'_>,\n        dss: &DigitallySignedStruct,\n    ) -> Result<HandshakeSignatureValid, rustls::Error> {\n        verify_tls13_signature(message, cert, dss, &self.provider.signature_verification_algorithms)\n    }\n\n    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {\n        self.provider.signature_verification_algorithms.supported_schemes()\n    }\n}\n"
  },
  {
    "path": "rustlib/src/rate_limited_log.rs",
    "content": "#[macro_export]\nmacro_rules! rate_limited_log {\n    ($silence:expr, $($log:tt)*) => {{\n        static LAST_LOG_AT: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);\n        let now = std::time::Instant::now();\n        let mut last = LAST_LOG_AT.lock().unwrap();\n        if !last.is_some_and(|last| last + $silence > now) {\n            *last = Some(now);\n            drop(last);\n            $($log)*\n        }\n    }};\n}\n"
  },
  {
    "path": "rustlib/src/relay_selection.rs",
    "content": "use crate::errors::RelaySelectionError;\nuse crate::net::{NetworkInterface, new_quic, new_udp};\nuse crate::quicwg::{QuicWgConnHandshaking, QuicWgConnectError};\nuse flume::{Receiver, SendError, bounded};\nuse obscuravpn_api::types::OneRelay;\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::spawn;\nuse tokio::task::JoinSet;\n\npub fn race_relay_handshakes(\n    network_interface: Option<&NetworkInterface>,\n    relays: Vec<OneRelay>,\n    sni: String,\n    use_tcp_tls: bool,\n    quic_frame_padding: bool,\n    force_small_mtu: bool,\n    mtu: Option<u16>,\n) -> Result<Receiver<(OneRelay, u16, Duration, QuicWgConnHandshaking)>, RelaySelectionError> {\n    let sni = Arc::new(sni);\n    let mut tasks = JoinSet::new();\n    let udp = new_udp(network_interface).map_err(RelaySelectionError::UdpSetup)?;\n    let quic_endpoint = new_quic(udp, mtu, force_small_mtu).map_err(RelaySelectionError::QuicSetup)?;\n\n    // Maximum number of relays to probe. This limit should be high enough that a non-malicious API server won't exceed it.\n    // This prevents memory exhaustion issues in case a malicious API server sends a large number of relays.\n    const MAX_RELAYS: usize = 100;\n\n    for relay in relays.iter().take(MAX_RELAYS) {\n        for &port in &relay.ports {\n            let quic_endpoint = quic_endpoint.clone();\n            let relay_addr = (relay.ip_v4, port).into();\n            let relay_cert = relay.tls_cert.clone().into();\n            let relay = relay.clone();\n            let sni = sni.clone();\n            tasks.spawn(async move {\n                let result: Result<(QuicWgConnHandshaking, Duration), QuicWgConnectError> = async {\n                    let mut handshaking = match use_tcp_tls {\n                        true => QuicWgConnHandshaking::start_tcp_tls(relay.id.clone(), relay_addr, relay_cert, &sni).await,\n                        false => {\n                            QuicWgConnHandshaking::start_quic(\n                                relay.id.clone(),\n                                &quic_endpoint,\n                                relay_addr,\n                                relay_cert,\n                                &sni,\n                                quic_frame_padding,\n                                force_small_mtu,\n                                mtu,\n                            )\n                            .await\n                        }\n                    }?;\n                    let rtt = handshaking.measure_rtt().await?;\n                    Ok((handshaking, rtt))\n                }\n                .await;\n                (result, relay, port)\n            });\n        }\n    }\n\n    let (sender, receiver) = bounded(0);\n    spawn(async move {\n        while let Some(Ok((result, relay, port))) = tasks.join_next().await {\n            let (handshaking, rtt) = match result {\n                Ok(ok) => ok,\n                Err(error) => {\n                    tracing::warn!(?error, relay.id, port, \"failed to connect during relay selection\");\n                    continue;\n                }\n            };\n            tracing::info!(relay.id, port, rtt_ms = rtt.as_millis(), \"successfully started handshake with relay\");\n            if let Err(SendError((_, _, _, handshaking))) = sender.send_async((relay, port, rtt, handshaking)).await {\n                spawn(handshaking.abandon());\n            }\n        }\n    });\n    Ok(receiver)\n}\n"
  },
  {
    "path": "rustlib/src/serde_safe.rs",
    "content": "#[derive(serde::Deserialize, Debug, Clone)]\n#[serde(untagged)]\npub enum TryParse<T> {\n    Valid(T),\n    Invalid(serde_json::Value),\n}\n\npub fn deserialize<'de, T: Default + serde::Deserialize<'de>, D: serde::Deserializer<'de>>(deserializer: D) -> Result<T, D::Error> {\n    Ok(match serde::Deserialize::deserialize(deserializer)? {\n        TryParse::Valid(v) => v,\n        TryParse::Invalid(json) => {\n            tracing::error!(?json, \"Deserialization invalid, using default\");\n            Default::default()\n        }\n    })\n}\n"
  },
  {
    "path": "rustlib/src/tokio.rs",
    "content": "pub struct AbortOnDrop(pub tokio::task::AbortHandle);\n\nimpl AbortOnDrop {\n    pub fn spawn<F: Future<Output = ()> + Send + 'static>(f: F) -> Self {\n        tokio::spawn(f).into()\n    }\n}\n\nimpl Drop for AbortOnDrop {\n    fn drop(&mut self) {\n        self.0.abort();\n    }\n}\n\nimpl From<tokio::task::AbortHandle> for AbortOnDrop {\n    fn from(handle: tokio::task::AbortHandle) -> Self {\n        AbortOnDrop(handle)\n    }\n}\n\nimpl From<tokio::task::JoinHandle<()>> for AbortOnDrop {\n    fn from(handle: tokio::task::JoinHandle<()>) -> Self {\n        handle.abort_handle().into()\n    }\n}\n"
  },
  {
    "path": "rustlib/src/tunnel_state.rs",
    "content": "use futures::future::pending;\nuse obscuravpn_api::types::{OneExit, OneRelay};\nuse std::convert::Infallible;\nuse std::ops::ControlFlow;\nuse std::time::Duration;\nuse std::{future::Future, sync::Arc};\nuse strum::EnumIs;\nuse tokio::select;\nuse tokio::sync::watch::{Receiver, Sender, channel};\nuse tokio::time::{Instant, sleep_until};\nuse uuid::Uuid;\n\nuse crate::client_state::ClientStateHandle;\nuse crate::errors::{ErrorAt, TunnelConnectError};\nuse crate::exit_selection::ExitSelectionState;\nuse crate::manager::ManagerTrafficStats;\nuse crate::net::NetworkInterface;\nuse crate::network_config::{DnsContentBlock, OsNetworkConfig, TunnelNetworkConfig};\nuse crate::os::os_trait::Os;\nuse crate::quicwg::{QuicWgConnPacketSender, QuicWgReceiveError, QuicWgTrafficStats};\nuse crate::{client_state::ClientState, manager::TunnelArgs, quicwg::QuicWgConn};\n\n#[derive(Clone, Debug, Eq, PartialEq)]\npub struct TargetState {\n    pub tunnel_args: Option<TunnelArgs>,\n    pub network_interface: Option<NetworkInterface>,\n    pub dns_content_block: DnsContentBlock,\n    pub use_system_dns: bool,\n}\n\n#[derive(derive_more::Debug, EnumIs)]\npub enum TunnelState {\n    Disconnected,\n    Connecting {\n        args: TunnelArgs,\n        connect_error: Option<ErrorAt<TunnelConnectError>>,\n        disconnect_reason: Option<ErrorAt<QuicWgReceiveError>>,\n        offset_traffic_stats: ManagerTrafficStats,\n        network_interface: Option<NetworkInterface>,\n    },\n    Connected {\n        args: TunnelArgs,\n        #[debug(skip)]\n        conn: Arc<QuicWgConn>,\n        network_config: TunnelNetworkConfig,\n        relay: OneRelay,\n        exit: OneExit,\n        offset_traffic_stats: ManagerTrafficStats,\n        network_interface: NetworkInterface,\n    },\n}\n\ntype Connected = (Arc<QuicWgConn>, TunnelNetworkConfig, OneExit, OneRelay);\n\nimpl TunnelState {\n    /// The constructed `TunnelState` can not be dropped due to spawned tasks, which hold references.\n    pub fn new(client_state: ClientStateHandle, os_impl: Arc<impl Os>) -> Receiver<TunnelState> {\n        let (tunnel_state_send, tunnel_state_recv) = channel(TunnelState::Disconnected);\n        tokio::spawn(Self::maintain(tunnel_state_send, client_state, os_impl));\n        tunnel_state_recv\n    }\n\n    pub fn traffic_stats(&self) -> ManagerTrafficStats {\n        match self {\n            TunnelState::Disconnected => {\n                ManagerTrafficStats { connected_ms: 0, conn_id: Uuid::new_v4(), tx_bytes: 0, rx_bytes: 0, latest_latency_ms: 0 }\n            }\n            TunnelState::Connecting { offset_traffic_stats, .. } => *offset_traffic_stats,\n            TunnelState::Connected { conn, offset_traffic_stats, .. } => {\n                let mut traffic_stats = *offset_traffic_stats;\n                let QuicWgTrafficStats { connected_at, tx_bytes, rx_bytes, latest_latency_ms } = conn.traffic_stats();\n                traffic_stats.connected_ms = traffic_stats\n                    .connected_ms\n                    .saturating_add(connected_at.elapsed().as_millis().try_into().unwrap_or(u64::MAX));\n                traffic_stats.rx_bytes = traffic_stats.rx_bytes.saturating_add(rx_bytes);\n                traffic_stats.tx_bytes = traffic_stats.tx_bytes.saturating_add(tx_bytes);\n                traffic_stats.latest_latency_ms = latest_latency_ms;\n                traffic_stats\n            }\n        }\n    }\n\n    fn set_disconnected(&mut self) {\n        *self = Self::Disconnected;\n    }\n\n    fn set_connecting(&mut self, new_args: &TunnelArgs, network_interface: &Option<NetworkInterface>, disconnect_reason: Option<QuicWgReceiveError>) {\n        match self {\n            this @ Self::Connected { .. } | this @ Self::Disconnected => {\n                *this = Self::Connecting {\n                    args: new_args.clone(),\n                    connect_error: None,\n                    disconnect_reason: disconnect_reason.map(Into::into),\n                    offset_traffic_stats: this.traffic_stats(),\n                    network_interface: network_interface.clone(),\n                }\n            }\n            Self::Connecting { args, .. } => *args = new_args.clone(),\n        }\n    }\n\n    fn set_connected(\n        &mut self,\n        args: &TunnelArgs,\n        network_interface: &NetworkInterface,\n        conn: Arc<QuicWgConn>,\n        network_config: TunnelNetworkConfig,\n        relay: OneRelay,\n        exit: OneExit,\n    ) {\n        *self = Self::Connected {\n            args: args.clone(),\n            network_interface: network_interface.clone(),\n            conn,\n            network_config,\n            relay,\n            exit,\n            offset_traffic_stats: self.traffic_stats(),\n        };\n    }\n\n    fn set_connect_error(&mut self, error: TunnelConnectError) {\n        let Self::Connecting { connect_error, .. } = self else {\n            tracing::error!(\n                message_id = \"jZGhFRZh\",\n                \"trying to set connect error on non-connecting tunnel state, this should be impossible\"\n            );\n            return;\n        };\n        *connect_error = Some(error.into())\n    }\n\n    pub fn get_conn(&self) -> Option<Arc<QuicWgConn>> {\n        match self {\n            TunnelState::Disconnected => None,\n            TunnelState::Connecting { .. } => None,\n            TunnelState::Connected { conn, .. } => Some(conn.clone()),\n        }\n    }\n\n    fn get_connected(&self) -> Option<Connected> {\n        match self {\n            TunnelState::Disconnected => None,\n            TunnelState::Connecting { .. } => None,\n            TunnelState::Connected { conn, network_config, exit, relay, .. } => {\n                Some((conn.clone(), network_config.clone(), exit.clone(), relay.clone()))\n            }\n        }\n    }\n\n    fn matches_target(&self, target_tunnel_args: Option<&TunnelArgs>, target_network_interface: Option<&NetworkInterface>) -> bool {\n        match self {\n            Self::Disconnected => target_tunnel_args.is_none(),\n            Self::Connecting { .. } => false,\n            Self::Connected { args, network_interface, .. } => {\n                Some(args) == target_tunnel_args && Some(network_interface) == target_network_interface\n            }\n        }\n    }\n\n    async fn maintain(tunnel_state: Sender<TunnelState>, client_state: ClientStateHandle, os_impl: Arc<impl Os>) -> ! {\n        let mut client_state_watch = client_state.subscribe();\n\n        // Delay processing new states or retrying after error for at least this long.\n        const DEBOUNCE_PERIOD: Duration = Duration::from_secs(1);\n\n        let mut last_start: Option<Instant> = None;\n        let mut disconnect_reason = None;\n        let mut selection_state = ExitSelectionState::default();\n\n        loop {\n            if let Some(last_start) = last_start {\n                sleep_until(last_start + DEBOUNCE_PERIOD).await;\n            }\n            last_start = Some(Instant::now());\n\n            let target_state = client_state_watch.borrow_and_update().target_state();\n            tracing::info!(\n                message_id = \"KT91bgvI\",\n                ?target_state,\n                ?disconnect_reason,\n                \"not in target state or tunnel broke\"\n            );\n\n            if !tunnel_state.borrow().is_disconnected() && target_state.tunnel_args.is_none() {\n                // Target state changed to disconnected, which means we will disconnect, but are in another state.\n                // This is the right time for key rotations without unnecessarily rotating keys of permanently unused devices.\n                client_state.rotate_wireguard_key_if_required()\n            }\n\n            // Drop tunnel if args changed or tunnel broke and change to connecting or disconnected as desired\n            if !tunnel_state\n                .borrow()\n                .matches_target(target_state.tunnel_args.as_ref(), target_state.network_interface.as_ref())\n                || disconnect_reason.is_some()\n            {\n                tunnel_state.send_modify(|tunnel_state| match &target_state {\n                    TargetState { tunnel_args: None, network_interface: _, dns_content_block: _, use_system_dns: _ } => {\n                        tunnel_state.set_disconnected()\n                    }\n                    TargetState { tunnel_args: Some(target_args), network_interface, dns_content_block: _, use_system_dns: _ } => {\n                        tunnel_state.set_connecting(target_args, network_interface, disconnect_reason.take())\n                    }\n                });\n            }\n\n            match &target_state {\n                TargetState {\n                    tunnel_args: Some(target_args),\n                    network_interface: Some(target_network_interface),\n                    dns_content_block,\n                    use_system_dns,\n                } => {\n                    let cf: ControlFlow<(), Connected> = if let Some(connected) = tunnel_state.borrow().get_connected() {\n                        // Already connected, continue with next steps\n                        ControlFlow::Continue(connected)\n                    } else {\n                        // Not connected, but target state indicates that this is possible and desired. Start capturing traffic and connect.\n                        if let Err(()) = os_impl\n                            .set_os_network_config(\n                                OsNetworkConfig::dummy(*dns_content_block, *use_system_dns),\n                                QuicWgConnPacketSender::new(None),\n                            )\n                            .await\n                        {\n                            tracing::error!(message_id = \"eTwAHomq\", \"failed to set dummy network config\");\n                            tunnel_state.send_modify(|tunnel_state| tunnel_state.set_connect_error(TunnelConnectError::SetOsNetworkConfig));\n                            ControlFlow::Break(())\n                        } else {\n                            match poll_until_change(\n                                &mut client_state_watch,\n                                &target_state,\n                                client_state.connect(&target_args.exit, Some(target_network_interface), &mut selection_state),\n                            )\n                            .await\n                            {\n                                None => {\n                                    tracing::info!(\n                                        message_id = \"SmLhzVwY\",\n                                        \"target state or tunnel arguments changed while trying to connect\"\n                                    );\n                                    ControlFlow::Break(())\n                                }\n                                Some(Err(error)) => {\n                                    tracing::error!(message_id = \"OfLfwKhf\", ?error, \"failed to connect\");\n                                    tunnel_state.send_modify(|tunnel_state| tunnel_state.set_connect_error(error));\n                                    ControlFlow::Break(())\n                                }\n                                Some(Ok((conn, network_config, exit, relay))) => {\n                                    tracing::info!(message_id = \"icGquatl\", \"connected successfully\");\n                                    selection_state = ExitSelectionState::default();\n                                    ControlFlow::Continue((Arc::new(conn), network_config, exit, relay))\n                                }\n                            }\n                        }\n                    };\n                    if let ControlFlow::Continue((conn, network_config, exit, relay)) = cf {\n                        // Reached connected state, set OS network config and update published tunnel state\n                        let os_network_config = OsNetworkConfig::new(&network_config, &exit.provider_name, *dns_content_block, *use_system_dns);\n                        if let Err(()) = os_impl\n                            .set_os_network_config(os_network_config, QuicWgConnPacketSender::new(Some(&conn)))\n                            .await\n                        {\n                            tracing::error!(message_id = \"t7QzSTGu\", \"failed to set network config\");\n                            tunnel_state.send_modify(|tunnel_state| tunnel_state.set_connect_error(TunnelConnectError::SetOsNetworkConfig));\n                        } else {\n                            tunnel_state.send_modify(|tunnel_state| {\n                                tunnel_state.set_connected(target_args, target_network_interface, conn.clone(), network_config, relay, exit)\n                            });\n                            // forward traffic until target state changes or the tunnel fails\n                            disconnect_reason = poll_until_change(&mut client_state_watch, &target_state, async {\n                                loop {\n                                    match conn.receive().await {\n                                        Ok(packet) => os_impl.packet_for_os(packet),\n                                        Err(error) => {\n                                            tracing::error!(message_id = \"tls1cZot\", ?error, \"tunnel failed\");\n                                            break error;\n                                        }\n                                    }\n                                }\n                            })\n                            .await;\n                        }\n                    }\n                }\n                TargetState { tunnel_args: None, network_interface: _, dns_content_block: _, use_system_dns: _ } => {\n                    selection_state = ExitSelectionState::default();\n                    tracing::info!(message_id = \"axfILRQy\", \"reached disconnected target state\");\n                    if let Err(()) = os_impl.unset_os_network_config().await {\n                        tracing::error!(message_id = \"PEgDYAz0\", \"failed to unset network config\");\n                    } else {\n                        // nothing to do until target args change\n                        poll_until_change(&mut client_state_watch, &target_state, pending::<Infallible>()).await;\n                    }\n                }\n                TargetState { tunnel_args: Some(_), network_interface: None, dns_content_block: _, use_system_dns: _ } => {\n                    tracing::warn!(message_id = \"0K9Nep8g\", \"stuck in connecting state without target interface\");\n                    selection_state = ExitSelectionState::default();\n                    tunnel_state.send_modify(|tunnel_state| tunnel_state.set_connect_error(TunnelConnectError::NoInternet));\n                    // nothing to do until target args changes or a network device becomes available\n                    poll_until_change(&mut client_state_watch, &target_state, pending::<Infallible>()).await;\n                }\n            }\n        }\n    }\n}\n\n// Run future, until complete or until the watch channel signals a change.\nasync fn poll_until_change<O>(watch: &mut Receiver<ClientState>, target_state: &TargetState, fut: impl Future<Output = O>) -> Option<O> {\n    select! {\n        _ = watch.wait_for(|new| new.target_state() != *target_state) => None,\n        o = fut => Some(o),\n    }\n}\n"
  },
  {
    "path": "tag.json",
    "content": "{\n  \"sourceHash\": \"ihvif9vc5fcsyn00gr38lmx4bnzi71y2\",\n  \"version\": \"1.159\"\n}\n"
  },
  {
    "path": "taplo.toml",
    "content": "[formatting]\nalign_comments = false\nindent_string = \"    \"\nreorder_keys = true\n\n[[rule]]\nformatting = { reorder_keys = false }\ninclude = [\"**/*.toml\"]\nkeys = [\"package\"]\n"
  }
]