[
  {
    "path": ".gitattributes",
    "content": "# On Windows, Git defaults to checkout Windows-style and commit Unix-style.\r\n# As such, line endings of bash scripts are converted from LF to CRLF, with\r\n# the effect that when mounting the checkout into a Linux container, the\r\n# bash scripts can't execute because bash does not handle CR. To mitigate,\r\n# conservatively force ALL line endings to be retained.\r\n* -text\r\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non: push\n\njobs:\n  # Detect if only UI files changed to skip heavy tests\n  detect-changes:\n    name: Detect changes\n    runs-on: ubuntu-24.04\n    outputs:\n      ui-only: ${{ steps.check.outputs.ui-only }}\n    steps:\n      - uses: actions/checkout@v3\n        with:\n          fetch-depth: 0\n      - id: check\n        run: |\n          DEFAULT_BRANCH=\"${{ github.event.repository.default_branch }}\"\n\n          # On default branch, always run all tests\n          if [[ \"${{ github.ref }}\" == \"refs/heads/${DEFAULT_BRANCH}\" ]]; then\n            echo \"ui-only=false\" >> $GITHUB_OUTPUT\n            exit 0\n          fi\n\n          # Get changed files compared to the previous push event\n          CHANGED_FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null || git diff --name-only HEAD~1 HEAD)\n\n          # Check if all changed files are in packages/ui/\n          UI_ONLY=\"true\"\n          while IFS= read -r file; do\n            if [[ -n \"$file\" && ! \"$file\" =~ ^packages/ui/ ]]; then\n              UI_ONLY=\"false\"\n              break\n            fi\n          done <<< \"$CHANGED_FILES\"\n\n          echo \"Changed files:\"\n          echo \"$CHANGED_FILES\"\n          echo \"UI only: $UI_ONLY\"\n          echo \"ui-only=$UI_ONLY\" >> $GITHUB_OUTPUT\n\n  # These jobs simply serve as architecture native cache steps to load up the Docker cache for umbrelOS.\n  # We must cache to unique scopes per architecture otherwise only one of the architectures gets cached.\n  umbrelos-amd64-cache:\n    name: Build umbrelOS amd64\n    needs: detect-changes\n    if: needs.detect-changes.outputs.ui-only != 'true'\n    runs-on: ubicloud-standard-4-ubuntu-2404\n    steps:\n      - uses: actions/checkout@v3\n      - uses: docker/setup-buildx-action@v3\n      - uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3\n      - name: Build amd64 Docker image\n        working-directory: packages/os\n        run: docker buildx build --platform linux/amd64 --file umbrelos.Dockerfile --cache-from type=gha,scope=umbrelos-amd64 --cache-to type=gha,mode=max,scope=umbrelos-amd64 ../../\n  umbrelos-arm64-cache:\n    name: Build umbrelOS arm64\n    needs: detect-changes\n    if: needs.detect-changes.outputs.ui-only != 'true'\n    runs-on: ubicloud-standard-4-ubuntu-2404\n    steps:\n      - uses: actions/checkout@v3\n      - uses: docker/setup-buildx-action@v3\n      - uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3\n      - name: Build arm64 Docker image\n        working-directory: packages/os\n        run: docker buildx build --platform linux/arm64 --file umbrelos.Dockerfile --cache-from type=gha,scope=umbrelos-arm64 --cache-to type=gha,mode=max,scope=umbrelos-arm64 ../../\n\n  # Build the image for the VM tests\n  # We run this in a seperate step so we only build it once and reuse for all VM runners to reduce billed minutes.\n  umbrelos-amd64-vm-image-build:\n    name: Build umbrelOS amd64 VM image\n    needs: [detect-changes, umbrelos-amd64-cache]\n    if: needs.detect-changes.outputs.ui-only != 'true'\n    runs-on: ubicloud-standard-4-ubuntu-2404\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v3\n        with:\n          submodules: recursive\n      - name: Setup node\n        uses: actions/setup-node@v3\n        with:\n          node-version: 22\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n      - name: Expose GitHub Actions runtime for Docker cache\n        uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3\n      - name: Build OS image\n        working-directory: packages/os\n        run: npm run build:amd64:rugix\n      # Hack to pass the image to the next job. We used to use upload-artifact but network performance\n      # is terrible between GitHub <> Ubicloud. Just abuse the cache for now which is local and fast.\n      - name: Cache OS image\n        uses: actions/cache/save@v4\n        with:\n          path: packages/os/build/umbrelos-amd64.img\n          key: umbrelos-amd64-image-${{ github.run_id }}\n\n  # Discover all MV test files so we can run the result as a build matrix\n  discover-vm-tests:\n    name: Discover VM tests\n    needs: detect-changes\n    if: needs.detect-changes.outputs.ui-only != 'true'\n    runs-on: ubuntu-24.04\n    outputs:\n      tests: ${{ steps.find.outputs.tests }}\n    steps:\n      - uses: actions/checkout@v3\n      - id: find\n        run: |\n          tests=$(find packages/umbreld/source -name \"*.vm.test.ts\" | sed 's|packages/umbreld/||' | jq -R -s -c 'split(\"\\n\") | map(select(length > 0)) | map({path: ., name: (. | split(\"/\") | last | sub(\"\\\\.vm\\\\.test\\\\.ts$\"; \"\"))})')\n          echo \"tests=$tests\" >> $GITHUB_OUTPUT\n\n  # Run each VM test in a different runner\n  umbrelos-amd64-vm:\n    name: VM test (${{ matrix.test.name }})\n    needs: [detect-changes, umbrelos-amd64-vm-image-build, discover-vm-tests]\n    if: needs.detect-changes.outputs.ui-only != 'true'\n    runs-on: ubicloud-standard-4-ubuntu-2404\n    strategy:\n      fail-fast: false\n      matrix:\n        test: ${{ fromJSON(needs.discover-vm-tests.outputs.tests) }}\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v3\n        with:\n          submodules: recursive\n      - name: Setup node\n        uses: actions/setup-node@v3\n        with:\n          node-version: 22\n      - name: Restore OS image from cache\n        uses: actions/cache/restore@v4\n        with:\n          path: packages/os/build/umbrelos-amd64.img\n          key: umbrelos-amd64-image-${{ github.run_id }}\n          fail-on-cache-miss: true\n      - name: Install QEMU\n        run: sudo apt-get update && sudo apt-get install -y qemu-system-x86\n      - name: Install umbreld dependencies\n        working-directory: packages/umbreld\n        run: npm clean-install\n      - name: Run VM test\n        working-directory: packages/umbreld\n        run: npm run test -- ${{ matrix.test.path }}\n\n  # Discover all integration test files so we can run the result as a build matrix\n  discover-integration-tests:\n    name: Discover integration tests\n    needs: detect-changes\n    if: needs.detect-changes.outputs.ui-only != 'true'\n    runs-on: ubuntu-24.04\n    outputs:\n      tests: ${{ steps.find.outputs.tests }}\n    steps:\n      - uses: actions/checkout@v3\n      - id: find\n        run: |\n          tests=$(find packages/umbreld/source -name \"*.integration.test.ts\" | sed 's|packages/umbreld/||' | jq -R -s -c 'split(\"\\n\") | map(select(length > 0)) | map({path: ., name: (. | split(\"/\") | last | sub(\"\\\\.integration\\\\.test\\\\.ts$\"; \"\"))})')\n          echo \"tests=$tests\" >> $GITHUB_OUTPUT\n\n  # Run each integration test in a different runner\n  umbreld-integration:\n    name: Integration test (${{ matrix.test.name }})\n    needs: [detect-changes, discover-integration-tests, umbrelos-amd64-cache]\n    if: needs.detect-changes.outputs.ui-only != 'true'\n    runs-on: ubicloud-standard-4-ubuntu-2404\n    strategy:\n      fail-fast: false\n      matrix:\n        test: ${{ fromJSON(needs.discover-integration-tests.outputs.tests) }}\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v3\n      - name: Setup node\n        uses: actions/setup-node@v3\n        with:\n          node-version: 18\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n      - name: Expose GitHub Actions runtime for Docker cache\n        uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3\n      - run: npm run dev rebuild\n      - run: npm run dev start\n      - run: npm run --prefix packages/umbreld test:umbrel-dev:prepare\n      - run: npm run --prefix packages/umbreld test:umbrel-dev ${{ matrix.test.path }}\n\n  # Run umbreld checks\n  umbreld:\n    name: umbreld checks\n    runs-on: ubuntu-24.04\n    defaults:\n      run:\n        working-directory: packages/umbreld\n    strategy:\n      fail-fast: false\n      matrix:\n        task:\n          - format:check\n          - typecheck\n          - test:unit -- --coverage\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v3\n      - name: Setup node\n        uses: actions/setup-node@v3\n        with:\n          node-version: 18\n      - name: Install dependencies\n        run: sudo npm clean-install\n      - run: sudo npm run ${{ matrix.task }}\n\n  # Run ui checks\n  ui:\n    name: ui checks\n    runs-on: ubuntu-24.04\n    defaults:\n      run:\n        working-directory: packages/ui\n    strategy:\n      fail-fast: false\n      matrix:\n        task:\n          - lint\n          - format:check\n          - typecheck\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v3\n      - name: Setup node\n        uses: actions/setup-node@v3\n        with:\n          node-version: 22\n      - name: Install Umbreld dependencies\n        if: ${{ matrix.task == 'typecheck' }}\n        run: npm --prefix ../umbreld clean-install\n      - name: Install dependencies\n        run: npm clean-install\n      - run: npm run ${{ matrix.task }}\n\n  # If the current build is tagged, build all release assets, push to Cloudflare R2 and create a GitHub Release\n  create-release:\n    name: Create release on tag\n    if: startsWith(github.ref, 'refs/tags/')\n    needs: [umbrelos-amd64-cache, umbrelos-arm64-cache]\n    runs-on: ubicloud-standard-16-ubuntu-2404\n    defaults:\n      run:\n        working-directory: packages/os\n    steps:\n      - uses: actions/checkout@v3\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n      - name: Expose GitHub Actions runtime for Docker cache\n        uses: crazy-max/ghaction-github-runtime@3cb05d89e1f492524af3d41a1c98c83bc3025124 # v3\n      - run: echo \"VERSION=${GITHUB_REF#refs/*/}\" >> $GITHUB_ENV\n      # We need this to namespace Docker images on forks\n      - run: echo \"VERSION_IS_SEMVER=$(if [[ '${{ env.VERSION }}' =~ ^[0-9]+\\.[0-9]+\\.[0-9]+ ]]; then echo 'true'; else echo 'false'; fi)\" >> $GITHUB_ENV\n      - run: echo \"PREFIX=$(if [ '${{ env.VERSION_IS_SEMVER }}' = 'true' ]; then echo ''; else echo $(basename ${{ github.repository }})-; fi)\" >> $GITHUB_ENV\n      - run: echo \"TAG=${{ github.repository_owner }}/${{ env.PREFIX }}umbrelos:${{ env.VERSION }}\" >> $GITHUB_ENV\n      - run: echo \"${{ secrets.GITHUB_TOKEN }}\" | docker login ghcr.io -u \"${{ github.repository_owner }}\" --password-stdin\n      - name: Build and push Docker image\n        run: docker buildx build --platform linux/amd64,linux/arm64 --file umbrelos.Dockerfile --tag ghcr.io/${{env.TAG }} --push --cache-from type=gha,scope=umbrelos-amd64 --cache-from type=gha,scope=umbrelos-arm64 ../../\n      - run: mkdir -p build && docker buildx imagetools inspect ghcr.io/${{ env.TAG }} > build/docker-umbrelos-${{ env.VERSION }}\n      # Build OS images\n      - uses: actions/setup-node@v3\n        with:\n          node-version: 18\n      - name: Build OS images\n        # Awkward hack to run in parallel but correctly handle errors\n        run: |\n          npm run build:amd64 \"${{ env.VERSION }}\" &\n          pid1=$!\n          npm run build:arm64 \"${{ env.VERSION }}\" &\n          pid2=$!\n          wait $pid1 || exit 1\n          wait $pid2 || exit 1\n      # TODO: Use .img.xz for all release assets once https://github.com/balena-io/etcher/issues/4064 is fixed\n      - name: Compress release assets\n        # Awkward hack to run in parallel but correctly handle errors\n        run: |\n          cd build\n          zip umbrelos-pi4.img.zip umbrelos-pi4.img &\n          pid1=$!\n          zip umbrelos-pi5.img.zip umbrelos-pi5.img &\n          pid2=$!\n          sudo xz --keep --threads=0 umbrelos-amd64.img &\n          pid3=$!\n          wait $pid1 || exit 1\n          wait $pid2 || exit 1\n          wait $pid3 || exit 1\n      - name: Create USB installer\n        run: npm run build:amd64:usb-installer\n      - name: Create release directory\n        run: |\n          mkdir -p release\n          mv build/docker-umbrelos-* release/\n          mv build/*.update release/\n          mv build/*.img.zip release/\n          mv build/*.img.xz release/\n          mv build/*.img release/\n          mv usb-installer/build/*.iso release/\n      - name: Create SHASUM\n        run: cd release && shasum -a 256 * | tee SHA256SUMS\n      - name: OpenTimestamps\n        run: npm ci && npx ots-cli.js stamp release/SHA256SUMS\n      - name: Nuke uncompressed images (we just wanted them covered by the SHASUMs)\n        run: rm -rf release/*.img\n      - name: Upload to R2\n        uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4\n        with:\n          r2-account-id: ${{ secrets.R2_ACCOUNT_ID }}\n          r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }}\n          r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }}\n          r2-bucket: ${{ secrets.R2_BUCKET }}\n          source-dir: packages/os/release\n          destination-dir: ./${{ env.VERSION }}\n      - name: Create GitHub Release\n        uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v0.1.15\n        with:\n          draft: true\n          name: umbrelOS ${{ github.ref_name }}\n          files: |\n            packages/os/release/SHA256SUMS*\n"
  },
  {
    "path": ".github/workflows/update-translations-in-pr.yml",
    "content": "name: Update Translations in PR\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened]\n    paths:\n      - 'packages/ui/public/locales/en.json'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  update-translations:\n    timeout-minutes: 10\n    permissions:\n      contents: write\n    runs-on: ubuntu-22.04\n\n    steps:\n    - name: Check if enabled\n      run: |\n        if [[ \"${{ secrets.TRANSLATIONS_ACTION_IS_ENABLED }}\" != \"true\" ]]; then\n          echo \"Translation generation is disabled.\"\n          exit 1\n        fi\n\n    - name: Checkout code\n      uses: actions/checkout@v4\n      with:\n        fetch-depth: 0  # Fetch full history for git comparisons\n        ref: ${{ github.head_ref }} # Check out the PR's head branch\n\n    - name: Fetch base branch\n      run: |\n        git fetch origin ${{ github.base_ref }}:${{ github.base_ref }}\n\n    - name: Setup node\n      uses: actions/setup-node@v4\n      with:\n        node-version: '18'\n\n    - name: Install dependencies\n      run: |\n        npm install\n      working-directory: packages/ui\n\n    - name: Update translations\n      env:\n        CI: 'true'\n        GITHUB_BASE_REF: ${{ github.base_ref }}\n        TRANSLATIONS_OPENAI_API_KEY: ${{ secrets.TRANSLATIONS_OPENAI_API_KEY }}\n        TRANSLATIONS_OPENAI_MODEL: ${{ secrets.TRANSLATIONS_OPENAI_MODEL }}\n        TRANSLATIONS_SYSTEM_PROMPT: ${{ secrets.TRANSLATIONS_SYSTEM_PROMPT }}\n        TRANSLATIONS_USER_PROMPT: ${{ secrets.TRANSLATIONS_USER_PROMPT }}\n      run: |\n        node update-translations.js\n      working-directory: packages/ui\n    \n    - name: Push changes\n      uses: stefanzweifel/git-auto-commit-action@8621497c8c39c72f3e2a999a26b4ca1b5058a842 # v5.0.1\n      with:\n        commit_message: Update translations"
  },
  {
    "path": ".gitignore",
    "content": "# Ignore node_modules anywhere they may be\n\nnode_modules\n\n# Ignore all the bash stuff\n\n.bash_history\n.bash_logout\n.bashrc\n.profile\n.ssh\n.viminfo\n.DS_Store\n\n# Python bytecode\n__pycache__\n*.py[cod]\n\n# umbrel-dev\ndocker-compose.override.yml\n\n# Files and data directories created by services\n# that we shouldn't accidently commit\n\n*.dat\n*.log\n*.cookie\n*.pid\n*.env\nbitcoin/*\ndb/*\nelectrs/*\nnginx/*\nevents/signals/*\nlnd/*\nlogs/*\nstatuses/*\ntor/*\napp-data/*\ndata/\n\n# Commit these files\n\n!statuses/update-status.json\n\n# Commit these empty directories\n\n!db/.gitkeep\n!events/signals/.gitkeep\n!lnd/.gitkeep\n!logs/.gitkeep\n!tor/data/.gitkeep\n!tor/run/.gitkeep\n.umbrel-dev\njwt\n./bin"
  },
  {
    "path": ".prettierrc.js",
    "content": "/**\n * @type {import('prettier').Config}\n */\nexport default {\n  \"printWidth\": 120,\n  \"semi\": false,\n  \"useTabs\": true,\n  \"trailingComma\": \"all\",\n  \"singleQuote\": true,\n  \"bracketSpacing\": false,\n  \"jsxSingleQuote\": true,\n}\n"
  },
  {
    "path": ".umbrel",
    "content": ""
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "Contributing to Umbrel\n======================\n\nUmbrel is an open project and we love to receive contributions from our community! There are many ways to contribute, from writing tutorials or blog posts, improving the documentation, submitting bug reports and feature requests or writing code which can be incorporated into Umbrel itself.\n\nBug reports\n-----------\n\nIf you think you have found a bug in Umbrel, first make sure that you are on the [latest version of Umbrel](https://github.com/getumbrel/umbrel/releases/latest) because your issue may already have been fixed. If not, search our [issues list](https://github.com/getumbrel/umbrel/issues) on GitHub in case a similar issue has already been opened.\n\nIf there's no existing issue, please open a new issue and provide us as much information about the bug as you can. It would be very helpful if you can list down the steps for us to reproduce the bug, because the easier it is for us to recreate the bug, the faster it is likely to be fixed.\n\nFeature requests\n----------------\n\nIf you find yourself wishing for a feature that doesn't exist in Umbrel, you are probably not alone. There are bound to be others out there with similar needs. Please search our [issues list](https://github.com/getumbrel/umbrel/issues) on GitHub and our [community forum](https://community.umbrel.com). If you can't find an existing feature request, please open a new topic on the [community forum](https://community.umbrel.com) with your feature request.\n\nContributing code and documentation changes\n-------------------------------------------\n\nIf you would like to contribute a new feature or a bugfix, please discuss your idea first on a GitHub issue. If there is no existing GitHub issue for your idea, please open one. There are often a number of ways to fix a problem and it is important to find the right approach before spending time on a PR that cannot be merged. So we request you to only start the work on implementing a feature once you received a green light from one of our maintainers on your idea and its implementation.\n\nWe add the [help wanted](https://github.com/getumbrel/umbrel/labels/help%20wanted) label to existing GitHub issues for which community contributions are particularly welcome, and we use the [good first issue](https://github.com/getumbrel/umbrel/labels/good%20first%20issue) label to mark issues that we think will be suitable for new contributors.\n\nThe process for contributing to any of the [Umbrel repositories](https://github.com/getumbrel/) is similar.\n\n### Contributor License\n\nBy submitting your contribution, you agree that all of your present and past contributions to us are licensed from you under the MIT license (text below) and do not require us to include your copyright notice. However, if you want to be on our contributor list, please leave us a message [here](https://keybase.io/team/getumbrel).\n\n```\nPermission is hereby granted, free of charge, to any \nperson obtaining a copy of this software and associated \ndocumentation files (the \"Software\"), to deal in the \nSoftware without restriction, including without limitation \nthe rights to use, copy, modify, merge, publish, distribute, \nsublicense, and/or sell copies of the Software, and to \npermit 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\nbe included 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 FROM, \nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER \nDEALINGS IN THE SOFTWARE.\n```\n"
  },
  {
    "path": "LICENSE.md",
    "content": "> Umbrel is licensed under the PolyForm Noncommercial License 1.0.0. Please refer to our [License FAQ](https://github.com/getumbrel/umbrel/wiki/License-FAQ) if you have any questions or reach out to us directly at support@umbrel.com.\n\n# 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": "[![umbrelOS](https://github.com/user-attachments/assets/cabf8af7-51ce-45df-ad3a-a664cc91c610)](https://umbrel.com/umbrelos)\n\n<p align=\"center\">\n  <h1 align=\"center\">umbrelOS</h1>\n  <p align=\"center\">\n    A beautiful home server OS for self-hosting\n    <br />\n    <a href=\"https://umbrel.com\"><strong>umbrel.com »</strong></a>\n    <br />\n    <br />\n       Get an <a href=\"https://umbrel.com/umbrel-pro\">Umbrel Pro</a> or <a href=\"https://umbrel.com/umbrel-home\">Umbrel Home</a> for the full experience, or install umbrelOS on a <a href=\"https://github.com/getumbrel/umbrel/wiki/Install-umbrelOS-on-a-Raspberry-Pi-5\">Raspberry Pi 5</a> or <a href=\"https://github.com/getumbrel/umbrel/wiki/Install-umbrelOS-on-x86-systems\">any x86 system</a> for free.\n    <br />\n    <br />\n    <a href=\"https://x.com/umbrel\">\n      <img src=\"https://img.shields.io/twitter/follow/umbrel?style=social\" />\n    </a>\n    <a href=\"https://discord.gg/efNtFzqtdx\">\n      <img src=\"https://img.shields.io/discord/936694604231766046?logo=discord&logoColor=5351FB&label=Discord&labelColor=white&color=5351FB&cacheSeconds=60\">\n    </a>\n    <a href=\"https://reddit.com/r/getumbrel\">\n      <img src=\"https://img.shields.io/reddit/subreddit-subscribers/getumbrel?style=social\">\n    </a>\n    <a href=\"https://community.umbrel.com\">\n      <img src=\"https://img.shields.io/discourse/users?server=https%3A%2F%2Fcommunity.umbrel.com&style=flat&label=Community%20Forum&color=5351FB&cacheSeconds=60\">\n    </a>\n  </p>\n</p>\n\n<br />\n\n<p align=\"center\">\nAt Umbrel, we believe that everyone should be able to enjoy the convenience and benefits of the cloud, without giving up ownership and control of their data.\n</p>\n\n<p align=\"center\">\nTo achieve our vision, we're building a new kind of a home server OS. Instead of paying ransoms for storing your data on someone else's computer while they auction it off to advertisers — you can now easily spin up a server and self-host your data and services at home.\n</p>\n\n<p align=\"center\">\nJust like the cloud, but one that you own and control.\n</p>\n\n<br />\n\n## Installing umbrelOS\n\numbrelOS is designed for the [Umbrel Pro](https://umbrel.com/umbrel-pro) and [Umbrel Home](https://umbrel.com/umbrel-home), where it includes first-class support for all features. On other devices (like Raspberry Pi or x86 systems), it’s freely available with core functionality, but support and feature availability are best-effort due to hardware differences.\n\nFor a detailed feature breakdown, see our [comparison guide](https://github.com/getumbrel/umbrel/wiki/umbrelOS-on-Umbrel-Home-vs.-DIY).\n\n### Installation guides\n- [Install umbrelOS on a Raspberry Pi 5](https://github.com/getumbrel/umbrel/wiki/Install-umbrelOS-on-a-Raspberry-Pi-5)\n- [Install umbrelOS on any x86 system](https://github.com/getumbrel/umbrel/wiki/Install-umbrelOS-on-x86-Systems)\n- [Install umbrelOS in a VM](https://github.com/getumbrel/umbrel/wiki/Install-umbrelOS-on-a-Linux-VM)\n\n[![umbrelOS use cases](https://github.com/user-attachments/assets/284feee7-15a1-48f2-a694-c968f1cc702f)](https://umbrel.com/umbrelos)\n[![Umbrel App Store](https://github.com/user-attachments/assets/3d7846c7-d896-48f5-8a30-3578554702fa)](https://apps.umbrel.com)\n[![Files on umbrelOS](https://github.com/user-attachments/assets/6c501256-47a0-4ce1-89ad-4ba02f4c9f2d)](https://umbrel.com/umbrelos)\n[![umbrelOS Features](https://github.com/user-attachments/assets/6828da74-2b64-4b56-a7b7-5db603d023c8)](https://umbrel.com/umbrelos)\n[![Backups in umbrelOS](https://github.com/user-attachments/assets/39778824-ed18-4f6f-a865-1d77bbfce833)](https://umbrel.com/umbrelos)\n[![External Storage & NAS in umbrelOS](https://github.com/user-attachments/assets/4841c2dc-4ba4-4d47-bf0a-0e342bf60166)](https://umbrel.com/umbrelos)\n\n## Building apps for umbrelOS\n\nIf you're interested in building an app for umbrelOS or packaging an existing one, please refer to the [Umbrel App Framework documentation](https://github.com/getumbrel/umbrel-apps/blob/master/README.md).\n\n## License\n\numbrelOS is licensed under the PolyForm Noncommercial 1.0.0 license. TL;DR — You're free to use, fork, modify, and redistribute Umbrel for personal and nonprofit use under the same license. If you're interested in using umbrelOS for commercial purposes, such as selling plug-and-play home servers with umbrelOS, etc — please reach out to us at partner@umbrel.com.\n\n[![License](https://img.shields.io/badge/license-PolyForm%20Noncommercial%201.0.0-%235351FB)](https://github.com/getumbrel/umbrel/blob/master/LICENSE.md)\n\n[umbrel.com](https://umbrel.com)\n"
  },
  {
    "path": "containers/app-auth/.dockerignore",
    "content": "Dockerfile\nnode_modules\n.git\n.github\ntest\ndist\n*.log"
  },
  {
    "path": "containers/app-auth/.gitignore",
    "content": ".DS_Store\nnode_modules\n/dist\n\n# Log files\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Editor directories and files\n.idea\n.vscode\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n# Local dev env\n.env.development\n\n# Local todo file\n.todo\n"
  },
  {
    "path": "containers/app-auth/Dockerfile",
    "content": "# UI build stage\nFROM node:18.19.1-buster-slim AS umbrel-app-auth-ui-builder\n\n# Set the working directory\nWORKDIR /app\n\n# Copy the package.json and package-lock.json\nCOPY packages/ui/package.json ./\nCOPY packages/ui/package-lock.json ./\n\n# Install the dependencies\nRUN npm ci\n\n# Copy the rest of the files\nCOPY packages/ui/ .\n\n# Build the app\nRUN npm run app-auth:build\n\n# Expose the port\nEXPOSE 2003\n\n# Start the app\nCMD [\"npm\", \"run\", \"app-auth:start\"]\n\n# Build Stage\nFROM node:16-buster-slim AS umbrel-app-auth-builder\n\n# Create app directory\nWORKDIR /app\n\n# Copy 'yarn.lock' and 'package.json'\nCOPY containers/app-auth/yarn.lock containers/app-auth/package.json ./\n\n# Install dependencies\nRUN yarn\n\n# Copy project files and folders to the current working directory (i.e. '/app')\nCOPY containers/app-auth .\n\n# Final image\nFROM node:16-buster-slim AS umbrel-app-auth\n\n# Copy built code from build stage to '/app' directory\nCOPY --from=umbrel-app-auth-builder /app /app\n\n# Copy built ui to /app/dist\nCOPY --from=umbrel-app-auth-ui-builder /app/dist-app-auth /app/dist\n\n# Change directory to '/app'\nWORKDIR /app\n\nCMD [ \"yarn\", \"start\" ]\n"
  },
  {
    "path": "containers/app-auth/README.md",
    "content": "[![Umbrel App Auth](https://static.getumbrel.com/github/github-banner-umbrel-app-auth.svg)](https://github.com/getumbrel/umbrel-app-auth)\n\n[![Docker Build](https://img.shields.io/github/workflow/status/getumbrel/umbrel-app-auth/Docker%20build%20on%20push?color=%235351FB)](https://github.com/getumbrel/umbrel-app-auth/actions?query=workflow%3A\"Docker+build+on+push\")\n[![Docker Pulls](https://img.shields.io/docker/pulls/getumbrel/app-auth?color=%235351FB)](https://hub.docker.com/repository/registry-1.docker.io/getumbrel/app-auth/tags?page=1)\n\n\n# ☂️ App Auth\n\nApp-auth is a simple authentication and redirection system for Umbrel apps. It ensures (where applicable) that apps are password (and OTP) protected. It runs by-default as a containerized service.\n\n## 🚀 Getting started\n\nIf you are looking to run Umbrel on your hardware, you do not need to run this service on it's own. Just download [Umbrel OS](https://github.com/getumbrel/umbrel-os/releases) and you're good to go.\n\n## 🛠 Running app-auth\n\nMake sure [`umbrel-manager`](https://github.com/getumbrel/umbrel-manager) are running and available.\n\n### Development and testing\n```sh\ncd $UMBREL_ROOT/containers/app-auth/test\n./test.sh\n```\n\n### Environment variables (dev/testing)\nThe following environment variables are set in `.env` file of the project's root:\n\n| Variable | Description | Default |\n| ------------- | ------------- | ------------- |\n| `PORT` | Web server port within container | `2000` |\n| `UMBREL_AUTH_SECRET` | A shared secret for manager, app-auth and app-proxy | `umbrel` |\n| `MANAGER_IP` | Umbrel's manager IP | `10.21.21.4` |\n| `MANAGER_PORT` | Umbrel's manager container port | `9005` |\n"
  },
  {
    "path": "containers/app-auth/bin/www",
    "content": "#!/usr/bin/env node\n\nconst cookieParser = require(\"cookie-parser\");\nconst express = require('express');\nconst { StatusCodes } = require('http-status-codes');\n\nconst authRoutes = require('../routes/auth.js');\n\nconst handleErrorMiddleware = require('../middleware/handle_error.js');\nconst CONSTANTS = require('../utils/const.js');\n\nconst app = express();\n\napp.disable('x-powered-by');\napp.set('view engine', 'ejs');\n\napp.use(cookieParser(CONSTANTS.UMBREL_AUTH_SECRET));\napp.use('/', authRoutes);\n\napp.use(handleErrorMiddleware);\napp.use((req, res) => {\n\tres.status(StatusCodes.NOT_FOUND).json();\n});\n\napp.listen(CONSTANTS.PORT, () => {\n\tconsole.log(`Listening on port: ${CONSTANTS.PORT}`);\n});"
  },
  {
    "path": "containers/app-auth/middleware/handle_error.js",
    "content": "function handleError(error, req, res, next) {\n  var statusCode = error.statusCode || 500;\n  var route = req.url || '';\n  var message = error.message || '';\n\n  res.status(statusCode).json(message);\n}\n\nmodule.exports = handleError;\n"
  },
  {
    "path": "containers/app-auth/middleware/validate_token.js",
    "content": "const url = require('url');\n\nconst hmacUtils = require('../utils/hmac.js');\nconst tokenUtils = require('../utils/token.js');\nconst expressUtils = require('../utils/express.js');\nconst appUtils = require(\"../utils/app.js\");\nconst hostResolution = require('../utils/host_resolution.js');\nconst CONSTANTS = require('../utils/const.js');\n\nconst APP_PROXY_AUTH_TOKEN_PATH = \"/umbrel_/api/v1/auth/token\";\n\nasync function redirectState(token, req) {\n\tconst app = expressUtils.getQueryParam(req, \"app\");\n\tconst origin = expressUtils.getQueryParam(req, \"origin\");\n\tconst path = expressUtils.getQueryParam(req, \"path\");\n\n\t// app ids are only allowed alpha-numeric characters\n\t// plus the hyphen (-)\n\tconst appIdSanitised = appUtils.sanitiseId(app);\n\n\t// This builds up a url as to where\n\t// We're going to redirect/POST to\n\tconst redirectUrl = url.format({\n\t\tprotocol: req.protocol,\n\t\thost: await hostResolution.host(req, appIdSanitised, origin),\n\t\tpathname: APP_PROXY_AUTH_TOKEN_PATH\n\t});\n\n\treturn {\n\t\turl: redirectUrl,\n\t\tparams: {\n\t\t\t\"r\": path,\n\t\t\t\"token\": token,\n\t\t\t\"signature\": hmacUtils.sign(token, CONSTANTS.UMBREL_AUTH_SECRET)\n\t\t}\n\t};\n}\n\nasync function redirect(res, token, req) {\n\tres.render(\"pages/redirect\", await redirectState(token, req));\n}\n\nfunction mw () {\n\treturn async function (req, res, next) {\n\t\tconst token = req.cookies.UMBREL_PROXY_TOKEN;\n\n\t\t// If we already have a valid token\n\t\t// Then the user doesn't need to login again\n\t\t// We can redirect to the app with the token\n\t\tif(await tokenUtils.validate(token)) {\n\t\t\tawait redirect(res, token, req);\n\t\t} else {\n\t\t\tnext();\n\t\t}\n\t};\n}\n\nmodule.exports = {\n\tmw,\n\tredirect,\n\tredirectState\n};"
  },
  {
    "path": "containers/app-auth/package.json",
    "content": "{\n  \"name\": \"app-auth\",\n  \"version\": \"0.0.1\",\n  \"private\": true,\n  \"scripts\": {\n    \"lint\": \"eslint\",\n    \"start\": \"node ./bin/www\",\n    \"test\": \"mocha 'test/**/*.js'\",\n    \"coverage\": \"nyc --all mocha 'test/**/*.js'\",\n    \"postcoverage\": \"codecov\",\n    \"build\": \"docker buildx build --platform linux/amd64,linux/arm64 --tag getumbrel/auth-server --file Dockerfile ../../\"\n  },\n  \"dependencies\": {\n    \"animate.css\": \"^3.7.2\",\n    \"axios\": \"^0.19.2\",\n    \"bootstrap-vue\": \"^2.11.0\",\n    \"cookie-parser\": \"^1.4.6\",\n    \"core-js\": \"^3.4.4\",\n    \"express\": \"^4.17.3\",\n    \"http-status-codes\": \"^2.2.0\",\n    \"js-yaml\": \"^4.1.0\",\n    \"jsonwebtoken\": \"^9.0.2\",\n    \"vue\": \"^2.6.10\",\n    \"vue-router\": \"^3.1.3\",\n    \"vuex\": \"^3.1.2\"\n  },\n  \"devDependencies\": {\n    \"@vue/cli-plugin-babel\": \"^4.1.0\",\n    \"@vue/cli-plugin-eslint\": \"^4.1.0\",\n    \"@vue/cli-plugin-router\": \"^4.1.0\",\n    \"@vue/cli-plugin-vuex\": \"^4.1.0\",\n    \"@vue/cli-service\": \"^4.1.0\",\n    \"@vue/eslint-config-prettier\": \"^5.0.0\",\n    \"babel-eslint\": \"^10.0.3\",\n    \"chai\": \"^4.1.2\",\n    \"chai-http\": \"^4.2.0\",\n    \"codecov\": \"^3.7.1\",\n    \"eslint\": \"^5.16.0\",\n    \"eslint-plugin-prettier\": \"^3.1.1\",\n    \"eslint-plugin-vue\": \"^5.0.0\",\n    \"mocha\": \"^7.1.2\",\n    \"nyc\": \"15.0.1\",\n    \"prettier\": \"^1.19.1\",\n    \"sass\": \"^1.23.7\",\n    \"sass-loader\": \"^8.0.0\",\n    \"vue-template-compiler\": \"^2.6.10\"\n  },\n  \"eslintConfig\": {\n    \"root\": true,\n    \"env\": {\n      \"node\": true\n    },\n    \"extends\": [\n      \"plugin:vue/essential\",\n      \"@vue/prettier\"\n    ],\n    \"rules\": {\n      \"no-console\": \"off\"\n    },\n    \"parserOptions\": {\n      \"parser\": \"babel-eslint\"\n    }\n  },\n  \"browserslist\": [\n    \"> 1%\",\n    \"last 2 versions\"\n  ]\n}\n"
  },
  {
    "path": "containers/app-auth/routes/auth.js",
    "content": "const express = require(\"express\");\nconst axios = require(\"axios\");\nconst { StatusCodes } = require(\"http-status-codes\");\nconst fs = require(\"fs\").promises;\nconst yaml = require(\"js-yaml\");\n\n// const CONSTANTS = require(\"../utils/const.js\");\nconst manager = require(\"../utils/manager.js\");\nconst dashboard = require(\"../utils/dashboard.js\");\nconst safeHandler = require(\"../utils/safe_handler.js\");\nconst expressUtils = require(\"../utils/express.js\");\nconst appUtils = require(\"../utils/app.js\");\nconst validateToken = require(\"../middleware/validate_token.js\");\n\nconst router = express.Router();\n\nrouter.use(express.json());\n\n// --- Public paths ---\n\nconst publicPaths = [\n  // \"/app-auth\",\n  \"/assets\",\n  \"/favicon\",\n  \"/figma-exports\",\n  \"/locales\",\n  \"/wallpapers\",\n  // not needed\n  // \"/generated-tabler-icons\"\n];\n\npublicPaths.forEach((path) => {\n  router.use(path, express.static(`/app/dist${path}`));\n});\n\nrouter.get(\"/\", safeHandler(validateToken.mw()), (req, res) => {\n  res.sendFile(\"/app/dist/index.html\");\n});\n\n// ---\n\nrouter.post(\n  \"/v1/account/login\",\n  safeHandler(async (req, res) => {\n    let response;\n    try {\n      response = await axios.post(\n        `http://${process.env.UMBRELD_RPC_HOST}/trpc/user.login`,\n        req.body\n      );\n    } catch (e) {\n      console.log({ e });\n      if (e.isAxiosError === true) {\n        return res.status(e.response.status).send(e.response.data);\n      }\n\n      throw e;\n    }\n\n    let proxyToken = \"\";\n\n    const setCookieHeader = response.headers[\"set-cookie\"];\n    if (setCookieHeader) {\n      // `set-cookie` header can be an array if multiple cookies are set\n      setCookieHeader.forEach((cookie) => {\n        if (cookie.startsWith(\"UMBREL_PROXY_TOKEN=\")) {\n          proxyToken = cookie.split(\";\")[0].split(\"=\")[1];\n        }\n      });\n    }\n\n    if (proxyToken) {\n      const ONE_SECOND = 1000;\n      const ONE_MINUTE = 60 * ONE_SECOND;\n      const ONE_HOUR = 60 * ONE_MINUTE;\n      const ONE_DAY = 24 * ONE_HOUR;\n      const ONE_WEEK = 7 * ONE_DAY;\n      const expires = new Date(Date.now() + ONE_WEEK);\n      res\n        .cookie(\"UMBREL_PROXY_TOKEN\", proxyToken, {\n          httpOnly: true,\n          expires,\n          sameSite: \"lax\",\n        })\n        .json(await validateToken.redirectState(proxyToken, req));\n    } else {\n      // This case should never happen as an error is thrown\n      // if the credentials are bad and is handled above (catch block)\n      res.status(StatusCodes.UNAUTHORIZED).send(\"Failed to authenticate\");\n    }\n  })\n);\n\n// Get wallpaper (public)\nrouter.get(\n  \"/v1/account/wallpaper\",\n  safeHandler(async (req, res) => {\n    const store = yaml.load(await fs.readFile(\"/data/umbrel.yaml\"));\n    const { wallpaper } = store.user;\n    res.send(wallpaper);\n  })\n);\n\n// Get basic info for an app\nrouter.get(\n  \"/v1/apps\",\n  safeHandler(async (req, res) => {\n    const appIdSanitised = appUtils.sanitiseId(\n      expressUtils.getQueryParam(req, \"app\")\n    );\n    res.send(await appUtils.getBasicInfo(appIdSanitised));\n  })\n);\n\n// TODO: remove\nrouter.get(\n  \"/wallpapers/:filename(\\\\d+[.]\\\\w+)\",\n  safeHandler(async (req, res) => {\n    const response = await dashboard.wallpaper.get(req.params.filename);\n    response.data.pipe(res);\n  })\n);\n\nmodule.exports = router;\n"
  },
  {
    "path": "containers/app-auth/test/docker-compose.yml",
    "content": "version: '3.7'\n\nservices:\n    auth:\n        image: getumbrel/app-auth1\n        user: \"1000:1000\"\n        build:\n            context: ..\n            dockerfile: Dockerfile.dev\n        ports:\n            - \"2001:2000\"\n        environment:\n            PORT: 2000\n            UMBREL_AUTH_SECRET: umbrel\n            MANAGER_IP: $MANAGER_IP\n            MANAGER_PORT: 3006\n        volumes:\n            - ..:/app\n            - ./fixtures/tor/data:/var/lib/tor:ro\n            - ./fixtures/app-data:/app-data:ro\n\nnetworks:\n    default:\n        external:\n            name: umbrel_main_network"
  },
  {
    "path": "containers/app-auth/test/fixtures/app-data/mempool/umbrel-app.yml",
    "content": "id: mempool\ncategory: Explorers\nname: mempool\nversion: 2.3.1\ntagline: A self-hosted explorer for the Bitcoin community\ndescription: |-\n  Trusted third parties are security holes. Self-host your own instance of mempool.space on Umbrel for maximum privacy.\n\n  Features:\n\n  - Live dashboard visualizing the mempool and blockchain\n  - Live transaction tracking\n  - Search any transaction, block or address\n  - Fee estimations\n  - Mempool historical data\n  - TV View for larger displays as a TV in a cafe or bar\n  - View transaction scripts and op_return messages\n  - Audio notifications on transaction confirmed and address balance change\n  - Multiple languages support\n  - JSON APIs\ndeveloper: Mempool Space K.K.\nwebsite: https://mempool.space/about\ndependencies:\n- bitcoind\n- electrum\nrepo: https://github.com/mempool/mempool\nsupport: https://mempool.support\nport: 4006\ngallery:\n- 1.jpg\n- 2.jpg\n- 3.jpg\npath: ''\ndefaultUsername: ''\ndefaultPassword: ''\n"
  },
  {
    "path": "containers/app-auth/test/global.js",
    "content": "const chai = require('chai');\nconst chaiHttp = require('chai-http');\n\nchai.use(chaiHttp);\nchai.should();\n\nglobal.expect = chai.expect;\nglobal.assert = chai.assert;\n\nbefore(() => {\n  \n});\n\nglobal.reset = () => {\n  \n};\n\nafter(() => {\n\n});\n"
  },
  {
    "path": "containers/app-auth/test/test.sh",
    "content": "#!/bin/bash\n\nexport MANAGER_IP=\"10.21.21.4\"\n\ndocker-compose up"
  },
  {
    "path": "containers/app-auth/test/utils/hmac.js",
    "content": "const hmac = require(\"../../utils/hmac.js\");\n\ndescribe('hmac', () => {\n  it('should sign the message', () => {\n    assert.equal(\"4oCtD/Y2Xfb8J/tvCw9mrsRmMekbirseumiW4JrFahI=\", hmac.sign(\"hello world\", \"my-secret-123\"));\n    assert.equal(\"qENA22U3zGY2ZhBPh9Tes+Fjt/SS8pjvL6d2Z0ZMTJk=\", hmac.sign(\"https://xkcd.com/386/\", \"my-secret-123\"));\n\n    assert.equal(\"+fSwPHNg4EtsNpso1Iope7g3A7pPbTZubygel/9WdYc=\", hmac.sign(\"https://xkcd.com/386/\", \"another-secret\"));\n  });\n\n  it('should verify the signature for a message', () => {\n    assert.isTrue(hmac.verify(\"https://xkcd.com/386/\", \"my-secret-123\", \"qENA22U3zGY2ZhBPh9Tes+Fjt/SS8pjvL6d2Z0ZMTJk=\"));\n    assert.isTrue(hmac.verify(\"https://xkcd.com/386/\", \"another-secret\", \"+fSwPHNg4EtsNpso1Iope7g3A7pPbTZubygel/9WdYc=\"));\n\n    assert.isFalse(hmac.verify(\"https://xkcd.com/386/something/random\", \"another-secret\", \"+fSwPHNg4EtsNpso1Iope7g3A7pPbTZubygel/9WdYc=\"));\n  });\n});\n"
  },
  {
    "path": "containers/app-auth/utils/app.js",
    "content": "const fs = require('fs').promises;\nconst path = require('path');\nconst yaml = require('js-yaml');\n\nconst CONSTANTS = require('./const.js');\n\nasync function getBasicInfo(app){\n\ttry {\n\t\tconst manifestFile = path.join(CONSTANTS.APP_DATA_PATH, app, 'umbrel-app.yml');\n\t\tconst manifestYaml = await fs.readFile(manifestFile, \"utf-8\");\n\t\tconst manifest = yaml.load(manifestYaml, 'utf8');\n\n\t\treturn {\n\t\t\tid: manifest.id,\n\t\t\tname: manifest.name\n\t\t};\n\t} catch(e) {\n\t\tthrow new Error(\"App not found\");\n\t}\n}\n\n// App IDs are only allowed\n// Alpha-numeric characters with hyphens\nfunction sanitiseId(appId){\n\treturn appId.replace(/[^a-zA-Z0-9-]/g, \"\");\n}\n\nmodule.exports = {\n\tgetBasicInfo,\n\tsanitiseId\n};"
  },
  {
    "path": "containers/app-auth/utils/const.js",
    "content": "function readFromEnvOrTerminate(key) {\n\tconst value = process.env[key];\n\n\tif(typeof(value) !== \"string\" || value.trim().length === 0) {\n\t\tconsole.error(`The env. variable '${key}' is not set. Terminating...`);\n\n\t\tprocess.exit(0);\n\t}\n\n\treturn value;\n}\n\nmodule.exports = Object.freeze({\n\tUMBREL_COOKIE_NAME: \"UMBREL_SESSION\",\n\n\tLOG_LEVEL: process.env.LOG_LEVEL || \"info\",\n\n\tPORT: parseInt(process.env.PORT) || 2000,\n\n\tUMBREL_AUTH_SECRET: readFromEnvOrTerminate(\"UMBREL_AUTH_SECRET\"),\n\n\tTOR_PATH: process.env.TOR_PATH || \"/var/lib/tor\",\n\tAPP_DATA_PATH: process.env.APP_DATA_PATH || \"/app-data\",\n\n\tMANAGER_IP: readFromEnvOrTerminate(\"MANAGER_IP\"),\n\tMANAGER_PORT: parseInt(readFromEnvOrTerminate(\"MANAGER_PORT\")),\n\n\tDASHBOARD_IP: readFromEnvOrTerminate(\"DASHBOARD_IP\"),\n\tDASHBOARD_PORT: parseInt(readFromEnvOrTerminate(\"DASHBOARD_PORT\")),\n});"
  },
  {
    "path": "containers/app-auth/utils/dashboard.js",
    "content": "const axios = require('axios');\nconst package = require('../package.json');\n\nconst CONSTANTS = require('./const.js');\n\nconst axiosInstance = axios.create({\n\tbaseURL: `http://${CONSTANTS.DASHBOARD_IP}:${CONSTANTS.DASHBOARD_PORT}`,\n\theaders: {\n\t\tcommon: {\n\t\t\t\"User-Agent\": `${package.name}/${package.version}`\n\t\t}\n\t}\n});\n\nconst wallpaper = {\n\tget: async function(filename) {\n\t\treturn axiosInstance({\n\t\t\tmethod: 'GET',\n\t\t\turl: `/wallpapers/${filename}`,\n\t\t\tresponseType: 'stream'\n\t\t});\n\t}\n};\n\nmodule.exports = {\n\twallpaper\n};"
  },
  {
    "path": "containers/app-auth/utils/express.js",
    "content": "function getQueryParam(req, key) {\n\tconst value = req.query[key];\n\n\tif(typeof(value) !== \"string\" || value.trim().length == 0) {\n\t\tthrow new Error(`'${key}' is missing`);\n\t}\n\n\treturn value;\n}\n\nmodule.exports = {\n\tgetQueryParam\n};"
  },
  {
    "path": "containers/app-auth/utils/hmac.js",
    "content": "const crypto = require('crypto');\n\nfunction sign(input, secret) {\n\treturn crypto\n\t\t.createHmac('sha256', secret)\n\t\t.update(input)\n\t\t.digest('base64');\n};\n\nfunction verify(input, secret, signature){\n\tconst inputSignature = Buffer.from( sign(input, secret) );\n\tconst testSignature = Buffer.from( signature );\n\n\treturn inputSignature.length === testSignature.length && crypto.timingSafeEqual(inputSignature, testSignature);\n};\n\nmodule.exports = {\n\tsign,\n\tverify\n};"
  },
  {
    "path": "containers/app-auth/utils/host_resolution.js",
    "content": "const fs = require('fs').promises;\nconst path = require('path');\nconst yaml = require('js-yaml');\n\nconst CONSTANTS = require('./const.js');\n\nasync function getTorHostname(app) {\n\tconst torHostnameFile = path.join(CONSTANTS.TOR_PATH, `app-${app}`, \"hostname\");\n\n\treturn (await fs.readFile(torHostnameFile, \"utf-8\")).trim();\n}\n\nasync function getAppPort(app) {\n\tconst appManifestFile = path.join(CONSTANTS.APP_DATA_PATH, app, 'umbrel-app.yml');\n\tconst appManifestYaml = await fs.readFile(appManifestFile, \"utf-8\");\n\tconst appManifest = yaml.load(appManifestYaml, 'utf8');\n\n\treturn appManifest.port;\n}\n\nasync function host(req, app, origin) {\n\ttry {\n\t\tswitch(origin) {\n\t\t\tcase \"tor\":\n\t\t\t\treturn (await getTorHostname(app));\n\t\t\tcase \"host\":\n\t\t\t\tconst appPort = (await getAppPort(app));\n\n\t\t\t\treturn `${req.hostname}:${appPort}`;\n\t\t}\n\t} catch (e) {\n\t\tthrow new Error(\"Failed to determine host\");\n\t}\n\n\tthrow new Error(\"Unsupported origin\");\n}\n\nmodule.exports = {\n\thost\n};"
  },
  {
    "path": "containers/app-auth/utils/manager.js",
    "content": "const axios = require('axios');\nconst package = require('../package.json');\n\nconst CONSTANTS = require('./const.js');\n\nconst axiosInstance = axios.create({\n\tbaseURL: `http://${CONSTANTS.MANAGER_IP}:${CONSTANTS.MANAGER_PORT}`,\n\theaders: {\n\t\tcommon: {\n\t\t\t\"User-Agent\": `${package.name}/${package.version}`\n\t\t}\n\t}\n});\n\nconst account = {\n\tlogin: async function(body) {\n\t\treturn axiosInstance.post('/v1/account/login', body);\n\t},\n\ttoken: async function(token) {\n\t\treturn axiosInstance.get('/v1/account/token', {\n\t\t\tparams: {\n\t\t\t\ttoken\n\t\t\t}\n\t\t});\n\t},\n\twallpaper: async function(token) {\n\t\treturn axiosInstance.get('/v1/account/wallpaper');\n\t}\n};\n\nmodule.exports = {\n\taccount\n};"
  },
  {
    "path": "containers/app-auth/utils/safe_handler.js",
    "content": "// this safe handler is used to wrap our api methods\n// so that we always fallback and return an exception if there is an error\n// inside of an async function\n// Mostly copied from vault/server/utils/safeHandler.js\nfunction safeHandler(handler) {\n  return async (req, res, next) => {\n    try {\n      return await handler(req, res, next);\n    } catch (err) {\n      return next(err);\n    }\n  };\n}\n\nmodule.exports = safeHandler;\n"
  },
  {
    "path": "containers/app-auth/utils/token.js",
    "content": "const jwt = require(\"jsonwebtoken\");\n\nconst JWT_ALGORITHM = \"HS256\";\n\nconst secret = process.env.JWT_SECRET;\n\nfunction validate(token) {\n  if (typeof token !== \"string\") return false;\n\n  console.log(`Validating token: ${token.substr(0, 12)} ...`);\n\n  const payload = jwt.verify(token, secret, {\n    algorithms: [JWT_ALGORITHM],\n  });\n\n  return payload.proxyToken === true;\n}\n\nmodule.exports = {\n  validate,\n};\n"
  },
  {
    "path": "containers/app-auth/views/pages/redirect.ejs",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n\t<meta charset=\"utf-8\">\n\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\t<title>Redirecting...</title>\n</head>\n<body onload=\"document.forms[0].submit();\">\n\t<form method=\"POST\" action=\"<%- url %>\">\n\t<% for (var key in params ) { %>\n\t\t<input type=\"hidden\" name=\"<%- key %>\" value=\"<%- params[key] %>\">\n\t<% } %>\n\t</form>\n</body>\n</html>"
  },
  {
    "path": "containers/app-proxy/.dockerignore",
    "content": "Dockerfile\nnode_modules\n.git\n.github\ntest"
  },
  {
    "path": "containers/app-proxy/.gitignore",
    "content": ".DS_Store\nnode_modules\n/dist\n\n# Log files\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Editor directories and files\n.idea\n.vscode\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n# Local dev env\n.env.development\n\n# Local todo file\n.todo\n"
  },
  {
    "path": "containers/app-proxy/Dockerfile",
    "content": "# Build Stage\nFROM node:16-buster-slim AS umbrel-app-proxy-builder\n\n# Create app directory\nWORKDIR /app\n\n# Copy 'yarn.lock' and 'package.json'\nCOPY yarn.lock package.json ./\n\n# Install dependencies\nRUN yarn install --production\n\n# Copy project files and folders to the current working directory (i.e. '/app')\nCOPY . .\n\n# Final image\nFROM node:16-buster-slim AS umbrel-app-proxy\n\n# Copy built code from build stage to '/app' directory\nCOPY --from=umbrel-app-proxy-builder /app /app\n\n# Change directory to '/app'\nWORKDIR /app\n\nCMD [ \"yarn\", \"start\" ]\n"
  },
  {
    "path": "containers/app-proxy/Dockerfile.dev",
    "content": "FROM node:16-buster-slim\n\n# make the 'app' folder the current working directory\nWORKDIR /app\n\nENTRYPOINT [\"bash\"]\nCMD [\"-c\", \"yarn && yarn start\"]"
  },
  {
    "path": "containers/app-proxy/README.md",
    "content": "[![Umbrel App Proxy](https://static.getumbrel.com/github/github-banner-umbrel-app-proxy.svg)](https://github.com/getumbrel/umbrel-app-proxy)\n\n[![Docker Build](https://img.shields.io/github/workflow/status/getumbrel/umbrel-app-proxy/Docker%20build%20on%20push?color=%235351FB)](https://github.com/getumbrel/umbrel-app-proxy/actions?query=workflow%3A\"Docker+build+on+push\")\n[![Docker Pulls](https://img.shields.io/docker/pulls/getumbrel/app-proxy?color=%235351FB)](https://hub.docker.com/repository/registry-1.docker.io/getumbrel/app-proxy/tags?page=1)\n\n# ☂️ App Proxy\n\nApp-proxy is a transparent HTTP proxy to add authentication to Umbrel apps. Every HTTP request and Websocket connection goes through the proxy and each request has the session token checked for validity. The session token is set via [App-auth](https://github.com/getumbrel/umbrel/tree/master/containers/app-auth). It runs by-default as a containerized service.\n\n## 🚀 Getting started\n\nIf you are looking to run Umbrel on your hardware, you do not need to run this service on it's own. Just download [Umbrel OS](https://github.com/getumbrel/umbrel-os/releases) and you're good to go.\n\n## 🛠 Running app-proxy\n\nMake sure [`umbrel-manager`](https://github.com/getumbrel/umbrel-manager) and [`app-auth`](https://github.com/getumbrel/umbrel/tree/master/containers/app-auth) are running and available.\n\n### Development and testing\n\n```sh\ncd $UMBREL_ROOT/containers/app-proxy/test\n./test.sh docker-compose.app1.yml\n```\n\nWithin the `test` directory there are several test apps to test different functionality such as Websocket and SSE with the proxy.\n\n### Environment variables (dev/testing)\n\nThe following environment variables are set in `.env` file of the project's root:\n\n| Variable                          | Description                                                                   | Default                      |\n| --------------------------------- | ----------------------------------------------------------------------------- | ---------------------------- |\n| `LOG_LEVEL`                       | Log level for the proxy (`http-proxy-middleware`)                             | `info`                       |\n| `PROXY_PORT`                      | HTTP proxy container port                                                     | `4000`                       |\n| `PROXY_AUTH_ADD`                  | `true`/`false` as to whether the app should be protected with authentication  | `true`                       |\n| `PROXY_AUTH_WHITELIST`            | A comma seperated list of paths that are whitelisted (e.g. `/public/*`)       |                              |\n| `PROXY_AUTH_BLACKLIST`            | A comma seperated list of paths that are whitelisted (e.g. `/admin/*,/api/*`) |                              |\n| `APP_HOST`                        | App's frontend container hostname/IP                                          |                              |\n| `APP_PORT`                        | App's frontend container port                                                 |                              |\n| `APP_MANIFEST_FILE`               | Location of app's manifest file                                               | `/extra/umbrel-app.yml`      |\n| `UMBREL_AUTH_PORT`                | App-auth's exposed (port-forwarded) port                                      | `2000`                       |\n| `UMBREL_AUTH_SECRET`              | A shared secret for manager, app-auth and app-proxy                           | `umbrel`                     |\n| `UMBREL_AUTH_HIDDEN_SERVICE_FILE` | Location of app-auth's Tor HS hostname                                        | `/var/lib/tor/auth/hostname` |\n| `MANAGER_IP`                      | Umbrel's manager IP                                                           | `10.21.21.4`                 |\n| `MANAGER_PORT`                    | Umbrel's manager container port                                               | `9005`                       |\n"
  },
  {
    "path": "containers/app-proxy/bin/www",
    "content": "#!/usr/bin/env node\n\nconst cookieParser = require('cookie-parser');\nconst express = require('express');\nconst waitPort = require('wait-port');\n\nconst proxy = require('../utils/proxy.js');\nconst umbrelRoutes = require('../routes/umbrel.js');\nconst handleErrorMiddleware = require('../middleware/handle_error.js');\n\nconst CONSTANTS = require('../utils/const.js');\n\nconst app = express();\n\napp.disable('x-powered-by');\napp.set('view engine', 'ejs');\n\napp.use(cookieParser(CONSTANTS.UMBREL_AUTH_SECRET));\napp.use('/umbrel_', umbrelRoutes);\n\napp.use(handleErrorMiddleware);\n\nconst middleware = proxy.apply(app);\n\n// We'll only open the app proxy's port\n// Once the app's port is open\nconsole.log(`Waiting for ${CONSTANTS.APP_HOST}:${CONSTANTS.APP_PORT} to open...`);\n\nconst delay = ms => new Promise(resolve => setTimeout(resolve, ms));\n\n(async () => {\n\twhile(true) {\n\t\ttry {\n\t\t\tawait waitPort({\n\t\t\t\thost: CONSTANTS.APP_HOST,\n\t\t\t\tport: CONSTANTS.APP_PORT,\n\t\t\t\toutput: \"silent\",\n\t\t\t\t// Wait indefinitely for the app to start\n\t\t\t\ttimeout: 0\n\t\t\t});\n\t\t\t// Exit loop on success\n\t\t\tbreak;\n\t\t} catch (error) {\n\t\t\tconsole.log(`Error waiting for port: \"${error.message}\"`);\n\t\t\t// Wait before retrying\n\t\t\tawait delay(100);\n\t\t\tconsole.log('Retrying...');\n\t\t}\n\t}\n\n\tconsole.log(`${CONSTANTS.APP.name} is now ready...`);\n\n\tconst server = app.listen(CONSTANTS.PROXY_PORT, () => {\n\t\tconsole.log(`Listening on port: ${CONSTANTS.PROXY_PORT}`);\n\t});\n\n\t// This allows it to proxy WebSockets without the initial http request\n\tserver.on('upgrade', middleware.upgrade);\n})();\n"
  },
  {
    "path": "containers/app-proxy/middleware/handle_error.js",
    "content": "function handleError(error, req, res, next) {\n  var statusCode = error.statusCode || 500;\n  var route = req.url || '';\n  var message = error.message || '';\n\n  res.status(statusCode).json(message);\n}\n\nmodule.exports = handleError;\n"
  },
  {
    "path": "containers/app-proxy/package.json",
    "content": "{\n  \"name\": \"app-proxy\",\n  \"version\": \"0.0.1\",\n  \"private\": true,\n  \"scripts\": {\n    \"lint\": \"eslint\",\n    \"start\": \"node ./bin/www\",\n    \"test\": \"mocha 'test/**/*.js'\",\n    \"coverage\": \"nyc --all mocha 'test/**/*.js'\",\n    \"postcoverage\": \"codecov\",\n    \"build\": \"docker buildx build --platform linux/amd64,linux/arm64 --tag getumbrel/app-proxy .\"\n  },\n  \"dependencies\": {\n    \"axios\": \"^0.26.1\",\n    \"cookie-parser\": \"^1.4.6\",\n    \"dotenv\": \"^16.0.0\",\n    \"ejs\": \"^3.1.6\",\n    \"express\": \"^4.17.3\",\n    \"express-validator\": \"^6.14.0\",\n    \"http-proxy-middleware\": \"^2.0.4\",\n    \"http-status-codes\": \"^2.2.0\",\n    \"js-yaml\": \"^4.1.0\",\n    \"jsonwebtoken\": \"^9.0.2\",\n    \"wait-port\": \"^0.2.9\"\n  },\n  \"devDependencies\": {\n    \"babel-eslint\": \"^10.1.0\",\n    \"chai\": \"^4.1.2\",\n    \"chai-http\": \"^4.2.0\",\n    \"codecov\": \"^3.7.1\",\n    \"eslint\": \"^7.0.0\",\n    \"mocha\": \"^7.1.2\",\n    \"node-mocks-http\": \"^1.11.0\",\n    \"nyc\": \"15.0.1\"\n  }\n}\n"
  },
  {
    "path": "containers/app-proxy/routes/umbrel.js",
    "content": "const { StatusCodes } = require(\"http-status-codes\");\nconst bodyParser = require(\"body-parser\");\nconst express = require(\"express\");\nconst validator = require(\"express-validator\");\n\nconst router = express.Router();\n\nconst CONSTANTS = require(\"../utils/const.js\");\nconst tokenUtils = require(\"../utils/token.js\");\nconst hmacUtils = require(\"../utils/hmac.js\");\nconst safeHandler = require(\"../utils/safe_handler.js\");\n\nconst ONE_SECOND = 1000;\nconst ONE_MINUTE = 60 * ONE_SECOND;\nconst ONE_HOUR = 60 * ONE_MINUTE;\nconst ONE_DAY = 24 * ONE_HOUR;\nconst ONE_WEEK = 7 * ONE_DAY;\n\nconst parseForm = bodyParser.urlencoded({ extended: false });\n\nrouter.use(parseForm);\n\nrouter.post(\n  \"/api/v1/auth/token\",\n  [\n    validator.body(\"token\").isString(),\n    validator.body(\"signature\").isString(),\n    validator.body(\"r\").isString(),\n  ],\n  safeHandler(async (req, res) => {\n    const errors = validator.validationResult(req);\n    if (!errors.isEmpty()) {\n      return res.status(StatusCodes.BAD_REQUEST).json({\n        errors: errors.array(),\n      });\n    }\n\n    const token = req.body.token;\n    const signature = req.body.signature;\n    const redirectTo = req.body.r;\n\n    // Before we validate the token, lets check the hmac\n    if (!hmacUtils.verify(token, CONSTANTS.UMBREL_AUTH_SECRET, signature)) {\n      return res\n        .status(StatusCodes.INTERNAL_SERVER_ERROR)\n        .send(`The signature is invalid`);\n    }\n\n    // Check that the token is valid before setting cookie...\n    if (!(await tokenUtils.validate(token))) {\n      return res\n        .status(StatusCodes.INTERNAL_SERVER_ERROR)\n        .send(`The token is invalid`);\n    }\n\n    const expires = new Date(Date.now() + ONE_WEEK);\n    res.cookie(\"UMBREL_PROXY_TOKEN\", token, {\n      httpOnly: true,\n      expires,\n      sameSite: \"lax\",\n    });\n\n    res.redirect(redirectTo);\n  })\n);\n\nmodule.exports = router;\n"
  },
  {
    "path": "containers/app-proxy/test/.gitignore",
    "content": "apps/"
  },
  {
    "path": "containers/app-proxy/test/docker-compose.app1.yml",
    "content": "version: '3.7'\n\nservices:\n    app_proxy:\n        environment:\n            APP_HOST: nginxdemo\n            APP_PORT: 80\n            PROXY_AUTH_WHITELIST: \"*\"\n            PROXY_AUTH_BLACKLIST: \"/admin/*,/admin2/*\"\n    nginxdemo:\n        image: nginxdemos/hello"
  },
  {
    "path": "containers/app-proxy/test/docker-compose.app2.yml",
    "content": "version: '3.7'\n\nservices:\n    app_proxy:\n        environment:\n            APP_HOST: frontend\n            APP_PORT: 8888\n    frontend:\n        image: mendhak/http-https-echo\n        environment:\n            HTTP_PORT: 8888"
  },
  {
    "path": "containers/app-proxy/test/docker-compose.bleskomat.yml",
    "content": "version: \"3.7\"\n\nservices:\n  app_proxy:\n    environment:\n      APP_HOST: bleskomat_web\n      APP_PORT: 3333\n      PROXY_AUTH_ADD: \"false\"\n\n  bleskomat_db:\n    image: postgres:10.20-stretch@sha256:130e08bb19199bd055e585e8938c5ebb0555dc13b445fad5b0bd727e4b75149c\n    user: \"1000:1000\"\n    restart: on-failure\n    stop_grace_period: 1m\n    volumes:\n        - ./apps/bleskomat/data/db:/var/lib/postgresql/data\n    environment:\n      - POSTGRES_USER=bleskomat_server\n      - POSTGRES_DB=bleskomat_server\n      - POSTGRES_PASSWORD=moneyprintergobrrr\n\n  bleskomat_web:\n    image: bleskomat/bleskomat-server:1.3.4@sha256:7bd91b896c5ca4f69b7c9509b40ccfae273cc46120ec66b2e27b295b0186f230\n    user: \"1000:1000\"\n    restart: on-failure\n    stop_grace_period: 1m\n    depends_on:\n      - bleskomat_db\n    volumes:\n      - ./apps/bleskomat/data/web:/usr/src/app/data\n      - ./apps/bleskomat/data/lnd:/lnd:ro\n    ports:\n      - \"3334:3333\"\n    environment:\n      DEBUG: \"bleskomat-server*,lnurl*\"\n      BLESKOMAT_SERVER_HOST: \"0.0.0.0\"\n      BLESKOMAT_SERVER_PORT: \"3333\"\n      BLESKOMAT_SERVER_URL: \"test.onion\"\n      BLESKOMAT_SERVER_ENDPOINT: \"/u\"\n      BLESKOMAT_SERVER_AUTH_API_KEYS: '[]'\n      BLESKOMAT_SERVER_LIGHTNING: '{\"backend\":\"lnd\",\"config\":{\"cert\":\"/lnd/tls.cert\",\"protocol\":\"https\",\"hostname\":\"lnd:12345\",\"macaroon\":\"/lnd/admin.macaroon\"}}'\n      BLESKOMAT_SERVER_STORE: '{\"backend\":\"knex\",\"config\":{\"client\":\"postgres\",\"connection\":{\"host\":\"bleskomat_db\",\"port\":5432,\"user\":\"bleskomat_server\",\"password\":\"moneyprintergobrrr\",\"database\":\"bleskomat_server\"}}}'\n      BLESKOMAT_SERVER_COINRATES_DEFAULTS_PROVIDER: \"coinbase\"\n      BLESKOMAT_SERVER_ADMIN_WEB: \"true\"\n      BLESKOMAT_SERVER_ADMIN_PASSWORD_PLAINTEXT: \"$APP_PASSWORD\"\n      BLESKOMAT_SERVER_ENV_FILEPATH: \"./data/.env\"\n"
  },
  {
    "path": "containers/app-proxy/test/docker-compose.error.yml",
    "content": "version: '3.7'\n\nservices:\n    app_proxy:\n        environment:\n            APP_HOST: app_wrong\n            APP_PORT: 80\n            PROXY_AUTH_WHITELIST: \"*\"\n    app:\n        image: nginxdemos/hello"
  },
  {
    "path": "containers/app-proxy/test/docker-compose.mempool.yml",
    "content": "version: \"3.7\"\n\nservices:\n  app_proxy:\n    environment:\n      APP_HOST: web_mempool\n      APP_PORT: 3006\n      PROXY_AUTH_WHITELIST: \"/admin/\"\n  web_mempool:\n    image: mempool/frontend:v2.3.1@sha256:38c955caeb58014b266904b059bfabbdab8321d20b11e7cccb139be6dfc8e36e\n    user: \"1000:1000\"\n    init: true\n    restart: on-failure\n    stop_grace_period: 1m\n    command: \"./wait-for mariadb:3306 --timeout=720 -- nginx -g 'daemon off;'\"\n    environment:\n      FRONTEND_HTTP_PORT: 3006\n      BACKEND_MAINNET_HTTP_HOST: \"api\"\n  api:\n    image: mempool/backend:v2.3.1@sha256:f7b16a6b00ea8aabf3b71a34ec05bb373fa0b6f1d31c7981b767edb2d1b7cf89\n    user: \"1000:1000\"\n    init: true\n    restart: on-failure\n    stop_grace_period: 1m\n    command: \"./wait-for-it.sh mariadb:3306 --timeout=720 --strict -- ./start.sh\"\n    volumes:\n      - ./apps/mempool/data:/backend/cache\n    environment:\n      ELECTRUM_TLS: \"false\"\n      DATABASE_HOST: \"mariadb\"\n      DATABASE_PORT: \"3306\"\n      DATABASE_DATABASE: \"mempool\"\n      DATABASE_USERNAME: \"mempool\"\n      DATABASE_PASSWORD: \"mempool\"\n      MEMPOOL_HTTP_PORT: \"8999\"\n      MEMPOOL_CACHE_DIR: \"/backend/cache\"\n      MEMPOOL_CLEAR_PROTECTION_MINUTES: \"20\"\n  mariadb:\n    image: mariadb:10.5.12@sha256:dfcba5641bdbfd7cbf5b07eeed707e6a3672f46823695a0d3aba2e49bbd9b1dd\n    user: \"1000:1000\"\n    restart: on-failure\n    stop_grace_period: 1m\n    volumes:\n      - ./apps/mempool/mysql/data:/var/lib/mysql\n    environment:\n      MYSQL_DATABASE: \"mempool\"\n      MYSQL_USER: \"mempool\"\n      MYSQL_PASSWORD: \"mempool\"\n      MYSQL_ROOT_PASSWORD: \"moneyprintergobrrr\"\n"
  },
  {
    "path": "containers/app-proxy/test/docker-compose.nextcloud.yml",
    "content": "version: \"3.7\"\n\nservices:\n  app_proxy:\n    environment:\n      - APP_HOST=web\n      - APP_PORT=80\n\n  db:\n    image: mariadb:10.5.12@sha256:dfcba5641bdbfd7cbf5b07eeed707e6a3672f46823695a0d3aba2e49bbd9b1dd\n    user: \"1000:1000\"\n    command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW\n    restart: on-failure\n    volumes:\n      - ./apps/nextcloud/data/db:/var/lib/mysql\n    environment:\n      - MYSQL_ROOT_PASSWORD=moneyprintergobrrr\n      - MYSQL_PASSWORD=moneyprintergobrrr\n      - MYSQL_DATABASE=nextcloud\n      - MYSQL_USER=nextcloud\n\n  redis:\n    image: redis:6.2.2-buster@sha256:e10f55f92478715698a2cef97c2bbdc48df2a05081edd884938903aa60df6396\n    user: \"1000:1000\"\n    restart: on-failure\n    volumes:\n      - \"./apps/nextcloud/data/redis:/data\"\n\n  web:\n    image: nextcloud:22.1.1-apache@sha256:99d94124b2024c9f7f38dc12144a92bc0d68d110bcfd374169ebb7e8df0adf8e\n    # Currently needs to be run as root, if we run as uid 1000 this fails\n    # https://github.com/nextcloud/docker/blob/05026b029d37fc5cd488d4a4a2a79480e39841ba/21.0/apache/entrypoint.sh#L53-L77\n    # user: \"1000:1000\"\n    restart: on-failure\n    volumes:\n      - ./apps/nextcloud/data/nextcloud:/var/www/html\n    environment:\n      - MYSQL_HOST=db\n      - REDIS_HOST=redis\n      - MYSQL_PASSWORD=moneyprintergobrrr\n      - MYSQL_DATABASE=nextcloud\n      - MYSQL_USER=nextcloud\n      - NEXTCLOUD_ADMIN_USER=umbrel\n      - NEXTCLOUD_ADMIN_PASSWORD=${APP_PASSWORD}\n      - NEXTCLOUD_TRUSTED_DOMAINS=${APP_DOMAIN}:${APP_PORT}\n    depends_on:\n      - db\n      - redis\n\n  cron:\n    image: nextcloud:22.0.0-apache@sha256:55de721417c16ff110720217406778e16f1b63154d2e8d42fc7913c37dbe6d50\n    # Currently needs to be run as root, if we run as uid 1000 this fails\n    # https://github.com/nextcloud/docker/blob/05026b029d37fc5cd488d4a4a2a79480e39841ba/21.0/apache/entrypoint.sh#L53-L77\n    # user: \"1000:1000\"\n    restart: on-failure\n    volumes:\n      - ./apps/nextcloud/data/nextcloud:/var/www/html\n    entrypoint: /cron.sh\n    depends_on:\n      - db\n      - redis\n"
  },
  {
    "path": "containers/app-proxy/test/docker-compose.proxy.yml",
    "content": "version: '3.7'\n\nservices:\n    caddy:\n        image: caddy:2.5.1\n        command: caddy reverse-proxy --from :4007 --to app_proxy:4000\n        ports:\n            - \"4007:4007\"\n\n    app_proxy:\n        environment:\n            APP_HOST: frontend\n            APP_PORT: 8888\n            PROXY_AUTH_WHITELIST: \"*\"\n            PROXY_TRUST_UPSTREAM: \"true\"\n    \n    frontend:\n        image: mendhak/http-https-echo\n        environment:\n            HTTP_PORT: 8888"
  },
  {
    "path": "containers/app-proxy/test/docker-compose.proxyhttps.yaml",
    "content": "version: '3.7'\n\nservices:\n    caddy:\n        image: caddy:2.5.1\n        volumes:\n            - \"./test/Caddyfile-https:/etc/caddy/Caddyfile\"\n        ports:\n            - \"4007:4007\"\n\n    app_proxy:\n        environment:\n            APP_HOST: frontend\n            APP_PORT: 8888\n            PROXY_AUTH_WHITELIST: \"*\"\n            PROXY_TRUST_UPSTREAM: \"true\"\n    \n    frontend:\n        image: mendhak/http-https-echo\n        environment:\n            HTTP_PORT: 8888"
  },
  {
    "path": "containers/app-proxy/test/docker-compose.sse.yml",
    "content": "version: '3.7'\n\nservices:\n    app_proxy:\n        environment:\n            APP_HOST: sse_server\n            APP_PORT: 80\n            PROXY_AUTH_WHITELIST: \"*\"\n    sse_server:\n        image: getumbrel/sse-test-server\n        build: ./sse-test-server"
  },
  {
    "path": "containers/app-proxy/test/docker-compose.suredbits.yml",
    "content": "version: \"3.7\"\n\nservices:\n  app_proxy:\n    environment:\n      APP_HOST: web\n      APP_PORT: 3002\n\n  web:\n    image: bitcoinscala/wallet-server-ui:1.9.1@sha256:3eb479b106811d523c4e0cfde244949f6c76a27c7d1fe59be9b8b51ba2372649\n    user: \"1000:1000\"\n    restart: on-failure\n    stop_grace_period: 1m\n    volumes:\n      - ./apps/suredbits/data/wallet:/home/bitcoin-s/.bitcoin-s\n      - ./apps/suredbits/data/log:/log\n    environment:\n      LOG_PATH: \"/log/\"\n      BITCOIN_S_HOME: \"/home/bitcoin-s/.bitcoin-s/\"\n      MEMPOOL_API_URL: \"http://umbrel.local:3004/api\"\n      WALLET_SERVER_API_URL: \"http://walletserver:9999/\"\n      WALLET_SERVER_WS: \"ws://walletserver:19999/events\"\n      TOR_PROXY: socks5://localhost:5555\n      DEFAULT_UI_PASSWORD: \"password123\"\n      BITCOIN_S_SERVER_RPC_PASSWORD: \"password123\"\n    depends_on: \n      - walletserver\n  \n  walletserver:\n    image: bitcoinscala/bitcoin-s-server:1.9.1-34-3dc70938-SNAPSHOT@sha256:1cd82d19059382f740f7b8acbc2d3aaeaf0c1fd7c662bdbc3ef7b97a27ee181f\n    user: \"1000:1000\"\n    restart: on-failure\n    volumes:\n      - ./apps/suredbits/data/wallet:/home/bitcoin-s/.bitcoin-s\n    environment:\n      BITCOIN_S_NODE_MODE: \"bitcoind\"\n      BITCOIN_S_NETWORK: \"regtest\"\n      BITCOIN_S_KEYMANAGER_ENTROPY: \"a75a58071bd4c65a1e87c1b970e9d736d9185a427a4f3110d250ea3945d9108d\"\n      BITCOIN_S_PROXY_ENABLED: \"false\"\n      BITCOIN_S_TOR_ENABLED: \"false\"\n      BITCOIN_S_TOR_PROVIDED: \"true\"\n      BITCOIN_S_DLCNODE_PROXY_ENABLED: \"true\"\n      BITCOIN_S_DLCNODE_PROXY_SOCKS5: \"localhost:5555\"\n      BITCOIN_S_DLCNODE_EXTERNAL_IP: \"hiddenservice.onion\"\n      BITCOIN_S_BITCOIND_HOST: \"bitcoind\"\n      BITCOIN_S_BITCOIND_PORT: \"18443\"\n      BITCOIN_S_BITCOIND_USER: \"umbrel\"\n      BITCOIN_S_BITCOIND_PASSWORD: \"bitcoinbitcoin\"\n      BITCOIN_S_SERVER_RPC_PASSWORD: \"password123\"\n    ports:\n      - \"2862:2862\"\n\n  bitcoind:\n    image: lncm/bitcoind:v22.0@sha256:37a1adb29b3abc9f972f0d981f45e41e5fca2e22816a023faa9fdc0084aa4507\n    command: \"${APP_BITCOIN_COMMAND}\"\n    restart: on-failure\n    stop_grace_period: 15m30s\n    volumes:\n      - ./apps/suredbits/data/bitcoin:/data/.bitcoin\n"
  },
  {
    "path": "containers/app-proxy/test/docker-compose.ws.yml",
    "content": "version: '3.7'\n\nservices:\n    app_proxy:\n        environment:\n            APP_HOST: ws_server\n            APP_PORT: 8010\n    ws_server:\n        image: ksdn117/web-socket-test"
  },
  {
    "path": "containers/app-proxy/test/docker-compose.yml",
    "content": "version: '3.7'\n\nservices:\n    app_proxy:\n        image: getumbrel/app-proxy\n        build:\n            context: ..\n            dockerfile: Dockerfile.dev\n        user: \"1000:1000\"\n        restart: on-failure\n        ports:\n            - \"${APP_PORT}:4000\"\n        volumes:\n            - ..:/app\n            - ./fixtures/mempool-umbrel-app.yml:/extra/umbrel-app.yml:ro\n            - ./fixtures/tor/data:/var/lib/tor:ro\n            - ./data:/app-data:ro\n        environment:\n            LOG_LEVEL: debug\n            PROXY_PORT: 4000\n            PROXY_AUTH_ADD: \"true\"\n            PROXY_AUTH_WHITELIST: \n            PROXY_AUTH_BLACKLIST: \n            PROXY_TRUST_UPSTREAM: \n            APP_HOST: \n            APP_PORT: \n            APP_MANIFEST_FILE: \"/extra/umbrel-app.yml\"\n            UMBREL_AUTH_PORT: \"${AUTH_PORT}\"\n            UMBREL_AUTH_SECRET: \"${UMBREL_AUTH_SECRET}\"\n            UMBREL_AUTH_HIDDEN_SERVICE_FILE: \"/var/lib/tor/auth/hostname\"\n            MANAGER_IP: \"${MANAGER_IP}\"\n            MANAGER_PORT: 3006\n\nnetworks:\n    default:\n        external:\n            name: umbrel_main_network\n"
  },
  {
    "path": "containers/app-proxy/test/fixtures/mempool-umbrel-app.yml",
    "content": "id: mempool\ncategory: Explorers\nname: mempool\nversion: 2.3.1\ntagline: A self-hosted explorer for the Bitcoin community\ndescription: |-\n  Trusted third parties are security holes. Self-host your own instance of mempool.space on Umbrel for maximum privacy.\n\n  Features:\n\n  - Live dashboard visualizing the mempool and blockchain\n  - Live transaction tracking\n  - Search any transaction, block or address\n  - Fee estimations\n  - Mempool historical data\n  - TV View for larger displays as a TV in a cafe or bar\n  - View transaction scripts and op_return messages\n  - Audio notifications on transaction confirmed and address balance change\n  - Multiple languages support\n  - JSON APIs\ndeveloper: Mempool Space K.K.\nwebsite: https://mempool.space/about\ndependencies:\n- bitcoind\n- electrum\nrepo: https://github.com/mempool/mempool\nsupport: https://mempool.support\nport: 4006\ngallery:\n- 1.jpg\n- 2.jpg\n- 3.jpg\npath: ''\ndefaultUsername: ''\ndefaultPassword: ''\n"
  },
  {
    "path": "containers/app-proxy/test/global.js",
    "content": "const chai = require('chai');\nconst chaiHttp = require('chai-http');\n\nchai.use(chaiHttp);\nchai.should();\n\nglobal.expect = chai.expect;\nglobal.assert = chai.assert;\n\nbefore(() => {\n  \n});\n\nglobal.reset = () => {\n  \n};\n\nafter(() => {\n\n});\n"
  },
  {
    "path": "containers/app-proxy/test/sse-test-server/.dockerignore",
    "content": "Dockerfile\nnode_modules\n.git\n.github\n"
  },
  {
    "path": "containers/app-proxy/test/sse-test-server/Dockerfile",
    "content": "# Build Stage\nFROM node:16-buster-slim AS umbrel-sse-test-server-builder\n\n# Create app directory\nWORKDIR /app\n\n# Copy 'yarn.lock' and 'package.json'\nCOPY yarn.lock package.json ./\n\n# Install dependencies\nRUN yarn install --production\n\n# Copy project files and folders to the current working directory (i.e. '/app')\nCOPY . .\n\n# Final image\nFROM node:16-buster-slim AS umbrel-sse-test-server\n\n# Copy built code from build stage to '/app' directory\nCOPY --from=umbrel-sse-test-server-builder /app /app\n\n# Change directory to '/app'\nWORKDIR /app\n\nCMD [ \"yarn\", \"start\" ]\n"
  },
  {
    "path": "containers/app-proxy/test/sse-test-server/bin/www",
    "content": "#!/usr/bin/env node\n\nconst express = require('express');\n\nconst PORT = process.env.PORT || 80;\n\nconst app = express();\n\napp.get('/clock', function(req, res) {\n\tres.writeHead(200, {\n\t\t'Content-Type': 'text/event-stream',\n\t\t'Cache-Control': 'no-cache',\n\t\t'Connection': 'keep-alive'\n\t});\n\n\tconst clockInterval = setInterval(() => {\n\t\tconst data = (new Date()).toString();\n\t\tres.write(\"data: \" + data + \"\\n\\n\");\n\t}, 1000);\n\n\treq.on(\"close\", () => {\n\t\tclearInterval(clockInterval);\n\t});\n});\n\napp.get('/', (req, res) => {\n\tres.end(`<html>\n\t<head>\n\t  <script>\n\t  if (!!window.EventSource) {\n\t    var source = new EventSource('/clock')\n\n\t    source.addEventListener('message', function(e) {\n\t      document.getElementById('data').innerHTML+= e.data + \"<br>\"\n\t    }, false)\n\n\t    source.addEventListener('open', function(e) {\n\t      document.getElementById('state').innerHTML = \"Connected\"\n\t    }, false)\n\n\t    source.addEventListener('error', function(e) {\n\t      const id_state = document.getElementById('state')\n\t      if (e.eventPhase == EventSource.CLOSED)\n\t        source.close()\n\t      if (e.target.readyState == EventSource.CLOSED) {\n\t        id_state.innerHTML = \"Disconnected\"\n\t      }\n\t      else if (e.target.readyState == EventSource.CONNECTING) {\n\t        id_state.innerHTML = \"Connecting...\"\n\t      }\n\t    }, false)\n\t  } else {\n\t    console.log(\"Your browser doesn't support SSE\")\n\t  }\n\t  </script>\n\t</head>\n\t<body>\n\t  <h2>Status: <span id=\"state\"></span></h2>\n\t  <h2>Data</h2>\n\t  <pre id=\"data\"></pre>\n\t</body>\n\t</html>`);\n});\n\napp.listen(PORT, () => {\n\tconsole.log(`Listening on port: ${PORT}`);\n});"
  },
  {
    "path": "containers/app-proxy/test/sse-test-server/package.json",
    "content": "{\n  \"name\": \"umbrel-sse-test-server\",\n  \"version\": \"0.0.1\",\n  \"private\": true,\n  \"scripts\": {\n    \"start\": \"node ./bin/www\"\n  },\n  \"dependencies\": {\n    \"express\": \"^4.17.3\"\n  }\n}\n"
  },
  {
    "path": "containers/app-proxy/test/test/Caddyfile-https",
    "content": "https://umbrel-dev.local:4007 {\n\treverse_proxy app_proxy:4000\n\ttls internal\n}"
  },
  {
    "path": "containers/app-proxy/test/test.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nUMBREL_ENV_FILE=\"$(readlink -f $(dirname \"${BASH_SOURCE[0]}\")/../../../.env)\"\n\nCOMPOSE_FILE=\"${1}\"\n\n# Some test env vars. for nextcloud\nexport APP_DOMAIN=\"localhost\"\nexport APP_PASSWORD=\"password\"\n\n# Test env vars. for bitcoind\nBIN_ARGS=()\nBIN_ARGS+=( \"-chain=regtest\" )\nBIN_ARGS+=( \"-rpcport=18443\" )\nBIN_ARGS+=( \"-rpcbind=0.0.0.0\" )\nBIN_ARGS+=( \"-rpcallowip=0.0.0.0/0\" )\nBIN_ARGS+=( \"-rpcuser=umbrel\" )\nBIN_ARGS+=( \"-rpcpassword=bitcoinbitcoin\" )\nBIN_ARGS+=( \"-txindex=1\" )\nBIN_ARGS+=( \"-blockfilterindex=1\" )\nBIN_ARGS+=( \"-peerbloomfilters=1\" )\nBIN_ARGS+=( \"-peerblockfilters=1\" )\nBIN_ARGS+=( \"-deprecatedrpc=addresses\" )\nBIN_ARGS+=( \"-rpcworkqueue=128\" )\n\nexport APP_BITCOIN_COMMAND=$(IFS=\" \"; echo \"${BIN_ARGS[@]}\")\n\n# Env vars. specific to app proxy\nexport APP_PORT=$(cat fixtures/mempool-umbrel-app.yml | yq '.port')\n\necho \"Proxy booting on port: ${APP_PORT}\"\n\n# Generate random project id\nPROJECT=$(echo -n \"${COMPOSE_FILE}\" | sha256sum)\n\ndocker-compose --env-file \"${UMBREL_ENV_FILE}\" --project-name \"${PROJECT}\" -f ./docker-compose.yml -f \"${COMPOSE_FILE}\" up"
  },
  {
    "path": "containers/app-proxy/test/utils/express.js",
    "content": "const httpMocks = require('node-mocks-http');\n\nconst express = require(\"../../utils/express.js\");\n\ndescribe('express', () => {\n  it('should attempt to remove a cookie even if that cookie does not exist', () => {\n    const req = httpMocks.createRequest({\n      method: 'GET',\n      protocol: \"http\",\n      headers: {\n        host: \"bitcoin.org:4444\",\n        cookie: \"session=abc123; csrf=a_value.das384jfdjsi4r2hf29f\"\n      }\n    });\n\n    const cookieHeader = express.removeCookie(req, \"abc\");\n\n    assert.equal(\"session=abc123; csrf=a_value.das384jfdjsi4r2hf29f\", cookieHeader);\n  });\n\n  it('should attempt to remove a cookie even if there are no cookies', () => {\n    const req = httpMocks.createRequest({\n      method: 'GET',\n      protocol: \"http\",\n      headers: {\n        host: \"bitcoin.org:4444\"\n      }\n    });\n\n    const cookieHeader = express.removeCookie(req, \"does_not_exist\");\n\n    assert.equal(\"\", cookieHeader);\n  });\n\n  it('should remove the cookie when the cookie exists in the request header', () => {\n    const req = httpMocks.createRequest({\n      method: 'GET',\n      protocol: \"http\",\n      headers: {\n        host: \"bitcoin.org:4444\",\n        cookie: \"session=abc123; another_cookie=some_value;csrf=a_value.das384jfdjsi4r2hf29f\"\n      }\n    });\n\n    const cookieHeader = express.removeCookie(req, \"session\");\n\n    assert.equal(\"another_cookie=some_value; csrf=a_value.das384jfdjsi4r2hf29f\", cookieHeader);\n  });\n\n  it('should remove the cookie when that 1 cookie exists in the request header', () => {\n    const req = httpMocks.createRequest({\n      method: 'GET',\n      protocol: \"http\",\n      headers: {\n        host: \"bitcoin.org:4444\",\n        cookie: \"session=abc123\"\n      }\n    });\n\n    const cookieHeader = express.removeCookie(req, \"session\");\n\n    assert.equal(\"\", cookieHeader);\n  });\n\n  it('should remove the cookie when that 1 cookie exists with delimiter in the request header', () => {\n    const req = httpMocks.createRequest({\n      method: 'GET',\n      protocol: \"http\",\n      headers: {\n        host: \"bitcoin.org:4444\",\n        cookie: \"session=abc123; \"\n      }\n    });\n\n    const cookieHeader = express.removeCookie(req, \"session\");\n\n    assert.equal(\"\", cookieHeader);\n  });\n});\n"
  },
  {
    "path": "containers/app-proxy/test/utils/tor.js",
    "content": "const tor = require(\"../../utils/tor.js\");\n\ndescribe('tor', () => {\n  it('should return the auth HS url', async () => {\n    const url = await tor.authHsUrl();\n\n    assert.equal(\"the-auth-hs-url.onion\", url);\n  });\n});\n"
  },
  {
    "path": "containers/app-proxy/utils/const.js",
    "content": "const yaml = require('js-yaml');\nconst fs   = require('fs');\n\nconst APP_MANIFEST_FILE = process.env.APP_MANIFEST_FILE || \"/extra/umbrel-app.yml\";\nconst CUSTOM_DOTENV_FILE = process.env.CUSTOM_DOTENV_FILE || \"/data/.env.app_proxy\";\n\nif(fs.existsSync(CUSTOM_DOTENV_FILE)) {\n\trequire('dotenv').config({\n\t\tpath: CUSTOM_DOTENV_FILE,\n\t\toverride: true\n\t});\n}\n\nfunction readUmbrelAppManifest() {\n\ttry {\n\t\treturn yaml.load(fs.readFileSync(APP_MANIFEST_FILE, 'utf8'));\n\t} catch (e) {\n\t\tconsole.error(\"Failed to open app manifest file\", e);\n\n\t\tprocess.exit(0);\n\t}\n}\n\nfunction readFromEnvOrTerminate(key) {\n\tconst value = process.env[key];\n\n\tif(typeof(value) !== \"string\" || value.trim().length === 0) {\n\t\tconsole.error(`The env. variable '${key}' is not set. Terminating...`);\n\n\t\tprocess.exit(0);\n\t}\n\n\treturn value;\n}\n\nfunction cleanHttpPaths(str) {\n\treturn str.split(/[, ]+/)\n\t\t.map(path => path.trim())\n\t\t.filter(path => path.length > 0);\n}\n\nmodule.exports = Object.freeze({\n\tUMBREL_COOKIE_NAME: \"UMBREL_SESSION\",\n\n\tLOG_LEVEL: process.env.LOG_LEVEL || \"info\",\n\n\tPROXY_PORT: parseInt(process.env.PROXY_PORT) || 4000,\n\tPROXY_TIMEOUT: parseInt(process.env.PROXY_TIMEOUT) || 0, // milliseconds or 0 for disabled\n\tPROXY_AUTH_ADD: (typeof(process.env.PROXY_AUTH_ADD) === \"string\") ? (process.env.PROXY_AUTH_ADD === \"true\") : true,\n\tPROXY_AUTH_WHITELIST: cleanHttpPaths(process.env.PROXY_AUTH_WHITELIST || \"\"),\n\tPROXY_AUTH_BLACKLIST: cleanHttpPaths(process.env.PROXY_AUTH_BLACKLIST || \"\"),\n\tPROXY_TRUST_UPSTREAM: (typeof(process.env.PROXY_TRUST_UPSTREAM) === \"string\") ? (process.env.PROXY_TRUST_UPSTREAM === \"true\") : false,\n\n\tAPP: readUmbrelAppManifest(),\n\tAPP_PROTOCOL: process.env.APP_PROTOCOL || \"http\",\n\tAPP_HOST: readFromEnvOrTerminate(\"APP_HOST\"),\n\tAPP_PORT: parseInt(readFromEnvOrTerminate(\"APP_PORT\")),\n\n\tUMBREL_AUTH_HIDDEN_SERVICE_FILE: process.env.UMBREL_AUTH_HIDDEN_SERVICE_FILE || \"/var/lib/tor/auth/hostname\",\n\n\tUMBREL_AUTH_SECRET: readFromEnvOrTerminate(\"UMBREL_AUTH_SECRET\"),\n\tUMBREL_AUTH_PORT: parseInt(process.env.UMBREL_AUTH_PORT) || 2000,\n\n\tMANAGER_IP: readFromEnvOrTerminate(\"MANAGER_IP\"),\n\tMANAGER_PORT: parseInt(process.env.MANAGER_PORT) || 3006,\n});"
  },
  {
    "path": "containers/app-proxy/utils/express.js",
    "content": "function removeCookie(req, cookieName) {\n\tconst allCookies = req.headers.cookie || \"\";\n\n\t// Split on '; ' (where space is optional)\n\t// More details re http cookie delimter:\n\t// https://www.rfc-editor.org/rfc/rfc6265#section-4.2.1\n\tconst cookiePairs = allCookies.split(/; */g).filter(pair => pair.length > 0);\n\n\t// Filter out cookie and re-join\n\t// to build http cookie string\n\t// (using cookie delimiter)\n\treturn cookiePairs.filter(pair => ! pair.startsWith(`${cookieName}=`)).join(\"; \");\n}\n\nmodule.exports = {\n\tremoveCookie\n};"
  },
  {
    "path": "containers/app-proxy/utils/hmac.js",
    "content": "const crypto = require('crypto');\n\nfunction sign(input, secret) {\n\treturn crypto\n\t\t.createHmac('sha256', secret)\n\t\t.update(input)\n\t\t.digest('base64');\n};\n\nfunction verify(input, secret, signature){\n\tconst inputSignature = Buffer.from( sign(input, secret) );\n\tconst testSignature = Buffer.from( signature );\n\n\treturn inputSignature.length === testSignature.length && crypto.timingSafeEqual(inputSignature, testSignature);\n};\n\nmodule.exports = {\n\tsign,\n\tverify\n};"
  },
  {
    "path": "containers/app-proxy/utils/manager.js",
    "content": "const axios = require('axios');\nconst package = require('../package.json');\n\nconst CONSTANTS = require('./const.js');\n\nconst axiosInstance = axios.create({\n\tbaseURL: `http://${CONSTANTS.MANAGER_IP}:${CONSTANTS.MANAGER_PORT}`,\n\theaders: {\n\t\tcommon: {\n\t\t\t\"User-Agent\": `${package.name}/${package.version}`\n\t\t}\n\t}\n});\n\nconst account = {\n\tlogin: async function(body) {\n\t\treturn axiosInstance.post('/v1/account/login', body);\n\t},\n\ttoken: async function(token) {\n\t\treturn axiosInstance.get('/v1/account/token', {\n\t\t\tparams: {\n\t\t\t\ttoken\n\t\t\t}\n\t\t});\n\t}\n};\n\nmodule.exports = {\n\taccount\n};"
  },
  {
    "path": "containers/app-proxy/utils/proxy.js",
    "content": "const { createProxyMiddleware } = require(\"http-proxy-middleware\");\nconst { StatusCodes } = require(\"http-status-codes\");\nconst url = require(\"url\");\n\nconst expressUtils = require(\"./express.js\");\nconst tokenUtils = require(\"./token.js\");\nconst torUtils = require(\"./tor.js\");\nconst CONSTANTS = require(\"./const.js\");\nconst safeHandler = require(\"./safe_handler.js\");\n\nfunction onProxyReq(proxyReq, req, res, config) {\n  // \"Value may be undefined if the socket is destroyed (for example, if the client disconnected).\"\n  // More details here: https://nodejs.org/api/net.html#socketremoteaddress\n  if (req.socket.remoteAddress === undefined) {\n    return res.end();\n  }\n\n  // If we don't trust the upstream, we'll set the x-forwarded headers\n  // Upstream could be a proxy and therefore trusted\n  // So we'll accept the incoming x-forwarded headers\n  if (!CONSTANTS.PROXY_TRUST_UPSTREAM) {\n    proxyReq.setHeader(\"x-forwarded-proto\", req.protocol);\n    proxyReq.setHeader(\"x-forwarded-host\", req.headers.host);\n    proxyReq.setHeader(\"x-forwarded-for\", req.socket.remoteAddress);\n  }\n\n  // Remove umbrel session cookie from proxied request\n  const cookies = expressUtils.removeCookie(req, CONSTANTS.UMBREL_COOKIE_NAME);\n  if (cookies.trim().length === 0) {\n    // \"the user agent sends a Cookie request header to the origin server if it has cookies\"\n    // More info: https://datatracker.ietf.org/doc/html/rfc2109#section-4.3.4\n    proxyReq.removeHeader(\"cookie\");\n  } else {\n    proxyReq.setHeader(\"cookie\", cookies);\n  }\n}\n\nfunction onError(err, req, res, target) {\n  // ENOTFOUND = The proxy could not reach the target (check APP_HOST and APP_PORT)\n  // ETIMEDOUT = The proxy could reach the target, but the target was too slow to respond (potentially PROXY_TIMEOUT is too low)\n\n  console.error(`Proxy error: ${err.message}`);\n\n  if (typeof res.status === \"function\") {\n    res.status(StatusCodes.BAD_GATEWAY).render(\"pages/error\", {\n      app: CONSTANTS.APP,\n      err,\n    });\n  }\n}\n\nfunction proxy() {\n  const proxyTarget = `${CONSTANTS.APP_PROTOCOL}://${CONSTANTS.APP_HOST}:${CONSTANTS.APP_PORT}`;\n\n  const proxyConfig = {\n    onProxyReq: onProxyReq,\n    onError: onError,\n    target: proxyTarget,\n    // Don't change the origin\n    // Pass through the origin ('host' header) from the browser\n    changeOrigin: false,\n    // Add websocket support, but this option assumes that\n    // an initial http request is made before the websocket connection\n    ws: true,\n    // If this is true, this will chain the x-forwarded header values\n    // Many applications don't handle multiple header values (e.g. BTC Pay Server)\n    xfwd: false,\n    logLevel: CONSTANTS.LOG_LEVEL,\n    proxyTimeout: CONSTANTS.PROXY_TIMEOUT,\n    // The proxy shouldn't follow redirect\n    // The browser should, therefore this must be off\n    followRedirects: false,\n  };\n\n  return createProxyMiddleware(proxyConfig);\n}\n\nfunction whitelist() {\n  return function (req, res, next) {\n    req.ignoreAuth = true;\n\n    next();\n  };\n}\n\nfunction blacklist() {\n  return function (req, res, next) {\n    req.ignoreAuth = false;\n\n    next();\n  };\n}\n\nfunction apply(app) {\n  if (CONSTANTS.PROXY_AUTH_ADD) {\n    if (CONSTANTS.PROXY_AUTH_WHITELIST.length > 0)\n      app.use(CONSTANTS.PROXY_AUTH_WHITELIST, whitelist());\n    if (CONSTANTS.PROXY_AUTH_BLACKLIST.length > 0)\n      app.use(CONSTANTS.PROXY_AUTH_BLACKLIST, blacklist());\n  }\n\n  const middleware = proxy();\n\n  app.use(\n    safeHandler(async (req, res, next) => {\n      // If route is part of the auth whitelist\n      // Then we ignore handling auth\n      if (CONSTANTS.PROXY_AUTH_ADD && req.ignoreAuth !== true) {\n        const token = req.cookies.UMBREL_PROXY_TOKEN;\n\n        // token could be false if hmac fails (ie. someone tampered with the token)\n        if (typeof token !== \"string\" || !(await tokenUtils.validate(token))) {\n          const origin = req.hostname.endsWith(\".onion\") ? \"tor\" : \"host\";\n\n          // Get the raw query string\n          // This could be null if there is no query string\n          let query = url.parse(req.url).query;\n          if (typeof query == \"string\") {\n            query = `?${query}`;\n          } else {\n            query = \"\";\n          }\n\n          const searchParams = new URLSearchParams({\n            origin: origin,\n            app: CONSTANTS.APP.id,\n            path: `${req.path}${query}`,\n          });\n\n          // If request came over Tor\n          // Then redirect to auth HS hosted on Tor\n          if (origin === \"tor\") {\n            const authHsUrl = await torUtils.authHsUrl();\n\n            return res.redirect(\n              `${req.protocol}://${authHsUrl}/?${searchParams.toString()}`\n            );\n          } else {\n            return res.redirect(\n              `${req.protocol}://${req.hostname}:${\n                CONSTANTS.UMBREL_AUTH_PORT\n              }/?${searchParams.toString()}`\n            );\n          }\n        }\n      }\n\n      middleware(req, res, next);\n    })\n  );\n\n  return middleware;\n}\n\nmodule.exports = {\n  proxy,\n  whitelist,\n  blacklist,\n  apply,\n};\n"
  },
  {
    "path": "containers/app-proxy/utils/safe_handler.js",
    "content": "// this safe handler is used to wrap our api methods\n// so that we always fallback and return an exception if there is an error\n// inside of an async function\n// Mostly copied from vault/server/utils/safeHandler.js\nfunction safeHandler(handler) {\n  return async (req, res, next) => {\n    try {\n      return await handler(req, res, next);\n    } catch (err) {\n      return next(err);\n    }\n  };\n}\n\nmodule.exports = safeHandler;\n"
  },
  {
    "path": "containers/app-proxy/utils/token.js",
    "content": "const jwt = require(\"jsonwebtoken\");\n\nconst JWT_ALGORITHM = \"HS256\";\n\nconst secret = process.env.JWT_SECRET;\n\nfunction validate(token) {\n  const payload = jwt.verify(token, secret, {\n    algorithms: [JWT_ALGORITHM],\n  });\n\n  return payload.proxyToken === true;\n}\n\nmodule.exports = {\n  validate,\n};\n"
  },
  {
    "path": "containers/app-proxy/utils/tor.js",
    "content": "const fs = require(\"fs\").promises;\n\nconst CONSTANTS = require(\"./const.js\");\n\nasync function authHsUrl() {\n  // Here is technically a race condition\n  // As the auth hs url may not yet be generated\n  try {\n    return (\n      await fs.readFile(CONSTANTS.UMBREL_AUTH_HIDDEN_SERVICE_FILE, \"utf-8\")\n    ).trim();\n  } catch (e) {\n    return \"not-yet-generated.onion\";\n  }\n}\n\nmodule.exports = {\n  authHsUrl,\n};\n"
  },
  {
    "path": "containers/app-proxy/views/pages/error.ejs",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n   <meta charset=\"utf-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\n   <style type=\"text/css\">\n   body {\n      font-family: system-ui,-apple-system,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,\"Noto Sans\",\"Liberation Sans\",sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Noto Color Emoji\";\n      padding: 0px;\n      margin: 0px;\n      text-align: center;\n   }\n   \n   .container {\n      margin-top: 200px;\n   }\n\n   .app-icon {\n      height: 100px;\n      border-radius: 20px;\n   }\n\n   h1 {\n      font-size: calc(1.375rem + 1.5vw);\n      font-weight: 500;\n   }\n   .error-text {\n      color: #6c757d;\n   }\n   </style>\n\n   <title>Error</title>\n</head>\n<body>\n   <div class=\"container\">\n      <div>\n         <!-- Umbrel logo -->\n         <svg width=\"93\" height=\"105\" viewBox=\"0 0 93 105\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path d=\"M47.1162 105C48.85 105 50.9245 104.679 53.3395 104.037C61.4926 100.971 65.5692 95.0672 65.5692 86.3255V61.3121C65.5692 60.8357 65.5383 60.4214 65.4764 60.0692C65.4764 60.0071 65.4712 59.9657 65.4609 59.9449C65.4506 59.9242 65.4248 59.8828 65.3835 59.8207C64.5372 57.687 63.1955 56.6202 61.3585 56.6202H61.1108L60.5844 56.6513H60.3058C60.2439 56.6513 60.1716 56.6823 60.0891 56.7445C57.963 57.5731 56.9 59.3235 56.9 61.9957V87.6305C56.9 89.0184 56.5491 90.3752 55.8473 91.701C53.8658 94.8289 50.9245 96.3929 47.0233 96.3929C44.0717 96.3929 41.5741 95.4711 39.5306 93.6275C37.8587 92.2603 37.0021 89.7434 36.9608 86.0769L36.8989 60.8771C36.8989 60.6493 36.8783 60.411 36.837 60.1624C36.837 60.0796 36.8318 60.0226 36.8215 59.9916C36.8112 59.9605 36.7751 59.9035 36.7131 59.8207C35.8462 57.6456 34.4736 56.558 32.5953 56.558C31.9967 56.6202 31.5942 56.6927 31.3878 56.7756C29.3237 57.6042 28.2916 59.0024 28.2916 60.9703L28.3845 87.1023C28.4052 90.9967 29.4785 94.5079 31.6045 97.6358C35.5676 102.545 40.6969 105 46.9924 105H47.1162Z\" fill=\"#5351FB\"/>\n            <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M17.4517 40.4302C12.7214 40.4302 9.82339 41.8182 7.98048 44.0001C6.47085 45.7874 3.80334 46.0081 2.02243 44.493C0.241521 42.978 0.0216106 40.3009 1.53125 38.5136C5.39717 33.9367 10.963 31.9453 17.4517 31.9453C22.9046 31.9453 27.9024 33.335 32.3379 36.1353C36.9332 33.4016 41.7749 31.9453 46.8188 31.9453C51.7503 31.9453 56.4062 33.3384 60.7341 35.9888C64.5971 33.3787 69.2338 32.1792 74.4003 32.1792C80.8802 32.1792 86.5877 34.0623 91.2259 38.0272C93.0032 39.5465 93.2168 42.2241 91.7029 44.0078C90.189 45.7914 87.521 46.0058 85.7437 44.4865C82.8402 42.0045 79.158 40.6641 74.4003 40.6641C69.9059 40.6641 66.6755 41.8574 64.3151 43.9022C62.4688 45.5017 59.7715 45.6251 57.7877 44.2009L57.7131 44.1474C54.1394 41.6012 50.5342 40.4302 46.8188 40.4302C43.0364 40.4302 39.1958 41.6452 35.232 44.3366C33.371 45.6002 30.9173 45.5447 29.1149 44.1982L28.984 44.1004C25.6669 41.6634 21.8736 40.4302 17.4517 40.4302Z\" fill=\"#5351FB\"/>\n            <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M70.262 16.392C64.097 11.0258 56.1491 8.29985 46.0535 8.49453C35.9105 8.69011 28.0333 11.6155 22.0552 16.9826C16.0265 22.3952 11.53 30.6505 8.87493 42.2101C8.35052 44.4932 6.08115 45.9174 3.80615 45.3912C1.53115 44.8649 0.112013 42.5874 0.636423 40.3042C3.55057 27.6167 8.70808 17.5811 16.4183 10.6589C24.179 3.69135 34.1255 0.238118 45.8911 0.0112437C57.7039 -0.216542 67.7956 3.01361 75.8015 9.9822C83.7379 16.8903 89.2264 27.1251 92.5785 40.2C93.1603 42.4691 91.799 44.7819 89.538 45.3657C87.277 45.9496 84.9724 44.5834 84.3907 42.3143C81.3097 30.2972 76.4964 21.8187 70.262 16.392Z\" fill=\"#5351FB\"/>\n         </svg>\n         <!-- Right caret -->\n         <svg width=\"100\" height=\"100\" viewBox=\"0 0 32 32\" viewBox=\"0 0 32 32\"><path d=\"M18.629 15.997l-7.083-7.081L13.462 7l8.997 8.997L13.457 25l-1.916-1.916z\" fill=\"#555555\" /></svg>\n         <!-- App icon -->\n         <img alt=\"<%= app.name %>\" src=\"https://getumbrel.github.io/umbrel-apps-gallery/<%= app.id %>/icon.svg\" class=\"app-icon\">\n      </div>\n      \n      <h1 class=\"text-center mt-5 mb-2\">Oops, there was an error</h1>\n      <p class=\"text-muted w-75 text-center error-text\">There was an error connecting to <%= app.name %>. Error code: <%= err.code %></p>\n   </div>\n</body>\n</html>"
  },
  {
    "path": "containers/tor/Dockerfile",
    "content": "# Based on https://github.com/lncm/docker-tor/tree/927ebac9fb43ba4d09249ee27688a4612b7a1707\n\nFROM debian:11-slim AS build\n\nARG VERSION=0.4.7.8\n\n# Add Tor keys\nENV KEYS 514102454D0A87DB0767A1EBBE6A0531C18A9179 B74417EDDF22AC9F9E90F49142E86A2A11F48D36 7A02B3521DC75C542BA015456AFEE6D49E92B601\n\nRUN apt update && \\\n    # Packages for verification\n    apt -y install gpg gpg-agent wget && \\\n    # Packages for Tor runtime and compilation\n    apt -y install libevent-dev libssl-dev zlib1g-dev build-essential\n\n# Download Tor source and checksum\nRUN wget https://dist.torproject.org/tor-$VERSION.tar.gz.sha256sum.asc && \\\n    wget https://dist.torproject.org/tor-$VERSION.tar.gz.sha256sum && \\\n    wget https://dist.torproject.org/tor-$VERSION.tar.gz\n\n# Verify source\nRUN gpg --keyserver keyserver.ubuntu.com --recv-keys $KEYS && \\\n    gpg --list-keys | tail -n +3 | tee /tmp/keys.txt && \\\n    gpg --list-keys $KEYS | diff - /tmp/keys.txt && \\\n    gpg --verify tor-$VERSION.tar.gz.sha256sum.asc && \\\n    sha256sum -c tor-$VERSION.tar.gz.sha256sum\n\n# Extract source\nRUN tar -xzf \"/tor-$VERSION.tar.gz\"\n\nWORKDIR /tor-$VERSION/\n\n# Build Tor\nRUN ./configure --sysconfdir=/etc --datadir=/var/lib && \\\n    make -j$(nproc) && \\\n    make install\n\nFROM debian:11-slim\n\n# Copy linked libraries\nCOPY  --from=build /usr/lib /usr/lib\n\n# Copy Tor binaries\nCOPY  --from=build /usr/local/bin/tor*  /usr/local/bin/\n\nENTRYPOINT [\"tor\"]"
  },
  {
    "path": "containers/tor/README.md",
    "content": "[![Umbrel Tor](https://static.getumbrel.com/github/github-banner-umbrel-tor.svg)](https://github.com/getumbrel/umbrel-tor)\n\n[![Docker Build](https://img.shields.io/github/workflow/status/getumbrel/umbrel-tor/Docker%20build%20on%20push?color=%235351FB)](https://github.com/getumbrel/umbrel-tor/actions?query=workflow%3A\"Docker+build+on+push\")\n[![Docker Pulls](https://img.shields.io/docker/pulls/getumbrel/tor?color=%235351FB)](https://hub.docker.com/repository/registry-1.docker.io/getumbrel/tor/tags?page=1)\n\n\n# ☂️ Tor\n\nA simple Docker image for Tor\n\n## 🛠 Build Tor Docker image\n\n### Build\n```sh\ndocker build -t getumbrel/tor .\n```\n\n### Run\n```sh\ndocker run --rm -u 1000:1000 -e HOME=/tmp getumbrel/tor\n```"
  },
  {
    "path": "containers/tor/test/.gitignore",
    "content": "data"
  },
  {
    "path": "containers/tor/test/docker-compose.entrypoint.yml",
    "content": "version: '3.7'\n\nservices:\n    web:\n        image: mendhak/http-https-echo\n        environment:\n            HTTP_PORT: 8888\n\n    tor:\n        image: getumbrel/tor\n        build: ..\n        user: 1000:1000\n        environment:\n            HOME: /tmp\n            HS_DIR: \"web2\"\n            HS_VIRTUAL_PORT: \"80\"\n            HS_HOST: \"web\"\n            HS_PORT: \"8888\"\n        entrypoint: /umbrel/entrypoint.sh\n        volumes:\n            - ./data:/data\n            - ./entrypoint.sh:/umbrel/entrypoint.sh\n"
  },
  {
    "path": "containers/tor/test/docker-compose.yml",
    "content": "version: '3.7'\n\nservices:\n    web:\n        image: mendhak/http-https-echo\n        environment:\n            HTTP_PORT: 8888\n\n    tor:\n        image: getumbrel/tor\n        build: ..\n        user: 1000:1000\n        environment:\n            HOME: /tmp\n        volumes:\n            - ./torrc:/etc/tor/torrc\n            - ./data:/data"
  },
  {
    "path": "containers/tor/test/entrypoint.sh",
    "content": "#!/bin/bash\n\nTORRC_PATH=\"/tmp/torrc\"\n\necho \"HiddenServiceDir /data/${HS_DIR}\" > \"${TORRC_PATH}\"\necho \"HiddenServicePort ${HS_VIRTUAL_PORT} ${HS_HOST}:${HS_PORT}\" >> \"${TORRC_PATH}\"\n\ntor -f \"${TORRC_PATH}\""
  },
  {
    "path": "containers/tor/test/test-entrypoint.sh",
    "content": "#!/bin/bash\n\ndocker-compose -f docker-compose.entrypoint.yml up --detach web\ndocker-compose -f docker-compose.entrypoint.yml up --detach tor\n\necho\necho \"Hostname:\"\ncat ./data/web2/hostname\n"
  },
  {
    "path": "containers/tor/test/test.sh",
    "content": "#!/bin/bash\n\ndocker-compose up --detach web\ndocker-compose up --detach tor\n\necho\necho \"Hostname:\"\ncat ./data/web/hostname"
  },
  {
    "path": "containers/tor/test/torrc",
    "content": "HiddenServiceDir /data/web\nHiddenServicePort 80 web:8888"
  },
  {
    "path": "info.json",
    "content": "{\n  \"NOTE\": \"We must keep this file here forever to allow old umbrelOS 0.5.x Umbrel Homes to find the 1.0.0 update and bootstrap themselves into the new mender based update system.\",\n  \"version\": \"1.0.0\",\n  \"name\": \"umbrelOS 1.0\",\n  \"requires\": \">=0.5.3\",\n  \"notes\": \"umbrelOS 1.0 brings a ground-up rebuild with a host of new features, a completely revamped architecture, and a brand new interface.\\n\\nLearn more: https://link.umbrel.com/os\\n\\n- Raspberry Pi users: Please follow the instructions at https://link.umbrel.com/pi-update to update to umbrelOS 1.0.\\n\\n- Umbrel Home users: You're ready to update now by clicking the 'Install Now' button below.\\n\\n- Linux users: Your update is on the horizon and will be available in April 2024: https://link.umbrel.com/linux-update.\\n\\nNote: Updates may overwrite any custom CLI scripts, files, packages, or settings (eg. WiFi) that you've manually installed or modified via command-line (SSH).\\n\\nWhat's new:\\n\\n- New UI: A visually stunning environment with seamless navigation, intuitive interactions, and a home screen you can personalize with widgets\\n\\n- Search (⌘K / Ctrl + K): Instantly find system settings, apps, or initiate actions with a simple keyboard shortcut.\\n\\n- Quick Actions: Interact with apps and system settings faster than ever with right-click shortcuts.\\n\\n- Architectural Overhaul: Re-engineered system for better stability, speed, and simplicity. umbrelOS 1.0 now sends device type information during the update check to ensure customized updates for different devices.\\n\\n- Live Usage: Stay informed with live updates on your device's storage, memory, and CPU usage.\\n\\n- Multilingual Support: Choose from 8 different system languages.\"\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"./scripts/umbrel-dev\",\n    \"dev:help\": \"npm run dev help\",\n    \"test:vm\": \"npm --prefix packages/os run build:amd64:rugix && rm -rf packages/os/rugix/build && npm --prefix packages/umbreld run test:vm --\",\n    \"build:remote\": \"./scripts/remote-builder build\",\n    \"test:remote\": \"./scripts/remote-builder test\"\n  }\n}\n"
  },
  {
    "path": "packages/os/.gitignore",
    "content": "build\nvm-state\n"
  },
  {
    "path": "packages/os/README.md",
    "content": "# umbrelOS\n\n\n## Build Process\n\nThe following diagram visualizes the build process and various build artifacts:\n\n```mermaid\ngraph TD\n    subgraph \"Docker\"\n        root-amd64\n        root-arm64\n    end\n    subgraph \"Rugix (Pi)\"\n        pi --> pi4\n        pi --> pi-tryboot\n        pi --> pi-mbr\n    end\n    subgraph \"Rugix (AMD64)\"\n        amd64\n        mender-amd64\n    end\n    root-amd64 --> amd64\n    root-amd64 --> mender-amd64\n    root-arm64 --> pi\n    amd64 --> amd64-img([amd64.img])\n    amd64 --> amd64-rugixb([amd64.rugixb])\n    mender-amd64 --> mender-amd64-mender([mender-amd64.mender])\n    mender-amd64 --> mender-amd64-rugixb([mender-amd64.rugixb])\n    pi-mbr --> pi-mbr-mender([pi.mender])\n    pi4 --> pi4-img([pi4.img])\n    pi-tryboot --> pi-tryboot-img([pi5.img])\n    pi-tryboot --> pi-tryboot-rugixb([pi.rugixb])\n```\n\n### OS Variants\n\nThere are three main umbrelOS *variants*:\n\n- `umbrelos-pi`: Rugix-native umbrelOS for Raspberry Pi.\n- `umbrelos-amd64`: Rugix-native umbrelOS for AMD64.\n- `umbrelos-mender-amd64`: Rugix-based but Mender-compatible umbrelOS for AMD64.\n\n> [!IMPORTANT]\n> **Legacy devices provisioned with Mender require the `umbrelos-mender-amd64` variant.** This variant includes a Rugix configuration to enable a safe migration from Mender to Rugix. Rugix will interface with GRUB in the same way Mender does to facilitate A/B switching. In addition, a Rugix hook is installed to migrate state into Rugix's state management mechanism. This enables factory resets and all other Rugix state management features.\n\n### Build Artifacts\n\nThe build process produces the following artifacts.\n\n#### Images\n\nImages for provisioning new umbrelOS devices.\n\n- `umbrelos-amd64.img`: Image for provisioning AMD64 devices and VMs.\n- `umbrelos-pi4.img`: Image for provisioning Raspberry Pi 4 devices (GPT-based).\n- `umbrelos-pi5.img`: Image for provisioning Raspberry Pi 5 devices (GPT-based).\n\nThe image for Raspberry Pi 4 includes a firmware update to enable the `tryboot` mechanism used for A/B switching. Otherwise, the image is identical to the image for Raspberry Pi 5.\n\n#### Mender Update Artifacts\n\nMender update artifacts for updating existing systems through Mender:\n\n- `umbrelos-pi.mender`: Mender update artifact for Raspberry Pi.\n- `umbrelos-mender-amd64.mender`: Mender update artifact for AMD64 devices.\n\nThe update artifact for Raspberry Pi works for Raspberry Pi 4 and 5.\n\n#### Rugix Update Bundles\n\nFor each of the umbrelOS variants, there is a respective Rugix update bundle:\n\n- `umbrelos-pi.rugixb`: Rugix update bundle for Raspberry Pi.\n- `umbrelos-amd64.rugixb`: Rugix update bundle for Rugix-native AMD64 devices.\n- `umbrelos-mender-amd64.rugixb`: Rugix update bundle for legacy Mender devices.\n\n\n## Mender to Rugix Migration\n\nDevices can be migrated to Rugix by installing the respective Mender update artifact.\n\n### Raspberry Pi\n\nAs umbrelOS for Raspberry Pi has always been based on Rugix, no special migration is needed.\n\n### AMD64\n\nIn case of Mender-based AMD64 devices, the directory structure on the data partition must be migrated such that Rugix's state management can be used.\nThis migration is performed by a `boot/post-init` Rugix hook: [`10-migrate-state.sh`](./rugix/recipes/setup-rugix-mender/files/migrate-state.sh).\nThis hook works as follows: Initially, a symlink `/data/umbrel-os` is placed in the `/data` directory managed by Rugix linking back to the bare data partition.\nThis way, after booting, all the bind mounts to `/data/umbrel-os` are set up correctly.\nThe migration hook replaces this symlink atomically with the directory from the data partition, **but only after the system has been committed**.\nThis is done to leave the state intact, in case there is a rollback.\nNote that the system needs to be rebooted once after the commit to trigger the migration.\nTo this end, after committing, `umbreld` may check whether `/data/umbrel-os` is a symlink and reboot the system if it is.\n\n\n## Factory Resets\n\nFactory resets can be triggered through Rugix Ctrl's state management mechanism as usual.\nTo trigger a factory reset, run `rugix-ctrl state reset`.\nThis will reboot the system and remove the state on the data partition in the process.\nIn case of a RAID configuration, a [`state-reset/prepare` hook](./rugix/recipes/setup-rugix/files/factory-reset.sh) is used to remove the RAID configuration and reset the main data partition.\nNote that Rugix only supports resetting the state on the actively used data partition (which is the RAID, if a RAID has been configured), hence, we need to use the hook here to make sure that the main data partition is wiped as well.\n\nRemoving state can take some time during the boot process.\nIt is therefore recommended to use Rugix's state reset mechanism with the `--backup` flag.\nThis will simply rename the old state directory according to the following pattern:\n\n```\n/run/rugix/mounts/data/state/default -> /run/rugix/mounts/data/state/default.XXXXXXXXXXXXXX\n```\n\nHere, `XXXXXXXXXXXXXX` is the date and time of the reset.\nThe residual state can then removed after booting by `umbreld`.\n"
  },
  {
    "path": "packages/os/build-steps/initialize.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\nSNAPSHOT_DATE=${1:-}\n\n# Disable the resume from SWAP functionality. This takes significant time during the boot\n# process, as it tries to resume using a swap device which never shows up.\nmkdir -p /etc/initramfs-tools/conf.d/\necho \"RESUME=none\" >/etc/initramfs-tools/conf.d/resume\n\nrm /etc/apt/sources.list.d/debian.sources\n\n# All apt packages are pinned to a specific date to ensure reproducibility.\n# This means building the same umbrelOS git tag always results in the same\n# package versions.\n# We should update this to the current date with each release to ensure we\n# are always using the latest packages.\ncat >/etc/apt/sources.list <<EOF\ndeb http://snapshot.debian.org/archive/debian/${SNAPSHOT_DATE} trixie main contrib non-free-firmware\ndeb-src http://snapshot.debian.org/archive/debian/${SNAPSHOT_DATE} trixie main contrib non-free-firmware\ndeb http://snapshot.debian.org/archive/debian-security/${SNAPSHOT_DATE} trixie-security main contrib non-free-firmware\ndeb-src http://snapshot.debian.org/archive/debian-security/${SNAPSHOT_DATE} trixie-security main contrib non-free-firmware\ndeb http://snapshot.debian.org/archive/debian/${SNAPSHOT_DATE} trixie-updates main contrib non-free-firmware\ndeb-src http://snapshot.debian.org/archive/debian/${SNAPSHOT_DATE} trixie-updates main contrib non-free-firmware\nEOF\n\n# This is also needed to avoid issues with apt refusing to install old packages from snapshot repos.\necho 'Acquire::Check-Valid-Until \"false\";' | tee /etc/apt/apt.conf.d/90snapshot-validuntil\n\napt-get update --yes\n\n# Install systemd\n#\n# We do this here as the Rasperry Pi setup requires Systemd. Without it, it will not\n# realize that it runs inside Docker and complain about missing mountpoints during\n# the installation.\napt-get install --yes systemd-sysv"
  },
  {
    "path": "packages/os/build-steps/setup-raspberrypi/cmdline.txt",
    "content": "console=serial0,115200 console=tty1 rootfstype=ext4 fsck.repair=yes rootwait cgroup_enable=memory swapaccount=1 loglevel=3 usb-storage.quirks=152d:1561:u,152d:1576:u,152d:0578:u,125f:a76a:u,04e8:61b6:u"
  },
  {
    "path": "packages/os/build-steps/setup-raspberrypi/config.txt",
    "content": "# Enable DRM VC4 V3D driver.\n#\n# MX: This has been enabled by default and is required for 3D graphics\n# hardware acceleration. We just leave it enabled.\ndtoverlay=vc4-kms-v3d\nmax_framebuffers=2\n\n# We want to run the processor in its 64-bit mode.\narm_64bit=1\n\n# Enable NVMe interface\n# This is not needed if booting with NVMe so potentially\n# we only want to support that and don't need this.\ndtparam=nvme\n\n# This may improve NVMe performance but is not stable:\n# > The Raspberry Pi 5 is not certified for Gen 3.0 speeds. PCIe Gen 3.0 connections may be unstable.\n# - https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#pcie-gen-3-0\n# dtparam=pciex1_gen=3"
  },
  {
    "path": "packages/os/build-steps/setup-raspberrypi/raspberrypi.gpg.key",
    "content": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v1.4.12 (GNU/Linux)\n\nmQENBE/d7o8BCACrwqQacGJfn3tnMzGui6mv2lLxYbsOuy/+U4rqMmGEuo3h9m92\n30E2EtypsoWczkBretzLUCFv+VUOxaA6sV9+puTqYGhhQZFuKUWcG7orf7QbZRuu\nTxsEUepW5lg7MExmAu1JJzqM0kMQX8fVyWVDkjchZ/is4q3BPOUCJbUJOsE+kK/6\n8kW6nWdhwSAjfDh06bA5wvoXNjYoDdnSZyVdcYCPEJXEg5jfF/+nmiFKMZBraHwn\neQsepr7rBXxNcEvDlSOPal11fg90KXpy7Umre1UcAZYJdQeWcHu7X5uoJx/MG5J8\nic6CwYmDaShIFa92f8qmFcna05+lppk76fsnABEBAAG0IFJhc3BiZXJyeSBQaSBB\ncmNoaXZlIFNpZ25pbmcgS2V5iQE4BBMBAgAiBQJP3e6PAhsDBgsJCAcDAgYVCAIJ\nCgsEFgIDAQIeAQIXgAAKCRCCsSmSf6MwPk6vB/9pePB3IukU9WC9Bammh3mpQTvL\nOifbkzHkmAYxzjfK6D2I8pT0xMxy949+ThzJ7uL60p6T/32ED9DR3LHIMXZvKtuc\nmQnSiNDX03E2p7lIP/htoxW2hDP2n8cdlNdt0M9IjaWBppsbO7IrDppG2B1aRLni\nuD7v8bHRL2mKTtIDLX42Enl8aLAkJYgNWpZyPkDyOqamjijarIWjGEPCkaURF7g4\nd44HvYhpbLMOrz1m6N5Bzoa5+nq3lmifeiWKxioFXU+Hy5bhtAM6ljVb59hbD2ra\nX4+3LXC9oox2flmQnyqwoyfZqVgSQa0B41qEQo8t1bz6Q1Ti7fbMLThmbRHiuQEN\nBE/d7o8BCADNlVtBZU63fm79SjHh5AEKFs0C3kwa0mOhp9oas/haDggmhiXdzeD3\n49JWz9ZTx+vlTq0s+I+nIR1a+q+GL+hxYt4HhxoA6vlDMegVfvZKzqTX9Nr2VqQa\nS4Kz3W5ULv81tw3WowK6i0L7pqDmvDqgm73mMbbxfHD0SyTt8+fk7qX6Ag2pZ4a9\nZdJGxvASkh0McGpbYJhk1WYD+eh4fqH3IaeJi6xtNoRdc5YXuzILnp+KaJyPE5CR\nqUY5JibOD3qR7zDjP0ueP93jLqmoKltCdN5+yYEExtSwz5lXniiYOJp8LWFCgv5h\nm8aYXkcJS1xVV9Ltno23YvX5edw9QY4hABEBAAGJAR8EGAECAAkFAk/d7o8CGwwA\nCgkQgrEpkn+jMD5Figf/dIC1qtDMTbu5IsI5uZPX63xydaExQNYf98cq5H2fWF6O\nyVR7ERzA2w33hI0yZQrqO6pU9SRnHRxCFvGv6y+mXXXMRcmjZG7GiD6tQWeN/3wb\nEbAn5cg6CJ/Lk/BI4iRRfBX07LbYULCohlGkwBOkRo10T+Ld4vCCnBftCh5x2OtZ\nTOWRULxP36y2PLGVNF+q9pho98qx+RIxvpofQM/842ZycjPJvzgVQsW4LT91KYAE\n4TVf6JjwUM6HZDoiNcX6d7zOhNfQihXTsniZZ6rky287htsWVDNkqOi5T3oTxWUo\nm++/7s3K3L0zWopdhMVcgg6Nt9gcjzqN1c0gy55L/g==\n=mNSj\n-----END PGP PUBLIC KEY BLOCK-----"
  },
  {
    "path": "packages/os/build-steps/setup-raspberrypi/raspberrypi.list",
    "content": "deb http://archive.raspberrypi.com/debian/ RELEASE main\n# Uncomment line below then 'apt-get update' to enable 'apt-get source'\n#deb-src http://archive.raspberrypi.com/debian/ RELEASE main"
  },
  {
    "path": "packages/os/build-steps/setup-raspberrypi.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\n# Install GPG (required for dearmoring the key).\napt-get install -y gpg\n\n\n# Remove any existing firmware and kernels.\nrm -rf /boot/firmware\nmkdir -p /boot/firmware\n\n# Configure APT with Raspberry Pi sources.\ninstall -m 644 \"/build-steps/setup-raspberrypi/raspberrypi.list\" \"/etc/apt/sources.list.d/\"\nsed -i \"s/RELEASE/trixie/g\" \"/etc/apt/sources.list.d/raspberrypi.list\"\n\ngpg --dearmor \\\n    < \"/build-steps/setup-raspberrypi/raspberrypi.gpg.key\" \\\n    > \"/etc/apt/trusted.gpg.d/raspberrypi-archive-stable.gpg\"\n\nchmod 644 \"/etc/apt/trusted.gpg.d/raspberrypi-archive-stable.gpg\"\n\napt-get update -y\napt-get install -y raspberrypi-archive-keyring\n\n\n# Install kernel and firmware for Pi 4 and 5.\napt-get install -y \\\n    initramfs-tools \\\n    raspi-firmware \\\n    firmware-brcm80211 \\\n    linux-image-rpi-v8 \\\n    linux-headers-rpi-v8 \\\n    linux-image-rpi-2712 \\\n    linux-headers-rpi-2712\n\n\n# Install boot configuration files.\ninstall -m 644 \"/build-steps/setup-raspberrypi/cmdline.txt\" \"/boot/firmware/\"\ninstall -m 644 \"/build-steps/setup-raspberrypi/config.txt\" \"/boot/firmware/\"\n"
  },
  {
    "path": "packages/os/build.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\n# Pin the Rugix Docker image.\nexport RUGIX_BAKERY_IMAGE=\"ghcr.io/silitics/rugix-bakery@sha256:1abcf7791548aa441c06a8bc9b97acf170356cca8cd269b3fa8df4cc762d0251\" # v0.8.15 + progress reporting for local delta hits (git-0c91222)\n# export RUGIX_VERSION=\"branch-main\"\n# export RUGIX_DEV=true\n\n# Allow running from anywhere\ncd \"$(dirname $(readlink -f \"${BASH_SOURCE[0]}\"))\"\n\ndocker_buildx() {\n    docker buildx build --load $@\n}\n\nmender_artifact() {\n    docker run --rm -v \"$(pwd):/data\" umbrelos:builder /usr/bin/mender-artifact \"$@\"\n}\n\n# Run a command with sudo only in GitHub Actions\n# These commands fail in GHA without sudo but they aren't needed locally and it's\n# annoying for the script to get blocked and be prompted.\nmaybe_sudo() {\n    if [ \"${GITHUB_ACTIONS:-}\" = \"true\" ]; then\n        sudo \"$@\"\n    else\n        \"$@\"\n    fi\n}\n\n# Main entrypoint.\nfunction main() {\n    release=\"${1:-}\"\n    dev=\"false\"\n    if [[ \"${release}\" == \"\" ]]\n    then\n        local git_hash\n        git_hash=\"$(git rev-parse --short HEAD 2>/dev/null || echo \"dev\")\"\n        release=\"${git_hash}-$(date +%s)\"\n        dev=\"true\"\n    fi\n\n    # Enable QEMU/binfmt-based multi-platform support for building arm64 on\n    # amd64 or vice versa, e.g., to build for Pi 5 on an x86 system.\n    docker run --privileged --rm tonistiigi/binfmt --install all\n\n    if [ -z \"${SKIP_ROOTS:-}\" ]; then\n        if [ -z \"${SKIP_ARM64:-}\" ]; then\n            build_root_fs arm64 \"${release}\"\n        fi\n        if [ -z \"${SKIP_AMD64:-}\" ]; then\n            build_root_fs amd64 \"${release}\"\n        fi\n    fi\n\n    if [ -z \"${SKIP_RUGIX_ARTIFACTS:-}\" ]; then\n        build_rugix_artifacts \"${release}\" \"${dev}\"\n    fi\n\n    if [ -z \"${SKIP_MENDER_ARTIFACTS:-}\" ]; then\n        docker_buildx \\\n            --platform \"linux/amd64\" \\\n            --cache-from type=gha,scope=builder \\\n            --cache-to type=gha,mode=max,scope=builder \\\n            --file builder.Dockerfile \\\n            --tag umbrelos:builder \\\n            .\n        build_mender_artifacts \"${release}\"\n    fi\n\n    # Rename artifacts\n    # TODO: Maybe do this a cleaner way\n    # *.update are the new rugix native artifacts\n    # *-legacy.update are rugix update artifacts for legacy mender formatted devices\n    # *-legacy-migration.update are mender update artifacts to allow mender based update\n    # systems to migrate to the new rugix based update system.\n    mv build/umbrelos-amd64.rugixb        build/umbrelos-amd64.update                  2>/dev/null || true\n    mv build/umbrelos-mender-amd64.mender build/umbrelos-amd64-legacy-migration.update 2>/dev/null || true\n    mv build/umbrelos-mender-amd64.rugixb build/umbrelos-amd64-legacy.update           2>/dev/null || true\n    mv build/umbrelos-pi.mender           build/umbrelos-pi-legacy-migration.update    2>/dev/null || true\n    mv build/umbrelos-pi.rugixb           build/umbrelos-pi.update                     2>/dev/null || true\n\n    # To boot from QEMU\n    # qemu-system-x86_64 -net nic -net user,hostfwd=tcp::2222-:22 -machine accel=tcg -cpu max -smp 4 -m 8192 -hda build/umbrelos.img -bios OVMF.fd\n}\n\n# Build the root filesystem.\n#\n# Arguments: <arch> <release>\nfunction build_root_fs() {\n    local arch=$1;\n    local release=$2;\n\n    echo \"Ensuring the build dir exists...\"\n    mkdir -p build\n\n    # Ensure that the overlay directory exists.\n    mkdir -p \"overlay-${arch}\"\n\n    echo \"Building Umbrel OS Docker image...\"\n    # Note that we run the build context in ../../ so the build process has access to the\n    # entire repo to copy in umbreld stuff.\n    docker_buildx \\\n        --cache-from type=gha,scope=umbrelos-${arch} \\\n        --cache-to type=gha,mode=max,scope=umbrelos-${arch} \\\n        --platform \"linux/${arch}\" \\\n        --file umbrelos.Dockerfile \\\n        --tag \"umbrelos-${arch}\" \\\n        ../../\n\n    echo \"Dumping Umbrel OS Docker image filesystem into a tar archive...\"\n    umbrel_os_container_id=$(docker run --platform \"linux/${arch}\" --detach \"umbrelos-${arch}\" /bin/true)\n    docker export --output \"build/umbrelos-root-${arch}.tar\" \"${umbrel_os_container_id}\"\n    docker rm \"${umbrel_os_container_id}\"\n}\n\n# Build the Rugix artifacts.\n#\n# Arguments: <release> <dev>\nfunction build_rugix_artifacts() {\n    local release=\"$1\"\n    local dev=\"$2\"\n\n    # Make sure that the Rugix build directory exists.\n    mkdir -p rugix/build/umbrelos-root\n    # Copy the root filesystems previously build with Docker.\n    cp build/*.tar rugix/build/umbrelos-root\n    # Copy `/etc/hostname` and `/etc/hosts` such that Rugix can fix them.\n    cp overlay-common/etc/{hostname,hosts} rugix/recipes/fix-overlay/files\n    \n    local compression=\"compression = { type = \\\"xz\\\", level = 9 }\"\n    if [ \"$dev\" == \"true\" ]; then\n        compression=\"\"\n    fi\n\n    pushd rugix\n    # Clean Rugix cache to force a clean build.\n    rm -rf .rugix || true\n\n    if [ -z \"${SKIP_ARM64:-}\" ] && [ -z \"${SKIP_PI4:-}\" ]; then \n        build_rugix_system \"umbrelos-pi4\" \"$release\" \"$dev\"\n        maybe_sudo mv -f \"build/umbrelos-pi4/system.img\" \"../build/umbrelos-pi4.img\"\n    fi\n    if [ -z \"${SKIP_ARM64:-}\" ] && [ -z \"${SKIP_PI_TRYBOOT:-}\" ]; then \n        build_rugix_system \"umbrelos-pi-tryboot\" \"$release\" \"$dev\"\n        maybe_sudo mv -f \"build/umbrelos-pi-tryboot/system.img\" \"../build/umbrelos-pi5.img\"\n        maybe_sudo mv -f \"build/umbrelos-pi-tryboot/system.rugixb\" \"../build/umbrelos-pi.rugixb\"\n    fi\n    if [ -z \"${SKIP_ARM64:-}\" ] && [ -z \"${SKIP_PI_MBR:-}\" ]; then \n        build_rugix_system \"umbrelos-pi-mbr\" \"$release\" \"$dev\"\n        # Truncate the image to the end of the last partition. This is required for\n        # compatibility with the legacy Mender-Rugpi update module.\n        popd\n        docker_buildx \\\n            --platform \"linux/amd64\" \\\n            --cache-from type=gha,scope=builder \\\n            --cache-to type=gha,mode=max,scope=builder \\\n            --file builder.Dockerfile \\\n            --tag umbrelos:builder \\\n            .\n        docker run --rm -v \"$(pwd)/rugix:/data\" umbrelos:builder /data/fix-umbrelos-pi-mbr.sh\n        pushd rugix\n    fi\n    if [ -z \"${SKIP_AMD64:-}\" ] && [ -z \"${SKIP_AMD64_RUGIX:-}\" ]; then \n        build_rugix_system \"umbrelos-amd64\" \"$release\" \"$dev\"\n        maybe_sudo mv -f \"build/umbrelos-amd64/system.img\" \"../build/umbrelos-amd64.img\"\n        maybe_sudo mv -f \"build/umbrelos-amd64/system.rugixb\" \"../build/umbrelos-amd64.rugixb\"\n    fi\n    if [ -z \"${SKIP_AMD64:-}\" ] && [ -z \"${SKIP_AMD64_MENDER:-}\" ]; then\n        ./run-bakery bake image --release-version \"$release\" \"umbrelos-mender-amd64\"\n        maybe_sudo mkdir -p build/umbrelos-mender-amd64/bundle\n        maybe_sudo ln -s ../filesystems build/umbrelos-mender-amd64/bundle/payloads\n        cat <<EOF | maybe_sudo tee build/umbrelos-mender-amd64/bundle/rugix-bundle.toml > /dev/null\nupdate-type = \"full\"\n\nhash-algorithm = \"sha512-256\"\n\n[[payloads]]\nfilename = \"partition-1.img\"\n[payloads.delivery]\ntype = \"slot\"\nslot = \"system\"\n[payloads.block-encoding]\nhash-algorithm = \"sha512-256\"\nchunker = \"casync-64\"\n$compression\ndeduplication = true\nEOF\n        ./run-bakery bundler bundle build/umbrelos-mender-amd64/bundle build/umbrelos-mender-amd64/system.rugixb\n        maybe_sudo mv -f \"build/umbrelos-mender-amd64/system.rugixb\" \"../build/umbrelos-mender-amd64.rugixb\"\n    fi\n    popd\n}\n\n# Build the image and update bundle for a given system.\n#\n# Arguments: <system> <release> <dev>\nfunction build_rugix_system() {\n    local system=\"$1\"\n    local release=\"$2\"\n    local dev=\"$3\"\n\n    local compression=\"\"\n    if [ \"$dev\" == \"true\" ]; then\n        compression=\"--without-compression\"\n    fi\n\n    ./run-bakery bake bundle --release-version \"$release\" $compression \"$system\"\n}\n\n# Build the Mender update artifacts.\n#\n# Arguments: <release>\nfunction build_mender_artifacts() {\n    local release=\"$1\"\n\n    if [ -z \"${SKIP_ARM64:-}\" ] && [ -z \"${SKIP_PI:-}\" ]; then\n        if [ ! -e \"rugix/build/umbrelos-pi-mbr/system.img\" ]; then\n            echo \"'umbrelos-pi-mbr' image is required to build Raspberry Pi Mender artifact.\"\n            exit 1\n        fi\n        echo \"Build Raspberry Pi Mender artifact...\"\n        mender_artifact write module-image \\\n            --artifact-name \"${release}\" \\\n            -t raspberrypi \\\n            -T rugpi-image \\\n            -f /data/rugix/build/umbrelos-pi-mbr/system.img \\\n            -o /data/build/umbrelos-pi.mender\n    fi\n    if [ -z \"${SKIP_AMD64:-}\" ] && [ -z \"${SKIP_AMD64_MENDER:-}\" ]; then\n        if [ ! -e \"rugix/build/umbrelos-mender-amd64/filesystems/partition-1.img\" ]; then\n            echo \"'umbrelos-mender-amd64' image is required to build AMD64 Mender artifact.\"\n            exit 1\n        fi\n        echo \"Build AMD64 Mender artifact...\"\n        mender_artifact write rootfs-image \\\n            --artifact-name \"${release}\" \\\n            -t amd64 \\\n            -f /data/rugix/build/umbrelos-mender-amd64/filesystems/partition-1.img \\\n            -o /data/build/umbrelos-mender-amd64.mender\n    fi\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "packages/os/builder.Dockerfile",
    "content": "FROM debian:bullseye\n\nRUN apt-get -y update\n\n# Install os image builder deps\nRUN apt-get -y install fdisk gdisk qemu-utils dosfstools tree\n\n# Install mender-convert\nRUN apt-get -y install git\nRUN git clone -b 4.0.1 https://github.com/mendersoftware/mender-convert.git /mender\nRUN apt-get install -y sudo gdisk $(cat /mender/requirements-deb.txt)\nRUN wget -q -O /usr/bin/mender-artifact https://downloads.mender.io/mender-artifact/3.10.0/linux/mender-artifact\nRUN chmod +x /usr/bin/mender-artifact"
  },
  {
    "path": "packages/os/mender.cfg",
    "content": "DEPLOY_IMAGE_NAME=\"umbrelos\"\nMENDER_DEVICE_TYPE=\"amd64\"\n\n# This gives us:\n# - 200Mb EFI\n# - 2x ~10GB OS partitions\n# - 512MB data partition (so that mkfs.ext4 can fit a 128MB journal contiguosly in one flex-group)\n# We'll expand the data partition to consume 100% of the block device on first boot.\nMENDER_STORAGE_TOTAL_SIZE_MB=\"$((1024 * 20))\"\nMENDER_BOOT_PART_SIZE_MB=\"200\"\nMENDER_DATA_PART_SIZE_MB=\"512\"\n\n# We don't want the Mender systemd cloud service.\nMENDER_ENABLE_SYSTEMD=\"n\"\n\n# We don't want to let mender-convert inject mender-client\n# since it appears to be broken on bookworm. Instead we\n# install via apt in the image.\nMENDER_CLIENT_INSTALL=\"n\"\nMENDER_CLIENT_VERSION=\"3.4.0\"\n\n# Rreasonably fast and offers good compression\nMENDER_ARTIFACT_COMPRESSION=\"gzip\"\n\n# If we don't disable this the image is unbootable\nMENDER_COPY_BOOT_GAP=\"n\"\n\n# We'll disable this and implement our own expansion logic.\n# This successfulyl expands the filesystem to 100% of the partition\n# but it doesn't expand the partition to 100% of the block device.\nMENDER_DATA_PART_GROWFS=\"n\"\nMENDER_DATA_PART_FSTAB_OPTS=\"${MENDER_DATA_PART_FSTAB_OPTS},x-systemd.growfs\"\nMENDER_ROOT_PART_FSTAB_OPTS=\"${MENDER_ROOT_PART_FSTAB_OPTS},x-systemd.growfs\"\n\n# mkfs.ext4 options for the initial 128 MB Mender data partition\n# We later grow this partition to 100% of the block device, so we must override\n# mkfs.ext4’s size-based heuristics; otherwise it will default to values that are\n# fine for a small 128 MB filesystem, but sub-optimal once the partition is expanded to\n# real-world sized drives.\n#\n# -b 4096: 4 KB blocks. This would default to 1KB for a 128MB partition.\n# -I 256: 256-byte inodes. This would default to 128 bytes for a 128MB partition.\n# -i 65536: 1 inode per 64 KB of data → On a 1.8TB partition this is ~30 million inodes,\n#           which take up ~7 GB for inode tables.\n# -m 0: Reserve 0 % of blocks for root processes. Custom -m percentage will not scale correctly\n#       during fs expansion, so to set a custom reserve we would need to set this via tune2fs after expansion.\n# -J size=128: Creates a 128MB journal. We set the initial image partition size to 512MB to ensure a contiguous 128MB of free space in one flex-group for the journal.\n# -O ...: Force-enable all ext4 features we rely on, even if some would already be on by default.\nMENDER_DATA_PART_MKFS_OPTS=\"-b 4096 -I 256 -i 65536 -m 0 \\\n-J size=128 \\\n-O extent,flex_bg,sparse_super,large_file,huge_file,dir_index,filetype,64bit,metadata_csum,large_dir,extra_isize,has_journal \\\n-L data\"\n\n# Dealing with partition UUIDs instead of device paths\n# allows us to produce a single image that is bootable\n# on both QEMU (/dev/sda) and Umbrel Home (/dev/nvme0n1p).\nMENDER_ENABLE_PARTUUID=\"y\"\nMENDER_BOOT_PART=\"/dev/disk/by-partuuid/14a31e9d-a8d7-4da0-9eb2-f268dd9d7ad9\"\nMENDER_ROOTFS_PART_A=\"/dev/disk/by-partuuid/2fe5a278-9b55-4266-8220-6665aa96940b\"\nMENDER_ROOTFS_PART_B=\"/dev/disk/by-partuuid/f5e6d27c-4a25-447b-8e08-a9d2e738345a\"\nMENDER_DATA_PART=\"/dev/disk/by-partuuid/d1d36e34-2753-4dc7-96eb-3c9b5584e867\"\n\n# Reduce noise on TTY\nMENDER_GRUB_KERNEL_BOOT_ARGS=\"loglevel=3\"\n\n# Don't disable this for now since it's much faster since\n# changing to gzip\n# # Speed up development builds\n# if [[ \"${UMBREL_OS_DEV_BUILD}\" == \"true\" ]]\n# then\n#     MENDER_ARTIFACT_COMPRESSION=\"none\"\n# fi"
  },
  {
    "path": "packages/os/overlay-amd64/umbrelOS",
    "content": ""
  },
  {
    "path": "packages/os/overlay-arm64/etc/systemd/system/umbrel-external-storage.service",
    "content": "# Umbrel External Storage Mounter\n# Installed at /etc/systemd/system/umbrel-external-storage.service\n\n[Unit]\nDescription=External Storage Mounter\nBefore=docker.service umbrel.service\n\n[Service]\nType=oneshot\nRestart=no\nExecStart=/opt/umbrel-external-storage/umbrel-external-storage\nTimeoutStartSec=45min\nUser=root\nGroup=root\nStandardOutput=syslog\nStandardError=syslog\nSyslogIdentifier=external storage mounter\nRemainAfterExit=yes\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "packages/os/overlay-arm64/opt/umbrel-external-storage/umbrel-external-storage",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\n# This script will:\n# - Look for external storage devices\n# - Check if they contain an Umbrel install\n# - If yes\n# - - Mount it\n# - If no\n# - - Format it\n# - - Mount it\n# - - Install Umbrel on it\n# - Bind mount the external installation on top of the local installation\n\nUMBREL_ROOT=\"/home/umbrel/umbrel\"\nMOUNT_POINT=\"/mnt/data\"\nEXTERNAL_UMBREL_ROOT=\"${MOUNT_POINT}/umbrel\"\nDOCKER_DIR=\"/var/lib/docker\"\nEXTERNAL_DOCKER_DIR=\"${MOUNT_POINT}/docker\"\nSWAP_DIR=\"/swap\"\nSWAP_FILE=\"${SWAP_DIR}/swapfile\"\n\ncheck_root () {\n  if [[ $UID != 0 ]]; then\n    echo \"This script must be run as root\"\n    exit 1\n  fi\n}\n\ncheck_dependencies () {\n  for cmd in \"$@\"; do\n    if ! command -v $cmd >/dev/null 2>&1; then\n      echo \"This script requires \\\"${cmd}\\\" to be installed\"\n      exit 1\n    fi\n  done\n}\n\nrunning_off_sdcard() {\n  if df -h | grep --silent /dev/mmcblk0\n  then\n    echo \"true\"\n  else\n    echo \"false\"\n  fi\n}\n\n# Returns a list of block device paths\nlist_block_devices () {\n  # We need to run sync here to make sure the filesystem is reflecting the\n  # the latest changes in /sys/block/sd*\n  sync\n  # We use \"2>/dev/null || true\" to swallow errors if there are\n  # no block devices. In that case the function just returns nothing\n  # instead of an error which is what we want.\n  #\n  # sed 's!.*/!!' is to return the device path so we get sda\n  # instead of /sys/block/sda\n  (ls -d /sys/block/sd* /sys/block/nvme0n* 2>/dev/null || true) | sed 's!.*/!!'\n}\n\n# Returns the vendor and model name of a block device\nget_block_device_model () {\n  device=\"${1}\"\n\n  if [[ \"${device}\" == nvme* ]]; then\n    vendor=$(cat \"/sys/block/${device}/device/device/vendor\")\n  else\n    vendor=$(cat \"/sys/block/${device}/device/vendor\")\n  fi\n  model=$(cat \"/sys/block/${device}/device/model\")\n\n  # We echo in a subshell without quotes to strip surrounding whitespace\n  echo \"$(echo $vendor) $(echo $model)\"\n}\n\n# Returns the path of the first partition of a block device\nget_first_partition_path () {\n  device_path=\"${1}\"\n  if [[ \"${device_path}\" == /dev/nvme* ]]; then\n    echo \"${device_path}p1\"\n  else\n    echo \"${device_path}1\"\n  fi\n}\n\n# Check if a block device contains partition layout that looks like\n# an umbrelOS install\nis_device_umbrelos_install () {\n  device_path=\"${1}\"\n\n  # Having a 7th partition with the label \"data\" is a good indicator\n  # this is an umbrelOS install\n  if blkid | grep \"${device_path}.*7: \" | grep --silent 'LABEL=\"data\"'\n  then\n    echo \"true\"\n  else\n    echo \"false\"\n  fi\n}\n\nis_partition_ext4 () {\n  partition_path=\"${1}\"\n  # We need to run sync here to make sure the filesystem is reflecting the\n  # the latest changes in /dev/*\n  sync\n  blkid -o value -s TYPE \"${partition_path}\" | grep --quiet '^ext4$'\n}\n\n# Wipes a block device and reformats it with a single EXT4 partition\nformat_block_device () {\n  device=\"${1}\"\n  device_path=\"/dev/${device}\"\n  partition_path=$(get_first_partition_path \"${device_path}\")\n  wipefs -a \"${device_path}\"\n  parted --script \"${device_path}\" mklabel gpt\n  parted --script \"${device_path}\" mkpart primary ext4 0% 100%\n  # We need to run sync here to make sure the filesystem is reflecting the\n  # the latest changes in /dev/*\n  sync\n  mkfs.ext4 -F -L umbrel \"${partition_path}\"\n}\n\n# Mounts the device given in the first argument at $MOUNT_POINT\nmount_partition () {\n  partition_path=\"${1}\"\n  mkdir -p \"${MOUNT_POINT}\"\n  mount \"${partition_path}\" \"${MOUNT_POINT}\"\n}\n\n# Unmounts $MOUNT_POINT\nunmount_partition () {\n  umount \"${MOUNT_POINT}\"\n}\n\n# Formats and sets up a new device\nsetup_new_device () {\n  block_device=\"${1}\"\n  partition_path=\"${2}\"\n\n  echo \"Formatting device...\"\n  format_block_device $block_device\n\n  echo \"Mounting partition...\"\n  mount_partition \"${partition_path}\"\n\n  echo \"Creating Umbrel data directory on external storage...\"\n  mkdir -p \"${EXTERNAL_UMBREL_ROOT}\"\n\n  echo \"Touching dotfile on external storage so we recognise this device in the future...\"\n  touch \"${EXTERNAL_UMBREL_ROOT}\"/.umbrel\n}\n\n# Copy Docker data dir to external storage\ncopy_docker_to_external_storage () {\n  mkdir -p \"${EXTERNAL_DOCKER_DIR}\"\n  cp  --recursive \\\n      --archive \\\n      --no-target-directory \\\n      \"${DOCKER_DIR}\" \"${EXTERNAL_DOCKER_DIR}\"\n}\n\nmain () {\n  echo \"Running external storage mount script...\"\n  check_root\n  check_dependencies sed wipefs parted mount sync umount\n\n  if [[ \"$(running_off_sdcard)\" == \"false\" ]]\n  then\n    echo \"This script should only run when umbrelOS boots from an SD card, exiting...\"\n    exit\n  fi\n\n  no_of_block_devices=$(list_block_devices | wc -l)\n\n  retry_for_block_devices=1\n\n  while [[ $no_of_block_devices -lt 1 ]]; do\n\n    echo \"No block devices found\"\n    echo \"Waiting for 5 seconds before checking again...\"\n\n    sleep 5\n\n    no_of_block_devices=$(list_block_devices | wc -l)\n    retry_for_block_devices=$(( $retry_for_block_devices + 1 ))\n\n    if [[ $retry_for_block_devices -gt 20 ]]; then\n      echo \"No block devices found in 20 tries...\"\n      echo \"Exiting mount script without doing anything\"\n      exit 1\n    fi\n\n  done\n\n  if [[ $no_of_block_devices -gt 1 ]]; then\n    echo \"Multiple block devices found, only one drive is supported\"\n    echo \"Exiting mount script without doing anything\"\n    exit 1\n  fi\n\n  # Check if device is running with uas driver, if so balcklist it and reboot\n  echo \"Checking if we need to blacklist UAS\"\n  blacklist_uas_output=$(umbreld blacklist-uas || true)\n  # Check output includes text \"mount-script-halt\" which means we are rebooting and should not mount\n  if [[ \"${blacklist_uas_output}\" == *\"mount-script-halt\"* ]]; then\n    echo \"UAS was blacklisted and device is rebooting, exiting\"\n    return\n  fi\n\n  # At this point we know there is only one block device attached\n  block_device=$(list_block_devices)\n  block_device_path=\"/dev/${block_device}\"\n  partition_path=$(get_first_partition_path \"${block_device_path}\")\n  block_device_model=$(get_block_device_model $block_device)\n  echo \"Found device \\\"${block_device_model}\\\"\"\n\n  echo \"Checking if the device contains an umbrelOS install...\"\n  if [[ $(is_device_umbrelos_install \"${block_device_path}\") == \"true\" ]]\n  then\n    echo \"Yes, it looks like this device is an umbrelOS install not a data drive, refusing to wipe it and exiting...\"\n    exit 1\n  fi\n  echo \"No, it's not\"\n\n  echo \"Checking if the device is ext4...\"\n\n  if is_partition_ext4 \"${partition_path}\" ; then\n    echo \"Yes, it is ext4\"\n\n    echo \"Checking filesystem for corruption...\"\n    # Swallow non-zero exit because successful recovery returns 1 and\n    # we still want to attempt a best effort boot on a failed recovery.\n    fsck.ext4 -y \"${partition_path}\" || true\n\n    echo \"Mounting partition...\"\n    mount_partition \"${partition_path}\" || {\n      echo \"Error mounting partition\"\n      exit 1\n    }\n\n    echo \"Checking if device contains an Umbrel install...\"\n\n    if [[ -f \"${EXTERNAL_UMBREL_ROOT}\"/.umbrel ]]; then\n      echo \"Yes, it contains an Umbrel install\"\n    else\n      echo \"No, it doesn't contain an Umbrel install\"\n      echo \"Unmounting partition...\"\n      unmount_partition\n      setup_new_device $block_device $partition_path\n    fi\n\n  else\n    echo \"No, it's not ext4\"\n    setup_new_device $block_device $partition_path\n  fi\n\n  if [[ ! -d \"${EXTERNAL_DOCKER_DIR}\" ]]; then\n    echo \"Copying Docker data directory to external storage...\"\n    copy_docker_to_external_storage\n  fi\n\n  echo \"Bind mounting external storage over local Umbrel installation...\"\n  mkdir -p \"${UMBREL_ROOT}\" # Create the directory if it doesn't exist\n  mount --bind \"${EXTERNAL_UMBREL_ROOT}\" \"${UMBREL_ROOT}\"\n\n  echo \"Bind mounting external storage over local Docker data dir...\"\n  mount --bind \"${EXTERNAL_DOCKER_DIR}\" \"${DOCKER_DIR}\"\n\n  echo \"Bind mounting external storage to ${SWAP_DIR}\"\n  mkdir -p \"${MOUNT_POINT}/swap\" \"${SWAP_DIR}\"\n  mount --bind \"${MOUNT_POINT}/swap\" \"${SWAP_DIR}\"\n\n  echo \"Bind mounting SD card root at /sd-card...\"\n  [[ ! -d \"/sd-root\" ]] && mkdir -p \"/sd-root\"\n  mount --bind \"/\" \"/sd-root\"\n\n  echo \"Checking Umbrel root is now on external storage...\"\n  sync\n  sleep 1\n  df -h \"${UMBREL_ROOT}\" | grep --quiet '/dev/sd\\|/dev/nvme'\n\n  echo \"Checking ${DOCKER_DIR} is now on external storage...\"\n  df -h \"${DOCKER_DIR}\" | grep --quiet '/dev/sd\\|/dev/nvme'\n\n  echo \"Checking ${SWAP_DIR} is now on external storage...\"\n  df -h \"${SWAP_DIR}\" | grep --quiet '/dev/sd\\|/dev/nvme'\n\n  echo \"Setting up swapfile\"\n  rm \"${SWAP_FILE}\" || true\n  fallocate -l 4G \"${SWAP_FILE}\"\n  chmod 600 \"${SWAP_FILE}\"\n  mkswap \"${SWAP_FILE}\"\n  swapon \"${SWAP_FILE}\"\n\n  echo \"Checking SD Card root is bind mounted at /sd-root...\"\n  df -h \"/sd-root\" | grep --quiet \"overlay\"\n\n  echo \"Mount script completed successfully!\"\n}\n\nmain\n"
  },
  {
    "path": "packages/os/overlay-common/etc/NetworkManager/NetworkManager.conf",
    "content": "[main]\nplugins=ifupdown,keyfile\n\n[ifupdown]\nmanaged=false\n"
  },
  {
    "path": "packages/os/overlay-common/etc/NetworkManager/conf.d/10-cloudflaredns.conf",
    "content": "# This is important, we use Cloudflare for DNS because some users have routers that provide\r\n# unreliable DNS that results in Docker errors when pulling like:\r\n# Get \"https://registry-1.docker.io/v2/tailscale/tailscale/manifests/sha256:d488853664499d792b359ea8c18f9a918b92e805b403733fe1c9aac9006ac8c1\": dial tcp [2600:1f18:2148:bc01:571f:e759:a87a:2961]:443: connect: network is unreachable\r\n[global-dns-domain-*]\r\nservers=1.1.1.1,1.0.0.1\r\n"
  },
  {
    "path": "packages/os/overlay-common/etc/acpi/events/power-button",
    "content": "event=button/power.*PBTN\naction=systemd-cat -t umbrel-power-button /etc/acpi/power-button.sh &"
  },
  {
    "path": "packages/os/overlay-common/etc/acpi/power-button.sh",
    "content": "#!/bin/bash\n\n# Configuration\nRECOVERY_SEQUENCE_COUNT=10\nLISTEN_TIME=1\nSTATE_FILE=\"/tmp/power_button_state\"\nPASSWORD_RESET_FLAG=\"/tmp/password_reset_flag\"\n\nreset_umbrel_password() {\n  yaml_file=\"/home/umbrel/umbrel/umbrel.yaml\"\n\n  echo \"umbrel:umbrel\" | chpasswd\n\n  if ! [ -f \"$yaml_file\" ]; then\n    echo \"Error: File not found.\"\n    return\n  fi\n\n  JSON=$(cat \"$user_json\" 2>/dev/null)\n\n  if ! yq eval . \"${yaml_file}\" >/dev/null 2>&1; then\n    echo \"Error: Invalid YAML file.\"\n    return\n  fi\n\n  if ! yq -e .user.hashedPassword \"${yaml_file}\" >/dev/null 2>&1 <<<\"$JSON\"; then\n    echo \"Error: Password property not found in the YAML file.\"\n    return\n  fi\n\n  yq eval '.user.hashedPassword = \"$2b$10$PDwSSnPmfCQJh3x72KjKs.Nb7NgU62gftuic991GkRyFMcIowpTv2\"' -i \"${yaml_file}\"\n  yq eval 'del(.user.totpUri)' -i \"${yaml_file}\"\n\n  echo \"Password updated successfully.\"\n}\n\n# Function to handle power button presses\nhandle_press() {\n  # Append the current timestamp to the state file\n  echo \"$(date +%s)\" >> \"${STATE_FILE}\"\n\n  # Check if the last n button presses happened recently\n  num_entries=$(wc -l < \"${STATE_FILE}\")\n  last_timestamps=$(tail -n \"${RECOVERY_SEQUENCE_COUNT}\" \"${STATE_FILE}\")\n  first_timestamp=$(echo \"${last_timestamps}\" | head -n 1)\n  last_timestamp=$(echo \"${last_timestamps}\" | tail -n 1)\n  total_allowed_duration=$((LISTEN_TIME * RECOVERY_SEQUENCE_COUNT))\n  if [[ \"${num_entries}\" -ge \"${RECOVERY_SEQUENCE_COUNT}\" ]] && [[ $((last_timestamp - first_timestamp)) -lt \"${total_allowed_duration}\" ]]; then\n    echo \"Power button pressed! Recovery sequence detected. Initiating factory reset.\"\n    # This flag indicates that a password reset has been initiated so the previous button presses\n    # don't also initiate a reset\n    touch \"${PASSWORD_RESET_FLAG}\"\n    reset_umbrel_password\n\n    # Remove the password reset flag after all previous button press event handlers have died\n    # so future resets will work.\n    sleep \"${LISTEN_TIME}\"\n    rm \"${PASSWORD_RESET_FLAG}\"\n    exit\n  fi\n\n  # Listen for additional button presses\n  echo \"Power button pressed! Listening for additional button presses for ${LISTEN_TIME} seconds...\"\n  sleep \"${LISTEN_TIME}\"\n\n  # Read the latest timestamp\n  latest_timestamp=$(tail -n 1 \"${STATE_FILE}\")\n\n  new_num_entries=$(wc -l < \"${STATE_FILE}\")\n  if [[ \"${num_entries}\" != \"${new_num_entries}\" ]] || [[ -e \"${PASSWORD_RESET_FLAG}\" ]]; then\n    # Another button press has been registered or a recovery has just been initiated.\n    # Exit this handler.\n    exit\n  fi\n}\n\n# Check if we should do any special handling and exit early if we do.\nhandle_press\n\n# If we get here no special handling is needed and we should trigger a shutdown.\necho \"Nothing else happened. Shutting down the system.\"\npoweroff"
  },
  {
    "path": "packages/os/overlay-common/etc/fstab",
    "content": "# <device>                                  <dir>                        <type>      <options>   <dump>  <fsck>\n/                                           /mnt/root                    none        bind        0       0\n/data/umbrel-os/var/log                     /var/log                     none        bind        0       0\n/data/umbrel-os/var/lib/docker              /var/lib/docker              none        bind        0       0\n/data/umbrel-os/home                        /home                        none        bind        0       0\n/data/umbrel-os/var/lib/systemd/timesync    /var/lib/systemd/timesync    none        bind        0       0\n/data/umbrel-os/kopia                       /kopia                       none        bind        0       0"
  },
  {
    "path": "packages/os/overlay-common/etc/hostname",
    "content": "umbrel\n"
  },
  {
    "path": "packages/os/overlay-common/etc/hosts",
    "content": "127.0.0.1       umbrel\n127.0.0.1       localhost\n"
  },
  {
    "path": "packages/os/overlay-common/etc/issue",
    "content": ""
  },
  {
    "path": "packages/os/overlay-common/etc/locale.conf",
    "content": "# Fixes locale warnings when logging in via SSH\nLANG=C.UTF-8\n"
  },
  {
    "path": "packages/os/overlay-common/etc/motd",
    "content": "\n\n              ,;###GGGGGGGGGGl#Sp\n           ,##GGGlW\"\"^'  '`\"\"%GGGG#S,\n         ,#GGG\"                  \"lGG#o\n        #GGl^                      '$GG#\n      ,#GGb                          \\GGG,\n      lGG\"                            \"GGG\n     #GGGlGGGl##p,,p##lGGl##p,,p###ll##GGGG\n    !GGGlW\"\"\"*GGGGGGG#\"\"\"\"WlGGGGG#W\"\"*WGGGGS\n     \"\"          \"^          '\"          \"\"\n\n- Warning ---------------------------------------------------------------------\n| Terminal access is only enabled for debugging purposes. Any modifications   |\n| made to the umbrelOS system will not be persisted between software updates. |\n| For use-cases where you want to run custom software in a Linux environment, |\n| consider using the Portainer app available in the Umbrel App Store.         |\n-------------------------------------------------------------------------------\n\n"
  },
  {
    "path": "packages/os/overlay-common/etc/sudoers.d/umbrel",
    "content": "# Remove the silly outdated warning from sudo the first time it's used:\n#\n# We trust you have received the usual lecture from the local System\n# Administrator. It usually boils down to these three things:\n#\n#     #1) Respect the privacy of others.\n#     #2) Think before you type.\n#     #3) With great power comes great responsibility.\n\nDefaults        lecture_file = /etc/sudoers.lecture"
  },
  {
    "path": "packages/os/overlay-common/etc/sudoers.lecture",
    "content": ""
  },
  {
    "path": "packages/os/overlay-common/etc/sysctl.d/99-vm-zram-parameters.conf",
    "content": "# Optimise swap for zram\n\n# Our swap is super fast in-memory via zram so we can afford to be more aggressive with swappiness.\n# https://docs.kernel.org/admin-guide/sysctl/vm.html#swappiness\nvm.swappiness = 180\n\n# With zstd zram the decompression adds enough overhead that there's essentially zero throughput gain\n# from readahead. Use vm.page-cluster=0.\n# (This is default on ChromeOS and Android)\n# https://old.reddit.com/r/Fedora/comments/mzun99/new_zram_tuning_benchmarks/\n# https://issues.chromium.org/issues/41028506#comment17\n# https://cs.android.com/search?q=page-cluster&start=21\nvm.page-cluster = 0\n\n# Watermark boosting pre-emptively frees up memory to prevent a page allocation miss\n# Broken feature that causes page thrashing, disabled by setting to \"0\"\n# (This is default on Ubuntu)\n# https://groups.google.com/g/linux.debian.user/c/YcDYu-jM-to\n# https://lists.ubuntu.com/archives/kernel-team/2020-March/108587.html\nvm.watermark_boost_factor = 0\n\n# Controls the aggressiveness of kswapd. It defines the amount of memory left in a node/system before\n# kswapd is woken up and how much memory needs to be free before kswapd goes back to sleep.\n# PopOS did a lot of testing with users and arrived at this value to optimise for zram swap.\n# https://wiki.archlinux.org/title/Zram#Optimizing_swap_on_zram\n# https://www.reddit.com/r/pop_os/comments/104kbs4/zram_now_enabled_by_default_in_pop/\n# https://github.com/pop-os/default-settings/pull/163/files#diff-8e1248486dec1681fa98c577efaaf729cb8655c9bb18ae19cc68f5a1baa8ab6bR3\nvm.watermark_scale_factor = 125\n"
  },
  {
    "path": "packages/os/overlay-common/etc/systemd/logind.conf.d/lid-switch.conf",
    "content": "# Ignore lid switch events to allow users running umbrelOS on laptops to keep the device on when the lid is closed.\n# The following settings result in ignoring lid switch events when the device is on battery power, when the device is on external power, and when the device is docked or connected to more than one display.\n\n[Login]\nHandleLidSwitch=ignore\n# HandleLidSwitchExternalPower=ignore (optional, HandleLidSwitch is used if not specified)\n# HandleLidSwitchDocked=ignore (default is set to ignore)"
  },
  {
    "path": "packages/os/overlay-common/etc/systemd/logind.conf.d/power-button.conf",
    "content": "# We want logind to ignore the power button press events.\n# We will register custom acpi event handlers to handle this\n# ourselves.\n\n[Login]\nHandlePowerKey=ignore"
  },
  {
    "path": "packages/os/overlay-common/etc/systemd/system/umbrel-dns-sync.service",
    "content": "[Unit]\r\nDescription=Synchronize DNS configuration before starting NetworkManager\r\nBefore=NetworkManager.service\r\n\r\n[Service]\r\nExecStart=bash /opt/umbrel-dns-sync/umbrel-dns-sync\r\nType=oneshot\r\n\r\n[Install]\r\nWantedBy=multi-user.target"
  },
  {
    "path": "packages/os/overlay-common/etc/systemd/system/umbrel-ssh-host-key-hydration.service",
    "content": "[Unit]\nDescription=Hydrate SSH Host Keys\nBefore=ssh.service\n\n[Service]\nType=oneshot\nExecStart=/opt/umbrel-ssh-host-key-hydration/umbrel-ssh-host-key-hydration\n\n[Install]\nWantedBy=multi-user.target"
  },
  {
    "path": "packages/os/overlay-common/etc/systemd/system/umbrel-tty-message.service",
    "content": "[Unit]\nDescription=Display Umbrel access information on TTY\nAfter=umbrel.service\n\n[Service]\nExecStart=/opt/umbrel-tty-message/umbrel-tty-message\nType=oneshot\n\n[Install]\nWantedBy=multi-user.target"
  },
  {
    "path": "packages/os/overlay-common/etc/systemd/system/umbrel.service",
    "content": "[Unit]\nDescription=Umbrel daemon\nAfter=network-online.target docker.service\n\n[Service]\nTimeoutStopSec=15min\nExecStart=umbreld --data-directory=/home/umbrel/umbrel\nRestart=always\n# This prevents us hitting restart rate limits and ensures we keep restarting\n# indefinitely.\nStartLimitInterval=0\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "packages/os/overlay-common/etc/systemd/timesyncd.conf.d/cloudflare.conf",
    "content": "# We default to Cloudflare for NTP because some users have issues\n# connecting to the default Debian ntp pool. If Cloudflare fails\n# the Debian ntp pool is still used as a fallback.\n\n[Time]\nNTP=time.cloudflare.com"
  },
  {
    "path": "packages/os/overlay-common/etc/systemd/zram-generator.conf",
    "content": "# We create a virtual in-memory swap device that is 75% of the RAM and compress it\n# with zstd which generally gives a ~3:1 compression ratio.\n#\n# In a 16GB RAM device this gives us 24GB of usable memory before hitting OOM errors.\n# 16-((16*0.75)/3)+(16*0.75) = 16GB RAM - 4GB compressed swap device in RAM + 12GB usable swap = 24GB\n#\n# The zram device is allocated ondemand so the full 16GB of RAM is still usable up until swap is actually needed.\n[zram0]\nzram-size=ram * 0.75\ncompression-algorithm=zstd"
  },
  {
    "path": "packages/os/overlay-common/opt/umbrel-data/umbrel-data-mount",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\nCONFIG_PARTITION=${CONFIG_PARTITION:-\"/run/rugix/mounts/config\"}\nCONFIG_FILE=\"$CONFIG_PARTITION/umbrel.yaml\"\n\nMOUNT_POINT=\"$1\"\nDEFAULT_PARTITION=\"${2:-}\"\n\nwait_for_devices() {\n    local devices=(\"$@\")\n    echo \">>> Waiting for devices to appear: ${devices[*]}\"\n    # 50 checks with 0.1s sleep = 5 second timeout\n    for i in {1..50}; do\n        local all_devices_present=\"true\"\n        for dev in \"${devices[@]}\"; do\n            if [ ! -b \"$dev\" ]; then\n                all_devices_present=\"false\"\n                break\n            fi\n        done\n        if [ \"$all_devices_present\" = \"true\" ]; then\n            break\n        fi\n        sleep 0.1\n    done\n}\n\n# Handle migration from storage to failsafe mode\n# This does the minimum at boot: final sync and pool rename\n# The rest of the migration is completed by umbreld after boot\nhandle_failsafe_transition() {\n    local pool_name=\"$1\"\n    local migration_pool=\"${pool_name}-migration\"\n    local previous_pool=\"${pool_name}-previous-migration\"\n\n    echo \">>> Migration pool detected, performing final sync\"\n\n    # Import both pools\n    echo \">>> Importing ${pool_name} pool\"\n    zpool import -o cachefile=none -f \"$pool_name\"\n\n    echo \">>> Importing ${migration_pool} pool\"\n    zpool import -o cachefile=none -f \"$migration_pool\"\n\n    # Create final snapshot and sync incrementally\n    # Using --raw to preserve encryption (sends encrypted blocks without needing key loaded)\n    echo \">>> Creating final snapshot\"\n    zfs snapshot -r \"${pool_name}@migration-final\"\n\n    echo \">>> Sending incremental changes to migration pool\"\n    zfs send --raw --replicate --large-block --compressed -i @migration \"${pool_name}@migration-final\" | zfs receive -Fu \"$migration_pool\"\n\n    # Rename pools\n    # umbrelos > umbrelos-previous-migration\n    # umbrelos-migration > umbrelos\n    # If anything before this goes wrong, we boot back into the old pool.\n    # After the first rename succeeds, if the second rename fails, we rollback\n    # by renaming umbrelos-previous-migration back to umbrelos.\n    echo \">>> Renaming pools for migration\"\n    zpool export \"$pool_name\"\n    zpool export \"$migration_pool\"\n    zpool import -o cachefile=none \"$pool_name\" \"$previous_pool\"\n    if zpool import -o cachefile=none \"$migration_pool\" \"$pool_name\"; then\n        echo \">>> Pool rename complete, umbreld will finish migration after boot\"\n    else\n        # TODO: Should we signal some error in the raid config here?\n        echo \">>> ERROR: Failed to rename migration pool, rolling back\"\n        zpool export \"$previous_pool\"\n        zpool import -o cachefile=none \"$previous_pool\" \"$pool_name\"\n        echo \">>> Rollback complete, migration aborted\"\n    fi\n\n    # Export the pool so we can import it again in the usual mount process\n    zpool export \"$pool_name\"\n}\n\n# Parse YAML config to get RAID settings if config file exists\nPOOL_NAME=\"\"\nDEVICES=()\nRAID_STATE=\"\"\nif [ -f \"$CONFIG_FILE\" ]; then\n    POOL_NAME=$(yq '.raid.poolName' \"$CONFIG_FILE\" 2>/dev/null || true)\n    mapfile -t DEVICES < <(yq '.raid.devices[]' \"$CONFIG_FILE\" 2>/dev/null || true)\n    RAID_STATE=$(yq '.raid.state' \"$CONFIG_FILE\" 2>/dev/null || true)\nfi\n\nif [ -n \"$POOL_NAME\" ] && [ ${#DEVICES[@]} -gt 0 ]; then\n    echo \">>> Found RAID config for pool '${POOL_NAME}' with ${#DEVICES[@]} devices\"\n\n    # Load zfs\n    modprobe zfs\n\n    # Attempt to wait for devices\n    # Continue if timeout is reached to allow mounting array with missing devices\n    wait_for_devices \"${DEVICES[@]}\"\n\n    # Check if we're in the middle of a failsafe transition\n    if [ \"$RAID_STATE\" = \"transitioning-to-failsafe\" ]; then\n        handle_failsafe_transition \"$POOL_NAME\" || true\n    fi\n\n    # Import the pool normally\n    #  -o cachefile=none means we don't read/write the cache files since\n    # we only have the read only image at this point.\n    #  -f force import if it thinks the pool is active elsewhere. This often\n    # happens after ota updates where it thinks we're on a new machine.\n    echo \">>> Importing ${POOL_NAME} pool\"\n    zpool import -o cachefile=none -f \"$POOL_NAME\"\n\n    # Load the encryption key for the data dataset\n    # We use a hardcoded encryption password for now. This obviously doesn't provide any security.\n\t# However initialising encryption now means we can enable full disk encryption in the future\n\t# by simply updating the password to something secure without requiring an entire backup and restore\n\t# of all data into a new encrypted dataset.\n    echo \">>> Loading encryption key for ${POOL_NAME}/data\"\n    echo \"umbrelumbrel\" | zfs load-key \"${POOL_NAME}/data\" 2>/dev/null\n\n    echo \">>> Mounting RAID array to '$MOUNT_POINT'\"\n    mkdir -p \"$MOUNT_POINT\"\n    mount -t zfs \"${POOL_NAME}/data\" \"$MOUNT_POINT\"\nelse\n    echo \">>> No RAID config found, falling back to default data partition\"\n\n    if [ -z \"$DEFAULT_PARTITION\" ]; then\n        echo \"ERROR: No default partition provided\"\n        exit 1\n    fi\n\n    echo \">>> Running fsck on '$DEFAULT_PARTITION'\"\n    fsck -p \"$DEFAULT_PARTITION\"\n\n    echo \">>> Mounting '$DEFAULT_PARTITION' to '$MOUNT_POINT'\"\n    mkdir -p \"$MOUNT_POINT\"\n    mount \"$DEFAULT_PARTITION\" \"$MOUNT_POINT\"\nfi\n"
  },
  {
    "path": "packages/os/overlay-common/opt/umbrel-dns-sync/umbrel-dns-sync",
    "content": "#!/bin/bash\n\nUMBREL_YAML=/home/umbrel/umbrel/umbrel.yaml\n\nCLOUDFLARE_CONF=/etc/NetworkManager/conf.d/10-cloudflaredns.conf\nCLOUDFLARE_CONF_DISABLED=/etc/NetworkManager/conf.d/10-cloudflaredns.conf.disabled\n\n# Use CloudFlare DNS unless the setting is explicitly set to `false`\nEXTERNAL_DNS=$(yq eval \".settings.externalDns != false\" \"$UMBREL_YAML\" 2>/dev/null || echo \"true\")\n\nif [[ \"$EXTERNAL_DNS\" == \"false\" ]]; then\n    if [[ -f \"$CLOUDFLARE_CONF\" ]]; then\n        mv -f \"$CLOUDFLARE_CONF\" \"$CLOUDFLARE_CONF_DISABLED\" || {\n            echo \"Failed to move $CLOUDFLARE_CONF to $CLOUDFLARE_CONF_DISABLED\"\n            exit 1\n        }\n    fi\nelse\n    if [[ ! -f \"$CLOUDFLARE_CONF\" ]]; then\n        mv -f \"$CLOUDFLARE_CONF_DISABLED\" \"$CLOUDFLARE_CONF\" || {\n            echo \"Failed to move $CLOUDFLARE_CONF_DISABLED to $CLOUDFLARE_CONF\"\n            exit 1\n        }\n    fi\nfi\n"
  },
  {
    "path": "packages/os/overlay-common/opt/umbrel-ssh-host-key-hydration/umbrel-ssh-host-key-hydration",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\nSSH_STATE_DIR=${SSH_STATE_DIR:-\"/data/ssh\"}\n\nif [ ! -f \"${SSH_STATE_DIR}\"/ssh_host_rsa_key ]; then\n    rm -f /etc/ssh/ssh_host_*_key*\n    ssh-keygen -A\n\n    # Copy the keys to the data partition.\n    mkdir -p \"${SSH_STATE_DIR}\"\n    cp /etc/ssh/ssh_host_*_key* \"${SSH_STATE_DIR}\"\nfi\n\n# Restore the keys from the data partition.\ncp \"${SSH_STATE_DIR}\"/ssh_host_*_key* /etc/ssh/"
  },
  {
    "path": "packages/os/overlay-common/opt/umbrel-tty-message/umbrel-tty-message",
    "content": "#!/bin/bash\n\nget_addresses() {\n    # Get all active wifi and ethernet interfaces\n    local active_interfaces=$(nmcli --terse --fields TYPE,DEVICE con show --active | grep 'ethernet\\|wireless' |  cut -d ':' -f 2)\n    for interface in $active_interfaces; do\n        # Get the interface IP\n        local ip=$(nmcli --terse --fields IP4.ADDRESS dev show \"${interface}\" | grep 'IP4.ADDRESS' | cut -d':' -f2 | cut -d'/' -f1)\n\n        # Return the IP\n        echo \"${ip}\"\n\n        # Return the avahi address\n        avahi-resolve -a \"${ip}\" | cut -f 2\n    done\n}\n\n# Wait for addresses to be assigned\nwhile true\ndo\n    addresses=$(get_addresses | sort -r | uniq)\n    [[ \"${addresses}\" != \"\" ]] && break\n    sleep 1\ndone\n\n# Format addresses for printing\nformatted_addresses=$(echo \"${addresses}\" | sed 's/^/  http:\\/\\//')\n\n# Clear TTY\necho -e \"\\033c\" > /dev/tty1\n\n# Write TTY message\necho -n \"Your Umbrel is now accessible at:\n${formatted_addresses}\n\numbrel login: \" > /dev/tty1"
  },
  {
    "path": "packages/os/overlay-common/umbrelOS",
    "content": ""
  },
  {
    "path": "packages/os/package.json",
    "content": "{\n  \"scripts\": {\n    \"build\": \"./build.sh\",\n    \"build:amd64\": \"SKIP_ARM64=true npm run build\",\n    \"build:amd64:rugix\": \"SKIP_AMD64_MENDER=true SKIP_MENDER_ARTIFACTS=true npm run build:amd64\",\n    \"build:amd64:usb-installer:prepare\": \"xz --keep --force --threads=0 build/umbrelos-amd64.img\",\n    \"build:amd64:usb-installer\": \"cd usb-installer && ./run.sh\",\n    \"build:arm64\": \"SKIP_AMD64=true npm run build\",\n    \"build:pi5\": \"SKIP_AMD64=true SKIP_PI4=true npm run build\",\n    \"vm\": \"./vm.sh\"\n  },\n  \"devDependencies\": {\n    \"opentimestamps\": \"^0.4.9\"\n  }\n}\n"
  },
  {
    "path": "packages/os/rugix/.gitignore",
    "content": "/.rugix\n/build\n"
  },
  {
    "path": "packages/os/rugix/fix-umbrelos-pi-mbr.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nLAST_USED_SECTOR=$(sfdisk -l /data/build/umbrelos-pi-mbr/system.img -o end | tail -n1)\nTARGET_SIZE=$(( (LAST_USED_SECTOR + 1) * 512 ))\ntruncate -s ${TARGET_SIZE} \"/data/build/umbrelos-pi-mbr/system.img\""
  },
  {
    "path": "packages/os/rugix/layers/umbrelos-amd64.toml",
    "content": "parent = \"umbrelos-root-amd64\"\n\nrecipes = [\n    # Prepare umbrelOS base image for Rugix.\n    \"umbrelos-prepare\",\n    # Copy umbrelOS boot files to boot partition.\n    \"umbrelos-boot\",\n    # Install and configure Rugix.\n    \"setup-rugix\",\n    # Fix `/etc/hostname` and `/etc/hosts`.\n    \"fix-overlay\",\n]\n\n[parameters.\"core/rugix-ctrl\"]\nuse_musl = false\n"
  },
  {
    "path": "packages/os/rugix/layers/umbrelos-mender-amd64.toml",
    "content": "parent = \"umbrelos-root-amd64\"\n\nrecipes = [\n    # Prepare umbrelOS base image for Rugix.\n    \"umbrelos-prepare\",\n    # Copy umbrelOS boot files to boot partition.\n    \"umbrelos-boot\",\n    # Install and configure Rugix.\n    \"setup-rugix\",\n    # Configure Rugix for compatibility with Mender-based legacy systems.\n    \"setup-rugix-mender\",\n    # Fix `/etc/hostname` and `/etc/hosts`.\n    \"fix-overlay\",\n]\n\n[parameters.\"core/rugix-ctrl\"]\nuse_musl = false\n"
  },
  {
    "path": "packages/os/rugix/layers/umbrelos-pi.toml",
    "content": "parent = \"umbrelos-root-arm64\"\n\nrecipes = [\n    # Prepare umbrelOS base image for Rugix.\n    \"umbrelos-prepare\",\n    # Copy umbrelOS boot files to boot partition.\n    \"umbrelos-boot\",\n    # Install and configure Rugix.\n    \"setup-rugix\",\n    # Fix `/etc/hostname` and `/etc/hosts`.\n    \"fix-overlay\",\n]\n\n[parameters.\"core/rugix-ctrl\"]\nuse_musl = false\n"
  },
  {
    "path": "packages/os/rugix/layers/umbrelos-pi4.toml",
    "content": "parent = \"umbrelos-pi\"\n\nrecipes = [\n    # Include the firmware update for Raspberry Pi 4.\n    \"core/rpi-include-firmware\",\n]\n\n[parameters.\"core/rpi-include-firmware\"]\nmodel = \"pi4\"\n"
  },
  {
    "path": "packages/os/rugix/layers/umbrelos-root-amd64.toml",
    "content": "url=\"file:///build/umbrelos-root/umbrelos-root-amd64.tar\"\n"
  },
  {
    "path": "packages/os/rugix/layers/umbrelos-root-arm64.toml",
    "content": "url=\"file:///build/umbrelos-root/umbrelos-root-arm64.tar\"\n"
  },
  {
    "path": "packages/os/rugix/recipes/fix-overlay/files/.gitignore",
    "content": "hostname\nhosts"
  },
  {
    "path": "packages/os/rugix/recipes/fix-overlay/files/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/os/rugix/recipes/fix-overlay/recipe.toml",
    "content": "description = \"fix `/etc/hostname` and `/etc/hosts`\"\n"
  },
  {
    "path": "packages/os/rugix/recipes/fix-overlay/steps/00-install.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\ninstall -m 644 \"${RECIPE_DIR}/files/hostname\" \"/etc/\"\ninstall -m 644 \"${RECIPE_DIR}/files/hosts\" \"/etc/\""
  },
  {
    "path": "packages/os/rugix/recipes/setup-rugix/files/bootstrapping-amd64.toml",
    "content": "[layout]\ntype = \"gpt\"\npartitions = [\n    { name = \"EFI\", size = \"256M\", type = \"C12A7328-F81F-11D2-BA4B-00A0C93EC93B\" },\n    { name = \"boot-a\", size = \"512MiB\" },\n    { name = \"boot-b\", size = \"512MiB\" },\n    { name = \"system-a\", size = \"10GiB\" },\n    { name = \"system-b\", size = \"10GiB\" },\n    # For the data partition, we reserve 0.5% of blocks (-m 0.5) for root-only writes to prevent\n    # full-disk deadlocks and keep logs/Docker alive if the FS hits 100%.\n    # The ext4 default of 5% is excessive on multi-TB disks (10s to 100s of GB wasted).\n    # At 0.5%, we reserve around 2.5GB for 0.5TB, 5GB for 1TB, 10GB for 2TB, and 20GB for 4TB,\n    # which is a small cost that gives us enough headroom for safe recovery.\n    { name = \"data\", filesystem = { type = \"ext4\", label = \"data\", additional-options = [\"-m\", \"0.5\"] } },\n]\n"
  },
  {
    "path": "packages/os/rugix/recipes/setup-rugix/files/bootstrapping-arm64.toml",
    "content": "[layout]\ntype = \"gpt\"\npartitions = [\n    { name = \"EFI\", size = \"256M\", type = \"C12A7328-F81F-11D2-BA4B-00A0C93EC93B\" },\n    { name = \"boot-a\", size = \"128MiB\", type = \"EBD0A0A2-B9E5-4433-87C0-68B6B72699C7\" },\n    { name = \"boot-b\", size = \"128MiB\", type = \"EBD0A0A2-B9E5-4433-87C0-68B6B72699C7\" },\n    { name = \"system-a\", size = \"5GiB\" },\n    { name = \"system-b\", size = \"5GiB\" },\n    # For the data partition, we reserve 0.5% of blocks (-m 0.5) for root-only writes to prevent\n    # full-disk deadlocks and keep logs/Docker alive if the FS hits 100%.\n    # The ext4 default of 5% is excessive on multi-TB disks (10s to 100s of GB wasted).\n    # At 0.5%, we reserve around 2.5GB for 0.5TB, 5GB for 1TB, 10GB for 2TB, and 20GB for 4TB,\n    # which is a small cost that gives us enough headroom for safe recovery.\n    { name = \"data\", filesystem = { type = \"ext4\", label = \"data\", additional-options = [\"-m\", \"0.5\"] } },\n]\n"
  },
  {
    "path": "packages/os/rugix/recipes/setup-rugix/files/hooks/state-reset/prepare.sh",
    "content": "#!/bin/bash\n\n# Rugix `state-reset/prepare` hook to reset the RAID and main disk data partition. By\n# default Rugix resets the state on the active data partition only. If the system is\n# running from the RAID, then this does only reset the state on the RAID but not the\n# RAID config on the config partition itself. This script checks whether a RAID has\n# been configured and reformats the main disk data partition, and removes a RAID\n# configuration from the config partition if one is detected.\n\nset -euo pipefail\n\nCONFIG_PARTITION=${CONFIG_PARTITION:-\"/run/rugix/mounts/config\"}\nCONFIG_FILE=\"$CONFIG_PARTITION/umbrel.yaml\"\n\nif [ ! -f \"$CONFIG_FILE\" ]; then\n    echo \"[INFO] no config state file detected, nothing to do\"\n    exit 0\nfi\n\n# Parse YAML config to get devices array if config file exists\nDEVICES=()\nmapfile -t DEVICES < <(yq '.raid.devices[]' \"$CONFIG_FILE\" 2>/dev/null || true)\n\necho \">>> Removing RAID configuration from config partition\"\nif mountpoint -q \"$CONFIG_PARTITION\"; then\n    # We need to remove the write-protection on the config partition.\n    cleanup() {\n        mount -o remount,ro \"$CONFIG_PARTITION\"\n    }\n    trap cleanup EXIT\n    mount -o remount,rw \"$CONFIG_PARTITION\"\nfi\nrm -f \"$CONFIG_FILE\"\n\n# If no RAID configuration is detected, nothing to do.\nif [ ${#DEVICES[@]} -eq 0 ]; then\n    echo \"[INFO] no RAID configuration detected, nothing to do\"\n    exit 0\nfi\n\n# If we have a RAID configuration, we need to reformat the main disk data partition.\n\nSYSTEM_INFO=$(rugix-ctrl system info)\nBOOT_FLOW=$(echo \"$SYSTEM_INFO\" | jq -r \".boot.bootFlow\")\n\n# Determine the main disk data partition.\nif [ \"$BOOT_FLOW\" == \"mender-grub\" ]; then\n    # On Mender legacy devices, the data partition is the 4th partition on the main disk.\n    MAIN_DATA_PARTITION=$(rugix-ctrl utils resolve-partition 4 | jq -r \".device\" || true)\nelse\n    # On Rugix-native devices the main disk data partition is the last partition on the\n    # main disk, which is either the 7th (MBR) or the 6h (GPT) partition.\n    for partition in 7 6; do\n        MAIN_DATA_PARTITION=$(rugix-ctrl utils resolve-partition $partition 2>/dev/null | jq -r \".device\" || true)\n        if [ ! -z \"${MAIN_DATA_PARTITION}\" ]; then\n            break\n        fi\n    done\nfi\nif [ -z \"${MAIN_DATA_PARTITION}\" ]; then\n    echo \"[ERROR] unable to determine main data partition\"\n    exit 1\nfi\n\necho \"[INFO] found main disk data partition: '$MAIN_DATA_PARTITION'\"\n\n# Ensure that the main disk data partition has not been mounted.\nif [ ! -z $(lsblk -no MOUNTPOINT \"$MAIN_DATA_PARTITION\") ]; then\n    echo \"[ERROR] main disk data partition appears to be mounted\"\n    exit 1\nfi\n\n# At this point, we can either reformat the data partition or remove any state on it.\n# Reformatting gives us a clean slate, so let's do that.\n#\n# We use -m 0.5 to reserve 0.5% of blocks for root-only writes (matching bootstrapping config).\nmkfs.ext4 -F -m 0.5 -L data \"$MAIN_DATA_PARTITION\"\n"
  },
  {
    "path": "packages/os/rugix/recipes/setup-rugix/files/state-data.toml",
    "content": "[[persist]]\ndirectory = \"/data\"\n"
  },
  {
    "path": "packages/os/rugix/recipes/setup-rugix/files/system.toml",
    "content": "[data-partition]\n# This is safe to enable on all systems as it mounts the default\n# data partition if no external RAID has been configured.\nmount-script = \"/opt/umbrel-data/umbrel-data-mount\"\n"
  },
  {
    "path": "packages/os/rugix/recipes/setup-rugix/recipe.toml",
    "content": "description = \"setup Rugix for umbrelOS\"\n\ndependencies = [\"core/rugix-ctrl\"]\n"
  },
  {
    "path": "packages/os/rugix/recipes/setup-rugix/steps/00-install.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\napt-get install -y fdisk parted\n\ninstall -D -m 644 \\\n    \"${RECIPE_DIR}/files/bootstrapping-${RUGIX_ARCH}.toml\" \\\n    \"/etc/rugix/bootstrapping.toml\"\n\ninstall -D -m 644 \\\n    \"${RECIPE_DIR}/files/state-data.toml\" \\\n    \"/etc/rugix/state/data.toml\"\n\ninstall -D -m 644 \\\n    \"${RECIPE_DIR}/files/system.toml\" \\\n    \"/etc/rugix/system.toml\"\n\n# Install the factory reset hook.\ninstall -D -m 755 \\\n    \"${RECIPE_DIR}/files/hooks/state-reset/prepare.sh\" \\\n    \"/etc/rugix/hooks/state-reset/prepare/10-umbrel.sh\"\n    "
  },
  {
    "path": "packages/os/rugix/recipes/setup-rugix-mender/files/init",
    "content": "#!/bin/sh\n\nDATA_DIR=\"/run/rugix/mounts/data\"\n\n# If the data directory exists, Rugix Ctrl initialized the system and we can proceed with Systemd.\nif [ -d \"$DATA_DIR\" ]; then\n    echo \"Directory $DATA_DIR exists, starting 'systemd'...\"\n    exec /lib/systemd/systemd\nelse\n    echo \"Directory $DATA_DIR does not exist, starting 'rugix-ctrl'...\"\n    exec /usr/bin/rugix-ctrl\nfi"
  },
  {
    "path": "packages/os/rugix/recipes/setup-rugix-mender/files/migrate-state.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\n# We check whether the old `umbrel-os` directory still exists. If it does not, then the\n# migration has been completed, and we have nothing further to do.\nif [ ! -d \"/run/rugix/mounts/data/umbrel-os\" ]; then\n    echo \"State has already been migrated.\"\n    exit 0\nfi\n\n# We simply always copy `/data/ssh` here as it is small.\nif  [ ! -d \"/data/ssh\" ] && [ -d \"/run/rugix/mounts/data/ssh\" ]; then\n    rm -rf /data/ssh.tmp\n    cp -rp /run/rugix/mounts/data/ssh /data/ssh.tmp\n    # This ensures that the copy is atomic.\n    mv -T /data/ssh.tmp /data/ssh\nfi\n\n# Remove the existing `umbrel-os` symlink/directory and replace with migrated state.\nrm -rf /data/umbrel-os\n\nif [ \"$RUGIX_REQUIRES_COMMIT\" == \"false\" ]; then\n    # System has previously been committed. Perform the migration now.\n    # Remove old SSH host keys.\n    rm -rf /run/rugix/mounts/data/ssh\n    # Atomically move the old `umbrel-os` directory to the new place.\n    mv -T /run/rugix/mounts/data/umbrel-os /run/rugix/mounts/data/state/default/persist/data/umbrel-os\nelse\n    # State has not been migrated but system has also not been committed. Make sure that\n    # the `/data/umbrel-os` symlink exists such that the old state is used.\n    ln -s /run/rugix/mounts/data/umbrel-os /data/umbrel-os\nfi\n"
  },
  {
    "path": "packages/os/rugix/recipes/setup-rugix-mender/files/system.toml",
    "content": "#:schema https://raw.githubusercontent.com/silitics/rugix/refs/tags/v0.8.0/schemas/rugix-ctrl-system.schema.json\n\n[data-partition]\npartition = 4\n# This is safe to enable on all systems as it mounts the default\n# data partition if no external RAID has been configured.\nmount-script = \"/opt/umbrel-data/umbrel-data-mount\"\n\n[boot-flow]\ntype = \"mender-grub\"\nboot-dir = \"/run/rugix/mounts/config\"\n\n[boot-groups.a]\nslots = { system = \"system-a\" }\n\n[boot-groups.b]\nslots = { system = \"system-b\" }\n\n[slots.system-a]\ntype = \"block\"\npartition = 2\n\n[slots.system-b]\ntype = \"block\"\npartition = 3\n"
  },
  {
    "path": "packages/os/rugix/recipes/setup-rugix-mender/recipe.toml",
    "content": "description = \"configure system for compatibility with legacy Mender systems\"\npriority = -100\n\ndependencies = [\"setup-rugix\"]\n"
  },
  {
    "path": "packages/os/rugix/recipes/setup-rugix-mender/steps/00-install.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\n# Install custom Rugix system configuration for Mender-compatibility.\ninstall -D -m 644 \\\n    \"${RECIPE_DIR}/files/system.toml\" \\\n    \"/etc/rugix/system.toml\"\n\n# Create kernel and initrd symlinks as required by Mender's Grub configuration.\ncd /boot\nln -s initrd* initrd\nln -s vmlinuz* kernel\n\n# To enable state management, Rugix Ctrl must run as the init system prior to Systemd. As\n# we cannot change the Kernel commandline parameters, we instead patch `/sbin/init`.\ninstall -D -m 755 \\\n    \"${RECIPE_DIR}/files/init\" \\\n    \"/sbin/init\"\n\n# Install the state migration hook.\ninstall -D -m 755 \\\n    \"${RECIPE_DIR}/files/migrate-state.sh\" \\\n    \"/etc/rugix/hooks/boot/post-init/10-migrate-state.sh\""
  },
  {
    "path": "packages/os/rugix/recipes/umbrelos-boot/files/grub.cfg",
    "content": "load_env -f /boot.grubenv\nlinux /vmlinuz console=ttyS0 console=tty1 loglevel=3 panic=5 ${rugpi_bootargs}\ninitrd /initrd.img\nboot\n"
  },
  {
    "path": "packages/os/rugix/recipes/umbrelos-boot/recipe.toml",
    "content": "description = \"copy umbrelOS boot files to boot partition\"\npriority = -800_000\n"
  },
  {
    "path": "packages/os/rugix/recipes/umbrelos-boot/steps/00-install.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\nBOOT_DIR=\"${RUGIX_LAYER_DIR}/roots/boot\"\n\nmkdir -p \"${BOOT_DIR}\"\n\ncase \"${RUGIX_ARCH}\" in\n    \"amd64\")\n        echo \"Copying kernel and initrd...\"\n        cp -L /vmlinuz \"${BOOT_DIR}\"\n        cp -L /initrd.img \"${BOOT_DIR}\"\n        echo \"Installing second stage boot script...\"\n        cp \"${RECIPE_DIR}/files/grub.cfg\" \"${BOOT_DIR}\"\n        ;;\n    \"arm64\")\n        echo \"Copying firmware files...\"\n        cp -rp /boot/firmware/* \"${BOOT_DIR}\"\n        ;;\n    *)\n        echo \"Unsupported architecture '${RUGIX_ARCH}'.\"\n        exit 1\nesac\n"
  },
  {
    "path": "packages/os/rugix/recipes/umbrelos-cleanup/recipe.toml",
    "content": "description = \"cleanup and restore original umbrelOS configuration\"\npriority = -800_000\n"
  },
  {
    "path": "packages/os/rugix/recipes/umbrelos-cleanup/steps/00-install.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\nmv /etc/resolv.conf.original /etc/resolv.conf\nrm -rf /var/log\n"
  },
  {
    "path": "packages/os/rugix/recipes/umbrelos-prepare/recipe.toml",
    "content": "description = \"prepare umbrelOS base image for Rugix\"\npriority = 800_000\ndependencies = [\"umbrelos-cleanup\"]\n"
  },
  {
    "path": "packages/os/rugix/recipes/umbrelos-prepare/steps/00-install.sh",
    "content": "#!/bin/bash\n\nset -euo pipefail\n\nmv /etc/resolv.conf /etc/resolv.conf.original\necho \"nameserver 1.1.1.1\" > /etc/resolv.conf\n\nmkdir -p /var/log/apt\n\n# Systemd uses this file to detect that it runs in Docker. This will prevent `reboot`\n# from working as it should and may also lead to a bunch of other problems.\nrm -f /.dockerenv\n"
  },
  {
    "path": "packages/os/rugix/rugix-bakery.toml",
    "content": "#:schema https://raw.githubusercontent.com/silitics/rugix/refs/tags/v0.8.0/schemas/rugix-bakery-project.schema.json\n\n# Image for AMD64 EFI systems.\n[systems.umbrelos-amd64]\nlayer = \"umbrelos-amd64\"\narchitecture = \"amd64\"\ntarget = \"generic-grub-efi\"\n# Configure a GPT layout to enlarge boot partitions to 512 MiB.\n[systems.umbrelos-amd64.image.layout]\ntype = \"gpt\"\npartitions = [\n    { root = \"config\", type = \"C12A7328-F81F-11D2-BA4B-00A0C93EC93B\", size = \"256M\", filesystem = { type = \"fat32\" } },\n    { root = \"boot\", size = \"512MiB\", filesystem = { type = \"ext4\" } },\n    { size = \"512M\" },\n    { root = \"system\", filesystem = { type = \"ext4\", additional-options = [\n        \"-O\",\n        \"^has_journal\",\n        \"-E\",\n        \"hash_seed=035cb65d-0a86-404a-bad7-19c88d05e400\",\n        \"-U\",\n        \"12341234-a4ec-4304-a70f-c549ea829da9\",\n    ] } },\n]\n\n# Image for Raspberry Pi 4 including the required firmware update.\n[systems.umbrelos-pi4]\nlayer = \"umbrelos-pi4\"\narchitecture = \"arm64\"\ntarget = \"rpi-tryboot\"\n# Configure a GPT layout to support devices disks larger than 2 TiB.\n[systems.umbrelos-pi4.image.layout]\ntype = \"gpt\"\npartitions = [\n    { root = \"config\", type = \"C12A7328-F81F-11D2-BA4B-00A0C93EC93B\", size = \"256M\", filesystem = { type = \"fat32\" } },\n    { root = \"boot\", type = \"EBD0A0A2-B9E5-4433-87C0-68B6B72699C7\", size = \"128M\", filesystem = { type = \"fat32\" } },\n    { type = \"EBD0A0A2-B9E5-4433-87C0-68B6B72699C7\", size = \"128M\" },\n    { root = \"system\", filesystem = { type = \"ext4\", additional-options = [\n        \"-O\",\n        \"^has_journal\",\n        \"-E\",\n        \"hash_seed=035cb65d-0a86-404a-bad7-19c88d05e400\",\n        \"-U\",\n        \"12341234-a4ec-4304-a70f-c549ea829da9\",\n    ] } },\n]\n\n# Image for Raspberry Pi 4 and 5 without the firmware update.\n[systems.umbrelos-pi-tryboot]\nlayer = \"umbrelos-pi\"\narchitecture = \"arm64\"\ntarget = \"rpi-tryboot\"\n# Configure a GPT layout to support devices disks larger than 2 TiB.\n[systems.umbrelos-pi-tryboot.image.layout]\ntype = \"gpt\"\npartitions = [\n    { root = \"config\", type = \"C12A7328-F81F-11D2-BA4B-00A0C93EC93B\", size = \"256M\", filesystem = { type = \"fat32\" } },\n    { root = \"boot\", type = \"EBD0A0A2-B9E5-4433-87C0-68B6B72699C7\", size = \"128M\", filesystem = { type = \"fat32\" } },\n    { type = \"EBD0A0A2-B9E5-4433-87C0-68B6B72699C7\", size = \"128M\" },\n    { root = \"system\", filesystem = { type = \"ext4\", additional-options = [\n        \"-O\",\n        \"^has_journal\",\n        \"-E\",\n        \"hash_seed=035cb65d-0a86-404a-bad7-19c88d05e400\",\n        \"-U\",\n        \"12341234-a4ec-4304-a70f-c549ea829da9\",\n    ] } },\n]\n\n# Legacy MBR-based image to build Mender update artifacts.\n[systems.umbrelos-pi-mbr]\nlayer = \"umbrelos-pi\"\narchitecture = \"arm64\"\ntarget = \"rpi-tryboot\"\n\n# Image for legacy Mender-based devices.\n[systems.umbrelos-mender-amd64]\nlayer = \"umbrelos-mender-amd64\"\narchitecture = \"amd64\"\ntarget = \"unknown\"\n# We need a custom layout here to create a filesystem compatible with Mender's version of\n# Grub. In particular, the feature `metadata_csum_seed` is incompatible, so we remove it.\n# We also do not need/want a journal as the root filesystem is read-only. Note that adding\n# a journal will also interfere with static delta updates.\n[systems.umbrelos-mender-amd64.image.layout]\ntype = \"gpt\"\npartitions = [\n    { root = \"system\", filesystem = { type = \"ext4\", additional-options = [\n        \"-O\",\n        \"^metadata_csum_seed,^has_journal\",\n        \"-E\",\n        \"hash_seed=035cb65d-0a86-404a-bad7-19c88d05e400\",\n        \"-U\",\n        \"12341234-a4ec-4304-a70f-c549ea829da9\",\n    ] } },\n]\n"
  },
  {
    "path": "packages/os/rugix/run-bakery",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nDOCKER=${DOCKER:-\"docker\"}\nDOCKER_FLAGS=${DOCKER_FLAGS:-\"\"}\n\nRUGIX_DEV=${RUGIX_DEV:-\"false\"}\n\nRUGIX_CONTEXT_DIR=${RUGIX_CONTEXT_DIR:-\"\"}\n\nRUGIX_CACHE_VOLUME=${RUGIX_CACHE_VOLUME:-\"rugix-build-cache\"}\n\nif [ \"${RUGIX_DEV}\" = \"false\" ]; then\n    RUGIX_VERSION=${RUGIX_VERSION:-\"v0.8\"}\nelse\n    RUGIX_VERSION=${RUGIX_VERSION:-\"dev\"}\nfi\n\nRUGIX_BAKERY_IMAGE=${RUGIX_BAKERY_IMAGE:-\"ghcr.io/silitics/rugix-bakery:${RUGIX_VERSION}\"}\n\nif [ \"${RUGIX_DEV}\" = \"false\" ]; then\n    $DOCKER pull \"${RUGIX_BAKERY_IMAGE}\"\nfi\n\nRUGIX_BAKERY_IMAGE=$($DOCKER inspect --format='{{.Id}}' \"${RUGIX_BAKERY_IMAGE}\")\n\nif [ -t 0 ] && [ -t 1 ]; then\n    DOCKER_FLAGS=\"${DOCKER_FLAGS} -it\"\nfi\n\nif [ -n \"${RUGIX_CACHE_VOLUME}\" ]; then\n    if ! $DOCKER volume inspect \"${RUGIX_CACHE_VOLUME}\" >/dev/null 2>&1; then\n        $DOCKER volume create \"${RUGIX_CACHE_VOLUME}\" >/dev/null\n    fi\n    DOCKER_FLAGS=\"${DOCKER_FLAGS} -v ${RUGIX_CACHE_VOLUME}:/run/rugix/bakery/cache\"\nfi\n\nif [ \"${1:-}\" == \"run\" ]; then\n    # Add port forwarding for SSH when running a system in a VM.\n    DOCKER_FLAGS=\"${DOCKER_FLAGS} -p 127.0.0.1:2222:2222 -p [::1]:2222:2222\"\nfi\n\nexec $DOCKER run --rm --privileged \\\n    $DOCKER_FLAGS \\\n    -v \"$(pwd)\":/project \\\n    -v \"$(pwd)/${RUGIX_CONTEXT_DIR}\":/run/rugix/bakery/context \\\n    -v /dev:/dev \\\n    -e \"RUGIX_HOST_PROJECT_DIR=$(pwd)\" \\\n    -e \"RUGIX_BAKERY_IMAGE=${RUGIX_BAKERY_IMAGE}\" \\\n    -e \"RUGIX_DEV=${RUGIX_DEV}\" \\\n    \"${RUGIX_BAKERY_IMAGE}\" \\\n    \"$@\""
  },
  {
    "path": "packages/os/rugpi-image",
    "content": "#!/usr/bin/env python3\n#\n# Copyright 2023-2024 Silitics GmbH <info@silitics.com>\n#\n# This file is part of Rugpi (https://rugpi.io).\n#\n# SPDX-License-Identifier: MIT OR Apache-2.0\n\n\nimport pathlib\nimport subprocess\nimport sys\n\n\ntry:\n    # Mender provides us with two arguments:\n    # 1. The current state of the update process.\n    # 2. A directory where we can find the files of the artifact.\n    STATE = sys.argv[1]\n    FILES = pathlib.Path(sys.argv[2])\nexcept IndexError:\n    raise RuntimeError(f\"usage: {sys.argv[0]} <state> <files>\")\n\n\ndef rugpi_commit_system():\n    \"\"\"Commit the current partition set.\"\"\"\n    subprocess.check_call([\"rugpi-ctrl\", \"system\", \"commit\"])\n\n\ndef rugpi_install_image(image: pathlib.Path):\n    \"\"\"Install a Rugpi image without rebooting in streaming mode.\"\"\"\n    with open(image, \"rb\") as image_file:\n        subprocess.check_call(\n            [\n                \"rugpi-ctrl\",\n                \"update\",\n                \"install\",\n                \"--stream\",\n                \"--no-reboot\",\n                \"-\",\n           ],\n           stdin=image_file,\n        )\n        while True:\n            if not image_file.read(4096):\n                break\n\n\ndef query_supports_rollback():\n    \"\"\"The `SupportsRollback` query of the update process.\"\"\"\n    # We do support rollbacks.\n    print(\"Yes\")\n\n\ndef query_needs_artifact_reboot():\n    \"\"\"The `NeedsArtifactReboot` query of the update process.\"\"\"\n    # We want Mender to take care of rebooting.\n    print(\"Automatic\")\n\n\ndef state_download():\n    \"\"\"The `Download` state of the update process.\"\"\"\n    image_found = False\n    # Commit the present system so that we can overwrite the cold partitions.\n    rugpi_commit_system()\n    with (FILES / \"stream-next\").open(\"rt\") as stream_next:\n        while True:\n            next_file = stream_next.readline().strip()\n            if not next_file:\n                # No more files left in the stream.\n                break\n            if next_file.strip().endswith(\".img\"):\n                # We found an image, let's install it.\n                image_found = True\n                rugpi_install_image(FILES / next_file)\n    if not image_found:\n        raise RuntimeError(\"unable to find image in the artifact\")\n\n\ndef state_artifact_install():\n    \"\"\"The `ArtifactInstall` state of the update process.\"\"\"\n    # The image has already been installed in the downloade state. It remains\n    # to create the marker file such that Mender reboots via Rugpi.\n    pathlib.Path(\"/run/rugpi/.mender-reboot-spare\").touch()\n\n\ndef state_artifact_rollback():\n    \"\"\"The `ArtifactRollback` state of the update process.\"\"\"\n    # Rebooting will automatically roll back the system.\n\n\ndef state_artifact_verify_reboot():\n    \"\"\"The `ArtifactVerifyReboot` state of the update process.\"\"\"\n    output = subprocess.check_output([\"rugpi-ctrl\", \"system\", \"info\"]).decode()\n    hot = None\n    default = None\n    for line in output.splitlines():\n        try:\n            key, _, value = line.partition(\":\")\n        except ValueError:\n            pass\n        else:\n            key = key.strip()\n            value = value.strip()\n            if key == \"Hot\":\n                hot = value\n            elif key == \"Default\":\n                default = value\n    if hot == default:\n        # Something went wrong!\n        sys.exit(1)\n\n\ndef state_artifact_commit():\n    \"\"\"The `ArtifactCommit` state of the update process.\"\"\"\n    rugpi_commit_system()\n\n\ndef state_nop():\n    \"\"\"Called for all states we do not need to handle.\"\"\"\n\n\n{\n    \"SupportsRollback\": query_supports_rollback,\n    \"NeedsArtifactReboot\": query_needs_artifact_reboot,\n    \"Download\": state_download,\n    \"ArtifactInstall\": state_artifact_install,\n    \"ArtifactRollback\": state_artifact_rollback,\n    \"ArtifactVerifyReboot\": state_artifact_verify_reboot,\n    \"ArtifactCommit\": state_artifact_commit,\n}.get(STATE, state_nop)()"
  },
  {
    "path": "packages/os/trigger-change",
    "content": "Thu 15 Jan 2026 17:12:57 +07\n"
  },
  {
    "path": "packages/os/umbrelos.Dockerfile",
    "content": "ARG DEBIAN_VERSION=trixie\nARG SNAPSHOT_DATE=20251229\n\nARG DOCKER_VERSION=28.5.0\nARG DOCKER_INSTALL_SCRIPT_COMMIT=5c8855edd778525564500337f5ac4ad65a0c168e\n\nARG YQ_VERSION=4.24.5\nARG YQ_SHA256_amd64=c93a696e13d3076e473c3a43c06fdb98fafd30dc2f43bc771c4917531961c760\nARG YQ_SHA256_arm64=8879e61c0b3b70908160535ea358ec67989ac4435435510e1fcb2eda5d74a0e9\n\nARG NODE_VERSION=22.13.0\nARG NODE_SHA256_amd64=9a33e89093a0d946c54781dcb3ccab4ccf7538a7135286528ca41ca055e9b38f  \nARG NODE_SHA256_arm64=e0cc088cb4fb2e945d3d5c416c601e1101a15f73e0f024c9529b964d9f6dce5b\n\nARG KOPIA_VERSION=0.19.0\nARG KOPIA_SHA256_amd64=c07843822c82ec752e5ee749774a18820b858215aabd7da448ce665b9b9107aa\nARG KOPIA_SHA256_arm64=632db9d72f2116f1758350bf7c20aa57c22c220480aaccb5f839e75669210ed9\n\n#########################################################################\n# ui build stage\n#########################################################################\n\nFROM node:${NODE_VERSION}-bookworm-slim AS ui-build\n\n# Set the working directory\nWORKDIR /app\n\n# Copy the package.json and package-lock.json\nCOPY packages/ui/ .\n\n# The ui-build stage only has 'packages/ui' in '/app', but the ui imports runtime values\n# via a relative path ('../../../umbreld/source/modules/server/trpc/common') that resolves outside '/app'.\n# We copy the target file to the expected path for the build to succeed.\nCOPY packages/umbreld/source/modules/server/trpc/common.ts /umbreld/source/modules/server/trpc/common.ts\n\n# Install the dependencies\nRUN rm -rf node_modules || true\nRUN npm ci\n\n# Build the app\nRUN npm run build\n\n\n#########################################################################\n# umbrelos-base-amd64 build stage\n#########################################################################\n\nFROM debian:${DEBIAN_VERSION}-${SNAPSHOT_DATE} AS umbrelos-base-amd64\n\nARG SNAPSHOT_DATE\n\nCOPY packages/os/build-steps /build-steps\n\nRUN /build-steps/initialize.sh \"${SNAPSHOT_DATE}\"\n\n# Install Linux kernel, non-free firmware and ZFS.\nRUN apt-get install --yes \\\n    zfs-dkms \\\n    zfsutils-linux \\\n    linux-headers-amd64 \\\n    linux-image-amd64 \\\n    intel-microcode \\\n    amd64-microcode \\\n    firmware-linux \\\n    firmware-realtek \\\n    firmware-iwlwifi \\\n    firmware-atheros\n\n# Cleanup build steps.\nRUN rm -rf /build-steps\n\n\n#########################################################################\n# umbrelos-base-arm64 build stage\n#########################################################################\n\nFROM debian:${DEBIAN_VERSION}-${SNAPSHOT_DATE} AS umbrelos-base-arm64\n\nARG SNAPSHOT_DATE\n\nCOPY packages/os/build-steps /build-steps\n\nRUN /build-steps/initialize.sh \"${SNAPSHOT_DATE}\"\n\nRUN /build-steps/setup-raspberrypi.sh\n\n# Cleanup build steps.\nRUN rm -rf /build-steps\n\n\n#########################################################################\n# umbrelos build stage\n#########################################################################\n\nARG TARGETARCH\n\n# TODO: Instead of using the debian:trixie image as a base we should\n# build a fresh rootfs from scratch. We can use the same tool the Docker\n# images use for reproducible Debian builds: https://github.com/debuerreotype/debuerreotype\nFROM umbrelos-base-${TARGETARCH} AS umbrelos\n\n# We need to duplicate this such that we can also use the argument below.\nARG TARGETARCH\nARG DOCKER_VERSION\nARG DOCKER_INSTALL_SCRIPT_COMMIT\nARG YQ_VERSION\nARG YQ_SHA256_amd64\nARG YQ_SHA256_arm64\nARG NODE_VERSION\nARG NODE_SHA256_amd64\nARG NODE_SHA256_arm64\nARG KOPIA_VERSION\nARG KOPIA_SHA256_amd64\nARG KOPIA_SHA256_arm64\n\n# Install acpid\n# We use acpid to implement custom behaviour for power button presses\nRUN apt-get install --yes acpid\nRUN systemctl enable acpid\n\n# Install zram-generator for swap\nRUN apt-get install --yes systemd-zram-generator\n\n# Install essential networking services\nRUN apt-get install --yes network-manager systemd-timesyncd openssh-server avahi-daemon avahi-discover avahi-utils libnss-mdns\n\n# Install bluetooth stack\n# The default configuration enables all bluetooth controllers/adapters present on boot and plugged in after boot\nRUN apt-get install --yes bluez\n\n# Install essential system utilities\nRUN apt-get install --yes sudo nano vim less man iproute2 iputils-ping curl wget ca-certificates usbutils whois build-essential e2fsprogs\n\n# Install umbreld dependencies\n# (many of these can be remove after the apps refactor)\nRUN apt-get install --yes python3 fswatch jq rsync git gettext-base gnupg procps dmidecode unar imagemagick ffmpeg samba wsdd2 cifs-utils smbclient nvme-cli pciutils\n\n# Disable automatically starting smbd and wsdd2 at boot so umbreld can initialize them only when they're needed\nRUN systemctl disable smbd wsdd2\n\n# Filessystem support\nRUN apt-get install --yes gdisk parted e2fsprogs exfatprogs\n# For some reason this always fails on arm64 but it's ok since we\n# don't support external storage on Pi anyway.\nRUN [ \"${TARGETARCH}\" = \"amd64\" ] && apt-get install --yes ntfs-3g || true\n\n# Install Node.js\nRUN NODE_ARCH=$([ \"${TARGETARCH}\" = \"arm64\" ] && echo \"arm64\" || echo \"x64\") && \\\n    NODE_SHA256=$(eval echo \\$NODE_SHA256_${TARGETARCH}) && \\\n    curl -fsSL https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz -o node.tar.gz && \\\n    echo \"${NODE_SHA256}  node.tar.gz\" | sha256sum -c - && \\\n    tar -xz -f node.tar.gz -C /usr/local --strip-components=1 && \\\n    rm -rf node.tar.gz\n\n# Install yq from binary\n# Debian repos have kislyuk/yq but we want mikefarah/yq\nRUN YQ_SHA256=$(eval echo \\$YQ_SHA256_${TARGETARCH}) && \\\n    curl -L https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_${TARGETARCH} -o /usr/bin/yq && \\\n    echo \"${YQ_SHA256} /usr/bin/yq\" | sha256sum -c && \\\n    chmod +x /usr/bin/yq\n\nRUN curl -fsSL https://raw.githubusercontent.com/docker/docker-install/${DOCKER_INSTALL_SCRIPT_COMMIT}/install.sh -o /tmp/install-docker.sh\nRUN sh /tmp/install-docker.sh --version v${DOCKER_VERSION}\nRUN rm /tmp/install-docker.sh\n\n# Install kopia from binary\nRUN KOPIA_ARCH=$([ \"${TARGETARCH}\" = \"arm64\" ] && echo \"arm64\" || echo \"x64\") && \\\n    KOPIA_SHA256=$(eval echo \\$KOPIA_SHA256_${TARGETARCH}) && \\\n    curl -L https://github.com/kopia/kopia/releases/download/v${KOPIA_VERSION}/kopia-${KOPIA_VERSION}-linux-${KOPIA_ARCH}.tar.gz -o /tmp/kopia.tar.gz && \\\n    echo \"${KOPIA_SHA256} /tmp/kopia.tar.gz\" | sha256sum -c && \\\n    tar -xz -f /tmp/kopia.tar.gz -C /tmp && \\\n    mv /tmp/kopia-${KOPIA_VERSION}-linux-${KOPIA_ARCH}/kopia /usr/bin/kopia && \\\n    chmod +x /usr/bin/kopia\n\n# kopia also requires fuse3 for mounting snapshots\nRUN apt-get install --yes fuse3 bindfs\n\n# Add Umbrel user\nRUN adduser --gecos \"\" --disabled-password umbrel\nRUN echo \"umbrel:umbrel\" | chpasswd\nRUN usermod -aG sudo umbrel\n\n# Preload images\nRUN sudo apt-get install --yes skopeo\nRUN mkdir -p /images\nRUN skopeo copy docker://getumbrel/tor@sha256:2ace83f22501f58857fa9b403009f595137fa2e7986c4fda79d82a8119072b6a docker-archive:/images/tor\nRUN skopeo copy docker://getumbrel/auth-server@sha256:b4a4b37896911a85fb74fa159e010129abd9dff751a40ef82f724ae066db3c2a docker-archive:/images/auth\n\n# Install umbreld\nCOPY packages/umbreld /opt/umbreld\nCOPY --from=ui-build /app/dist /opt/umbreld/ui\nWORKDIR /opt/umbreld\nRUN rm -rf node_modules || true\nRUN npm clean-install --omit dev && npm link\nWORKDIR /\n\n# Copy in filesystem overlay\nCOPY packages/os/overlay-common /\nCOPY \"packages/os/overlay-${TARGETARCH}\" /\n\n# Move persistant locations to /data to be bind mounted over the OS.\n# /data will exist on a seperate partition that survives OS updates.\n# This step should always be last so things like /var/log/apt/\n# exist while installing packages.\n# Migrataing current data is required to not break journald, otherwise\n# /var/log/journal will not exist and journald will log to RAM and not\n# persist between reboots.\nRUN mkdir -p /data/umbrel-os/var\nRUN mv /var/log     /data/umbrel-os/var/log\nRUN mv /home        /data/umbrel-os/home\n"
  },
  {
    "path": "packages/os/usb-installer/.gitignore",
    "content": "build\n"
  },
  {
    "path": "packages/os/usb-installer/build.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nrootfs_dir=\"/tmp/rootfs\"\niso_image=\"/tmp/umbrelos-amd64-usb-installer.iso\"\n\necho \"Creating directories for ISO image...\"\nmkdir -p \"${rootfs_dir}/boot/grub\"\n\necho \"Extracting rootfs...\"\ntar -xf /data/build/rootfs.tar --directory \"${rootfs_dir}\"\n\necho \"Creating grub.cfg...\"\ncat > \"${rootfs_dir}/boot/grub/grub.cfg\" <<EOF\nset default=0\nset timeout=5\n\nset gfxmode=auto\ninsmod all_video\ninsmod gfxterm\nterminal_output gfxterm\n\nmenuentry \"umbrelOS installer\" {\n    linux /vmlinuz root=LABEL=UMBRELINSTALLER ro quiet loglevel=0 nomodeset vga=normal fbcon=font:VGA8x16\n    initrd /initrd.img\n}\n\nmenuentry \"umbrelOS installer (alt graphics)\" {\n    linux /vmlinuz root=LABEL=UMBRELINSTALLER ro quiet loglevel=0\n    initrd /initrd.img\n}\nEOF\n\necho \"Creating ISO image...\"\ngrub-mkrescue -o \"${iso_image}\" -volid \"UMBRELINSTALLER\" \"${rootfs_dir}\" -- -hfsplus off\n\necho \"Copying to ./build/...\"\nmv \"${iso_image}\" /data/build/\n\necho \"Done!\""
  },
  {
    "path": "packages/os/usb-installer/builder.Dockerfile",
    "content": "FROM debian:bookworm\n\nRUN apt-get -y update\nRUN apt-get -y install grub-common grub-efi xorriso mtools"
  },
  {
    "path": "packages/os/usb-installer/overlay/etc/systemd/system/custom-tty.service",
    "content": "[Unit]\nDescription=Custom TTY\nAfter=multi-user.target\n\n[Service]\nExecStart=/opt/custom-tty\nStandardInput=tty\nStandardOutput=tty\nStandardError=tty\nTTYPath=/dev/tty1\nRestart=on-failure\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "packages/os/usb-installer/overlay/opt/custom-tty",
    "content": "#!/usr/bin/env bash\n\nsleep 1\n\nclear\n\ncat << 'EOF'\n\n              ,;###GGGGGGGGGGl#Sp\n           ,##GGGlW\"\"^'  '`\"\"%GGGG#S,\n         ,#GGG\"                  \"lGG#o\n        #GGl^                      '$GG#\n      ,#GGb                          \\GGG,\n      lGG\"                            \"GGG\n     #GGGlGGGl##p,,p##lGGl##p,,p###ll##GGGG\n    !GGGlW\"\"\"*GGGGGGG#\"\"\"\"WlGGGGG#W\"\"*WGGGGS\n     \"\"          \"^          '\"          \"\"\n             umbrelOS USB Installer\nEOF\necho\n\n# If device is an Umbrel Home auto flash and shutdown non-interactively since we don't have video output.\nif dmidecode -t system | grep --silent 'Umbrel Home'\nthen\n    echo \"Umbrel Home detected.\"\n    echo \"Automatically flashing internal storage...\"\n    xz --decompress --stdout /umbrelos-amd64.img.xz | dd of=/dev/nvme0n1 bs=4M status=progress\n    echo \"umbrelOS has been installed, shutting down.\"\n    poweroff\n    exit 1\nfi\n\n# If device is an Umbrel Pro auto flash and shutdown non-interactively.\nif dmidecode -t system | grep --silent 'Umbrel Pro'\nthen\n    echo \"Umbrel Pro detected.\"\n    echo \"Automatically flashing internal storage...\"\n    xz --decompress --stdout /umbrelos-amd64.img.xz | dd of=/dev/mmcblk0 bs=4M status=progress\n    echo \"umbrelOS has been installed, shutting down.\"\n    poweroff\n    exit 1\nfi\n\n# For all other devices, run the interactive installer.\necho \"Installing umbrelOS will wipe your entire storage device.\"\necho\nreadarray -t devices < <(lsblk --nodeps --output NAME,VENDOR,MODEL,SIZE | sed '1d')\nPS3=\"Select a storage device by number to install umbrelOS on: \"\nselect device in \"${devices[@]}\"\ndo\n    if [[ -n \"$device\" ]]\n    then\n        echo \"installing umbrelOS on: $device\"\n        device_path=\"/dev/$(echo $device | awk '{print $1}')\"\n        xz --decompress --stdout /umbrelos-amd64.img.xz | dd of=$device_path bs=4M status=progress\n        sync\n        echo\n        echo \"umbrelOS has been installed!\"\n        printf \"Press any key to shutdown, remember to remove the USB drive before turning the device back on.\"\n        read -n 1 -s\n        poweroff\n        break\n    else\n        echo \"Invalid choice, please try again.\"\n    fi\ndone"
  },
  {
    "path": "packages/os/usb-installer/run.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nmkdir -p build\ndocker buildx build --load -f usb-installer.Dockerfile --platform linux/amd64 --cache-from type=gha,scope=usb-installer --cache-to type=gha,mode=max,scope=usb-installer -t usb-installer ../\ndocker export -o build/rootfs.tar $(docker run -d usb-installer /bin/true)\ndocker buildx build --load -f builder.Dockerfile --platform linux/amd64 --cache-from type=gha,scope=usb-installer-builder --cache-to type=gha,mode=max,scope=usb-installer-builder -t usb-installer:builder .\ndocker run --entrypoint /data/build.sh -v $PWD:/data --privileged --platform linux/amd64 usb-installer:builder\n\n# Test CD-ROM boot (used by VMs)\n# qemu-system-x86_64 -net nic -net user -machine accel=tcg -m 2048 -bios ~/Downloads/OVMF.bin -cdrom umbrelos-amd64-usb-installer.iso\n\n# Test USB boot (used by physical machines)\n# qemu-system-x86_64 -net nic -net user -machine accel=tcg -m 2048 -bios ~/Downloads/OVMF.bin -drive if=none,id=stick,format=raw,file=umbrelos-amd64-usb-installer.iso -device nec-usb-xhci,id=xhci -device usb-storage,bus=xhci.0,drive=stick"
  },
  {
    "path": "packages/os/usb-installer/usb-installer.Dockerfile",
    "content": "FROM debian:bookworm-slim\n\nRUN echo \"root:root\" | chpasswd\n\nRUN apt-get -y update\n\n# Install Linux kernel, systemd, bootloader and script deps\nRUN apt-get install --yes --no-install-recommends linux-image-amd64 systemd-sysv xz-utils dmidecode\n\n# Reduce size\n# We have to do this extremely aggreseively because we're close to GitHub's 2GB release asset limit\nRUN apt-get clean && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* /tmp/* /usr/share/man /usr/share/doc /usr/share/info /var/log/*\nRUN find / -name '*.a' -delete && \\\n    find / -name '*.so*' -exec strip --strip-debug {} \\;\nRUN rm -rf /usr/lib/modules/6.1.0-20-amd64/kernel/drivers/gpu\nRUN rm -rf /usr/lib/modules/6.1.0-20-amd64/kernel/drivers/net\nRUN rm -rf /usr/lib/modules/6.1.0-20-amd64/kernel/drivers/infiniband\nRUN rm -rf /usr/lib/modules/6.1.0-20-amd64/kernel/net\nRUN rm -rf /usr/lib/modules/6.1.0-20-amd64/kernel/sound\n\n# Copy in umbrelOS image\nCOPY build/umbrelos-amd64.img.xz  /\n\n# Copy in filesystem overlay\nCOPY usb-installer/overlay /\n\n# Configure TTY services\nRUN systemctl enable custom-tty.service\nRUN systemctl mask console-getty.service\nRUN systemctl mask getty@tty1.service"
  },
  {
    "path": "packages/os/vm.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nSTATE_DIR=\"${VM_STATE_DIR:-$SCRIPT_DIR/vm-state}\"\nNVME_STATE_FILE=\"$STATE_DIR/nvme.json\"\n\n# PCIe Physical Slot Numbers that match Umbrel Pro hardware\n# Maps slot 1-4 to their respective PCIe slot numbers\ndeclare -A PCI_SLOT_MAP=([1]=12 [2]=14 [3]=4 [4]=6)\n\n# Defaults\nDEFAULT_MEMORY=2048\nDEFAULT_CORES=4\nDEFAULT_DISK_SIZE=\"64G\"\nDEFAULT_SSH_PORT=2222\nDEFAULT_HTTP_PORT=8080\nDEFAULT_NVME_SIZE=\"64G\"\n\nshow_help() {\n  cat << EOF\nvm.sh - Manage an umbrelOS QEMU virtual machine\n\nUsage: $0 <command> [options]\n\nCommands:\n    boot <image>                   Boot VM from the given image\n    reflash                        Delete boot disk overlay (simulates reflashing the OS)\n    reset                          Delete all VM state (overlay, NVMe disks, UEFI vars)\n\n    nvme list                      List all NVMe devices and their status\n    nvme add <slot> [--size SIZE]  Add an NVMe device to slot (1-4)\n    nvme destroy <slot>            Destroy an NVMe device (deletes data)\n    nvme connect <slot>            Connect an existing NVMe device to the VM\n    nvme disconnect <slot>         Disconnect an NVMe device from the VM\n    nvme move <from> <to>          Move an NVMe device from one slot to another\n\nBoot Options:\n    --memory <MiB>                 RAM in MiB (default: ${DEFAULT_MEMORY})\n    --cores <count>                CPU cores (default: ${DEFAULT_CORES})\n    --disk-size <size>             Boot disk size (default: ${DEFAULT_DISK_SIZE})\n    --ssh-port <port>              Local SSH port forward (default: ${DEFAULT_SSH_PORT})\n    --http-port <port>             Local HTTP port forward (default: ${DEFAULT_HTTP_PORT})\n\nNVMe Options:\n    --size <size>                  NVMe disk size (default: ${DEFAULT_NVME_SIZE})\n\nEnvironment Variables:\n    VM_STATE_DIR                   Override state directory (default: ./vm-state)\n\nExamples:\n    $0 boot umbrelos.img --memory 4096 --cores 8\n    $0 nvme add 1 --size 128G\n    $0 nvme add 2\n    $0 nvme disconnect 2\n    $0 nvme list\n\nEOF\n}\n\n# Initialize state directory and NVMe state file\ninit_state() {\n  mkdir -p \"$STATE_DIR\"\n  if [[ ! -f \"$NVME_STATE_FILE\" ]]; then\n    echo '{}' > \"$NVME_STATE_FILE\"\n  fi\n}\n\n# Get NVMe state for a slot\nget_nvme_state() {\n  local slot=\"$1\"\n  local key=\"${2:-}\"\n  if [[ -n \"$key\" ]]; then\n    jq -r \".\\\"$slot\\\".$key // empty\" \"$NVME_STATE_FILE\"\n  else\n    jq -r \".\\\"$slot\\\" // empty\" \"$NVME_STATE_FILE\"\n  fi\n}\n\n# Set NVMe state for a slot\nset_nvme_state() {\n  local slot=\"$1\"\n  local key=\"$2\"\n  local value=\"$3\"\n  local tmp\n  tmp=$(mktemp)\n  jq \".\\\"$slot\\\".$key = $value\" \"$NVME_STATE_FILE\" > \"$tmp\" && mv \"$tmp\" \"$NVME_STATE_FILE\"\n}\n\n# Initialize NVMe entry\ninit_nvme_entry() {\n  local slot=\"$1\"\n  local size=\"$2\"\n  local serial=\"$3\"\n  local tmp\n  tmp=$(mktemp)\n  jq \".\\\"$slot\\\" = {\\\"size\\\": \\\"$size\\\", \\\"serial\\\": \\\"$serial\\\", \\\"connected\\\": true, \\\"exists\\\": true}\" \"$NVME_STATE_FILE\" > \"$tmp\" && mv \"$tmp\" \"$NVME_STATE_FILE\"\n}\n\n# Remove NVMe entry\nremove_nvme_entry() {\n  local slot=\"$1\"\n  local tmp\n  tmp=$(mktemp)\n  jq \"del(.\\\"$slot\\\")\" \"$NVME_STATE_FILE\" > \"$tmp\" && mv \"$tmp\" \"$NVME_STATE_FILE\"\n}\n\n# Validate slot number\nvalidate_slot() {\n  local slot=\"$1\"\n  if [[ ! \"$slot\" =~ ^[1-4]$ ]]; then\n    echo \"Error: Slot must be 1-4\" >&2\n    exit 1\n  fi\n}\n\n# Get disk path for a slot\nget_nvme_disk_path() {\n  local slot=\"$1\"\n  echo \"$STATE_DIR/nvme-slot${slot}.qcow2\"\n}\n\n# List all NVMe devices\nnvme_list() {\n  init_state\n  echo \"NVMe Devices:\"\n  echo \"=============\"\n  echo\n  printf \"%-6s %-12s %-10s %-10s\\n\" \"Slot\" \"Status\" \"Connected\" \"Size\"\n  printf \"%-6s %-12s %-10s %-10s\\n\" \"----\" \"------\" \"---------\" \"----\"\n\n  for slot in 1 2 3 4; do\n    local exists connected size status\n    exists=$(get_nvme_state \"$slot\" \"exists\")\n    connected=$(get_nvme_state \"$slot\" \"connected\")\n    size=$(get_nvme_state \"$slot\" \"size\")\n\n    if [[ \"$exists\" == \"true\" ]]; then\n      if [[ \"$connected\" == \"true\" ]]; then\n        status=\"present\"\n        connected=\"yes\"\n      else\n        status=\"disconnected\"\n        connected=\"no\"\n      fi\n    else\n      status=\"empty\"\n      connected=\"-\"\n      size=\"-\"\n    fi\n\n    printf \"%-6s %-12s %-10s %-10s\\n\" \"$slot\" \"$status\" \"$connected\" \"$size\"\n  done\n  echo\n}\n\n# Add NVMe device\nnvme_add() {\n  local slot=\"$1\"\n  local size=\"$2\"\n\n  validate_slot \"$slot\"\n  init_state\n\n  local disk_path\n  disk_path=$(get_nvme_disk_path \"$slot\")\n\n  if [[ -f \"$disk_path\" ]]; then\n    echo \"Error: NVMe device already exists in slot $slot\" >&2\n    echo \"Use 'nvme destroy $slot' to remove it first\" >&2\n    exit 1\n  fi\n\n  # Generate a unique serial number using timestamp and random suffix\n  local serial=\"nvme${slot}-$(date +%s)-${RANDOM}\"\n\n  echo \"Creating NVMe device in slot $slot (${size})...\"\n  qemu-img create -f qcow2 \"$disk_path\" \"$size\" >/dev/null\n  init_nvme_entry \"$slot\" \"$size\" \"$serial\"\n  echo \"Done. NVMe device created in slot $slot (serial: $serial)\"\n}\n\n# Destroy NVMe device\nnvme_destroy() {\n  local slot=\"$1\"\n\n  validate_slot \"$slot\"\n  init_state\n\n  local disk_path\n  disk_path=$(get_nvme_disk_path \"$slot\")\n\n  if [[ ! -f \"$disk_path\" ]]; then\n    echo \"Error: No NVMe device in slot $slot\" >&2\n    exit 1\n  fi\n\n  echo \"Destroying NVMe device in slot $slot...\"\n  rm -f \"$disk_path\"\n  remove_nvme_entry \"$slot\"\n  echo \"Done. NVMe device in slot $slot destroyed\"\n}\n\n# Connect NVMe device\nnvme_connect() {\n  local slot=\"$1\"\n\n  validate_slot \"$slot\"\n  init_state\n\n  local disk_path\n  disk_path=$(get_nvme_disk_path \"$slot\")\n\n  if [[ ! -f \"$disk_path\" ]]; then\n    echo \"Error: No NVMe device in slot $slot\" >&2\n    echo \"Use 'nvme add $slot' to create one first\" >&2\n    exit 1\n  fi\n\n  local connected\n  connected=$(get_nvme_state \"$slot\" \"connected\")\n  if [[ \"$connected\" == \"true\" ]]; then\n    echo \"NVMe device in slot $slot is already connected\"\n    exit 0\n  fi\n\n  set_nvme_state \"$slot\" \"connected\" \"true\"\n  echo \"NVMe device in slot $slot connected (will be available on next boot)\"\n}\n\n# Disconnect NVMe device\nnvme_disconnect() {\n  local slot=\"$1\"\n\n  validate_slot \"$slot\"\n  init_state\n\n  local exists\n  exists=$(get_nvme_state \"$slot\" \"exists\")\n\n  if [[ \"$exists\" != \"true\" ]]; then\n    echo \"Error: No NVMe device in slot $slot\" >&2\n    exit 1\n  fi\n\n  local connected\n  connected=$(get_nvme_state \"$slot\" \"connected\")\n  if [[ \"$connected\" != \"true\" ]]; then\n    echo \"NVMe device in slot $slot is already disconnected\"\n    exit 0\n  fi\n\n  set_nvme_state \"$slot\" \"connected\" \"false\"\n  echo \"NVMe device in slot $slot disconnected (will be unavailable on next boot)\"\n}\n\n# Move NVMe device from one slot to another\nnvme_move() {\n  local from_slot=\"$1\"\n  local to_slot=\"$2\"\n\n  validate_slot \"$from_slot\"\n  validate_slot \"$to_slot\"\n  init_state\n\n  if [[ \"$from_slot\" == \"$to_slot\" ]]; then\n    echo \"Error: Source and destination slots are the same\" >&2\n    exit 1\n  fi\n\n  local from_disk_path to_disk_path\n  from_disk_path=$(get_nvme_disk_path \"$from_slot\")\n  to_disk_path=$(get_nvme_disk_path \"$to_slot\")\n\n  if [[ ! -f \"$from_disk_path\" ]]; then\n    echo \"Error: No NVMe device in slot $from_slot\" >&2\n    exit 1\n  fi\n\n  if [[ -f \"$to_disk_path\" ]]; then\n    echo \"Error: Slot $to_slot already has an NVMe device\" >&2\n    echo \"Use 'nvme destroy $to_slot' to remove it first\" >&2\n    exit 1\n  fi\n\n  # Move the disk file\n  mv \"$from_disk_path\" \"$to_disk_path\"\n\n  # Move the state entry\n  local tmp from_state\n  tmp=$(mktemp)\n  from_state=$(get_nvme_state \"$from_slot\")\n  jq \".\\\"$to_slot\\\" = $from_state | del(.\\\"$from_slot\\\")\" \"$NVME_STATE_FILE\" > \"$tmp\" && mv \"$tmp\" \"$NVME_STATE_FILE\"\n\n  echo \"NVMe device moved from slot $from_slot to slot $to_slot\"\n}\n\n# Build QEMU NVMe arguments for connected devices\nbuild_nvme_args() {\n  local nvme_args=\"\"\n\n  for slot in 1 2 3 4; do\n    local exists connected disk_path pci_slot serial\n    exists=$(get_nvme_state \"$slot\" \"exists\")\n    connected=$(get_nvme_state \"$slot\" \"connected\")\n\n    if [[ \"$exists\" == \"true\" && \"$connected\" == \"true\" ]]; then\n      disk_path=$(get_nvme_disk_path \"$slot\")\n      pci_slot=\"${PCI_SLOT_MAP[$slot]}\"\n      serial=$(get_nvme_state \"$slot\" \"serial\")\n      # Fallback for devices created before serial tracking\n      if [[ -z \"$serial\" ]]; then\n        serial=\"nvme${slot}\"\n      fi\n\n      # Create PCIe root port with correct slot number, then attach NVMe\n      nvme_args=\"$nvme_args -device pcie-root-port,id=rp${slot},slot=${pci_slot},chassis=${slot}\"\n      nvme_args=\"$nvme_args -drive file=${disk_path},format=qcow2,if=none,id=nvme${slot},cache=none,discard=unmap,aio=threads\"\n      nvme_args=\"$nvme_args -device nvme,drive=nvme${slot},serial=${serial},bus=rp${slot}\"\n    fi\n  done\n\n  echo \"$nvme_args\"\n}\n\n# Detect OVMF firmware paths\ndetect_ovmf() {\n  if [[ -f \"/opt/homebrew/share/qemu/edk2-x86_64-code.fd\" ]]; then\n    OVMF_CODE=\"/opt/homebrew/share/qemu/edk2-x86_64-code.fd\"\n    OVMF_VARS_TEMPLATE=\"/opt/homebrew/share/qemu/edk2-i386-vars.fd\"\n  elif [[ -f \"/usr/local/share/qemu/edk2-x86_64-code.fd\" ]]; then\n    OVMF_CODE=\"/usr/local/share/qemu/edk2-x86_64-code.fd\"\n    OVMF_VARS_TEMPLATE=\"/usr/local/share/qemu/edk2-i386-vars.fd\"\n  elif [[ -f \"/usr/share/OVMF/OVMF_CODE_4M.fd\" ]]; then\n    OVMF_CODE=\"/usr/share/OVMF/OVMF_CODE_4M.fd\"\n    OVMF_VARS_TEMPLATE=\"/usr/share/OVMF/OVMF_VARS_4M.fd\"\n  else\n    echo \"Error: OVMF firmware not found. On macOS: brew install qemu\" >&2\n    exit 1\n  fi\n}\n\n# Boot the VM\nboot_vm() {\n  local image=\"$1\"\n  local memory=\"$2\"\n  local cores=\"$3\"\n  local disk_size=\"$4\"\n  local ssh_port=\"$5\"\n  local http_port=\"$6\"\n\n  init_state\n  detect_ovmf\n\n  if [[ ! -f \"$image\" ]]; then\n    echo \"Error: Image not found: $image\" >&2\n    exit 1\n  fi\n\n  command -v qemu-img >/dev/null 2>&1 || { echo \"Error: 'qemu-img' not found in PATH\" >&2; exit 1; }\n  command -v qemu-system-x86_64 >/dev/null 2>&1 || { echo \"Error: 'qemu-system-x86_64' not found in PATH\" >&2; exit 1; }\n\n  # Setup OVMF VARS\n  local ovmf_vars=\"$STATE_DIR/ovmf-vars.fd\"\n  if [[ ! -f \"$ovmf_vars\" ]]; then\n    cp \"$OVMF_VARS_TEMPLATE\" \"$ovmf_vars\"\n  fi\n\n  # Setup overlay disk\n  local overlay=\"$STATE_DIR/overlay.qcow2\"\n  if [[ ! -f \"$overlay\" ]]; then\n    echo \"Creating overlay image...\"\n    local image_abs\n    image_abs=\"$(cd \"$(dirname \"$image\")\" && pwd)/$(basename \"$image\")\"\n    qemu-img create -f qcow2 -F raw -b \"$image_abs\" \"$overlay\" \"$disk_size\" >/dev/null\n  else\n    echo \"Using existing overlay image\"\n  fi\n\n  # Build NVMe arguments\n  local nvme_args\n  nvme_args=$(build_nvme_args)\n\n  # Platform-specific acceleration\n  local accel_args\n  local qemu_sudo=\"\"\n  case \"$(uname -s)\" in\n    Linux)\n      accel_args=\"-enable-kvm -machine accel=kvm,type=q35 -cpu host\"\n      # Use sudo for KVM access on Linux\n      qemu_sudo=\"sudo\"\n      ;;\n    Darwin)\n      if qemu-system-x86_64 -accel help 2>&1 | grep -q hvf; then\n        accel_args=\"-machine accel=hvf,type=q35 -cpu max\"\n      else\n        echo \"WARNING: HVF not available, using TCG (slow)\" >&2\n        accel_args=\"-machine accel=tcg,type=q35 -cpu max\"\n      fi\n      ;;\n    *)\n      echo \"Error: Unsupported platform: $(uname -s)\" >&2\n      exit 1\n      ;;\n  esac\n\n  echo \"Booting VM...\"\n  echo \"  SSH: ssh -p ${ssh_port} umbrel@localhost\"\n  echo \"  HTTP: http://localhost:${http_port}\"\n  echo\n\n  # shellcheck disable=SC2086\n  exec $qemu_sudo qemu-system-x86_64 \\\n    $accel_args \\\n    -smp \"$cores\" \\\n    -m \"$memory\" \\\n    -rtc base=utc \\\n    -nographic -monitor none -chardev stdio,id=char0,signal=off -serial chardev:char0 \\\n    -smbios \"type=1,manufacturer=Umbrel,, Inc.,product=Umbrel Pro,sku=U4XN1,family=NAS\" \\\n    -drive if=pflash,format=raw,readonly=on,file=\"$OVMF_CODE\" \\\n    -drive if=pflash,format=raw,file=\"$ovmf_vars\" \\\n    -drive file=\"$overlay\",if=none,id=boot,format=qcow2,cache=none,discard=unmap,aio=threads \\\n    -device virtio-blk-pci,drive=boot,bootindex=0 \\\n    -netdev user,id=net0,hostfwd=tcp:127.0.0.1:${ssh_port}-:22,hostfwd=tcp:127.0.0.1:${http_port}-:80 \\\n    -device virtio-net-pci,netdev=net0 \\\n    $nvme_args\n}\n\n# Reflash (delete overlay to simulate fresh OS install)\nreflash() {\n  local overlay=\"$STATE_DIR/overlay.qcow2\"\n  if [[ -f \"$overlay\" ]]; then\n    echo \"Removing boot disk overlay...\"\n    rm -f \"$overlay\"\n    echo \"Done. Next boot will start fresh.\"\n  else\n    echo \"No overlay to remove.\"\n  fi\n}\n\n# Reset all state\nreset_state() {\n  if [[ -d \"$STATE_DIR\" ]]; then\n    echo \"Removing VM state directory: $STATE_DIR\"\n    rm -rf \"$STATE_DIR\"\n    echo \"Done.\"\n  else\n    echo \"No state to reset.\"\n  fi\n}\n\n# Main\nif [[ $# -lt 1 ]]; then\n  show_help\n  exit 1\nfi\n\ncommand=\"$1\"\nshift\n\ncase \"$command\" in\n  help|--help|-h)\n    show_help\n    exit 0\n    ;;\n\n  reflash)\n    reflash\n    exit 0\n    ;;\n\n  reset)\n    reset_state\n    exit 0\n    ;;\n\n  nvme)\n    if [[ $# -lt 1 ]]; then\n      echo \"Error: nvme requires a subcommand\" >&2\n      echo \"Usage: $0 nvme <list|add|destroy|connect|disconnect> [args]\" >&2\n      exit 1\n    fi\n\n    subcommand=\"$1\"\n    shift\n\n    case \"$subcommand\" in\n      list)\n        nvme_list\n        ;;\n      add)\n        if [[ $# -lt 1 ]]; then\n          echo \"Error: nvme add requires a slot number\" >&2\n          exit 1\n        fi\n        slot=\"$1\"\n        shift\n        size=\"$DEFAULT_NVME_SIZE\"\n        while [[ $# -gt 0 ]]; do\n          case \"$1\" in\n            --size)\n              size=\"$2\"\n              shift 2\n              ;;\n            *)\n              echo \"Error: Unknown option: $1\" >&2\n              exit 1\n              ;;\n          esac\n        done\n        nvme_add \"$slot\" \"$size\"\n        ;;\n      destroy)\n        if [[ $# -lt 1 ]]; then\n          echo \"Error: nvme destroy requires a slot number\" >&2\n          exit 1\n        fi\n        nvme_destroy \"$1\"\n        ;;\n      connect)\n        if [[ $# -lt 1 ]]; then\n          echo \"Error: nvme connect requires a slot number\" >&2\n          exit 1\n        fi\n        nvme_connect \"$1\"\n        ;;\n      disconnect)\n        if [[ $# -lt 1 ]]; then\n          echo \"Error: nvme disconnect requires a slot number\" >&2\n          exit 1\n        fi\n        nvme_disconnect \"$1\"\n        ;;\n      move)\n        if [[ $# -lt 2 ]]; then\n          echo \"Error: nvme move requires source and destination slot numbers\" >&2\n          echo \"Usage: $0 nvme move <from-slot> <to-slot>\" >&2\n          exit 1\n        fi\n        nvme_move \"$1\" \"$2\"\n        ;;\n      *)\n        echo \"Error: Unknown nvme subcommand: $subcommand\" >&2\n        echo \"Usage: $0 nvme <list|add|destroy|connect|disconnect|move> [args]\" >&2\n        exit 1\n        ;;\n    esac\n    exit 0\n    ;;\n\n  boot)\n    if [[ $# -lt 1 ]]; then\n      echo \"Error: boot requires an image path\" >&2\n      exit 1\n    fi\n\n    image=\"$1\"\n    shift\n\n    memory=\"$DEFAULT_MEMORY\"\n    cores=\"$DEFAULT_CORES\"\n    disk_size=\"$DEFAULT_DISK_SIZE\"\n    ssh_port=\"$DEFAULT_SSH_PORT\"\n    http_port=\"$DEFAULT_HTTP_PORT\"\n\n    while [[ $# -gt 0 ]]; do\n      case \"$1\" in\n        --memory)\n          memory=\"$2\"\n          shift 2\n          ;;\n        --cores)\n          cores=\"$2\"\n          shift 2\n          ;;\n        --disk-size)\n          disk_size=\"$2\"\n          shift 2\n          ;;\n        --ssh-port)\n          ssh_port=\"$2\"\n          shift 2\n          ;;\n        --http-port)\n          http_port=\"$2\"\n          shift 2\n          ;;\n        *)\n          echo \"Error: Unknown option: $1\" >&2\n          exit 1\n          ;;\n      esac\n    done\n\n    boot_vm \"$image\" \"$memory\" \"$cores\" \"$disk_size\" \"$ssh_port\" \"$http_port\"\n    ;;\n\n  *)\n    echo \"Error: Unknown command: $command\" >&2\n    show_help\n    exit 1\n    ;;\nesac\n"
  },
  {
    "path": "packages/ui/.dockerignore",
    "content": "node_modules\n.git\n.dockerignore\nDockerfile\nREADME.md\nnpm-debug.log"
  },
  {
    "path": "packages/ui/.gitignore",
    "content": "# custom\npublic/generated-tabler-icons\ntodo.md\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-app-auth\ndist-ssr\n*.local\n\n# Editor directories and files\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "packages/ui/.prettierignore",
    "content": "package-lock.json\nnode_modules\npublic/locales/*.json"
  },
  {
    "path": "packages/ui/.prettierrc.js",
    "content": "import baseConfig from '../../.prettierrc.js'\n\n/**\n * @type {import('prettier').Config & import(\"@ianvs/prettier-plugin-sort-imports\").PluginConfig}\n */\nexport default {\n\t...baseConfig,\n\tplugins: [\n\t\t...(baseConfig.plugins || []),\n\t\t'@ianvs/prettier-plugin-sort-imports',\n\t\t'prettier-plugin-css-order',\n\t\t'prettier-plugin-tailwindcss', // must come last\n\t],\n\t// Empty string to separate groups\n\timportOrder: ['<THIRD_PARTY_MODULES>', '', '^@/', '', '^[../]', '^[./]'],\n\timportOrderParserPlugins: ['typescript', 'jsx'],\n\timportOrderTypeScriptVersion: '4.4.0',\n\ttailwindStylesheet: './src/index.css',\n}\n"
  },
  {
    "path": "packages/ui/Dockerfile",
    "content": "FROM node:18.19.1-buster-slim\n\n# Set the working directory\nWORKDIR /app\n\n# Copy the package.json and package-lock.json\nCOPY packages/ui/package.json ./\nCOPY packages/ui/package-lock.json ./\n\n# Install the dependencies\nRUN npm ci\n\n# Copy the rest of the files\nCOPY packages/ui/ .\n\n# Build the app\nRUN npm run app-auth:build\n\n# Expose the port\nEXPOSE 2003\n\n# Start the app\nCMD [\"npm\", \"run\", \"app-auth:start\"]\n"
  },
  {
    "path": "packages/ui/app-auth/README.md",
    "content": "# Local testing\n\nMake sure umbreld is running\n\n```\ncd packages/umbreld\nnpm run dev\n```\n\nThen in another terminal\n\n```\ncd packages/ui\npnpm run app-auth:dev\n```\n\nGo to `localhost:3001` and make sure you're logged out.\n\nThen, assuming `transmission` is installed and running, open:\nhttp://localhost:2001/app-auth/?origin=host&app=transmission&path=%2Ftransmission%2Fweb%2F\n\nIn production, it would be:\nhttp://localhost:2000/?origin=host&app=transmission&path=%2Ftransmission%2Fweb%2F\n\nLogin with password and 2fa should work and you should be redirected to the right page.\n"
  },
  {
    "path": "packages/ui/app-auth/index.html",
    "content": "<!doctype html>\n<html class=\"h-full min-h-full\">\n\t<head>\n\t\t<meta charset=\"UTF-8\" />\n\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1\" />\n\t\t<meta name=\"theme-color\" content=\"#000000\" />\n\t\t<meta name=\"robots\" content=\"noindex, nofollow\" />\n\t\t<meta name=\"referrer\" content=\"no-referrer\" />\n\n\t\t<link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/favicon/apple-touch-icon.png\" />\n\t\t<link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/favicon/favicon-32x32.png\" />\n\t\t<link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicon/favicon-16x16.png\" />\n\t\t<link rel=\"manifest\" href=\"/site.webmanifest\" />\n\n\t\t<title>Umbrel</title>\n\t</head>\n\t<body style=\"background: black; color: white\" class=\"h-full min-h-full\">\n\t\t<noscript>\n\t\t\t<h1>umbrelOS</h1>\n\t\t\t<p>You need to enable JavaScript to run this app.</p>\n\t\t</noscript>\n\t\t<div id=\"root\" class=\"h-full min-h-full\"></div>\n\t\t<script type=\"module\" src=\"/app-auth/src/main.tsx\"></script>\n\t</body>\n</html>\n"
  },
  {
    "path": "packages/ui/app-auth/src/login-with-umbrel.tsx",
    "content": "import {ReactNode, useEffect, useState} from 'react'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {Button} from '@/components/ui/button'\nimport {FadeInImg} from '@/components/ui/fade-in-img'\nimport {PasswordInput} from '@/components/ui/input'\nimport {PinInput} from '@/components/ui/pin-input'\nimport {toast} from '@/components/ui/toast'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {cn} from '@/lib/utils'\nimport {useWallpaperCssVars, WallpaperId, wallpaperIds} from '@/providers/wallpaper'\nimport {t} from '@/utils/i18n'\n\ntype Step = 'password' | '2fa'\n\nexport default function LoginWithUmbrel() {\n\tconst [password, setPassword] = useState('')\n\tconst [step, setStep] = useState<Step>('password')\n\n\tconst login = useLogin()\n\n\tconst handleSubmitPassword = async (e: React.FormEvent<HTMLFormElement>) => {\n\t\te.preventDefault()\n\t\t// alert('submit')\n\t\ttry {\n\t\t\tconst data = await login({password, totpToken: ''})\n\t\t\tif ('error' in data && data.error) {\n\t\t\t\tif (data.error.message === 'Missing 2FA code') {\n\t\t\t\t\tsetStep('2fa')\n\t\t\t\t} else {\n\t\t\t\t\ttoast.error(data.error.message)\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error: any) {\n\t\t\tif (error.message === 'Missing 2FA code') {\n\t\t\t\tsetStep('2fa')\n\t\t\t} else {\n\t\t\t\ttoast.error(error?.message)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Specifying return because we want to ensure that the return type is a boolean for the `onCodeCheck` prop\n\tconst handleSubmit2fa = async (totpToken: string): Promise<boolean> => {\n\t\tconst data = await login({password, totpToken})\n\n\t\treturn 'error' in data\n\t}\n\n\tswitch (step) {\n\t\tcase 'password': {\n\t\t\treturn (\n\t\t\t\t<LoginWithLayout>\n\t\t\t\t\t<form className='contents' onSubmit={handleSubmitPassword}>\n\t\t\t\t\t\t{/* <JSONTree data={params.object} /> */}\n\t\t\t\t\t\t<PasswordInput\n\t\t\t\t\t\t\tlabel={t('login.password-label')}\n\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\tvalue={password}\n\t\t\t\t\t\t\tonValueChange={setPassword}\n\t\t\t\t\t\t\t// error={loginMut.error?.message}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<Button type='submit' variant={'primary'} size='lg' className='text-13'>\n\t\t\t\t\t\t\t\t{t('login.password.submit')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</form>\n\t\t\t\t</LoginWithLayout>\n\t\t\t)\n\t\t}\n\t\tcase '2fa': {\n\t\t\treturn (\n\t\t\t\t<LoginWithLayout>\n\t\t\t\t\t<form className='contents' onSubmit={handleSubmitPassword}>\n\t\t\t\t\t\t<PinInput autoFocus length={6} onCodeCheck={handleSubmit2fa} />\n\t\t\t\t\t</form>\n\t\t\t\t</LoginWithLayout>\n\t\t\t)\n\t\t}\n\t}\n}\n\nfunction useLogin() {\n\t// /v1/account/login\n\n\tconst login = ({password, totpToken}: {password: string; totpToken: string}) => {\n\t\t// Forward the query params to the login endpoint\n\t\treturn fetch('/v1/account/login' + document.location.search, {\n\t\t\tmethod: 'POST',\n\t\t\theaders: {'Content-Type': 'application/json'},\n\t\t\tbody: JSON.stringify({password, totpToken}),\n\t\t}).then(async (res) => {\n\t\t\tconst data = (await res.json()) as\n\t\t\t\t| {\n\t\t\t\t\t\turl: string\n\t\t\t\t\t\tparams: {r: string; token: string; signature: string}\n\t\t\t\t  }\n\t\t\t\t| {error?: {code: number; message: string}}\n\n\t\t\t// \t{\n\t\t\t// \t\t\"error\": {\n\t\t\t// \t\t\t\t\"message\": \"Missing 2FA code\",\n\t\t\t// \t\t\t\t\"code\": -32001,\n\t\t\t// \t\t\t\t\"data\": {\n\t\t\t// \t\t\t\t\t\t\"code\": \"UNAUTHORIZED\",\n\t\t\t// \t\t\t\t\t\t\"httpStatus\": 401,\n\t\t\t// \t\t\t\t\t\t\"stack\": \"TRPCError: Missing 2FA code...\n\t\t\t// \t\t\t\t\t\t\"path\": \"user.login\",\n\t\t\t// \t\t\t\t\t\t\"zodError\": null\n\t\t\t// \t\t\t\t}\n\t\t\t// \t\t}\n\t\t\t// }\n\n\t\t\tif ('url' in data) {\n\t\t\t\t// \tconst json = {\n\t\t\t\t// \t\t\"url\": \"http://localhost:3011/umbrel_/api/v1/auth/token\",\n\t\t\t\t// \t\t\"params\": {\n\t\t\t\t// \t\t\t\t\"r\": \"/\",\n\t\t\t\t// \t\t\t\t\"token\": \"eyJhbGciOiJI...\",\n\t\t\t\t// \t\t\t\t\"signature\": \"NUH1ZzFEeS...\"\n\t\t\t\t// \t\t}\n\t\t\t\t// }\n\n\t\t\t\tconst form = document.createElement('form')\n\t\t\t\tform.method = 'POST'\n\t\t\t\tform.action = data.url\n\t\t\t\tform.style.display = 'none'\n\t\t\t\tfor (const [key, value] of Object.entries(data.params)) {\n\t\t\t\t\tconst input = document.createElement('input')\n\t\t\t\t\tinput.type = 'hidden'\n\t\t\t\t\tinput.name = key\n\t\t\t\t\tinput.value = value\n\t\t\t\t\tform.appendChild(input)\n\t\t\t\t}\n\t\t\t\tdocument.body.appendChild(form)\n\t\t\t\tform.submit()\n\t\t\t}\n\n\t\t\treturn data\n\t\t})\n\t}\n\n\treturn login\n}\n\ntype App = {\n\tid: string\n\ticon: string\n\tname: string\n}\n\nfunction useApp(appId: string) {\n\tconst [app, setApp] = useState<App>({id: '', icon: '', name: ''})\n\n\t// const [searchParams, setSearchParams] = useSearchParams()\n\n\tuseEffect(() => {\n\t\tfetch(`/v1/apps?app=${appId}`).then(async (res) => {\n\t\t\tconst data = await res.json()\n\t\t\tsetApp({...data, icon: appId ? `https://getumbrel.github.io/umbrel-apps-gallery/${appId}/icon.svg` : undefined})\n\t\t})\n\t}, [appId])\n\n\treturn app\n}\n\nfunction useWallpaperId() {\n\tconst [wallpaper, setWallpaper] = useState<WallpaperId>()\n\n\tuseEffect(() => {\n\t\tfetch('/v1/account/wallpaper')\n\t\t\t.then(async (res) => {\n\t\t\t\t// `unknown` because `any` is too loose\n\t\t\t\tconst id = (await res.text()) as unknown\n\t\t\t\tconst knownId = arrayIncludes(wallpaperIds, id) ? id : '18'\n\t\t\t\tsetWallpaper(knownId)\n\t\t\t})\n\t\t\t.catch(() => {\n\t\t\t\tsetWallpaper('18')\n\t\t\t})\n\t}, [])\n\n\treturn wallpaper\n}\n\nfunction LoginWithLayout({children}: {children: ReactNode}) {\n\tconst params = useQueryParams<{app: string; path: string; host: string}>()\n\tconst app = useApp(params.object.app)\n\tconst wallpaperId = useWallpaperId()\n\n\tuseWallpaperCssVars(wallpaperId)\n\n\treturn (\n\t\t<>\n\t\t\t<FadeInImg\n\t\t\t\tsrc={`/wallpapers/generated-thumbs/${wallpaperId}.jpg`}\n\t\t\t\tclassName='pointer-events-none fixed inset-0 h-full w-full scale-125 object-cover object-center blur-[var(--wallpaper-blur)] duration-1000'\n\t\t\t/>\n\t\t\t<div className='fixed inset-0 bg-black/50 contrast-more:bg-black' />\n\t\t\t<div className='relative flex min-h-[100dvh] flex-col items-center justify-between p-5'>\n\t\t\t\t<div className='flex h-full w-full flex-grow items-center justify-center'>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t'w-full rounded-20 bg-dialog-content/70 p-8 shadow-dialog sm:max-w-[480px]',\n\t\t\t\t\t\t\t'flex flex-col gap-5',\n\t\t\t\t\t\t\t'animate-in duration-200 ease-out zoom-in-90 fade-in',\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className='flex h-0 -translate-y-[56px] gap-5'>\n\t\t\t\t\t\t\t<AppIcon src='/figma-exports/umbrel-ios.png' size={56} className='rounded-12' />\n\t\t\t\t\t\t\t<AppIcon src={app.icon} size={56} className='rounded-12 bg-neutral-600' />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className='flex flex-col gap-1'>\n\t\t\t\t\t\t\t<h1 className='truncate text-17 leading-tight font-semibold -tracking-2'>\n\t\t\t\t\t\t\t\t{t('login-with-umbrel.title')}\n\t\t\t\t\t\t\t</h1>\n\t\t\t\t\t\t\t<p className='text-13 leading-tight -tracking-2 text-white/40'>\n\t\t\t\t\t\t\t\t{t('login-with-umbrel.description', {app: app.name})}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{children}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/app-auth/src/main.tsx",
    "content": "import {BrowserRouter} from 'react-router-dom'\n\nimport {init} from '../../src/init'\nimport LoginWithUmbrel from './login-with-umbrel'\n\ninit(\n\t// NOTE: not putting `GlobalSystemStateProvider` here because we don't care.\n\t// It doesn't matter for the auth page\n\t<BrowserRouter>\n\t\t<LoginWithUmbrel />\n\t</BrowserRouter>,\n)\n"
  },
  {
    "path": "packages/ui/app-auth/vite.config.ts",
    "content": "import path from 'node:path'\nimport react from '@vitejs/plugin-react'\nimport {defineConfig} from 'vite'\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n\tplugins: [react()],\n\tserver: {\n\t\tproxy: {\n\t\t\t'/v1': 'http://localhost:2000',\n\t\t},\n\t},\n\tresolve: {\n\t\talias: {\n\t\t\t'@/': `${path.resolve(__dirname, '../src')}/`,\n\t\t},\n\t},\n\tbuild: {\n\t\trollupOptions: {\n\t\t\tinput: {\n\t\t\t\tindex: path.resolve(__dirname, 'index.html'),\n\t\t\t},\n\t\t\toutput: {\n\t\t\t\tminifyInternalExports: true,\n\t\t\t},\n\t\t},\n\t\toutDir: 'dist-app-auth',\n\t},\n})\n"
  },
  {
    "path": "packages/ui/components.json",
    "content": "{\n\t\"$schema\": \"https://ui.shadcn.com/schema.json\",\n\t\"style\": \"default\",\n\t\"rsc\": false,\n\t\"tsx\": true,\n\t\"tailwind\": {\n\t\t\"config\": \"tailwind.config.ts\",\n\t\t\"css\": \"src/index.css\",\n\t\t\"baseColor\": \"neutral\",\n\t\t\"cssVariables\": false\n\t},\n\t\"aliases\": {\n\t\t\"components\": \"@/components\",\n\t\t\"ui\": \"@/components/ui\",\n\t\t\"utils\": \"@/lib/utils\",\n\t\t\"lib\": \"@/lib\",\n\t\t\"hooks\": \"@/hooks\"\n\t}\n}\n"
  },
  {
    "path": "packages/ui/eslint.config.js",
    "content": "import js from '@eslint/js'\nimport pluginQuery from '@tanstack/eslint-plugin-query'\nimport pluginReact from 'eslint-plugin-react'\nimport pluginReactHooks from 'eslint-plugin-react-hooks'\nimport pluginReactRefresh from 'eslint-plugin-react-refresh'\nimport globals from 'globals'\nimport tseslint from 'typescript-eslint'\n\nexport default [\n\t// Global ignores\n\t{\n\t\tignores: ['dist/**', 'dist-app-auth/**'],\n\t},\n\n\t// Base JS recommended rules\n\tjs.configs.recommended,\n\n\t// TypeScript recommended rules\n\t...tseslint.configs.recommended,\n\n\t// TanStack Query recommended rules\n\t...pluginQuery.configs['flat/recommended'],\n\n\t// Main config for all JS/TS files\n\t{\n\t\tfiles: ['**/*.{js,jsx,ts,tsx}'],\n\t\tlanguageOptions: {\n\t\t\tglobals: {\n\t\t\t\t...globals.browser,\n\t\t\t\t...globals.es2020,\n\t\t\t},\n\t\t},\n\t\tplugins: {\n\t\t\treact: pluginReact,\n\t\t\t'react-hooks': pluginReactHooks,\n\t\t\t'react-refresh': pluginReactRefresh,\n\t\t},\n\t\tsettings: {\n\t\t\treact: {version: '19'},\n\t\t},\n\t\trules: {\n\t\t\t'react/jsx-key': 'error',\n\t\t\t// Prettier configured to use tabs, which means smart tabs. We don't manually indent anyways\n\t\t\t'no-mixed-spaces-and-tabs': 'off',\n\t\t\t// Ignore even if it's true that it catches problems fast refresh\n\t\t\t'react-refresh/only-export-components': ['off', {allowConstantExport: true}],\n\t\t\t// https://github.com/prettier/prettier/issues/2800\n\t\t\t// Prettier will remove extra semi-colons anyways. Prevent error when prettier puts a semicolon before an IIFE\n\t\t\t'no-extra-semi': 'off',\n\t\t\t// shadcn ui sometimes takes an unused `children` prop so it's not spread into an element that shouldn't take children\n\t\t\t'@typescript-eslint/no-unused-vars': 'warn',\n\t\t\t// Allow any\n\t\t\t'@typescript-eslint/no-explicit-any': 'off',\n\t\t},\n\t},\n]\n"
  },
  {
    "path": "packages/ui/index.html",
    "content": "<!doctype html>\n<html class=\"h-full min-h-full\">\n\t<head>\n\t\t<meta charset=\"UTF-8\" />\n\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1\" />\n\t\t<meta name=\"theme-color\" content=\"#000000\" />\n\t\t<meta name=\"robots\" content=\"noindex, nofollow\" />\n\t\t<meta name=\"referrer\" content=\"no-referrer\" />\n\n\t\t<link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/favicon/apple-touch-icon.png\" />\n\t\t<link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/favicon/favicon-32x32.png\" />\n\t\t<link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicon/favicon-16x16.png\" />\n\t\t<link rel=\"manifest\" href=\"/site.webmanifest\" />\n\n\t\t<title>Umbrel</title>\n\t</head>\n\t<body style=\"background: black; color: white\" class=\"h-full min-h-full\">\n\t\t<noscript>\n\t\t\t<h1>umbrelOS</h1>\n\t\t\t<p>You need to enable JavaScript to run this app.</p>\n\t\t</noscript>\n\t\t<div id=\"root\" class=\"h-full min-h-full\"></div>\n\t\t<script type=\"module\" src=\"/src/main.tsx\"></script>\n\t</body>\n</html>\n"
  },
  {
    "path": "packages/ui/package.json",
    "content": "{\n\t\"name\": \"ui\",\n\t\"private\": true,\n\t\"version\": \"0.0.0\",\n\t\"type\": \"module\",\n\t\"engines\": {\n\t\t\"node\": \"^22.13.0\"\n\t},\n\t\"scripts\": {\n\t\t\"dev\": \"vite --port 3000\",\n\t\t\"build\": \"vite build\",\n\t\t\"app-auth:dev\": \"vite --config app-auth/vite.config.js --port 2001\",\n\t\t\"app-auth:build\": \"vite build --config app-auth/vite.config.js && mv dist-app-auth/app-auth/index.html dist-app-auth/index.html\",\n\t\t\"app-auth:start\": \"serve -p 2003 dist-app-auth\",\n\t\t\"lint\": \"eslint .\",\n\t\t\"typecheck\": \"tsc --noEmit\",\n\t\t\"preview\": \"vite preview\",\n\t\t\"format\": \"prettier --write .\",\n\t\t\"format:check\": \"prettier --check .\",\n\t\t\"tsc\": \"tsc --noEmit\",\n\t\t\"size\": \"vite-bundle-visualizer\",\n\t\t\"copy-tabler-icons\": \"mkdir -p public/generated-tabler-icons && cp -r ./node_modules/@tabler/icons/icons/. ./public/generated-tabler-icons\",\n\t\t\"postinstall\": \"npm run copy-tabler-icons\"\n\t},\n\t\"dependencies\": {\n\t\t\"@dnd-kit/core\": \"^6.3.1\",\n\t\t\"@dnd-kit/modifiers\": \"^9.0.0\",\n\t\t\"@dnd-kit/utilities\": \"^3.2.2\",\n\t\t\"@hookform/resolvers\": \"5.2.2\",\n\t\t\"@radix-ui/react-checkbox\": \"^1.3.3\",\n\t\t\"@radix-ui/react-context-menu\": \"^2.2.16\",\n\t\t\"@radix-ui/react-dialog\": \"^1.1.15\",\n\t\t\"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n\t\t\"@radix-ui/react-label\": \"^2.1.8\",\n\t\t\"@radix-ui/react-popover\": \"^1.1.15\",\n\t\t\"@radix-ui/react-portal\": \"^1.1.10\",\n\t\t\"@radix-ui/react-progress\": \"^1.1.8\",\n\t\t\"@radix-ui/react-radio-group\": \"^1.3.8\",\n\t\t\"@radix-ui/react-scroll-area\": \"^1.2.10\",\n\t\t\"@radix-ui/react-separator\": \"^1.1.8\",\n\t\t\"@radix-ui/react-slot\": \"^1.2.4\",\n\t\t\"@radix-ui/react-switch\": \"^1.2.6\",\n\t\t\"@radix-ui/react-tabs\": \"^1.1.13\",\n\t\t\"@radix-ui/react-tooltip\": \"^1.2.8\",\n\t\t\"@tanstack/react-query\": \"5.90.20\",\n\t\t\"@tanstack/react-query-devtools\": \"5.91.3\",\n\t\t\"@trpc/client\": \"11.1.1\",\n\t\t\"@trpc/react-query\": \"11.1.1\",\n\t\t\"@trpc/server\": \"11.1.1\",\n\t\t\"@xterm/addon-fit\": \"^0.9.0\",\n\t\t\"@xterm/xterm\": \"^5.4.0\",\n\t\t\"bignumber.js\": \"^9.1.2\",\n\t\t\"class-variance-authority\": \"0.7.1\",\n\t\t\"clsx\": \"2.1.1\",\n\t\t\"cmdk\": \"^1.1.1\",\n\t\t\"colorthief\": \"^2.4.0\",\n\t\t\"compute-scroll-into-view\": \"^3.1.0\",\n\t\t\"date-fns\": \"^3.0.6\",\n\t\t\"embla-carousel-react\": \"^8.6.0\",\n\t\t\"file-saver\": \"^2.0.5\",\n\t\t\"filenamify\": \"^6.0.0\",\n\t\t\"fuse.js\": \"^7.0.0\",\n\t\t\"i18next\": \"23.6.0\",\n\t\t\"i18next-browser-languagedetector\": \"7.1.0\",\n\t\t\"i18next-http-backend\": \"2.2.2\",\n\t\t\"inter-ui\": \"3.19.3\",\n\t\t\"lucide-react\": \"^0.440.0\",\n\t\t\"match-sorter\": \"6.3.1\",\n\t\t\"motion\": \"^12.33.0\",\n\t\t\"photoswipe\": \"^5.4.2\",\n\t\t\"pretty-bytes\": \"^6.1.1\",\n\t\t\"rci\": \"0.1.0\",\n\t\t\"react\": \"^19.0.0\",\n\t\t\"react-dom\": \"^19.0.0\",\n\t\t\"react-dropzone\": \"^14.3.5\",\n\t\t\"react-error-boundary\": \"^4.0.11\",\n\t\t\"react-hook-form\": \"7.71.1\",\n\t\t\"react-i18next\": \"13.3.1\",\n\t\t\"react-icons\": \"4.11.0\",\n\t\t\"react-json-tree\": \"^0.20.0\",\n\t\t\"react-markdown\": \"^9.0.1\",\n\t\t\"react-merge-refs\": \"^2.1.1\",\n\t\t\"react-qr-code\": \"^2.0.18\",\n\t\t\"react-router-dom\": \"^6.30.3\",\n\t\t\"react-use\": \"^17.6.0\",\n\t\t\"react-video-kit\": \"^3.0.0\",\n\t\t\"react-virtualized-auto-sizer\": \"^1.0.25\",\n\t\t\"react-window\": \"^1.8.11\",\n\t\t\"react-window-infinite-loader\": \"^1.0.10\",\n\t\t\"recharts\": \"^2.15.4\",\n\t\t\"remark-breaks\": \"^4.0.0\",\n\t\t\"remark-gfm\": \"^4.0.0\",\n\t\t\"remeda\": \"^1.28.0\",\n\t\t\"semver\": \"^7.6.3\",\n\t\t\"sonner\": \"^2.0.7\",\n\t\t\"tailwind-merge\": \"^3.0.0\",\n\t\t\"ts-extras\": \"^0.11.0\",\n\t\t\"tw-animate-css\": \"^1.4.0\",\n\t\t\"use-is-focused\": \"0.0.1\",\n\t\t\"vaul\": \"^1.1.2\",\n\t\t\"zod\": \"4.0.8\",\n\t\t\"zustand\": \"^5.0.2\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@eslint/js\": \"^9.0.0\",\n\t\t\"@ianvs/prettier-plugin-sort-imports\": \"4.7.1\",\n\t\t\"@tabler/icons\": \"2.39.0\",\n\t\t\"@tailwindcss/typography\": \"^0.5.19\",\n\t\t\"@tailwindcss/vite\": \"^4.0.0\",\n\t\t\"@tanstack/eslint-plugin-query\": \"^5.0.5\",\n\t\t\"@types/file-saver\": \"^2.0.7\",\n\t\t\"@types/node\": \"20.19.32\",\n\t\t\"@types/react\": \"^19.0.0\",\n\t\t\"@types/react-dom\": \"^19.0.0\",\n\t\t\"@types/react-virtualized-auto-sizer\": \"^1.0.4\",\n\t\t\"@types/react-window\": \"^1.8.8\",\n\t\t\"@types/react-window-infinite-loader\": \"^1.0.9\",\n\t\t\"@types/semver\": \"^7.5.4\",\n\t\t\"@vitejs/plugin-react\": \"^5.1.3\",\n\t\t\"babel-plugin-react-compiler\": \"^1.0.0\",\n\t\t\"eslint\": \"^9.0.0\",\n\t\t\"eslint-plugin-react\": \"^7.37.0\",\n\t\t\"eslint-plugin-react-hooks\": \"^5.0.0\",\n\t\t\"eslint-plugin-react-refresh\": \"^0.4.19\",\n\t\t\"fast-glob\": \"^3.3.2\",\n\t\t\"globals\": \"^15.0.0\",\n\t\t\"openai\": \"^6.0.0\",\n\t\t\"prettier\": \"3.8.1\",\n\t\t\"prettier-plugin-css-order\": \"2.2.0\",\n\t\t\"prettier-plugin-tailwindcss\": \"^0.7.2\",\n\t\t\"serve\": \"14.2.5\",\n\t\t\"tailwindcss\": \"^4.1.18\",\n\t\t\"ts-plugin-sort-import-suggestions\": \"^1.0.4\",\n\t\t\"typescript\": \"^5.8.3\",\n\t\t\"typescript-eslint\": \"^8.0.0\",\n\t\t\"vite\": \"^6.0.0\",\n\t\t\"vite-bundle-visualizer\": \"^1.2.0\",\n\t\t\"vite-imagetools\": \"^9.0.0\"\n\t}\n}\n"
  },
  {
    "path": "packages/ui/public/locales/de.json",
    "content": "{\n  \"2fa\": \"2FA\",\n  \"2fa-description\": \"Eine zweite Sicherheitsebene für dein Umbrel-Login und Apps\",\n  \"2fa.disable.title\": \"Zwei-Faktor-Authentifizierung deaktivieren\",\n  \"2fa.enable.or-paste\": \"Oder füge den folgenden Code in deine Authenticator-App ein\",\n  \"2fa.enable.scan-this\": \"Scanne diesen QR-Code mit einer Authenticator-App wie Google Authenticator oder Authy\",\n  \"2fa.enable.title\": \"Zwei-Faktor-Authentifizierung aktivieren\",\n  \"2fa.enter-code\": \"Gib den Code ein, der in deiner Authenticator-App angezeigt wird\",\n  \"account\": \"Konto\",\n  \"account-description\": \"Dein Name und Passwort\",\n  \"advanced-settings\": \"Erweiterte Einstellungen\",\n  \"advanced-settings-description\": \"Terminal, umbrelOS-Beta-Programm, Cloudflare DNS und mehr\",\n  \"app-not-found\": \"App nicht gefunden: {{app}}\",\n  \"app-only-over-tor\": \"{{app}} kann nur über Tor verwendet werden. Bitte rufe dein Umbrel in einem Tor-Browser über deine Remote‑Zugriffs‑URL (Einstellungen > Erweiterte Einstellungen > Remote Tor‑Zugriff) auf, um diese App zu öffnen.\",\n  \"app-page.section.about\": \"Über\",\n  \"app-page.section.credentials.title\": \"Standardanmeldeinformationen\",\n  \"app-page.section.dependencies.n-alternatives\": \"{{count}} Alternativen ansehen\",\n  \"app-page.section.info.compatibility\": \"Kompatibilität\",\n  \"app-page.section.info.compatibility-compatible\": \"Kompatibel\",\n  \"app-page.section.info.compatibility-not-compatible\": \"Nicht kompatibel\",\n  \"app-page.section.info.developer\": \"Entwickler\",\n  \"app-page.section.info.source-code\": \"Quellcode\",\n  \"app-page.section.info.source-code.public\": \"Öffentlich\",\n  \"app-page.section.info.submitted-by\": \"Eingereicht von\",\n  \"app-page.section.info.support\": \"Support erhalten\",\n  \"app-page.section.info.title\": \"Info\",\n  \"app-page.section.info.version\": \"Version\",\n  \"app-page.section.recommendations.title\": \"Das könnte dir auch gefallen\",\n  \"app-page.section.release-notes.title\": \"Neuigkeiten\",\n  \"app-page.section.release-notes.version\": \"Version {{version}}\",\n  \"app-page.section.requires\": \"Benötigt\",\n  \"app-picker.search\": \"Suchen...\",\n  \"app-picker.select-app\": \"App auswählen...\",\n  \"app-settings.connected-to\": \"{{appName}} ist mit diesen Apps verbunden\",\n  \"app-settings.save-changes\": \"Änderungen speichern\",\n  \"app-settings.title\": \"Einstellungen\",\n  \"app-store.browse-category-apps\": \"{{category}} Apps durchsuchen\",\n  \"app-store.category.ai\": \"KI\",\n  \"app-store.category.all\": \"Alle Apps\",\n  \"app-store.category.automation\": \"Zuhause & Automatisierung\",\n  \"app-store.category.bitcoin\": \"Bitcoin\",\n  \"app-store.category.crypto\": \"Krypto\",\n  \"app-store.category.developer\": \"Entwicklerwerkzeuge\",\n  \"app-store.category.discover\": \"Entdecken\",\n  \"app-store.category.files\": \"Dateien & Produktivität\",\n  \"app-store.category.finance\": \"Finanzen\",\n  \"app-store.category.media\": \"Medien\",\n  \"app-store.category.networking\": \"Netzwerk\",\n  \"app-store.category.social\": \"Sozial\",\n  \"app-store.description\": \"Deine App-Aktualisierungseinstellungen\",\n  \"app-store.discover.temporarily-unavailable-description\": \"Durchsuche die Kategorien oben oder verwende die Suche, um Apps zu finden\",\n  \"app-store.discover.temporarily-unavailable-title\": \"Ausgewählte Inhalte vorübergehend nicht verfügbar\",\n  \"app-store.menu.community-app-stores\": \"Community App Stores\",\n  \"app-store.search-apps\": \"Apps suchen\",\n  \"app-store.search.no-results\": \"Keine Ergebnisse\",\n  \"app-store.search.results-for\": \"Ergebnisse für\",\n  \"app-store.title\": \"App Store\",\n  \"app-store.updates\": \"Aktualisierungen\",\n  \"app-updates.less\": \"weniger\",\n  \"app-updates.more\": \"mehr\",\n  \"app-updates.no-updates\": \"Alle Apps sind auf dem neuesten Stand!\",\n  \"app-updates.update\": \"Aktualisieren\",\n  \"app-updates.update-all\": \"Alle aktualisieren\",\n  \"app-updates.updates-available-count_one\": \"{{count}} Aktualisierung verfügbar\",\n  \"app-updates.updates-available-count_other\": \"{{count}} Aktualisierungen verfügbar\",\n  \"app-updates.updating\": \"Aktualisiert...\",\n  \"app.install\": \"Installieren\",\n  \"app.installed\": \"Installiert\",\n  \"app.installing\": \"Installiert\",\n  \"app.offline\": \"Nicht in Betrieb\",\n  \"app.open\": \"Öffnen\",\n  \"app.optimized-for-umbrel-home\": \"Optimiert für Umbrel Home\",\n  \"app.os-update-required.confirm\": \"Prüfe auf umbrelOS-Update\",\n  \"app.os-update-required.description\": \"{{appName}} erfordert umbrelOS {{version}} oder neuer\",\n  \"app.os-update-required.title\": \"umbrelOS aktualisieren\",\n  \"app.restarting\": \"Wird neu gestartet\",\n  \"app.starting\": \"Wird gestartet\",\n  \"app.stopping\": \"Wird gestoppt\",\n  \"app.uninstall.confirm.description\": \"Alle mit {{app}} verbundenen Daten werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.\",\n  \"app.uninstall.confirm.submit\": \"Deinstallieren\",\n  \"app.uninstall.confirm.title\": \"{{app}} deinstallieren?\",\n  \"app.uninstall.deps.used-by.description_one\": \"Deinstalliere zuerst {{firstAppToUninstall}}, um {{app}} zu deinstallieren.\",\n  \"app.uninstall.deps.used-by.description_other\": \"Deinstalliere zuerst diese Apps, um {{app}} zu deinstallieren.\",\n  \"app.uninstall.deps.used-by.title\": \"{{app}} wird verwendet von\",\n  \"app.uninstalling\": \"Deinstallieren\",\n  \"app.updating\": \"Aktualisieren\",\n  \"app.view\": \"Ansehen\",\n  \"app_one\": \"App\",\n  \"app_other\": \"Apps\",\n  \"apps.uninstall.failed-to-get-required-apps\": \"Fehler beim Abrufen erforderlicher Apps\",\n  \"apps.uninstalled-all.success\": \"Alle Apps deinstalliert\",\n  \"auth.checking-backend-for-user\": \"Lädt...\",\n  \"auth.failed-checking-if-user-logged-in\": \"Fehler: Auth-Login-Überprüfung fehlgeschlagen\",\n  \"auth.failed-to-check-if-user-exists\": \"Fehler: Auth-Existenzprüfung fehlgeschlagen\",\n  \"back\": \"Zurück\",\n  \"backups\": \"Backups\",\n  \"backups-configure\": \"Konfigurieren\",\n  \"backups-configure.add-backup-location\": \"Backup-Standort hinzufügen\",\n  \"backups-configure.available\": \"Verfügbar\",\n  \"backups-configure.awaiting-next-backup\": \"Warten auf das nächste automatische Backup\",\n  \"backups-configure.back-up-now\": \"Jetzt sichern\",\n  \"backups-configure.backing-up-now\": \"Sicherung läuft...\",\n  \"backups-configure.connected\": \"Verbunden\",\n  \"backups-configure.connection\": \"Verbindung\",\n  \"backups-configure.in-progress\": \"In Bearbeitung\",\n  \"backups-configure.last-backup\": \"Letzte Sicherung\",\n  \"backups-configure.locations\": \"Standorte\",\n  \"backups-configure.no-backup-locations\": \"Füge einen Backup-Standort hinzu, um deine Daten zu sichern\",\n  \"backups-configure.not-connected\": \"Nicht verbunden\",\n  \"backups-configure.path\": \"Pfad\",\n  \"backups-configure.remove-backup-location\": \"Backup-Standort entfernen\",\n  \"backups-configure.remove-backup-location-confirmation\": \"Bist du sicher?\",\n  \"backups-configure.remove-backup-location-confirmation-description\": \"Dadurch wird '{{device}}' aus deinen Backup-Standorten entfernt. Deine vorhandenen Backups auf diesem Gerät werden nicht gelöscht, aber automatische Backups werden eingestellt.\",\n  \"backups-configure.status\": \"Status\",\n  \"backups-configure.total-backups\": \"Backups insgesamt\",\n  \"backups-configure.used\": \"Belegt\",\n  \"backups-configure.view\": \"Ansehen\",\n  \"backups-description\": \"Sichere deine Dateien, Apps und Daten auf einem anderen Umbrel, NAS oder einem externen Laufwerk\",\n  \"backups-error.backup-not-found\": \"Das Backup konnte nicht gefunden werden.\",\n  \"backups-error.generic\": \"Etwas ist schiefgelaufen: {{details}}\",\n  \"backups-error.in-progress\": \"Ein Backup-Prozess läuft bereits. Bitte warte, bis er abgeschlossen ist.\",\n  \"backups-error.invalid-exclusion-path\": \"Nur Dateien und Ordner in deinem Home-Verzeichnis können von Backups ausgeschlossen werden.\",\n  \"backups-error.invalid-password\": \"Das Verschlüsselungspasswort ist falsch.\",\n  \"backups-error.invalid-path\": \"Der ausgewählte Speicherort ist für Backups nicht geeignet.\",\n  \"backups-error.mount-failed\": \"Auf den Backup-Snapshot konnte nicht zugegriffen werden.\",\n  \"backups-error.mount-timeout\": \"Auf den Backup-Snapshot konnte nicht zugegriffen werden. Versuche es erneut oder prüfe, ob das Gerät richtig angeschlossen ist.\",\n  \"backups-error.not-enough-space\": \"Auf dem Backup-Gerät ist nicht genug Speicherplatz verfügbar.\",\n  \"backups-error.not-found\": \"Das Backup oder der Backup-Speicherort konnten nicht gefunden werden.\",\n  \"backups-error.repository-exists\": \"Für diesen Ordner existiert bereits ein Backup-Speicherort.\",\n  \"backups-error.repository-not-found\": \"Der Backup-Speicherort konnte nicht gefunden werden.\",\n  \"backups-exclusions.add\": \"Hinzufügen\",\n  \"backups-exclusions.app-paths-cannot-be-modified\": \"Diese Dateien/Ordner werden vom App-Entwickler festgelegt und können nicht geändert werden:\",\n  \"backups-exclusions.app-paths-explanation\": \"Diese App schließt die folgenden Daten vom Backup aus. Diese Pfade enthalten meist nicht essentielle Elemente (wie Caches oder Logs, die neu erzeugt werden können) oder Daten, die Probleme verursachen könnten, wenn sie wiederhergestellt werden (z. B. veraltete App-Zustände, die Konflikte oder Inkonsistenzen auslösen könnten).\",\n  \"backups-exclusions.auto-excluded\": \"Automatisch ausgeschlossen\",\n  \"backups-exclusions.exclude-entire-app\": \"Ganze App ausschließen\",\n  \"backups-exclusions.excluded-apps\": \"Ausgeschlossene Apps\",\n  \"backups-exclusions.files-and-folders\": \"Ausgeschlossene Dateien und Ordner\",\n  \"backups-exclusions.no-excluded-apps\": \"Keine ausgeschlossenen Apps\",\n  \"backups-exclusions.no-excluded-files-or-folders\": \"Keine ausgeschlossenen Dateien oder Ordner\",\n  \"backups-exclusions.select-item-to-exclude\": \"Element zum Ausschließen auswählen\",\n  \"backups-exclusions.stop-excluding\": \"Ausschluss aufheben\",\n  \"backups-floating-island.backing-up\": \"Sicherung läuft...\",\n  \"backups-floating-island.backing-up-to\": \"Sichere dein Umbrel...\",\n  \"backups-restore\": \"Wiederherstellen\",\n  \"backups-restore-full\": \"Vollständige Wiederherstellung\",\n  \"backups-restore-full-description\": \"Stelle dein gesamtes Umbrel aus einem Backup wieder her\",\n  \"backups-restore-header\": \"Stelle dein Umbrel wieder her\",\n  \"backups-restore-pro.after-restore\": \"Nach der Wiederherstellung wird dein temporäres Konto durch dein gesichertes Konto und dessen Daten ersetzt.\",\n  \"backups-restore-pro.step1\": \"Schließe die Einrichtung ab, indem du unten auf \\\"Los geht's\\\" klickst. Dies wird dein temporäres Konto sein, bis du dein gesichertes Konto wiederherstellst.\",\n  \"backups-restore-pro.step2\": \"Sobald die Einrichtung abgeschlossen ist, gehe zu <0>Einstellungen → Backups → Wiederherstellen</0>\",\n  \"backups-restore-pro.step3\": \"Folge den Anweisungen des Wiederherstellungsassistenten.\",\n  \"backups-restore-pro.subtitle\": \"Das Wiederherstellen eines Backups auf Umbrel Pro erfordert ein paar zusätzliche Schritte\",\n  \"backups-restore.backup-date\": \"Backup-Datum\",\n  \"backups-restore.backup-location\": \"Backup-Standort\",\n  \"backups-restore.browse-cloud-subtitle\": \"Von der Umbrel Private Cloud wiederherstellen (kommt bald)\",\n  \"backups-restore.browse-cloud-title\": \"Umbrel Private Cloud\",\n  \"backups-restore.browse-external-subtitle\": \"Von einem externen USB-Laufwerk wiederherstellen\",\n  \"backups-restore.browse-external-title\": \"Externe Festplatte\",\n  \"backups-restore.browse-nas-or-external\": \"Durchsuche einen anderen Umbrel, NAS oder ein externes Laufwerk, um ein Backup wiederherzustellen\",\n  \"backups-restore.browse-nas-subtitle\": \"Von einem anderen Umbrel- oder NAS-Gerät in deinem Netzwerk wiederherstellen\",\n  \"backups-restore.browse-nas-title\": \"Anderes Umbrel oder NAS\",\n  \"backups-restore.choose\": \"Auswählen\",\n  \"backups-restore.choose-backup-location\": \"Wähle einen Backup-Standort\",\n  \"backups-restore.connect-to-backup-location\": \"Mit Backup-Standort verbinden\",\n  \"backups-restore.encryption-password\": \"Verschlüsselungs-Passwort\",\n  \"backups-restore.encryption-password-description\": \"Gib das Verschlüsselungspasswort ein, das du festgelegt hast, als du Backups aktiviert hast.\",\n  \"backups-restore.enter-password-to-confirm\": \"Gib dein Umbrel-Passwort ein, um zu bestätigen\",\n  \"backups-restore.final-confirmation\": \"Bist du sicher?\",\n  \"backups-restore.final-confirmation-description\": \"Das Wiederherstellen aus diesem Backup ersetzt deine aktuellen umbrelOS Apps und Daten durch den Inhalt des ausgewählten Backups. Alle Dateien, Ordner oder Apps, die von diesem Backup ausgeschlossen wurden, werden von deinem Umbrel entfernt. Diese Aktion kann nicht rückgängig gemacht werden.\",\n  \"backups-restore.invalid-password\": \"Ungültiges Passwort\",\n  \"backups-restore.last-backup\": \"Letztes Backup: {{date}}\",\n  \"backups-restore.latest\": \"Neueste\",\n  \"backups-restore.no-backups-found\": \"Keine Backups gefunden\",\n  \"backups-restore.no-backups-yet\": \"Noch keine Backups\",\n  \"backups-restore.please-select-backup\": \"Bitte wähle ein Backup aus\",\n  \"backups-restore.please-select-repository\": \"Bitte wähle ein Repository aus\",\n  \"backups-restore.restore-from-nas-or-external\": \"Stelle dein Umbrel aus einem Backup wieder her, das sich auf einem anderen Umbrel, einem NAS oder einem externen Laufwerk befindet.\",\n  \"backups-restore.restore-from-unlisted\": \"Von einem anderen Ort wiederherstellen\",\n  \"backups-restore.restore-umbrel\": \"Umbrel wiederherstellen\",\n  \"backups-restore.restore-warning\": \"Das Wiederherstellen aus diesem Backup ersetzt deine aktuellen umbrelOS Apps und Daten durch den Inhalt des ausgewählten Backups. Alle von diesem Backup ausgeschlossenen Dateien, Ordner oder Apps werden von deinem Umbrel entfernt. Öffne <0>Rewind</0>, wenn du stattdessen bestimmte Dateien oder Ordner wiederherstellen möchtest.\",\n  \"backups-restore.restoring-from\": \"Du wirst gleich das folgende Backup wiederherstellen:\",\n  \"backups-restore.review-description\": \"Beim Wiederherstellen wird dein Umbrel mit dem Konto, den Files, den Apps und den Einstellungen eingerichtet, die zum Zeitpunkt dieses Backups enthalten waren. Das kann einige Zeit dauern. Sobald der Vorgang abgeschlossen ist, wird dein Anmeldepasswort auf das Passwort gesetzt, das du beim Erstellen des Backups verwendet hast.\",\n  \"backups-restore.select-backup\": \"Wähle ein Backup\",\n  \"backups-restore.select-backup-description\": \"Wähle das Backup aus, von dem du wiederherstellen möchtest\",\n  \"backups-restore.select-backup-file\": \"Wähle deine Backup-Datei aus\",\n  \"backups-restore.select-backup-file-only\": \"Nur <bold>{{backupFileName}}</bold> kann ausgewählt werden\",\n  \"backups-restore.total-size\": \"Gesamtgröße\",\n  \"backups-restore.unknown-date\": \"Unbekanntes Datum\",\n  \"backups-restore.unknown-repository\": \"Unbekanntes Repository\",\n  \"backups-rewind\": \"Rewind\",\n  \"backups-rewind-description\": \"Spring zurück in der Zeit, um bestimmte Dateien und Ordner wiederherzustellen\",\n  \"backups-rewind.start\": \"Rewind starten\",\n  \"backups-setup\": \"Einrichten\",\n  \"backups-setup-confirm\": \"Einrichtung abschließen\",\n  \"backups-setup-external-description\": \"Auf ein externes USB-Laufwerk sichern\",\n  \"backups-setup-nas-or-umbrel-description\": \"Sichere auf einem anderen Umbrel oder einem NAS-Gerät in deinem Netzwerk\",\n  \"backups-setup-umbrel-or-nas\": \"Anderer Umbrel oder NAS\",\n  \"backups-setup-umbrel-private-cloud\": \"Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-cta\": \"Erweitere deine Sorgenfreiheit über dein Zuhause hinaus mit <bold>Ende-zu-Ende-verschlüsselten Backups</bold> in die Umbrel Private Cloud.\",\n  \"backups-setup-umbrel-private-cloud-cta-link\": \"Frühzugang erhalten\",\n  \"backups-setup-umbrel-private-cloud-description\": \"Ende-zu-Ende-verschlüsselte Backups in die Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-subtitle\": \"Demnächst\",\n  \"backups.add-umbrel-or-nas\": \"Umbrel oder NAS hinzufügen\",\n  \"backups.all-apps-and-data-will-be-backed-up\": \"Alle Apps und Daten werden gesichert\",\n  \"backups.apps-and-data\": \"Apps & Daten\",\n  \"backups.backup-location\": \"Backup-Standort\",\n  \"backups.browse\": \"Durchsuchen\",\n  \"backups.choose-folder-within-device\": \"Wähle einen Ordner innerhalb von <bold>{{device}}</bold>, um deine Backups zu speichern\",\n  \"backups.confirm-password\": \"Passwort bestätigen\",\n  \"backups.copy\": \"Kopieren\",\n  \"backups.encryption\": \"Verschlüsselung\",\n  \"backups.encryption-password-warning\": \"Stelle sicher, dass dein Verschlüsselungs-Passwort sicher gespeichert ist, z. B. in einem Passwort-Manager. Du wirst es nicht wiedersehen können und benötigst es, um deine Backups wiederherzustellen.\",\n  \"backups.exclude-from-backups\": \"Vom Backup ausschließen\",\n  \"backups.exclude-from-backups-description\": \"Schließe bestimmte Dateien, Ordner und Apps von deinen Backups aus.\",\n  \"backups.hide\": \"Ausblenden\",\n  \"backups.i-understand\": \"Ich verstehe\",\n  \"backups.location\": \"Standort\",\n  \"backups.modals.already-in-use.description\": \"Dieser Backup-Speicherort wird bereits für Backups auf diesem Umbrel verwendet.\",\n  \"backups.modals.already-in-use.manage\": \"In Backups verwalten\",\n  \"backups.modals.already-in-use.title\": \"Backup-Speicherort bereits in Verwendung\",\n  \"backups.modals.connect-existing.description\": \"An diesem Speicherort existiert bereits ein Umbrel-Backup. Gib dessen Verschlüsselungspasswort ein, um es zu diesem Umbrel hinzuzufügen.\",\n  \"backups.modals.connect-existing.title\": \"Bestehendes Umbrel-Backup verbinden\",\n  \"backups.no-external-drives-detected\": \"Es wurden keine externen Laufwerke erkannt\",\n  \"backups.no-password-set\": \"Kein Passwort gesetzt\",\n  \"backups.password-is-set\": \"Passwort ist gesetzt\",\n  \"backups.password-minimum-length\": \"Das Passwort muss mindestens 8 Zeichen lang sein\",\n  \"backups.password-safety-warning\": \"Deine Backups werden mit diesem Passwort verschlüsselt. Bewahre es sicher auf, denn du wirst es nicht wiedersehen können und benötigst es, um deine Backups wiederherzustellen.\",\n  \"backups.passwords-do-not-match\": \"Passwörter stimmen nicht überein\",\n  \"backups.please-choose-folder\": \"Bitte wähle einen Ordner\",\n  \"backups.restore-failed.message\": \"Beim Wiederherstellen deines Umbrel ist ein Fehler aufgetreten. Deine aktuellen Apps und Daten wurden nicht geändert.\",\n  \"backups.restore-failed.retry\": \"Zur Wiederherstellung\",\n  \"backups.restore-failed.title\": \"Wiederherstellung fehlgeschlagen\",\n  \"backups.restoring\": \"Deinen Umbrel wiederherstellen\",\n  \"backups.restoring-completing\": \"Fast fertig. Dein Umbrel startet in Kürze neu...\",\n  \"backups.restoring-progress\": \"{{percent}}% wiederhergestellt\",\n  \"backups.restoring-time-remaining\": \"{{time}} verbleibend\",\n  \"backups.restoring-warning\": \"Schalte deinen Umbrel während der Wiederherstellung nicht aus und trenne den Backup-Standort nicht\",\n  \"backups.review\": \"Überprüfen und bestätigen\",\n  \"backups.review-description\": \"Überprüfe die Details deines Backups und bestätige deine Auswahl\",\n  \"backups.scanning-for-external-drives\": \"Scanne nach externen Laufwerken...\",\n  \"backups.schedule-description\": \"umbrelOS sichert deine Daten automatisch stündlich. Es behält verschlüsselte stündliche Backups für die letzten 24 Stunden, tägliche Backups für die letzte Woche, wöchentliche Backups für den letzten Monat und monatliche Backups für das letzte Jahr. Backups, die älter als ein Jahr sind, werden automatisch entfernt.\",\n  \"backups.select-backup-folder\": \"Backup-Ordner auswählen\",\n  \"backups.select-backup-folder-description\": \"Wähle einen Ordner, in dem deine Backups gespeichert werden sollen.\",\n  \"backups.select-backup-location\": \"Wähle einen Backup-Standort\",\n  \"backups.set-encryption-password\": \"Verschlüsselungs-Passwort festlegen\",\n  \"backups.set-encryption-password-description\": \"Schütze deine Backups mit einem Passwort. So bleiben deine Daten privat und können nur mit diesem Passwort wiederhergestellt werden.\",\n  \"backups.show\": \"Anzeigen\",\n  \"backups.storage-capacity-warning\": \"{{device}} muss über freien Speicherplatz verfügen, der mindestens dem Doppelten der Backup-Größe entspricht\",\n  \"backups.store-encryption-password-safely\": \"Speichere dein Verschlüsselungs-Passwort sicher\",\n  \"beta-program\": \"umbrelOS Beta Programm\",\n  \"beta-program-description\": \"Melde dich an, um Beta-Updates von umbrelOS zu erhalten, erhalte frühen Zugang zu neuen Funktionen und hilf uns, diese zu verbessern, indem du dein Feedback gibst. Beta-Updates können instabil sein und die Fehlerbehebung kann Kenntnisse des Terminals erfordern.\",\n  \"cancel\": \"Abbrechen\",\n  \"change\": \"Ändern\",\n  \"change-name\": \"Name ändern\",\n  \"change-name.failed.name-required\": \"Name ist erforderlich\",\n  \"change-name.input-placeholder\": \"Dein Name\",\n  \"change-password\": \"Passwort ändern\",\n  \"change-password.callout\": \"Wenn du dein Passwort verlierst, kannst du dich nicht bei deinem Umbrel anmelden. Stelle sicher, dass du es sicher aufbewahrst.\",\n  \"change-password.current-password\": \"Aktuelles Passwort\",\n  \"change-password.failed.current-required\": \"Aktuelles Passwort ist erforderlich\",\n  \"change-password.failed.min-length\": \"Passwort muss mindestens {{characters}} Zeichen lang sein\",\n  \"change-password.failed.must-be-unique\": \"Neues Passwort muss sich vom aktuellen Passwort unterscheiden\",\n  \"change-password.failed.new-required\": \"Neues Passwort ist erforderlich\",\n  \"change-password.failed.no-match\": \"Passwörter stimmen nicht überein\",\n  \"change-password.failed.repeat-required\": \"Passwortwiederholung ist erforderlich\",\n  \"change-password.new-password\": \"Neues Passwort\",\n  \"change-password.repeat-password\": \"Passwort wiederholen\",\n  \"check-for-latest-version\": \"Nach der neuesten umbrelOS Aktualisierung suchen\",\n  \"clipboard.copied\": \"Kopiert\",\n  \"close\": \"Schließen\",\n  \"cmdk.change-wallpaper\": \"Hintergrundbild ändern\",\n  \"cmdk.frequent-apps\": \"Häufig verwendet\",\n  \"cmdk.input-placeholder\": \"Suche nach Apps, Einstellungen oder Aktionen\",\n  \"cmdk.live-usage\": \"Live-Nutzung\",\n  \"cmdk.restart-umbrel\": \"Umbrel neu starten\",\n  \"cmdk.shutdown-umbrel\": \"Umbrel herunterfahren\",\n  \"cmdk.update-all-apps\": \"Alle Apps aktualisieren\",\n  \"cmdk.widgets\": \"Widgets\",\n  \"community-app-store\": \"Community App Store\",\n  \"community-app-store.add-error\": \"Fehler beim Hinzufügen des App Stores: {{message}}\",\n  \"community-app-store.back-to-umbrel-app-store\": \"Zurück zum Umbrel App Store\",\n  \"community-app-store.open-button\": \"Öffnen\",\n  \"community-app-store.remove-button\": \"Entfernen\",\n  \"community-app-store.remove-error\": \"Fehler beim Entfernen des App Stores: {{message}}\",\n  \"community-app-stores.add-button\": \"Hinzufügen\",\n  \"community-app-stores.description\": \"Community App Stores ermöglichen es dir, Apps auf deinem Umbrel zu installieren, die möglicherweise nicht im offiziellen Umbrel App Store verfügbar sind. Sie erleichtern auch das Testen von Beta-Versionen von Umbrel-Apps, bevor Entwickler sie im offiziellen Umbrel App Store veröffentlichen.\",\n  \"community-app-stores.learn-more\": \"Mehr erfahren\",\n  \"community-app-stores.warning\": \"Community App Stores können von jedem erstellt werden. Die darin veröffentlichten Apps sind nicht vom offiziellen Umbrel App Store-Team verifiziert oder geprüft und können potenziell unsicher oder bösartig sein. Sei vorsichtig und füge nur App-Stores von Entwicklern hinzu, denen du vertraust.\",\n  \"confirm\": \"Bestätigen\",\n  \"connect\": \"Verbinden\",\n  \"connecting\": \"Verbindet...\",\n  \"connection-lost\": \"Verbindung verloren\",\n  \"connection-lost-description\": \"Das kann passieren, wenn dein Browser-Tab inaktiv war, deine Netzwerkverbindung unterbrochen wurde oder dein Gerät offline ist.\",\n  \"continue\": \"Fortsetzen\",\n  \"continue-to-log-in\": \"Weiter zur Anmeldung\",\n  \"cpu\": \"CPU\",\n  \"cpu-core-count\": \"{{cores}} Threads\",\n  \"default-credentials.close\": \"Verstanden\",\n  \"default-credentials.description\": \"Hier sind die Anmeldeinformationen, die du benötigst, um dich bei der App anzumelden.\",\n  \"default-credentials.dont-show-again\": \"Nicht mehr anzeigen\",\n  \"default-credentials.dont-show-again-notice\": \"Du kannst diese Anmeldedaten jederzeit zukünftig abrufen, indem du mit der rechten Maustaste auf das App-Symbol klickst.\",\n  \"default-credentials.open\": \"{{app}} öffnen\",\n  \"default-credentials.password\": \"Standardpasswort\",\n  \"default-credentials.title\": \"Anmeldeinformationen für {{app}}\",\n  \"default-credentials.username\": \"Standardbenutzername\",\n  \"desktop.app.context.go-to-store-page\": \"Im App Store anzeigen\",\n  \"desktop.app.context.settings\": \"Einstellungen\",\n  \"desktop.app.context.show-default-credentials\": \"Standardanmeldeinformationen anzeigen\",\n  \"desktop.app.context.uninstall\": \"Deinstallieren\",\n  \"desktop.context-menu.change-wallpaper\": \"Hintergrundbild ändern\",\n  \"desktop.context-menu.edit-widgets\": \"Widgets bearbeiten\",\n  \"desktop.context-menu.logout\": \"Abmelden\",\n  \"desktop.greeting.afternoon\": \"Guten Tag, {{name}}\",\n  \"desktop.greeting.evening\": \"Guten Abend, {{name}}\",\n  \"desktop.greeting.morning\": \"Guten Morgen, {{name}}\",\n  \"desktop.install-first.for-the-ai-enthusiast\": \"Für Viber\",\n  \"desktop.install-first.for-the-bitcoiner\": \"Für Bitcoiner\",\n  \"desktop.install-first.for-the-self-hoster\": \"Für Selbsthoster\",\n  \"desktop.install-first.for-the-streamer\": \"Für Streamer\",\n  \"desktop.install-first.link-to-app-store\": \"Entdecke mehr im App Store\",\n  \"desktop.not-enough-room\": \"Verwende einen größeren Bildschirm, um deine Apps anzuzeigen.\",\n  \"device\": \"Gerät\",\n  \"device-info\": \"Geräteinformation\",\n  \"device-info-description\": \"Informationen über dein Gerät\",\n  \"device-info.device\": \"Gerät\",\n  \"device-info.model-number\": \"Modellnummer\",\n  \"device-info.serial-number\": \"Seriennummer\",\n  \"device-info.view-info\": \"Informationen anzeigen\",\n  \"device-name.home-or-pro\": \"Umbrel Home oder Umbrel Pro\",\n  \"disable\": \"Deaktivieren\",\n  \"done\": \"Fertig\",\n  \"download-logs\": \"Protokolle herunterladen\",\n  \"enabling-tor\": \"Remote-Tor-Zugriff wird aktiviert\",\n  \"external-dns\": \"Cloudflare DNS\",\n  \"external-dns-description\": \"Cloudflare DNS bietet eine bessere Netzwerkzuverlässigkeit. Deaktiviere es, um die DNS-Einstellungen deines Routers zu verwenden.\",\n  \"external-dns-error\": \"Fehler beim Aktualisieren der DNS-Einstellung: {{message}}\",\n  \"external-drive\": \"Externes Laufwerk\",\n  \"factory-reset\": \"Werkseinstellungen\",\n  \"factory-reset-description\": \"Lösche alle deine Daten und Apps, um umbrelOS auf die Standardeinstellungen zurückzusetzen\",\n  \"factory-reset-failed\": \"Fehler beim Zurücksetzen deines Geräts: {{message}}\",\n  \"factory-reset.confirm.body\": \"Bestätige dein Passwort, um zurückzusetzen\",\n  \"factory-reset.confirm.ethernet-required-warning\": \"Stelle sicher, dass dein Gerät über Ethernet (nicht Wi-Fi) mit deinem Router verbunden ist und du von deinem lokalen Netzwerk darauf zugreifst (z.B. http://umbrel.local oder die lokale IP-Adresse deines Geräts).\",\n  \"factory-reset.confirm.submit\": \"Alles löschen und zurücksetzen\",\n  \"factory-reset.confirm.submit-callout\": \"Diese Aktion kann nicht rückgängig gemacht werden.\",\n  \"factory-reset.rebooting.message\": \"Dein Gerät wird neu gestartet und alle Daten werden gelöscht. Bitte schließe diese Seite nicht.\",\n  \"factory-reset.rebooting.status\": \"Wird zurückgesetzt...\",\n  \"factory-reset.rebooting.title\": \"Werkseinstellungen werden zurückgesetzt\",\n  \"factory-reset.review.account-info\": \"Kontoinformationen und Passwort\",\n  \"factory-reset.review.apps\": \"Apps\",\n  \"factory-reset.review.following-will-be-removed\": \"Folgendes wird von deinem Gerät entfernt\",\n  \"factory-reset.review.installed-apps_one\": \"{{count}} installierte App\",\n  \"factory-reset.review.installed-apps_other\": \"{{count}} installierte Apps\",\n  \"factory-reset.review.submit\": \"Fortfahren\",\n  \"factory-reset.review.total-data\": \"Gesamtdaten\",\n  \"files\": \"Files\",\n  \"files-action.add-favorite\": \"Zu Favoriten hinzufügen\",\n  \"files-action.add-network-device\": \"Gerät hinzufügen\",\n  \"files-action.cancel-upload\": \"Hochladen abbrechen\",\n  \"files-action.compress\": \"Komprimieren\",\n  \"files-action.copy\": \"Kopieren\",\n  \"files-action.cut\": \"Ausschneiden\",\n  \"files-action.delete\": \"Dauerhaft löschen\",\n  \"files-action.download\": \"Herunterladen\",\n  \"files-action.download-items\": \"Lade {{count}} Elemente herunter\",\n  \"files-action.drop-to-upload\": \"Ablegen zum Hochladen\",\n  \"files-action.eject-disk\": \"Auswerfen\",\n  \"files-action.empty-trash\": \"Papierkorb leeren\",\n  \"files-action.format-drive\": \"Formatieren\",\n  \"files-action.go-to-path\": \"Gehe zu...\",\n  \"files-action.new-folder\": \"Neuer Ordner\",\n  \"files-action.open\": \"Öffnen\",\n  \"files-action.paste\": \"Einfügen\",\n  \"files-action.remove-favorite\": \"Aus Favoriten entfernen\",\n  \"files-action.remove-network-host\": \"Netzwerklaufwerk auswerfen\",\n  \"files-action.remove-network-share\": \"Netzwerkfreigabe auswerfen\",\n  \"files-action.rename\": \"Umbenennen\",\n  \"files-action.restore\": \"Wiederherstellen\",\n  \"files-action.select\": \"Auswählen\",\n  \"files-action.share\": \"Im Netzwerk freigeben...\",\n  \"files-action.sharing\": \"Wird freigegeben...\",\n  \"files-action.show-in-folder\": \"Im übergeordneten Ordner anzeigen\",\n  \"files-action.trash\": \"In den Papierkorb verschieben\",\n  \"files-action.uncompress\": \"Entpacken\",\n  \"files-action.upload\": \"Hochladen\",\n  \"files-add-network-share.add-manually\": \"Manuell hinzufügen\",\n  \"files-add-network-share.add-share\": \"Freigabe hinzufügen\",\n  \"files-add-network-share.back\": \"Zurück\",\n  \"files-add-network-share.continue\": \"Weiter\",\n  \"files-add-network-share.description\": \"Verbinde dich mit einem NAS oder einem anderen freigegebenen Laufwerk in deinem Netzwerk, um innerhalb von Files darauf zuzugreifen.\",\n  \"files-add-network-share.discovering\": \"Suche...\",\n  \"files-add-network-share.enter-details-manually\": \"Serverdetails manuell eingeben\",\n  \"files-add-network-share.host-label\": \"Serveradresse\",\n  \"files-add-network-share.host-required\": \"Serveradresse erforderlich\",\n  \"files-add-network-share.manual-share-help\": \"Gib den genauen Namen der Freigabe ein, wie er auf deinem Server angezeigt wird\",\n  \"files-add-network-share.no-shares-found\": \"Auf diesem Server wurden keine Freigaben gefunden\",\n  \"files-add-network-share.not-seeing-share\": \"Siehst du deine Freigabe nicht?\",\n  \"files-add-network-share.password-label\": \"Passwort\",\n  \"files-add-network-share.password-required\": \"Passwort erforderlich\",\n  \"files-add-network-share.retrieving-shares\": \"Freigaben werden abgerufen...\",\n  \"files-add-network-share.retry-discovery\": \"Netzwerk erneut scannen\",\n  \"files-add-network-share.select-share\": \"Wähle eine Freigabe zum Hinzufügen\",\n  \"files-add-network-share.share-placeholder\": \"shared-documents\",\n  \"files-add-network-share.share-required\": \"Freigabename erforderlich\",\n  \"files-add-network-share.title\": \"Netzwerkfreigabe hinzufügen\",\n  \"files-add-network-share.username-label\": \"Benutzername\",\n  \"files-add-network-share.username-placeholder\": \"admin\",\n  \"files-add-network-share.username-required\": \"Benutzername erforderlich\",\n  \"files-audio-island.now-playing\": \"Jetzt läuft\",\n  \"files-audio-island.pause\": \"Pausieren\",\n  \"files-audio-island.play\": \"Abspielen\",\n  \"files-backend-error.base-directory-not-found\": \"Das Stammverzeichnis konnte nicht gefunden werden\",\n  \"files-backend-error.cant-find-root\": \"Der Dateipfad konnte nicht überprüft werden\",\n  \"files-backend-error.destination-already-exists\": \"Am Ziel existiert bereits ein Element mit demselben Namen\",\n  \"files-backend-error.destination-not-exist\": \"Der Zielordner existiert nicht\",\n  \"files-backend-error.does-not-exist\": \"Die Datei oder der Ordner existiert nicht\",\n  \"files-backend-error.escapes-base\": \"Der Pfad liegt außerhalb des erlaubten Verzeichnisses\",\n  \"files-backend-error.invalid-base\": \"Der Pfad gehört zu keinem gültigen Verzeichnis\",\n  \"files-backend-error.invalid-filename\": \"Der Dateiname ist ungültig\",\n  \"files-backend-error.invalid-path\": \"Der Dateipfad ist ungültig\",\n  \"files-backend-error.mkdir-failed\": \"Ordner konnte nicht erstellt werden\",\n  \"files-backend-error.move-failed\": \"Verschieben des Elements fehlgeschlagen\",\n  \"files-backend-error.not-enough-space\": \"Nicht genügend Speicherplatz verfügbar\",\n  \"files-backend-error.operation-not-allowed\": \"Diese Aktion ist nicht erlaubt\",\n  \"files-backend-error.parent-not-directory\": \"Der übergeordnete Pfad ist kein Ordner\",\n  \"files-backend-error.parent-not-exist\": \"Der übergeordnete Ordner existiert nicht\",\n  \"files-backend-error.path-not-absolute\": \"Der Dateipfad ist nicht gültig\",\n  \"files-backend-error.share-already-exists\": \"Dieser Ordner ist bereits freigegeben\",\n  \"files-backend-error.share-name-generation-failed\": \"Konnte keinen eindeutigen Namen für die Freigabe erzeugen\",\n  \"files-backend-error.source-not-exists\": \"Die Quelldatei oder der Quellordner existiert nicht\",\n  \"files-backend-error.subdir-of-self\": \"Ein Ordner kann nicht in sich selbst verschoben oder kopiert werden\",\n  \"files-backend-error.trash-meta-not-exists\": \"Konnte den ursprünglichen Speicherort dieses Elements nicht finden\",\n  \"files-backend-error.unique-name-index-exceeded\": \"Konnte keinen eindeutigen Namen generieren. Zu viele Elemente mit ähnlichen Namen vorhanden\",\n  \"files-backend-error.upload-failed\": \"Upload fehlgeschlagen\",\n  \"files-collision.action.keep-both\": \"Beide behalten\",\n  \"files-collision.action.replace\": \"Ersetzen\",\n  \"files-collision.action.skip\": \"Überspringen\",\n  \"files-collision.destination.original-location\": \"seinem ursprünglichen Speicherort\",\n  \"files-collision.message\": \"Möchtest du das vorhandene Element ersetzen oder beide behalten?\",\n  \"files-collision.title\": \"\\\"{{itemName}}\\\" ist bereits in {{destinationName}} vorhanden\",\n  \"files-download.confirm\": \"Herunterladen\",\n  \"files-download.description\": \"Files kann diesen Dateityp nicht öffnen. Möchtest du ihn stattdessen herunterladen?\",\n  \"files-download.title\": \"„{{name}}“ herunterladen?\",\n  \"files-empty-trash.confirm\": \"Leeren\",\n  \"files-empty-trash.description\": \"Bist du sicher, dass du alle Elemente im Papierkorb endgültig löschen möchtest? Du kannst diese Aktion nicht rückgängig machen.\",\n  \"files-empty-trash.title\": \"Papierkorb leeren?\",\n  \"files-empty.directory\": \"Keine Elemente in diesem Ordner\",\n  \"files-empty.network\": \"Keine Netzwerkgeräte\",\n  \"files-empty.network-host-offline\": \"Netzwerkgerät offline\",\n  \"files-error.add-favorite\": \"Hinzufügen zu den Favoriten fehlgeschlagen: {{message}}\",\n  \"files-error.add-share\": \"Freigabe des Ordners fehlgeschlagen: {{message}}\",\n  \"files-error.compress\": \"Komprimierung fehlgeschlagen: {{message}}\",\n  \"files-error.copy\": \"Kopieren fehlgeschlagen: {{message}}\",\n  \"files-error.create-folder\": \"Ordner erstellen fehlgeschlagen: {{message}}\",\n  \"files-error.delete\": \"Löschen fehlgeschlagen: {{message}}\",\n  \"files-error.eject-disk\": \"Auswerfen des Laufwerks fehlgeschlagen: {{message}}\",\n  \"files-error.empty-trash\": \"Papierkorb leeren fehlgeschlagen: {{message}}\",\n  \"files-error.extract\": \"Entpacken fehlgeschlagen: {{message}}\",\n  \"files-error.folder-already-exists\": \"Ein Ordner mit diesem Namen existiert bereits\",\n  \"files-error.move\": \"Verschieben fehlgeschlagen: {{message}}\",\n  \"files-error.remove-favorite\": \"Entfernen aus den Favoriten fehlgeschlagen: {{message}}\",\n  \"files-error.remove-share\": \"Entfernen der Freigabe fehlgeschlagen: {{message}}\",\n  \"files-error.rename\": \"Umbenennen fehlgeschlagen: {{message}}\",\n  \"files-error.restore\": \"Wiederherstellen fehlgeschlagen: {{message}}\",\n  \"files-error.trash\": \"In den Papierkorb verschieben fehlgeschlagen: {{message}}\",\n  \"files-error.upload\": \"Upload fehlgeschlagen: {{message}}\",\n  \"files-error.upload-network-error\": \"Upload von {{name}} fehlgeschlagen: Ein Netzwerkfehler ist aufgetreten\",\n  \"files-extension-change.confirm\": \"Fortfahren\",\n  \"files-extension-change.description-add\": \"Bist du sicher, dass du die Dateiendung von „{{fileName}}“ in „{{extension}}“ ändern möchtest? Dadurch kann die Datei möglicherweise unlesbar werden.\",\n  \"files-extension-change.description-remove\": \"Bist du sicher, dass du die Dateiendung von „{{fileName}}“ entfernen möchtest?\",\n  \"files-extension-change.title-add\": \"Dateiendung in „{{extension}}“ ändern?\",\n  \"files-extension-change.title-remove\": \"Dateiendung entfernen?\",\n  \"files-external-storage.unsupported.description\": \"Dein angeschlossenes externes Laufwerk lässt sich aufgrund von Stromproblemen nicht an einem Raspberry Pi verwenden. Externer Speicher ist auf dem Umbrel Home, Umbrel Pro und allen x86-Geräten (Intel oder AMD) verfügbar.\",\n  \"files-external-storage.unsupported.description-general\": \"Externer Speicher ist auf dem Raspberry Pi wegen Stromproblemen nicht verfügbar. Externer Speicher ist auf dem Umbrel Home, Umbrel Pro und allen x86-Geräten (Intel oder AMD) verfügbar.\",\n  \"files-external-storage.unsupported.title\": \"Externer Speicher wird nicht unterstützt\",\n  \"files-folder\": \"Ordner\",\n  \"files-format.confirm\": \"Formatieren\",\n  \"files-format.description\": \"Beim Formatieren werden alle Daten auf {{driveName}} gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.\",\n  \"files-format.description-unreadable\": \"umbrelOS kann den Inhalt von {{driveName}} nicht lesen. Du kannst es formatieren, um es mit umbrelOS zu verwenden.\",\n  \"files-format.drive-label\": \"Name\",\n  \"files-format.error\": \"Formatieren des Laufwerks fehlgeschlagen\",\n  \"files-format.exfat-description\": \"Maximale Kompatibilität mit Windows, macOS und Linux\",\n  \"files-format.ext4-description\": \"Bessere Leistung mit umbrelOS und Linux\",\n  \"files-format.filesystem\": \"Dateisystem\",\n  \"files-format.filesystem-label\": \"Formatieren als\",\n  \"files-format.formatting\": \"Formatieren...\",\n  \"files-format.title\": \"Laufwerk formatieren\",\n  \"files-format.title-requires-format\": \"Formatierung erforderlich\",\n  \"files-formatting-island.formatting\": \"Formatieren...\",\n  \"files-formatting-island.formatting-drives\": \"Formatieren von {{count}} Laufwerken\",\n  \"files-listing.empty\": \"Keine Elemente\",\n  \"files-listing.error\": \"Es ist ein Fehler aufgetreten\",\n  \"files-listing.item-count-truncated\": \"{{formattedCount}}+ Elemente\",\n  \"files-listing.item-count_one\": \"{{formattedCount}} Element\",\n  \"files-listing.item-count_other\": \"{{formattedCount}} Elemente\",\n  \"files-listing.loading\": \"Wird geladen...\",\n  \"files-listing.no-such-file\": \"Keine solche Datei oder kein solcher Ordner\",\n  \"files-listing.selected-count\": \"{{selectedCount}} von {{totalCount}} ausgewählt\",\n  \"files-listing.selected-count-truncated\": \"{{selectedCount}} von {{totalCount}}+ ausgewählt\",\n  \"files-name-drawer.new-folder\": \"Neuer Ordner\",\n  \"files-name-drawer.new-folder-description\": \"Gib einen Namen für den neuen Ordner ein.\",\n  \"files-name-drawer.new-folder-input\": \"Ordnername\",\n  \"files-name-drawer.rename-file\": \"Datei umbenennen\",\n  \"files-name-drawer.rename-file-description\": \"Gib einen neuen Namen für diese Datei ein.\",\n  \"files-name-drawer.rename-file-input\": \"Dateiname\",\n  \"files-name-drawer.rename-folder\": \"Ordner umbenennen\",\n  \"files-name-drawer.rename-folder-description\": \"Gib einen neuen Namen für diesen Ordner ein.\",\n  \"files-name-drawer.rename-folder-input\": \"Ordnername\",\n  \"files-network-storage-error.add-share\": \"Hinzufügen der Netzwerkfreigabe fehlgeschlagen: {{message}}\",\n  \"files-network-storage-error.discover-servers\": \"Erkennung von Netzwerkgeräten fehlgeschlagen: {{message}}\",\n  \"files-network-storage-error.discover-shares\": \"Suche nach Netzwerkfreigaben fehlgeschlagen: {{message}}\",\n  \"files-network-storage-error.remove-share\": \"Entfernen der Netzwerkfreigabe fehlgeschlagen: {{message}}\",\n  \"files-operations-island.copying\": \"Kopieren von \\\"{{from}}\\\" nach \\\"{{to}}\\\"\",\n  \"files-operations-island.moving\": \"Verschieben von \\\"{{from}}\\\" nach \\\"{{to}}\\\"\",\n  \"files-operations-island.restoring\": \"Stelle \\\"{{from}}\\\" nach \\\"{{to}}\\\" wieder her\",\n  \"files-path.input-group\": \"Pfadeingabe\",\n  \"files-path.input-label\": \"Aktueller Pfad\",\n  \"files-permanently-delete.confirm\": \"Endgültig löschen\",\n  \"files-permanently-delete.description-multiple\": \"Bist du sicher, dass du diese {{count}} Elemente endgültig löschen möchtest? Du kannst diese Aktion nicht rückgängig machen.\",\n  \"files-permanently-delete.description-single\": \"Bist du sicher, dass du „{{fileName}}“ endgültig löschen möchtest? Du kannst diese Aktion nicht rückgängig machen.\",\n  \"files-permanently-delete.title-multiple\": \"{{count}} Elemente endgültig löschen?\",\n  \"files-permanently-delete.title-single\": \"Endgültig löschen?\",\n  \"files-search.default\": \"Nach Dateien und Ordnern suchen\",\n  \"files-search.no-results\": \"Keine Ergebnisse gefunden für \\\"{{query}}\\\"\",\n  \"files-search.placeholder\": \"Suchen\",\n  \"files-search.searching-label\": \"Suche im Umbrel von {{name}}\",\n  \"files-share.home-description\": \"Greife von anderen Geräten in deinem Netzwerk auf alle Dateien in „{{homeDirectoryName}}“ zu.\",\n  \"files-share.home-title\": \"„{{homeDirectoryName}}“ im Netzwerk freigeben\",\n  \"files-share.instructions.how-to-access\": \"So greifst du darauf zu\",\n  \"files-share.instructions.ios.enter-password\": \"Gib <field>{{password}}</field> als Passwort ein.\",\n  \"files-share.instructions.ios.enter-server\": \"Gib <field>{{smbUrl}}</field> als Serveradresse ein.\",\n  \"files-share.instructions.ios.enter-username\": \"Gib <field>{{username}}</field> als Benutzernamen ein.\",\n  \"files-share.instructions.ios.install-files\": \"Installiere die App „Files“ aus dem App Store, falls nicht vorhanden.\",\n  \"files-share.instructions.ios.tap-connect\": \"Tippe auf „Verbinden“, um darauf zuzugreifen.\",\n  \"files-share.instructions.ios.tap-dots\": \"Tippe oben rechts auf die drei Punkte (...) und wähle „Mit Server verbinden“.\",\n  \"files-share.instructions.macos.click-connect\": \"Klicke auf „Verbinden“, um darauf zuzugreifen.\",\n  \"files-share.instructions.macos.enter-password\": \"Gib <field>{{password}}</field> als Passwort ein.\",\n  \"files-share.instructions.macos.enter-url\": \"Gib <field>{{smbUrl}}</field> ein und klicke auf „Verbinden“.\",\n  \"files-share.instructions.macos.enter-username\": \"Gib <field>{{username}}</field> als Benutzernamen ein.\",\n  \"files-share.instructions.macos.open-finder\": \"Öffne „Finder“ und drücke ⌘ + K.\",\n  \"files-share.instructions.macos.select-registered\": \"Wähle „Registrierter Benutzer“, wenn du dazu aufgefordert wirst.\",\n  \"files-share.instructions.macos.time-machine\": \"Wie du es als Time Machine-Backupziel verwendest\",\n  \"files-share.instructions.macos.time-machine.choose-encryption\": \"Wähle zwischen verschlüsselten oder unverschlüsselten Backups.\",\n  \"files-share.instructions.macos.time-machine.disk-limit\": \"Lege unter „Disk Usage Limit“ fest, wie viel Speicherplatz du auf deinem Umbrel für Time Machine-Backups reservieren möchtest, und klicke dann auf „Fertig“.\",\n  \"files-share.instructions.macos.time-machine.follow-steps\": \"Befolge die obigen Schritte und öffne die Systemeinstellungen auf deinem Mac.\",\n  \"files-share.instructions.macos.time-machine.go-settings\": \"Gehe zu Time Machine und klicke auf „Backup-Datenträger hinzufügen…“.\",\n  \"files-share.instructions.macos.time-machine.select-disk\": \"Wähle den Ordner aus und klicke auf \\\"Laufwerk einrichten...\\\".\",\n  \"files-share.instructions.umbrelos.backup.follow-onscreen\": \"Folge den geführten Schritten, um dein Backup einzurichten.\",\n  \"files-share.instructions.umbrelos.backup.follow-then-go-to\": \"Führe die oben genannten Schritte aus und gehe dann auf deinem anderen Umbrel zu \\\"{{settings}}\\\" > \\\"{{backups}}\\\".\",\n  \"files-share.instructions.umbrelos.backup.select-add\": \"Wähle die Option \\\"{{addUmbrelOrNas}}\\\".\",\n  \"files-share.instructions.umbrelos.backup.select-connected\": \"Wähle dieses Umbrel-Gerät aus der Liste der verbundenen Geräte.\",\n  \"files-share.instructions.umbrelos.backup.title\": \"Wie du es als Sicherungsziel für dein anderes Umbrel verwendest\",\n  \"files-share.instructions.umbrelos.cant-find-note\": \"Findest du es nicht? Wähle \\\"Manuell hinzufügen\\\" und nutze die folgenden Zugangsdaten. Wenn es trotzdem nicht klappt, überprüfe, dass beide Geräte im selben Netzwerk sind.\",\n  \"files-share.instructions.umbrelos.enter-password\": \"Gib <field>{{password}}</field> als Passwort ein.\",\n  \"files-share.instructions.umbrelos.enter-username\": \"Gib <field>{{username}}</field> als Benutzernamen ein.\",\n  \"files-share.instructions.umbrelos.open-and-click\": \"Öffne auf deinem anderen Umbrel \\\"Files\\\" und klicke in der Seitenleiste auf <plus/> neben \\\"<deviceIcon/> {{deviceLabel}}\\\".\",\n  \"files-share.instructions.umbrelos.select-device\": \"Wähle dieses Umbrel-Gerät aus der Liste automatisch erkannter Geräte in deinem Netzwerk aus.\",\n  \"files-share.instructions.umbrelos.select-sharename\": \"Wähle \\\"{{sharename}}\\\" und klicke, um die Freigabe hinzuzufügen.\",\n  \"files-share.instructions.windows.enter-password\": \"Gib <field>{{password}}</field> als Passwort ein.\",\n  \"files-share.instructions.windows.enter-url\": \"Gib <field>{{smbUrl}}</field> ein und drücke Enter.\",\n  \"files-share.instructions.windows.enter-username\": \"Gib <field>{{username}}</field> als Benutzernamen ein.\",\n  \"files-share.instructions.windows.open-run\": \"Drücke Windows + R, um das „Ausführen“-Dialogfeld zu öffnen.\",\n  \"files-share.instructions.windows.remember-credentials\": \"Aktiviere „Remember my credentials“ und klicke auf OK.\",\n  \"files-share.regular-description\": \"Gib diesen Ordner frei, um von anderen Geräten in deinem Netzwerk darauf zuzugreifen\",\n  \"files-share.regular-title\": \"Ordner im Netzwerk freigeben\",\n  \"files-share.toggle\": \"„{{name}}“ in deinem Netzwerk freigeben\",\n  \"files-sidebar.apps\": \"Apps\",\n  \"files-sidebar.external-storage\": \"Externer Speicher\",\n  \"files-sidebar.favorites\": \"Favoriten\",\n  \"files-sidebar.home\": \"Startseite\",\n  \"files-sidebar.navigation\": \"Dateinavigation\",\n  \"files-sidebar.network\": \"Netzwerk\",\n  \"files-sidebar.network-pathbar\": \"Netzwerkgeräte\",\n  \"files-sidebar.network-sidebar\": \"Geräte\",\n  \"files-sidebar.recents\": \"Kürzlich\",\n  \"files-sidebar.shared-folders\": \"Freigegebene Ordner\",\n  \"files-sidebar.trash\": \"Papierkorb\",\n  \"files-sidebar.trash.open\": \"Öffnen\",\n  \"files-sort.created\": \"Hinzugefügt\",\n  \"files-sort.modified\": \"Geändert\",\n  \"files-sort.name\": \"Name\",\n  \"files-sort.size\": \"Größe\",\n  \"files-sort.type\": \"Typ\",\n  \"files-state.uploading\": \"Wird hochgeladen...\",\n  \"files-state.waiting\": \"Warten...\",\n  \"files-type.3gp\": \"3GP-Video\",\n  \"files-type.3gp2\": \"3GP2-Video\",\n  \"files-type.7z\": \"7Z-Archiv\",\n  \"files-type.aac\": \"AAC-Audio\",\n  \"files-type.ai\": \"Illustrator-Datei\",\n  \"files-type.aiff\": \"AIFF-Audio\",\n  \"files-type.au\": \"AU-Audio\",\n  \"files-type.avi\": \"AVI-Video\",\n  \"files-type.avif\": \"AVIF-Bild\",\n  \"files-type.bmp\": \"BMP-Bild\",\n  \"files-type.bzip2\": \"BZIP2-Archiv\",\n  \"files-type.caf\": \"CAF-Audio\",\n  \"files-type.compressed\": \"Komprimiertes Archiv\",\n  \"files-type.csv\": \"CSV-Datei\",\n  \"files-type.directory\": \"Ordner\",\n  \"files-type.dmg\": \"Festplatten-Image\",\n  \"files-type.dv\": \"DV-Video\",\n  \"files-type.epub\": \"EPUB-eBook\",\n  \"files-type.excel\": \"Excel-Tabelle\",\n  \"files-type.exe\": \"Windows-Ausführbare Datei\",\n  \"files-type.executable\": \"Ausführbare Datei\",\n  \"files-type.external-drive\": \"Laufwerk\",\n  \"files-type.flac\": \"FLAC-Audio\",\n  \"files-type.flv\": \"FLV-Video\",\n  \"files-type.gif\": \"GIF-Bild\",\n  \"files-type.gzip\": \"GZIP-Archiv\",\n  \"files-type.heic\": \"HEIC-Bild\",\n  \"files-type.ico\": \"ICO-Bild\",\n  \"files-type.iso\": \"ISO-Image\",\n  \"files-type.jpeg\": \"JPEG-Bild\",\n  \"files-type.keynote\": \"Keynote-Präsentation\",\n  \"files-type.lzip\": \"LZIP-Archiv\",\n  \"files-type.lzma\": \"LZMA-Archiv\",\n  \"files-type.lzop\": \"LZOP-Archiv\",\n  \"files-type.m3u\": \"M3U-Wiedergabeliste\",\n  \"files-type.m4a\": \"M4A-Audio\",\n  \"files-type.m4v\": \"M4V-Video\",\n  \"files-type.midi\": \"MIDI-Audio\",\n  \"files-type.mka\": \"MKA-Audio\",\n  \"files-type.mkv\": \"MKV-Video\",\n  \"files-type.mng\": \"MNG-Video\",\n  \"files-type.mobi\": \"MOBI-eBook\",\n  \"files-type.mp3\": \"MP3-Audio\",\n  \"files-type.mp4\": \"MP4-Video\",\n  \"files-type.mp4-audio\": \"MP4-Audio\",\n  \"files-type.mpeg\": \"MPEG-Video\",\n  \"files-type.mpeg-ts\": \"MPEG-Transportstream\",\n  \"files-type.network-drive\": \"Netzwerklaufwerk\",\n  \"files-type.numbers\": \"Numbers-Tabelle\",\n  \"files-type.ogg\": \"OGG-Audio\",\n  \"files-type.ogv\": \"OGV-Video\",\n  \"files-type.pages\": \"Pages-Dokument\",\n  \"files-type.pdf\": \"PDF-Dokument\",\n  \"files-type.png\": \"PNG-Bild\",\n  \"files-type.powerpoint\": \"PowerPoint-Präsentation\",\n  \"files-type.psd\": \"Photoshop-Dokument\",\n  \"files-type.quicktime\": \"QuickTime-Video\",\n  \"files-type.rar\": \"RAR-Archiv\",\n  \"files-type.sgi\": \"SGI-Film\",\n  \"files-type.svg\": \"SVG-Bild\",\n  \"files-type.tar\": \"TAR-Archiv\",\n  \"files-type.tiff\": \"TIFF-Bild\",\n  \"files-type.ts\": \"TS-Video\",\n  \"files-type.txt\": \"Textdatei\",\n  \"files-type.umbrel-backup\": \"Umbrel Backup\",\n  \"files-type.wav\": \"WAV-Audio\",\n  \"files-type.webm\": \"WebM-Video\",\n  \"files-type.webm-audio\": \"WebM-Audio\",\n  \"files-type.webp\": \"WebP-Bild\",\n  \"files-type.wma\": \"WMA-Audio\",\n  \"files-type.wmv\": \"WMV-Video\",\n  \"files-type.word\": \"Word-Dokument\",\n  \"files-type.xz\": \"XZ-Archiv\",\n  \"files-type.zip\": \"ZIP-Archiv\",\n  \"files-upload-island.uploading-count\": \"Lade {{count}} Elemente hoch\",\n  \"files-view.icons\": \"Symbole\",\n  \"files-view.list\": \"Liste\",\n  \"files-view.sort-by\": \"Sortieren nach\",\n  \"files-view.view-as\": \"Anzeigen als\",\n  \"files-widgets.favorites.no-items-text\": \"Füge einen Ordner zu deinen Favoriten hinzu, um ihn hier zu sehen\",\n  \"files-widgets.recents.no-items-text\": \"Keine kürzlich verwendeten Dateien\",\n  \"generic-in\": \"im\",\n  \"hide-details\": \"Details ausblenden\",\n  \"install-first.install-app\": \"Installiere {{app}}\",\n  \"install-first.title\": \"{{app}} benötigt diese Apps\",\n  \"install-your-first-app\": \"Installiere deine erste App\",\n  \"language\": \"Sprache\",\n  \"language-description\": \"Deine bevorzugte umbrelOS Sprache\",\n  \"language.select-description\": \"Bevorzugte umbrelOS-Sprache auswählen\",\n  \"live-usage\": \"Live-Nutzung\",\n  \"loading\": \"Lädt\",\n  \"local-ip\": \"Lokale IP\",\n  \"login-2fa.subtitle\": \"Gib den 2FA-Code ein, der in deiner Authenticator-App angezeigt wird\",\n  \"login-2fa.title\": \"Authentifizieren\",\n  \"login-with-umbrel.description\": \"Gib dein Umbrel-Passwort ein, um {{app}} zu öffnen\",\n  \"login-with-umbrel.title\": \"Mit Umbrel anmelden\",\n  \"login.password-label\": \"Passwort\",\n  \"login.password.submit\": \"Anmelden\",\n  \"login.subtitle\": \"Gib dein Umbrel-Passwort ein, um dich anzumelden\",\n  \"login.title\": \"Willkommen zurück\",\n  \"logout\": \"Abmelden\",\n  \"logout-error-generic\": \"Fehler: Abmeldung fehlgeschlagen\",\n  \"logout.confirm.submit\": \"Abmelden\",\n  \"logout.confirm.title\": \"Möchtest du dich wirklich abmelden?\",\n  \"memory\": \"Arbeitsspeicher\",\n  \"memory.low\": \"Wenig Arbeitsspeicher\",\n  \"migrate\": \"Migrieren\",\n  \"migrate.callout\": \"Schalte dein Umbrel nicht aus, bis die Migration abgeschlossen ist\",\n  \"migrate.failed.retry\": \"Erneut versuchen\",\n  \"migrate.failed.title\": \"Migration fehlgeschlagen\",\n  \"migrate.success.description\": \"Alle deine Apps, App-Daten und Kontodetails wurden auf dein Umbrel Home migriert.\",\n  \"migrate.success.title\": \"Migration erfolgreich\",\n  \"migration-assistant\": \"Migrationsassistent\",\n  \"migration-assistant-description\": \"Übertrage alle deine Apps und Daten von einem Raspberry Pi auf {{deviceName}}\",\n  \"migration-assistant-unsupported-device-description\": \"Migration Assistant unterstützt derzeit das Übertragen aller Daten und Apps von einem Raspberry Pi mit umbrelOS auf Umbrel Home oder Umbrel Pro. Öffne Migration Assistant auf deinem Umbrel Home oder Umbrel Pro, um loszulegen.\",\n  \"migration-assistant.continue-migration.ready.submit\": \"Migration starten\",\n  \"migration-assistant.failed\": \"Irgendetwas stimmt nicht...\",\n  \"migration-assistant.failed.retrying-message\": \"Wiederholen...\",\n  \"migration-assistant.mobile.start-button\": \"Migration starten\",\n  \"migration-assistant.prep.body\": \"Vorbereitung für die Migration\",\n  \"migration-assistant.prep.button-continue\": \"Fortfahren\",\n  \"migration-assistant.prep.callout\": \"Die Daten auf deinem {{deviceName}}, falls vorhanden, werden dauerhaft gelöscht.\",\n  \"migration-assistant.prep.connect-disk-to-home\": \"Schließe das externe Laufwerk an einen beliebigen USB-Anschluss deines {{deviceName}} an.\",\n  \"migration-assistant.prep.prep-done-continue-message\": \"Wenn du fertig bist, klicke unten auf '{{button}}'.\",\n  \"migration-assistant.prep.shut-down-rpi\": \"Schalte dein Raspberry Pi Umbrel aus.\",\n  \"migration-assistant.ready.description\": \"Alle deine Daten und Apps sind bereit, auf dein {{deviceName}} migriert zu werden\",\n  \"migration-assistant.ready.hint-header\": \"Dinge, die du beachten solltest\",\n  \"migration-assistant.ready.hint-keep-pi-off.description\": \"Dies hilft, Probleme mit Apps wie Lightning Node zu vermeiden\",\n  \"migration-assistant.ready.hint-keep-pi-off.title\": \"Halte dein Raspberry Pi nach dem Update ausgeschaltet\",\n  \"migration-assistant.ready.hint-use-same-password.description\": \"Denk daran, das Passwort deines Raspberry Pi Umbrel zu verwenden, um dich bei deinem {{deviceName}} anzumelden\",\n  \"migration-assistant.ready.hint-use-same-password.title\": \"Verwende das gleiche Passwort\",\n  \"migration-assistant.ready.title\": \"Du bist bereit für die Migration!\",\n  \"mini-browser.default-title\": \"Ordner auswählen\",\n  \"mini-browser.empty-external\": \"Schließe ein externes Laufwerk an, damit es hier angezeigt wird.\",\n  \"mini-browser.empty-network\": \"Füge ein Umbrel oder ein NAS hinzu, damit es hier angezeigt wird.\",\n  \"mini-browser.load-more\": \"Mehr laden\",\n  \"mini-browser.load-more-in-folder\": \"Mehr in {{name}} laden\",\n  \"mini-browser.loading-more\": \"Mehr wird geladen…\",\n  \"mini-browser.select\": \"Auswählen\",\n  \"mini-browser.select-folder\": \"Ordner auswählen\",\n  \"name\": \"Name\",\n  \"nas\": \"NAS\",\n  \"no-forgot-password-message\": \"Wenn du dein Passwort verlierst, kannst du dich nicht bei deinem Umbrel anmelden. Stelle sicher, dass du es sicher aufbewahrst.\",\n  \"no-results-found\": \"Keine Ergebnisse gefunden\",\n  \"not-found-404\": \"Fehlercode: 404\",\n  \"not-found-404.back\": \"Zurück\",\n  \"not-found-404.home\": \"Zur Startseite\",\n  \"notifications.backups-failing-location.description\": \"Automatische Backups zu {{location}} schlagen fehl. Überprüfe die Verbindung und deine Einstellungen für Backups.\",\n  \"notifications.backups-failing.description\": \"Automatische Backups sind fehlgeschlagen. Schau dir deinen Backup-Standort und deine Einstellungen an.\",\n  \"notifications.backups-failing.go-to-backups\": \"Zu Backups\",\n  \"notifications.backups-failing.title\": \"Keine Backups in den letzten 24 Stunden\",\n  \"notifications.cpu.too-hot\": \"Hohe CPU-Temperatur\",\n  \"notifications.memory.low\": \"Der Arbeitsspeicher deines Geräts ist gering\",\n  \"notifications.new-version-available\": \"{{update}} ist jetzt zur Installation verfügbar\",\n  \"notifications.raid.issue.description\": \"Speicherproblem erkannt. Schau im Storage Manager nach Details.\",\n  \"notifications.raid.issue.title\": \"Sofortiges Handeln erforderlich\",\n  \"notifications.ssd.health.description\": \"Eine oder mehrere SSDs benötigen möglicherweise deine Aufmerksamkeit. Schau im Storage Manager nach Details.\",\n  \"notifications.ssd.health.title\": \"Warnung zur SSD-Gesundheit\",\n  \"notifications.storage.full\": \"Der Speicherplatz deines Geräts ist voll\",\n  \"notifications.view\": \"Anzeigen\",\n  \"ok\": \"OK\",\n  \"onboarding.account-created.by-clicking-button-you-agree\": \"Mit Klick auf 'Weiter' stimmst du den <linked>umbrelOS Nutzungsbedingungen</linked> zu\",\n  \"onboarding.account-created.youre-all-set-name\": \"Alles bereit, {{name}}.\",\n  \"onboarding.contact-support\": \"Support\",\n  \"onboarding.create-account\": \"Konto erstellen\",\n  \"onboarding.create-account.confirm-password.input-label\": \"Passwort bestätigen\",\n  \"onboarding.create-account.failed.name-required\": \"Name ist erforderlich\",\n  \"onboarding.create-account.failed.passwords-dont-match\": \"Passwörter stimmen nicht überein\",\n  \"onboarding.create-account.name.input-placeholder\": \"Dein Name\",\n  \"onboarding.create-account.password.input-label\": \"Passwort\",\n  \"onboarding.create-account.submit\": \"Erstellen\",\n  \"onboarding.create-account.submitting\": \"Wird erstellt\",\n  \"onboarding.create-account.subtitle\": \"Deine Kontoinformationen werden nur auf deinem Umbrel gespeichert. Stelle sicher, dass du dein Passwort sicher aufbewahrst, da es keine Möglichkeit gibt, es zurückzusetzen.\",\n  \"onboarding.create-instead-long\": \"Neues Konto erstellen\",\n  \"onboarding.create-instead-short\": \"Neues Konto\",\n  \"onboarding.launch-umbrelos\": \"Starte umbrelOS\",\n  \"onboarding.raid.available-storage\": \"Verfügbarer Speicher\",\n  \"onboarding.raid.change-drives-link\": \"Möchtest du Laufwerke hinzufügen oder austauschen?\",\n  \"onboarding.raid.configuring.subtitle\": \"Das kann ein paar Minuten dauern.\",\n  \"onboarding.raid.configuring.title\": \"Wir richten deinen Speicher ein\",\n  \"onboarding.raid.configuring.warning\": \"Bitte aktualisiere diese Seite nicht und schalte dein Umbrel nicht aus, während dein Speicher konfiguriert wird.\",\n  \"onboarding.raid.continue\": \"Weiter\",\n  \"onboarding.raid.error.detection-instructions\": \"Schalte Umbrel Pro aus, überprüfe, ob deine SSDs richtig sitzen, und versuche es erneut.\",\n  \"onboarding.raid.error.no-ssds-detected\": \"Keine SSDs erkannt\",\n  \"onboarding.raid.error.no-ssds-instructions\": \"Schalte Umbrel Pro aus und stecke mindestens eine SSD ein, um fortzufahren.\",\n  \"onboarding.raid.failsafe\": \"FailSafe\",\n  \"onboarding.raid.failsafe.cant-enable\": \"Du kannst FailSafe noch nicht aktivieren.\",\n  \"onboarding.raid.failsafe.enable\": \"FailSafe aktivieren\",\n  \"onboarding.raid.failsafe.mixed-sizes\": \"FailSafe wird durch deine kleinste SSD begrenzt ({{smallest}}). Extra Platz auf größeren SSDs kann nicht genutzt werden, sodass {{wasted}} unbrauchbar bleibt.\",\n  \"onboarding.raid.failsafe.protection-info-2ssds\": \"{{protection}} wird zum Schutz deiner Daten verwendet. Füge eine weitere {{smallest}} SSD hinzu, um den verfügbaren Speicher auf {{futureWith3}} zu erhöhen, oder füge zwei weitere hinzu, um {{futureWith4}} zu erreichen. Du kannst jederzeit weitere SSDs hinzufügen.\",\n  \"onboarding.raid.failsafe.protection-info-3ssds\": \"{{protection}} wird zum Schutz deiner Daten verwendet. Füge eine weitere {{smallest}} SSD hinzu, um den verfügbaren Speicher auf {{futureWith4}} zu erhöhen. Du kannst jederzeit weitere SSDs hinzufügen.\",\n  \"onboarding.raid.failsafe.single-ssd-info\": \"Du hast nur eine SSD. Füge mindestens eine weitere {{size}} SSD hinzu, um den FailSafe-Schutz für deine Daten zu aktivieren. Du kannst jederzeit weitere SSDs hinzufügen.\",\n  \"onboarding.raid.failsafe.subtitle\": \"Deine Daten bleiben sicher, falls eine einzelne SSD ausfällt\",\n  \"onboarding.raid.failsafe.tip\": \"Verwende SSDs gleicher Größe für maximalen Speicherplatz und keinen unbrauchbaren Platz.\",\n  \"onboarding.raid.failsafe.warning-now-only\": \"Wenn mehr als eine SSD vorhanden ist, kann FailSafe nur während der Ersteinrichtung aktiviert werden. Du kannst es später nicht mehr aktivieren.\",\n  \"onboarding.raid.health-warning\": \"Dieses Laufwerk meldet Gesundheitsprobleme\",\n  \"onboarding.raid.launching\": \"Wird gestartet…\",\n  \"onboarding.raid.no-ssds-alt\": \"Keine SSDs gefunden\",\n  \"onboarding.raid.recommended\": \"Empfohlen\",\n  \"onboarding.raid.scanning\": \"Überprüfe deine SSD-Steckplätze\",\n  \"onboarding.raid.scanning-alt\": \"SSDs werden gescannt\",\n  \"onboarding.raid.setup-failed.description-no-retry\": \"Bitte fahre das Gerät herunter und versuche es erneut.\",\n  \"onboarding.raid.setup-failed.description-retry\": \"Versuche es erneut oder fahre herunter, um deine Laufwerke zu überprüfen.\",\n  \"onboarding.raid.setup-failed.title\": \"Speichereinrichtung fehlgeschlagen\",\n  \"onboarding.raid.shutdown-dialog.description\": \"Um Laufwerke hinzuzufügen oder auszutauschen, schalte Umbrel Pro aus. Danach kannst du es wieder einschalten und mit der Einrichtung fortfahren.\",\n  \"onboarding.raid.shutdown-dialog.title\": \"Laufwerke ändern?\",\n  \"onboarding.raid.ssd-in-slot\": \"Eine <highlight>{{size}}</highlight> SSD in <highlight>Slot {{slot}}</highlight>\",\n  \"onboarding.raid.ssd-label\": \"SSD {{number}}\",\n  \"onboarding.raid.ssd-tray-alt\": \"SSD-Schacht\",\n  \"onboarding.raid.ssds-found\": \"Die folgenden SSDs wurden in deinem Umbrel Pro gefunden\",\n  \"onboarding.raid.storage\": \"Speicher\",\n  \"onboarding.raid.storage-label\": \"Speicher\",\n  \"onboarding.raid.success.storage-info\": \"Speicher {{available}}\",\n  \"onboarding.raid.success.storage-info-failsafe\": \"Speicher {{available}} · FailSafe {{failsafe}}\",\n  \"onboarding.raid.try-again\": \"Erneut versuchen\",\n  \"onboarding.raid.wasted\": \"Unbrauchbar\",\n  \"onboarding.restore-long\": \"Mein Umbrel wiederherstellen\",\n  \"onboarding.restore-short\": \"Wiederherstellen\",\n  \"onboarding.start.continue\": \"Los geht's\",\n  \"onboarding.start.subtitle\": \"Dein Heim-Cloud-Server ist bereit zur Einrichtung.\",\n  \"onboarding.start.title\": \"Willkommen bei umbrelOS\",\n  \"open\": \"Öffnen\",\n  \"open-live-usage\": \"Live-Nutzung öffnen\",\n  \"password\": \"Passwort\",\n  \"preferences\": \"Einstellungen\",\n  \"raid-error.description\": \"Dein Speichersystem konnte nicht richtig starten. Prüfe unten den Status deiner SSDs und folge den Schritten zur Fehlerbehebung. Falls das Problem weiterhin besteht, müssen eventuell betroffene SSDs ersetzt werden.\",\n  \"raid-error.factory-reset-dialog.description\": \"Dies löscht alle Daten auf deinem Umbrel Pro und setzt ihn auf Werkseinstellungen zurück. Diese Aktion kann nicht rückgängig gemacht werden.\",\n  \"raid-error.factory-reset-dialog.title\": \"Werkseinstellungen zurücksetzen?\",\n  \"raid-error.factory-reset-failed\": \"Zurücksetzen auf Werkseinstellungen fehlgeschlagen\",\n  \"raid-error.health-warning\": \"Gesundheitswarnung\",\n  \"raid-error.missing-ssd-multiple\": \"{{count}} SSDs reagieren nicht\",\n  \"raid-error.missing-ssd-one\": \"1 SSD reagiert nicht\",\n  \"raid-error.shutdown-dialog.description\": \"Fahre dein Umbrel Pro herunter, stell sicher, dass alle SSDs richtig in ihren Steckplätzen sitzen, und schalte dann wieder ein.\",\n  \"raid-error.shutdown-dialog.title\": \"Herunterfahren, um Laufwerke zu prüfen?\",\n  \"raid-error.ssd-in-slot\": \"Eine <highlight>{{size}}</highlight> SSD in <highlight>Steckplatz {{slot}}</highlight>\",\n  \"raid-error.step-check-connections.button\": \"Herunterfahren\",\n  \"raid-error.step-check-connections.description\": \"Fahre herunter und überprüfe, ob alle SSDs richtig sitzen.\",\n  \"raid-error.step-check-connections.title\": \"Verbindungen der SSDs prüfen\",\n  \"raid-error.step-factory-reset.button\": \"Auf Werkseinstellungen zurücksetzen\",\n  \"raid-error.step-factory-reset.description\": \"Letzter Ausweg, wenn sonst nichts hilft. Dadurch werden alle Daten gelöscht.\",\n  \"raid-error.step-factory-reset.title\": \"Werkseinstellungen\",\n  \"raid-error.step-restart.button\": \"Neu starten\",\n  \"raid-error.step-restart.description\": \"Ein schneller erster Schritt, der oft hilft.\",\n  \"raid-error.step-restart.title\": \"Versuche neu zu starten\",\n  \"raid-error.title\": \"Speicherproblem festgestellt\",\n  \"read-less\": \"Weniger lesen\",\n  \"read-more\": \"Mehr lesen\",\n  \"reconnect\": \"Erneut verbinden\",\n  \"redirect.to-home\": \"Lädt...\",\n  \"redirect.to-login\": \"Lädt...\",\n  \"redirect.to-onboarding\": \"Lädt...\",\n  \"redirect.to-raid-error\": \"Wird geladen...\",\n  \"reload\": \"Neu laden\",\n  \"remote-tor-access\": \"Remote Tor-Zugang\",\n  \"reset\": \"Zurücksetzen\",\n  \"restart\": \"Neustarten\",\n  \"restart.confirm.submit\": \"Neustarten\",\n  \"restart.confirm.title\": \"Möchtest du dein Umbrel wirklich neu starten?\",\n  \"restart.restarting\": \"Neustart\",\n  \"restart.restarting-message\": \"Bitte aktualisiere diese Seite nicht und schalte dein Umbrel nicht aus, während es neu startet.\",\n  \"rewind\": \"Rewind\",\n  \"rewind.files-as-of\": \"Deine Dateien vom\",\n  \"rewind.loading-snapshots\": \"Snapshots werden geladen...\",\n  \"rewind.now\": \"Jetzt\",\n  \"rewind.preflight.description\": \"Finde Dateien und Ordner aus deinen vergangenen Backups und stelle sie in die Gegenwart wieder her.\",\n  \"rewind.preflight.enable-backups\": \"Richte Backups in den Einstellungen ein, um Rewind zu verwenden\",\n  \"rewind.restore-complete\": \"Wiederherstellung abgeschlossen\",\n  \"rewind.restore-error-description\": \"Bitte versuche es erneut.\",\n  \"rewind.restore-failed\": \"Wiederherstellung fehlgeschlagen\",\n  \"rewind.restore-running-description\": \"Schließe oder aktualisiere diese Seite nicht, bis die Wiederherstellung abgeschlossen ist\",\n  \"rewind.restore-selected\": \"Ausgewählte wiederherstellen\",\n  \"rewind.restore-success-description\": \"Deine Dateien wurden wiederhergestellt\",\n  \"rewind.restoring\": \"Wird wiederhergestellt\",\n  \"rewind.snapshots-count_one\": \"{{count}} Backup seit\",\n  \"rewind.snapshots-count_other\": \"{{count}} Backups seit\",\n  \"search\": \"Suche\",\n  \"settings\": \"Einstellungen\",\n  \"settings.app-store-preferences.title\": \"App Store Einstellungen\",\n  \"settings.contact-support\": \"Brauchst du Hilfe? <linked>Kontaktiere den Support.</linked>\",\n  \"settings.file-sharing\": \"Dateifreigabe\",\n  \"settings.file-sharing.add-folder\": \"Hinzufügen\",\n  \"settings.file-sharing.add-folder-title\": \"Wähle einen Ordner zum Freigeben\",\n  \"settings.file-sharing.choice-entire-description\": \"Teile alle Dateien auf deinem Umbrel\",\n  \"settings.file-sharing.choice-entire-title\": \"Alles\",\n  \"settings.file-sharing.choice-heading\": \"Was möchtest du teilen?\",\n  \"settings.file-sharing.choice-specific-description\": \"Wähle aus, welche Ordner du teilen möchtest\",\n  \"settings.file-sharing.choice-specific-title\": \"Bestimmte Ordner\",\n  \"settings.file-sharing.choice-subtitle\": \"Greife auf deine Dateien und Ordner wie bei Dropbox als Netzwerkordner auf deinem Computer oder Smartphone zu\",\n  \"settings.file-sharing.configure\": \"Konfigurieren\",\n  \"settings.file-sharing.description\": \"Greife auf deine Dateien wie bei Dropbox als Netzwerkordner (SMB) auf anderen Geräten zu\",\n  \"settings.file-sharing.home-shared-note\": \"Dein gesamter Ordner \\\"{{homeDirectoryName}}\\\" ist freigegeben. Einzelne Ordner müssen nicht separat freigegeben werden.\",\n  \"settings.file-sharing.share-entire-home-dir\": \"Gesamten Home-Ordner freigeben\",\n  \"settings.file-sharing.share-entire-home-dir-description\": \"Greife von anderen Geräten in deinem Netzwerk auf alle Dateien und Ordner in \\\"{{homeDirectoryName}}\\\" zu\",\n  \"settings.file-sharing.shared-folders\": \"Freigegebene Ordner\",\n  \"show-details\": \"Details anzeigen\",\n  \"shut-down\": \"Herunterfahren\",\n  \"shut-down.complete\": \"Herunterfahren abgeschlossen\",\n  \"shut-down.complete-text\": \"Du kannst jetzt dein Gerät vom Strom trennen.\",\n  \"shut-down.confirm.submit\": \"Herunterfahren\",\n  \"shut-down.confirm.title\": \"Möchtest du dein Umbrel wirklich herunterfahren?\",\n  \"shut-down.failed\": \"Fehler beim Herunterfahren: {{message}}\",\n  \"shut-down.shutting-down\": \"Herunterfahren\",\n  \"shut-down.shutting-down-message\": \"Bitte aktualisiere diese Seite nicht und schalte dein Umbrel nicht aus, während es herunterfährt.\",\n  \"software-update.callout\": \"Bitte aktualisiere diese Seite nicht und schalte dein Umbrel nicht aus, während es aktualisiert wird.\",\n  \"software-update.check\": \"Nach Aktualisierungen suchen\",\n  \"software-update.checking\": \"Suche nach Aktualisierungen...\",\n  \"software-update.current-running\": \"Du verwendest\",\n  \"software-update.failed\": \"Aktualisierung fehlgeschlagen\",\n  \"software-update.failed-to-check\": \"Fehler beim Suchen nach Aktualisierungen\",\n  \"software-update.failed.retry\": \"Erneut versuchen\",\n  \"software-update.install-now\": \"Jetzt installieren\",\n  \"software-update.new-version\": \"Neue {{name}} ist verfügbar zur Installation\",\n  \"software-update.on-latest\": \"Du verwendest die neueste Version von umbrelOS\",\n  \"software-update.see-whats-new\": \"Sieh dir <linked>die Neuerungen</linked> an\",\n  \"software-update.title\": \"Softwareaktualisierung\",\n  \"software-update.updating-to\": \"Aktualisiere auf {{name}}\",\n  \"software-update.view\": \"Anzeigen\",\n  \"something-left\": \"{{left}} verbleibend\",\n  \"something-went-wrong\": \"⚠ Es ist ein Fehler aufgetreten\",\n  \"start\": \"Starten\",\n  \"stop\": \"Stoppen\",\n  \"storage\": \"Speicherplatz\",\n  \"storage-manager\": \"Speicherverwaltung\",\n  \"storage-manager.add\": \"Hinzufügen\",\n  \"storage-manager.add-to-raid.add-ssd\": \"SSD hinzufügen\",\n  \"storage-manager.add-to-raid.available\": \"Verfügbar:\",\n  \"storage-manager.add-to-raid.description\": \"Eine neue SSD wurde erkannt und kann hinzugefügt werden.\",\n  \"storage-manager.add-to-raid.enable-failsafe\": \"FailSafe aktivieren\",\n  \"storage-manager.add-to-raid.failed-add\": \"Konnte die SSD nicht hinzufügen.\",\n  \"storage-manager.add-to-raid.failed-enable-failsafe\": \"Konnte FailSafe nicht aktivieren.\",\n  \"storage-manager.add-to-raid.failsafe-label\": \"FailSafe:\",\n  \"storage-manager.add-to-raid.info-capacity-added\": \"Deine neue <highlight>{{size}}</highlight> SSD wird dem verfügbaren Speicher hinzugefügt.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-available\": \"Deine neue <highlight>{{size}}</highlight> SSD fügt <highlight>{{available}}</highlight> verfügbaren Speicher hinzu.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-both\": \"Deine neue <highlight>{{size}}</highlight> SSD fügt <highlight>{{available}}</highlight> verfügbaren Speicher und <highlight>{{protection}}</highlight> zum Schutz deiner Daten hinzu.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only\": \"Deine neue <highlight>{{size}}</highlight> SSD fügt <highlight>{{protection}}</highlight> zum Schutz deiner Daten hinzu.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only-full\": \"Deine neue <highlight>{{size}}</highlight> SSD wird vollständig zum Schutz deiner Daten verwendet.\",\n  \"storage-manager.add-to-raid.info-data-safe\": \"Deine Daten sind sicher, falls eine einzelne SSD ausfällt.\",\n  \"storage-manager.add-to-raid.info-no-protection\": \"Wenn eine SSD ausfällt, könntest du deine Daten verlieren.\",\n  \"storage-manager.add-to-raid.info-total-wasted\": \"<wasted>{{size}}</wasted> insgesamt unbrauchbar aufgrund unterschiedlicher SSD-Größen.\",\n  \"storage-manager.add-to-raid.info-wasted\": \"<wasted>{{size}}</wasted> werden aufgrund unterschiedlicher SSD-Größen unbrauchbar sein.\",\n  \"storage-manager.add-to-raid.recommended\": \"Empfohlen\",\n  \"storage-manager.add-to-raid.recommended-inline\": \"(empfohlen)\",\n  \"storage-manager.add-to-raid.restart-active-tasks\": \"Aktive Aufgaben werden unterbrochen\",\n  \"storage-manager.add-to-raid.restart-after\": \"Nach dem Neustart wird die FailSafe-Einrichtung automatisch abgeschlossen und du kannst das System wie gewohnt nutzen.\",\n  \"storage-manager.add-to-raid.restart-during\": \"Während des Neustarts:\",\n  \"storage-manager.add-to-raid.restart-intro\": \"Du kannst umbrelOS während dieses Vorgangs normal weiter verwenden. Bei 50% Fortschritt wird dein Umbrel automatisch neu gestartet.\",\n  \"storage-manager.add-to-raid.restart-required\": \"Systemneustart erforderlich\",\n  \"storage-manager.add-to-raid.restart-ui-inaccessible\": \"umbrelOS ist vorübergehend nicht erreichbar.\",\n  \"storage-manager.add-to-raid.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD in <highlight>Steckplatz {{slot}}</highlight>\",\n  \"storage-manager.add-to-raid.title\": \"SSD zum Speicher hinzufügen\",\n  \"storage-manager.add-to-raid.too-small\": \"SSD zu klein\",\n  \"storage-manager.add-to-raid.too-small-description\": \"Diese SSD ({{deviceSize}}) ist kleiner als die derzeit kleinste installierte SSD ({{minSize}}). FailSafe erfordert, dass alle SSDs mindestens so groß sind wie die kleinste verwendete SSD.\",\n  \"storage-manager.add-to-raid.understand-continue\": \"Verstanden, fortfahren\",\n  \"storage-manager.add-to-raid.warning-failsafe-now-only\": \"Wenn du mehr als eine SSD hast, kann FailSafe jetzt nur noch sofort aktiviert werden. Später wird das nicht mehr möglich sein.\",\n  \"storage-manager.add-to-raid.wasted-label\": \"Unbrauchbar:\",\n  \"storage-manager.available-storage\": \"Verfügbarer Speicher\",\n  \"storage-manager.description\": \"Speicher, Zustand und Einstellungen deiner SSDs anzeigen\",\n  \"storage-manager.empty\": \"Leer\",\n  \"storage-manager.failsafe-transition-failed\": \"Konnte FailSafe nicht aktivieren.\",\n  \"storage-manager.for-failsafe\": \"Für FailSafe\",\n  \"storage-manager.health.checksum-errors\": \"Prüfsummenfehler: {{count}}\",\n  \"storage-manager.health.critical\": \"Kritisch\",\n  \"storage-manager.health.critical-threshold\": \"Kritische Schwelle\",\n  \"storage-manager.health.current-temperature\": \"Aktuelle Temperatur\",\n  \"storage-manager.health.estimated-life\": \"Geschätzte verbleibende Lebensdauer\",\n  \"storage-manager.health.general\": \"Allgemein\",\n  \"storage-manager.health.health-status\": \"Zustand\",\n  \"storage-manager.health.low\": \"Niedrig\",\n  \"storage-manager.health.model-and-capacity\": \"Modell & Kapazität\",\n  \"storage-manager.health.overheating\": \"Überhitzung\",\n  \"storage-manager.health.raid-failed-advice\": \"Diese SSD hat ein Problem. Fahre dein Umbrel herunter und überprüfe die SSD-Verbindung. Wenn das Problem weiterhin besteht, muss die SSD möglicherweise ersetzt werden.\",\n  \"storage-manager.health.read-errors\": \"Lese­fehler: {{count}}\",\n  \"storage-manager.health.serial-number\": \"Seriennummer\",\n  \"storage-manager.health.status-healthy\": \"Gesund\",\n  \"storage-manager.health.status-unhealthy\": \"Ungesund\",\n  \"storage-manager.health.status-unknown\": \"Unbekannt\",\n  \"storage-manager.health.temperature\": \"Temperatur\",\n  \"storage-manager.health.title\": \"SSD-Gesundheit\",\n  \"storage-manager.health.warning-life-advice\": \"Erwäge, diese SSD bald zu ersetzen.\",\n  \"storage-manager.health.warning-life-message\": \"Nur noch {{percent}}% Restlebensdauer.\",\n  \"storage-manager.health.warning-temp-advice\": \"Stelle sicher, dass dein Umbrel Pro gute Luftzirkulation hat und die SSD richtig eingesetzt ist.\",\n  \"storage-manager.health.warning-temp-critical\": \"Temperatur kritisch ({{temperature}})\",\n  \"storage-manager.health.warning-temp-overheating\": \"Laufwerk überhitzt ({{temperature}})\",\n  \"storage-manager.health.warning-threshold\": \"Warnschwelle\",\n  \"storage-manager.health.warning-unhealthy-advice\": \"Diese SSD könnte bald ausfallen. Erwäge, sie zu ersetzen.\",\n  \"storage-manager.health.warning-unhealthy-message\": \"Diese SSD könnte ein Problem haben.\",\n  \"storage-manager.health.warnings\": \"Warnungen\",\n  \"storage-manager.health.wear\": \"Verschleiß\",\n  \"storage-manager.health.write-errors\": \"Schreibfehler: {{count}}\",\n  \"storage-manager.install-ssd.description\": \"Füge weitere SSDs hinzu, um deinen Speicher zu erweitern.\",\n  \"storage-manager.install-ssd.step-insert\": \"Setze neue SSDs in die freien Steckplätze ein\",\n  \"storage-manager.install-ssd.step-power-on\": \"Schalte dein {{deviceName}} ein\",\n  \"storage-manager.install-ssd.step-remove-bottom-cover\": \"Entferne die magnetische Bodenabdeckung\",\n  \"storage-manager.install-ssd.step-replace-bottom-cover\": \"Setze die untere Abdeckung wieder auf\",\n  \"storage-manager.install-ssd.step-return\": \"Kehre hierher zurück, um die SSDs zu deinem Speicher hinzuzufügen\",\n  \"storage-manager.install-ssd.step-shut-down\": \"Fahre dein {{deviceName}} herunter\",\n  \"storage-manager.install-ssd.title\": \"SSDs hinzufügen\",\n  \"storage-manager.install-tips.image-alt\": \"SSD-Installationsanleitung\",\n  \"storage-manager.install-tips.instructions\": \"Zum Einbauen: Entferne die Daumenschraube und schiebe die SSD schräg in den Steckplatz. Drücke die SSD nach unten, bis sie auf der Schraubensäule aufliegt, und befestige sie dann mit der Daumenschraube.\",\n  \"storage-manager.install-tips.toggle\": \"Vergessen, wie man eine SSD einbaut?\",\n  \"storage-manager.manage\": \"Verwalten\",\n  \"storage-manager.missing-ssd-warning\": \"Eine SSD scheint zu fehlen. Fahre dein Umbrel herunter und überprüfe, ob alle SSDs angeschlossen sind. Wenn das Problem weiterhin besteht, muss die SSD möglicherweise ersetzt werden.\",\n  \"storage-manager.mode\": \"Modus\",\n  \"storage-manager.mode.failsafe\": \"FailSafe\",\n  \"storage-manager.mode.failsafe.description\": \"Schützt deine Daten, falls eine SSD ausfällt. Wenn deine SSDs unterschiedliche Größen haben, bleibt zusätzlicher Platz auf größeren SSDs ungenutzt.\",\n  \"storage-manager.mode.failsafe.info-description\": \"FailSafe schützt deine Daten, indem Kopien auf mehreren SSDs gespeichert werden. Wenn eine SSD ausfällt, bleiben deine Daten sicher und können wiederhergestellt werden, sobald du eine Ersatz-SSD einsetzt.\",\n  \"storage-manager.mode.failsafe.info-title\": \"Über FailSafe\",\n  \"storage-manager.mode.full-storage\": \"Full Storage\",\n  \"storage-manager.mode.full-storage.description\": \"Nutze den gesamten SSD-Speicher gemeinsam. Wenn eine SSD ausfällt, könntest du deine Daten verlieren.\",\n  \"storage-manager.mode.full-storage.info-description\": \"Full Storage fasst alle SSDs zu einem großen Speicher zusammen und bietet maximalen Platz. Wenn jedoch eine SSD ausfällt, gehen alle Daten verloren.\",\n  \"storage-manager.mode.full-storage.info-title\": \"Über Full Storage\",\n  \"storage-manager.mode.switch-from-failsafe-unavailable\": \"Der Wechsel von FailSafe in den Full Storage-Modus erfordert, dass du deine Daten sicherst, das Gerät auf Werkseinstellungen zurücksetzt und ein Backup wiederherstellst.\",\n  \"storage-manager.mode.switch-to-failsafe-unavailable\": \"Mit mehreren SSDs im Full Storage-Modus sind deine Daten über alle Laufwerke verteilt. Ein Wechsel zu FailSafe erfordert Backup, Werkseinstellungen und Wiederherstellung.\",\n  \"storage-manager.mode.why-cant-switch\": \"Warum kann ich nicht wechseln?\",\n  \"storage-manager.operation-in-progress.shutdown-description\": \"Du kannst das Gerät sicher herunterfahren. Der Vorgang wird pausiert und nach dem Neustart fortgesetzt, muss jedoch abgeschlossen sein, bevor du weitere Änderungen vornehmen kannst.\",\n  \"storage-manager.operation-in-progress.shutdown-title\": \"Dein Speicher wird aktualisiert\",\n  \"storage-manager.operation-in-progress.wait-description\": \"Bitte warte, bis der aktuelle Vorgang abgeschlossen ist, bevor du weitere Änderungen vornimmst.\",\n  \"storage-manager.operation-in-progress.wait-title\": \"Dein Speicher wird aktualisiert\",\n  \"storage-manager.operation.adding-ssd\": \"SSD wird hinzugefügt...\",\n  \"storage-manager.operation.enabling-failsafe\": \"FailSafe wird aktiviert...\",\n  \"storage-manager.operation.expanding\": \"Speicher wird erweitert...\",\n  \"storage-manager.operation.rebuilding\": \"Daten werden wieder aufgebaut...\",\n  \"storage-manager.operation.replacing\": \"Laufwerk wird ausgetauscht...\",\n  \"storage-manager.operation.restarting\": \"Neustart...\",\n  \"storage-manager.operation.starting\": \"Starte...\",\n  \"storage-manager.operation.syncing-restarts\": \"Daten werden synchronisiert • Neustart bei 50%\",\n  \"storage-manager.raid-status.degraded\": \"Reduziert\",\n  \"storage-manager.raid-status.failed\": \"Ausgefallen\",\n  \"storage-manager.raid-status.offline\": \"Offline\",\n  \"storage-manager.raid-status.online\": \"Online\",\n  \"storage-manager.raid-status.removed\": \"Entfernt\",\n  \"storage-manager.raid-status.unavailable\": \"Nicht verfügbar\",\n  \"storage-manager.replace\": \"Ersetzen\",\n  \"storage-manager.replace-failed.degraded\": \"FailSafe-Schutz reduziert\",\n  \"storage-manager.replace-failed.degraded-description\": \"Eine SSD fehlt in deinem FailSafe-Speicher. Ersetze sie, um den vollen Schutz wiederherzustellen.\",\n  \"storage-manager.replace-failed.description\": \"Verwende diese SSD, um deinen FailSafe-Schutz wiederherzustellen.\",\n  \"storage-manager.replace-failed.error\": \"Konnte den Austausch nicht starten\",\n  \"storage-manager.replace-failed.replace-now\": \"Jetzt austauschen\",\n  \"storage-manager.replace-failed.ssd-in-slot\": \"<highlight>{{size}}</highlight>-SSD in Steckplatz {{slot}}\",\n  \"storage-manager.replace-failed.step-protected\": \"Nach Abschluss sind deine Daten wieder vollständig geschützt\",\n  \"storage-manager.replace-failed.step-rebuild\": \"Die Daten werden auf die neue SSD wieder aufgebaut\",\n  \"storage-manager.replace-failed.step-time\": \"Das kann je nach Datenmenge eine Weile dauern\",\n  \"storage-manager.replace-failed.title\": \"SSD ersetzen\",\n  \"storage-manager.replace-failed.too-small\": \"SSD zu klein\",\n  \"storage-manager.replace-failed.too-small-description\": \"Diese SSD ({{deviceSize}}) ist kleiner als die für deinen FailSafe-Speicher erforderliche Mindestgröße ({{minSize}}).\",\n  \"storage-manager.replace-failed.what-happens\": \"Wie es weitergeht:\",\n  \"storage-manager.ssd-failing\": \"Fehlerhaft\",\n  \"storage-manager.swap\": \"Tauschen\",\n  \"storage-manager.swap.data-erased-description\": \"Im Full Storage-Modus gibt es keinen Schutz vor Datenverlust. Alle Daten auf deinem {{deviceName}} werden beim Zurücksetzen gelöscht. Sichere vorher alles.\",\n  \"storage-manager.swap.data-protected\": \"Deine Daten sind geschützt\",\n  \"storage-manager.swap.data-protected-description\": \"Mit aktiviertem FailSafe kannst du eine einzelne SSD austauschen, ohne Daten zu verlieren. Kein Backup nötig.\",\n  \"storage-manager.swap.data-will-be-erased\": \"Daten werden gelöscht\",\n  \"storage-manager.swap.description-failsafe\": \"Ersetze ein Laufwerk in deinem FailSafe-Speicher.\",\n  \"storage-manager.swap.description-full-storage\": \"Ersetze ein Laufwerk in deiner Full Storage-Konfiguration.\",\n  \"storage-manager.swap.description-no-free-slot\": \"Im Full Storage-Modus mit allen belegten Steckplätzen erfordert der Tausch einer SSD einen vollständigen Backup- und Wiederherstellungsprozess.\",\n  \"storage-manager.swap.description-replace\": \"Migriere deine Daten auf eine neue SSD und entferne dann die alte.\",\n  \"storage-manager.swap.failed-to-start\": \"Konnte den Ersatzvorgang nicht starten.\",\n  \"storage-manager.swap.no-data-loss\": \"Kein Datenverlust\",\n  \"storage-manager.swap.no-data-loss-description\": \"Deine Daten werden auf die neue SSD kopiert. Nach Abschluss kannst du die alte sicher entfernen.\",\n  \"storage-manager.swap.safe-swap-available\": \"Sicherer Tausch verfügbar\",\n  \"storage-manager.swap.safe-swap-description\": \"Da du einen freien Steckplatz hast, kannst du zuerst die neue SSD hinzufügen und dann deine Daten migrieren, bevor du die alte entfernst. Kein Backup nötig.\",\n  \"storage-manager.swap.select-new-ssd\": \"Wähle die neue SSD:\",\n  \"storage-manager.swap.ssd-in-slot\": \"{{size}} SSD in Steckplatz {{slot}}\",\n  \"storage-manager.swap.step-backup\": \"Sichere deine Daten\",\n  \"storage-manager.swap.step-backup-description\": \"Gehe zu Einstellungen → Backups und erstelle ein Backup aller Daten.\",\n  \"storage-manager.swap.step-data-copied\": \"Die Daten werden von der alten SSD auf die neue kopiert\",\n  \"storage-manager.swap.step-factory-reset\": \"Werkseinstellungen\",\n  \"storage-manager.swap.step-factory-reset-description\": \"Gehe zu Einstellungen → Erweitert → Werkseinstellungen, um dein {{deviceName}} zu löschen.\",\n  \"storage-manager.swap.step-insert-new-ssd\": \"Setze die neue SSD in einen freien Steckplatz ein\",\n  \"storage-manager.swap.step-may-take-while\": \"Das kann eine Weile dauern, je nachdem wie viele Daten du hast.\",\n  \"storage-manager.swap.step-power-on\": \"Schalte dein {{deviceName}} ein\",\n  \"storage-manager.swap.step-remove-bottom-cover\": \"Entferne die magnetische Bodenabdeckung\",\n  \"storage-manager.swap.step-remove-old\": \"Sobald fertig, fahre herunter und entferne {{ssd}}\",\n  \"storage-manager.swap.step-replace-bottom-cover\": \"Setze die Bodenabdeckung wieder auf\",\n  \"storage-manager.swap.step-restore\": \"Stelle deine Daten wieder her\",\n  \"storage-manager.swap.step-restore-description\": \"Gehe zu Einstellungen → Backups und stelle dein Backup wieder her.\",\n  \"storage-manager.swap.step-return-to-storage-manager\": \"Kehre zur Speicherverwaltung zurück, um den Tausch zu bestätigen und die neue SSD zu deinem Speicher hinzuzufügen\",\n  \"storage-manager.swap.step-return-to-swap\": \"Kehre zur Speicherverwaltung zurück und klicke erneut auf \\\"Swap\\\", um den Austausch zu starten\",\n  \"storage-manager.swap.step-setup-new-storage\": \"Richte deinen neuen Speicher ein\",\n  \"storage-manager.swap.step-setup-new-storage-description\": \"Schalte dein {{deviceName}} ein und schließe die Einrichtung mit der neuen SSD ab.\",\n  \"storage-manager.swap.step-shut-down\": \"Fahre dein {{deviceName}} herunter\",\n  \"storage-manager.swap.step-shut-down-and-swap\": \"Herunterfahren und {{ssd}} tauschen\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-other\": \"Ausschalten, Gerät öffnen, SSD ersetzen und wieder zusammenbauen.\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-pro\": \"Ausschalten, Bodenabdeckung entfernen, SSD ersetzen und Abdeckung wieder schließen.\",\n  \"storage-manager.swap.step-swap-ssd\": \"Tausche {{ssd}} gegen eine neue mit derselben Größe\",\n  \"storage-manager.swap.too-small\": \"Zu klein ({{size}} benötigt)\",\n  \"storage-manager.swap.what-happens-next\": \"Was als Nächstes passiert:\",\n  \"storage-manager.total-capacity-added\": \"Hinzugefügte Gesamtkapazität\",\n  \"storage-manager.umbrel-pro\": \"Umbrel Pro\",\n  \"storage-manager.used\": \"Belegt\",\n  \"storage-manager.wasted\": \"Unbrauchbar\",\n  \"storage-manager.wasted-size\": \"{{size}} nicht verwendbar\",\n  \"storage.full\": \"Speicherplatz voll\",\n  \"storage.low\": \"Wenig Speicherplatz\",\n  \"temperature\": \"Temperatur\",\n  \"temperature.dangerously-hot\": \"Sehr heiß\",\n  \"temperature.nice\": \"Angenehm\",\n  \"temperature.normal\": \"Normal\",\n  \"temperature.too-hot-suggestion\": \"Überlege, die Umgebung deines Geräts zu ändern.\",\n  \"temperature.warm\": \"Warm\",\n  \"terminal\": \"Terminal\",\n  \"terminal-description\": \"Führe benutzerdefinierte Befehle in umbrelOS oder innerhalb einer App aus\",\n  \"terminal.app\": \"App\",\n  \"terminal.app-description\": \"Führe benutzerdefinierte Befehle innerhalb einer spezifischen App aus\",\n  \"terminal.umbrelos-description\": \"Führe benutzerdefinierte Befehle in umbrelOS aus\",\n  \"tor-description\": \"Greife von überall auf dein Umbrel zu mit einem Tor-Browser\",\n  \"tor-enabled-description\": \"Greife von überall mit einem Tor-Browser über die folgende URL auf dein Umbrel zu:\",\n  \"tor-error\": \"Fehler beim Aktualisieren der Tor-Einstellung: {{message}}\",\n  \"tor.disable.description\": \"Dies kann einige Minuten dauern\",\n  \"tor.disable.progress\": \"Remote-Tor-Zugriff wird deaktiviert\",\n  \"tor.enable.description\": \"Dies kann einige Minuten dauern\",\n  \"tor.enable.mobile.switch-label\": \"Remote Tor-Zugang aktivieren\",\n  \"tor.hidden-service\": \"Tor Hidden Service URL\",\n  \"troubleshoot\": \"Fehlerbehebung\",\n  \"troubleshoot-description\": \"Fehlerbehebung für umbrelOS oder eine App\",\n  \"troubleshoot-no-logs-yet\": \"Noch keine Protokolle\",\n  \"troubleshoot-pick-title\": \"Fehlerbehebung\",\n  \"troubleshoot.app\": \"App\",\n  \"troubleshoot.app-description\": \"Protokolle einer auf deinem Umbrel installierten App anzeigen\",\n  \"troubleshoot.app-download\": \"{{app}} Protokolle herunterladen\",\n  \"troubleshoot.share-with-umbrel-support\": \"Mit dem Umbrel-Support teilen\",\n  \"troubleshoot.system-download\": \"{{label}} herunterladen\",\n  \"troubleshoot.umbrelos-description\": \"umbrelOS-Protokolle anzeigen\",\n  \"troubleshoot.umbrelos-logs\": \"umbrelOS-Protokolle\",\n  \"trpc.backend-unavailable\": \"Fehler: Verbindung zur System-API fehlgeschlagen\",\n  \"trpc.checking-backend\": \"Lädt...\",\n  \"try-again\": \"Erneut versuchen\",\n  \"umbrel\": \"Umbrel\",\n  \"umbrelos\": \"umbrelOS\",\n  \"unknown\": \"Unbekannt\",\n  \"unknown-app\": \"Unbekannte App\",\n  \"unknown-error\": \"Unbekannter Fehler\",\n  \"uptime\": \"Betriebszeit\",\n  \"url\": \"URL\",\n  \"wallpaper\": \"Hintergrundbild\",\n  \"wallpaper-description\": \"Dein Umbrel Hintergrundbild und Thema\",\n  \"whats-new.continue\": \"Fortfahren\",\n  \"whats-new.feature-1.description\": \"Richte automatisierte, verschlüsselte Backups deines gesamten Umbrel auf einem externen USB-Laufwerk, einem NAS oder einem anderen Umbrel ein.\",\n  \"whats-new.feature-2.description\": \"Springe in der Zeit zurück, um bestimmte Dateien und Ordner aus früheren Backups wiederherzustellen.\",\n  \"whats-new.feature-3.description\": \"Oder stelle dein gesamtes Umbrel wieder her, inklusive aller Apps, Dateien und Daten.\",\n  \"whats-new.feature-4.description\": \"Verbinde ein NAS oder ein anderes Umbrel und greife über Files auf dessen Speicher zu.\",\n  \"whats-new.feature-4.title\": \"Netzwerkgeräte\",\n  \"whats-new.feature-5.description\": \"Schließe externe USB-Laufwerke an (auf dem Umbrel Home oder auf jedem Intel- oder AMD-Gerät) und greife über Files darauf zu.\",\n  \"whats-new.feature-5.helper-text\": \"Auf Raspberry Pi-Geräten nicht unterstützt, da es zu Stromproblemen kommen kann.\",\n  \"whats-new.feature-5.title\": \"Externer Speicher\",\n  \"whats-new.next\": \"Weiter\",\n  \"whats-new.title\": \"Neu in {{version}}\",\n  \"widget.progress.in-progress\": \"In Bearbeitung\",\n  \"widgets.edit.select-up-to-3-widgets\": \"Wähle bis zu 3 Widgets\",\n  \"widgets.install-an-app-before-using-widgets\": \"Installiere eine App, um deinen Startbildschirm mit Widgets anzupassen.\",\n  \"wifi\": \"Wi-Fi\",\n  \"wifi-connect-insecure-message\": \"Offene Netzwerke können unsicher sein\",\n  \"wifi-connection-failed\": \"Verbindung fehlgeschlagen\",\n  \"wifi-dangerous-change-confirmation-description\": \"Ein Wechsel des Wi-Fi-Netzwerks kann dich von deinem Umbrel trennen. Um dich erneut zu verbinden, stelle sicher, dass sowohl dein Umbrel als auch das Gerät, von dem aus du darauf zugreifst, im selben Netzwerk sind.\",\n  \"wifi-dangerous-change-confirmation-title\": \"Bist du sicher, dass du das Wi-Fi-Netzwerk wechseln möchtest?\",\n  \"wifi-dangerous-disable-confirmation-description\": \"Das Deaktivieren von Wi-Fi kann dich von deinem Umbrel trennen. Um dich erneut zu verbinden, stecke ein Ethernet-Kabel in dein Umbrel und stelle sicher, dass sowohl dein Umbrel als auch das Gerät, von dem aus du darauf zugreifst, im selben Netzwerk sind.\",\n  \"wifi-dangerous-disable-confirmation-title\": \"Bist du sicher, dass du Wi-Fi deaktivieren möchtest?\",\n  \"wifi-description\": \"Verbinde dein Gerät mit einem Wi-Fi-Netzwerk\",\n  \"wifi-description-long\": \"Dein Gerät bleibt mit deinem gewählten Wi-Fi verbunden, auch wenn das Ethernet-Kabel entfernt wird, und verbindet sich beim Start automatisch wieder mit dem Wi-Fi.\",\n  \"wifi-no-networks-message\": \"Keine Wi-Fi-Netzwerke gefunden\",\n  \"wifi-searching\": \"Suche nach Wi-Fi-Netzwerken...\",\n  \"wifi-unsupported-device-description\": \"Wi-Fi wird auf diesem Gerät nicht unterstützt. Dies kann an einem fehlenden oder inkompatiblen WLAN-Adapter liegen.\",\n  \"wifi-view-networks\": \"Netzwerke anzeigen\"\n}"
  },
  {
    "path": "packages/ui/public/locales/en.json",
    "content": "{\n  \"2fa\": \"2FA\",\n  \"2fa-description\": \"A second layer of security for your Umbrel login and apps\",\n  \"2fa.disable.title\": \"Disable two-factor authentication\",\n  \"2fa.enable.or-paste\": \"Or paste the following code in your authenticator app\",\n  \"2fa.enable.scan-this\": \"Scan this QR code using an authenticator app like Google Authenticator or Authy\",\n  \"2fa.enable.title\": \"Enable two-factor authentication\",\n  \"2fa.enter-code\": \"Enter the code displayed in your authenticator app\",\n  \"account\": \"Account\",\n  \"account-description\": \"Your name and password\",\n  \"advanced-settings\": \"Advanced settings\",\n  \"advanced-settings-description\": \"Terminal, umbrelOS Beta Program, Cloudflare DNS, and more\",\n  \"app-not-found\": \"App not found: {{app}}\",\n  \"app-only-over-tor\": \"{{app}} can only be used over Tor. Please access your Umbrel in a Tor browser on your remote access URL (Settings > Advanced settings > Remote Tor access) to open this app.\",\n  \"app-page.section.about\": \"About\",\n  \"app-page.section.credentials.title\": \"Default credentials\",\n  \"app-page.section.dependencies.n-alternatives\": \"See {{count}} alternatives\",\n  \"app-page.section.info.compatibility\": \"Compatibility\",\n  \"app-page.section.info.compatibility-compatible\": \"Compatible\",\n  \"app-page.section.info.compatibility-not-compatible\": \"Not compatible\",\n  \"app-page.section.info.developer\": \"Developer\",\n  \"app-page.section.info.source-code\": \"Source Code\",\n  \"app-page.section.info.source-code.public\": \"Public\",\n  \"app-page.section.info.submitted-by\": \"Submitted by\",\n  \"app-page.section.info.support\": \"Get support\",\n  \"app-page.section.info.title\": \"Info\",\n  \"app-page.section.info.version\": \"Version\",\n  \"app-page.section.recommendations.title\": \"You might also like\",\n  \"app-page.section.release-notes.title\": \"What's new\",\n  \"app-page.section.release-notes.version\": \"Version {{version}}\",\n  \"app-page.section.requires\": \"Requires\",\n  \"app-picker.search\": \"Search...\",\n  \"app-picker.select-app\": \"Select app...\",\n  \"app-settings.connected-to\": \"{{appName}} is connected to these apps\",\n  \"app-settings.save-changes\": \"Save changes\",\n  \"app-settings.title\": \"Settings\",\n  \"app-store.browse-category-apps\": \"Browse {{category}} apps\",\n  \"app-store.category.ai\": \"AI\",\n  \"app-store.category.all\": \"All apps\",\n  \"app-store.category.automation\": \"Home & Automation\",\n  \"app-store.category.bitcoin\": \"Bitcoin\",\n  \"app-store.category.crypto\": \"Crypto\",\n  \"app-store.category.developer\": \"Developer Tools\",\n  \"app-store.category.discover\": \"Discover\",\n  \"app-store.category.files\": \"Files & Productivity\",\n  \"app-store.category.finance\": \"Finance\",\n  \"app-store.category.media\": \"Media\",\n  \"app-store.category.networking\": \"Networking\",\n  \"app-store.category.social\": \"Social\",\n  \"app-store.description\": \"Your app update settings\",\n  \"app-store.discover.temporarily-unavailable-description\": \"Browse categories above or search to find apps\",\n  \"app-store.discover.temporarily-unavailable-title\": \"Featured content temporarily unavailable\",\n  \"app-store.menu.community-app-stores\": \"Community App Stores\",\n  \"app-store.search-apps\": \"Search apps\",\n  \"app-store.search.no-results\": \"No results\",\n  \"app-store.search.results-for\": \"Results for\",\n  \"app-store.title\": \"App Store\",\n  \"app-store.updates\": \"Updates\",\n  \"app-updates.less\": \"less\",\n  \"app-updates.more\": \"more\",\n  \"app-updates.no-updates\": \"All apps are up-to-date!\",\n  \"app-updates.update\": \"Update\",\n  \"app-updates.update-all\": \"Update all\",\n  \"app-updates.updates-available-count_one\": \"{{count}} update available\",\n  \"app-updates.updates-available-count_other\": \"{{count}} updates available\",\n  \"app-updates.updating\": \"Updating...\",\n  \"app.install\": \"Install\",\n  \"app.installed\": \"Installed\",\n  \"app.installing\": \"Installing\",\n  \"app.offline\": \"Not running\",\n  \"app.open\": \"Open\",\n  \"app.optimized-for-umbrel-home\": \"Optimized for Umbrel Home\",\n  \"app.os-update-required.confirm\": \"Check for umbrelOS update\",\n  \"app.os-update-required.description\": \"{{appName}} requires umbrelOS {{version}} or later\",\n  \"app.os-update-required.title\": \"Update umbrelOS\",\n  \"app.restarting\": \"Restarting\",\n  \"app.starting\": \"Starting\",\n  \"app.stopping\": \"Stopping\",\n  \"app.uninstall.confirm.description\": \"All data associated with {{app}} will be permanently deleted. This action cannot be undone.\",\n  \"app.uninstall.confirm.submit\": \"Uninstall\",\n  \"app.uninstall.confirm.title\": \"Uninstall {{app}}?\",\n  \"app.uninstall.deps.used-by.description_one\": \"Uninstall {{firstAppToUninstall}} first to uninstall {{app}}.\",\n  \"app.uninstall.deps.used-by.description_other\": \"Uninstall these apps first to uninstall {{app}}.\",\n  \"app.uninstall.deps.used-by.title\": \"{{app}} is used by\",\n  \"app.uninstalling\": \"Uninstalling\",\n  \"app.updating\": \"Updating\",\n  \"app.view\": \"View\",\n  \"app_one\": \"app\",\n  \"app_other\": \"apps\",\n  \"apps.uninstall.failed-to-get-required-apps\": \"Failed to get required apps\",\n  \"apps.uninstalled-all.success\": \"Uninstalled all apps\",\n  \"auth.checking-backend-for-user\": \"Loading...\",\n  \"auth.failed-checking-if-user-logged-in\": \"Error: Auth login check failed\",\n  \"auth.failed-to-check-if-user-exists\": \"Error: Auth existence check failes\",\n  \"back\": \"Back\",\n  \"backups\": \"Backups\",\n  \"backups-configure\": \"Configure\",\n  \"backups-configure.add-backup-location\": \"Add backup location\",\n  \"backups-configure.available\": \"Available\",\n  \"backups-configure.awaiting-next-backup\": \"Awaiting next automatic backup\",\n  \"backups-configure.back-up-now\": \"Back up now\",\n  \"backups-configure.backing-up-now\": \"Backing up now...\",\n  \"backups-configure.connected\": \"Connected\",\n  \"backups-configure.connection\": \"Connection\",\n  \"backups-configure.in-progress\": \"In progress\",\n  \"backups-configure.last-backup\": \"Last backup\",\n  \"backups-configure.locations\": \"Locations\",\n  \"backups-configure.no-backup-locations\": \"Add a backup location to start backing up your data\",\n  \"backups-configure.not-connected\": \"Not connected\",\n  \"backups-configure.path\": \"Path\",\n  \"backups-configure.remove-backup-location\": \"Remove backup location\",\n  \"backups-configure.remove-backup-location-confirmation\": \"Are you sure?\",\n  \"backups-configure.remove-backup-location-confirmation-description\": \"This will remove '{{device}}' from your backup locations. Your existing backups on this device will not be deleted, but automatic backups will stop.\",\n  \"backups-configure.status\": \"Status\",\n  \"backups-configure.total-backups\": \"Total backups\",\n  \"backups-configure.used\": \"Used\",\n  \"backups-configure.view\": \"View\",\n  \"backups-description\": \"Back up your files, apps, and data to another Umbrel, NAS, or external drive\",\n  \"backups-error.backup-not-found\": \"The backup could not be found.\",\n  \"backups-error.generic\": \"Something went wrong: {{details}}\",\n  \"backups-error.in-progress\": \"A backup process is already running. Please wait for it to finish.\",\n  \"backups-error.invalid-exclusion-path\": \"Only files and folders in your Home directory can be excluded from backups.\",\n  \"backups-error.invalid-password\": \"The encryption password is incorrect.\",\n  \"backups-error.invalid-path\": \"The selected location is not valid for backups.\",\n  \"backups-error.mount-failed\": \"Could not access the backup snapshot.\",\n  \"backups-error.mount-timeout\": \"Could not access the backup snapshot. Try again or check if the device is connected properly.\",\n  \"backups-error.not-enough-space\": \"Not enough space available on the backup device.\",\n  \"backups-error.not-found\": \"The backup or backup location could not be found.\",\n  \"backups-error.repository-exists\": \"A backup location already exists at this folder.\",\n  \"backups-error.repository-not-found\": \"The backup location could not be found.\",\n  \"backups-exclusions.add\": \"Add\",\n  \"backups-exclusions.app-paths-cannot-be-modified\": \"These files/folders are set by the app developer and cannot be modified:\",\n  \"backups-exclusions.app-paths-explanation\": \"This app excludes the following data from being backed up. These paths usually contain non-essential items (like caches or logs that can be recreated) or data that could cause issues if restored (such as outdated app states that might lead to conflicts or inconsistencies).\",\n  \"backups-exclusions.auto-excluded\": \"Auto-excluded\",\n  \"backups-exclusions.exclude-entire-app\": \"Exclude entire app\",\n  \"backups-exclusions.excluded-apps\": \"Excluded apps\",\n  \"backups-exclusions.files-and-folders\": \"Excluded files and folders\",\n  \"backups-exclusions.no-excluded-apps\": \"No excluded apps\",\n  \"backups-exclusions.no-excluded-files-or-folders\": \"No excluded files or folders\",\n  \"backups-exclusions.select-item-to-exclude\": \"Select item to exclude\",\n  \"backups-exclusions.stop-excluding\": \"Stop excluding\",\n  \"backups-floating-island.backing-up\": \"Backing up...\",\n  \"backups-floating-island.backing-up-to\": \"Backing up your Umbrel...\",\n  \"backups-restore\": \"Restore\",\n  \"backups-restore-full\": \"Full Restore\",\n  \"backups-restore-full-description\": \"Restore your entire Umbrel from a backup\",\n  \"backups-restore-header\": \"Restore your Umbrel\",\n  \"backups-restore-pro.after-restore\": \"After restoring, your temporary account will be replaced with your backed-up account and data.\",\n  \"backups-restore-pro.step1\": \"Complete onboarding by clicking \\\"Get Started\\\" below. This will be your temporary account until you restore your backed-up account.\",\n  \"backups-restore-pro.step2\": \"Once setup is complete, go to <0>Settings → Backups → Restore</0>\",\n  \"backups-restore-pro.step3\": \"Follow the prompts in the Restore Wizard.\",\n  \"backups-restore-pro.subtitle\": \"Restoring from a backup on Umbrel Pro requires a few extra steps\",\n  \"backups-restore.backup-date\": \"Backup date\",\n  \"backups-restore.backup-location\": \"Backup location\",\n  \"backups-restore.browse-cloud-subtitle\": \"Restore from Umbrel Private Cloud (coming soon)\",\n  \"backups-restore.browse-cloud-title\": \"Umbrel Private Cloud\",\n  \"backups-restore.browse-external-subtitle\": \"Restore from an external USB drive\",\n  \"backups-restore.browse-external-title\": \"External Drive\",\n  \"backups-restore.browse-nas-or-external\": \"Browse another Umbrel, NAS, or an external drive to restore from a backup\",\n  \"backups-restore.browse-nas-subtitle\": \"Restore from another Umbrel or NAS device on your network\",\n  \"backups-restore.browse-nas-title\": \"Another Umbrel or NAS\",\n  \"backups-restore.choose\": \"Choose\",\n  \"backups-restore.choose-backup-location\": \"Choose a backup location\",\n  \"backups-restore.connect-to-backup-location\": \"Connect to a backup location\",\n  \"backups-restore.encryption-password\": \"Encryption password\",\n  \"backups-restore.encryption-password-description\": \"Enter the encryption password that you set when you enabled backups\",\n  \"backups-restore.enter-password-to-confirm\": \"Enter your Umbrel password to confirm\",\n  \"backups-restore.final-confirmation\": \"Are you sure?\",\n  \"backups-restore.final-confirmation-description\": \"Restoring from this backup will replace your current umbrelOS apps and data with the contents of the selected backup. Any files, folders, or apps excluded from this backup will be removed from your Umbrel. This action cannot be undone.\",\n  \"backups-restore.invalid-password\": \"Invalid password\",\n  \"backups-restore.last-backup\": \"Last backup: {{date}}\",\n  \"backups-restore.latest\": \"Latest\",\n  \"backups-restore.no-backups-found\": \"No backups found\",\n  \"backups-restore.no-backups-yet\": \"No backups yet\",\n  \"backups-restore.please-select-backup\": \"Please select a backup\",\n  \"backups-restore.please-select-repository\": \"Please select a repository\",\n  \"backups-restore.restore-from-nas-or-external\": \"Restore your Umbrel from a backup on another Umbrel, a NAS, or an external drive\",\n  \"backups-restore.restore-from-unlisted\": \"Restore from another location\",\n  \"backups-restore.restore-umbrel\": \"Restore Umbrel\",\n  \"backups-restore.restore-warning\": \"Restoring from this backup will replace your current umbrelOS apps and data with the contents of the selected backup. Any files, folders, or apps excluded from this backup will be removed from your Umbrel. Open <0>Rewind</0> if you wish to restore specific files or folders instead.\",\n  \"backups-restore.restoring-from\": \"You are about to restore from the following backup:\",\n  \"backups-restore.review-description\": \"Restoring will set up your Umbrel with the account, files, apps, and settings that were included at the time of this backup. This may take some time. Once it's complete, your login password will be set to the one you used when the backup was created.\",\n  \"backups-restore.select-backup\": \"Select a backup\",\n  \"backups-restore.select-backup-description\": \"Select the backup you want to restore from\",\n  \"backups-restore.select-backup-file\": \"Select your backup file\",\n  \"backups-restore.select-backup-file-only\": \"Only <bold>{{backupFileName}}</bold> can be selected\",\n  \"backups-restore.total-size\": \"Total size\",\n  \"backups-restore.unknown-date\": \"Unknown date\",\n  \"backups-restore.unknown-repository\": \"Unknown repository\",\n  \"backups-rewind\": \"Rewind\",\n  \"backups-rewind-description\": \"Jump back in time to restore specific files and folders\",\n  \"backups-rewind.start\": \"Start Rewind\",\n  \"backups-setup\": \"Set up\",\n  \"backups-setup-confirm\": \"Finish setup\",\n  \"backups-setup-external-description\": \"Back up to an external USB drive\",\n  \"backups-setup-nas-or-umbrel-description\": \"Back up to another Umbrel or a NAS device on your network\",\n  \"backups-setup-umbrel-or-nas\": \"Another Umbrel or NAS\",\n  \"backups-setup-umbrel-private-cloud\": \"Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-cta\": \"Extend your peace of mind beyond your home with <bold>end-to-end encrypted backups</bold> to Umbrel Private Cloud.\",\n  \"backups-setup-umbrel-private-cloud-cta-link\": \"Get early access\",\n  \"backups-setup-umbrel-private-cloud-description\": \"End-to-end encrypted backups to Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-subtitle\": \"Coming soon\",\n  \"backups.add-umbrel-or-nas\": \"Add Umbrel or NAS\",\n  \"backups.all-apps-and-data-will-be-backed-up\": \"All apps and data will be backed up\",\n  \"backups.apps-and-data\": \"Apps & data\",\n  \"backups.backup-location\": \"Backup location\",\n  \"backups.browse\": \"Browse\",\n  \"backups.choose-folder-within-device\": \"Choose a folder within <bold>{{device}}</bold> to save your backups\",\n  \"backups.confirm-password\": \"Confirm password\",\n  \"backups.copy\": \"Copy\",\n  \"backups.encryption\": \"Encryption\",\n  \"backups.encryption-password-warning\": \"Make sure your encryption password is stored securely, such as in a password manager. You will not be able to see it again, and you will need it to restore from your backups.\",\n  \"backups.exclude-from-backups\": \"Exclude from Backups\",\n  \"backups.exclude-from-backups-description\": \"Exclude specific files, folders, and apps from your backups.\",\n  \"backups.hide\": \"Hide\",\n  \"backups.i-understand\": \"I understand\",\n  \"backups.location\": \"Location\",\n  \"backups.modals.already-in-use.description\": \"This backup location is already being used for backups on this Umbrel.\",\n  \"backups.modals.already-in-use.manage\": \"Manage in Backups\",\n  \"backups.modals.already-in-use.title\": \"Backup location already in use\",\n  \"backups.modals.connect-existing.description\": \"An Umbrel backup already exists at this location. Enter its encryption password to add it to this Umbrel.\",\n  \"backups.modals.connect-existing.title\": \"Connect existing Umbrel backup\",\n  \"backups.no-external-drives-detected\": \"No external drives detected\",\n  \"backups.no-password-set\": \"No password set\",\n  \"backups.password-is-set\": \"Password is set\",\n  \"backups.password-minimum-length\": \"Password must be at least 8 characters\",\n  \"backups.password-safety-warning\": \"Your backups will be encrypted with this password. Keep it safe, as you will not be able to see it again, and you will need it to restore from your backups.\",\n  \"backups.passwords-do-not-match\": \"Passwords do not match\",\n  \"backups.please-choose-folder\": \"Please choose a folder\",\n  \"backups.restore-failed.message\": \"There was an error while restoring your Umbrel. Your current apps and data were not changed.\",\n  \"backups.restore-failed.retry\": \"Go to Restore\",\n  \"backups.restore-failed.title\": \"Restore failed\",\n  \"backups.restoring\": \"Restoring your Umbrel\",\n  \"backups.restoring-completing\": \"Finishing up. Your Umbrel will restart shortly...\",\n  \"backups.restoring-progress\": \"Restored {{percent}}%\",\n  \"backups.restoring-time-remaining\": \"{{time}} remaining\",\n  \"backups.restoring-warning\": \"Do not power off your Umbrel or disconnect your backup location during restore\",\n  \"backups.review\": \"Review and confirm\",\n  \"backups.review-description\": \"Review the details of your backup and confirm your selection\",\n  \"backups.scanning-for-external-drives\": \"Scanning for external drives...\",\n  \"backups.schedule-description\": \"umbrelOS backs up your data automatically on an hourly basis. It keeps encrypted hourly backups for the past 24 hours, daily backups for the past week, weekly backups for the past month, and monthly backups for the past year. Backups older than one year are automatically removed.\",\n  \"backups.select-backup-folder\": \"Select backup folder\",\n  \"backups.select-backup-folder-description\": \"Choose a folder where you'd like to store your backups.\",\n  \"backups.select-backup-location\": \"Select a backup location\",\n  \"backups.set-encryption-password\": \"Set encryption password\",\n  \"backups.set-encryption-password-description\": \"Protect your backups with a password. This ensures your data stays private and can only be restored with this password.\",\n  \"backups.show\": \"Show\",\n  \"backups.storage-capacity-warning\": \"{{device}} must have free space equal to at least twice your backup size\",\n  \"backups.store-encryption-password-safely\": \"Store your encryption password safely\",\n  \"beta-program\": \"umbrelOS Beta Program\",\n  \"beta-program-description\": \"Opt in to receive umbrelOS beta updates, gain early access to new features, and help us refine them by providing your feedback. Beta updates might be unstable, and troubleshooting may require familiarity with terminal.\",\n  \"cancel\": \"Cancel\",\n  \"change\": \"Change\",\n  \"change-name\": \"Change name\",\n  \"change-name.failed.name-required\": \"Name is required\",\n  \"change-name.input-placeholder\": \"Your name\",\n  \"change-password\": \"Change password\",\n  \"change-password.callout\": \"If you lose your password, you won't be able to log into your Umbrel. Make sure to safely secure it.\",\n  \"change-password.current-password\": \"Current password\",\n  \"change-password.failed.current-required\": \"Current password is required\",\n  \"change-password.failed.min-length\": \"Password must be at least {{characters}} characters\",\n  \"change-password.failed.must-be-unique\": \"New password must be different from the current password\",\n  \"change-password.failed.new-required\": \"New password is required\",\n  \"change-password.failed.no-match\": \"Passwords do not match\",\n  \"change-password.failed.repeat-required\": \"Repeat password is required\",\n  \"change-password.new-password\": \"New password\",\n  \"change-password.repeat-password\": \"Repeat password\",\n  \"check-for-latest-version\": \"Check for the latest umbrelOS update\",\n  \"clipboard.copied\": \"Copied\",\n  \"close\": \"Close\",\n  \"cmdk.change-wallpaper\": \"Change wallpaper\",\n  \"cmdk.frequent-apps\": \"Frequently used\",\n  \"cmdk.input-placeholder\": \"Search for apps, settings, or actions\",\n  \"cmdk.live-usage\": \"Live Usage\",\n  \"cmdk.restart-umbrel\": \"Restart Umbrel\",\n  \"cmdk.shutdown-umbrel\": \"Shut down Umbrel\",\n  \"cmdk.update-all-apps\": \"Update all apps\",\n  \"cmdk.widgets\": \"Widgets\",\n  \"community-app-store\": \"Community App Store\",\n  \"community-app-store.add-error\": \"Failed to add app store: {{message}}\",\n  \"community-app-store.back-to-umbrel-app-store\": \"Back to Umbrel App Store\",\n  \"community-app-store.open-button\": \"Open\",\n  \"community-app-store.remove-button\": \"Remove\",\n  \"community-app-store.remove-error\": \"Failed to remove app store: {{message}}\",\n  \"community-app-stores.add-button\": \"Add\",\n  \"community-app-stores.description\": \"Community App Stores allow you to install apps on your Umbrel that may not be available in the official Umbrel App Store. They also make it easy to test beta versions of Umbrel apps before developers release them on the official Umbrel App Store.\",\n  \"community-app-stores.learn-more\": \"Learn more\",\n  \"community-app-stores.warning\": \"Community App Stores can be created by anyone. The apps published in them are not verified or vetted by the official Umbrel App Store team, and can potentially be insecure or malicious. Use caution and only add app stores from developers you trust.\",\n  \"confirm\": \"Confirm\",\n  \"connect\": \"Connect\",\n  \"connecting\": \"Connecting...\",\n  \"connection-lost\": \"Connection lost\",\n  \"connection-lost-description\": \"This can happen when your browser tab has been inactive, your network connection was interrupted, or your device is offline.\",\n  \"continue\": \"Continue\",\n  \"continue-to-log-in\": \"Continue to log in\",\n  \"cpu\": \"CPU\",\n  \"cpu-core-count\": \"{{cores}} threads\",\n  \"default-credentials.close\": \"Got it\",\n  \"default-credentials.description\": \"Here are the default credentials you will need to log into the app.\",\n  \"default-credentials.dont-show-again\": \"Don't show this again\",\n  \"default-credentials.dont-show-again-notice\": \"You can access these credentials at any time in the future by right-clicking on the app icon.\",\n  \"default-credentials.open\": \"Open {{app}}\",\n  \"default-credentials.password\": \"Default password\",\n  \"default-credentials.title\": \"Credentials for {{app}}\",\n  \"default-credentials.username\": \"Default username\",\n  \"desktop.app.context.go-to-store-page\": \"View in App Store\",\n  \"desktop.app.context.settings\": \"Settings\",\n  \"desktop.app.context.show-default-credentials\": \"Show default credentials\",\n  \"desktop.app.context.uninstall\": \"Uninstall\",\n  \"desktop.context-menu.change-wallpaper\": \"Change wallpaper\",\n  \"desktop.context-menu.edit-widgets\": \"Edit widgets\",\n  \"desktop.context-menu.logout\": \"Log out\",\n  \"desktop.greeting.afternoon\": \"Good afternoon, {{name}}\",\n  \"desktop.greeting.evening\": \"Good evening, {{name}}\",\n  \"desktop.greeting.morning\": \"Good morning, {{name}}\",\n  \"desktop.install-first.for-the-ai-enthusiast\": \"For the viber\",\n  \"desktop.install-first.for-the-bitcoiner\": \"For the Bitcoiner\",\n  \"desktop.install-first.for-the-self-hoster\": \"For the self-hoster\",\n  \"desktop.install-first.for-the-streamer\": \"For the streamer\",\n  \"desktop.install-first.link-to-app-store\": \"Explore more in App Store\",\n  \"desktop.not-enough-room\": \"Use a larger screen to view your apps.\",\n  \"device\": \"Device\",\n  \"device-info\": \"Device info\",\n  \"device-info-description\": \"Information about your device\",\n  \"device-info.device\": \"Device\",\n  \"device-info.model-number\": \"Model number\",\n  \"device-info.serial-number\": \"Serial number\",\n  \"device-info.view-info\": \"View info\",\n  \"device-name.home-or-pro\": \"Umbrel Home or Umbrel Pro\",\n  \"disable\": \"Disable\",\n  \"done\": \"Done\",\n  \"download-logs\": \"Download logs\",\n  \"enabling-tor\": \"Enabling Remote Tor access\",\n  \"external-dns\": \"Cloudflare DNS\",\n  \"external-dns-description\": \"Cloudflare DNS offers better network reliability. Disable to use your router's DNS settings.\",\n  \"external-dns-error\": \"Failed to update DNS setting: {{message}}\",\n  \"external-drive\": \"External Drive\",\n  \"factory-reset\": \"Factory Reset\",\n  \"factory-reset-description\": \"Erase all your data and apps, restoring umbrelOS to default settings\",\n  \"factory-reset-failed\": \"Failed to reset your device: {{message}}\",\n  \"factory-reset.confirm.body\": \"Confirm your password to reset\",\n  \"factory-reset.confirm.ethernet-required-warning\": \"Ensure your device is connected to your router via Ethernet (not Wi-Fi) and you're accessing it from your local network (e.g., http://umbrel.local or your device's local IP address).\",\n  \"factory-reset.confirm.submit\": \"Erase everything and reset\",\n  \"factory-reset.confirm.submit-callout\": \"This action cannot be undone.\",\n  \"factory-reset.rebooting.message\": \"Your device will restart and all data will be erased. Please don't close this page.\",\n  \"factory-reset.rebooting.status\": \"Resetting...\",\n  \"factory-reset.rebooting.title\": \"Factory reset in progress\",\n  \"factory-reset.review.account-info\": \"Account info and password\",\n  \"factory-reset.review.apps\": \"Apps\",\n  \"factory-reset.review.following-will-be-removed\": \"The following will be removed from your device\",\n  \"factory-reset.review.installed-apps_one\": \"{{count}} installed app\",\n  \"factory-reset.review.installed-apps_other\": \"{{count}} installed apps\",\n  \"factory-reset.review.submit\": \"Continue\",\n  \"factory-reset.review.total-data\": \"Total data\",\n  \"files\": \"Files\",\n  \"files-action.add-favorite\": \"Add to favorites\",\n  \"files-action.add-network-device\": \"Add device\",\n  \"files-action.cancel-upload\": \"Cancel upload\",\n  \"files-action.compress\": \"Compress\",\n  \"files-action.copy\": \"Copy\",\n  \"files-action.cut\": \"Cut\",\n  \"files-action.delete\": \"Delete permanently\",\n  \"files-action.download\": \"Download\",\n  \"files-action.download-items\": \"Download {{count}} items\",\n  \"files-action.drop-to-upload\": \"Drop to upload\",\n  \"files-action.eject-disk\": \"Eject\",\n  \"files-action.empty-trash\": \"Empty Trash\",\n  \"files-action.format-drive\": \"Format\",\n  \"files-action.go-to-path\": \"Go to...\",\n  \"files-action.new-folder\": \"New Folder\",\n  \"files-action.open\": \"Open\",\n  \"files-action.paste\": \"Paste\",\n  \"files-action.remove-favorite\": \"Remove from favorites\",\n  \"files-action.remove-network-host\": \"Eject network drive\",\n  \"files-action.remove-network-share\": \"Eject network share\",\n  \"files-action.rename\": \"Rename\",\n  \"files-action.restore\": \"Restore\",\n  \"files-action.select\": \"Select\",\n  \"files-action.share\": \"Share over network...\",\n  \"files-action.sharing\": \"Sharing...\",\n  \"files-action.show-in-folder\": \"Show in Enclosing Folder\",\n  \"files-action.trash\": \"Trash\",\n  \"files-action.uncompress\": \"Uncompress\",\n  \"files-action.upload\": \"Upload\",\n  \"files-add-network-share.add-manually\": \"Add manually\",\n  \"files-add-network-share.add-share\": \"Add share\",\n  \"files-add-network-share.back\": \"Back\",\n  \"files-add-network-share.continue\": \"Continue\",\n  \"files-add-network-share.description\": \"Connect to another Umbrel, a NAS, or a shared drive on your network to access them within Files.\",\n  \"files-add-network-share.discovering\": \"Discovering...\",\n  \"files-add-network-share.enter-details-manually\": \"Enter server details\",\n  \"files-add-network-share.host-label\": \"Server address\",\n  \"files-add-network-share.host-required\": \"Server address is required\",\n  \"files-add-network-share.manual-share-help\": \"Enter the exact name of the share as it appears on your server\",\n  \"files-add-network-share.no-shares-found\": \"No shares found on this server\",\n  \"files-add-network-share.not-seeing-share\": \"Not seeing your share?\",\n  \"files-add-network-share.password-label\": \"Password\",\n  \"files-add-network-share.password-required\": \"Password is required\",\n  \"files-add-network-share.retrieving-shares\": \"Retrieving shares...\",\n  \"files-add-network-share.retry-discovery\": \"Rescan network\",\n  \"files-add-network-share.select-share\": \"Select a share to add\",\n  \"files-add-network-share.share-placeholder\": \"shared-documents\",\n  \"files-add-network-share.share-required\": \"Share is required\",\n  \"files-add-network-share.title\": \"Add a network share\",\n  \"files-add-network-share.username-label\": \"Username\",\n  \"files-add-network-share.username-placeholder\": \"admin\",\n  \"files-add-network-share.username-required\": \"Username is required\",\n  \"files-audio-island.now-playing\": \"Now Playing\",\n  \"files-audio-island.pause\": \"Pause\",\n  \"files-audio-island.play\": \"Play\",\n  \"files-backend-error.base-directory-not-found\": \"The base directory could not be found\",\n  \"files-backend-error.cant-find-root\": \"Could not verify the file path\",\n  \"files-backend-error.destination-already-exists\": \"An item with the same name already exists at the destination\",\n  \"files-backend-error.destination-not-exist\": \"The destination folder does not exist\",\n  \"files-backend-error.does-not-exist\": \"The file or folder does not exist\",\n  \"files-backend-error.escapes-base\": \"The path is outside the allowed directory\",\n  \"files-backend-error.invalid-base\": \"The path does not belong to a valid directory\",\n  \"files-backend-error.invalid-filename\": \"The file name is not valid\",\n  \"files-backend-error.invalid-path\": \"The file path is not valid\",\n  \"files-backend-error.mkdir-failed\": \"Failed to create the folder\",\n  \"files-backend-error.move-failed\": \"Failed to move the item\",\n  \"files-backend-error.not-enough-space\": \"Not enough storage space available\",\n  \"files-backend-error.operation-not-allowed\": \"This operation is not allowed\",\n  \"files-backend-error.parent-not-directory\": \"The parent path is not a folder\",\n  \"files-backend-error.parent-not-exist\": \"The parent folder does not exist\",\n  \"files-backend-error.path-not-absolute\": \"The file path is not valid\",\n  \"files-backend-error.share-already-exists\": \"This folder is already shared\",\n  \"files-backend-error.share-name-generation-failed\": \"Could not generate a unique share name\",\n  \"files-backend-error.source-not-exists\": \"The source file or folder does not exist\",\n  \"files-backend-error.subdir-of-self\": \"A folder cannot be moved or copied into itself\",\n  \"files-backend-error.trash-meta-not-exists\": \"Could not find the original location for this item\",\n  \"files-backend-error.unique-name-index-exceeded\": \"Could not generate a unique name. Too many items with similar names exist\",\n  \"files-backend-error.upload-failed\": \"Upload failed\",\n  \"files-collision.action.keep-both\": \"Keep Both\",\n  \"files-collision.action.replace\": \"Replace\",\n  \"files-collision.action.skip\": \"Skip\",\n  \"files-collision.destination.original-location\": \"its original location\",\n  \"files-collision.message\": \"Do you want to replace the existing item or keep both?\",\n  \"files-collision.title\": \"\\\"{{itemName}}\\\" already exists in {{destinationName}}\",\n  \"files-download.confirm\": \"Download\",\n  \"files-download.description\": \"Files cannot open this type of file. Would you like to download it instead?\",\n  \"files-download.title\": \"Download {{name}}?\",\n  \"files-empty-trash.confirm\": \"Empty\",\n  \"files-empty-trash.description\": \"Are you sure you want to permanently delete all items in the trash? You can't undo this action.\",\n  \"files-empty-trash.title\": \"Empty Trash?\",\n  \"files-empty.directory\": \"No items in this folder\",\n  \"files-empty.network\": \"No network devices\",\n  \"files-empty.network-host-offline\": \"Network device offline\",\n  \"files-error.add-favorite\": \"Add to favorites failed: {{message}}\",\n  \"files-error.add-share\": \"Share folder failed: {{message}}\",\n  \"files-error.compress\": \"Compression failed: {{message}}\",\n  \"files-error.copy\": \"Copy failed: {{message}}\",\n  \"files-error.create-folder\": \"Create folder failed: {{message}}\",\n  \"files-error.delete\": \"Delete failed: {{message}}\",\n  \"files-error.eject-disk\": \"Eject drive failed: {{message}}\",\n  \"files-error.empty-trash\": \"Empty trash failed: {{message}}\",\n  \"files-error.extract\": \"Extraction failed: {{message}}\",\n  \"files-error.folder-already-exists\": \"A folder with this name already exists\",\n  \"files-error.move\": \"Move failed: {{message}}\",\n  \"files-error.remove-favorite\": \"Remove from favorites failed: {{message}}\",\n  \"files-error.remove-share\": \"Remove shared folder failed: {{message}}\",\n  \"files-error.rename\": \"Rename failed: {{message}}\",\n  \"files-error.restore\": \"Restore failed: {{message}}\",\n  \"files-error.trash\": \"Move to trash failed: {{message}}\",\n  \"files-error.upload\": \"Upload failed: {{message}}\",\n  \"files-error.upload-network-error\": \"Upload failed for {{name}}: A network error occurred\",\n  \"files-extension-change.confirm\": \"Continue\",\n  \"files-extension-change.description-add\": \"Are you sure you want to change the extension of '{{fileName}}' to '{{extension}}'? This may cause the file to be unreadable.\",\n  \"files-extension-change.description-remove\": \"Are you sure you want to remove the extension of '{{fileName}}'?\",\n  \"files-extension-change.title-add\": \"Change extension to '{{extension}}'?\",\n  \"files-extension-change.title-remove\": \"Remove extension?\",\n  \"files-external-storage.unsupported.description\": \"Your connected external drive can't be used on a Raspberry Pi due to power issues. External storage is available on the Umbrel Home, Umbrel Pro, and all x86 (Intel or AMD) devices.\",\n  \"files-external-storage.unsupported.description-general\": \"External storage is not available on Raspberry Pi due to power issues. External storage is available on the Umbrel Home, Umbrel Pro, and all x86 (Intel or AMD) devices.\",\n  \"files-external-storage.unsupported.title\": \"External Storage Not Supported\",\n  \"files-folder\": \"Folder\",\n  \"files-format.confirm\": \"Format\",\n  \"files-format.description\": \"Formatting will erase all data on {{driveName}}. This action cannot be undone.\",\n  \"files-format.description-unreadable\": \"umbrelOS cannot read the contents of {{driveName}}. You can format it to use with umbrelOS.\",\n  \"files-format.drive-label\": \"Name\",\n  \"files-format.error\": \"Failed to format drive\",\n  \"files-format.exfat-description\": \"Maximum compatibility with Windows, macOS, and Linux\",\n  \"files-format.ext4-description\": \"Better performance with umbrelOS and Linux\",\n  \"files-format.filesystem\": \"Filesystem\",\n  \"files-format.filesystem-label\": \"Format as\",\n  \"files-format.formatting\": \"Formatting...\",\n  \"files-format.title\": \"Format Drive\",\n  \"files-format.title-requires-format\": \"Format Required\",\n  \"files-formatting-island.formatting\": \"Formatting...\",\n  \"files-formatting-island.formatting-drives\": \"Formatting {{count}} drives\",\n  \"files-listing.empty\": \"No items\",\n  \"files-listing.error\": \"An error occurred\",\n  \"files-listing.item-count-truncated\": \"{{formattedCount}}+ items\",\n  \"files-listing.item-count_one\": \"{{formattedCount}} item\",\n  \"files-listing.item-count_other\": \"{{formattedCount}} items\",\n  \"files-listing.loading\": \"Loading...\",\n  \"files-listing.no-such-file\": \"No such file or folder\",\n  \"files-listing.selected-count\": \"{{selectedCount}} of {{totalCount}} selected\",\n  \"files-listing.selected-count-truncated\": \"{{selectedCount}} of {{totalCount}}+ selected\",\n  \"files-name-drawer.new-folder\": \"New Folder\",\n  \"files-name-drawer.new-folder-description\": \"Enter a name for the new folder.\",\n  \"files-name-drawer.new-folder-input\": \"Folder Name\",\n  \"files-name-drawer.rename-file\": \"Rename File\",\n  \"files-name-drawer.rename-file-description\": \"Enter a new name for this file.\",\n  \"files-name-drawer.rename-file-input\": \"File Name\",\n  \"files-name-drawer.rename-folder\": \"Rename Folder\",\n  \"files-name-drawer.rename-folder-description\": \"Enter a new name for this folder.\",\n  \"files-name-drawer.rename-folder-input\": \"Folder Name\",\n  \"files-network-storage-error.add-share\": \"Add network share failed: {{message}}\",\n  \"files-network-storage-error.discover-servers\": \"Network device discovery failed: {{message}}\",\n  \"files-network-storage-error.discover-shares\": \"Network share discovery failed: {{message}}\",\n  \"files-network-storage-error.remove-share\": \"Remove network share failed: {{message}}\",\n  \"files-operations-island.copying\": \"Copying \\\"{{from}}\\\" to \\\"{{to}}\\\"\",\n  \"files-operations-island.moving\": \"Moving \\\"{{from}}\\\" to \\\"{{to}}\\\"\",\n  \"files-operations-island.restoring\": \"Restoring \\\"{{from}}\\\" to \\\"{{to}}\\\"\",\n  \"files-path.input-group\": \"Path input\",\n  \"files-path.input-label\": \"Current path\",\n  \"files-permanently-delete.confirm\": \"Delete permanently\",\n  \"files-permanently-delete.description-multiple\": \"Are you sure you want to permanently delete these {{count}} items? You can't undo this action.\",\n  \"files-permanently-delete.description-single\": \"Are you sure you want to permanently delete \\\"{{fileName}}\\\"? You can't undo this action.\",\n  \"files-permanently-delete.title-multiple\": \"Delete {{count}} items permanently?\",\n  \"files-permanently-delete.title-single\": \"Delete permanently?\",\n  \"files-search.default\": \"Search for files and folders\",\n  \"files-search.no-results\": \"No results found for \\\"{{query}}\\\"\",\n  \"files-search.placeholder\": \"Search\",\n  \"files-search.searching-label\": \"Searching {{name}}'s Umbrel\",\n  \"files-share.home-description\": \"Access all the files in \\\"{{homeDirectoryName}}\\\" from other devices on your network\",\n  \"files-share.home-title\": \"Share \\\"{{homeDirectoryName}}\\\" over network\",\n  \"files-share.instructions.how-to-access\": \"How to access\",\n  \"files-share.instructions.ios.enter-password\": \"Enter <field>{{password}}</field> as the password.\",\n  \"files-share.instructions.ios.enter-server\": \"Enter <field>{{smbUrl}}</field> as the server address.\",\n  \"files-share.instructions.ios.enter-username\": \"Enter <field>{{username}}</field> as the username.\",\n  \"files-share.instructions.ios.install-files\": \"Install \\\"Files\\\" app from App Store if not installed.\",\n  \"files-share.instructions.ios.tap-connect\": \"Tap \\\"Connect\\\" to access it.\",\n  \"files-share.instructions.ios.tap-dots\": \"Tap the three dots (...) on the top right and select \\\"Connect to Server\\\".\",\n  \"files-share.instructions.macos.click-connect\": \"Click \\\"Connect\\\" to access it.\",\n  \"files-share.instructions.macos.enter-password\": \"Enter <field>{{password}}</field> as the password.\",\n  \"files-share.instructions.macos.enter-url\": \"Enter <field>{{smbUrl}}</field> and click Connect.\",\n  \"files-share.instructions.macos.enter-username\": \"Enter <field>{{username}}</field> as the username.\",\n  \"files-share.instructions.macos.open-finder\": \"Open \\\"Finder\\\", and press ⌘ + K.\",\n  \"files-share.instructions.macos.select-registered\": \"Select \\\"Registered User\\\" when prompted.\",\n  \"files-share.instructions.macos.time-machine\": \"How to use as a Time Machine backup location\",\n  \"files-share.instructions.macos.time-machine.choose-encryption\": \"Choose between encrypted or unencrypted backups.\",\n  \"files-share.instructions.macos.time-machine.disk-limit\": \"For 'Disk Usage Limit', specify the maximum amount of space you want to allocate on your Umbrel for Time Machine backups, then click \\\"Done\\\".\",\n  \"files-share.instructions.macos.time-machine.follow-steps\": \"Follow the above steps and open System Settings on your Mac.\",\n  \"files-share.instructions.macos.time-machine.go-settings\": \"Go to Time Machine, click \\\"Add Backup Disk...\\\".\",\n  \"files-share.instructions.macos.time-machine.select-disk\": \"Select the folder and click \\\"Set Up Disk...\\\".\",\n  \"files-share.instructions.umbrelos.backup.follow-onscreen\": \"Follow the guided steps to configure your backup.\",\n  \"files-share.instructions.umbrelos.backup.follow-then-go-to\": \"Follow the above steps and then go to \\\"{{settings}}\\\" > \\\"{{backups}}\\\" on your other Umbrel.\",\n  \"files-share.instructions.umbrelos.backup.select-add\": \"Select the option to \\\"{{addUmbrelOrNas}}\\\".\",\n  \"files-share.instructions.umbrelos.backup.select-connected\": \"Select this Umbrel device from the list of connected devices.\",\n  \"files-share.instructions.umbrelos.backup.title\": \"How to use as a backup location for your other Umbrel\",\n  \"files-share.instructions.umbrelos.cant-find-note\": \"Can't find it? Try selecting \\\"Add manually\\\" and use the following credentials. If you still can't add it, ensure that both of your devices are on the same network.\",\n  \"files-share.instructions.umbrelos.enter-password\": \"Enter <field>{{password}}</field> as the password.\",\n  \"files-share.instructions.umbrelos.enter-username\": \"Enter <field>{{username}}</field> as the username.\",\n  \"files-share.instructions.umbrelos.open-and-click\": \"On your other Umbrel, open \\\"Files\\\" and click <plus/> next to \\\"<deviceIcon/> {{deviceLabel}}\\\" in the sidebar.\",\n  \"files-share.instructions.umbrelos.select-device\": \"Select this Umbrel device from the list of auto-detected devices on your network.\",\n  \"files-share.instructions.umbrelos.select-sharename\": \"Select \\\"{{sharename}}\\\" and click to add share.\",\n  \"files-share.instructions.windows.enter-password\": \"Enter <field>{{password}}</field> as the password.\",\n  \"files-share.instructions.windows.enter-url\": \"Type <field>{{smbUrl}}</field> and press Enter.\",\n  \"files-share.instructions.windows.enter-username\": \"Enter <field>{{username}}</field> as the username.\",\n  \"files-share.instructions.windows.open-run\": \"Press Windows + R to open Run dialog.\",\n  \"files-share.instructions.windows.remember-credentials\": \"Check \\\"Remember my credentials\\\" and click OK.\",\n  \"files-share.regular-description\": \"Share this folder to access it from other devices on your network\",\n  \"files-share.regular-title\": \"Share folder over network\",\n  \"files-share.toggle\": \"Share \\\"{{name}}\\\" over your network\",\n  \"files-sidebar.apps\": \"Apps\",\n  \"files-sidebar.external-storage\": \"External storage\",\n  \"files-sidebar.favorites\": \"Favorites\",\n  \"files-sidebar.home\": \"Home\",\n  \"files-sidebar.navigation\": \"File navigation\",\n  \"files-sidebar.network\": \"Network\",\n  \"files-sidebar.network-pathbar\": \"Network Devices\",\n  \"files-sidebar.network-sidebar\": \"Devices\",\n  \"files-sidebar.recents\": \"Recents\",\n  \"files-sidebar.shared-folders\": \"Shared folders\",\n  \"files-sidebar.trash\": \"Trash\",\n  \"files-sidebar.trash.open\": \"Open\",\n  \"files-sort.created\": \"Added\",\n  \"files-sort.modified\": \"Modified\",\n  \"files-sort.name\": \"Name\",\n  \"files-sort.size\": \"Size\",\n  \"files-sort.type\": \"Type\",\n  \"files-state.uploading\": \"Uploading...\",\n  \"files-state.waiting\": \"Waiting...\",\n  \"files-type.3gp\": \"3GP Video\",\n  \"files-type.3gp2\": \"3GP2 Video\",\n  \"files-type.7z\": \"7Z Archive\",\n  \"files-type.aac\": \"AAC Audio\",\n  \"files-type.ai\": \"Illustrator File\",\n  \"files-type.aiff\": \"AIFF Audio\",\n  \"files-type.au\": \"AU Audio\",\n  \"files-type.avi\": \"AVI Video\",\n  \"files-type.avif\": \"AVIF Image\",\n  \"files-type.bmp\": \"BMP Image\",\n  \"files-type.bzip2\": \"BZIP2 Archive\",\n  \"files-type.caf\": \"CAF Audio\",\n  \"files-type.compressed\": \"Compressed Archive\",\n  \"files-type.csv\": \"CSV File\",\n  \"files-type.directory\": \"Folder\",\n  \"files-type.dmg\": \"Disk Image\",\n  \"files-type.dv\": \"DV Video\",\n  \"files-type.epub\": \"EPUB eBook\",\n  \"files-type.excel\": \"Excel Spreadsheet\",\n  \"files-type.exe\": \"Windows Executable\",\n  \"files-type.executable\": \"Executable\",\n  \"files-type.external-drive\": \"Drive\",\n  \"files-type.flac\": \"FLAC Audio\",\n  \"files-type.flv\": \"FLV Video\",\n  \"files-type.gif\": \"GIF Image\",\n  \"files-type.gzip\": \"GZIP Archive\",\n  \"files-type.heic\": \"HEIC Image\",\n  \"files-type.ico\": \"ICO Image\",\n  \"files-type.iso\": \"ISO Image\",\n  \"files-type.jpeg\": \"JPEG Image\",\n  \"files-type.keynote\": \"Keynote Presentation\",\n  \"files-type.lzip\": \"LZIP Archive\",\n  \"files-type.lzma\": \"LZMA Archive\",\n  \"files-type.lzop\": \"LZOP Archive\",\n  \"files-type.m3u\": \"M3U Playlist\",\n  \"files-type.m4a\": \"M4A Audio\",\n  \"files-type.m4v\": \"M4V Video\",\n  \"files-type.midi\": \"MIDI Audio\",\n  \"files-type.mka\": \"MKA Audio\",\n  \"files-type.mkv\": \"MKV Video\",\n  \"files-type.mng\": \"MNG Video\",\n  \"files-type.mobi\": \"MOBI eBook\",\n  \"files-type.mp3\": \"MP3 Audio\",\n  \"files-type.mp4\": \"MP4 Video\",\n  \"files-type.mp4-audio\": \"MP4 Audio\",\n  \"files-type.mpeg\": \"MPEG Video\",\n  \"files-type.mpeg-ts\": \"MPEG Transport Stream\",\n  \"files-type.network-drive\": \"Network Drive\",\n  \"files-type.numbers\": \"Numbers Spreadsheet\",\n  \"files-type.ogg\": \"OGG Audio\",\n  \"files-type.ogv\": \"OGV Video\",\n  \"files-type.pages\": \"Pages Document\",\n  \"files-type.pdf\": \"PDF Document\",\n  \"files-type.png\": \"PNG Image\",\n  \"files-type.powerpoint\": \"PowerPoint Presentation\",\n  \"files-type.psd\": \"Photoshop Document\",\n  \"files-type.quicktime\": \"QuickTime Video\",\n  \"files-type.rar\": \"RAR Archive\",\n  \"files-type.sgi\": \"SGI Movie\",\n  \"files-type.svg\": \"SVG Image\",\n  \"files-type.tar\": \"TAR Archive\",\n  \"files-type.tiff\": \"TIFF Image\",\n  \"files-type.ts\": \"TS Video\",\n  \"files-type.txt\": \"Text File\",\n  \"files-type.umbrel-backup\": \"Umbrel Backup\",\n  \"files-type.wav\": \"WAV Audio\",\n  \"files-type.webm\": \"WebM Video\",\n  \"files-type.webm-audio\": \"WebM Audio\",\n  \"files-type.webp\": \"WebP Image\",\n  \"files-type.wma\": \"WMA Audio\",\n  \"files-type.wmv\": \"WMV Video\",\n  \"files-type.word\": \"Word Document\",\n  \"files-type.xz\": \"XZ Archive\",\n  \"files-type.zip\": \"ZIP Archive\",\n  \"files-upload-island.uploading-count\": \"Uploading {{count}} items\",\n  \"files-view.icons\": \"Icons\",\n  \"files-view.list\": \"List\",\n  \"files-view.sort-by\": \"Sort by\",\n  \"files-view.view-as\": \"View as\",\n  \"files-widgets.favorites.no-items-text\": \"Add a folder to favorites to see it here\",\n  \"files-widgets.recents.no-items-text\": \"No recent files\",\n  \"generic-in\": \"in\",\n  \"hide-details\": \"Hide details\",\n  \"install-first.install-app\": \"Install {{app}}\",\n  \"install-first.title\": \"{{app}} requires these apps\",\n  \"install-your-first-app\": \"Install your first app\",\n  \"language\": \"Language\",\n  \"language-description\": \"Your preferred umbrelOS language\",\n  \"language.select-description\": \"Select preferred umbrelOS language\",\n  \"live-usage\": \"Live Usage\",\n  \"loading\": \"Loading\",\n  \"local-ip\": \"Local IP\",\n  \"login-2fa.subtitle\": \"Enter the 2FA code displayed in your authenticator app\",\n  \"login-2fa.title\": \"Authenticate\",\n  \"login-with-umbrel.description\": \"Enter your Umbrel password to open {{app}}\",\n  \"login-with-umbrel.title\": \"Log in with Umbrel\",\n  \"login.password-label\": \"Password\",\n  \"login.password.submit\": \"Log in\",\n  \"login.subtitle\": \"Enter your Umbrel password to log in\",\n  \"login.title\": \"Welcome back\",\n  \"logout\": \"Log out\",\n  \"logout-error-generic\": \"Error: Logout failed\",\n  \"logout.confirm.submit\": \"Log out\",\n  \"logout.confirm.title\": \"Are you sure you want to log out?\",\n  \"memory\": \"Memory\",\n  \"memory.low\": \"Low memory\",\n  \"migrate\": \"Migrate\",\n  \"migrate.callout\": \"Do not turn off your Umbrel until the migration is complete\",\n  \"migrate.failed.retry\": \"Retry\",\n  \"migrate.failed.title\": \"Migration failed\",\n  \"migrate.success.description\": \"All your apps, app data, and account details have been migrated to your Umbrel Home.\",\n  \"migrate.success.title\": \"Migration successful\",\n  \"migration-assistant\": \"Migration Assistant\",\n  \"migration-assistant-description\": \"Transfer all your apps and data from a Raspberry Pi to {{deviceName}}\",\n  \"migration-assistant-unsupported-device-description\": \"Migration Assistant currently supports transferring all data and apps from a Raspberry Pi with umbrelOS to Umbrel Home or Umbrel Pro. Open Migration Assistant on your Umbrel Home or Umbrel Pro to get started.\",\n  \"migration-assistant.continue-migration.ready.submit\": \"Start migration\",\n  \"migration-assistant.failed\": \"Something's not right...\",\n  \"migration-assistant.failed.retrying-message\": \"Retrying...\",\n  \"migration-assistant.mobile.start-button\": \"Start migration\",\n  \"migration-assistant.prep.body\": \"Prepare for migration\",\n  \"migration-assistant.prep.button-continue\": \"Continue\",\n  \"migration-assistant.prep.callout\": \"The data on your {{deviceName}}, if any, will be permanently deleted.\",\n  \"migration-assistant.prep.connect-disk-to-home\": \"Connect its external drive to any USB port on your {{deviceName}}.\",\n  \"migration-assistant.prep.prep-done-continue-message\": \"Once done, click '{{button}}' below.\",\n  \"migration-assistant.prep.shut-down-rpi\": \"Shut down your Raspberry Pi Umbrel.\",\n  \"migration-assistant.ready.description\": \"All your data and apps are ready to be migrated to your {{deviceName}}\",\n  \"migration-assistant.ready.hint-header\": \"Things to keep in mind\",\n  \"migration-assistant.ready.hint-keep-pi-off.description\": \"This helps prevent issues with apps such as Lightning Node\",\n  \"migration-assistant.ready.hint-keep-pi-off.title\": \"Keep your Raspberry Pi off after the update\",\n  \"migration-assistant.ready.hint-use-same-password.description\": \"Remember to use your Raspberry Pi Umbrel password to log into your {{deviceName}}\",\n  \"migration-assistant.ready.hint-use-same-password.title\": \"Use the same password\",\n  \"migration-assistant.ready.title\": \"You're all set to migrate!\",\n  \"mini-browser.default-title\": \"Select folder\",\n  \"mini-browser.empty-external\": \"Connect an external drive to have it appear here.\",\n  \"mini-browser.empty-network\": \"Add an Umbrel or NAS to have it appear here.\",\n  \"mini-browser.load-more\": \"Load more\",\n  \"mini-browser.load-more-in-folder\": \"Load more in {{name}}\",\n  \"mini-browser.loading-more\": \"Loading more…\",\n  \"mini-browser.select\": \"Select\",\n  \"mini-browser.select-folder\": \"Select folder\",\n  \"name\": \"Name\",\n  \"nas\": \"NAS\",\n  \"no-forgot-password-message\": \"If you lose your password, you won't be able to log into your Umbrel. Make sure to safely secure it.\",\n  \"no-results-found\": \"No results found\",\n  \"not-found-404\": \"Error code: 404\",\n  \"not-found-404.back\": \"Back\",\n  \"not-found-404.home\": \"Go to Home\",\n  \"notifications.backups-failing-location.description\": \"Automatic backups to {{location}} have been failing. Check the connection and review your backup settings.\",\n  \"notifications.backups-failing.description\": \"Automatic backups have been failing. Check your backup location and review your settings.\",\n  \"notifications.backups-failing.go-to-backups\": \"Go to Backups\",\n  \"notifications.backups-failing.title\": \"No Backups in the last 24 hours\",\n  \"notifications.cpu.too-hot\": \"High CPU temperature\",\n  \"notifications.memory.low\": \"Your device's memory is low\",\n  \"notifications.new-version-available\": \"{{update}} is now available to install\",\n  \"notifications.raid.issue.description\": \"Storage issue detected. Check Storage Manager for details.\",\n  \"notifications.raid.issue.title\": \"Urgent action required\",\n  \"notifications.ssd.health.description\": \"One or more SSDs may need attention. Check Storage Manager for details.\",\n  \"notifications.ssd.health.title\": \"SSD health warning\",\n  \"notifications.storage.full\": \"Your device's storage is full\",\n  \"notifications.view\": \"View\",\n  \"ok\": \"OK\",\n  \"onboarding.account-created.by-clicking-button-you-agree\": \"By clicking 'Launch umbrelOS', you agree to the <linked>umbrelOS Terms of Service</linked>\",\n  \"onboarding.account-created.youre-all-set-name\": \"You're all set, {{name}}.\",\n  \"onboarding.contact-support\": \"Support\",\n  \"onboarding.create-account\": \"Create account\",\n  \"onboarding.create-account.confirm-password.input-label\": \"Confirm password\",\n  \"onboarding.create-account.failed.name-required\": \"Name is required\",\n  \"onboarding.create-account.failed.passwords-dont-match\": \"Passwords do not match\",\n  \"onboarding.create-account.name.input-placeholder\": \"Your name\",\n  \"onboarding.create-account.password.input-label\": \"Password\",\n  \"onboarding.create-account.submit\": \"Create\",\n  \"onboarding.create-account.submitting\": \"Creating\",\n  \"onboarding.create-account.subtitle\": \"Your account info is stored only on your Umbrel. Make sure to back up your password safely as there is no way to reset it.\",\n  \"onboarding.create-instead-long\": \"Create new account\",\n  \"onboarding.create-instead-short\": \"New account\",\n  \"onboarding.launch-umbrelos\": \"Launch umbrelOS\",\n  \"onboarding.raid.available-storage\": \"Available storage\",\n  \"onboarding.raid.change-drives-link\": \"Need to add or change drives?\",\n  \"onboarding.raid.configuring.subtitle\": \"This may take a few minutes.\",\n  \"onboarding.raid.configuring.title\": \"Configuring your storage\",\n  \"onboarding.raid.configuring.warning\": \"Please do not refresh this page or turn off your Umbrel while it is configuring your storage.\",\n  \"onboarding.raid.continue\": \"Continue\",\n  \"onboarding.raid.error.detection-instructions\": \"Power off Umbrel Pro, check that your SSDs are properly seated, and try again.\",\n  \"onboarding.raid.error.no-ssds-detected\": \"No SSDs detected\",\n  \"onboarding.raid.error.no-ssds-instructions\": \"Power off Umbrel Pro and insert at least one SSD to continue.\",\n  \"onboarding.raid.failsafe\": \"FailSafe\",\n  \"onboarding.raid.failsafe.cant-enable\": \"Can't enable FailSafe yet\",\n  \"onboarding.raid.failsafe.enable\": \"Enable FailSafe\",\n  \"onboarding.raid.failsafe.mixed-sizes\": \"FailSafe is limited by your smallest SSD ({{smallest}}). Extra space on larger SSDs can't be used, leaving {{wasted}} unusable.\",\n  \"onboarding.raid.failsafe.protection-info-2ssds\": \"{{protection}} is used for data protection. Add another {{smallest}} SSD to increase available storage to {{futureWith3}}, or add two more for {{futureWith4}}. You can add more SSDs at any time.\",\n  \"onboarding.raid.failsafe.protection-info-3ssds\": \"{{protection}} is used for data protection. Add another {{smallest}} SSD to increase available storage to {{futureWith4}}. You can add more SSDs at any time.\",\n  \"onboarding.raid.failsafe.single-ssd-info\": \"You only have one SSD. Add at least one more {{size}} SSD to enable FailSafe protection for your data. You can add more SSDs at any time.\",\n  \"onboarding.raid.failsafe.subtitle\": \"Your data stays safe if any single SSD fails\",\n  \"onboarding.raid.failsafe.tip\": \"Use same-sized SSDs for maximum storage and zero unusable space.\",\n  \"onboarding.raid.failsafe.warning-now-only\": \"With more than one SSD, FailSafe can only be enabled during initial setup. You won't be able to enable it later.\",\n  \"onboarding.raid.health-warning\": \"This drive is reporting health issues\",\n  \"onboarding.raid.launching\": \"Launching...\",\n  \"onboarding.raid.no-ssds-alt\": \"No SSDs found\",\n  \"onboarding.raid.recommended\": \"Recommended\",\n  \"onboarding.raid.scanning\": \"Checking your SSD slots\",\n  \"onboarding.raid.scanning-alt\": \"Scanning SSDs\",\n  \"onboarding.raid.setup-failed.description-no-retry\": \"Please shut down and try again.\",\n  \"onboarding.raid.setup-failed.description-retry\": \"Try again, or shut down to check your drives.\",\n  \"onboarding.raid.setup-failed.title\": \"Storage setup failed\",\n  \"onboarding.raid.shutdown-dialog.description\": \"To add or change drives, power off Umbrel Pro. Once done you can power back on and continue with the setup.\",\n  \"onboarding.raid.shutdown-dialog.title\": \"Change drives?\",\n  \"onboarding.raid.ssd-in-slot\": \"One <highlight>{{size}}</highlight> SSD in <highlight>Slot {{slot}}</highlight>\",\n  \"onboarding.raid.ssd-label\": \"SSD {{number}}\",\n  \"onboarding.raid.ssd-tray-alt\": \"SSD Tray\",\n  \"onboarding.raid.ssds-found\": \"The following SSDs were found in your Umbrel Pro\",\n  \"onboarding.raid.storage\": \"Storage\",\n  \"onboarding.raid.storage-label\": \"Storage\",\n  \"onboarding.raid.success.storage-info\": \"Storage {{available}}\",\n  \"onboarding.raid.success.storage-info-failsafe\": \"Storage {{available}} · FailSafe {{failsafe}}\",\n  \"onboarding.raid.try-again\": \"Try again\",\n  \"onboarding.raid.wasted\": \"Unusable\",\n  \"onboarding.restore-long\": \"Restore my Umbrel\",\n  \"onboarding.restore-short\": \"Restore\",\n  \"onboarding.start.continue\": \"Get started\",\n  \"onboarding.start.subtitle\": \"Your home cloud server is ready to set up.\",\n  \"onboarding.start.title\": \"Welcome to umbrelOS\",\n  \"open\": \"Open\",\n  \"open-live-usage\": \"Open Live Usage\",\n  \"password\": \"Password\",\n  \"preferences\": \"Preferences\",\n  \"raid-error.description\": \"Your storage system could not start properly. Check the status of your SSDs below and follow the troubleshooting steps. If the issue persists, any impacted SSDs may need to be replaced.\",\n  \"raid-error.factory-reset-dialog.description\": \"This will erase all data on your Umbrel Pro and reset it to factory settings. This action cannot be undone.\",\n  \"raid-error.factory-reset-dialog.title\": \"Factory reset?\",\n  \"raid-error.factory-reset-failed\": \"Couldn't factory reset\",\n  \"raid-error.health-warning\": \"Health warning\",\n  \"raid-error.missing-ssd-multiple\": \"{{count}} SSDs are not responding\",\n  \"raid-error.missing-ssd-one\": \"1 SSD is not responding\",\n  \"raid-error.shutdown-dialog.description\": \"Power off your Umbrel Pro, ensure all SSDs are properly seated in their slots, then power back on.\",\n  \"raid-error.shutdown-dialog.title\": \"Shut down to check drives?\",\n  \"raid-error.ssd-in-slot\": \"One <highlight>{{size}}</highlight> SSD in <highlight>Slot {{slot}}</highlight>\",\n  \"raid-error.step-check-connections.button\": \"Shut down\",\n  \"raid-error.step-check-connections.description\": \"Shut down and check that all SSDs are properly seated.\",\n  \"raid-error.step-check-connections.title\": \"Check SSD connections\",\n  \"raid-error.step-factory-reset.button\": \"Factory Reset\",\n  \"raid-error.step-factory-reset.description\": \"Last resort if nothing else works. This erases all data.\",\n  \"raid-error.step-factory-reset.title\": \"Factory reset\",\n  \"raid-error.step-restart.button\": \"Restart\",\n  \"raid-error.step-restart.description\": \"A quick first step that often helps\",\n  \"raid-error.step-restart.title\": \"Try restarting\",\n  \"raid-error.title\": \"Storage Issue Detected\",\n  \"read-less\": \"Read less\",\n  \"read-more\": \"Read more\",\n  \"reconnect\": \"Reconnect\",\n  \"redirect.to-home\": \"Loading...\",\n  \"redirect.to-login\": \"Loading...\",\n  \"redirect.to-onboarding\": \"Loading...\",\n  \"redirect.to-raid-error\": \"Loading...\",\n  \"reload\": \"Reload\",\n  \"remote-tor-access\": \"Remote Tor access\",\n  \"reset\": \"Reset\",\n  \"restart\": \"Restart\",\n  \"restart.confirm.submit\": \"Restart\",\n  \"restart.confirm.title\": \"Are you sure you want to restart your Umbrel?\",\n  \"restart.restarting\": \"Restarting\",\n  \"restart.restarting-message\": \"Please do not refresh this page or turn off your Umbrel while it is restarting.\",\n  \"rewind\": \"Rewind\",\n  \"rewind.files-as-of\": \"Your Files as of\",\n  \"rewind.loading-snapshots\": \"Loading snapshots...\",\n  \"rewind.now\": \"Now\",\n  \"rewind.preflight.description\": \"Find files and folders from your past backups, and recover them to the present.\",\n  \"rewind.preflight.enable-backups\": \"Set up Backups in Settings to start using Rewind\",\n  \"rewind.restore-complete\": \"Restore complete\",\n  \"rewind.restore-error-description\": \"Please try again.\",\n  \"rewind.restore-failed\": \"Restore failed\",\n  \"rewind.restore-running-description\": \"Do not close or refresh this page until restore is complete\",\n  \"rewind.restore-selected\": \"Restore selected\",\n  \"rewind.restore-success-description\": \"Your files have been restored\",\n  \"rewind.restoring\": \"Restoring\",\n  \"rewind.snapshots-count_one\": \"{{count}} backup since\",\n  \"rewind.snapshots-count_other\": \"{{count}} backups since\",\n  \"search\": \"Search\",\n  \"settings\": \"Settings\",\n  \"settings.app-store-preferences.title\": \"App Store Preferences\",\n  \"settings.contact-support\": \"Need help? <linked>Contact support.</linked>\",\n  \"settings.file-sharing\": \"File sharing\",\n  \"settings.file-sharing.add-folder\": \"Add\",\n  \"settings.file-sharing.add-folder-title\": \"Select a folder to share\",\n  \"settings.file-sharing.choice-entire-description\": \"Share all files on your Umbrel\",\n  \"settings.file-sharing.choice-entire-title\": \"Everything\",\n  \"settings.file-sharing.choice-heading\": \"What would you like to share?\",\n  \"settings.file-sharing.choice-specific-description\": \"Choose which folders to share\",\n  \"settings.file-sharing.choice-specific-title\": \"Specific folders\",\n  \"settings.file-sharing.choice-subtitle\": \"Access your files and folders Dropbox-style as network folders on your computer or phone\",\n  \"settings.file-sharing.configure\": \"Configure\",\n  \"settings.file-sharing.description\": \"Access your files Dropbox-style as a network folder (SMB) on other devices\",\n  \"settings.file-sharing.home-shared-note\": \"Your entire \\\"{{homeDirectoryName}}\\\" folder is shared. Individual folders don't need separate sharing.\",\n  \"settings.file-sharing.share-entire-home-dir\": \"Share your entire Home folder\",\n  \"settings.file-sharing.share-entire-home-dir-description\": \"Access all files and folders in \\\"{{homeDirectoryName}}\\\" from other devices on your network\",\n  \"settings.file-sharing.shared-folders\": \"Shared folders\",\n  \"show-details\": \"Show details\",\n  \"shut-down\": \"Shut down\",\n  \"shut-down.complete\": \"Shutdown complete\",\n  \"shut-down.complete-text\": \"You can now unplug your device from the power.\",\n  \"shut-down.confirm.submit\": \"Shut down\",\n  \"shut-down.confirm.title\": \"Are you sure you want to shut down your Umbrel?\",\n  \"shut-down.failed\": \"Failed to shut down: {{message}}\",\n  \"shut-down.shutting-down\": \"Shutting down\",\n  \"shut-down.shutting-down-message\": \"Please do not refresh this page or turn off your Umbrel while it is shutting down.\",\n  \"software-update.callout\": \"Please do not refresh this page or turn off your Umbrel while it is updating.\",\n  \"software-update.check\": \"Check for update\",\n  \"software-update.checking\": \"Checking for update...\",\n  \"software-update.current-running\": \"You are on\",\n  \"software-update.failed\": \"Failed to update\",\n  \"software-update.failed-to-check\": \"Failed to check for updates\",\n  \"software-update.failed.retry\": \"Retry\",\n  \"software-update.install-now\": \"Install now\",\n  \"software-update.new-version\": \"New {{name}} is available to install\",\n  \"software-update.on-latest\": \"You are on the latest umbrelOS\",\n  \"software-update.see-whats-new\": \"See <linked>what's new</linked>\",\n  \"software-update.title\": \"Software update\",\n  \"software-update.updating-to\": \"Updating to {{name}}\",\n  \"software-update.view\": \"View\",\n  \"something-left\": \"{{left}} left\",\n  \"something-went-wrong\": \"⚠ Something went wrong\",\n  \"start\": \"Start\",\n  \"stop\": \"Stop\",\n  \"storage\": \"Storage\",\n  \"storage-manager\": \"Storage Manager\",\n  \"storage-manager.add\": \"Add\",\n  \"storage-manager.add-to-raid.add-ssd\": \"Add SSD\",\n  \"storage-manager.add-to-raid.available\": \"Available:\",\n  \"storage-manager.add-to-raid.description\": \"A new SSD has been detected and is ready to be added.\",\n  \"storage-manager.add-to-raid.enable-failsafe\": \"Enable FailSafe\",\n  \"storage-manager.add-to-raid.failed-add\": \"Couldn't add the SSD\",\n  \"storage-manager.add-to-raid.failed-enable-failsafe\": \"Couldn't enable FailSafe\",\n  \"storage-manager.add-to-raid.failsafe-label\": \"FailSafe:\",\n  \"storage-manager.add-to-raid.info-capacity-added\": \"Your new <highlight>{{size}}</highlight> SSD will be added to available storage.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-available\": \"Your new <highlight>{{size}}</highlight> SSD will add <highlight>{{available}}</highlight> of available storage.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-both\": \"Your new <highlight>{{size}}</highlight> SSD will add <highlight>{{available}}</highlight> of available storage and <highlight>{{protection}}</highlight> for data protection.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only\": \"Your new <highlight>{{size}}</highlight> SSD will add <highlight>{{protection}}</highlight> for data protection.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only-full\": \"Your new <highlight>{{size}}</highlight> SSD will be used entirely for data protection.\",\n  \"storage-manager.add-to-raid.info-data-safe\": \"Your data will be safe if any single SSD fails.\",\n  \"storage-manager.add-to-raid.info-no-protection\": \"If an SSD fails, you could lose your data.\",\n  \"storage-manager.add-to-raid.info-total-wasted\": \"<wasted>{{size}}</wasted> total unusable due to different SSD sizes.\",\n  \"storage-manager.add-to-raid.info-wasted\": \"<wasted>{{size}}</wasted> will be unusable due to different SSD sizes.\",\n  \"storage-manager.add-to-raid.recommended\": \"Recommended\",\n  \"storage-manager.add-to-raid.recommended-inline\": \"(recommended)\",\n  \"storage-manager.add-to-raid.restart-active-tasks\": \"Any active tasks will be interrupted\",\n  \"storage-manager.add-to-raid.restart-after\": \"After the restart, FailSafe setup will complete automatically and you can resume normal use.\",\n  \"storage-manager.add-to-raid.restart-during\": \"During the restart:\",\n  \"storage-manager.add-to-raid.restart-intro\": \"You can continue using umbrelOS normally during this process. However, at 50% progress your Umbrel will restart automatically.\",\n  \"storage-manager.add-to-raid.restart-required\": \"System restart required\",\n  \"storage-manager.add-to-raid.restart-ui-inaccessible\": \"umbrelOS will be temporarily inaccessible\",\n  \"storage-manager.add-to-raid.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD in <highlight>Slot {{slot}}</highlight>\",\n  \"storage-manager.add-to-raid.title\": \"Add SSD to storage\",\n  \"storage-manager.add-to-raid.too-small\": \"SSD too small\",\n  \"storage-manager.add-to-raid.too-small-description\": \"This SSD ({{deviceSize}}) is smaller than the smallest SSD currently installed ({{minSize}}). FailSafe requires all SSDs to be at least as large as the smallest SSD being used.\",\n  \"storage-manager.add-to-raid.understand-continue\": \"I understand, continue\",\n  \"storage-manager.add-to-raid.warning-failsafe-now-only\": \"Having more than one SSD means FailSafe can only be enabled now. You will not be able to enable it later.\",\n  \"storage-manager.add-to-raid.wasted-label\": \"Unusable:\",\n  \"storage-manager.available-storage\": \"Available storage\",\n  \"storage-manager.description\": \"View storage, health, and settings for your SSDs\",\n  \"storage-manager.empty\": \"Empty\",\n  \"storage-manager.failsafe-transition-failed\": \"Couldn't enable FailSafe\",\n  \"storage-manager.for-failsafe\": \"For FailSafe\",\n  \"storage-manager.health.checksum-errors\": \"Checksum errors: {{count}}\",\n  \"storage-manager.health.critical\": \"Critical\",\n  \"storage-manager.health.critical-threshold\": \"Critical threshold\",\n  \"storage-manager.health.current-temperature\": \"Current temperature\",\n  \"storage-manager.health.estimated-life\": \"Estimated life remaining\",\n  \"storage-manager.health.general\": \"General\",\n  \"storage-manager.health.health-status\": \"Health status\",\n  \"storage-manager.health.low\": \"Low\",\n  \"storage-manager.health.model-and-capacity\": \"Model & size\",\n  \"storage-manager.health.overheating\": \"Overheating\",\n  \"storage-manager.health.raid-failed-advice\": \"This SSD has an issue. Shut down your Umbrel and check the SSD connection. If the issue persists, the SSD may need to be replaced.\",\n  \"storage-manager.health.read-errors\": \"Read errors: {{count}}\",\n  \"storage-manager.health.serial-number\": \"Serial number\",\n  \"storage-manager.health.status-healthy\": \"Healthy\",\n  \"storage-manager.health.status-unhealthy\": \"Unhealthy\",\n  \"storage-manager.health.status-unknown\": \"Unknown\",\n  \"storage-manager.health.temperature\": \"Temperature\",\n  \"storage-manager.health.title\": \"SSD Health\",\n  \"storage-manager.health.warning-life-advice\": \"Consider replacing this SSD soon.\",\n  \"storage-manager.health.warning-life-message\": \"Only {{percent}}% life remaining\",\n  \"storage-manager.health.warning-temp-advice\": \"Make sure your Umbrel Pro has good airflow and the SSD is properly seated.\",\n  \"storage-manager.health.warning-temp-critical\": \"Temperature is critical ({{temperature}})\",\n  \"storage-manager.health.warning-temp-overheating\": \"Drive is overheating ({{temperature}})\",\n  \"storage-manager.health.warning-threshold\": \"Warning threshold\",\n  \"storage-manager.health.warning-unhealthy-advice\": \"This SSD may fail soon. Consider replacing it.\",\n  \"storage-manager.health.warning-unhealthy-message\": \"This SSD may have a problem\",\n  \"storage-manager.health.warnings\": \"Warnings\",\n  \"storage-manager.health.wear\": \"Wear\",\n  \"storage-manager.health.write-errors\": \"Write errors: {{count}}\",\n  \"storage-manager.install-ssd.description\": \"Add more SSDs to expand your storage\",\n  \"storage-manager.install-ssd.step-insert\": \"Insert new SSDs into the empty slots\",\n  \"storage-manager.install-ssd.step-power-on\": \"Power on your {{deviceName}}\",\n  \"storage-manager.install-ssd.step-remove-bottom-cover\": \"Remove the magnetic bottom cover\",\n  \"storage-manager.install-ssd.step-replace-bottom-cover\": \"Place the bottom cover back on\",\n  \"storage-manager.install-ssd.step-return\": \"Return here to add the SSDs to your storage\",\n  \"storage-manager.install-ssd.step-shut-down\": \"Shut down your {{deviceName}}\",\n  \"storage-manager.install-ssd.title\": \"Adding SSDs\",\n  \"storage-manager.install-tips.image-alt\": \"SSD installation instruction\",\n  \"storage-manager.install-tips.instructions\": \"To install, remove the thumb screw and slide the SSD into the slot at an angle. Press the SSD down until it rests on the screw pillar, then secure it with the thumb screw.\",\n  \"storage-manager.install-tips.toggle\": \"Forgot how to insert an SSD?\",\n  \"storage-manager.manage\": \"Manage\",\n  \"storage-manager.missing-ssd-warning\": \"An SSD appears to be missing. Shut down your Umbrel and check that all SSDs are connected. If the problem continues, the SSD may need to be replaced.\",\n  \"storage-manager.mode\": \"Mode\",\n  \"storage-manager.mode.failsafe\": \"FailSafe\",\n  \"storage-manager.mode.failsafe.description\": \"Keeps your data safe if an SSD fails. If your SSDs are different sizes, extra space on larger ones goes unused.\",\n  \"storage-manager.mode.failsafe.info-description\": \"FailSafe protects your data by keeping copies of it across your SSDs. If any single SSD fails, your data stays safe and can be restored when you add a replacement SSD.\",\n  \"storage-manager.mode.failsafe.info-title\": \"About FailSafe\",\n  \"storage-manager.mode.full-storage\": \"Full Storage\",\n  \"storage-manager.mode.full-storage.description\": \"Use all your SSD space together. If an SSD fails, you could lose your data.\",\n  \"storage-manager.mode.full-storage.info-description\": \"Full Storage combines all your SSDs into one large space, giving you maximum storage. However, if any SSD fails, all your data will be lost.\",\n  \"storage-manager.mode.full-storage.info-title\": \"About Full Storage\",\n  \"storage-manager.mode.switch-from-failsafe-unavailable\": \"Switching from FailSafe to Full Storage mode requires backing up your data, factory resetting your device, and restoring from a backup.\",\n  \"storage-manager.mode.switch-to-failsafe-unavailable\": \"With multiple SSDs in Full Storage mode, your data is spread across all drives. Switching to FailSafe requires backing up your data, factory resetting, and restoring.\",\n  \"storage-manager.mode.why-cant-switch\": \"Why can't I switch?\",\n  \"storage-manager.operation-in-progress.shutdown-description\": \"It's safe to shut down. The operation will pause and resume after restart, but must finish before you can make other changes.\",\n  \"storage-manager.operation-in-progress.shutdown-title\": \"Your storage is being updated\",\n  \"storage-manager.operation-in-progress.wait-description\": \"Please wait for the current operation to finish before making more changes.\",\n  \"storage-manager.operation-in-progress.wait-title\": \"Your storage is being updated\",\n  \"storage-manager.operation.adding-ssd\": \"Adding SSD...\",\n  \"storage-manager.operation.enabling-failsafe\": \"Enabling FailSafe...\",\n  \"storage-manager.operation.expanding\": \"Expanding storage...\",\n  \"storage-manager.operation.rebuilding\": \"Rebuilding data...\",\n  \"storage-manager.operation.replacing\": \"Replacing drive...\",\n  \"storage-manager.operation.restarting\": \"Restarting...\",\n  \"storage-manager.operation.starting\": \"Starting...\",\n  \"storage-manager.operation.syncing-restarts\": \"Syncing data • Restarts at 50%\",\n  \"storage-manager.raid-status.degraded\": \"Degraded\",\n  \"storage-manager.raid-status.failed\": \"Failed\",\n  \"storage-manager.raid-status.offline\": \"Offline\",\n  \"storage-manager.raid-status.online\": \"Online\",\n  \"storage-manager.raid-status.removed\": \"Removed\",\n  \"storage-manager.raid-status.unavailable\": \"Unavailable\",\n  \"storage-manager.replace\": \"Replace\",\n  \"storage-manager.replace-failed.degraded\": \"FailSafe protection reduced\",\n  \"storage-manager.replace-failed.degraded-description\": \"An SSD is missing from your FailSafe storage. Replace it to restore full protection.\",\n  \"storage-manager.replace-failed.description\": \"Use this SSD to restore your FailSafe protection.\",\n  \"storage-manager.replace-failed.error\": \"Couldn't start replacement\",\n  \"storage-manager.replace-failed.replace-now\": \"Replace now\",\n  \"storage-manager.replace-failed.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD in Slot {{slot}}\",\n  \"storage-manager.replace-failed.step-protected\": \"Once complete, your data will be fully protected again\",\n  \"storage-manager.replace-failed.step-rebuild\": \"Data will be rebuilt onto the new SSD\",\n  \"storage-manager.replace-failed.step-time\": \"This may take a while depending on how much data you have\",\n  \"storage-manager.replace-failed.title\": \"Replace SSD\",\n  \"storage-manager.replace-failed.too-small\": \"SSD too small\",\n  \"storage-manager.replace-failed.too-small-description\": \"This SSD ({{deviceSize}}) is smaller than the minimum required ({{minSize}}) for your FailSafe storage.\",\n  \"storage-manager.replace-failed.what-happens\": \"What happens next:\",\n  \"storage-manager.ssd-failing\": \"Failing\",\n  \"storage-manager.swap\": \"Swap\",\n  \"storage-manager.swap.data-erased-description\": \"Full Storage mode doesn't have data protection. All data on your {{deviceName}} will be erased during factory reset. Make sure to back up everything first.\",\n  \"storage-manager.swap.data-protected\": \"Your data is protected\",\n  \"storage-manager.swap.data-protected-description\": \"With FailSafe enabled, you can swap out any single SSD without losing your data. No backup needed.\",\n  \"storage-manager.swap.data-will-be-erased\": \"Data will be erased\",\n  \"storage-manager.swap.description-failsafe\": \"Replace a drive in your FailSafe storage.\",\n  \"storage-manager.swap.description-full-storage\": \"Replace a drive in your Full Storage setup.\",\n  \"storage-manager.swap.description-no-free-slot\": \"In Full Storage mode with all slots in use, swapping an SSD requires a full backup and restore process.\",\n  \"storage-manager.swap.description-replace\": \"Migrate your data to a new SSD, then remove the old one.\",\n  \"storage-manager.swap.failed-to-start\": \"Couldn't start replacement\",\n  \"storage-manager.swap.no-data-loss\": \"No data loss\",\n  \"storage-manager.swap.no-data-loss-description\": \"Your data will be copied to the new SSD. Once complete, you can safely remove the old one.\",\n  \"storage-manager.swap.safe-swap-available\": \"Safe swap available\",\n  \"storage-manager.swap.safe-swap-description\": \"Since you have an empty slot, you can add the new SSD first and migrate your data before removing the old one. No backup required.\",\n  \"storage-manager.swap.select-new-ssd\": \"Select the new SSD to use:\",\n  \"storage-manager.swap.ssd-in-slot\": \"{{size}} SSD in Slot {{slot}}\",\n  \"storage-manager.swap.step-backup\": \"Back up your data\",\n  \"storage-manager.swap.step-backup-description\": \"Go to Settings → Backups and create a backup of all your data.\",\n  \"storage-manager.swap.step-data-copied\": \"Data will be copied from the old SSD to the new one\",\n  \"storage-manager.swap.step-factory-reset\": \"Factory reset\",\n  \"storage-manager.swap.step-factory-reset-description\": \"Go to Settings → Advanced → Factory Reset to erase your {{deviceName}}.\",\n  \"storage-manager.swap.step-insert-new-ssd\": \"Insert the new SSD into an empty slot\",\n  \"storage-manager.swap.step-may-take-while\": \"This may take a while depending on how much data you have\",\n  \"storage-manager.swap.step-power-on\": \"Power on your {{deviceName}}\",\n  \"storage-manager.swap.step-remove-bottom-cover\": \"Remove the magnetic bottom cover\",\n  \"storage-manager.swap.step-remove-old\": \"Once complete, shut down and remove {{ssd}}\",\n  \"storage-manager.swap.step-replace-bottom-cover\": \"Replace the bottom cover\",\n  \"storage-manager.swap.step-restore\": \"Restore your data\",\n  \"storage-manager.swap.step-restore-description\": \"Go to Settings → Backups and restore from your backup.\",\n  \"storage-manager.swap.step-return-to-storage-manager\": \"Return here to Storage Manager to confirm the swap and add the new SSD to your storage\",\n  \"storage-manager.swap.step-return-to-swap\": \"Return here to Storage Manager and click \\\"Swap\\\" again to start the replacement\",\n  \"storage-manager.swap.step-setup-new-storage\": \"Set up your new storage\",\n  \"storage-manager.swap.step-setup-new-storage-description\": \"Power on your {{deviceName}} and complete the setup process with your new SSD.\",\n  \"storage-manager.swap.step-shut-down\": \"Shut down your {{deviceName}}\",\n  \"storage-manager.swap.step-shut-down-and-swap\": \"Shut down and swap {{ssd}}\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-other\": \"Power off, open your device, replace the SSD, and reassemble.\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-pro\": \"Power off, remove the bottom cover, replace the SSD, and close the cover.\",\n  \"storage-manager.swap.step-swap-ssd\": \"Swap {{ssd}} with a new one of the same size\",\n  \"storage-manager.swap.too-small\": \"Too small ({{size}} required)\",\n  \"storage-manager.swap.what-happens-next\": \"What happens next:\",\n  \"storage-manager.total-capacity-added\": \"Total capacity added\",\n  \"storage-manager.umbrel-pro\": \"Umbrel Pro\",\n  \"storage-manager.used\": \"Used\",\n  \"storage-manager.wasted\": \"Unusable\",\n  \"storage-manager.wasted-size\": \"{{size}} Unusable\",\n  \"storage.full\": \"Storage full\",\n  \"storage.low\": \"Low storage\",\n  \"temperature\": \"Temperature\",\n  \"temperature.dangerously-hot\": \"Very hot\",\n  \"temperature.nice\": \"Nice\",\n  \"temperature.normal\": \"Normal\",\n  \"temperature.too-hot-suggestion\": \"Consider changing your device's environment.\",\n  \"temperature.warm\": \"Warm\",\n  \"terminal\": \"Terminal\",\n  \"terminal-description\": \"Run custom commands in umbrelOS or within an app\",\n  \"terminal.app\": \"App\",\n  \"terminal.app-description\": \"Run custom commands within a specific app\",\n  \"terminal.umbrelos-description\": \"Run custom commands in umbrelOS\",\n  \"tor-description\": \"Access your Umbrel from anywhere using a Tor browser\",\n  \"tor-enabled-description\": \"Access your Umbrel from anywhere using a Tor browser on the following URL:\",\n  \"tor-error\": \"Failed to update Tor setting: {{message}}\",\n  \"tor.disable.description\": \"This may take a few minutes\",\n  \"tor.disable.progress\": \"Disabling Remote Tor access\",\n  \"tor.enable.description\": \"This may take a few minutes\",\n  \"tor.enable.mobile.switch-label\": \"Enable remote Tor access\",\n  \"tor.hidden-service\": \"Tor hidden service URL\",\n  \"troubleshoot\": \"Troubleshoot\",\n  \"troubleshoot-description\": \"Troubleshoot umbrelOS or an app\",\n  \"troubleshoot-no-logs-yet\": \"No logs yet\",\n  \"troubleshoot-pick-title\": \"Troubleshoot\",\n  \"troubleshoot.app\": \"App\",\n  \"troubleshoot.app-description\": \"View logs of an app installed on your Umbrel\",\n  \"troubleshoot.app-download\": \"Download {{app}} logs\",\n  \"troubleshoot.share-with-umbrel-support\": \"Share with Umbrel Support\",\n  \"troubleshoot.system-download\": \"Download {{label}}\",\n  \"troubleshoot.umbrelos-description\": \"View umbrelOS logs\",\n  \"troubleshoot.umbrelos-logs\": \"umbrelOS logs\",\n  \"trpc.backend-unavailable\": \"Error: Connection to the system API failed\",\n  \"trpc.checking-backend\": \"Loading...\",\n  \"try-again\": \"Try again\",\n  \"umbrel\": \"Umbrel\",\n  \"umbrelos\": \"umbrelOS\",\n  \"unknown\": \"Unknown\",\n  \"unknown-app\": \"Unknown app\",\n  \"unknown-error\": \"Unknown error\",\n  \"uptime\": \"Uptime\",\n  \"url\": \"URL\",\n  \"wallpaper\": \"Wallpaper\",\n  \"wallpaper-description\": \"Your Umbrel wallpaper and theme\",\n  \"whats-new.continue\": \"Continue\",\n  \"whats-new.feature-1.description\": \"Set up automated, encrypted backups of your entire Umbrel to an external USB drive, a NAS, or another Umbrel.\",\n  \"whats-new.feature-2.description\": \"Jump back in time to recover specific files and folders from previous backups.\",\n  \"whats-new.feature-3.description\": \"Or restore your entire Umbrel, including all your apps, files, and data.\",\n  \"whats-new.feature-4.description\": \"Connect a NAS or another Umbrel, and access its storage from Files.\",\n  \"whats-new.feature-4.title\": \"Network Devices\",\n  \"whats-new.feature-5.description\": \"Connect external USB drives (on the Umbrel Home, or any Intel or AMD device) and access them from Files.\",\n  \"whats-new.feature-5.helper-text\": \"Not supported on Raspberry Pi devices due to potential power issues.\",\n  \"whats-new.feature-5.title\": \"External Storage\",\n  \"whats-new.next\": \"Next\",\n  \"whats-new.title\": \"What's new in {{version}}\",\n  \"widget.progress.in-progress\": \"In progress\",\n  \"widgets.edit.select-up-to-3-widgets\": \"Select up to 3 widgets\",\n  \"widgets.install-an-app-before-using-widgets\": \"Install an app to start customizing your homescreen with widgets.\",\n  \"wifi\": \"Wi-Fi\",\n  \"wifi-connect-insecure-message\": \"Open networks can be insecure\",\n  \"wifi-connection-failed\": \"Unable to connect\",\n  \"wifi-dangerous-change-confirmation-description\": \"Changing the Wi-Fi network may disconnect you from your Umbrel. To reconnect, ensure that both your Umbrel and the device you're accessing it from are on the same network.\",\n  \"wifi-dangerous-change-confirmation-title\": \"Are you sure you want to change Wi-Fi network?\",\n  \"wifi-dangerous-disable-confirmation-description\": \"Disabling Wi-Fi may disconnect you from your Umbrel. To reconnect, plug in an Ethernet cable to your Umbrel and ensure that both your Umbrel and the device you're accessing it from are on the same network.\",\n  \"wifi-dangerous-disable-confirmation-title\": \"Are you sure you want to disable Wi-Fi?\",\n  \"wifi-description\": \"Connect your device to a Wi-Fi network\",\n  \"wifi-description-long\": \"Your device stays connected to your chosen Wi-Fi, even if the Ethernet cable is removed, and automatically reconnects to Wi-Fi on startup.\",\n  \"wifi-no-networks-message\": \"No Wi-Fi networks found\",\n  \"wifi-searching\": \"Searching for Wi-Fi networks...\",\n  \"wifi-unsupported-device-description\": \"Wi-Fi is not supported on this device. This may be due to a missing or incompatible wireless adapter.\",\n  \"wifi-view-networks\": \"View networks\"\n}"
  },
  {
    "path": "packages/ui/public/locales/es.json",
    "content": "{\n  \"2fa\": \"2FA\",\n  \"2fa-description\": \"Una segunda capa de seguridad para tu inicio de sesión en Umbrel y aplicaciones\",\n  \"2fa.disable.title\": \"Desactivar autenticación de dos factores\",\n  \"2fa.enable.or-paste\": \"O pega el siguiente código en tu aplicación de autenticación\",\n  \"2fa.enable.scan-this\": \"Escanea este código QR usando una aplicación de autenticación como Google Authenticator o Authy\",\n  \"2fa.enable.title\": \"Activar autenticación de dos factores\",\n  \"2fa.enter-code\": \"Introduce el código mostrado en tu aplicación de autenticación\",\n  \"account\": \"Cuenta\",\n  \"account-description\": \"Tu nombre y contraseña\",\n  \"advanced-settings\": \"Configuración avanzada\",\n  \"advanced-settings-description\": \"Terminal, Programa Beta de umbrelOS, Cloudflare DNS, y más\",\n  \"app-not-found\": \"Aplicación no encontrada: {{app}}\",\n  \"app-only-over-tor\": \"{{app}} solo se puede usar a través de Tor. Por favor, accede a tu Umbrel desde un navegador Tor usando tu URL de acceso remoto (Settings > Advanced settings > Remote Tor access) para abrir esta aplicación.\",\n  \"app-page.section.about\": \"Acerca de\",\n  \"app-page.section.credentials.title\": \"Credenciales predeterminadas\",\n  \"app-page.section.dependencies.n-alternatives\": \"Ver {{count}} alternativas\",\n  \"app-page.section.info.compatibility\": \"Compatibilidad\",\n  \"app-page.section.info.compatibility-compatible\": \"Compatible\",\n  \"app-page.section.info.compatibility-not-compatible\": \"No compatible\",\n  \"app-page.section.info.developer\": \"Desarrollador\",\n  \"app-page.section.info.source-code\": \"Código fuente\",\n  \"app-page.section.info.source-code.public\": \"Público\",\n  \"app-page.section.info.submitted-by\": \"Enviado por\",\n  \"app-page.section.info.support\": \"Obtener soporte\",\n  \"app-page.section.info.title\": \"Información\",\n  \"app-page.section.info.version\": \"Versión\",\n  \"app-page.section.recommendations.title\": \"También te podría gustar\",\n  \"app-page.section.release-notes.title\": \"Novedades\",\n  \"app-page.section.release-notes.version\": \"Versión {{version}}\",\n  \"app-page.section.requires\": \"Requiere\",\n  \"app-picker.search\": \"Buscar...\",\n  \"app-picker.select-app\": \"Seleccionar aplicación...\",\n  \"app-settings.connected-to\": \"{{appName}} está conectado a estas aplicaciones\",\n  \"app-settings.save-changes\": \"Guardar cambios\",\n  \"app-settings.title\": \"Configuración\",\n  \"app-store.browse-category-apps\": \"Explorar aplicaciones de {{category}}\",\n  \"app-store.category.ai\": \"IA\",\n  \"app-store.category.all\": \"Todas las aplicaciones\",\n  \"app-store.category.automation\": \"Hogar y Automatización\",\n  \"app-store.category.bitcoin\": \"Bitcoin\",\n  \"app-store.category.crypto\": \"Cripto\",\n  \"app-store.category.developer\": \"Herramientas para Desarrolladores\",\n  \"app-store.category.discover\": \"Descubrir\",\n  \"app-store.category.files\": \"Archivos y Productividad\",\n  \"app-store.category.finance\": \"Finanzas\",\n  \"app-store.category.media\": \"Medios\",\n  \"app-store.category.networking\": \"Redes\",\n  \"app-store.category.social\": \"Social\",\n  \"app-store.description\": \"Tus configuraciones de actualización de aplicaciones\",\n  \"app-store.discover.temporarily-unavailable-description\": \"Explora las categorías de arriba o usa la búsqueda para encontrar aplicaciones\",\n  \"app-store.discover.temporarily-unavailable-title\": \"Contenido destacado temporalmente no disponible\",\n  \"app-store.menu.community-app-stores\": \"Tiendas de Aplicaciones Comunitarias\",\n  \"app-store.search-apps\": \"Buscar aplicaciones\",\n  \"app-store.search.no-results\": \"Sin resultados\",\n  \"app-store.search.results-for\": \"Resultados para\",\n  \"app-store.title\": \"App Store\",\n  \"app-store.updates\": \"Actualizaciones\",\n  \"app-updates.less\": \"menos\",\n  \"app-updates.more\": \"más\",\n  \"app-updates.no-updates\": \"¡Todas las aplicaciones están actualizadas!\",\n  \"app-updates.update\": \"Actualizar\",\n  \"app-updates.update-all\": \"Actualizar todo\",\n  \"app-updates.updates-available-count_one\": \"{{count}} actualización disponible\",\n  \"app-updates.updates-available-count_other\": \"{{count}} actualizaciones disponibles\",\n  \"app-updates.updating\": \"Actualizando...\",\n  \"app.install\": \"Instalar\",\n  \"app.installed\": \"Instalado\",\n  \"app.installing\": \"Instalando\",\n  \"app.offline\": \"No en ejecución\",\n  \"app.open\": \"Abrir\",\n  \"app.optimized-for-umbrel-home\": \"Optimizado para Umbrel Home\",\n  \"app.os-update-required.confirm\": \"Buscar actualización de umbrelOS\",\n  \"app.os-update-required.description\": \"{{appName}} requiere umbrelOS {{version}} o posterior\",\n  \"app.os-update-required.title\": \"Actualizar umbrelOS\",\n  \"app.restarting\": \"Reiniciando\",\n  \"app.starting\": \"Iniciando\",\n  \"app.stopping\": \"Deteniendo\",\n  \"app.uninstall.confirm.description\": \"Todos los datos asociados con {{app}} serán eliminados permanentemente. Esta acción no se puede deshacer.\",\n  \"app.uninstall.confirm.submit\": \"Desinstalar\",\n  \"app.uninstall.confirm.title\": \"¿Desinstalar {{app}}?\",\n  \"app.uninstall.deps.used-by.description_one\": \"Desinstala {{firstAppToUninstall}} primero para desinstalar {{app}}.\",\n  \"app.uninstall.deps.used-by.description_other\": \"Desinstala estas aplicaciones primero para desinstalar {{app}}.\",\n  \"app.uninstall.deps.used-by.title\": \"{{app}} es utilizado por\",\n  \"app.uninstalling\": \"Desinstalando\",\n  \"app.updating\": \"Actualizando\",\n  \"app.view\": \"Ver\",\n  \"app_one\": \"aplicación\",\n  \"app_other\": \"aplicaciones\",\n  \"apps.uninstall.failed-to-get-required-apps\": \"Error al obtener aplicaciones requeridas\",\n  \"apps.uninstalled-all.success\": \"Todas las aplicaciones desinstaladas\",\n  \"auth.checking-backend-for-user\": \"Cargando...\",\n  \"auth.failed-checking-if-user-logged-in\": \"Error: Falló la verificación de inicio de sesión\",\n  \"auth.failed-to-check-if-user-exists\": \"Error: Falló la verificación de existencia\",\n  \"back\": \"Atrás\",\n  \"backups\": \"Copias de seguridad\",\n  \"backups-configure\": \"Configurar\",\n  \"backups-configure.add-backup-location\": \"Agregar ubicación de copia de seguridad\",\n  \"backups-configure.available\": \"Disponible\",\n  \"backups-configure.awaiting-next-backup\": \"Esperando la próxima copia de seguridad automática\",\n  \"backups-configure.back-up-now\": \"Hacer copia de seguridad ahora\",\n  \"backups-configure.backing-up-now\": \"Haciendo copia de seguridad...\",\n  \"backups-configure.connected\": \"Conectado\",\n  \"backups-configure.connection\": \"Conexión\",\n  \"backups-configure.in-progress\": \"En progreso\",\n  \"backups-configure.last-backup\": \"Última copia de seguridad\",\n  \"backups-configure.locations\": \"Ubicaciones\",\n  \"backups-configure.no-backup-locations\": \"Agrega una ubicación de copia de seguridad para empezar a hacer copias de seguridad de tus datos\",\n  \"backups-configure.not-connected\": \"No conectado\",\n  \"backups-configure.path\": \"Ruta\",\n  \"backups-configure.remove-backup-location\": \"Eliminar ubicación de copia de seguridad\",\n  \"backups-configure.remove-backup-location-confirmation\": \"¿Estás seguro?\",\n  \"backups-configure.remove-backup-location-confirmation-description\": \"Esto eliminará '{{device}}' de tus ubicaciones de copia de seguridad. Las copias existentes en ese dispositivo no se borrarán, pero las copias automáticas dejarán de realizarse.\",\n  \"backups-configure.status\": \"Estado\",\n  \"backups-configure.total-backups\": \"Total de copias de seguridad\",\n  \"backups-configure.used\": \"Usado\",\n  \"backups-configure.view\": \"Ver\",\n  \"backups-description\": \"Haz copias de seguridad de tus archivos, apps y datos en otro Umbrel, un NAS o un disco externo\",\n  \"backups-error.backup-not-found\": \"No se pudo encontrar la copia de seguridad.\",\n  \"backups-error.generic\": \"Algo salió mal: {{details}}\",\n  \"backups-error.in-progress\": \"Ya se está ejecutando un proceso de copia de seguridad. Por favor, espera a que termine.\",\n  \"backups-error.invalid-exclusion-path\": \"Solo se pueden excluir de las copias de seguridad archivos y carpetas dentro de tu carpeta personal.\",\n  \"backups-error.invalid-password\": \"La contraseña de cifrado es incorrecta.\",\n  \"backups-error.invalid-path\": \"La ubicación seleccionada no es válida para copias de seguridad.\",\n  \"backups-error.mount-failed\": \"No se pudo acceder a la instantánea de la copia de seguridad.\",\n  \"backups-error.mount-timeout\": \"No se pudo acceder a la instantánea de la copia de seguridad. Intenta de nuevo o comprueba si el dispositivo está correctamente conectado.\",\n  \"backups-error.not-enough-space\": \"No hay suficiente espacio disponible en el dispositivo de copia de seguridad.\",\n  \"backups-error.not-found\": \"No se pudo encontrar la copia de seguridad o su ubicación.\",\n  \"backups-error.repository-exists\": \"Ya existe una ubicación de copia de seguridad en esta carpeta.\",\n  \"backups-error.repository-not-found\": \"No se pudo encontrar la ubicación de copia de seguridad.\",\n  \"backups-exclusions.add\": \"Agregar\",\n  \"backups-exclusions.app-paths-cannot-be-modified\": \"Estos archivos/carpetas están definidos por el desarrollador de la app y no se pueden modificar:\",\n  \"backups-exclusions.app-paths-explanation\": \"Esta app excluye los siguientes datos de la copia de seguridad. Estas rutas suelen contener elementos no esenciales (como caches o registros que se pueden recrear) o datos que podrían causar problemas si se restauran (por ejemplo, estados antiguos de la app que podrían generar conflictos o inconsistencias).\",\n  \"backups-exclusions.auto-excluded\": \"Excluido automáticamente\",\n  \"backups-exclusions.exclude-entire-app\": \"Excluir toda la app\",\n  \"backups-exclusions.excluded-apps\": \"Apps excluidas\",\n  \"backups-exclusions.files-and-folders\": \"Archivos y carpetas excluidos\",\n  \"backups-exclusions.no-excluded-apps\": \"No hay apps excluidas\",\n  \"backups-exclusions.no-excluded-files-or-folders\": \"No hay archivos o carpetas excluidos\",\n  \"backups-exclusions.select-item-to-exclude\": \"Selecciona el elemento a excluir\",\n  \"backups-exclusions.stop-excluding\": \"Dejar de excluir\",\n  \"backups-floating-island.backing-up\": \"Haciendo copia de seguridad...\",\n  \"backups-floating-island.backing-up-to\": \"Haciendo copia de seguridad de tu Umbrel...\",\n  \"backups-restore\": \"Restaurar\",\n  \"backups-restore-full\": \"Restauración completa\",\n  \"backups-restore-full-description\": \"Restaura todo tu Umbrel desde una copia de seguridad\",\n  \"backups-restore-header\": \"Restaurar tu Umbrel\",\n  \"backups-restore-pro.after-restore\": \"Después de restaurar, tu cuenta temporal será reemplazada por la cuenta y los datos de tu copia de seguridad.\",\n  \"backups-restore-pro.step1\": \"Completa la configuración inicial haciendo clic en \\\"Comenzar\\\" a continuación. Esta será tu cuenta temporal hasta que restaures tu cuenta respaldada.\",\n  \"backups-restore-pro.step2\": \"Una vez completada la configuración, ve a <0>Configuración → Backups → Restaurar</0>\",\n  \"backups-restore-pro.step3\": \"Sigue las indicaciones del asistente de restauración.\",\n  \"backups-restore-pro.subtitle\": \"Restaurar una copia de seguridad en Umbrel Pro requiere algunos pasos adicionales\",\n  \"backups-restore.backup-date\": \"Fecha de la copia de seguridad\",\n  \"backups-restore.backup-location\": \"Ubicación de copia de seguridad\",\n  \"backups-restore.browse-cloud-subtitle\": \"Restaurar desde Umbrel Private Cloud (próximamente)\",\n  \"backups-restore.browse-cloud-title\": \"Umbrel Private Cloud\",\n  \"backups-restore.browse-external-subtitle\": \"Restaurar desde una unidad USB externa\",\n  \"backups-restore.browse-external-title\": \"Disco externo\",\n  \"backups-restore.browse-nas-or-external\": \"Explora otro Umbrel, NAS o un disco externo para restaurar desde una copia de seguridad\",\n  \"backups-restore.browse-nas-subtitle\": \"Restaurar desde otro dispositivo Umbrel o NAS en tu red\",\n  \"backups-restore.browse-nas-title\": \"Otro Umbrel o NAS\",\n  \"backups-restore.choose\": \"Seleccionar\",\n  \"backups-restore.choose-backup-location\": \"Selecciona una ubicación de copia de seguridad\",\n  \"backups-restore.connect-to-backup-location\": \"Conectar a una ubicación de copia de seguridad\",\n  \"backups-restore.encryption-password\": \"Contraseña de cifrado\",\n  \"backups-restore.encryption-password-description\": \"Introduce la contraseña de cifrado que estableciste cuando activaste las copias de seguridad\",\n  \"backups-restore.enter-password-to-confirm\": \"Introduce tu contraseña de Umbrel para confirmar\",\n  \"backups-restore.final-confirmation\": \"¿Estás seguro?\",\n  \"backups-restore.final-confirmation-description\": \"Restaurar desde esta copia de seguridad reemplazará las apps y datos actuales de umbrelOS con el contenido de la copia seleccionada. Cualquier archivo, carpeta o app excluida de esta copia se eliminará de tu Umbrel. Esta acción no se puede deshacer.\",\n  \"backups-restore.invalid-password\": \"Contraseña inválida\",\n  \"backups-restore.last-backup\": \"Última copia de seguridad: {{date}}\",\n  \"backups-restore.latest\": \"Última\",\n  \"backups-restore.no-backups-found\": \"No se encontraron copias de seguridad\",\n  \"backups-restore.no-backups-yet\": \"Aún no hay copias de seguridad\",\n  \"backups-restore.please-select-backup\": \"Por favor, selecciona una copia de seguridad\",\n  \"backups-restore.please-select-repository\": \"Por favor, selecciona un repositorio\",\n  \"backups-restore.restore-from-nas-or-external\": \"Restaura tu Umbrel desde una copia de seguridad en otro Umbrel, en un NAS o en una unidad externa\",\n  \"backups-restore.restore-from-unlisted\": \"Restaurar desde otra ubicación\",\n  \"backups-restore.restore-umbrel\": \"Restaurar Umbrel\",\n  \"backups-restore.restore-warning\": \"Restaurar desde esta copia de seguridad reemplazará las apps y datos actuales de umbrelOS con el contenido de la copia seleccionada. Cualquier archivo, carpeta o app excluida de esta copia se eliminará de tu Umbrel. Abre <0>Rewind</0> si quieres restaurar archivos o carpetas específicos en su lugar.\",\n  \"backups-restore.restoring-from\": \"Estás a punto de restaurar desde la siguiente copia de seguridad:\",\n  \"backups-restore.review-description\": \"La restauración configurará tu Umbrel con la cuenta, los archivos, las apps y la configuración que estaban incluidos en el momento de esta copia de seguridad. Esto puede tardar un poco. Una vez finalizado, tu contraseña de inicio de sesión se establecerá en la que usaste cuando se creó la copia de seguridad.\",\n  \"backups-restore.select-backup\": \"Selecciona una copia de seguridad\",\n  \"backups-restore.select-backup-description\": \"Selecciona la copia de seguridad desde la que quieres restaurar\",\n  \"backups-restore.select-backup-file\": \"Selecciona tu archivo de copia de seguridad\",\n  \"backups-restore.select-backup-file-only\": \"Solo se puede seleccionar <bold>{{backupFileName}}</bold>\",\n  \"backups-restore.total-size\": \"Tamaño total\",\n  \"backups-restore.unknown-date\": \"Fecha desconocida\",\n  \"backups-restore.unknown-repository\": \"Repositorio desconocido\",\n  \"backups-rewind\": \"Rewind\",\n  \"backups-rewind-description\": \"Retrocede en el tiempo para restaurar archivos y carpetas específicos\",\n  \"backups-rewind.start\": \"Iniciar Rewind\",\n  \"backups-setup\": \"Configurar\",\n  \"backups-setup-confirm\": \"Finalizar configuración\",\n  \"backups-setup-external-description\": \"Haz una copia de seguridad en una unidad USB externa\",\n  \"backups-setup-nas-or-umbrel-description\": \"Realiza copias de seguridad en otro Umbrel o en un dispositivo NAS de tu red\",\n  \"backups-setup-umbrel-or-nas\": \"Otro Umbrel o NAS\",\n  \"backups-setup-umbrel-private-cloud\": \"Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-cta\": \"Extiende tu tranquilidad más allá de tu hogar con <bold>copias de seguridad cifradas de extremo a extremo</bold> en Umbrel Private Cloud.\",\n  \"backups-setup-umbrel-private-cloud-cta-link\": \"Obtén acceso anticipado\",\n  \"backups-setup-umbrel-private-cloud-description\": \"Copias de seguridad cifradas de extremo a extremo en Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-subtitle\": \"Próximamente\",\n  \"backups.add-umbrel-or-nas\": \"Agregar Umbrel o NAS\",\n  \"backups.all-apps-and-data-will-be-backed-up\": \"Se harán copias de seguridad de todas las apps y datos\",\n  \"backups.apps-and-data\": \"Apps y datos\",\n  \"backups.backup-location\": \"Ubicación de copia de seguridad\",\n  \"backups.browse\": \"Examinar\",\n  \"backups.choose-folder-within-device\": \"Elige una carpeta dentro de <bold>{{device}}</bold> para guardar tus copias de seguridad\",\n  \"backups.confirm-password\": \"Confirmar contraseña\",\n  \"backups.copy\": \"Copiar\",\n  \"backups.encryption\": \"Cifrado\",\n  \"backups.encryption-password-warning\": \"Asegúrate de guardar tu contraseña de cifrado de forma segura, por ejemplo en un gestor de contraseñas. No podrás verla de nuevo y la necesitarás para restaurar tus copias de seguridad.\",\n  \"backups.exclude-from-backups\": \"Excluir de las copias de seguridad\",\n  \"backups.exclude-from-backups-description\": \"Excluye archivos, carpetas y apps específicos de tus copias de seguridad.\",\n  \"backups.hide\": \"Ocultar\",\n  \"backups.i-understand\": \"Entiendo\",\n  \"backups.location\": \"Ubicación\",\n  \"backups.modals.already-in-use.description\": \"Esta ubicación ya se está usando para Backups en este Umbrel.\",\n  \"backups.modals.already-in-use.manage\": \"Administrar en Backups\",\n  \"backups.modals.already-in-use.title\": \"Ubicación de Backups ya en uso\",\n  \"backups.modals.connect-existing.description\": \"Ya existe una copia de seguridad de Umbrel en esta ubicación. Introduce su contraseña de cifrado para añadirla a este Umbrel.\",\n  \"backups.modals.connect-existing.title\": \"Conectar copia de seguridad existente de Umbrel\",\n  \"backups.no-external-drives-detected\": \"No se detectaron discos externos\",\n  \"backups.no-password-set\": \"No hay contraseña establecida\",\n  \"backups.password-is-set\": \"Contraseña establecida\",\n  \"backups.password-minimum-length\": \"La contraseña debe tener al menos 8 caracteres\",\n  \"backups.password-safety-warning\": \"Tus copias de seguridad se cifrarán con esta contraseña. Guárdala de forma segura, ya que no podrás verla de nuevo y la necesitarás para restaurarlas.\",\n  \"backups.passwords-do-not-match\": \"Las contraseñas no coinciden\",\n  \"backups.please-choose-folder\": \"Por favor, elige una carpeta\",\n  \"backups.restore-failed.message\": \"Se produjo un error al restaurar tu Umbrel. Tus aplicaciones y datos actuales no se han modificado.\",\n  \"backups.restore-failed.retry\": \"Ir a Restaurar\",\n  \"backups.restore-failed.title\": \"Error al restaurar\",\n  \"backups.restoring\": \"Restaurando tu Umbrel\",\n  \"backups.restoring-completing\": \"Casi listo. Tu Umbrel se reiniciará en breve...\",\n  \"backups.restoring-progress\": \"Restaurado {{percent}}%\",\n  \"backups.restoring-time-remaining\": \"{{time}} restantes\",\n  \"backups.restoring-warning\": \"No apagues tu Umbrel ni desconectes la ubicación de copia de seguridad durante la restauración\",\n  \"backups.review\": \"Revisar y confirmar\",\n  \"backups.review-description\": \"Revisa los detalles de tu copia de seguridad y confirma tu selección\",\n  \"backups.scanning-for-external-drives\": \"Buscando discos externos...\",\n  \"backups.schedule-description\": \"umbrelOS hace copias de seguridad automáticamente cada hora. Mantiene copias cifradas horarias de las últimas 24 horas, copias diarias de la última semana, copias semanales del último mes y copias mensuales del último año. Las copias de seguridad con más de un año se eliminan automáticamente.\",\n  \"backups.select-backup-folder\": \"Seleccionar carpeta de copia de seguridad\",\n  \"backups.select-backup-folder-description\": \"Elige una carpeta donde quieras almacenar tus copias de seguridad.\",\n  \"backups.select-backup-location\": \"Seleccionar una ubicación de copia de seguridad\",\n  \"backups.set-encryption-password\": \"Establecer contraseña de cifrado\",\n  \"backups.set-encryption-password-description\": \"Protege tus copias de seguridad con una contraseña. Esto asegura que tus datos permanezcan privados y solo puedan restaurarse con esta contraseña.\",\n  \"backups.show\": \"Mostrar\",\n  \"backups.storage-capacity-warning\": \"{{device}} debe tener espacio libre al menos igual al doble del tamaño de tu copia de seguridad\",\n  \"backups.store-encryption-password-safely\": \"Guarda tu contraseña de cifrado de forma segura\",\n  \"beta-program\": \"Programa Beta de umbrelOS\",\n  \"beta-program-description\": \"Opta por recibir actualizaciones beta de umbrelOS, accede antes a nuevas características y ayúdanos a refinarlas proporcionando tu feedback. Las actualizaciones beta pueden ser inestables y la solución de problemas puede requerir familiaridad con el terminal.\",\n  \"cancel\": \"Cancelar\",\n  \"change\": \"Cambiar\",\n  \"change-name\": \"Cambiar nombre\",\n  \"change-name.failed.name-required\": \"Se requiere nombre\",\n  \"change-name.input-placeholder\": \"Tu nombre\",\n  \"change-password\": \"Cambiar contraseña\",\n  \"change-password.callout\": \"Si pierdes tu contraseña, no podrás iniciar sesión en tu Umbrel. Asegúrate de guardarla de forma segura.\",\n  \"change-password.current-password\": \"Contraseña actual\",\n  \"change-password.failed.current-required\": \"Se requiere contraseña actual\",\n  \"change-password.failed.min-length\": \"La contraseña debe tener al menos {{characters}} caracteres\",\n  \"change-password.failed.must-be-unique\": \"La nueva contraseña debe ser diferente de la contraseña actual\",\n  \"change-password.failed.new-required\": \"Se requiere nueva contraseña\",\n  \"change-password.failed.no-match\": \"Las contraseñas no coinciden\",\n  \"change-password.failed.repeat-required\": \"Se requiere repetir la contraseña\",\n  \"change-password.new-password\": \"Nueva contraseña\",\n  \"change-password.repeat-password\": \"Repetir contraseña\",\n  \"check-for-latest-version\": \"Buscar la última actualización de umbrelOS\",\n  \"clipboard.copied\": \"Copiado\",\n  \"close\": \"Cerrar\",\n  \"cmdk.change-wallpaper\": \"Cambiar fondo de pantalla\",\n  \"cmdk.frequent-apps\": \"Usadas frecuentemente\",\n  \"cmdk.input-placeholder\": \"Buscar aplicaciones, configuraciones o acciones\",\n  \"cmdk.live-usage\": \"Uso en vivo\",\n  \"cmdk.restart-umbrel\": \"Reiniciar Umbrel\",\n  \"cmdk.shutdown-umbrel\": \"Apagar Umbrel\",\n  \"cmdk.update-all-apps\": \"Actualizar todas las aplicaciones\",\n  \"cmdk.widgets\": \"Widgets\",\n  \"community-app-store\": \"Tienda de Aplicaciones Comunitaria\",\n  \"community-app-store.add-error\": \"No pudimos añadir la App Store: {{message}}\",\n  \"community-app-store.back-to-umbrel-app-store\": \"Volver a la Tienda de Aplicaciones de Umbrel\",\n  \"community-app-store.open-button\": \"Abrir\",\n  \"community-app-store.remove-button\": \"Eliminar\",\n  \"community-app-store.remove-error\": \"No pudimos eliminar la App Store: {{message}}\",\n  \"community-app-stores.add-button\": \"Añadir\",\n  \"community-app-stores.description\": \"Las Tiendas de Aplicaciones Comunitarias te permiten instalar aplicaciones en tu Umbrel que pueden no estar disponibles en la Tienda de Aplicaciones oficial de Umbrel. También facilitan probar versiones beta de aplicaciones de Umbrel antes de que los desarrolladores las lancen en la Tienda de Aplicaciones oficial de Umbrel.\",\n  \"community-app-stores.learn-more\": \"Aprender más\",\n  \"community-app-stores.warning\": \"Cualquiera puede crear Tiendas de Aplicaciones Comunitarias. Las aplicaciones publicadas en ellas no están verificadas ni revisadas por el equipo de la Tienda de Aplicaciones oficial de Umbrel, y pueden ser potencialmente inseguras o maliciosas. Usa precaución y solo añade tiendas de aplicaciones de desarrolladores en los que confíes.\",\n  \"confirm\": \"Confirmar\",\n  \"connect\": \"Conectar\",\n  \"connecting\": \"Conectando...\",\n  \"connection-lost\": \"Conexión perdida\",\n  \"connection-lost-description\": \"Esto puede ocurrir si la pestaña de tu navegador ha estado inactiva, tu conexión de red se ha interrumpido o tu dispositivo está sin conexión.\",\n  \"continue\": \"Continuar\",\n  \"continue-to-log-in\": \"Continuar para iniciar sesión\",\n  \"cpu\": \"CPU\",\n  \"cpu-core-count\": \"{{cores}} hilos\",\n  \"default-credentials.close\": \"Entendido\",\n  \"default-credentials.description\": \"Aquí tienes las credenciales que necesitarás para iniciar sesión en la aplicación.\",\n  \"default-credentials.dont-show-again\": \"No mostrar esto nuevamente\",\n  \"default-credentials.dont-show-again-notice\": \"Puedes acceder a estas credenciales en cualquier momento en el futuro haciendo clic derecho en el icono de la aplicación.\",\n  \"default-credentials.open\": \"Abrir {{app}}\",\n  \"default-credentials.password\": \"Contraseña por defecto\",\n  \"default-credentials.title\": \"Credenciales para {{app}}\",\n  \"default-credentials.username\": \"Nombre de usuario por defecto\",\n  \"desktop.app.context.go-to-store-page\": \"Ver en la Tienda de Aplicaciones\",\n  \"desktop.app.context.settings\": \"Configuración\",\n  \"desktop.app.context.show-default-credentials\": \"Mostrar credenciales predeterminadas\",\n  \"desktop.app.context.uninstall\": \"Desinstalar\",\n  \"desktop.context-menu.change-wallpaper\": \"Cambiar fondo de pantalla\",\n  \"desktop.context-menu.edit-widgets\": \"Editar widgets\",\n  \"desktop.context-menu.logout\": \"Cerrar sesión\",\n  \"desktop.greeting.afternoon\": \"Buenas tardes, {{name}}\",\n  \"desktop.greeting.evening\": \"Buenas noches, {{name}}\",\n  \"desktop.greeting.morning\": \"Buenos días, {{name}}\",\n  \"desktop.install-first.for-the-ai-enthusiast\": \"Para Viber\",\n  \"desktop.install-first.for-the-bitcoiner\": \"Para el bitcoinero\",\n  \"desktop.install-first.for-the-self-hoster\": \"Para el autoalojador\",\n  \"desktop.install-first.for-the-streamer\": \"Para el streamer\",\n  \"desktop.install-first.link-to-app-store\": \"Explorar más en la Tienda de Aplicaciones\",\n  \"desktop.not-enough-room\": \"Usa una pantalla más grande para ver tus aplicaciones.\",\n  \"device\": \"Dispositivo\",\n  \"device-info\": \"Información del dispositivo\",\n  \"device-info-description\": \"Información sobre tu dispositivo\",\n  \"device-info.device\": \"Dispositivo\",\n  \"device-info.model-number\": \"Número de modelo\",\n  \"device-info.serial-number\": \"Número de serie\",\n  \"device-info.view-info\": \"Ver información\",\n  \"device-name.home-or-pro\": \"Umbrel Home or Umbrel Pro\",\n  \"disable\": \"Desactivar\",\n  \"done\": \"Hecho\",\n  \"download-logs\": \"Descargar registros\",\n  \"enabling-tor\": \"Habilitando el acceso remoto mediante Tor\",\n  \"external-dns\": \"Cloudflare DNS\",\n  \"external-dns-description\": \"Cloudflare DNS ofrece una mejor fiabilidad de red. Desactívalo para usar las configuraciones DNS de tu router.\",\n  \"external-dns-error\": \"No pudimos actualizar la configuración de DNS: {{message}}\",\n  \"external-drive\": \"Unidad externa\",\n  \"factory-reset\": \"Restablecimiento de fábrica\",\n  \"factory-reset-description\": \"Borra todos tus datos y aplicaciones, restaurando umbrelOS a su configuración predeterminada\",\n  \"factory-reset-failed\": \"No pudimos restablecer tu dispositivo: {{message}}\",\n  \"factory-reset.confirm.body\": \"Confirma tu contraseña para restablecer\",\n  \"factory-reset.confirm.ethernet-required-warning\": \"Asegúrate de que tu dispositivo esté conectado a tu router mediante Ethernet (no Wi-Fi) y que estés accediendo desde tu red local (por ejemplo, http://umbrel.local o la dirección IP local de tu dispositivo).\",\n  \"factory-reset.confirm.submit\": \"Borrar todo y restablecer\",\n  \"factory-reset.confirm.submit-callout\": \"Esta acción no se puede deshacer.\",\n  \"factory-reset.rebooting.message\": \"Tu dispositivo se reiniciará y todos los datos se borrarán. Por favor, no cierres esta página.\",\n  \"factory-reset.rebooting.status\": \"Restableciendo...\",\n  \"factory-reset.rebooting.title\": \"Restablecimiento de fábrica en curso\",\n  \"factory-reset.review.account-info\": \"Información de la cuenta y contraseña\",\n  \"factory-reset.review.apps\": \"Aplicaciones\",\n  \"factory-reset.review.following-will-be-removed\": \"Lo siguiente será eliminado de tu dispositivo\",\n  \"factory-reset.review.installed-apps_one\": \"{{count}} aplicación instalada\",\n  \"factory-reset.review.installed-apps_other\": \"{{count}} aplicaciones instaladas\",\n  \"factory-reset.review.submit\": \"Continuar\",\n  \"factory-reset.review.total-data\": \"Datos totales\",\n  \"files\": \"Files\",\n  \"files-action.add-favorite\": \"Agregar a favoritos\",\n  \"files-action.add-network-device\": \"Añadir dispositivo\",\n  \"files-action.cancel-upload\": \"Cancelar subida\",\n  \"files-action.compress\": \"Comprimir\",\n  \"files-action.copy\": \"Copiar\",\n  \"files-action.cut\": \"Cortar\",\n  \"files-action.delete\": \"Eliminar permanentemente\",\n  \"files-action.download\": \"Descargar\",\n  \"files-action.download-items\": \"Descargar {{count}} elementos\",\n  \"files-action.drop-to-upload\": \"Suelta para subir\",\n  \"files-action.eject-disk\": \"Expulsar\",\n  \"files-action.empty-trash\": \"Vaciar papelera\",\n  \"files-action.format-drive\": \"Formatear\",\n  \"files-action.go-to-path\": \"Ir a...\",\n  \"files-action.new-folder\": \"Nueva carpeta\",\n  \"files-action.open\": \"Abrir\",\n  \"files-action.paste\": \"Pegar\",\n  \"files-action.remove-favorite\": \"Quitar de Favoritos\",\n  \"files-action.remove-network-host\": \"Expulsar unidad de red\",\n  \"files-action.remove-network-share\": \"Expulsar recurso compartido de red\",\n  \"files-action.rename\": \"Renombrar\",\n  \"files-action.restore\": \"Restaurar\",\n  \"files-action.select\": \"Seleccionar\",\n  \"files-action.share\": \"Compartir en la red...\",\n  \"files-action.sharing\": \"Compartiendo...\",\n  \"files-action.show-in-folder\": \"Mostrar en la carpeta que lo contiene\",\n  \"files-action.trash\": \"Enviar a la papelera\",\n  \"files-action.uncompress\": \"Descomprimir\",\n  \"files-action.upload\": \"Subir\",\n  \"files-add-network-share.add-manually\": \"Agregar manualmente\",\n  \"files-add-network-share.add-share\": \"Agregar recurso compartido\",\n  \"files-add-network-share.back\": \"Atrás\",\n  \"files-add-network-share.continue\": \"Continuar\",\n  \"files-add-network-share.description\": \"Conéctate a un NAS u otra unidad compartida en tu red para acceder a ellos desde Files.\",\n  \"files-add-network-share.discovering\": \"Buscando...\",\n  \"files-add-network-share.enter-details-manually\": \"Introduce los detalles del servidor\",\n  \"files-add-network-share.host-label\": \"Dirección del servidor\",\n  \"files-add-network-share.host-required\": \"Se requiere la dirección del servidor\",\n  \"files-add-network-share.manual-share-help\": \"Introduce el nombre exacto de la carpeta compartida tal y como aparece en tu servidor\",\n  \"files-add-network-share.no-shares-found\": \"No se encontraron carpetas compartidas en este servidor\",\n  \"files-add-network-share.not-seeing-share\": \"¿No ves tu carpeta compartida?\",\n  \"files-add-network-share.password-label\": \"Contraseña\",\n  \"files-add-network-share.password-required\": \"Se requiere la contraseña\",\n  \"files-add-network-share.retrieving-shares\": \"Obteniendo recursos compartidos...\",\n  \"files-add-network-share.retry-discovery\": \"Volver a escanear la red\",\n  \"files-add-network-share.select-share\": \"Selecciona un recurso compartido para agregar\",\n  \"files-add-network-share.share-placeholder\": \"shared-documents\",\n  \"files-add-network-share.share-required\": \"Se requiere el recurso compartido\",\n  \"files-add-network-share.title\": \"Agregar un recurso compartido de red\",\n  \"files-add-network-share.username-label\": \"Nombre de usuario\",\n  \"files-add-network-share.username-placeholder\": \"admin\",\n  \"files-add-network-share.username-required\": \"Se requiere el nombre de usuario\",\n  \"files-audio-island.now-playing\": \"Reproduciendo ahora\",\n  \"files-audio-island.pause\": \"Pausar\",\n  \"files-audio-island.play\": \"Reproducir\",\n  \"files-backend-error.base-directory-not-found\": \"No se encontró el directorio base\",\n  \"files-backend-error.cant-find-root\": \"No se pudo verificar la ruta del archivo\",\n  \"files-backend-error.destination-already-exists\": \"Ya existe un elemento con el mismo nombre en el destino\",\n  \"files-backend-error.destination-not-exist\": \"La carpeta de destino no existe\",\n  \"files-backend-error.does-not-exist\": \"El archivo o la carpeta no existe\",\n  \"files-backend-error.escapes-base\": \"La ruta está fuera del directorio permitido\",\n  \"files-backend-error.invalid-base\": \"La ruta no pertenece a un directorio válido\",\n  \"files-backend-error.invalid-filename\": \"El nombre del archivo no es válido\",\n  \"files-backend-error.invalid-path\": \"La ruta del archivo no es válida\",\n  \"files-backend-error.mkdir-failed\": \"No se pudo crear la carpeta\",\n  \"files-backend-error.move-failed\": \"No se pudo mover el elemento\",\n  \"files-backend-error.not-enough-space\": \"No hay suficiente espacio de almacenamiento disponible\",\n  \"files-backend-error.operation-not-allowed\": \"Esta operación no está permitida\",\n  \"files-backend-error.parent-not-directory\": \"La ruta padre no es una carpeta\",\n  \"files-backend-error.parent-not-exist\": \"La carpeta padre no existe\",\n  \"files-backend-error.path-not-absolute\": \"La ruta del archivo no es válida\",\n  \"files-backend-error.share-already-exists\": \"Esta carpeta ya está compartida\",\n  \"files-backend-error.share-name-generation-failed\": \"No se pudo generar un nombre único para la carpeta compartida\",\n  \"files-backend-error.source-not-exists\": \"El archivo o la carpeta de origen no existe\",\n  \"files-backend-error.subdir-of-self\": \"No se puede mover o copiar una carpeta dentro de sí misma\",\n  \"files-backend-error.trash-meta-not-exists\": \"No se pudo encontrar la ubicación original de este elemento\",\n  \"files-backend-error.unique-name-index-exceeded\": \"No se pudo generar un nombre único. Existen demasiados elementos con nombres similares\",\n  \"files-backend-error.upload-failed\": \"Subida fallida\",\n  \"files-collision.action.keep-both\": \"Conservar ambos\",\n  \"files-collision.action.replace\": \"Reemplazar\",\n  \"files-collision.action.skip\": \"Omitir\",\n  \"files-collision.destination.original-location\": \"su ubicación original\",\n  \"files-collision.message\": \"¿Quieres reemplazar el elemento existente o conservar ambos?\",\n  \"files-collision.title\": \"\\\"{{itemName}}\\\" ya existe en {{destinationName}}\",\n  \"files-download.confirm\": \"Descargar\",\n  \"files-download.description\": \"Files no puede abrir este tipo de archivo. ¿Quieres descargarlo?\",\n  \"files-download.title\": \"¿Descargar {{name}}?\",\n  \"files-empty-trash.confirm\": \"Vaciar\",\n  \"files-empty-trash.description\": \"¿Estás seguro de que quieres eliminar permanentemente todos los elementos de la papelera? No podrás deshacer esta acción.\",\n  \"files-empty-trash.title\": \"¿Vaciar la papelera?\",\n  \"files-empty.directory\": \"No hay elementos en esta carpeta\",\n  \"files-empty.network\": \"Sin dispositivos de red\",\n  \"files-empty.network-host-offline\": \"Dispositivo de red sin conexión\",\n  \"files-error.add-favorite\": \"No se pudo añadir a favoritos: {{message}}\",\n  \"files-error.add-share\": \"No se pudo compartir la carpeta: {{message}}\",\n  \"files-error.compress\": \"Compresión fallida: {{message}}\",\n  \"files-error.copy\": \"Copia fallida: {{message}}\",\n  \"files-error.create-folder\": \"No se pudo crear la carpeta: {{message}}\",\n  \"files-error.delete\": \"No se pudo eliminar: {{message}}\",\n  \"files-error.eject-disk\": \"No se pudo expulsar la unidad: {{message}}\",\n  \"files-error.empty-trash\": \"No se pudo vaciar la papelera: {{message}}\",\n  \"files-error.extract\": \"Extracción fallida: {{message}}\",\n  \"files-error.folder-already-exists\": \"Ya existe una carpeta con este nombre\",\n  \"files-error.move\": \"No se pudo mover: {{message}}\",\n  \"files-error.remove-favorite\": \"No se pudo quitar de favoritos: {{message}}\",\n  \"files-error.remove-share\": \"No se pudo eliminar la carpeta compartida: {{message}}\",\n  \"files-error.rename\": \"No se pudo renombrar: {{message}}\",\n  \"files-error.restore\": \"No se pudo restaurar: {{message}}\",\n  \"files-error.trash\": \"No se pudo mover a la papelera: {{message}}\",\n  \"files-error.upload\": \"No se pudo subir: {{message}}\",\n  \"files-error.upload-network-error\": \"La subida de {{name}} falló: ocurrió un error de red\",\n  \"files-extension-change.confirm\": \"Continuar\",\n  \"files-extension-change.description-add\": \"¿Estás seguro de que quieres cambiar la extensión de '{{fileName}}' a '{{extension}}'? Esto podría hacer que el archivo fuera ilegible.\",\n  \"files-extension-change.description-remove\": \"¿Estás seguro de que quieres quitar la extensión de '{{fileName}}'?\",\n  \"files-extension-change.title-add\": \"¿Cambiar la extensión a '{{extension}}'?\",\n  \"files-extension-change.title-remove\": \"¿Quitar la extensión?\",\n  \"files-external-storage.unsupported.description\": \"Tu unidad externa conectada no se puede usar en una Raspberry Pi debido a problemas de alimentación. El almacenamiento externo está disponible en Umbrel Home, Umbrel Pro y en todos los dispositivos x86 (Intel o AMD).\",\n  \"files-external-storage.unsupported.description-general\": \"El almacenamiento externo no está disponible en Raspberry Pi debido a problemas de energía. Está disponible en Umbrel Home, Umbrel Pro y en todos los dispositivos x86 (Intel o AMD).\",\n  \"files-external-storage.unsupported.title\": \"Almacenamiento externo no compatible\",\n  \"files-folder\": \"Carpeta\",\n  \"files-format.confirm\": \"Formatear\",\n  \"files-format.description\": \"El formateo borrará todos los datos de {{driveName}}. Esta acción no se puede deshacer.\",\n  \"files-format.description-unreadable\": \"umbrelOS no puede leer el contenido de {{driveName}}. Puedes formatearlo para usarlo con umbrelOS.\",\n  \"files-format.drive-label\": \"Nombre\",\n  \"files-format.error\": \"No se pudo formatear la unidad\",\n  \"files-format.exfat-description\": \"Máxima compatibilidad con Windows, macOS y Linux\",\n  \"files-format.ext4-description\": \"Mejor rendimiento con umbrelOS y Linux\",\n  \"files-format.filesystem\": \"Sistema de archivos\",\n  \"files-format.filesystem-label\": \"Formatear como\",\n  \"files-format.formatting\": \"Formateando...\",\n  \"files-format.title\": \"Formatear unidad\",\n  \"files-format.title-requires-format\": \"Se requiere formateo\",\n  \"files-formatting-island.formatting\": \"Formateando...\",\n  \"files-formatting-island.formatting-drives\": \"Formateando {{count}} unidades\",\n  \"files-listing.empty\": \"No hay elementos\",\n  \"files-listing.error\": \"Ocurrió un error\",\n  \"files-listing.item-count-truncated\": \"{{formattedCount}}+ elementos\",\n  \"files-listing.item-count_one\": \"{{formattedCount}} elemento\",\n  \"files-listing.item-count_other\": \"{{formattedCount}} elementos\",\n  \"files-listing.loading\": \"Cargando...\",\n  \"files-listing.no-such-file\": \"No existe tal archivo o carpeta\",\n  \"files-listing.selected-count\": \"{{selectedCount}} de {{totalCount}} seleccionados\",\n  \"files-listing.selected-count-truncated\": \"{{selectedCount}} de {{totalCount}}+ seleccionados\",\n  \"files-name-drawer.new-folder\": \"Nueva carpeta\",\n  \"files-name-drawer.new-folder-description\": \"Ingresa un nombre para la nueva carpeta.\",\n  \"files-name-drawer.new-folder-input\": \"Nombre de carpeta\",\n  \"files-name-drawer.rename-file\": \"Renombrar archivo\",\n  \"files-name-drawer.rename-file-description\": \"Ingresa un nuevo nombre para este archivo.\",\n  \"files-name-drawer.rename-file-input\": \"Nombre de archivo\",\n  \"files-name-drawer.rename-folder\": \"Renombrar carpeta\",\n  \"files-name-drawer.rename-folder-description\": \"Ingresa un nuevo nombre para esta carpeta.\",\n  \"files-name-drawer.rename-folder-input\": \"Nombre de carpeta\",\n  \"files-network-storage-error.add-share\": \"No se pudo añadir el recurso compartido de red: {{message}}\",\n  \"files-network-storage-error.discover-servers\": \"Error al descubrir dispositivos de red: {{message}}\",\n  \"files-network-storage-error.discover-shares\": \"Error al descubrir recursos compartidos de red: {{message}}\",\n  \"files-network-storage-error.remove-share\": \"No se pudo eliminar el recurso compartido de red: {{message}}\",\n  \"files-operations-island.copying\": \"Copiando \\\"{{from}}\\\" a \\\"{{to}}\\\"\",\n  \"files-operations-island.moving\": \"Moviendo \\\"{{from}}\\\" a \\\"{{to}}\\\"\",\n  \"files-operations-island.restoring\": \"Restaurando \\\"{{from}}\\\" en \\\"{{to}}\\\"\",\n  \"files-path.input-group\": \"Entrada de ruta\",\n  \"files-path.input-label\": \"Ruta actual\",\n  \"files-permanently-delete.confirm\": \"Eliminar permanentemente\",\n  \"files-permanently-delete.description-multiple\": \"¿Estás seguro de que quieres eliminar permanentemente estos {{count}} elementos? No podrás deshacer esta acción.\",\n  \"files-permanently-delete.description-single\": \"¿Estás seguro de que quieres eliminar permanentemente \\\"{{fileName}}\\\"? No podrás deshacer esta acción.\",\n  \"files-permanently-delete.title-multiple\": \"¿Eliminar permanentemente {{count}} elementos?\",\n  \"files-permanently-delete.title-single\": \"¿Eliminar permanentemente?\",\n  \"files-search.default\": \"Buscar archivos y carpetas\",\n  \"files-search.no-results\": \"No se encontraron resultados para \\\"{{query}}\\\"\",\n  \"files-search.placeholder\": \"Buscar\",\n  \"files-search.searching-label\": \"Buscando el Umbrel de {{name}}\",\n  \"files-share.home-description\": \"Accede a todos los archivos en \\\"{{homeDirectoryName}}\\\" desde otros dispositivos en tu red\",\n  \"files-share.home-title\": \"Compartir \\\"{{homeDirectoryName}}\\\" en la red\",\n  \"files-share.instructions.how-to-access\": \"Cómo acceder\",\n  \"files-share.instructions.ios.enter-password\": \"Ingresa <field>{{password}}</field> como contraseña.\",\n  \"files-share.instructions.ios.enter-server\": \"Ingresa <field>{{smbUrl}}</field> como dirección del servidor.\",\n  \"files-share.instructions.ios.enter-username\": \"Ingresa <field>{{username}}</field> como nombre de usuario.\",\n  \"files-share.instructions.ios.install-files\": \"Instala la aplicación \\\"Files\\\" desde App Store si no está instalada.\",\n  \"files-share.instructions.ios.tap-connect\": \"Toca \\\"Conectar\\\" para acceder.\",\n  \"files-share.instructions.ios.tap-dots\": \"Toca los tres puntos (...) en la parte superior derecha y selecciona \\\"Conectarse al servidor\\\".\",\n  \"files-share.instructions.macos.click-connect\": \"Haz clic en \\\"Conectar\\\" para acceder.\",\n  \"files-share.instructions.macos.enter-password\": \"Ingresa <field>{{password}}</field> como contraseña.\",\n  \"files-share.instructions.macos.enter-url\": \"Ingresa <field>{{smbUrl}}</field> y haz clic en Conectar.\",\n  \"files-share.instructions.macos.enter-username\": \"Ingresa <field>{{username}}</field> como nombre de usuario.\",\n  \"files-share.instructions.macos.open-finder\": \"Abre \\\"Finder\\\" y presiona ⌘ + K.\",\n  \"files-share.instructions.macos.select-registered\": \"Selecciona \\\"Usuario registrado\\\" cuando se te solicite.\",\n  \"files-share.instructions.macos.time-machine\": \"Cómo usarlo como ubicación de copia de seguridad de Time Machine\",\n  \"files-share.instructions.macos.time-machine.choose-encryption\": \"Elige entre respaldos cifrados o sin cifrar.\",\n  \"files-share.instructions.macos.time-machine.disk-limit\": \"En 'Límite de uso de disco', especifica la cantidad máxima de espacio que deseas asignar en tu Umbrel para las copias de seguridad de Time Machine y luego haz clic en \\\"Hecho\\\".\",\n  \"files-share.instructions.macos.time-machine.follow-steps\": \"Sigue los pasos anteriores y abre Configuración del Sistema en tu Mac.\",\n  \"files-share.instructions.macos.time-machine.go-settings\": \"Ve a Time Machine y haz clic en \\\"Agregar disco de respaldo...\\\".\",\n  \"files-share.instructions.macos.time-machine.select-disk\": \"Selecciona la carpeta y haz clic en \\\"Configurar disco...\\\".\",\n  \"files-share.instructions.umbrelos.backup.follow-onscreen\": \"Sigue los pasos guiados para configurar tus Backups.\",\n  \"files-share.instructions.umbrelos.backup.follow-then-go-to\": \"Sigue los pasos anteriores y luego ve a \\\"{{settings}}\\\" > \\\"{{backups}}\\\" en tu otro Umbrel.\",\n  \"files-share.instructions.umbrelos.backup.select-add\": \"Selecciona la opción para \\\"{{addUmbrelOrNas}}\\\".\",\n  \"files-share.instructions.umbrelos.backup.select-connected\": \"Selecciona este dispositivo Umbrel de la lista de dispositivos conectados.\",\n  \"files-share.instructions.umbrelos.backup.title\": \"Cómo usarlo como ubicación de copia de seguridad para tu otro Umbrel\",\n  \"files-share.instructions.umbrelos.cant-find-note\": \"¿No lo encuentras? Intenta seleccionar \\\"Agregar manualmente\\\" y usa las siguientes credenciales. Si aún no puedes agregarlo, asegúrate de que ambos dispositivos estén en la misma red.\",\n  \"files-share.instructions.umbrelos.enter-password\": \"Introduce <field>{{password}}</field> como contraseña.\",\n  \"files-share.instructions.umbrelos.enter-username\": \"Introduce <field>{{username}}</field> como nombre de usuario.\",\n  \"files-share.instructions.umbrelos.open-and-click\": \"En tu otro Umbrel, abre \\\"Files\\\" y haz clic en <plus/> junto a \\\"<deviceIcon/> {{deviceLabel}}\\\" en la barra lateral.\",\n  \"files-share.instructions.umbrelos.select-device\": \"Selecciona este dispositivo Umbrel de la lista de dispositivos detectados automáticamente en tu red.\",\n  \"files-share.instructions.umbrelos.select-sharename\": \"Selecciona \\\"{{sharename}}\\\" y haz clic para agregar el recurso compartido.\",\n  \"files-share.instructions.windows.enter-password\": \"Ingresa <field>{{password}}</field> como contraseña.\",\n  \"files-share.instructions.windows.enter-url\": \"Escribe <field>{{smbUrl}}</field> y presiona Enter.\",\n  \"files-share.instructions.windows.enter-username\": \"Ingresa <field>{{username}}</field> como nombre de usuario.\",\n  \"files-share.instructions.windows.open-run\": \"Presiona Windows + R para abrir el cuadro de diálogo Ejecutar.\",\n  \"files-share.instructions.windows.remember-credentials\": \"Marca \\\"Remember my credentials\\\" y haz clic en OK.\",\n  \"files-share.regular-description\": \"Comparte esta carpeta para acceder a ella desde otros dispositivos en tu red\",\n  \"files-share.regular-title\": \"Compartir carpeta en la red\",\n  \"files-share.toggle\": \"Compartir \\\"{{name}}\\\" en tu red\",\n  \"files-sidebar.apps\": \"Aplicaciones\",\n  \"files-sidebar.external-storage\": \"Almacenamiento externo\",\n  \"files-sidebar.favorites\": \"Favoritos\",\n  \"files-sidebar.home\": \"Inicio\",\n  \"files-sidebar.navigation\": \"Navegación de archivos\",\n  \"files-sidebar.network\": \"Red\",\n  \"files-sidebar.network-pathbar\": \"Dispositivos de red\",\n  \"files-sidebar.network-sidebar\": \"Dispositivos\",\n  \"files-sidebar.recents\": \"Recientes\",\n  \"files-sidebar.shared-folders\": \"Carpetas compartidas\",\n  \"files-sidebar.trash\": \"Papelera\",\n  \"files-sidebar.trash.open\": \"Abrir\",\n  \"files-sort.created\": \"Agregado\",\n  \"files-sort.modified\": \"Modificado\",\n  \"files-sort.name\": \"Nombre\",\n  \"files-sort.size\": \"Tamaño\",\n  \"files-sort.type\": \"Tipo\",\n  \"files-state.uploading\": \"Subiendo...\",\n  \"files-state.waiting\": \"Esperando...\",\n  \"files-type.3gp\": \"Vídeo 3GP\",\n  \"files-type.3gp2\": \"Vídeo 3GP2\",\n  \"files-type.7z\": \"Archivo 7Z\",\n  \"files-type.aac\": \"Audio AAC\",\n  \"files-type.ai\": \"Archivo de Illustrator\",\n  \"files-type.aiff\": \"Audio AIFF\",\n  \"files-type.au\": \"Audio AU\",\n  \"files-type.avi\": \"Vídeo AVI\",\n  \"files-type.avif\": \"Imagen AVIF\",\n  \"files-type.bmp\": \"Imagen BMP\",\n  \"files-type.bzip2\": \"Archivo BZIP2\",\n  \"files-type.caf\": \"Audio CAF\",\n  \"files-type.compressed\": \"Archivo comprimido\",\n  \"files-type.csv\": \"Archivo CSV\",\n  \"files-type.directory\": \"Carpeta\",\n  \"files-type.dmg\": \"Imagen de disco\",\n  \"files-type.dv\": \"Vídeo DV\",\n  \"files-type.epub\": \"eBook EPUB\",\n  \"files-type.excel\": \"Hoja de cálculo de Excel\",\n  \"files-type.exe\": \"Ejecutable de Windows\",\n  \"files-type.executable\": \"Ejecutable\",\n  \"files-type.external-drive\": \"Unidad\",\n  \"files-type.flac\": \"Audio FLAC\",\n  \"files-type.flv\": \"Vídeo FLV\",\n  \"files-type.gif\": \"Imagen GIF\",\n  \"files-type.gzip\": \"Archivo GZIP\",\n  \"files-type.heic\": \"Imagen HEIC\",\n  \"files-type.ico\": \"Imagen ICO\",\n  \"files-type.iso\": \"Imagen ISO\",\n  \"files-type.jpeg\": \"Imagen JPEG\",\n  \"files-type.keynote\": \"Presentación de Keynote\",\n  \"files-type.lzip\": \"Archivo LZIP\",\n  \"files-type.lzma\": \"Archivo LZMA\",\n  \"files-type.lzop\": \"Archivo LZOP\",\n  \"files-type.m3u\": \"Lista de reproducción M3U\",\n  \"files-type.m4a\": \"Audio M4A\",\n  \"files-type.m4v\": \"Vídeo M4V\",\n  \"files-type.midi\": \"Audio MIDI\",\n  \"files-type.mka\": \"Audio MKA\",\n  \"files-type.mkv\": \"Vídeo MKV\",\n  \"files-type.mng\": \"Vídeo MNG\",\n  \"files-type.mobi\": \"eBook MOBI\",\n  \"files-type.mp3\": \"Audio MP3\",\n  \"files-type.mp4\": \"Vídeo MP4\",\n  \"files-type.mp4-audio\": \"Audio MP4\",\n  \"files-type.mpeg\": \"Vídeo MPEG\",\n  \"files-type.mpeg-ts\": \"Flujo de transporte MPEG\",\n  \"files-type.network-drive\": \"Unidad de red\",\n  \"files-type.numbers\": \"Hoja de cálculo de Numbers\",\n  \"files-type.ogg\": \"Audio OGG\",\n  \"files-type.ogv\": \"Vídeo OGV\",\n  \"files-type.pages\": \"Documento de Pages\",\n  \"files-type.pdf\": \"Documento PDF\",\n  \"files-type.png\": \"Imagen PNG\",\n  \"files-type.powerpoint\": \"Presentación de PowerPoint\",\n  \"files-type.psd\": \"Documento de Photoshop\",\n  \"files-type.quicktime\": \"Vídeo QuickTime\",\n  \"files-type.rar\": \"Archivo RAR\",\n  \"files-type.sgi\": \"Vídeo SGI\",\n  \"files-type.svg\": \"Imagen SVG\",\n  \"files-type.tar\": \"Archivo TAR\",\n  \"files-type.tiff\": \"Imagen TIFF\",\n  \"files-type.ts\": \"Vídeo TS\",\n  \"files-type.txt\": \"Archivo de texto\",\n  \"files-type.umbrel-backup\": \"Umbrel Backup\",\n  \"files-type.wav\": \"Audio WAV\",\n  \"files-type.webm\": \"Vídeo WebM\",\n  \"files-type.webm-audio\": \"Audio WebM\",\n  \"files-type.webp\": \"Imagen WebP\",\n  \"files-type.wma\": \"Audio WMA\",\n  \"files-type.wmv\": \"Vídeo WMV\",\n  \"files-type.word\": \"Documento de Word\",\n  \"files-type.xz\": \"Archivo XZ\",\n  \"files-type.zip\": \"Archivo ZIP\",\n  \"files-upload-island.uploading-count\": \"Subiendo {{count}} elementos\",\n  \"files-view.icons\": \"Iconos\",\n  \"files-view.list\": \"Lista\",\n  \"files-view.sort-by\": \"Ordenar por\",\n  \"files-view.view-as\": \"Ver como\",\n  \"files-widgets.favorites.no-items-text\": \"Agrega una carpeta a Favoritos para verla aquí\",\n  \"files-widgets.recents.no-items-text\": \"No hay archivos recientes\",\n  \"generic-in\": \"en\",\n  \"hide-details\": \"Ocultar detalles\",\n  \"install-first.install-app\": \"Instalar {{app}}\",\n  \"install-first.title\": \"{{app}} requiere estas aplicaciones\",\n  \"install-your-first-app\": \"Instala tu primera aplicación\",\n  \"language\": \"Idioma\",\n  \"language-description\": \"Tu idioma preferido para umbrelOS\",\n  \"language.select-description\": \"Selecciona el idioma preferido para umbrelOS\",\n  \"live-usage\": \"Uso en vivo\",\n  \"loading\": \"Cargando\",\n  \"local-ip\": \"IP local\",\n  \"login-2fa.subtitle\": \"Ingresa el código 2FA mostrado en tu aplicación de autenticación\",\n  \"login-2fa.title\": \"Autenticar\",\n  \"login-with-umbrel.description\": \"Ingresa tu contraseña de Umbrel para abrir {{app}}\",\n  \"login-with-umbrel.title\": \"Iniciar sesión con Umbrel\",\n  \"login.password-label\": \"Contraseña\",\n  \"login.password.submit\": \"Iniciar sesión\",\n  \"login.subtitle\": \"Ingresa tu contraseña de Umbrel para iniciar sesión\",\n  \"login.title\": \"Bienvenido de nuevo\",\n  \"logout\": \"Cerrar sesión\",\n  \"logout-error-generic\": \"Error: Falló el cierre de sesión\",\n  \"logout.confirm.submit\": \"Cerrar sesión\",\n  \"logout.confirm.title\": \"¿Estás seguro de que quieres cerrar sesión?\",\n  \"memory\": \"Memoria\",\n  \"memory.low\": \"Memoria baja\",\n  \"migrate\": \"Migrar\",\n  \"migrate.callout\": \"No apagues tu Umbrel hasta que la migración se complete\",\n  \"migrate.failed.retry\": \"Reintentar\",\n  \"migrate.failed.title\": \"La migración falló\",\n  \"migrate.success.description\": \"Todas tus aplicaciones, datos de aplicaciones y detalles de cuenta han sido migrados a tu Umbrel Home.\",\n  \"migrate.success.title\": \"Migración exitosa\",\n  \"migration-assistant\": \"Asistente de migración\",\n  \"migration-assistant-description\": \"Transfiere todas tus aplicaciones y datos desde un Raspberry Pi a {{deviceName}}\",\n  \"migration-assistant-unsupported-device-description\": \"Migration Assistant actualmente admite transferir todos los datos y aplicaciones desde un Raspberry Pi con umbrelOS a Umbrel Home o Umbrel Pro. Abre Migration Assistant en tu Umbrel Home o Umbrel Pro para empezar.\",\n  \"migration-assistant.continue-migration.ready.submit\": \"Iniciar migración\",\n  \"migration-assistant.failed\": \"Algo no está bien...\",\n  \"migration-assistant.failed.retrying-message\": \"Reintentando...\",\n  \"migration-assistant.mobile.start-button\": \"Iniciar migración\",\n  \"migration-assistant.prep.body\": \"Prepararse para la migración\",\n  \"migration-assistant.prep.button-continue\": \"Continuar\",\n  \"migration-assistant.prep.callout\": \"Los datos en tu {{deviceName}}, si los hay, se eliminarán de forma permanente.\",\n  \"migration-assistant.prep.connect-disk-to-home\": \"Conecta el disco externo a cualquier puerto USB de tu {{deviceName}}.\",\n  \"migration-assistant.prep.prep-done-continue-message\": \"Una vez hecho, haz clic en '{{button}}' abajo.\",\n  \"migration-assistant.prep.shut-down-rpi\": \"Apaga tu Umbrel Raspberry Pi.\",\n  \"migration-assistant.ready.description\": \"Todos tus datos y aplicaciones están listos para ser migrados a tu {{deviceName}}\",\n  \"migration-assistant.ready.hint-header\": \"Cosas a tener en cuenta\",\n  \"migration-assistant.ready.hint-keep-pi-off.description\": \"Esto ayuda a prevenir problemas con aplicaciones como Lightning Node\",\n  \"migration-assistant.ready.hint-keep-pi-off.title\": \"Mantén tu Raspberry Pi apagado después de la actualización\",\n  \"migration-assistant.ready.hint-use-same-password.description\": \"Recuerda usar la contraseña de Umbrel de tu Raspberry Pi para iniciar sesión en tu {{deviceName}}\",\n  \"migration-assistant.ready.hint-use-same-password.title\": \"Usa la misma contraseña\",\n  \"migration-assistant.ready.title\": \"Todo listo para migrar!\",\n  \"mini-browser.default-title\": \"Seleccionar carpeta\",\n  \"mini-browser.empty-external\": \"Conecta una unidad externa para que aparezca aquí.\",\n  \"mini-browser.empty-network\": \"Añade un Umbrel o NAS para que aparezca aquí.\",\n  \"mini-browser.load-more\": \"Cargar más\",\n  \"mini-browser.load-more-in-folder\": \"Cargar más en {{name}}\",\n  \"mini-browser.loading-more\": \"Cargando más…\",\n  \"mini-browser.select\": \"Seleccionar\",\n  \"mini-browser.select-folder\": \"Seleccionar carpeta\",\n  \"name\": \"Nombre\",\n  \"nas\": \"NAS\",\n  \"no-forgot-password-message\": \"Si pierdes tu contraseña, no podrás iniciar sesión en tu Umbrel. Asegúrate de guardarla de forma segura.\",\n  \"no-results-found\": \"No se encontraron resultados\",\n  \"not-found-404\": \"Código de error: 404\",\n  \"not-found-404.back\": \"Atrás\",\n  \"not-found-404.home\": \"Ir a inicio\",\n  \"notifications.backups-failing-location.description\": \"Los backups automáticos en {{location}} han estado fallando. Comprueba la conexión y verifica la configuración de tus Backups.\",\n  \"notifications.backups-failing.description\": \"Los Backups automáticos han estado fallando. Revisa la ubicación de tus Backups y verifica tu configuración.\",\n  \"notifications.backups-failing.go-to-backups\": \"Ir a Backups\",\n  \"notifications.backups-failing.title\": \"No se han realizado Backups en las últimas 24 horas\",\n  \"notifications.cpu.too-hot\": \"Alta temperatura del CPU\",\n  \"notifications.memory.low\": \"La memoria de tu dispositivo está baja\",\n  \"notifications.new-version-available\": \"{{update}} está disponible para instalar\",\n  \"notifications.raid.issue.description\": \"Se ha detectado un problema de almacenamiento. Revisa Storage Manager para más detalles.\",\n  \"notifications.raid.issue.title\": \"Se necesita acción urgente\",\n  \"notifications.ssd.health.description\": \"Uno o más SSD podrían necesitar atención. Revisa Storage Manager para más detalles.\",\n  \"notifications.ssd.health.title\": \"Advertencia de salud del SSD\",\n  \"notifications.storage.full\": \"El almacenamiento de tu dispositivo está lleno\",\n  \"notifications.view\": \"Ver\",\n  \"ok\": \"OK\",\n  \"onboarding.account-created.by-clicking-button-you-agree\": \"Al hacer clic en 'Siguiente', aceptas los <linked>Términos de servicio de umbrelOS</linked>\",\n  \"onboarding.account-created.youre-all-set-name\": \"Todo listo, {{name}}.\",\n  \"onboarding.contact-support\": \"Soporte\",\n  \"onboarding.create-account\": \"Crear cuenta\",\n  \"onboarding.create-account.confirm-password.input-label\": \"Confirmar contraseña\",\n  \"onboarding.create-account.failed.name-required\": \"Se requiere nombre\",\n  \"onboarding.create-account.failed.passwords-dont-match\": \"Las contraseñas no coinciden\",\n  \"onboarding.create-account.name.input-placeholder\": \"Tu nombre\",\n  \"onboarding.create-account.password.input-label\": \"Contraseña\",\n  \"onboarding.create-account.submit\": \"Crear\",\n  \"onboarding.create-account.submitting\": \"Creando\",\n  \"onboarding.create-account.subtitle\": \"La información de tu cuenta se almacena solo en tu Umbrel. Asegúrate de hacer una copia de seguridad de tu contraseña de forma segura, ya que no hay forma de restablecerla.\",\n  \"onboarding.create-instead-long\": \"Crear cuenta nueva\",\n  \"onboarding.create-instead-short\": \"Nueva cuenta\",\n  \"onboarding.launch-umbrelos\": \"Iniciar umbrelOS\",\n  \"onboarding.raid.available-storage\": \"Almacenamiento disponible\",\n  \"onboarding.raid.change-drives-link\": \"¿Necesitas añadir o cambiar unidades?\",\n  \"onboarding.raid.configuring.subtitle\": \"Esto puede tardar unos minutos.\",\n  \"onboarding.raid.configuring.title\": \"Configurando tu almacenamiento\",\n  \"onboarding.raid.configuring.warning\": \"Por favor, no actualices esta página ni apagues tu Umbrel mientras se configura el almacenamiento.\",\n  \"onboarding.raid.continue\": \"Continuar\",\n  \"onboarding.raid.error.detection-instructions\": \"Apaga tu Umbrel Pro, comprueba que tus SSDs estén bien colocados y vuelve a intentarlo.\",\n  \"onboarding.raid.error.no-ssds-detected\": \"No se detectaron SSDs\",\n  \"onboarding.raid.error.no-ssds-instructions\": \"Apaga tu Umbrel Pro e inserta al menos un SSD para continuar.\",\n  \"onboarding.raid.failsafe\": \"FailSafe\",\n  \"onboarding.raid.failsafe.cant-enable\": \"Aún no se puede activar FailSafe\",\n  \"onboarding.raid.failsafe.enable\": \"Activar FailSafe\",\n  \"onboarding.raid.failsafe.mixed-sizes\": \"FailSafe está limitado por tu SSD más pequeño ({{smallest}}). El espacio extra en los SSD más grandes no puede usarse, dejando {{wasted}} inutilizable.\",\n  \"onboarding.raid.failsafe.protection-info-2ssds\": \"Se usa {{protection}} para la protección de datos. Añade otro SSD de {{smallest}} para aumentar el almacenamiento disponible a {{futureWith3}}, o añade dos más para {{futureWith4}}. Puedes añadir más SSDs en cualquier momento.\",\n  \"onboarding.raid.failsafe.protection-info-3ssds\": \"Se usa {{protection}} para la protección de datos. Añade otro SSD de {{smallest}} para aumentar el almacenamiento disponible a {{futureWith4}}. Puedes añadir más SSDs en cualquier momento.\",\n  \"onboarding.raid.failsafe.single-ssd-info\": \"Solo tienes un SSD. Añade al menos otro SSD de {{size}} para habilitar la protección FailSafe de tus datos. Puedes añadir más SSDs en cualquier momento.\",\n  \"onboarding.raid.failsafe.subtitle\": \"Tus datos estarán seguros si falla algún SSD\",\n  \"onboarding.raid.failsafe.tip\": \"Usa SSDs del mismo tamaño para obtener el máximo almacenamiento y cero espacio inutilizable.\",\n  \"onboarding.raid.failsafe.warning-now-only\": \"Con más de un SSD, FailSafe solo puede activarse durante la configuración inicial. No podrás activarlo después.\",\n  \"onboarding.raid.health-warning\": \"Esta unidad presenta problemas de salud\",\n  \"onboarding.raid.launching\": \"Iniciando...\",\n  \"onboarding.raid.no-ssds-alt\": \"No se encontraron SSDs\",\n  \"onboarding.raid.recommended\": \"Recomendado\",\n  \"onboarding.raid.scanning\": \"Revisando las ranuras de tus SSDs\",\n  \"onboarding.raid.scanning-alt\": \"Buscando SSDs\",\n  \"onboarding.raid.setup-failed.description-no-retry\": \"Apaga el dispositivo e inténtalo de nuevo.\",\n  \"onboarding.raid.setup-failed.description-retry\": \"Inténtalo de nuevo, o apaga el dispositivo para revisar tus unidades.\",\n  \"onboarding.raid.setup-failed.title\": \"Error en la configuración del almacenamiento\",\n  \"onboarding.raid.shutdown-dialog.description\": \"Para añadir o cambiar unidades, apaga Umbrel Pro. Cuando termines, enciéndelo de nuevo y continúa con la configuración.\",\n  \"onboarding.raid.shutdown-dialog.title\": \"¿Cambiar unidades?\",\n  \"onboarding.raid.ssd-in-slot\": \"Un <highlight>{{size}}</highlight> SSD en la <highlight>ranura {{slot}}</highlight>\",\n  \"onboarding.raid.ssd-label\": \"SSD {{number}}\",\n  \"onboarding.raid.ssd-tray-alt\": \"Bandeja SSD\",\n  \"onboarding.raid.ssds-found\": \"Se encontraron los siguientes SSDs en tu Umbrel Pro\",\n  \"onboarding.raid.storage\": \"Almacenamiento\",\n  \"onboarding.raid.storage-label\": \"Almacenamiento\",\n  \"onboarding.raid.success.storage-info\": \"Almacenamiento {{available}}\",\n  \"onboarding.raid.success.storage-info-failsafe\": \"Almacenamiento {{available}} · FailSafe {{failsafe}}\",\n  \"onboarding.raid.try-again\": \"Reintentar\",\n  \"onboarding.raid.wasted\": \"Inutilizable\",\n  \"onboarding.restore-long\": \"Restaurar mi Umbrel\",\n  \"onboarding.restore-short\": \"Restaurar\",\n  \"onboarding.start.continue\": \"Comenzar\",\n  \"onboarding.start.subtitle\": \"Tu servidor en la nube está listo para configurarse.\",\n  \"onboarding.start.title\": \"Bienvenido a umbrelOS\",\n  \"open\": \"Abrir\",\n  \"open-live-usage\": \"Abrir uso en vivo\",\n  \"password\": \"Contraseña\",\n  \"preferences\": \"Preferencias\",\n  \"raid-error.description\": \"Tu sistema de almacenamiento no pudo iniciarse correctamente. Revisa el estado de tus SSDs a continuación y sigue los pasos de solución. Si el problema persiste, puede que sea necesario reemplazar los SSDs afectados.\",\n  \"raid-error.factory-reset-dialog.description\": \"Esto borrará todos los datos de tu Umbrel Pro y lo restablecerá a los valores de fábrica. Esta acción no se puede deshacer.\",\n  \"raid-error.factory-reset-dialog.title\": \"¿Restablecer de fábrica?\",\n  \"raid-error.factory-reset-failed\": \"No se pudo restablecer de fábrica\",\n  \"raid-error.health-warning\": \"Advertencia de salud\",\n  \"raid-error.missing-ssd-multiple\": \"{{count}} SSDs no responden\",\n  \"raid-error.missing-ssd-one\": \"1 SSD no responde\",\n  \"raid-error.shutdown-dialog.description\": \"Apaga tu Umbrel Pro, asegúrate de que todos los SSDs están bien colocados en sus slots y vuelve a encenderlo.\",\n  \"raid-error.shutdown-dialog.title\": \"¿Apagar para comprobar las unidades?\",\n  \"raid-error.ssd-in-slot\": \"Un SSD de <highlight>{{size}}</highlight> en <highlight>Slot {{slot}}</highlight>\",\n  \"raid-error.step-check-connections.button\": \"Apagar\",\n  \"raid-error.step-check-connections.description\": \"Apaga el dispositivo y comprueba que todos los SSDs están correctamente colocados.\",\n  \"raid-error.step-check-connections.title\": \"Comprueba las conexiones de los SSDs\",\n  \"raid-error.step-factory-reset.button\": \"Restablecer de fábrica\",\n  \"raid-error.step-factory-reset.description\": \"Último recurso si nada más funciona. Esto borrará todos los datos.\",\n  \"raid-error.step-factory-reset.title\": \"Restablecer de fábrica\",\n  \"raid-error.step-restart.button\": \"Reiniciar\",\n  \"raid-error.step-restart.description\": \"Un primer paso rápido que suele ayudar\",\n  \"raid-error.step-restart.title\": \"Intenta reiniciar\",\n  \"raid-error.title\": \"Problema de almacenamiento detectado\",\n  \"read-less\": \"Leer menos\",\n  \"read-more\": \"Leer más\",\n  \"reconnect\": \"Reconectar\",\n  \"redirect.to-home\": \"Cargando...\",\n  \"redirect.to-login\": \"Cargando...\",\n  \"redirect.to-onboarding\": \"Cargando...\",\n  \"redirect.to-raid-error\": \"Cargando...\",\n  \"reload\": \"Recargar\",\n  \"remote-tor-access\": \"Acceso remoto Tor\",\n  \"reset\": \"Reiniciar\",\n  \"restart\": \"Reiniciar\",\n  \"restart.confirm.submit\": \"Reiniciar\",\n  \"restart.confirm.title\": \"¿Estás seguro de que quieres reiniciar tu Umbrel?\",\n  \"restart.restarting\": \"Reiniciando\",\n  \"restart.restarting-message\": \"Por favor, no refresques esta página ni apagues tu Umbrel mientras se reinicia.\",\n  \"rewind\": \"Rewind\",\n  \"rewind.files-as-of\": \"Tus archivos a fecha de\",\n  \"rewind.loading-snapshots\": \"Cargando instantáneas...\",\n  \"rewind.now\": \"Ahora\",\n  \"rewind.preflight.description\": \"Encuentra archivos y carpetas de tus copias de seguridad anteriores y recupéralos al presente.\",\n  \"rewind.preflight.enable-backups\": \"Configura Backups en Ajustes para empezar a usar Rewind\",\n  \"rewind.restore-complete\": \"Restauración completada\",\n  \"rewind.restore-error-description\": \"Por favor, inténtalo de nuevo.\",\n  \"rewind.restore-failed\": \"Restauración fallida\",\n  \"rewind.restore-running-description\": \"No cierres ni recargues esta página hasta que la restauración haya finalizado\",\n  \"rewind.restore-selected\": \"Restaurar seleccionados\",\n  \"rewind.restore-success-description\": \"Tus archivos se han restaurado\",\n  \"rewind.restoring\": \"Restaurando\",\n  \"rewind.snapshots-count_one\": \"{{count}} copia de seguridad desde\",\n  \"rewind.snapshots-count_other\": \"{{count}} copias de seguridad desde\",\n  \"search\": \"Buscar\",\n  \"settings\": \"Configuración\",\n  \"settings.app-store-preferences.title\": \"Preferencias de App Store\",\n  \"settings.contact-support\": \"¿Necesitas ayuda? <linked>Contacta con soporte.</linked>\",\n  \"settings.file-sharing\": \"Compartir archivos\",\n  \"settings.file-sharing.add-folder\": \"Añadir\",\n  \"settings.file-sharing.add-folder-title\": \"Selecciona una carpeta para compartir\",\n  \"settings.file-sharing.choice-entire-description\": \"Comparte todos los archivos de tu Umbrel\",\n  \"settings.file-sharing.choice-entire-title\": \"Todo\",\n  \"settings.file-sharing.choice-heading\": \"¿Qué quieres compartir?\",\n  \"settings.file-sharing.choice-specific-description\": \"Elige qué carpetas compartir\",\n  \"settings.file-sharing.choice-specific-title\": \"Carpetas específicas\",\n  \"settings.file-sharing.choice-subtitle\": \"Accede a tus archivos y carpetas al estilo Dropbox como carpetas de red en tu ordenador o teléfono\",\n  \"settings.file-sharing.configure\": \"Configurar\",\n  \"settings.file-sharing.description\": \"Accede a tus archivos al estilo Dropbox como una carpeta de red (SMB) desde otros dispositivos\",\n  \"settings.file-sharing.home-shared-note\": \"Toda tu carpeta \\\"{{homeDirectoryName}}\\\" está compartida. No es necesario compartir las carpetas individuales por separado.\",\n  \"settings.file-sharing.share-entire-home-dir\": \"Comparte toda tu carpeta Home\",\n  \"settings.file-sharing.share-entire-home-dir-description\": \"Accede a todos los archivos y carpetas en \\\"{{homeDirectoryName}}\\\" desde otros dispositivos en tu red\",\n  \"settings.file-sharing.shared-folders\": \"Carpetas compartidas\",\n  \"show-details\": \"Mostrar detalles\",\n  \"shut-down\": \"Apagar\",\n  \"shut-down.complete\": \"Apagado completo\",\n  \"shut-down.complete-text\": \"Ahora puedes desconectar tu dispositivo de la energía.\",\n  \"shut-down.confirm.submit\": \"Apagar\",\n  \"shut-down.confirm.title\": \"¿Estás seguro de que quieres apagar tu Umbrel?\",\n  \"shut-down.failed\": \"No pudimos apagar: {{message}}\",\n  \"shut-down.shutting-down\": \"Apagando\",\n  \"shut-down.shutting-down-message\": \"Por favor, no refresques esta página ni apagues tu Umbrel mientras se apaga.\",\n  \"software-update.callout\": \"Por favor, no refresques esta página ni apagues tu Umbrel mientras se actualiza.\",\n  \"software-update.check\": \"Buscar actualización\",\n  \"software-update.checking\": \"Buscando actualización...\",\n  \"software-update.current-running\": \"Estás en\",\n  \"software-update.failed\": \"Falló la actualización\",\n  \"software-update.failed-to-check\": \"Error al buscar actualizaciones\",\n  \"software-update.failed.retry\": \"Reintentar\",\n  \"software-update.install-now\": \"Instalar ahora\",\n  \"software-update.new-version\": \"Nueva versión {{name}} disponible para instalar\",\n  \"software-update.on-latest\": \"Estás en la última versión de umbrelOS\",\n  \"software-update.see-whats-new\": \"Ver <linked>novedades</linked>\",\n  \"software-update.title\": \"Actualización de software\",\n  \"software-update.updating-to\": \"Actualizando a {{name}}\",\n  \"software-update.view\": \"Ver\",\n  \"something-left\": \"{{left}} restantes\",\n  \"something-went-wrong\": \"⚠ Algo salió mal\",\n  \"start\": \"Iniciar\",\n  \"stop\": \"Detener\",\n  \"storage\": \"Almacenamiento\",\n  \"storage-manager\": \"Administrador de almacenamiento\",\n  \"storage-manager.add\": \"Agregar\",\n  \"storage-manager.add-to-raid.add-ssd\": \"Agregar SSD\",\n  \"storage-manager.add-to-raid.available\": \"Disponible:\",\n  \"storage-manager.add-to-raid.description\": \"Se ha detectado un nuevo SSD y está listo para ser agregado.\",\n  \"storage-manager.add-to-raid.enable-failsafe\": \"Activar FailSafe\",\n  \"storage-manager.add-to-raid.failed-add\": \"No se pudo agregar el SSD\",\n  \"storage-manager.add-to-raid.failed-enable-failsafe\": \"No se pudo activar FailSafe\",\n  \"storage-manager.add-to-raid.failsafe-label\": \"FailSafe:\",\n  \"storage-manager.add-to-raid.info-capacity-added\": \"Tu nuevo SSD de <highlight>{{size}}</highlight> se añadirá al almacenamiento disponible.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-available\": \"Tu nuevo SSD de <highlight>{{size}}</highlight> agregará <highlight>{{available}}</highlight> de almacenamiento disponible.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-both\": \"Tu nuevo SSD de <highlight>{{size}}</highlight> agregará <highlight>{{available}}</highlight> de almacenamiento disponible y <highlight>{{protection}}</highlight> para protección de datos.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only\": \"Tu nuevo SSD de <highlight>{{size}}</highlight> agregará <highlight>{{protection}}</highlight> para protección de datos.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only-full\": \"Tu nuevo SSD de <highlight>{{size}}</highlight> se usará completamente para protección de datos.\",\n  \"storage-manager.add-to-raid.info-data-safe\": \"Tus datos estarán seguros si falla cualquier SSD individual.\",\n  \"storage-manager.add-to-raid.info-no-protection\": \"Si un SSD falla, podrías perder tus datos.\",\n  \"storage-manager.add-to-raid.info-total-wasted\": \"<wasted>{{size}}</wasted> en total inutilizable debido a los diferentes tamaños de SSD.\",\n  \"storage-manager.add-to-raid.info-wasted\": \"<wasted>{{size}}</wasted> será inutilizable debido a los diferentes tamaños de SSD.\",\n  \"storage-manager.add-to-raid.recommended\": \"Recomendado\",\n  \"storage-manager.add-to-raid.recommended-inline\": \"(recomendado)\",\n  \"storage-manager.add-to-raid.restart-active-tasks\": \"Cualquier tarea activa será interrumpida\",\n  \"storage-manager.add-to-raid.restart-after\": \"Después del reinicio, la configuración de FailSafe se completará automáticamente y podrás reanudar el uso normal.\",\n  \"storage-manager.add-to-raid.restart-during\": \"Durante el reinicio:\",\n  \"storage-manager.add-to-raid.restart-intro\": \"Puedes seguir usando umbrelOS con normalidad durante este proceso. Sin embargo, al llegar al 50% de progreso tu Umbrel se reiniciará automáticamente.\",\n  \"storage-manager.add-to-raid.restart-required\": \"Se requiere reinicio del sistema\",\n  \"storage-manager.add-to-raid.restart-ui-inaccessible\": \"umbrelOS estará temporalmente inaccesible\",\n  \"storage-manager.add-to-raid.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD en <highlight>Slot {{slot}}</highlight>\",\n  \"storage-manager.add-to-raid.title\": \"Agregar SSD al almacenamiento\",\n  \"storage-manager.add-to-raid.too-small\": \"SSD demasiado pequeño\",\n  \"storage-manager.add-to-raid.too-small-description\": \"Este SSD ({{deviceSize}}) es más pequeño que el SSD más pequeño instalado actualmente ({{minSize}}). FailSafe requiere que todos los SSD sean al menos tan grandes como el SSD más pequeño en uso.\",\n  \"storage-manager.add-to-raid.understand-continue\": \"Entiendo, continuar\",\n  \"storage-manager.add-to-raid.warning-failsafe-now-only\": \"Tener más de un SSD significa que FailSafe solo puede activarse ahora. No podrás activarlo más tarde.\",\n  \"storage-manager.add-to-raid.wasted-label\": \"Inutilizable:\",\n  \"storage-manager.available-storage\": \"Almacenamiento disponible\",\n  \"storage-manager.description\": \"Ver el almacenamiento, el estado y la configuración de tus SSDs\",\n  \"storage-manager.empty\": \"Vacío\",\n  \"storage-manager.failsafe-transition-failed\": \"No se pudo activar FailSafe\",\n  \"storage-manager.for-failsafe\": \"Para FailSafe\",\n  \"storage-manager.health.checksum-errors\": \"Errores de checksum: {{count}}\",\n  \"storage-manager.health.critical\": \"Crítico\",\n  \"storage-manager.health.critical-threshold\": \"Umbral crítico\",\n  \"storage-manager.health.current-temperature\": \"Temperatura actual\",\n  \"storage-manager.health.estimated-life\": \"Vida útil estimada restante\",\n  \"storage-manager.health.general\": \"General\",\n  \"storage-manager.health.health-status\": \"Estado de salud\",\n  \"storage-manager.health.low\": \"Bajo\",\n  \"storage-manager.health.model-and-capacity\": \"Modelo y tamaño\",\n  \"storage-manager.health.overheating\": \"Sobrecalentamiento\",\n  \"storage-manager.health.raid-failed-advice\": \"Este SSD tiene un problema. Apaga tu Umbrel y comprueba la conexión del SSD. Si el problema persiste, puede que sea necesario reemplazar el SSD.\",\n  \"storage-manager.health.read-errors\": \"Errores de lectura: {{count}}\",\n  \"storage-manager.health.serial-number\": \"Número de serie\",\n  \"storage-manager.health.status-healthy\": \"Saludable\",\n  \"storage-manager.health.status-unhealthy\": \"Con problemas\",\n  \"storage-manager.health.status-unknown\": \"Desconocido\",\n  \"storage-manager.health.temperature\": \"Temperatura\",\n  \"storage-manager.health.title\": \"Estado de los SSD\",\n  \"storage-manager.health.warning-life-advice\": \"Considera reemplazar este SSD pronto.\",\n  \"storage-manager.health.warning-life-message\": \"Solo queda {{percent}}% de vida\",\n  \"storage-manager.health.warning-temp-advice\": \"Asegúrate de que tu Umbrel Pro tenga buena ventilación y que el SSD esté correctamente insertado.\",\n  \"storage-manager.health.warning-temp-critical\": \"La temperatura es crítica ({{temperature}})\",\n  \"storage-manager.health.warning-temp-overheating\": \"La unidad se está sobrecalentando ({{temperature}})\",\n  \"storage-manager.health.warning-threshold\": \"Umbral de advertencia\",\n  \"storage-manager.health.warning-unhealthy-advice\": \"Este SSD puede fallar pronto. Considera reemplazarlo.\",\n  \"storage-manager.health.warning-unhealthy-message\": \"Este SSD podría tener un problema\",\n  \"storage-manager.health.warnings\": \"Advertencias\",\n  \"storage-manager.health.wear\": \"Desgaste\",\n  \"storage-manager.health.write-errors\": \"Errores de escritura: {{count}}\",\n  \"storage-manager.install-ssd.description\": \"Añade más SSDs para ampliar tu almacenamiento\",\n  \"storage-manager.install-ssd.step-insert\": \"Inserta los nuevos SSDs en los slots vacíos\",\n  \"storage-manager.install-ssd.step-power-on\": \"Enciende tu {{deviceName}}\",\n  \"storage-manager.install-ssd.step-remove-bottom-cover\": \"Retira la cubierta magnética inferior\",\n  \"storage-manager.install-ssd.step-replace-bottom-cover\": \"Vuelve a colocar la tapa inferior\",\n  \"storage-manager.install-ssd.step-return\": \"Vuelve aquí para agregar los SSDs a tu almacenamiento\",\n  \"storage-manager.install-ssd.step-shut-down\": \"Apaga tu {{deviceName}}\",\n  \"storage-manager.install-ssd.title\": \"Instalación de SSDs\",\n  \"storage-manager.install-tips.image-alt\": \"Instrucciones de instalación del SSD\",\n  \"storage-manager.install-tips.instructions\": \"Para instalarlo, quita el tornillo de pulgar y desliza el SSD en la ranura en diagonal. Presiona el SSD hacia abajo hasta que repose sobre el soporte del tornillo y luego asegúralo con el tornillo de pulgar.\",\n  \"storage-manager.install-tips.toggle\": \"¿Olvidaste cómo insertar un SSD?\",\n  \"storage-manager.manage\": \"Administrar\",\n  \"storage-manager.missing-ssd-warning\": \"Parece faltar un SSD. Apaga tu Umbrel y comprueba que todos los SSD estén conectados. Si el problema continúa, puede que sea necesario reemplazar el SSD.\",\n  \"storage-manager.mode\": \"Modo\",\n  \"storage-manager.mode.failsafe\": \"FailSafe\",\n  \"storage-manager.mode.failsafe.description\": \"Mantiene tus datos seguros si un SSD falla. Si tus SSDs son de distinto tamaño, el espacio extra en los más grandes queda sin usar.\",\n  \"storage-manager.mode.failsafe.info-description\": \"FailSafe protege tus datos manteniendo copias en tus SSDs. Si falla cualquier SSD, tus datos permanecen seguros y se pueden restaurar cuando añadas un SSD de reemplazo.\",\n  \"storage-manager.mode.failsafe.info-title\": \"Acerca de FailSafe\",\n  \"storage-manager.mode.full-storage\": \"Almacenamiento completo\",\n  \"storage-manager.mode.full-storage.description\": \"Usa todo el espacio de tus SSDs conjuntamente. Si un SSD falla, podrías perder tus datos.\",\n  \"storage-manager.mode.full-storage.info-description\": \"Full Storage combina todos tus SSDs en un único gran espacio, ofreciéndote el máximo almacenamiento. Sin embargo, si algún SSD falla, todos tus datos se perderán.\",\n  \"storage-manager.mode.full-storage.info-title\": \"Acerca de Almacenamiento completo\",\n  \"storage-manager.mode.switch-from-failsafe-unavailable\": \"Cambiar de FailSafe a Full Storage requiere que hagas una copia de seguridad de tus datos, restablezcas el dispositivo a valores de fábrica y restaures desde la copia.\",\n  \"storage-manager.mode.switch-to-failsafe-unavailable\": \"Con varios SSDs en Full Storage, tus datos están repartidos entre todas las unidades. Cambiar a FailSafe requiere hacer copia de seguridad, restablecer a valores de fábrica y restaurar.\",\n  \"storage-manager.mode.why-cant-switch\": \"¿Por qué no puedo cambiar?\",\n  \"storage-manager.operation-in-progress.shutdown-description\": \"Es seguro apagar el dispositivo. La operación se pausará y se reanudará después del reinicio, pero debe finalizar antes de que puedas hacer otros cambios.\",\n  \"storage-manager.operation-in-progress.shutdown-title\": \"Tu almacenamiento se está actualizando\",\n  \"storage-manager.operation-in-progress.wait-description\": \"Por favor, espera a que la operación actual termine antes de hacer más cambios.\",\n  \"storage-manager.operation-in-progress.wait-title\": \"Tu almacenamiento se está actualizando\",\n  \"storage-manager.operation.adding-ssd\": \"Agregando SSD...\",\n  \"storage-manager.operation.enabling-failsafe\": \"Activando FailSafe...\",\n  \"storage-manager.operation.expanding\": \"Ampliando el almacenamiento...\",\n  \"storage-manager.operation.rebuilding\": \"Reconstruyendo datos...\",\n  \"storage-manager.operation.replacing\": \"Reemplazando la unidad...\",\n  \"storage-manager.operation.restarting\": \"Reiniciando...\",\n  \"storage-manager.operation.starting\": \"Iniciando...\",\n  \"storage-manager.operation.syncing-restarts\": \"Sincronizando datos • Se reinicia al 50%\",\n  \"storage-manager.raid-status.degraded\": \"Degradado\",\n  \"storage-manager.raid-status.failed\": \"Fallado\",\n  \"storage-manager.raid-status.offline\": \"Desconectado\",\n  \"storage-manager.raid-status.online\": \"En línea\",\n  \"storage-manager.raid-status.removed\": \"Retirado\",\n  \"storage-manager.raid-status.unavailable\": \"No disponible\",\n  \"storage-manager.replace\": \"Reemplazar\",\n  \"storage-manager.replace-failed.degraded\": \"Protección de FailSafe reducida\",\n  \"storage-manager.replace-failed.degraded-description\": \"Falta un SSD en tu almacenamiento FailSafe. Reemplázalo para restaurar la protección completa.\",\n  \"storage-manager.replace-failed.description\": \"Usa este SSD para restaurar la protección de FailSafe.\",\n  \"storage-manager.replace-failed.error\": \"No se pudo iniciar el reemplazo\",\n  \"storage-manager.replace-failed.replace-now\": \"Reemplazar ahora\",\n  \"storage-manager.replace-failed.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD en la ranura {{slot}}\",\n  \"storage-manager.replace-failed.step-protected\": \"Una vez completado, tus datos estarán completamente protegidos de nuevo\",\n  \"storage-manager.replace-failed.step-rebuild\": \"Los datos se reconstruirán en el nuevo SSD\",\n  \"storage-manager.replace-failed.step-time\": \"Esto puede tardar un tiempo, según la cantidad de datos que tengas\",\n  \"storage-manager.replace-failed.title\": \"Reemplazar SSD\",\n  \"storage-manager.replace-failed.too-small\": \"SSD demasiado pequeño\",\n  \"storage-manager.replace-failed.too-small-description\": \"Este SSD ({{deviceSize}}) es más pequeño que el mínimo requerido ({{minSize}}) para tu almacenamiento FailSafe.\",\n  \"storage-manager.replace-failed.what-happens\": \"Qué pasa a continuación:\",\n  \"storage-manager.ssd-failing\": \"Con fallos\",\n  \"storage-manager.swap\": \"Intercambiar\",\n  \"storage-manager.swap.data-erased-description\": \"El modo Full Storage no tiene protección de datos. Todos los datos en tu {{deviceName}} se borrarán durante el restablecimiento de fábrica. Asegúrate de hacer una copia de seguridad de todo primero.\",\n  \"storage-manager.swap.data-protected\": \"Tus datos están protegidos\",\n  \"storage-manager.swap.data-protected-description\": \"Con FailSafe activado, puedes cambiar cualquier SSD individual sin perder tus datos. No necesitas copia de seguridad.\",\n  \"storage-manager.swap.data-will-be-erased\": \"Los datos se borrarán\",\n  \"storage-manager.swap.description-failsafe\": \"Reemplaza una unidad en tu almacenamiento FailSafe.\",\n  \"storage-manager.swap.description-full-storage\": \"Reemplaza una unidad en tu configuración Full Storage.\",\n  \"storage-manager.swap.description-no-free-slot\": \"En Full Storage con todos los slots ocupados, intercambiar un SSD requiere un proceso completo de copia de seguridad y restauración.\",\n  \"storage-manager.swap.description-replace\": \"Migra tus datos a un nuevo SSD y luego retira el antiguo.\",\n  \"storage-manager.swap.failed-to-start\": \"No se pudo iniciar el reemplazo\",\n  \"storage-manager.swap.no-data-loss\": \"Sin pérdida de datos\",\n  \"storage-manager.swap.no-data-loss-description\": \"Tus datos se copiarán al nuevo SSD. Una vez completado, podrás retirar el antiguo de forma segura.\",\n  \"storage-manager.swap.safe-swap-available\": \"Intercambio seguro disponible\",\n  \"storage-manager.swap.safe-swap-description\": \"Como tienes un slot vacío, puedes agregar primero el nuevo SSD y migrar tus datos antes de retirar el antiguo. No se requiere copia de seguridad.\",\n  \"storage-manager.swap.select-new-ssd\": \"Selecciona el nuevo SSD a usar:\",\n  \"storage-manager.swap.ssd-in-slot\": \"{{size}} SSD en Slot {{slot}}\",\n  \"storage-manager.swap.step-backup\": \"Haz una copia de seguridad de tus datos\",\n  \"storage-manager.swap.step-backup-description\": \"Ve a Ajustes → Backups y crea una copia de seguridad de todos tus datos.\",\n  \"storage-manager.swap.step-data-copied\": \"Los datos se copiarán del SSD antiguo al nuevo\",\n  \"storage-manager.swap.step-factory-reset\": \"Restablecer de fábrica\",\n  \"storage-manager.swap.step-factory-reset-description\": \"Ve a Ajustes → Avanzado → Restablecer de fábrica para borrar tu {{deviceName}}.\",\n  \"storage-manager.swap.step-insert-new-ssd\": \"Inserta el nuevo SSD en un slot vacío\",\n  \"storage-manager.swap.step-may-take-while\": \"Esto puede tardar un tiempo, dependiendo de la cantidad de datos que tengas\",\n  \"storage-manager.swap.step-power-on\": \"Enciende tu {{deviceName}}\",\n  \"storage-manager.swap.step-remove-bottom-cover\": \"Retira la cubierta magnética inferior\",\n  \"storage-manager.swap.step-remove-old\": \"Una vez completado, apaga y retira {{ssd}}\",\n  \"storage-manager.swap.step-replace-bottom-cover\": \"Vuelve a colocar la cubierta inferior\",\n  \"storage-manager.swap.step-restore\": \"Restaura tus datos\",\n  \"storage-manager.swap.step-restore-description\": \"Ve a Ajustes → Backups y restaura desde tu copia de seguridad.\",\n  \"storage-manager.swap.step-return-to-storage-manager\": \"Vuelve al Administrador de almacenamiento para confirmar el intercambio y añadir el nuevo SSD a tu almacenamiento\",\n  \"storage-manager.swap.step-return-to-swap\": \"Vuelve al Administrador de almacenamiento y haz clic en \\\"Intercambiar\\\" nuevamente para iniciar el reemplazo\",\n  \"storage-manager.swap.step-setup-new-storage\": \"Configura tu nuevo almacenamiento\",\n  \"storage-manager.swap.step-setup-new-storage-description\": \"Enciende tu {{deviceName}} y completa el proceso de configuración con tu nuevo SSD.\",\n  \"storage-manager.swap.step-shut-down\": \"Apaga tu {{deviceName}}\",\n  \"storage-manager.swap.step-shut-down-and-swap\": \"Apaga y reemplaza {{ssd}}\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-other\": \"Apaga, abre tu dispositivo, reemplaza el SSD y vuelve a montar.\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-pro\": \"Apaga, retira la cubierta inferior, reemplaza el SSD y vuelve a colocar la cubierta.\",\n  \"storage-manager.swap.step-swap-ssd\": \"Intercambia {{ssd}} por uno nuevo del mismo tamaño\",\n  \"storage-manager.swap.too-small\": \"Demasiado pequeño (se requiere {{size}})\",\n  \"storage-manager.swap.what-happens-next\": \"Qué sucede a continuación:\",\n  \"storage-manager.total-capacity-added\": \"Capacidad total añadida\",\n  \"storage-manager.umbrel-pro\": \"Umbrel Pro\",\n  \"storage-manager.used\": \"Usado\",\n  \"storage-manager.wasted\": \"No utilizable\",\n  \"storage-manager.wasted-size\": \"{{size}} No utilizable\",\n  \"storage.full\": \"Almacenamiento lleno\",\n  \"storage.low\": \"Almacenamiento bajo\",\n  \"temperature\": \"Temperatura\",\n  \"temperature.dangerously-hot\": \"Muy caliente\",\n  \"temperature.nice\": \"Agradable\",\n  \"temperature.normal\": \"Normal\",\n  \"temperature.too-hot-suggestion\": \"Considera cambiar el entorno de tu dispositivo.\",\n  \"temperature.warm\": \"Cálido\",\n  \"terminal\": \"Terminal\",\n  \"terminal-description\": \"Ejecuta comandos personalizados en umbrelOS o dentro de una aplicación\",\n  \"terminal.app\": \"App\",\n  \"terminal.app-description\": \"Ejecuta comandos personalizados dentro de una app específica\",\n  \"terminal.umbrelos-description\": \"Ejecuta comandos personalizados en umbrelOS\",\n  \"tor-description\": \"Accede a tu Umbrel desde cualquier lugar usando un navegador Tor\",\n  \"tor-enabled-description\": \"Accede a tu Umbrel desde cualquier lugar usando un navegador Tor en la siguiente URL:\",\n  \"tor-error\": \"No pudimos actualizar la configuración de Tor: {{message}}\",\n  \"tor.disable.description\": \"Esto puede tardar unos minutos\",\n  \"tor.disable.progress\": \"Deshabilitando el acceso remoto mediante Tor\",\n  \"tor.enable.description\": \"Esto puede tardar unos minutos\",\n  \"tor.enable.mobile.switch-label\": \"Activar acceso remoto Tor\",\n  \"tor.hidden-service\": \"URL del servicio oculto de Tor\",\n  \"troubleshoot\": \"Solucionar problemas\",\n  \"troubleshoot-description\": \"Solucionar problemas de umbrelOS o una aplicación\",\n  \"troubleshoot-no-logs-yet\": \"Aún no hay registros\",\n  \"troubleshoot-pick-title\": \"Solucionar problemas\",\n  \"troubleshoot.app\": \"Aplicación\",\n  \"troubleshoot.app-description\": \"Ver registros de una aplicación instalada en tu Umbrel\",\n  \"troubleshoot.app-download\": \"Descargar registros de {{app}}\",\n  \"troubleshoot.share-with-umbrel-support\": \"Compartir con el soporte de Umbrel\",\n  \"troubleshoot.system-download\": \"Descargar {{label}}\",\n  \"troubleshoot.umbrelos-description\": \"Ver los registros de umbrelOS\",\n  \"troubleshoot.umbrelos-logs\": \"Registros de umbrelOS\",\n  \"trpc.backend-unavailable\": \"Error: Conexión con la API del sistema fallida\",\n  \"trpc.checking-backend\": \"Cargando...\",\n  \"try-again\": \"Intentar de nuevo\",\n  \"umbrel\": \"Umbrel\",\n  \"umbrelos\": \"umbrelOS\",\n  \"unknown\": \"Desconocido\",\n  \"unknown-app\": \"Aplicación desconocida\",\n  \"unknown-error\": \"Error desconocido\",\n  \"uptime\": \"Tiempo de actividad\",\n  \"url\": \"URL\",\n  \"wallpaper\": \"Fondo de pantalla\",\n  \"wallpaper-description\": \"Tu fondo de pantalla y tema de Umbrel\",\n  \"whats-new.continue\": \"Continuar\",\n  \"whats-new.feature-1.description\": \"Configura copias de seguridad automáticas y cifradas de todo tu Umbrel en un disco USB externo, un NAS o en otro Umbrel.\",\n  \"whats-new.feature-2.description\": \"Vuelve atrás en el tiempo para recuperar archivos y carpetas específicos de copias de seguridad anteriores.\",\n  \"whats-new.feature-3.description\": \"O restaura todo tu Umbrel, incluyendo todas tus aplicaciones, archivos y datos.\",\n  \"whats-new.feature-4.description\": \"Conecta un NAS u otro Umbrel y accede a su almacenamiento desde Files.\",\n  \"whats-new.feature-4.title\": \"Dispositivos de red\",\n  \"whats-new.feature-5.description\": \"Conecta discos USB externos (en Umbrel Home o en cualquier dispositivo Intel o AMD) y accede a ellos desde Files.\",\n  \"whats-new.feature-5.helper-text\": \"No es compatible con dispositivos Raspberry Pi debido a posibles problemas de energía.\",\n  \"whats-new.feature-5.title\": \"Almacenamiento externo\",\n  \"whats-new.next\": \"Siguiente\",\n  \"whats-new.title\": \"Novedades en {{version}}\",\n  \"widget.progress.in-progress\": \"En progreso\",\n  \"widgets.edit.select-up-to-3-widgets\": \"Selecciona hasta 3 widgets\",\n  \"widgets.install-an-app-before-using-widgets\": \"Instala una aplicación para comenzar a personalizar tu pantalla de inicio con widgets.\",\n  \"wifi\": \"Wi-Fi\",\n  \"wifi-connect-insecure-message\": \"Las redes abiertas pueden ser inseguras\",\n  \"wifi-connection-failed\": \"No se pudo conectar\",\n  \"wifi-dangerous-change-confirmation-description\": \"Cambiar la red Wi-Fi puede desconectarte de tu Umbrel. Para reconectar, asegúrate de que tanto tu Umbrel como el dispositivo desde el que accedes estén en la misma red.\",\n  \"wifi-dangerous-change-confirmation-title\": \"¿Estás seguro de que quieres cambiar la red Wi-Fi?\",\n  \"wifi-dangerous-disable-confirmation-description\": \"Desactivar el Wi-Fi puede desconectarte de tu Umbrel. Para reconectar, enchufa un cable Ethernet a tu Umbrel y asegúrate de que tanto tu Umbrel como el dispositivo desde el que accedes estén en la misma red.\",\n  \"wifi-dangerous-disable-confirmation-title\": \"¿Estás seguro de que quieres desactivar el Wi-Fi?\",\n  \"wifi-description\": \"Conecta tu dispositivo a una red Wi-Fi\",\n  \"wifi-description-long\": \"Tu dispositivo permanece conectado a tu Wi-Fi elegido, incluso si se quita el cable Ethernet, y se reconecta automáticamente al Wi-Fi al iniciar.\",\n  \"wifi-no-networks-message\": \"No se encontraron redes Wi-Fi\",\n  \"wifi-searching\": \"Buscando redes Wi-Fi...\",\n  \"wifi-unsupported-device-description\": \"El Wi-Fi no es compatible con este dispositivo. Esto puede deberse a un adaptador inalámbrico faltante o incompatible.\",\n  \"wifi-view-networks\": \"Ver redes\"\n}"
  },
  {
    "path": "packages/ui/public/locales/fr.json",
    "content": "{\n  \"2fa\": \"2FA\",\n  \"2fa-description\": \"Une seconde couche de sécurité pour votre connexion Umbrel et applications\",\n  \"2fa.disable.title\": \"Désactiver l'authentification à deux facteurs\",\n  \"2fa.enable.or-paste\": \"Ou collez le code suivant dans votre application d'authentification\",\n  \"2fa.enable.scan-this\": \"Scannez ce code QR avec une application d'authentification comme Google Authenticator ou Authy\",\n  \"2fa.enable.title\": \"Activer l'authentification à deux facteurs\",\n  \"2fa.enter-code\": \"Entrez le code affiché dans votre application d'authentification\",\n  \"account\": \"Compte\",\n  \"account-description\": \"Votre nom et mot de passe\",\n  \"advanced-settings\": \"Paramètres avancés\",\n  \"advanced-settings-description\": \"Terminal, Programme Beta umbrelOS, DNS Cloudflare, et plus\",\n  \"app-not-found\": \"Application non trouvée : {{app}}\",\n  \"app-only-over-tor\": \"{{app}} ne peut être utilisé que via Tor. Accédez à votre Umbrel depuis un navigateur Tor en utilisant votre URL d'accès à distance (Paramètres > Paramètres avancés > Accès distant via Tor) pour ouvrir cette application.\",\n  \"app-page.section.about\": \"À propos\",\n  \"app-page.section.credentials.title\": \"Identifiants par défaut\",\n  \"app-page.section.dependencies.n-alternatives\": \"Voir {{count}} alternatives\",\n  \"app-page.section.info.compatibility\": \"Compatibilité\",\n  \"app-page.section.info.compatibility-compatible\": \"Compatible\",\n  \"app-page.section.info.compatibility-not-compatible\": \"Non compatible\",\n  \"app-page.section.info.developer\": \"Développeur\",\n  \"app-page.section.info.source-code\": \"Code source\",\n  \"app-page.section.info.source-code.public\": \"Public\",\n  \"app-page.section.info.submitted-by\": \"Soumis par\",\n  \"app-page.section.info.support\": \"Obtenir de l'aide\",\n  \"app-page.section.info.title\": \"Infos\",\n  \"app-page.section.info.version\": \"Version\",\n  \"app-page.section.recommendations.title\": \"Vous pourriez aussi aimer\",\n  \"app-page.section.release-notes.title\": \"Nouveautés\",\n  \"app-page.section.release-notes.version\": \"Version {{version}}\",\n  \"app-page.section.requires\": \"Nécessite\",\n  \"app-picker.search\": \"Rechercher...\",\n  \"app-picker.select-app\": \"Sélectionner une application...\",\n  \"app-settings.connected-to\": \"{{appName}} est connecté à ces applications\",\n  \"app-settings.save-changes\": \"Enregistrer les modifications\",\n  \"app-settings.title\": \"Réglages\",\n  \"app-store.browse-category-apps\": \"Parcourir les applications {{category}}\",\n  \"app-store.category.ai\": \"IA\",\n  \"app-store.category.all\": \"Toutes les applications\",\n  \"app-store.category.automation\": \"Maison & Automatisation\",\n  \"app-store.category.bitcoin\": \"Bitcoin\",\n  \"app-store.category.crypto\": \"Crypto\",\n  \"app-store.category.developer\": \"Outils Développeur\",\n  \"app-store.category.discover\": \"Découvrir\",\n  \"app-store.category.files\": \"Fichiers & Productivité\",\n  \"app-store.category.finance\": \"Finance\",\n  \"app-store.category.media\": \"Médias\",\n  \"app-store.category.networking\": \"Réseautage\",\n  \"app-store.category.social\": \"Social\",\n  \"app-store.description\": \"Vos paramètres de mise à jour des applications\",\n  \"app-store.discover.temporarily-unavailable-description\": \"Parcourez les catégories ci-dessus ou utilisez la recherche pour trouver des apps\",\n  \"app-store.discover.temporarily-unavailable-title\": \"Contenu mis en avant momentanément indisponible\",\n  \"app-store.menu.community-app-stores\": \"App Stores Communautaires\",\n  \"app-store.search-apps\": \"Rechercher des applications\",\n  \"app-store.search.no-results\": \"Aucun résultat\",\n  \"app-store.search.results-for\": \"Résultats pour\",\n  \"app-store.title\": \"App Store\",\n  \"app-store.updates\": \"Mises à jour\",\n  \"app-updates.less\": \"moins\",\n  \"app-updates.more\": \"plus\",\n  \"app-updates.no-updates\": \"Toutes les applications sont à jour !\",\n  \"app-updates.update\": \"Mettre à jour\",\n  \"app-updates.update-all\": \"Mettre à jour tout\",\n  \"app-updates.updates-available-count_one\": \"{{count}} mise à jour disponible\",\n  \"app-updates.updates-available-count_other\": \"{{count}} mises à jour disponibles\",\n  \"app-updates.updating\": \"Mise à jour...\",\n  \"app.install\": \"Installer\",\n  \"app.installed\": \"Installé\",\n  \"app.installing\": \"Installation\",\n  \"app.offline\": \"Non en cours d'exécution\",\n  \"app.open\": \"Ouvrir\",\n  \"app.optimized-for-umbrel-home\": \"Optimisé pour Umbrel Home\",\n  \"app.os-update-required.confirm\": \"Vérifier les mises à jour d'umbrelOS\",\n  \"app.os-update-required.description\": \"{{appName}} nécessite umbrelOS {{version}} ou une version ultérieure\",\n  \"app.os-update-required.title\": \"Mettre à jour umbrelOS\",\n  \"app.restarting\": \"Redémarrage\",\n  \"app.starting\": \"Démarrage\",\n  \"app.stopping\": \"Arrêt\",\n  \"app.uninstall.confirm.description\": \"Toutes les données associées à {{app}} seront définitivement supprimées. Cette action est irréversible.\",\n  \"app.uninstall.confirm.submit\": \"Désinstaller\",\n  \"app.uninstall.confirm.title\": \"Désinstaller {{app}} ?\",\n  \"app.uninstall.deps.used-by.description_one\": \"Désinstallez {{firstAppToUninstall}} d'abord pour désinstaller {{app}}.\",\n  \"app.uninstall.deps.used-by.description_other\": \"Désinstallez ces applications d'abord pour désinstaller {{app}}.\",\n  \"app.uninstall.deps.used-by.title\": \"{{app}} est utilisé par\",\n  \"app.uninstalling\": \"Désinstallation\",\n  \"app.updating\": \"Mise à jour\",\n  \"app.view\": \"Voir\",\n  \"app_one\": \"application\",\n  \"app_other\": \"applications\",\n  \"apps.uninstall.failed-to-get-required-apps\": \"Échec de la récupération des applications requises\",\n  \"apps.uninstalled-all.success\": \"Toutes les applications désinstallées\",\n  \"auth.checking-backend-for-user\": \"Chargement...\",\n  \"auth.failed-checking-if-user-logged-in\": \"Erreur : Vérification de la connexion échouée\",\n  \"auth.failed-to-check-if-user-exists\": \"Erreur : Vérification de l'existence échouée\",\n  \"back\": \"Retour\",\n  \"backups\": \"Sauvegardes\",\n  \"backups-configure\": \"Configurer\",\n  \"backups-configure.add-backup-location\": \"Ajouter un emplacement de sauvegarde\",\n  \"backups-configure.available\": \"Disponible\",\n  \"backups-configure.awaiting-next-backup\": \"En attente de la prochaine sauvegarde automatique\",\n  \"backups-configure.back-up-now\": \"Sauvegarder maintenant\",\n  \"backups-configure.backing-up-now\": \"Sauvegarde en cours...\",\n  \"backups-configure.connected\": \"Connecté\",\n  \"backups-configure.connection\": \"Connexion\",\n  \"backups-configure.in-progress\": \"En cours\",\n  \"backups-configure.last-backup\": \"Dernière sauvegarde\",\n  \"backups-configure.locations\": \"Emplacements\",\n  \"backups-configure.no-backup-locations\": \"Ajoutez un emplacement de sauvegarde pour commencer à sauvegarder vos données\",\n  \"backups-configure.not-connected\": \"Non connecté\",\n  \"backups-configure.path\": \"Chemin\",\n  \"backups-configure.remove-backup-location\": \"Supprimer l'emplacement de sauvegarde\",\n  \"backups-configure.remove-backup-location-confirmation\": \"Êtes-vous sûr ?\",\n  \"backups-configure.remove-backup-location-confirmation-description\": \"Cela supprimera '{{device}}' de vos emplacements de sauvegarde. Vos sauvegardes existantes sur cet appareil ne seront pas supprimées, mais les sauvegardes automatiques cesseront.\",\n  \"backups-configure.status\": \"Statut\",\n  \"backups-configure.total-backups\": \"Total des Backups\",\n  \"backups-configure.used\": \"Utilisé\",\n  \"backups-configure.view\": \"Afficher\",\n  \"backups-description\": \"Sauvegardez vos fichiers, applications et données vers un autre Umbrel, un NAS ou un disque externe\",\n  \"backups-error.backup-not-found\": \"La sauvegarde est introuvable.\",\n  \"backups-error.generic\": \"Une erreur est survenue : {{details}}\",\n  \"backups-error.in-progress\": \"Une sauvegarde est déjà en cours. Patientez le temps qu'elle se termine.\",\n  \"backups-error.invalid-exclusion-path\": \"Seuls les fichiers et dossiers de votre répertoire personnel peuvent être exclus des sauvegardes.\",\n  \"backups-error.invalid-password\": \"Le mot de passe de chiffrement est incorrect.\",\n  \"backups-error.invalid-path\": \"L'emplacement sélectionné n'est pas valide pour les sauvegardes.\",\n  \"backups-error.mount-failed\": \"Impossible d'accéder à l'instantané de sauvegarde.\",\n  \"backups-error.mount-timeout\": \"Impossible d'accéder à l'instantané de sauvegarde. Réessayez ou vérifiez que le périphérique est bien connecté.\",\n  \"backups-error.not-enough-space\": \"Pas assez d'espace disponible sur le périphérique de sauvegarde.\",\n  \"backups-error.not-found\": \"La sauvegarde ou l'emplacement de sauvegarde est introuvable.\",\n  \"backups-error.repository-exists\": \"Un emplacement de sauvegarde existe déjà dans ce dossier.\",\n  \"backups-error.repository-not-found\": \"L'emplacement de sauvegarde est introuvable.\",\n  \"backups-exclusions.add\": \"Ajouter\",\n  \"backups-exclusions.app-paths-cannot-be-modified\": \"Ces fichiers/dossiers sont définis par le développeur de l'application et ne peuvent pas être modifiés :\",\n  \"backups-exclusions.app-paths-explanation\": \"Cette application exclut les données suivantes des sauvegardes. Ces chemins contiennent généralement des éléments non essentiels (comme des caches ou des logs recréables) ou des données qui pourraient poser problème si elles sont restaurées (par exemple des états d'application obsolètes pouvant entraîner des conflits ou des incohérences).\",\n  \"backups-exclusions.auto-excluded\": \"Exclu automatiquement\",\n  \"backups-exclusions.exclude-entire-app\": \"Exclure l'application entière\",\n  \"backups-exclusions.excluded-apps\": \"Applications exclues\",\n  \"backups-exclusions.files-and-folders\": \"Fichiers et dossiers exclus\",\n  \"backups-exclusions.no-excluded-apps\": \"Aucune application exclue\",\n  \"backups-exclusions.no-excluded-files-or-folders\": \"Aucun fichier ou dossier exclu\",\n  \"backups-exclusions.select-item-to-exclude\": \"Sélectionnez l'élément à exclure\",\n  \"backups-exclusions.stop-excluding\": \"Ne plus exclure\",\n  \"backups-floating-island.backing-up\": \"Sauvegarde en cours...\",\n  \"backups-floating-island.backing-up-to\": \"Sauvegarde de votre Umbrel...\",\n  \"backups-restore\": \"Restaurer\",\n  \"backups-restore-full\": \"Restauration complète\",\n  \"backups-restore-full-description\": \"Restaurer l'intégralité de votre Umbrel depuis une sauvegarde\",\n  \"backups-restore-header\": \"Restaurer votre Umbrel\",\n  \"backups-restore-pro.after-restore\": \"Après la restauration, votre compte temporaire sera remplacé par votre compte sauvegardé et ses données.\",\n  \"backups-restore-pro.step1\": \"Terminez la configuration en cliquant sur « Commencer » ci-dessous. Ce compte sera temporaire jusqu'à ce que vous restauriez votre compte sauvegardé.\",\n  \"backups-restore-pro.step2\": \"Une fois la configuration terminée, allez dans <0>Paramètres → Sauvegardes → Restaurer</0>\",\n  \"backups-restore-pro.step3\": \"Suivez les instructions de l'assistant de restauration.\",\n  \"backups-restore-pro.subtitle\": \"La restauration d'une sauvegarde sur Umbrel Pro demande quelques étapes supplémentaires\",\n  \"backups-restore.backup-date\": \"Date de la sauvegarde\",\n  \"backups-restore.backup-location\": \"Emplacement de sauvegarde\",\n  \"backups-restore.browse-cloud-subtitle\": \"Restaurer depuis Umbrel Private Cloud (bientôt disponible)\",\n  \"backups-restore.browse-cloud-title\": \"Umbrel Private Cloud\",\n  \"backups-restore.browse-external-subtitle\": \"Restaurer depuis un disque USB externe\",\n  \"backups-restore.browse-external-title\": \"Disque dur externe\",\n  \"backups-restore.browse-nas-or-external\": \"Parcourez un autre Umbrel, un NAS ou un disque externe pour restaurer une sauvegarde\",\n  \"backups-restore.browse-nas-subtitle\": \"Restaurer depuis un autre appareil Umbrel ou NAS sur votre réseau\",\n  \"backups-restore.browse-nas-title\": \"Un autre Umbrel ou NAS\",\n  \"backups-restore.choose\": \"Choisir\",\n  \"backups-restore.choose-backup-location\": \"Choisissez un emplacement de sauvegarde\",\n  \"backups-restore.connect-to-backup-location\": \"Se connecter à un emplacement de sauvegarde\",\n  \"backups-restore.encryption-password\": \"Mot de passe de chiffrement\",\n  \"backups-restore.encryption-password-description\": \"Saisissez le mot de passe de chiffrement que vous avez défini lorsque vous avez activé les sauvegardes\",\n  \"backups-restore.enter-password-to-confirm\": \"Entrez votre mot de passe Umbrel pour confirmer\",\n  \"backups-restore.final-confirmation\": \"Êtes-vous sûr ?\",\n  \"backups-restore.final-confirmation-description\": \"Restaurer à partir de cette sauvegarde remplacera vos applications et données umbrelOS actuelles par le contenu de la sauvegarde sélectionnée. Tous les fichiers, dossiers ou applications exclus de cette sauvegarde seront supprimés de votre Umbrel. Cette action est irréversible.\",\n  \"backups-restore.invalid-password\": \"Mot de passe invalide\",\n  \"backups-restore.last-backup\": \"Dernière sauvegarde : {{date}}\",\n  \"backups-restore.latest\": \"Dernière\",\n  \"backups-restore.no-backups-found\": \"Aucune sauvegarde trouvée\",\n  \"backups-restore.no-backups-yet\": \"Pas encore de sauvegardes\",\n  \"backups-restore.please-select-backup\": \"Veuillez sélectionner une sauvegarde\",\n  \"backups-restore.please-select-repository\": \"Veuillez sélectionner un dépôt\",\n  \"backups-restore.restore-from-nas-or-external\": \"Restaurer votre Umbrel à partir d'une sauvegarde sur un autre Umbrel, un NAS ou un disque externe\",\n  \"backups-restore.restore-from-unlisted\": \"Restaurer depuis un autre emplacement\",\n  \"backups-restore.restore-umbrel\": \"Restaurer Umbrel\",\n  \"backups-restore.restore-warning\": \"Restaurer à partir de cette sauvegarde remplacera vos applications et données umbrelOS actuelles par le contenu de la sauvegarde sélectionnée. Tous les fichiers, dossiers ou applications exclus de cette sauvegarde seront supprimés de votre Umbrel. Ouvrez <0>Rewind</0> si vous souhaitez restaurer des fichiers ou dossiers spécifiques à la place.\",\n  \"backups-restore.restoring-from\": \"Vous êtes sur le point de restaurer la sauvegarde suivante :\",\n  \"backups-restore.review-description\": \"La restauration configurera votre Umbrel avec le compte, les fichiers, les applications et les paramètres présents au moment de cette sauvegarde. Cela peut prendre un peu de temps. Une fois terminée, votre mot de passe de connexion sera celui que vous aviez utilisé lors de la création de la sauvegarde.\",\n  \"backups-restore.select-backup\": \"Sélectionnez une sauvegarde\",\n  \"backups-restore.select-backup-description\": \"Sélectionnez la sauvegarde à partir de laquelle vous souhaitez restaurer\",\n  \"backups-restore.select-backup-file\": \"Sélectionnez votre fichier de sauvegarde\",\n  \"backups-restore.select-backup-file-only\": \"Seul <bold>{{backupFileName}}</bold> peut être sélectionné\",\n  \"backups-restore.total-size\": \"Taille totale\",\n  \"backups-restore.unknown-date\": \"Date inconnue\",\n  \"backups-restore.unknown-repository\": \"Dépôt inconnu\",\n  \"backups-rewind\": \"Rewind\",\n  \"backups-rewind-description\": \"Remontez dans le temps pour restaurer certains fichiers et dossiers.\",\n  \"backups-rewind.start\": \"Démarrer Rewind\",\n  \"backups-setup\": \"Configurer\",\n  \"backups-setup-confirm\": \"Terminer la configuration\",\n  \"backups-setup-external-description\": \"Sauvegarder sur un disque USB externe\",\n  \"backups-setup-nas-or-umbrel-description\": \"Sauvegarder sur un autre Umbrel ou un périphérique NAS sur votre réseau\",\n  \"backups-setup-umbrel-or-nas\": \"Un autre Umbrel ou NAS\",\n  \"backups-setup-umbrel-private-cloud\": \"Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-cta\": \"Prolongez votre tranquillité d'esprit au-delà de votre domicile avec des <bold>sauvegardes chiffrées de bout en bout</bold> vers Umbrel Private Cloud.\",\n  \"backups-setup-umbrel-private-cloud-cta-link\": \"Obtenir l'accès anticipé\",\n  \"backups-setup-umbrel-private-cloud-description\": \"Sauvegardes chiffrées de bout en bout vers Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-subtitle\": \"Bientôt disponible\",\n  \"backups.add-umbrel-or-nas\": \"Ajouter Umbrel ou NAS\",\n  \"backups.all-apps-and-data-will-be-backed-up\": \"Toutes les applications et données seront sauvegardées\",\n  \"backups.apps-and-data\": \"Applications & données\",\n  \"backups.backup-location\": \"Emplacement de sauvegarde\",\n  \"backups.browse\": \"Parcourir\",\n  \"backups.choose-folder-within-device\": \"Choisissez un dossier dans <bold>{{device}}</bold> pour enregistrer vos sauvegardes\",\n  \"backups.confirm-password\": \"Confirmer le mot de passe\",\n  \"backups.copy\": \"Copier\",\n  \"backups.encryption\": \"Chiffrement\",\n  \"backups.encryption-password-warning\": \"Assurez-vous de conserver votre mot de passe de chiffrement en lieu sûr, par exemple dans un gestionnaire de mots de passe. Vous ne pourrez plus le voir ensuite, et vous en aurez besoin pour restaurer vos sauvegardes.\",\n  \"backups.exclude-from-backups\": \"Exclure des sauvegardes\",\n  \"backups.exclude-from-backups-description\": \"Exclure des fichiers, dossiers et applications spécifiques de vos sauvegardes.\",\n  \"backups.hide\": \"Masquer\",\n  \"backups.i-understand\": \"J'ai compris\",\n  \"backups.location\": \"Emplacement\",\n  \"backups.modals.already-in-use.description\": \"Cet emplacement de sauvegarde est déjà utilisé pour les sauvegardes de cet Umbrel.\",\n  \"backups.modals.already-in-use.manage\": \"Gérer dans Backups\",\n  \"backups.modals.already-in-use.title\": \"Emplacement de sauvegarde déjà utilisé\",\n  \"backups.modals.connect-existing.description\": \"Une sauvegarde Umbrel existe déjà à cet emplacement. Saisissez son mot de passe de chiffrement pour l'ajouter à cet Umbrel.\",\n  \"backups.modals.connect-existing.title\": \"Connecter une sauvegarde Umbrel existante\",\n  \"backups.no-external-drives-detected\": \"Aucun disque externe détecté\",\n  \"backups.no-password-set\": \"Aucun mot de passe défini\",\n  \"backups.password-is-set\": \"Mot de passe défini\",\n  \"backups.password-minimum-length\": \"Le mot de passe doit contenir au moins 8 caractères\",\n  \"backups.password-safety-warning\": \"Vos sauvegardes seront chiffrées avec ce mot de passe. Gardez-le en sécurité, car vous ne pourrez plus le voir ensuite, et vous en aurez besoin pour restaurer vos sauvegardes.\",\n  \"backups.passwords-do-not-match\": \"Les mots de passe ne correspondent pas\",\n  \"backups.please-choose-folder\": \"Veuillez choisir un dossier\",\n  \"backups.restore-failed.message\": \"Une erreur est survenue lors de la restauration de votre Umbrel. Vos applications et données actuelles n'ont pas été modifiées.\",\n  \"backups.restore-failed.retry\": \"Aller à la restauration\",\n  \"backups.restore-failed.title\": \"Échec de la restauration\",\n  \"backups.restoring\": \"Restauration de votre Umbrel\",\n  \"backups.restoring-completing\": \"Finalisation en cours. Votre Umbrel redémarrera sous peu...\",\n  \"backups.restoring-progress\": \"Restauré {{percent}}%\",\n  \"backups.restoring-time-remaining\": \"Il reste {{time}}\",\n  \"backups.restoring-warning\": \"Ne coupez pas l'alimentation de votre Umbrel et ne déconnectez pas l'emplacement de sauvegarde pendant la restauration\",\n  \"backups.review\": \"Vérifier et confirmer\",\n  \"backups.review-description\": \"Vérifiez les détails de votre sauvegarde et confirmez votre sélection\",\n  \"backups.scanning-for-external-drives\": \"Recherche de disques externes...\",\n  \"backups.schedule-description\": \"umbrelOS sauvegarde automatiquement vos données toutes les heures. Il conserve des sauvegardes horaires chiffrées pour les dernières 24 heures, des sauvegardes quotidiennes pour la semaine passée, des sauvegardes hebdomadaires pour le mois passé et des sauvegardes mensuelles pour l'année passée. Les sauvegardes de plus d'un an sont supprimées automatiquement.\",\n  \"backups.select-backup-folder\": \"Sélectionner le dossier de sauvegarde\",\n  \"backups.select-backup-folder-description\": \"Choisissez un dossier où vous souhaitez stocker vos sauvegardes.\",\n  \"backups.select-backup-location\": \"Sélectionner un emplacement de sauvegarde\",\n  \"backups.set-encryption-password\": \"Définir le mot de passe de chiffrement\",\n  \"backups.set-encryption-password-description\": \"Protégez vos sauvegardes par mot de passe. Cela garantit que vos données restent privées et ne peuvent être restaurées qu'avec ce mot de passe.\",\n  \"backups.show\": \"Afficher\",\n  \"backups.storage-capacity-warning\": \"{{device}} doit disposer d'un espace libre au moins égal au double de la taille de votre sauvegarde\",\n  \"backups.store-encryption-password-safely\": \"Conservez votre mot de passe de chiffrement en lieu sûr\",\n  \"beta-program\": \"Programme bêta d'umbrelOS\",\n  \"beta-program-description\": \"Optez pour recevoir des mises à jour bêta d'umbrelOS, accédez en avant-première à de nouvelles fonctionnalités et aidez-nous à les peaufiner en fournissant vos retours. Les mises à jour bêta peuvent être instables, et le dépannage peut nécessiter une familiarité avec le terminal.\",\n  \"cancel\": \"Annuler\",\n  \"change\": \"Changer\",\n  \"change-name\": \"Changer de nom\",\n  \"change-name.failed.name-required\": \"Le nom est requis\",\n  \"change-name.input-placeholder\": \"Votre nom\",\n  \"change-password\": \"Changer le mot de passe\",\n  \"change-password.callout\": \"Si vous perdez votre mot de passe, vous ne pourrez pas vous connecter à votre Umbrel. Assurez-vous de le sécuriser soigneusement.\",\n  \"change-password.current-password\": \"Mot de passe actuel\",\n  \"change-password.failed.current-required\": \"Le mot de passe actuel est requis\",\n  \"change-password.failed.min-length\": \"Le mot de passe doit comporter au moins {{characters}} caractères\",\n  \"change-password.failed.must-be-unique\": \"Le nouveau mot de passe doit être différent du mot de passe actuel\",\n  \"change-password.failed.new-required\": \"Un nouveau mot de passe est requis\",\n  \"change-password.failed.no-match\": \"Les mots de passe ne correspondent pas\",\n  \"change-password.failed.repeat-required\": \"Répétition du mot de passe requise\",\n  \"change-password.new-password\": \"Nouveau mot de passe\",\n  \"change-password.repeat-password\": \"Répéter le mot de passe\",\n  \"check-for-latest-version\": \"Vérifier la dernière mise à jour d'umbrelOS\",\n  \"clipboard.copied\": \"Copié\",\n  \"close\": \"Fermer\",\n  \"cmdk.change-wallpaper\": \"Changer de fond d'écran\",\n  \"cmdk.frequent-apps\": \"Fréquemment utilisé\",\n  \"cmdk.input-placeholder\": \"Rechercher des applications, paramètres ou actions\",\n  \"cmdk.live-usage\": \"Utilisation en direct\",\n  \"cmdk.restart-umbrel\": \"Redémarrer Umbrel\",\n  \"cmdk.shutdown-umbrel\": \"Éteindre Umbrel\",\n  \"cmdk.update-all-apps\": \"Mettre à jour toutes les applications\",\n  \"cmdk.widgets\": \"Widgets\",\n  \"community-app-store\": \"App Store Communautaire\",\n  \"community-app-store.add-error\": \"Impossible d'ajouter l'App Store : {{message}}\",\n  \"community-app-store.back-to-umbrel-app-store\": \"Retour à l'App Store Umbrel\",\n  \"community-app-store.open-button\": \"Ouvrir\",\n  \"community-app-store.remove-button\": \"Retirer\",\n  \"community-app-store.remove-error\": \"Impossible de supprimer l'App Store : {{message}}\",\n  \"community-app-stores.add-button\": \"Ajouter\",\n  \"community-app-stores.description\": \"Les App Stores Communautaires vous permettent d'installer des applications sur votre Umbrel qui peuvent ne pas être disponibles dans l'App Store officiel d'Umbrel. Ils facilitent également le test des versions bêta des applications Umbrel avant que les développeurs les publient sur l'App Store officiel d'Umbrel.\",\n  \"community-app-stores.learn-more\": \"En savoir plus\",\n  \"community-app-stores.warning\": \"Les App Stores Communautaires peuvent être créés par n'importe qui. Les applications publiées ne sont pas vérifiées ni approuvées par l'équipe de l'App Store officiel d'Umbrel et peuvent potentiellement être non sécurisées ou malveillantes. Soyez prudent et ajoutez uniquement des app stores de développeurs en qui vous avez confiance.\",\n  \"confirm\": \"Confirmer\",\n  \"connect\": \"Connecter\",\n  \"connecting\": \"Connexion...\",\n  \"connection-lost\": \"Connexion perdue\",\n  \"connection-lost-description\": \"Cela peut arriver lorsque l'onglet de votre navigateur est resté inactif, que votre connexion réseau a été interrompue ou que votre appareil est hors ligne.\",\n  \"continue\": \"Continuer\",\n  \"continue-to-log-in\": \"Continuer pour se connecter\",\n  \"cpu\": \"CPU\",\n  \"cpu-core-count\": \"{{cores}} threads\",\n  \"default-credentials.close\": \"Compris\",\n  \"default-credentials.description\": \"Voici les identifiants dont vous aurez besoin pour vous connecter à l'application.\",\n  \"default-credentials.dont-show-again\": \"Ne plus afficher\",\n  \"default-credentials.dont-show-again-notice\": \"Vous pouvez accéder à ces identifiants à tout moment dans le futur en cliquant avec le bouton droit sur l'icône de l'application.\",\n  \"default-credentials.open\": \"Ouvrir {{app}}\",\n  \"default-credentials.password\": \"Mot de passe par défaut\",\n  \"default-credentials.title\": \"Identifiants pour {{app}}\",\n  \"default-credentials.username\": \"Nom d'utilisateur par défaut\",\n  \"desktop.app.context.go-to-store-page\": \"Voir dans l'App Store\",\n  \"desktop.app.context.settings\": \"Réglages\",\n  \"desktop.app.context.show-default-credentials\": \"Afficher les identifiants par défaut\",\n  \"desktop.app.context.uninstall\": \"Désinstaller\",\n  \"desktop.context-menu.change-wallpaper\": \"Changer de fond d'écran\",\n  \"desktop.context-menu.edit-widgets\": \"Modifier les widgets\",\n  \"desktop.context-menu.logout\": \"Se déconnecter\",\n  \"desktop.greeting.afternoon\": \"Bon après-midi, {{name}}\",\n  \"desktop.greeting.evening\": \"Bonsoir, {{name}}\",\n  \"desktop.greeting.morning\": \"Bonjour, {{name}}\",\n  \"desktop.install-first.for-the-ai-enthusiast\": \"Pour Viber\",\n  \"desktop.install-first.for-the-bitcoiner\": \"Pour le Bitcoiner\",\n  \"desktop.install-first.for-the-self-hoster\": \"Pour l'auto-hébergement\",\n  \"desktop.install-first.for-the-streamer\": \"Pour le streamer\",\n  \"desktop.install-first.link-to-app-store\": \"Explorer plus dans l'App Store\",\n  \"desktop.not-enough-room\": \"Utilisez un écran plus grand pour voir vos applications.\",\n  \"device\": \"Appareil\",\n  \"device-info\": \"Infos sur l'appareil\",\n  \"device-info-description\": \"Informations sur votre appareil\",\n  \"device-info.device\": \"Appareil\",\n  \"device-info.model-number\": \"Numéro de modèle\",\n  \"device-info.serial-number\": \"Numéro de série\",\n  \"device-info.view-info\": \"Voir les informations\",\n  \"device-name.home-or-pro\": \"Umbrel Home ou Umbrel Pro\",\n  \"disable\": \"Désactiver\",\n  \"done\": \"Terminé\",\n  \"download-logs\": \"Télécharger les journaux\",\n  \"enabling-tor\": \"Activation de l'accès Tor à distance\",\n  \"external-dns\": \"DNS Cloudflare\",\n  \"external-dns-description\": \"Le DNS Cloudflare offre une meilleure fiabilité du réseau. Désactivez pour utiliser les paramètres DNS de votre routeur.\",\n  \"external-dns-error\": \"Impossible de mettre à jour le paramètre DNS : {{message}}\",\n  \"external-drive\": \"Disque externe\",\n  \"factory-reset\": \"Réinitialisation d'usine\",\n  \"factory-reset-description\": \"Effacez toutes vos données et applications, et rétablissez umbrelOS aux paramètres par défaut\",\n  \"factory-reset-failed\": \"Impossible de réinitialiser votre appareil : {{message}}\",\n  \"factory-reset.confirm.body\": \"Confirmez votre mot de passe pour réinitialiser\",\n  \"factory-reset.confirm.ethernet-required-warning\": \"Assurez-vous que votre appareil est connecté à votre routeur via un câble Ethernet (pas en Wi-Fi) et que vous y accédez depuis votre réseau local (par exemple, http://umbrel.local ou l'adresse IP locale de votre appareil).\",\n  \"factory-reset.confirm.submit\": \"Effacer tout et réinitialiser\",\n  \"factory-reset.confirm.submit-callout\": \"Cette action est irréversible.\",\n  \"factory-reset.rebooting.message\": \"Votre appareil va redémarrer et toutes les données seront effacées. Ne fermez pas cette page.\",\n  \"factory-reset.rebooting.status\": \"Réinitialisation...\",\n  \"factory-reset.rebooting.title\": \"Réinitialisation d'usine en cours\",\n  \"factory-reset.review.account-info\": \"Infos du compte et mot de passe\",\n  \"factory-reset.review.apps\": \"Applications\",\n  \"factory-reset.review.following-will-be-removed\": \"Les éléments suivants seront supprimés de votre appareil\",\n  \"factory-reset.review.installed-apps_one\": \"{{count}} application installée\",\n  \"factory-reset.review.installed-apps_other\": \"{{count}} applications installées\",\n  \"factory-reset.review.submit\": \"Continuer\",\n  \"factory-reset.review.total-data\": \"Données totales\",\n  \"files\": \"Files\",\n  \"files-action.add-favorite\": \"Ajouter aux favoris\",\n  \"files-action.add-network-device\": \"Ajouter un appareil\",\n  \"files-action.cancel-upload\": \"Annuler l’envoi\",\n  \"files-action.compress\": \"Compresser\",\n  \"files-action.copy\": \"Copier\",\n  \"files-action.cut\": \"Couper\",\n  \"files-action.delete\": \"Supprimer définitivement\",\n  \"files-action.download\": \"Télécharger\",\n  \"files-action.download-items\": \"Télécharger {{count}} éléments\",\n  \"files-action.drop-to-upload\": \"Déposez pour importer\",\n  \"files-action.eject-disk\": \"Éjecter\",\n  \"files-action.empty-trash\": \"Vider la corbeille\",\n  \"files-action.format-drive\": \"Formater\",\n  \"files-action.go-to-path\": \"Aller à...\",\n  \"files-action.new-folder\": \"Nouveau dossier\",\n  \"files-action.open\": \"Ouvrir\",\n  \"files-action.paste\": \"Coller\",\n  \"files-action.remove-favorite\": \"Retirer des favoris\",\n  \"files-action.remove-network-host\": \"Éjecter le lecteur réseau\",\n  \"files-action.remove-network-share\": \"Éjecter le partage réseau\",\n  \"files-action.rename\": \"Renommer\",\n  \"files-action.restore\": \"Restaurer\",\n  \"files-action.select\": \"Sélectionner\",\n  \"files-action.share\": \"Partager sur le réseau…\",\n  \"files-action.sharing\": \"Partage en cours…\",\n  \"files-action.show-in-folder\": \"Afficher dans le dossier parent\",\n  \"files-action.trash\": \"Corbeille\",\n  \"files-action.uncompress\": \"Décompresser\",\n  \"files-action.upload\": \"Importer\",\n  \"files-add-network-share.add-manually\": \"Ajouter manuellement\",\n  \"files-add-network-share.add-share\": \"Ajouter le partage\",\n  \"files-add-network-share.back\": \"Retour\",\n  \"files-add-network-share.continue\": \"Continuer\",\n  \"files-add-network-share.description\": \"Connecte-toi à un NAS ou à un autre lecteur partagé sur ton réseau pour y accéder depuis Fichiers.\",\n  \"files-add-network-share.discovering\": \"Recherche en cours...\",\n  \"files-add-network-share.enter-details-manually\": \"Entrez les détails du serveur\",\n  \"files-add-network-share.host-label\": \"Adresse du serveur\",\n  \"files-add-network-share.host-required\": \"L'adresse du serveur est requise\",\n  \"files-add-network-share.manual-share-help\": \"Entrez le nom exact du partage tel qu'il apparaît sur votre serveur\",\n  \"files-add-network-share.no-shares-found\": \"Aucun partage trouvé sur ce serveur\",\n  \"files-add-network-share.not-seeing-share\": \"Vous ne voyez pas votre partage ?\",\n  \"files-add-network-share.password-label\": \"Mot de passe\",\n  \"files-add-network-share.password-required\": \"Le mot de passe est requis\",\n  \"files-add-network-share.retrieving-shares\": \"Récupération des partages…\",\n  \"files-add-network-share.retry-discovery\": \"Analyser le réseau à nouveau\",\n  \"files-add-network-share.select-share\": \"Sélectionne un partage à ajouter\",\n  \"files-add-network-share.share-placeholder\": \"shared-documents\",\n  \"files-add-network-share.share-required\": \"Le partage est requis\",\n  \"files-add-network-share.title\": \"Ajouter un partage réseau\",\n  \"files-add-network-share.username-label\": \"Nom d'utilisateur\",\n  \"files-add-network-share.username-placeholder\": \"admin\",\n  \"files-add-network-share.username-required\": \"Le nom d'utilisateur est requis\",\n  \"files-audio-island.now-playing\": \"Lecture en cours\",\n  \"files-audio-island.pause\": \"Pause\",\n  \"files-audio-island.play\": \"Lire\",\n  \"files-backend-error.base-directory-not-found\": \"Impossible de trouver le répertoire de base\",\n  \"files-backend-error.cant-find-root\": \"Impossible de vérifier le chemin du fichier\",\n  \"files-backend-error.destination-already-exists\": \"Un élément portant le même nom existe déjà à la destination\",\n  \"files-backend-error.destination-not-exist\": \"Le dossier de destination n'existe pas\",\n  \"files-backend-error.does-not-exist\": \"Le fichier ou le dossier n'existe pas\",\n  \"files-backend-error.escapes-base\": \"Le chemin est en dehors du répertoire autorisé\",\n  \"files-backend-error.invalid-base\": \"Le chemin ne correspond pas à un répertoire valide\",\n  \"files-backend-error.invalid-filename\": \"Le nom de fichier n'est pas valide\",\n  \"files-backend-error.invalid-path\": \"Le chemin du fichier n'est pas valide\",\n  \"files-backend-error.mkdir-failed\": \"Impossible de créer le dossier\",\n  \"files-backend-error.move-failed\": \"Impossible de déplacer l'élément\",\n  \"files-backend-error.not-enough-space\": \"Espace de stockage insuffisant\",\n  \"files-backend-error.operation-not-allowed\": \"Cette opération n'est pas autorisée\",\n  \"files-backend-error.parent-not-directory\": \"Le chemin parent n'est pas un dossier\",\n  \"files-backend-error.parent-not-exist\": \"Le dossier parent n'existe pas\",\n  \"files-backend-error.path-not-absolute\": \"Le chemin du fichier n'est pas valide\",\n  \"files-backend-error.share-already-exists\": \"Ce dossier est déjà partagé\",\n  \"files-backend-error.share-name-generation-failed\": \"Impossible de générer un nom de partage unique\",\n  \"files-backend-error.source-not-exists\": \"Le fichier ou dossier source n'existe pas\",\n  \"files-backend-error.subdir-of-self\": \"Un dossier ne peut pas être déplacé ou copié dans lui-même\",\n  \"files-backend-error.trash-meta-not-exists\": \"Impossible de trouver l'emplacement d'origine de cet élément\",\n  \"files-backend-error.unique-name-index-exceeded\": \"Impossible de générer un nom unique : trop d'éléments ont des noms similaires\",\n  \"files-backend-error.upload-failed\": \"Échec du téléversement\",\n  \"files-collision.action.keep-both\": \"Conserver les deux\",\n  \"files-collision.action.replace\": \"Remplacer\",\n  \"files-collision.action.skip\": \"Ignorer\",\n  \"files-collision.destination.original-location\": \"son emplacement d’origine\",\n  \"files-collision.message\": \"Voulez-vous remplacer l’élément existant ou conserver les deux ?\",\n  \"files-collision.title\": \"\\\"{{itemName}}\\\" existe déjà dans {{destinationName}}\",\n  \"files-download.confirm\": \"Télécharger\",\n  \"files-download.description\": \"Files ne peut pas ouvrir ce type de fichier. Voulez-vous plutôt le télécharger ?\",\n  \"files-download.title\": \"Télécharger {{name}} ?\",\n  \"files-empty-trash.confirm\": \"Vider\",\n  \"files-empty-trash.description\": \"Êtes-vous sûr de vouloir supprimer définitivement tous les éléments de la corbeille ? Vous ne pourrez pas annuler cette action.\",\n  \"files-empty-trash.title\": \"Vider la corbeille ?\",\n  \"files-empty.directory\": \"Aucun élément dans ce dossier\",\n  \"files-empty.network\": \"Aucun périphérique réseau\",\n  \"files-empty.network-host-offline\": \"Appareil réseau hors ligne\",\n  \"files-error.add-favorite\": \"Impossible d'ajouter aux favoris : {{message}}\",\n  \"files-error.add-share\": \"Impossible de partager le dossier : {{message}}\",\n  \"files-error.compress\": \"Impossible de compresser : {{message}}\",\n  \"files-error.copy\": \"Impossible de copier : {{message}}\",\n  \"files-error.create-folder\": \"Impossible de créer le dossier : {{message}}\",\n  \"files-error.delete\": \"Impossible de supprimer : {{message}}\",\n  \"files-error.eject-disk\": \"Impossible d'éjecter le lecteur : {{message}}\",\n  \"files-error.empty-trash\": \"Impossible de vider la corbeille : {{message}}\",\n  \"files-error.extract\": \"Impossible d'extraire : {{message}}\",\n  \"files-error.folder-already-exists\": \"Un dossier portant ce nom existe déjà\",\n  \"files-error.move\": \"Impossible de déplacer : {{message}}\",\n  \"files-error.remove-favorite\": \"Impossible de retirer des favoris : {{message}}\",\n  \"files-error.remove-share\": \"Impossible de supprimer le dossier partagé : {{message}}\",\n  \"files-error.rename\": \"Impossible de renommer : {{message}}\",\n  \"files-error.restore\": \"Impossible de restaurer : {{message}}\",\n  \"files-error.trash\": \"Impossible de déplacer vers la corbeille : {{message}}\",\n  \"files-error.upload\": \"Échec du téléversement : {{message}}\",\n  \"files-error.upload-network-error\": \"Échec du téléversement de {{name}} : une erreur réseau est survenue\",\n  \"files-extension-change.confirm\": \"Continuer\",\n  \"files-extension-change.description-add\": \"Êtes-vous sûr de vouloir changer l’extension de « {{fileName}} » en « {{extension}} » ? Cela pourrait rendre le fichier illisible.\",\n  \"files-extension-change.description-remove\": \"Êtes-vous sûr de vouloir supprimer l’extension de « {{fileName}} » ?\",\n  \"files-extension-change.title-add\": \"Changer l’extension en « {{extension}} » ?\",\n  \"files-extension-change.title-remove\": \"Supprimer l’extension ?\",\n  \"files-external-storage.unsupported.description\": \"Le disque externe branché ne peut pas être utilisé sur un Raspberry Pi à cause de problèmes d'alimentation. Le stockage externe est disponible sur Umbrel Home, Umbrel Pro et tous les appareils x86 (Intel ou AMD).\",\n  \"files-external-storage.unsupported.description-general\": \"Le stockage externe n'est pas disponible sur Raspberry Pi à cause de problèmes d'alimentation. Le stockage externe est disponible sur Umbrel Home, Umbrel Pro et tous les appareils x86 (Intel ou AMD).\",\n  \"files-external-storage.unsupported.title\": \"Stockage externe non pris en charge\",\n  \"files-folder\": \"Dossier\",\n  \"files-format.confirm\": \"Formater\",\n  \"files-format.description\": \"Le formatage effacera toutes les données de {{driveName}}. Cette action est irréversible.\",\n  \"files-format.description-unreadable\": \"umbrelOS ne peut pas lire le contenu de {{driveName}}. Vous pouvez le formater pour l'utiliser avec umbrelOS.\",\n  \"files-format.drive-label\": \"Nom\",\n  \"files-format.error\": \"Impossible de formater le disque\",\n  \"files-format.exfat-description\": \"Compatibilité maximale avec Windows, macOS et Linux\",\n  \"files-format.ext4-description\": \"Meilleures performances avec umbrelOS et Linux\",\n  \"files-format.filesystem\": \"Système de fichiers\",\n  \"files-format.filesystem-label\": \"Formater en\",\n  \"files-format.formatting\": \"Formatage...\",\n  \"files-format.title\": \"Formater le disque\",\n  \"files-format.title-requires-format\": \"Format requis\",\n  \"files-formatting-island.formatting\": \"Formatage en cours...\",\n  \"files-formatting-island.formatting-drives\": \"Formatage de {{count}} lecteurs\",\n  \"files-listing.empty\": \"Aucun élément\",\n  \"files-listing.error\": \"Une erreur s'est produite\",\n  \"files-listing.item-count-truncated\": \"{{formattedCount}}+ éléments\",\n  \"files-listing.item-count_one\": \"{{formattedCount}} élément\",\n  \"files-listing.item-count_other\": \"{{formattedCount}} éléments\",\n  \"files-listing.loading\": \"Chargement...\",\n  \"files-listing.no-such-file\": \"Aucun fichier ou dossier de ce nom\",\n  \"files-listing.selected-count\": \"{{selectedCount}} sur {{totalCount}} sélectionnés\",\n  \"files-listing.selected-count-truncated\": \"{{selectedCount}} sur {{totalCount}}+ sélectionnés\",\n  \"files-name-drawer.new-folder\": \"Nouveau dossier\",\n  \"files-name-drawer.new-folder-description\": \"Entrez un nom pour le nouveau dossier.\",\n  \"files-name-drawer.new-folder-input\": \"Nom du dossier\",\n  \"files-name-drawer.rename-file\": \"Renommer le fichier\",\n  \"files-name-drawer.rename-file-description\": \"Entrez un nouveau nom pour ce fichier.\",\n  \"files-name-drawer.rename-file-input\": \"Nom du fichier\",\n  \"files-name-drawer.rename-folder\": \"Renommer le dossier\",\n  \"files-name-drawer.rename-folder-description\": \"Entrez un nouveau nom pour ce dossier.\",\n  \"files-name-drawer.rename-folder-input\": \"Nom du dossier\",\n  \"files-network-storage-error.add-share\": \"Impossible d'ajouter un partage réseau : {{message}}\",\n  \"files-network-storage-error.discover-servers\": \"Échec de la découverte des appareils réseau : {{message}}\",\n  \"files-network-storage-error.discover-shares\": \"Échec de la découverte des partages réseau : {{message}}\",\n  \"files-network-storage-error.remove-share\": \"Impossible de supprimer le partage réseau : {{message}}\",\n  \"files-operations-island.copying\": \"Copie de \\\"{{from}}\\\" vers \\\"{{to}}\\\"\",\n  \"files-operations-island.moving\": \"Déplacement de \\\"{{from}}\\\" vers \\\"{{to}}\\\"\",\n  \"files-operations-island.restoring\": \"Restauration de \\\"{{from}}\\\" vers \\\"{{to}}\\\"\",\n  \"files-path.input-group\": \"Saisie du chemin\",\n  \"files-path.input-label\": \"Chemin actuel\",\n  \"files-permanently-delete.confirm\": \"Supprimer définitivement\",\n  \"files-permanently-delete.description-multiple\": \"Êtes-vous sûr de vouloir supprimer définitivement ces {{count}} éléments ? Vous ne pourrez pas annuler cette action.\",\n  \"files-permanently-delete.description-single\": \"Êtes-vous sûr de vouloir supprimer définitivement « {{fileName}} » ? Vous ne pourrez pas annuler cette action.\",\n  \"files-permanently-delete.title-multiple\": \"Supprimer définitivement {{count}} éléments ?\",\n  \"files-permanently-delete.title-single\": \"Supprimer définitivement ?\",\n  \"files-search.default\": \"Rechercher des fichiers et des dossiers\",\n  \"files-search.no-results\": \"Aucun résultat trouvé pour \\\"{{query}}\\\"\",\n  \"files-search.placeholder\": \"Rechercher\",\n  \"files-search.searching-label\": \"Recherche de l'Umbrel de {{name}}\",\n  \"files-share.home-description\": \"Accédez à tous les fichiers de « {{homeDirectoryName}} » depuis d’autres appareils sur votre réseau\",\n  \"files-share.home-title\": \"Partager « {{homeDirectoryName}} » sur le réseau\",\n  \"files-share.instructions.how-to-access\": \"Comment y accéder\",\n  \"files-share.instructions.ios.enter-password\": \"Saisissez <field>{{password}}</field> comme mot de passe.\",\n  \"files-share.instructions.ios.enter-server\": \"Saisissez <field>{{smbUrl}}</field> comme adresse du serveur.\",\n  \"files-share.instructions.ios.enter-username\": \"Saisissez <field>{{username}}</field> comme nom d’utilisateur.\",\n  \"files-share.instructions.ios.install-files\": \"Installez l’app « Files » depuis l’App Store si elle n’est pas déjà installée.\",\n  \"files-share.instructions.ios.tap-connect\": \"Touchez « Connect » pour y accéder.\",\n  \"files-share.instructions.ios.tap-dots\": \"Touchez les trois points (...) en haut à droite et sélectionnez « Connect to Server ».\",\n  \"files-share.instructions.macos.click-connect\": \"Cliquez sur « Se connecter » pour y accéder.\",\n  \"files-share.instructions.macos.enter-password\": \"Saisissez <field>{{password}}</field> comme mot de passe.\",\n  \"files-share.instructions.macos.enter-url\": \"Saisissez <field>{{smbUrl}}</field> et cliquez sur « Connecter ».\",\n  \"files-share.instructions.macos.enter-username\": \"Saisissez <field>{{username}}</field> comme nom d’utilisateur.\",\n  \"files-share.instructions.macos.open-finder\": \"Ouvrez « Finder », puis appuyez sur ⌘ + K.\",\n  \"files-share.instructions.macos.select-registered\": \"Sélectionnez « Utilisateur enregistré » lorsqu’on vous le demande.\",\n  \"files-share.instructions.macos.time-machine\": \"Comment l'utiliser comme emplacement de sauvegarde pour Time Machine\",\n  \"files-share.instructions.macos.time-machine.choose-encryption\": \"Choisissez entre des sauvegardes chiffrées ou non chiffrées.\",\n  \"files-share.instructions.macos.time-machine.disk-limit\": \"Pour « Disk Usage Limit », définissez l’espace maximal à allouer sur votre Umbrel pour les sauvegardes Time Machine, puis cliquez sur « Done ».\",\n  \"files-share.instructions.macos.time-machine.follow-steps\": \"Suivez les étapes ci-dessus puis ouvrez Réglages Système sur votre Mac.\",\n  \"files-share.instructions.macos.time-machine.go-settings\": \"Accédez à Time Machine, puis cliquez sur « Add Backup Disk... ».\",\n  \"files-share.instructions.macos.time-machine.select-disk\": \"Sélectionnez le dossier et cliquez sur \\\"Configurer le disque...\\\".\",\n  \"files-share.instructions.umbrelos.backup.follow-onscreen\": \"Suivez les étapes guidées pour configurer votre sauvegarde.\",\n  \"files-share.instructions.umbrelos.backup.follow-then-go-to\": \"Suivez les étapes ci‑dessus, puis allez dans \\\"{{settings}}\\\" > \\\"{{backups}}\\\" sur votre autre Umbrel.\",\n  \"files-share.instructions.umbrelos.backup.select-add\": \"Sélectionnez l'option \\\"{{addUmbrelOrNas}}\\\".\",\n  \"files-share.instructions.umbrelos.backup.select-connected\": \"Sélectionnez cet appareil Umbrel dans la liste des appareils connectés.\",\n  \"files-share.instructions.umbrelos.backup.title\": \"Comment l'utiliser comme emplacement de sauvegarde pour votre autre Umbrel\",\n  \"files-share.instructions.umbrelos.cant-find-note\": \"Vous ne le trouvez pas ? Essayez de sélectionner \\\"Ajouter manuellement\\\" et utilisez les identifiants suivants. Si vous n'arrivez toujours pas à l'ajouter, vérifiez que vos deux appareils sont sur le même réseau.\",\n  \"files-share.instructions.umbrelos.enter-password\": \"Saisissez <field>{{password}}</field> comme mot de passe.\",\n  \"files-share.instructions.umbrelos.enter-username\": \"Saisissez <field>{{username}}</field> comme nom d'utilisateur.\",\n  \"files-share.instructions.umbrelos.open-and-click\": \"Sur votre autre Umbrel, ouvrez \\\"Files\\\" et cliquez sur <plus/> à côté de \\\"<deviceIcon/> {{deviceLabel}}\\\" dans la barre latérale.\",\n  \"files-share.instructions.umbrelos.select-device\": \"Sélectionnez cet appareil Umbrel dans la liste des appareils détectés automatiquement sur votre réseau.\",\n  \"files-share.instructions.umbrelos.select-sharename\": \"Sélectionnez \\\"{{sharename}}\\\" et cliquez pour ajouter le partage.\",\n  \"files-share.instructions.windows.enter-password\": \"Saisissez <field>{{password}}</field> comme mot de passe.\",\n  \"files-share.instructions.windows.enter-url\": \"Tapez <field>{{smbUrl}}</field> et appuyez sur Entrée.\",\n  \"files-share.instructions.windows.enter-username\": \"Saisissez <field>{{username}}</field> comme nom d’utilisateur.\",\n  \"files-share.instructions.windows.open-run\": \"Appuyez sur Windows + R pour ouvrir la boîte de dialogue « Exécuter ».\",\n  \"files-share.instructions.windows.remember-credentials\": \"Cochez « Remember my credentials » puis cliquez sur OK.\",\n  \"files-share.regular-description\": \"Partagez ce dossier pour y accéder depuis d’autres appareils sur votre réseau\",\n  \"files-share.regular-title\": \"Partager ce dossier sur le réseau\",\n  \"files-share.toggle\": \"Partager « {{name}} » sur votre réseau\",\n  \"files-sidebar.apps\": \"Applications\",\n  \"files-sidebar.external-storage\": \"Stockage externe\",\n  \"files-sidebar.favorites\": \"Favoris\",\n  \"files-sidebar.home\": \"Accueil\",\n  \"files-sidebar.navigation\": \"Navigation des fichiers\",\n  \"files-sidebar.network\": \"Réseau\",\n  \"files-sidebar.network-pathbar\": \"Périphériques réseau\",\n  \"files-sidebar.network-sidebar\": \"Périphériques\",\n  \"files-sidebar.recents\": \"Récents\",\n  \"files-sidebar.shared-folders\": \"Dossiers partagés\",\n  \"files-sidebar.trash\": \"Corbeille\",\n  \"files-sidebar.trash.open\": \"Ouvrir\",\n  \"files-sort.created\": \"Ajouté\",\n  \"files-sort.modified\": \"Modifié\",\n  \"files-sort.name\": \"Nom\",\n  \"files-sort.size\": \"Taille\",\n  \"files-sort.type\": \"Type\",\n  \"files-state.uploading\": \"Téléversement...\",\n  \"files-state.waiting\": \"En attente...\",\n  \"files-type.3gp\": \"Vidéo 3GP\",\n  \"files-type.3gp2\": \"Vidéo 3GP2\",\n  \"files-type.7z\": \"Archive 7Z\",\n  \"files-type.aac\": \"Audio AAC\",\n  \"files-type.ai\": \"Fichier Illustrator\",\n  \"files-type.aiff\": \"Audio AIFF\",\n  \"files-type.au\": \"Audio AU\",\n  \"files-type.avi\": \"Vidéo AVI\",\n  \"files-type.avif\": \"Image AVIF\",\n  \"files-type.bmp\": \"Image BMP\",\n  \"files-type.bzip2\": \"Archive BZIP2\",\n  \"files-type.caf\": \"Audio CAF\",\n  \"files-type.compressed\": \"Archive compressée\",\n  \"files-type.csv\": \"Fichier CSV\",\n  \"files-type.directory\": \"Dossier\",\n  \"files-type.dmg\": \"Image disque\",\n  \"files-type.dv\": \"Vidéo DV\",\n  \"files-type.epub\": \"Livre numérique EPUB\",\n  \"files-type.excel\": \"Feuille de calcul Excel\",\n  \"files-type.exe\": \"Exécutable Windows\",\n  \"files-type.executable\": \"Exécutable\",\n  \"files-type.external-drive\": \"Disque\",\n  \"files-type.flac\": \"Audio FLAC\",\n  \"files-type.flv\": \"Vidéo FLV\",\n  \"files-type.gif\": \"Image GIF\",\n  \"files-type.gzip\": \"Archive GZIP\",\n  \"files-type.heic\": \"Image HEIC\",\n  \"files-type.ico\": \"Image ICO\",\n  \"files-type.iso\": \"Image ISO\",\n  \"files-type.jpeg\": \"Image JPEG\",\n  \"files-type.keynote\": \"Présentation Keynote\",\n  \"files-type.lzip\": \"Archive LZIP\",\n  \"files-type.lzma\": \"Archive LZMA\",\n  \"files-type.lzop\": \"Archive LZOP\",\n  \"files-type.m3u\": \"Liste de lecture M3U\",\n  \"files-type.m4a\": \"Audio M4A\",\n  \"files-type.m4v\": \"Vidéo M4V\",\n  \"files-type.midi\": \"Audio MIDI\",\n  \"files-type.mka\": \"Audio MKA\",\n  \"files-type.mkv\": \"Vidéo MKV\",\n  \"files-type.mng\": \"Vidéo MNG\",\n  \"files-type.mobi\": \"Livre numérique MOBI\",\n  \"files-type.mp3\": \"Audio MP3\",\n  \"files-type.mp4\": \"Vidéo MP4\",\n  \"files-type.mp4-audio\": \"Audio MP4\",\n  \"files-type.mpeg\": \"Vidéo MPEG\",\n  \"files-type.mpeg-ts\": \"Flux de transport MPEG\",\n  \"files-type.network-drive\": \"Lecteur réseau\",\n  \"files-type.numbers\": \"Feuille de calcul Numbers\",\n  \"files-type.ogg\": \"Audio OGG\",\n  \"files-type.ogv\": \"Vidéo OGV\",\n  \"files-type.pages\": \"Document Pages\",\n  \"files-type.pdf\": \"Document PDF\",\n  \"files-type.png\": \"Image PNG\",\n  \"files-type.powerpoint\": \"Présentation PowerPoint\",\n  \"files-type.psd\": \"Document Photoshop\",\n  \"files-type.quicktime\": \"Vidéo QuickTime\",\n  \"files-type.rar\": \"Archive RAR\",\n  \"files-type.sgi\": \"Vidéo SGI\",\n  \"files-type.svg\": \"Image SVG\",\n  \"files-type.tar\": \"Archive TAR\",\n  \"files-type.tiff\": \"Image TIFF\",\n  \"files-type.ts\": \"Vidéo TS\",\n  \"files-type.txt\": \"Fichier texte\",\n  \"files-type.umbrel-backup\": \"Sauvegarde Umbrel\",\n  \"files-type.wav\": \"Audio WAV\",\n  \"files-type.webm\": \"Vidéo WebM\",\n  \"files-type.webm-audio\": \"Audio WebM\",\n  \"files-type.webp\": \"Image WebP\",\n  \"files-type.wma\": \"Audio WMA\",\n  \"files-type.wmv\": \"Vidéo WMV\",\n  \"files-type.word\": \"Document Word\",\n  \"files-type.xz\": \"Archive XZ\",\n  \"files-type.zip\": \"Archive ZIP\",\n  \"files-upload-island.uploading-count\": \"Importation de {{count}} éléments\",\n  \"files-view.icons\": \"Icônes\",\n  \"files-view.list\": \"Liste\",\n  \"files-view.sort-by\": \"Trier par\",\n  \"files-view.view-as\": \"Afficher sous forme de\",\n  \"files-widgets.favorites.no-items-text\": \"Ajoutez un dossier à vos favoris pour le voir ici\",\n  \"files-widgets.recents.no-items-text\": \"Aucun fichier récent\",\n  \"generic-in\": \"dans l’\",\n  \"hide-details\": \"Masquer les détails\",\n  \"install-first.install-app\": \"Installez {{app}}\",\n  \"install-first.title\": \"{{app}} nécessite ces applications\",\n  \"install-your-first-app\": \"Installez votre première application\",\n  \"language\": \"Langue\",\n  \"language-description\": \"Votre langue préférée pour umbrelOS\",\n  \"language.select-description\": \"Sélectionnez la langue préférée pour umbrelOS\",\n  \"live-usage\": \"Utilisation en direct\",\n  \"loading\": \"Chargement\",\n  \"local-ip\": \"IP locale\",\n  \"login-2fa.subtitle\": \"Entrez le code 2FA affiché dans votre application d'authentification\",\n  \"login-2fa.title\": \"Authentifiez-vous\",\n  \"login-with-umbrel.description\": \"Entrez votre mot de passe Umbrel pour ouvrir {{app}}\",\n  \"login-with-umbrel.title\": \"Se connecter avec Umbrel\",\n  \"login.password-label\": \"Mot de passe\",\n  \"login.password.submit\": \"Se connecter\",\n  \"login.subtitle\": \"Entrez votre mot de passe Umbrel pour vous connecter\",\n  \"login.title\": \"Bon retour\",\n  \"logout\": \"Se déconnecter\",\n  \"logout-error-generic\": \"Erreur : Échec de la déconnexion\",\n  \"logout.confirm.submit\": \"Se déconnecter\",\n  \"logout.confirm.title\": \"Êtes-vous sûr de vouloir vous déconnecter ?\",\n  \"memory\": \"Mémoire\",\n  \"memory.low\": \"Mémoire faible\",\n  \"migrate\": \"Migrer\",\n  \"migrate.callout\": \"Ne pas éteindre votre Umbrel jusqu'à ce que la migration soit terminée\",\n  \"migrate.failed.retry\": \"Réessayer\",\n  \"migrate.failed.title\": \"Échec de la migration\",\n  \"migrate.success.description\": \"Toutes vos applications, données d'application et détails de compte ont été migrés vers votre Umbrel Home.\",\n  \"migrate.success.title\": \"Migration réussie\",\n  \"migration-assistant\": \"Assistant de migration\",\n  \"migration-assistant-description\": \"Transférez toutes vos applications et données depuis un Raspberry Pi vers {{deviceName}}\",\n  \"migration-assistant-unsupported-device-description\": \"Migration Assistant prend actuellement en charge le transfert de toutes les données et applications d'un Raspberry Pi sous umbrelOS vers Umbrel Home ou Umbrel Pro. Ouvrez Migration Assistant sur votre Umbrel Home ou Umbrel Pro pour commencer.\",\n  \"migration-assistant.continue-migration.ready.submit\": \"Commencer la migration\",\n  \"migration-assistant.failed\": \"Quelque chose ne va pas...\",\n  \"migration-assistant.failed.retrying-message\": \"Nouvelle tentative...\",\n  \"migration-assistant.mobile.start-button\": \"Commencer la migration\",\n  \"migration-assistant.prep.body\": \"Préparation à la migration\",\n  \"migration-assistant.prep.button-continue\": \"Continuer\",\n  \"migration-assistant.prep.callout\": \"Les données de votre {{deviceName}}, le cas échéant, seront définitivement supprimées.\",\n  \"migration-assistant.prep.connect-disk-to-home\": \"Branchez son disque externe à n'importe quel port USB de votre {{deviceName}}.\",\n  \"migration-assistant.prep.prep-done-continue-message\": \"Une fois terminé, cliquez sur '{{button}}' ci-dessous.\",\n  \"migration-assistant.prep.shut-down-rpi\": \"Éteignez votre Umbrel Raspberry Pi.\",\n  \"migration-assistant.ready.description\": \"Toutes vos données et applications sont prêtes à être migrées vers votre {{deviceName}}\",\n  \"migration-assistant.ready.hint-header\": \"Choses à garder à l'esprit\",\n  \"migration-assistant.ready.hint-keep-pi-off.description\": \"Cela aide à éviter les problèmes avec des applications telles que Lightning Node\",\n  \"migration-assistant.ready.hint-keep-pi-off.title\": \"Gardez votre Raspberry Pi éteint après la mise à jour\",\n  \"migration-assistant.ready.hint-use-same-password.description\": \"N'oubliez pas d'utiliser le mot de passe Umbrel de votre Raspberry Pi pour vous connecter à votre {{deviceName}}\",\n  \"migration-assistant.ready.hint-use-same-password.title\": \"Utilisez le même mot de passe\",\n  \"migration-assistant.ready.title\": \"Tout est prêt pour la migration !\",\n  \"mini-browser.default-title\": \"Sélectionner un dossier\",\n  \"mini-browser.empty-external\": \"Connectez un disque externe pour qu'il apparaisse ici.\",\n  \"mini-browser.empty-network\": \"Ajoutez un Umbrel ou un NAS pour qu'il apparaisse ici.\",\n  \"mini-browser.load-more\": \"Charger plus\",\n  \"mini-browser.load-more-in-folder\": \"Charger plus dans {{name}}\",\n  \"mini-browser.loading-more\": \"Chargement…\",\n  \"mini-browser.select\": \"Sélectionner\",\n  \"mini-browser.select-folder\": \"Sélectionner un dossier\",\n  \"name\": \"Nom\",\n  \"nas\": \"NAS\",\n  \"no-forgot-password-message\": \"Si vous perdez votre mot de passe, vous ne pourrez pas vous connecter à votre Umbrel. Assurez-vous de le sécuriser soigneusement.\",\n  \"no-results-found\": \"Aucun résultat trouvé\",\n  \"not-found-404\": \"Code d'erreur : 404\",\n  \"not-found-404.back\": \"Retour\",\n  \"not-found-404.home\": \"Aller à l'accueil\",\n  \"notifications.backups-failing-location.description\": \"Les Backups automatiques vers {{location}} échouent. Vérifiez la connexion et revoyez vos paramètres de Backups.\",\n  \"notifications.backups-failing.description\": \"Les backups automatiques échouent. Vérifiez l'emplacement de vos backups et revoyez vos paramètres.\",\n  \"notifications.backups-failing.go-to-backups\": \"Aller à Backups\",\n  \"notifications.backups-failing.title\": \"Pas de Backups au cours des dernières 24 heures\",\n  \"notifications.cpu.too-hot\": \"Température élevée du CPU\",\n  \"notifications.memory.low\": \"La mémoire de votre appareil est faible\",\n  \"notifications.new-version-available\": \"{{update}} est maintenant disponible à l'installation\",\n  \"notifications.raid.issue.description\": \"Problème de stockage détecté. Vérifiez le Gestionnaire de stockage pour plus de détails.\",\n  \"notifications.raid.issue.title\": \"Action urgente requise\",\n  \"notifications.ssd.health.description\": \"Un ou plusieurs SSD peuvent nécessiter une intervention. Vérifiez le Gestionnaire de stockage pour plus de détails.\",\n  \"notifications.ssd.health.title\": \"Alerte : état du SSD\",\n  \"notifications.storage.full\": \"Le stockage de votre appareil est plein\",\n  \"notifications.view\": \"Voir\",\n  \"ok\": \"OK\",\n  \"onboarding.account-created.by-clicking-button-you-agree\": \"En cliquant sur 'Suivant', vous acceptez les <linked>conditions d'utilisation d'umbrelOS</linked>\",\n  \"onboarding.account-created.youre-all-set-name\": \"Tout est prêt, {{name}}.\",\n  \"onboarding.contact-support\": \"Support\",\n  \"onboarding.create-account\": \"Créer un compte\",\n  \"onboarding.create-account.confirm-password.input-label\": \"Confirmer le mot de passe\",\n  \"onboarding.create-account.failed.name-required\": \"Le nom est requis\",\n  \"onboarding.create-account.failed.passwords-dont-match\": \"Les mots de passe ne correspondent pas\",\n  \"onboarding.create-account.name.input-placeholder\": \"Votre nom\",\n  \"onboarding.create-account.password.input-label\": \"Mot de passe\",\n  \"onboarding.create-account.submit\": \"Créer\",\n  \"onboarding.create-account.submitting\": \"Création\",\n  \"onboarding.create-account.subtitle\": \"Les informations de votre compte sont stockées uniquement sur votre Umbrel. Assurez-vous de sauvegarder votre mot de passe en toute sécurité car il n'y a aucun moyen de le réinitialiser.\",\n  \"onboarding.create-instead-long\": \"Créer un nouveau compte\",\n  \"onboarding.create-instead-short\": \"Nouveau compte\",\n  \"onboarding.launch-umbrelos\": \"Lancer umbrelOS\",\n  \"onboarding.raid.available-storage\": \"Stockage disponible\",\n  \"onboarding.raid.change-drives-link\": \"Besoin d'ajouter ou de changer des disques ?\",\n  \"onboarding.raid.configuring.subtitle\": \"Cela peut prendre quelques minutes.\",\n  \"onboarding.raid.configuring.title\": \"Configuration de votre stockage\",\n  \"onboarding.raid.configuring.warning\": \"Veuillez ne pas actualiser cette page ni éteindre votre Umbrel pendant la configuration du stockage.\",\n  \"onboarding.raid.continue\": \"Continuer\",\n  \"onboarding.raid.error.detection-instructions\": \"Éteignez Umbrel Pro, vérifiez que vos SSD sont correctement insérés, puis réessayez.\",\n  \"onboarding.raid.error.no-ssds-detected\": \"Aucun SSD détecté\",\n  \"onboarding.raid.error.no-ssds-instructions\": \"Éteignez Umbrel Pro et insérez au moins un SSD pour continuer.\",\n  \"onboarding.raid.failsafe\": \"FailSafe\",\n  \"onboarding.raid.failsafe.cant-enable\": \"Impossible d'activer FailSafe pour le moment\",\n  \"onboarding.raid.failsafe.enable\": \"Activer FailSafe\",\n  \"onboarding.raid.failsafe.mixed-sizes\": \"FailSafe est limité par votre plus petit SSD ({{smallest}}). L'espace supplémentaire sur les SSD plus grands ne pourra pas être utilisé, laissant {{wasted}} d'espace inutilisable.\",\n  \"onboarding.raid.failsafe.protection-info-2ssds\": \"{{protection}} sert à protéger vos données. Ajoutez un autre SSD de {{smallest}} pour augmenter le stockage disponible à {{futureWith3}}, ou ajoutez-en deux de plus pour atteindre {{futureWith4}}. Vous pouvez ajouter d'autres SSD à tout moment.\",\n  \"onboarding.raid.failsafe.protection-info-3ssds\": \"{{protection}} sert à protéger vos données. Ajoutez un autre SSD de {{smallest}} pour augmenter le stockage disponible à {{futureWith4}}. Vous pouvez ajouter d'autres SSD à tout moment.\",\n  \"onboarding.raid.failsafe.single-ssd-info\": \"Vous n'avez qu'un seul SSD. Ajoutez au moins un autre SSD de {{size}} pour activer la protection FailSafe de vos données. Vous pouvez ajouter d'autres SSD à tout moment.\",\n  \"onboarding.raid.failsafe.subtitle\": \"Vos données restent protégées si un seul SSD tombe en panne\",\n  \"onboarding.raid.failsafe.tip\": \"Utilisez des SSD de même taille pour un stockage maximal et zéro espace inutilisable.\",\n  \"onboarding.raid.failsafe.warning-now-only\": \"Avec plus d'un SSD, FailSafe ne peut être activé que lors de la configuration initiale. Vous ne pourrez pas l'activer plus tard.\",\n  \"onboarding.raid.health-warning\": \"Ce disque signale des problèmes de santé\",\n  \"onboarding.raid.launching\": \"Lancement...\",\n  \"onboarding.raid.no-ssds-alt\": \"Aucun SSD trouvé\",\n  \"onboarding.raid.recommended\": \"Recommandé\",\n  \"onboarding.raid.scanning\": \"Vérification des emplacements SSD\",\n  \"onboarding.raid.scanning-alt\": \"Analyse des SSD\",\n  \"onboarding.raid.setup-failed.description-no-retry\": \"Veuillez éteindre l'appareil et réessayer.\",\n  \"onboarding.raid.setup-failed.description-retry\": \"Réessayez, ou éteignez l'appareil pour vérifier vos disques.\",\n  \"onboarding.raid.setup-failed.title\": \"Échec de la configuration du stockage\",\n  \"onboarding.raid.shutdown-dialog.description\": \"Pour ajouter ou remplacer des disques, éteignez Umbrel Pro. Une fois terminé, vous pourrez rallumer et poursuivre la configuration.\",\n  \"onboarding.raid.shutdown-dialog.title\": \"Changer les disques ?\",\n  \"onboarding.raid.ssd-in-slot\": \"Un SSD <highlight>{{size}}</highlight> dans l'<highlight>emplacement {{slot}}</highlight>\",\n  \"onboarding.raid.ssd-label\": \"SSD {{number}}\",\n  \"onboarding.raid.ssd-tray-alt\": \"Tiroir SSD\",\n  \"onboarding.raid.ssds-found\": \"Les SSD suivants ont été trouvés dans votre Umbrel Pro\",\n  \"onboarding.raid.storage\": \"Stockage\",\n  \"onboarding.raid.storage-label\": \"Stockage\",\n  \"onboarding.raid.success.storage-info\": \"Stockage {{available}}\",\n  \"onboarding.raid.success.storage-info-failsafe\": \"Stockage {{available}} · FailSafe {{failsafe}}\",\n  \"onboarding.raid.try-again\": \"Réessayer\",\n  \"onboarding.raid.wasted\": \"Inutilisable\",\n  \"onboarding.restore-long\": \"Restaurer mon Umbrel\",\n  \"onboarding.restore-short\": \"Restaurer\",\n  \"onboarding.start.continue\": \"C'est parti\",\n  \"onboarding.start.subtitle\": \"Votre serveur cloud domestique est prêt à être configuré.\",\n  \"onboarding.start.title\": \"Bienvenue sur umbrelOS\",\n  \"open\": \"Ouvrir\",\n  \"open-live-usage\": \"Ouvrir l'utilisation en direct\",\n  \"password\": \"Mot de passe\",\n  \"preferences\": \"Préférences\",\n  \"raid-error.description\": \"Votre système de stockage n'a pas pu démarrer correctement. Vérifiez ci‑dessous l'état de vos SSD et suivez les étapes de dépannage. Si le problème persiste, les SSD concernés devront peut‑être être remplacés.\",\n  \"raid-error.factory-reset-dialog.description\": \"Cela effacera toutes les données de votre Umbrel Pro et le rétablira aux paramètres d'usine. Cette opération est irréversible.\",\n  \"raid-error.factory-reset-dialog.title\": \"Réinitialisation d'usine ?\",\n  \"raid-error.factory-reset-failed\": \"Impossible de réinitialiser aux paramètres d'usine\",\n  \"raid-error.health-warning\": \"Avertissement de santé\",\n  \"raid-error.missing-ssd-multiple\": \"{{count}} SSD ne répondent pas\",\n  \"raid-error.missing-ssd-one\": \"1 SSD ne répond pas\",\n  \"raid-error.shutdown-dialog.description\": \"Éteignez votre Umbrel Pro, assurez-vous que tous les SSD sont correctement insérés dans leurs emplacements, puis rallumez-le.\",\n  \"raid-error.shutdown-dialog.title\": \"Éteindre pour vérifier les disques ?\",\n  \"raid-error.ssd-in-slot\": \"Un SSD <highlight>{{size}}</highlight> dans l'<highlight>emplacement {{slot}}</highlight>\",\n  \"raid-error.step-check-connections.button\": \"Éteindre\",\n  \"raid-error.step-check-connections.description\": \"Éteignez l'appareil et vérifiez que tous les SSD sont correctement en place.\",\n  \"raid-error.step-check-connections.title\": \"Vérifier les connexions des SSD\",\n  \"raid-error.step-factory-reset.button\": \"Réinitialiser\",\n  \"raid-error.step-factory-reset.description\": \"Dernier recours si rien d'autre ne fonctionne. Cela efface toutes les données.\",\n  \"raid-error.step-factory-reset.title\": \"Réinitialisation d'usine\",\n  \"raid-error.step-restart.button\": \"Redémarrer\",\n  \"raid-error.step-restart.description\": \"Une première étape rapide qui aide souvent\",\n  \"raid-error.step-restart.title\": \"Essayez de redémarrer\",\n  \"raid-error.title\": \"Problème de stockage détecté\",\n  \"read-less\": \"Lire moins\",\n  \"read-more\": \"Lire plus\",\n  \"reconnect\": \"Se reconnecter\",\n  \"redirect.to-home\": \"Chargement...\",\n  \"redirect.to-login\": \"Chargement...\",\n  \"redirect.to-onboarding\": \"Chargement...\",\n  \"redirect.to-raid-error\": \"Chargement...\",\n  \"reload\": \"Recharger\",\n  \"remote-tor-access\": \"Accès Tor à distance\",\n  \"reset\": \"Réinitialiser\",\n  \"restart\": \"Redémarrer\",\n  \"restart.confirm.submit\": \"Redémarrer\",\n  \"restart.confirm.title\": \"Êtes-vous sûr de vouloir redémarrer votre Umbrel ?\",\n  \"restart.restarting\": \"Redémarrage\",\n  \"restart.restarting-message\": \"Veuillez ne pas actualiser cette page ou éteindre votre Umbrel pendant le redémarrage.\",\n  \"rewind\": \"Rewind\",\n  \"rewind.files-as-of\": \"Vos fichiers au\",\n  \"rewind.loading-snapshots\": \"Chargement des instantanés...\",\n  \"rewind.now\": \"Maintenant\",\n  \"rewind.preflight.description\": \"Trouvez des fichiers et dossiers dans vos anciennes sauvegardes, et restaurez-les dans le présent.\",\n  \"rewind.preflight.enable-backups\": \"Configurez les Sauvegardes dans les Réglages pour commencer à utiliser Rewind\",\n  \"rewind.restore-complete\": \"Restauration terminée\",\n  \"rewind.restore-error-description\": \"Veuillez réessayer.\",\n  \"rewind.restore-failed\": \"Restaure échouée\",\n  \"rewind.restore-running-description\": \"Ne fermez pas et ne rafraîchissez pas cette page tant que la restauration n'est pas terminée\",\n  \"rewind.restore-selected\": \"Restaurer la sélection\",\n  \"rewind.restore-success-description\": \"Vos fichiers ont été restaurés\",\n  \"rewind.restoring\": \"Restauration en cours\",\n  \"rewind.snapshots-count_one\": \"{{count}} sauvegarde depuis\",\n  \"rewind.snapshots-count_other\": \"{{count}} sauvegardes depuis\",\n  \"search\": \"Recherche\",\n  \"settings\": \"Paramètres\",\n  \"settings.app-store-preferences.title\": \"Préférences de l'App Store\",\n  \"settings.contact-support\": \"Besoin d'aide ? <linked>Contactez le support.</linked>\",\n  \"settings.file-sharing\": \"Partage de fichiers\",\n  \"settings.file-sharing.add-folder\": \"Ajouter\",\n  \"settings.file-sharing.add-folder-title\": \"Sélectionnez un dossier à partager\",\n  \"settings.file-sharing.choice-entire-description\": \"Partager tous les fichiers sur votre Umbrel\",\n  \"settings.file-sharing.choice-entire-title\": \"Tout\",\n  \"settings.file-sharing.choice-heading\": \"Que voulez-vous partager ?\",\n  \"settings.file-sharing.choice-specific-description\": \"Choisissez les dossiers à partager\",\n  \"settings.file-sharing.choice-specific-title\": \"Dossiers spécifiques\",\n  \"settings.file-sharing.choice-subtitle\": \"Accédez à vos fichiers et dossiers à la manière de Dropbox, comme des dossiers réseau sur votre ordinateur ou votre téléphone\",\n  \"settings.file-sharing.configure\": \"Configurer\",\n  \"settings.file-sharing.description\": \"Accédez à vos fichiers à la manière de Dropbox, via un dossier réseau (SMB) depuis d'autres appareils\",\n  \"settings.file-sharing.home-shared-note\": \"Votre dossier \\\"{{homeDirectoryName}}\\\" est entièrement partagé. Les dossiers individuels n'ont pas besoin d'être partagés séparément.\",\n  \"settings.file-sharing.share-entire-home-dir\": \"Partager l'intégralité de votre dossier personnel\",\n  \"settings.file-sharing.share-entire-home-dir-description\": \"Accédez à tous les fichiers et dossiers de \\\"{{homeDirectoryName}}\\\" depuis d'autres appareils sur votre réseau\",\n  \"settings.file-sharing.shared-folders\": \"Dossiers partagés\",\n  \"show-details\": \"Afficher les détails\",\n  \"shut-down\": \"Éteindre\",\n  \"shut-down.complete\": \"Arrêt complet\",\n  \"shut-down.complete-text\": \"Vous pouvez maintenant débrancher votre appareil.\",\n  \"shut-down.confirm.submit\": \"Éteindre\",\n  \"shut-down.confirm.title\": \"Êtes-vous sûr de vouloir éteindre votre Umbrel ?\",\n  \"shut-down.failed\": \"Impossible d'arrêter : {{message}}\",\n  \"shut-down.shutting-down\": \"Arrêt en cours\",\n  \"shut-down.shutting-down-message\": \"Veuillez ne pas actualiser cette page ou éteindre votre Umbrel pendant l'arrêt.\",\n  \"software-update.callout\": \"Veuillez ne pas actualiser cette page ou éteindre votre Umbrel pendant la mise à jour.\",\n  \"software-update.check\": \"Vérifier la mise à jour\",\n  \"software-update.checking\": \"Recherche de mise à jour...\",\n  \"software-update.current-running\": \"Vous utilisez\",\n  \"software-update.failed\": \"Échec de la mise à jour\",\n  \"software-update.failed-to-check\": \"Échec de la vérification des mises à jour\",\n  \"software-update.failed.retry\": \"Réessayer\",\n  \"software-update.install-now\": \"Installer maintenant\",\n  \"software-update.new-version\": \"Nouvelle version d'{{name}} disponible à l'installation\",\n  \"software-update.on-latest\": \"Vous utilisez la dernière version d'umbrelOS\",\n  \"software-update.see-whats-new\": \"Voir <linked>les nouveautés</linked>\",\n  \"software-update.title\": \"Mise à jour logicielle\",\n  \"software-update.updating-to\": \"Mise à jour vers {{name}}\",\n  \"software-update.view\": \"Voir\",\n  \"something-left\": \"{{left}} restant\",\n  \"something-went-wrong\": \"⚠ Un problème est survenu\",\n  \"start\": \"Démarrer\",\n  \"stop\": \"Arrêter\",\n  \"storage\": \"Stockage\",\n  \"storage-manager\": \"Gestionnaire de stockage\",\n  \"storage-manager.add\": \"Ajouter\",\n  \"storage-manager.add-to-raid.add-ssd\": \"Ajouter un SSD\",\n  \"storage-manager.add-to-raid.available\": \"Disponible :\",\n  \"storage-manager.add-to-raid.description\": \"Un nouveau SSD a été détecté et est prêt à être ajouté.\",\n  \"storage-manager.add-to-raid.enable-failsafe\": \"Activer FailSafe\",\n  \"storage-manager.add-to-raid.failed-add\": \"Impossible d'ajouter le SSD\",\n  \"storage-manager.add-to-raid.failed-enable-failsafe\": \"Impossible d'activer FailSafe\",\n  \"storage-manager.add-to-raid.failsafe-label\": \"FailSafe :\",\n  \"storage-manager.add-to-raid.info-capacity-added\": \"Votre nouveau <highlight>{{size}}</highlight> SSD sera ajouté à l'espace disponible.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-available\": \"Votre nouveau <highlight>{{size}}</highlight> SSD ajoutera <highlight>{{available}}</highlight> d'espace disponible.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-both\": \"Votre nouveau <highlight>{{size}}</highlight> SSD ajoutera <highlight>{{available}}</highlight> d'espace disponible et <highlight>{{protection}}</highlight> pour la protection des données.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only\": \"Votre nouveau <highlight>{{size}}</highlight> SSD ajoutera <highlight>{{protection}}</highlight> pour la protection des données.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only-full\": \"Votre nouveau <highlight>{{size}}</highlight> SSD sera entièrement utilisé pour la protection des données.\",\n  \"storage-manager.add-to-raid.info-data-safe\": \"Vos données seront en sécurité si un seul SSD tombe en panne.\",\n  \"storage-manager.add-to-raid.info-no-protection\": \"Si un SSD tombe en panne, vous pourriez perdre vos données.\",\n  \"storage-manager.add-to-raid.info-total-wasted\": \"<wasted>{{size}}</wasted> au total inutilisables en raison de différences de taille entre les SSD.\",\n  \"storage-manager.add-to-raid.info-wasted\": \"<wasted>{{size}}</wasted> seront inutilisables en raison de tailles de SSD différentes.\",\n  \"storage-manager.add-to-raid.recommended\": \"Recommandé\",\n  \"storage-manager.add-to-raid.recommended-inline\": \"(recommandé)\",\n  \"storage-manager.add-to-raid.restart-active-tasks\": \"Toutes les tâches actives seront interrompues\",\n  \"storage-manager.add-to-raid.restart-after\": \"Après le redémarrage, la configuration de FailSafe se terminera automatiquement et vous pourrez reprendre une utilisation normale.\",\n  \"storage-manager.add-to-raid.restart-during\": \"Pendant le redémarrage :\",\n  \"storage-manager.add-to-raid.restart-intro\": \"Vous pouvez continuer à utiliser umbrelOS normalement pendant ce processus. Toutefois, à 50 % d'avancement, votre Umbrel redémarrera automatiquement.\",\n  \"storage-manager.add-to-raid.restart-required\": \"Redémarrage du système requis\",\n  \"storage-manager.add-to-raid.restart-ui-inaccessible\": \"umbrelOS sera temporairement inaccessible\",\n  \"storage-manager.add-to-raid.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD dans l'<highlight>emplacement {{slot}}</highlight>\",\n  \"storage-manager.add-to-raid.title\": \"Ajouter un SSD au stockage\",\n  \"storage-manager.add-to-raid.too-small\": \"SSD trop petit\",\n  \"storage-manager.add-to-raid.too-small-description\": \"Ce SSD ({{deviceSize}}) est plus petit que le plus petit SSD actuellement installé ({{minSize}}). FailSafe exige que tous les SSD soient au moins aussi grands que le plus petit SSD utilisé.\",\n  \"storage-manager.add-to-raid.understand-continue\": \"J'ai compris, continuer\",\n  \"storage-manager.add-to-raid.warning-failsafe-now-only\": \"Avoir plus d'un SSD signifie que FailSafe ne peut être activé que maintenant. Vous ne pourrez pas l'activer plus tard.\",\n  \"storage-manager.add-to-raid.wasted-label\": \"Inutilisable :\",\n  \"storage-manager.available-storage\": \"Stockage disponible\",\n  \"storage-manager.description\": \"Consultez le stockage, l'état et les paramètres de vos SSD\",\n  \"storage-manager.empty\": \"Vide\",\n  \"storage-manager.failsafe-transition-failed\": \"Impossible d'activer FailSafe\",\n  \"storage-manager.for-failsafe\": \"Pour FailSafe\",\n  \"storage-manager.health.checksum-errors\": \"Erreurs de checksum : {{count}}\",\n  \"storage-manager.health.critical\": \"Critique\",\n  \"storage-manager.health.critical-threshold\": \"Seuil critique\",\n  \"storage-manager.health.current-temperature\": \"Température actuelle\",\n  \"storage-manager.health.estimated-life\": \"Durée de vie estimée restante\",\n  \"storage-manager.health.general\": \"Général\",\n  \"storage-manager.health.health-status\": \"État de santé\",\n  \"storage-manager.health.low\": \"Faible\",\n  \"storage-manager.health.model-and-capacity\": \"Modèle et capacité\",\n  \"storage-manager.health.overheating\": \"Surchauffe\",\n  \"storage-manager.health.raid-failed-advice\": \"Ce SSD présente un problème. Éteignez votre Umbrel et vérifiez la connexion du SSD. Si le problème persiste, le SSD devra peut‑être être remplacé.\",\n  \"storage-manager.health.read-errors\": \"Erreurs de lecture : {{count}}\",\n  \"storage-manager.health.serial-number\": \"Numéro de série\",\n  \"storage-manager.health.status-healthy\": \"En bon état\",\n  \"storage-manager.health.status-unhealthy\": \"En mauvais état\",\n  \"storage-manager.health.status-unknown\": \"Inconnu\",\n  \"storage-manager.health.temperature\": \"Température\",\n  \"storage-manager.health.title\": \"Santé des SSD\",\n  \"storage-manager.health.warning-life-advice\": \"Pensez à remplacer ce SSD bientôt.\",\n  \"storage-manager.health.warning-life-message\": \"Seulement {{percent}}% de durée de vie restante\",\n  \"storage-manager.health.warning-temp-advice\": \"Assurez-vous que votre Umbrel Pro bénéficie d'un bon flux d'air et que le SSD est correctement installé.\",\n  \"storage-manager.health.warning-temp-critical\": \"Température critique ({{temperature}})\",\n  \"storage-manager.health.warning-temp-overheating\": \"Le disque est en surchauffe ({{temperature}})\",\n  \"storage-manager.health.warning-threshold\": \"Seuil d'avertissement\",\n  \"storage-manager.health.warning-unhealthy-advice\": \"Ce SSD pourrait bientôt tomber en panne. Envisagez de le remplacer.\",\n  \"storage-manager.health.warning-unhealthy-message\": \"Ce SSD pourrait présenter un problème\",\n  \"storage-manager.health.warnings\": \"Avertissements\",\n  \"storage-manager.health.wear\": \"Usure\",\n  \"storage-manager.health.write-errors\": \"Erreurs d'écriture : {{count}}\",\n  \"storage-manager.install-ssd.description\": \"Ajoutez des SSD supplémentaires pour étendre votre stockage\",\n  \"storage-manager.install-ssd.step-insert\": \"Insérez les nouveaux SSD dans les emplacements vides\",\n  \"storage-manager.install-ssd.step-power-on\": \"Allumez votre {{deviceName}}\",\n  \"storage-manager.install-ssd.step-remove-bottom-cover\": \"Retirez le couvercle magnétique inférieur\",\n  \"storage-manager.install-ssd.step-replace-bottom-cover\": \"Remettez le capot inférieur en place\",\n  \"storage-manager.install-ssd.step-return\": \"Revenez ici pour ajouter les SSD à votre stockage\",\n  \"storage-manager.install-ssd.step-shut-down\": \"Éteignez votre {{deviceName}}\",\n  \"storage-manager.install-ssd.title\": \"Ajout de SSD\",\n  \"storage-manager.install-tips.image-alt\": \"Instructions d'installation du SSD\",\n  \"storage-manager.install-tips.instructions\": \"Pour l'installer, retirez la vis moletée et glissez le SSD dans l'emplacement en l'inclinant. Appuyez sur le SSD jusqu'à ce qu'il repose sur le pilier de la vis, puis fixez-le avec la vis moletée.\",\n  \"storage-manager.install-tips.toggle\": \"Vous avez oublié comment insérer un SSD ?\",\n  \"storage-manager.manage\": \"Gérer\",\n  \"storage-manager.missing-ssd-warning\": \"Un SSD semble manquer. Éteignez votre Umbrel et vérifiez que tous les SSD sont bien connectés. Si le problème persiste, le SSD devra peut‑être être remplacé.\",\n  \"storage-manager.mode\": \"Mode\",\n  \"storage-manager.mode.failsafe\": \"FailSafe\",\n  \"storage-manager.mode.failsafe.description\": \"Protège vos données si un SSD tombe en panne. Si vos SSD ont des tailles différentes, l'espace supplémentaire des plus grands restera inutilisé.\",\n  \"storage-manager.mode.failsafe.info-description\": \"FailSafe protège vos données en en conservant des copies réparties sur vos SSD. Si un SSD tombe en panne, vos données restent sécurisées et peuvent être restaurées lorsque vous ajoutez un SSD de remplacement.\",\n  \"storage-manager.mode.failsafe.info-title\": \"À propos de FailSafe\",\n  \"storage-manager.mode.full-storage\": \"Stockage complet\",\n  \"storage-manager.mode.full-storage.description\": \"Use all your SSD space together. If an SSD fails, you could lose your data.\",\n  \"storage-manager.mode.full-storage.info-description\": \"Full Storage combine tous vos SSD en un seul grand espace, vous offrant un stockage maximal. Cependant, si un SSD tombe en panne, toutes vos données seront perdues.\",\n  \"storage-manager.mode.full-storage.info-title\": \"À propos du Stockage complet\",\n  \"storage-manager.mode.switch-from-failsafe-unavailable\": \"Passer de FailSafe au mode Full Storage nécessite de sauvegarder vos données, de réinitialiser l'appareil aux paramètres d'usine et de restaurer depuis une sauvegarde.\",\n  \"storage-manager.mode.switch-to-failsafe-unavailable\": \"Avec plusieurs SSD en mode Full Storage, vos données sont réparties sur tous les disques. Passer à FailSafe nécessite de sauvegarder vos données, de réinitialiser l'appareil aux paramètres d'usine et de restaurer.\",\n  \"storage-manager.mode.why-cant-switch\": \"Pourquoi ne puis-je pas basculer ?\",\n  \"storage-manager.operation-in-progress.shutdown-description\": \"Vous pouvez éteindre en toute sécurité. L'opération sera mise en pause et reprendra après le redémarrage, mais elle doit se terminer avant que vous puissiez effectuer d'autres modifications.\",\n  \"storage-manager.operation-in-progress.shutdown-title\": \"Votre stockage est en cours de mise à jour\",\n  \"storage-manager.operation-in-progress.wait-description\": \"Patientez jusqu'à la fin de l'opération en cours avant de faire d'autres modifications.\",\n  \"storage-manager.operation-in-progress.wait-title\": \"Votre stockage est en cours de mise à jour\",\n  \"storage-manager.operation.adding-ssd\": \"Ajout du SSD...\",\n  \"storage-manager.operation.enabling-failsafe\": \"Activation de FailSafe...\",\n  \"storage-manager.operation.expanding\": \"Extension du stockage...\",\n  \"storage-manager.operation.rebuilding\": \"Reconstruction des données...\",\n  \"storage-manager.operation.replacing\": \"Remplacement du disque...\",\n  \"storage-manager.operation.restarting\": \"Redémarrage...\",\n  \"storage-manager.operation.starting\": \"Démarrage...\",\n  \"storage-manager.operation.syncing-restarts\": \"Synchronisation des données • Redémarrage à 50 %\",\n  \"storage-manager.raid-status.degraded\": \"Dégradé\",\n  \"storage-manager.raid-status.failed\": \"Défaillant\",\n  \"storage-manager.raid-status.offline\": \"Hors ligne\",\n  \"storage-manager.raid-status.online\": \"En ligne\",\n  \"storage-manager.raid-status.removed\": \"Retiré\",\n  \"storage-manager.raid-status.unavailable\": \"Indisponible\",\n  \"storage-manager.replace\": \"Remplacer\",\n  \"storage-manager.replace-failed.degraded\": \"Protection FailSafe réduite\",\n  \"storage-manager.replace-failed.degraded-description\": \"Un SSD manque dans votre stockage FailSafe. Remplacez-le pour retrouver une protection complète.\",\n  \"storage-manager.replace-failed.description\": \"Utilisez ce SSD pour restaurer la protection FailSafe.\",\n  \"storage-manager.replace-failed.error\": \"Impossible de démarrer le remplacement\",\n  \"storage-manager.replace-failed.replace-now\": \"Remplacer maintenant\",\n  \"storage-manager.replace-failed.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD dans l'emplacement {{slot}}\",\n  \"storage-manager.replace-failed.step-protected\": \"Une fois terminé, vos données seront à nouveau entièrement protégées.\",\n  \"storage-manager.replace-failed.step-rebuild\": \"Les données seront reconstruites sur le nouveau SSD\",\n  \"storage-manager.replace-failed.step-time\": \"Cela peut prendre un certain temps en fonction de la quantité de données.\",\n  \"storage-manager.replace-failed.title\": \"Remplacer le SSD\",\n  \"storage-manager.replace-failed.too-small\": \"SSD trop petit\",\n  \"storage-manager.replace-failed.too-small-description\": \"Ce SSD ({{deviceSize}}) est plus petit que la taille minimale requise ({{minSize}}) pour votre stockage FailSafe.\",\n  \"storage-manager.replace-failed.what-happens\": \"Que se passe-t-il ensuite :\",\n  \"storage-manager.ssd-failing\": \"Défaillant\",\n  \"storage-manager.swap\": \"Échanger\",\n  \"storage-manager.swap.data-erased-description\": \"Le mode Full Storage n'offre pas de protection des données. Toutes les données sur votre {{deviceName}} seront effacées lors de la réinitialisation d'usine. Veillez à tout sauvegarder au préalable.\",\n  \"storage-manager.swap.data-protected\": \"Vos données sont protégées\",\n  \"storage-manager.swap.data-protected-description\": \"Avec FailSafe activé, vous pouvez remplacer n'importe quel SSD sans perdre vos données. Aucune sauvegarde nécessaire.\",\n  \"storage-manager.swap.data-will-be-erased\": \"Les données seront effacées\",\n  \"storage-manager.swap.description-failsafe\": \"Remplacez un disque dans votre stockage FailSafe.\",\n  \"storage-manager.swap.description-full-storage\": \"Remplacez un disque dans votre configuration Full Storage.\",\n  \"storage-manager.swap.description-no-free-slot\": \"En mode Full Storage avec tous les emplacements occupés, remplacer un SSD nécessite un processus complet de sauvegarde et de restauration.\",\n  \"storage-manager.swap.description-replace\": \"Migrez vos données vers un nouveau SSD, puis retirez l'ancien.\",\n  \"storage-manager.swap.failed-to-start\": \"Impossible de démarrer le remplacement\",\n  \"storage-manager.swap.no-data-loss\": \"Aucune perte de données\",\n  \"storage-manager.swap.no-data-loss-description\": \"Vos données seront copiées sur le nouveau SSD. Une fois l'opération terminée, vous pourrez retirer l'ancien en toute sécurité.\",\n  \"storage-manager.swap.safe-swap-available\": \"Échange sécurisé disponible\",\n  \"storage-manager.swap.safe-swap-description\": \"Comme vous disposez d'un emplacement vide, vous pouvez ajouter d'abord le nouveau SSD et migrer vos données avant de retirer l'ancien. Aucune sauvegarde requise.\",\n  \"storage-manager.swap.select-new-ssd\": \"Sélectionnez le nouveau SSD à utiliser :\",\n  \"storage-manager.swap.ssd-in-slot\": \"{{size}} SSD dans l'emplacement {{slot}}\",\n  \"storage-manager.swap.step-backup\": \"Sauvegardez vos données\",\n  \"storage-manager.swap.step-backup-description\": \"Allez dans Paramètres → Backups et créez une sauvegarde de toutes vos données.\",\n  \"storage-manager.swap.step-data-copied\": \"Les données seront copiées de l'ancien SSD vers le nouveau\",\n  \"storage-manager.swap.step-factory-reset\": \"Réinitialisation d'usine\",\n  \"storage-manager.swap.step-factory-reset-description\": \"Allez dans Paramètres → Avancé → Réinitialisation d'usine pour effacer votre {{deviceName}}.\",\n  \"storage-manager.swap.step-insert-new-ssd\": \"Insérez le nouveau SSD dans un emplacement vide\",\n  \"storage-manager.swap.step-may-take-while\": \"Cela peut prendre un certain temps selon la quantité de données que vous avez\",\n  \"storage-manager.swap.step-power-on\": \"Allumez votre {{deviceName}}\",\n  \"storage-manager.swap.step-remove-bottom-cover\": \"Retirez le couvercle magnétique inférieur\",\n  \"storage-manager.swap.step-remove-old\": \"Une fois terminé, éteignez et retirez {{ssd}}\",\n  \"storage-manager.swap.step-replace-bottom-cover\": \"Replacez le couvercle inférieur\",\n  \"storage-manager.swap.step-restore\": \"Restaurez vos données\",\n  \"storage-manager.swap.step-restore-description\": \"Allez dans Paramètres → Backups et restaurez depuis votre sauvegarde.\",\n  \"storage-manager.swap.step-return-to-storage-manager\": \"Revenez ici dans le Gestionnaire de stockage pour confirmer l'échange et ajouter le nouveau SSD à votre stockage\",\n  \"storage-manager.swap.step-return-to-swap\": \"Revenez ici dans le Gestionnaire de stockage et cliquez de nouveau sur \\\"Échanger\\\" pour démarrer le remplacement\",\n  \"storage-manager.swap.step-setup-new-storage\": \"Configurez votre nouveau stockage\",\n  \"storage-manager.swap.step-setup-new-storage-description\": \"Allumez votre {{deviceName}} et terminez le processus de configuration avec votre nouveau SSD.\",\n  \"storage-manager.swap.step-shut-down\": \"Éteignez votre {{deviceName}}\",\n  \"storage-manager.swap.step-shut-down-and-swap\": \"Éteignez et remplacez {{ssd}}\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-other\": \"Éteignez, ouvrez votre appareil, remplacez le SSD, puis remontez-le.\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-pro\": \"Éteignez, retirez le couvercle inférieur, remplacez le SSD, puis refermez le couvercle.\",\n  \"storage-manager.swap.step-swap-ssd\": \"Remplacez {{ssd}} par un nouveau de la même taille\",\n  \"storage-manager.swap.too-small\": \"Trop petit ({{size}} requis)\",\n  \"storage-manager.swap.what-happens-next\": \"Ce qui se passe ensuite :\",\n  \"storage-manager.total-capacity-added\": \"Capacité totale ajoutée\",\n  \"storage-manager.umbrel-pro\": \"Umbrel Pro\",\n  \"storage-manager.used\": \"Utilisé\",\n  \"storage-manager.wasted\": \"Inutilisable\",\n  \"storage-manager.wasted-size\": \"{{size}} Inutilisable\",\n  \"storage.full\": \"Stockage plein\",\n  \"storage.low\": \"Stockage faible\",\n  \"temperature\": \"Température\",\n  \"temperature.dangerously-hot\": \"Très chaud\",\n  \"temperature.nice\": \"Agréable\",\n  \"temperature.normal\": \"Normal\",\n  \"temperature.too-hot-suggestion\": \"Envisagez de changer l'environnement de votre appareil.\",\n  \"temperature.warm\": \"Tiède\",\n  \"terminal\": \"Terminal\",\n  \"terminal-description\": \"Exécutez des commandes personnalisées dans umbrelOS ou au sein d'une application\",\n  \"terminal.app\": \"App\",\n  \"terminal.app-description\": \"Exécutez des commandes personnalisées dans une application spécifique\",\n  \"terminal.umbrelos-description\": \"Exécutez des commandes personnalisées dans umbrelOS\",\n  \"tor-description\": \"Accédez à votre Umbrel de n'importe où en utilisant un navigateur Tor\",\n  \"tor-enabled-description\": \"Accédez à votre Umbrel de n'importe où en utilisant un navigateur Tor à l'URL suivante :\",\n  \"tor-error\": \"Impossible de mettre à jour le paramètre Tor : {{message}}\",\n  \"tor.disable.description\": \"Cela peut prendre quelques minutes\",\n  \"tor.disable.progress\": \"Désactivation de l'accès Tor à distance\",\n  \"tor.enable.description\": \"Cela peut prendre quelques minutes\",\n  \"tor.enable.mobile.switch-label\": \"Activer l'accès Tor à distance\",\n  \"tor.hidden-service\": \"URL du service caché Tor\",\n  \"troubleshoot\": \"Dépannage\",\n  \"troubleshoot-description\": \"Dépanner umbrelOS ou une application\",\n  \"troubleshoot-no-logs-yet\": \"Aucun journal pour le moment\",\n  \"troubleshoot-pick-title\": \"Dépannage\",\n  \"troubleshoot.app\": \"Application\",\n  \"troubleshoot.app-description\": \"Voir les journaux d'une application installée sur votre Umbrel\",\n  \"troubleshoot.app-download\": \"Télécharger les journaux de {{app}}\",\n  \"troubleshoot.share-with-umbrel-support\": \"Partager avec le support Umbrel\",\n  \"troubleshoot.system-download\": \"Télécharger {{label}}\",\n  \"troubleshoot.umbrelos-description\": \"Afficher les journaux umbrelOS\",\n  \"troubleshoot.umbrelos-logs\": \"Journaux umbrelOS\",\n  \"trpc.backend-unavailable\": \"Erreur : Connexion à l'API système échouée\",\n  \"trpc.checking-backend\": \"Chargement...\",\n  \"try-again\": \"Réessayer\",\n  \"umbrel\": \"Umbrel\",\n  \"umbrelos\": \"umbrelOS\",\n  \"unknown\": \"Inconnu\",\n  \"unknown-app\": \"Application inconnue\",\n  \"unknown-error\": \"Erreur inconnue\",\n  \"uptime\": \"Temps de fonctionnement\",\n  \"url\": \"URL\",\n  \"wallpaper\": \"Fond d'écran\",\n  \"wallpaper-description\": \"Votre fond d'écran et thème Umbrel\",\n  \"whats-new.continue\": \"Continuer\",\n  \"whats-new.feature-1.description\": \"Configurez des sauvegardes automatisées et chiffrées de l'intégralité de votre Umbrel vers un disque USB externe, un NAS ou un autre Umbrel.\",\n  \"whats-new.feature-2.description\": \"Revenez en arrière pour récupérer des fichiers et dossiers précis à partir de sauvegardes précédentes.\",\n  \"whats-new.feature-3.description\": \"Ou restaurez l'intégralité de votre Umbrel, y compris toutes vos applications, fichiers et données.\",\n  \"whats-new.feature-4.description\": \"Connectez un NAS ou un autre Umbrel et accédez à son stockage depuis Files.\",\n  \"whats-new.feature-4.title\": \"Périphériques réseau\",\n  \"whats-new.feature-5.description\": \"Connectez des disques USB externes (sur Umbrel Home ou sur n'importe quel appareil Intel ou AMD) et accédez-y depuis Files.\",\n  \"whats-new.feature-5.helper-text\": \"Non pris en charge sur les appareils Raspberry Pi en raison de possibles problèmes d'alimentation.\",\n  \"whats-new.feature-5.title\": \"Stockage externe\",\n  \"whats-new.next\": \"Suivant\",\n  \"whats-new.title\": \"Nouveautés de {{version}}\",\n  \"widget.progress.in-progress\": \"En cours\",\n  \"widgets.edit.select-up-to-3-widgets\": \"Sélectionnez jusqu'à 3 widgets\",\n  \"widgets.install-an-app-before-using-widgets\": \"Installez une application pour commencer à personnaliser votre écran d'accueil avec des widgets.\",\n  \"wifi\": \"Wi-Fi\",\n  \"wifi-connect-insecure-message\": \"Les réseaux ouverts peuvent être peu sécurisés\",\n  \"wifi-connection-failed\": \"Impossible de se connecter\",\n  \"wifi-dangerous-change-confirmation-description\": \"Changer de réseau Wi-Fi peut vous déconnecter de votre Umbrel. Pour vous reconnecter, assurez-vous que votre Umbrel et l'appareil à partir duquel vous y accédez sont sur le même réseau.\",\n  \"wifi-dangerous-change-confirmation-title\": \"Êtes-vous sûr de vouloir changer de réseau Wi-Fi ?\",\n  \"wifi-dangerous-disable-confirmation-description\": \"Désactiver le Wi-Fi peut vous déconnecter de votre Umbrel. Pour vous reconnecter, branchez un câble Ethernet à votre Umbrel et assurez-vous que votre Umbrel et l'appareil à partir duquel vous y accédez sont sur le même réseau.\",\n  \"wifi-dangerous-disable-confirmation-title\": \"Êtes-vous sûr de vouloir désactiver le Wi-Fi ?\",\n  \"wifi-description\": \"Connectez votre appareil à un réseau Wi-Fi\",\n  \"wifi-description-long\": \"Votre appareil reste connecté à votre Wi-Fi choisi, même si le câble Ethernet est retiré, et se reconnecte automatiquement au Wi-Fi au démarrage.\",\n  \"wifi-no-networks-message\": \"Aucun réseau Wi-Fi trouvé\",\n  \"wifi-searching\": \"Recherche de réseaux Wi-Fi...\",\n  \"wifi-unsupported-device-description\": \"Le Wi-Fi n'est pas pris en charge sur cet appareil. Cela peut être dû à un adaptateur sans fil manquant ou incompatible.\",\n  \"wifi-view-networks\": \"Voir les réseaux\"\n}"
  },
  {
    "path": "packages/ui/public/locales/hu.json",
    "content": "{\n  \"2fa\": \"2FA\",\n  \"2fa-description\": \"Egy második biztonsági réteg az Umbrel bejelentkezésedhez és az alkalmazásaidhoz\",\n  \"2fa.disable.title\": \"Kétlépcsős azonosítás letiltása\",\n  \"2fa.enable.or-paste\": \"Vagy illeszd be a következő kódot az hitelesítő alkalmazásodba\",\n  \"2fa.enable.scan-this\": \"Olvasd be ezt a QR-kódot egy hitelesítő alkalmazással, például a Google Authenticator vagy az Authy segítségével\",\n  \"2fa.enable.title\": \"Kétlépcsős azonosítás engedélyezése\",\n  \"2fa.enter-code\": \"Add meg a hitelesítő alkalmazásod által megjelenített kódot\",\n  \"account\": \"Fiók\",\n  \"account-description\": \"A neved és jelszavad\",\n  \"advanced-settings\": \"Haladó beállítások\",\n  \"advanced-settings-description\": \"Terminál, umbrelOS Béta Program, Cloudflare DNS, és még sok más\",\n  \"app-not-found\": \"Alkalmazás nem található: {{app}}\",\n  \"app-only-over-tor\": \"{{app}} csak Toron keresztül használható. Kérlek, nyisd meg az Umbrel-t egy Tor böngészőben a távoli hozzáférési URL-eden (Beállítások > Speciális beállítások > Távoli Tor-hozzáférés), hogy megnyisd ezt az alkalmazást.\",\n  \"app-page.section.about\": \"Rólunk\",\n  \"app-page.section.credentials.title\": \"Alapértelmezett hitelesítési adatok\",\n  \"app-page.section.dependencies.n-alternatives\": \"Lásd a {{count}} alternatívát\",\n  \"app-page.section.info.compatibility\": \"Kompatibilitás\",\n  \"app-page.section.info.compatibility-compatible\": \"Kompatibilis\",\n  \"app-page.section.info.compatibility-not-compatible\": \"Nem kompatibilis\",\n  \"app-page.section.info.developer\": \"Fejlesztő\",\n  \"app-page.section.info.source-code\": \"Forráskód\",\n  \"app-page.section.info.source-code.public\": \"Nyilvános\",\n  \"app-page.section.info.submitted-by\": \"Beküldte\",\n  \"app-page.section.info.support\": \"Támogatás kérése\",\n  \"app-page.section.info.title\": \"Információ\",\n  \"app-page.section.info.version\": \"Verzió\",\n  \"app-page.section.recommendations.title\": \"Neked is tetszhet\",\n  \"app-page.section.release-notes.title\": \"Újdonságok\",\n  \"app-page.section.release-notes.version\": \"{{version}} verzió\",\n  \"app-page.section.requires\": \"Követelmények\",\n  \"app-picker.search\": \"Keresés...\",\n  \"app-picker.select-app\": \"Válassz alkalmazást...\",\n  \"app-settings.connected-to\": \"{{appName}} ezekhez az alkalmazásokhoz csatlakozik\",\n  \"app-settings.save-changes\": \"Változtatások mentése\",\n  \"app-settings.title\": \"Beállítások\",\n  \"app-store.browse-category-apps\": \"Böngészd a(z) {{category}} alkalmazásokat\",\n  \"app-store.category.ai\": \"AI\",\n  \"app-store.category.all\": \"Összes alkalmazás\",\n  \"app-store.category.automation\": \"Otthon és automatizáció\",\n  \"app-store.category.bitcoin\": \"Bitcoin\",\n  \"app-store.category.crypto\": \"Kripto\",\n  \"app-store.category.developer\": \"Fejlesztői eszközök\",\n  \"app-store.category.discover\": \"Felfedezés\",\n  \"app-store.category.files\": \"Fájlok és termelékenység\",\n  \"app-store.category.finance\": \"Pénzügy\",\n  \"app-store.category.media\": \"Média\",\n  \"app-store.category.networking\": \"Hálózat\",\n  \"app-store.category.social\": \"Közösségi\",\n  \"app-store.description\": \"Az alkalmazásfrissítési beállításaid\",\n  \"app-store.discover.temporarily-unavailable-description\": \"Böngészd a fenti kategóriákat, vagy keress, hogy alkalmazásokat találj\",\n  \"app-store.discover.temporarily-unavailable-title\": \"A kiemelt tartalom jelenleg nem érhető el\",\n  \"app-store.menu.community-app-stores\": \"Közösségi alkalmazásboltok\",\n  \"app-store.search-apps\": \"Alkalmazások keresése\",\n  \"app-store.search.no-results\": \"Nincs találat\",\n  \"app-store.search.results-for\": \"Eredmények erre\",\n  \"app-store.title\": \"Alkalmazásbolt\",\n  \"app-store.updates\": \"Frissítések\",\n  \"app-updates.less\": \"kevesebb\",\n  \"app-updates.more\": \"több\",\n  \"app-updates.no-updates\": \"Minden alkalmazás naprakész!\",\n  \"app-updates.update\": \"Frissítés\",\n  \"app-updates.update-all\": \"Összes frissítése\",\n  \"app-updates.updates-available-count_one\": \"{{count}} frissítés elérhető\",\n  \"app-updates.updates-available-count_other\": \"{{count}} frissítés elérhető\",\n  \"app-updates.updating\": \"Frissítés...\",\n  \"app.install\": \"Telepítés\",\n  \"app.installed\": \"Telepítve\",\n  \"app.installing\": \"Telepítés folyamatban\",\n  \"app.offline\": \"Nem fut\",\n  \"app.open\": \"Megnyitás\",\n  \"app.optimized-for-umbrel-home\": \"Optimalizált az Umbrel Home-hoz\",\n  \"app.os-update-required.confirm\": \"Ellenőrizd az umbrelOS frissítését\",\n  \"app.os-update-required.description\": \"A(z) {{appName}} alkalmazásnak umbrelOS {{version}} vagy újabb verzióra van szüksége\",\n  \"app.os-update-required.title\": \"Frissítsd az umbrelOS-t\",\n  \"app.restarting\": \"Újraindítás\",\n  \"app.starting\": \"Indítás\",\n  \"app.stopping\": \"Leállítás\",\n  \"app.uninstall.confirm.description\": \"Az {{app}}-hez tartozó összes adat véglegesen törlésre kerül. Ez a művelet nem vonható vissza.\",\n  \"app.uninstall.confirm.submit\": \"Eltávolítás\",\n  \"app.uninstall.confirm.title\": \"{{app}} eltávolítása?\",\n  \"app.uninstall.deps.used-by.description_one\": \"Először távolítsd el a(z) {{firstAppToUninstall}} alkalmazást az {{app}} eltávolításához.\",\n  \"app.uninstall.deps.used-by.description_other\": \"Először távolítsd el ezeket az alkalmazásokat az {{app}} eltávolításához.\",\n  \"app.uninstall.deps.used-by.title\": \"{{app}} használja\",\n  \"app.uninstalling\": \"Eltávolítás\",\n  \"app.updating\": \"Frissítés\",\n  \"app.view\": \"Megtekintés\",\n  \"app_one\": \"alkalmazás\",\n  \"app_other\": \"alkalmazások\",\n  \"apps.uninstall.failed-to-get-required-apps\": \"Nem sikerült megszerezni a szükséges alkalmazásokat\",\n  \"apps.uninstalled-all.success\": \"Minden alkalmazás eltávolítva\",\n  \"auth.checking-backend-for-user\": \"Betöltés...\",\n  \"auth.failed-checking-if-user-logged-in\": \"Hiba: Nem sikerült ellenőrizni a bejelentkezést\",\n  \"auth.failed-to-check-if-user-exists\": \"Hiba: Nem sikerült ellenőrizni a felhasználó létezését\",\n  \"back\": \"Vissza\",\n  \"backups\": \"Backups\",\n  \"backups-configure\": \"Konfigurálás\",\n  \"backups-configure.add-backup-location\": \"Backup-hely hozzáadása\",\n  \"backups-configure.available\": \"Elérhető\",\n  \"backups-configure.awaiting-next-backup\": \"Várakozás a következő automatikus backupra\",\n  \"backups-configure.back-up-now\": \"Most backup készítése\",\n  \"backups-configure.backing-up-now\": \"Backup készítése...\",\n  \"backups-configure.connected\": \"Csatlakoztatva\",\n  \"backups-configure.connection\": \"Kapcsolat\",\n  \"backups-configure.in-progress\": \"Folyamatban\",\n  \"backups-configure.last-backup\": \"Utolsó backup\",\n  \"backups-configure.locations\": \"Helyek\",\n  \"backups-configure.no-backup-locations\": \"Adj hozzá egy backup helyet az adataid backupolásának megkezdéséhez\",\n  \"backups-configure.not-connected\": \"Nincs csatlakoztatva\",\n  \"backups-configure.path\": \"Elérési út\",\n  \"backups-configure.remove-backup-location\": \"Backup-hely eltávolítása\",\n  \"backups-configure.remove-backup-location-confirmation\": \"Biztos vagy benne?\",\n  \"backups-configure.remove-backup-location-confirmation-description\": \"Ez eltávolítja a '{{device}}' eszközt a backup helyek közül. A meglévő backupjaid ezen az eszközön nem törlődnek, de az automatikus backupok leállnak.\",\n  \"backups-configure.status\": \"Állapot\",\n  \"backups-configure.total-backups\": \"Backups összesen\",\n  \"backups-configure.used\": \"Használt\",\n  \"backups-configure.view\": \"Megtekintés\",\n  \"backups-description\": \"Készíts backupot a fájljaidról, alkalmazásaidról és adataidról egy másik Umbrelre, NAS-ra vagy külső meghajtóra\",\n  \"backups-error.backup-not-found\": \"A biztonsági mentés nem található.\",\n  \"backups-error.generic\": \"Hiba történt: {{details}}\",\n  \"backups-error.in-progress\": \"Egy biztonsági mentés már folyamatban van. Kérlek, várj, amíg befejeződik.\",\n  \"backups-error.invalid-exclusion-path\": \"Csak a Home könyvtáradban található fájlok és mappák zárhatók ki a mentésekből.\",\n  \"backups-error.invalid-password\": \"A titkosítási jelszó helytelen.\",\n  \"backups-error.invalid-path\": \"A kiválasztott hely nem alkalmas biztonsági mentésre.\",\n  \"backups-error.mount-failed\": \"Nem sikerült elérni a mentési pillanatképet.\",\n  \"backups-error.mount-timeout\": \"Nem sikerült elérni a mentési pillanatképet. Próbáld újra, vagy ellenőrizd, hogy az eszköz megfelelően csatlakozik-e.\",\n  \"backups-error.not-enough-space\": \"Nincs elegendő hely a mentéshez használt eszközön.\",\n  \"backups-error.not-found\": \"A biztonsági mentés vagy a mentési hely nem található.\",\n  \"backups-error.repository-exists\": \"Ebben a mappában már létezik mentési hely.\",\n  \"backups-error.repository-not-found\": \"A mentési hely nem található.\",\n  \"backups-exclusions.add\": \"Hozzáadás\",\n  \"backups-exclusions.app-paths-cannot-be-modified\": \"Ezeket a fájlokat/mappákat az alkalmazás fejlesztője határozta meg, és nem módosíthatók:\",\n  \"backups-exclusions.app-paths-explanation\": \"Ez az alkalmazás a következő adatokat zárja ki a backupból. Ezek az útvonalak általában nem létfontosságú elemeket tartalmaznak (például gyorsítótárakat vagy naplókat, amelyek újra létrehozhatók), vagy olyan adatokat, amelyek visszaállítás esetén problémát okozhatnak (például elavult alkalmazásállapotok, amelyek ütközésekhez vagy inkonzisztenciához vezethetnek).\",\n  \"backups-exclusions.auto-excluded\": \"Automatikusan kizárt\",\n  \"backups-exclusions.exclude-entire-app\": \"Az egész alkalmazás kizárása\",\n  \"backups-exclusions.excluded-apps\": \"Kizárt alkalmazások\",\n  \"backups-exclusions.files-and-folders\": \"Kizárt fájlok és mappák\",\n  \"backups-exclusions.no-excluded-apps\": \"Nincsenek kizárt alkalmazások\",\n  \"backups-exclusions.no-excluded-files-or-folders\": \"Nincsenek kizárt fájlok vagy mappák\",\n  \"backups-exclusions.select-item-to-exclude\": \"Válassz egy elemet a kizáráshoz\",\n  \"backups-exclusions.stop-excluding\": \"Kizárás megszüntetése\",\n  \"backups-floating-island.backing-up\": \"Backup készítése...\",\n  \"backups-floating-island.backing-up-to\": \"Umbrel backupolása...\",\n  \"backups-restore\": \"Visszaállítás\",\n  \"backups-restore-full\": \"Teljes visszaállítás\",\n  \"backups-restore-full-description\": \"Állítsd vissza az egész Umbrel rendszeredet egy biztonsági mentésből\",\n  \"backups-restore-header\": \"Umbrel visszaállítása\",\n  \"backups-restore-pro.after-restore\": \"A visszaállítás után az ideiglenes fiókod helyére a mentett fiókod és annak adatai kerülnek.\",\n  \"backups-restore-pro.step1\": \"Fejezd be a beállítást az alábbi \\\"Get Started\\\" gombra kattintva. Ez lesz az ideiglenes fiókod, amíg vissza nem állítod a mentett fiókodat.\",\n  \"backups-restore-pro.step2\": \"Ha a beállítás elkészült, menj a <0>Beállítások → Backups → Visszaállítás</0>\",\n  \"backups-restore-pro.step3\": \"Kövesd a Restore Wizard utasításait.\",\n  \"backups-restore-pro.subtitle\": \"Umbrel Pro-n egy biztonsági mentés visszaállítása néhány további lépést igényel\",\n  \"backups-restore.backup-date\": \"Backup dátuma\",\n  \"backups-restore.backup-location\": \"Backup hely\",\n  \"backups-restore.browse-cloud-subtitle\": \"Visszaállítás az Umbrel Private Cloud-ból (hamarosan)\",\n  \"backups-restore.browse-cloud-title\": \"Umbrel Private Cloud\",\n  \"backups-restore.browse-external-subtitle\": \"Visszaállítás külső USB-meghajtóról\",\n  \"backups-restore.browse-external-title\": \"Külső meghajtó\",\n  \"backups-restore.browse-nas-or-external\": \"Böngéssz egy másik Umbrelt, NAS-t vagy külső meghajtót, ahonnan backupból visszaállíthatsz\",\n  \"backups-restore.browse-nas-subtitle\": \"Visszaállítás a hálózatodon található másik Umbrelről vagy NAS eszközről\",\n  \"backups-restore.browse-nas-title\": \"Egy másik Umbrel vagy NAS\",\n  \"backups-restore.choose\": \"Válassz\",\n  \"backups-restore.choose-backup-location\": \"Válassz egy backup helyet\",\n  \"backups-restore.connect-to-backup-location\": \"Csatlakozás egy backup helyhez\",\n  \"backups-restore.encryption-password\": \"Titkosítási jelszó\",\n  \"backups-restore.encryption-password-description\": \"Add meg azt a titkosítási jelszót, amit a biztonsági mentések engedélyezésekor állítottál be\",\n  \"backups-restore.enter-password-to-confirm\": \"Add meg az Umbrel jelszavadat a megerősítéshez\",\n  \"backups-restore.final-confirmation\": \"Biztos vagy benne?\",\n  \"backups-restore.final-confirmation-description\": \"Ennek a backupnak a visszaállítása felülírja a jelenlegi umbrelOS alkalmazásaidat és adataidat a kiválasztott backup tartalmával. A backupból kizárt fájlok, mappák vagy alkalmazások eltávolításra kerülnek az Umbrelről. Ezt a műveletet nem lehet visszavonni.\",\n  \"backups-restore.invalid-password\": \"Érvénytelen jelszó\",\n  \"backups-restore.last-backup\": \"Utolsó backup: {{date}}\",\n  \"backups-restore.latest\": \"Legfrissebb\",\n  \"backups-restore.no-backups-found\": \"Nem találhatók backupok\",\n  \"backups-restore.no-backups-yet\": \"Még nincsenek backupok\",\n  \"backups-restore.please-select-backup\": \"Kérlek, válassz egy backupot\",\n  \"backups-restore.please-select-repository\": \"Kérlek, válassz egy tárolót\",\n  \"backups-restore.restore-from-nas-or-external\": \"Állítsd vissza az Umbrel-edet egy másik Umbrel-ről, egy NAS-ról vagy egy külső meghajtóról készült biztonsági mentésből\",\n  \"backups-restore.restore-from-unlisted\": \"Visszaállítás másik helyről\",\n  \"backups-restore.restore-umbrel\": \"Umbrel visszaállítása\",\n  \"backups-restore.restore-warning\": \"Ennek a backupnak a visszaállítása felülírja a jelenlegi umbrelOS alkalmazásaidat és adataidat a kiválasztott backup tartalmával. A backupból kizárt fájlok, mappák vagy alkalmazások eltávolításra kerülnek az Umbrelről. Ha csak bizonyos fájlokat vagy mappákat szeretnél visszaállítani, nyisd meg a <0>Rewind</0>-et.\",\n  \"backups-restore.restoring-from\": \"Most a következő biztonsági mentésről fogsz visszaállítani:\",\n  \"backups-restore.review-description\": \"A visszaállítás azzal a fiókkal, fájlokkal, alkalmazásokkal és beállításokkal állítja be az Umbrel-edet, amelyek a mentés készítésekor szerepeltek. Ez eltarthat egy ideig. Ha kész, a bejelentkezési jelszavad arra a jelszóra lesz visszaállítva, amelyet a mentés készítésekor használtál.\",\n  \"backups-restore.select-backup\": \"Válassz egy backupot\",\n  \"backups-restore.select-backup-description\": \"Válaszd ki azt a backupot, amelyből vissza szeretnél állítani\",\n  \"backups-restore.select-backup-file\": \"Válaszd ki a biztonsági mentés fájlodat\",\n  \"backups-restore.select-backup-file-only\": \"Csak a <bold>{{backupFileName}}</bold> választható\",\n  \"backups-restore.total-size\": \"Teljes méret\",\n  \"backups-restore.unknown-date\": \"Ismeretlen dátum\",\n  \"backups-restore.unknown-repository\": \"Ismeretlen tároló\",\n  \"backups-rewind\": \"Rewind\",\n  \"backups-rewind-description\": \"Ugorj vissza az időben, és állítsd vissza a kiválasztott fájlokat és mappákat\",\n  \"backups-rewind.start\": \"Rewind indítása\",\n  \"backups-setup\": \"Beállítás\",\n  \"backups-setup-confirm\": \"Beállítás befejezése\",\n  \"backups-setup-external-description\": \"Biztonsági mentés külső USB-meghajtóra\",\n  \"backups-setup-nas-or-umbrel-description\": \"Készíts backupot egy másik Umbrelre vagy a hálózatodon lévő NAS eszközre\",\n  \"backups-setup-umbrel-or-nas\": \"Másik Umbrel vagy NAS\",\n  \"backups-setup-umbrel-private-cloud\": \"Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-cta\": \"Nyugi otthonon kívülre is: küldj <bold>végpontok közötti titkosítással védett backupokat</bold> az Umbrel Private Cloud-ba.\",\n  \"backups-setup-umbrel-private-cloud-cta-link\": \"Szerezz korai hozzáférést\",\n  \"backups-setup-umbrel-private-cloud-description\": \"Végpontok közötti titkosítással védett backupok az Umbrel Private Cloud-ba\",\n  \"backups-setup-umbrel-private-cloud-subtitle\": \"Hamarosan\",\n  \"backups.add-umbrel-or-nas\": \"Umbrel vagy NAS hozzáadása\",\n  \"backups.all-apps-and-data-will-be-backed-up\": \"Minden alkalmazás és adat backupolva lesz\",\n  \"backups.apps-and-data\": \"Alkalmazások és adatok\",\n  \"backups.backup-location\": \"Backup hely\",\n  \"backups.browse\": \"Böngészés\",\n  \"backups.choose-folder-within-device\": \"Válassz egy mappát a <bold>{{device}}</bold> eszközön, ahová elmented a backupjaidat\",\n  \"backups.confirm-password\": \"Jelszó megerősítése\",\n  \"backups.copy\": \"Másolás\",\n  \"backups.encryption\": \"Titkosítás\",\n  \"backups.encryption-password-warning\": \"Győződj meg róla, hogy a titkosítási jelszó biztonságban van tárolva (például jelszókezelőben). Ezt a jelszót később nem fogod tudni újra megnézni, és szükséged lesz rá a backupok visszaállításához.\",\n  \"backups.exclude-from-backups\": \"Kizárás a Backups-ból\",\n  \"backups.exclude-from-backups-description\": \"Zárj ki konkrét fájlokat, mappákat és alkalmazásokat a backupjaidból.\",\n  \"backups.hide\": \"Elrejtés\",\n  \"backups.i-understand\": \"Értem\",\n  \"backups.location\": \"Hely\",\n  \"backups.modals.already-in-use.description\": \"Ezt a helyet már használják biztonsági mentésekhez ezen az Umbrel-en.\",\n  \"backups.modals.already-in-use.manage\": \"Kezelés a Backups-ban\",\n  \"backups.modals.already-in-use.title\": \"A biztonsági mentés helye már használatban van\",\n  \"backups.modals.connect-existing.description\": \"Ezen a helyen már létezik egy Umbrel biztonsági mentés. Add meg a titkosítási jelszót, hogy hozzáadd ehhez az Umbrel-hez.\",\n  \"backups.modals.connect-existing.title\": \"Meglévő Umbrel biztonsági mentés csatlakoztatása\",\n  \"backups.no-external-drives-detected\": \"Nem található külső meghajtó\",\n  \"backups.no-password-set\": \"Nincs beállítva jelszó\",\n  \"backups.password-is-set\": \"Jelszó beállítva\",\n  \"backups.password-minimum-length\": \"A jelszónak legalább 8 karakter hosszúnak kell lennie\",\n  \"backups.password-safety-warning\": \"A backupjaidat ezzel a jelszóval fogjuk titkosítani. Tartsd biztonságban, mert később nem fogod tudni megtekinteni, és szükséged lesz rá a backupok visszaállításához.\",\n  \"backups.passwords-do-not-match\": \"A jelszavak nem egyeznek\",\n  \"backups.please-choose-folder\": \"Kérlek, válassz egy mappát\",\n  \"backups.restore-failed.message\": \"Hiba történt az Umbrel visszaállítása közben. A jelenlegi alkalmazásaid és adataid nem módosultak.\",\n  \"backups.restore-failed.retry\": \"Visszaállítás\",\n  \"backups.restore-failed.title\": \"Visszaállítás sikertelen\",\n  \"backups.restoring\": \"Umbrel visszaállítása\",\n  \"backups.restoring-completing\": \"Épp befejezzük. Az Umbrel rövidesen újraindul...\",\n  \"backups.restoring-progress\": \"{{percent}}% visszaállítva\",\n  \"backups.restoring-time-remaining\": \"{{time}} hátra\",\n  \"backups.restoring-warning\": \"Ne kapcsold ki az Umbrel-t, és ne válaszd le a backup helyet visszaállítás közben\",\n  \"backups.review\": \"Ellenőrizd és erősítsd meg\",\n  \"backups.review-description\": \"Ellenőrizd a backup részleteit, majd erősítsd meg a választásodat\",\n  \"backups.scanning-for-external-drives\": \"Külső meghajtók keresése...\",\n  \"backups.schedule-description\": \"Az umbrelOS óránként automatikusan backupot készít az adataidról. Az elmúlt 24 órából óránként titkosított backupokat tart meg, az elmúlt hétre naponta backupokat, az elmúlt hónapra hetente backupokat, és az elmúlt évre havi backupokat. Az egy évnél régebbi backupok automatikusan törlődnek.\",\n  \"backups.select-backup-folder\": \"Válassz backup mappát\",\n  \"backups.select-backup-folder-description\": \"Válassz egy mappát, ahová a backupjaidat szeretnéd tárolni.\",\n  \"backups.select-backup-location\": \"Válassz egy backup helyet\",\n  \"backups.set-encryption-password\": \"Titkosítási jelszó beállítása\",\n  \"backups.set-encryption-password-description\": \"Védd a backupjaidat egy jelszóval. Ez biztosítja, hogy az adataid privátok maradjanak, és csak ezzel a jelszóval állíthatók vissza.\",\n  \"backups.show\": \"Megjelenítés\",\n  \"backups.storage-capacity-warning\": \"{{device}}-nek legalább a backup méretének kétszeresével megegyező szabad hellyel kell rendelkeznie\",\n  \"backups.store-encryption-password-safely\": \"Tárold a titkosítási jelszavad biztonságosan\",\n  \"beta-program\": \"umbrelOS Beta Program\",\n  \"beta-program-description\": \"Iratkozz fel az umbrelOS béta frissítésekre, szerezd meg az új funkciók korai hozzáférését, és segíts fejleszteni azokat a visszajelzéseiddel. A béta frissítések instabilak lehetnek, és a hibaelhárítás terminál ismereteket igényelhet.\",\n  \"cancel\": \"Mégse\",\n  \"change\": \"Változtatás\",\n  \"change-name\": \"Név változtatása\",\n  \"change-name.failed.name-required\": \"A név megadása kötelező\",\n  \"change-name.input-placeholder\": \"A te neved\",\n  \"change-password\": \"Jelszó változtatása\",\n  \"change-password.callout\": \"Ha elveszted a jelszavad, nem tudsz majd bejelentkezni az Umbrel-be. Biztosítsd, hogy biztonságosan tárolod.\",\n  \"change-password.current-password\": \"Jelenlegi jelszó\",\n  \"change-password.failed.current-required\": \"A jelenlegi jelszó megadása kötelező\",\n  \"change-password.failed.min-length\": \"A jelszónak legalább {{characters}} karakter hosszúnak kell lennie\",\n  \"change-password.failed.must-be-unique\": \"Az új jelszónak különböznie kell a jelenlegitől\",\n  \"change-password.failed.new-required\": \"Az új jelszó megadása kötelező\",\n  \"change-password.failed.no-match\": \"A jelszavak nem egyeznek\",\n  \"change-password.failed.repeat-required\": \"A jelszó ismétlése kötelező\",\n  \"change-password.new-password\": \"Új jelszó\",\n  \"change-password.repeat-password\": \"Jelszó ismétlése\",\n  \"check-for-latest-version\": \"Legfrissebb umbrelOS frissítés ellenőrzése\",\n  \"clipboard.copied\": \"Másolva\",\n  \"close\": \"Bezárás\",\n  \"cmdk.change-wallpaper\": \"Háttérkép megváltoztatása\",\n  \"cmdk.frequent-apps\": \"Gyakran használt\",\n  \"cmdk.input-placeholder\": \"Keresés alkalmazások, beállítások vagy műveletek között\",\n  \"cmdk.live-usage\": \"Élő használat\",\n  \"cmdk.restart-umbrel\": \"Umbrel újraindítása\",\n  \"cmdk.shutdown-umbrel\": \"Umbrel leállítása\",\n  \"cmdk.update-all-apps\": \"Összes alkalmazás frissítése\",\n  \"cmdk.widgets\": \"Widgetek\",\n  \"community-app-store\": \"Közösségi Alkalmazásbolt\",\n  \"community-app-store.add-error\": \"App Store hozzáadása sikertelen: {{message}}\",\n  \"community-app-store.back-to-umbrel-app-store\": \"Vissza az Umbrel Alkalmazásboltba\",\n  \"community-app-store.open-button\": \"Megnyitás\",\n  \"community-app-store.remove-button\": \"Eltávolítás\",\n  \"community-app-store.remove-error\": \"App Store eltávolítása sikertelen: {{message}}\",\n  \"community-app-stores.add-button\": \"Hozzáadás\",\n  \"community-app-stores.description\": \"A Közösségi Alkalmazásboltok lehetővé teszik, hogy olyan alkalmazásokat telepíts az Umbrel-re, amelyek nem elérhetőek az hivatalos Umbrel Alkalmazásboltban. Ezen kívül egyszerűvé teszik az Umbrel alkalmazások béta verzióinak tesztelését, mielőtt a fejlesztők kiadják őket az hivatalos Umbrel Alkalmazásboltban.\",\n  \"community-app-stores.learn-more\": \"Tudj meg többet\",\n  \"community-app-stores.warning\": \"A Közösségi Alkalmazásboltokat bárki létrehozhatja. Az ezekben közzétett alkalmazásokat nem ellenőrzi vagy vizsgálja az hivatalos Umbrel Alkalmazásbolt csapata, és potenciálisan nem biztonságosak vagy rosszindulatúak lehetnek. Legyél óvatos, és csak olyan alkalmazásboltokat adj hozzá, amelyeket megbízható fejlesztők készítettek.\",\n  \"confirm\": \"Megerősítés\",\n  \"connect\": \"Csatlakozás\",\n  \"connecting\": \"Csatlakozás...\",\n  \"connection-lost\": \"Kapcsolat megszakadt\",\n  \"connection-lost-description\": \"Ez akkor fordulhat elő, ha a böngészőfül inaktív volt, a hálózati kapcsolat megszakadt, vagy az eszköz offline.\",\n  \"continue\": \"Folytatás\",\n  \"continue-to-log-in\": \"Folytatás a bejelentkezéshez\",\n  \"cpu\": \"CPU\",\n  \"cpu-core-count\": \"{{cores}} szál\",\n  \"default-credentials.close\": \"Rendben\",\n  \"default-credentials.description\": \"Itt találod a bejelentkezéshez szükséges hitelesítési adatokat.\",\n  \"default-credentials.dont-show-again\": \"Ne mutasd újra ezt\",\n  \"default-credentials.dont-show-again-notice\": \"A jövőben bármikor hozzáférhetsz ezekhez a hitelesítő adatokhoz az alkalmazás ikonra jobb egérgombbal kattintva.\",\n  \"default-credentials.open\": \"Nyisd meg a(z) {{app}} alkalmazást\",\n  \"default-credentials.password\": \"Alapértelmezett jelszó\",\n  \"default-credentials.title\": \"{{app}} hitelesítési adatai\",\n  \"default-credentials.username\": \"Alapértelmezett felhasználónév\",\n  \"desktop.app.context.go-to-store-page\": \"Megtekintés az Alkalmazásboltban\",\n  \"desktop.app.context.settings\": \"Beállítások\",\n  \"desktop.app.context.show-default-credentials\": \"Alapértelmezett hitelesítési adatok megjelenítése\",\n  \"desktop.app.context.uninstall\": \"Eltávolítás\",\n  \"desktop.context-menu.change-wallpaper\": \"Háttérkép megváltoztatása\",\n  \"desktop.context-menu.edit-widgets\": \"Widgetek szerkesztése\",\n  \"desktop.context-menu.logout\": \"Kijelentkezés\",\n  \"desktop.greeting.afternoon\": \"Jó délutánt, {{name}}\",\n  \"desktop.greeting.evening\": \"Jó estét, {{name}}\",\n  \"desktop.greeting.morning\": \"Jó reggelt, {{name}}\",\n  \"desktop.install-first.for-the-ai-enthusiast\": \"Viber-felhasználóknak\",\n  \"desktop.install-first.for-the-bitcoiner\": \"A Bitcoin felhasználóknak\",\n  \"desktop.install-first.for-the-self-hoster\": \"Az önálló hosztok számára\",\n  \"desktop.install-first.for-the-streamer\": \"A streamerek számára\",\n  \"desktop.install-first.link-to-app-store\": \"Fedezz fel többet az App Store-ban\",\n  \"desktop.not-enough-room\": \"Használj nagyobb képernyőt az alkalmazások megtekintéséhez.\",\n  \"device\": \"Eszköz\",\n  \"device-info\": \"Eszközinformációk\",\n  \"device-info-description\": \"Információk az eszközödről\",\n  \"device-info.device\": \"Eszköz\",\n  \"device-info.model-number\": \"Modellszám\",\n  \"device-info.serial-number\": \"Sorozatszám\",\n  \"device-info.view-info\": \"Információ megtekintése\",\n  \"device-name.home-or-pro\": \"Umbrel Home vagy Umbrel Pro\",\n  \"disable\": \"Letiltás\",\n  \"done\": \"Kész\",\n  \"download-logs\": \"Naplók letöltése\",\n  \"enabling-tor\": \"Távoli Tor-hozzáférés engedélyezése\",\n  \"external-dns\": \"Cloudflare DNS\",\n  \"external-dns-description\": \"A Cloudflare DNS jobb hálózati megbízhatóságot kínál. Kapcsold ki, ha a routered DNS beállításait szeretnéd használni.\",\n  \"external-dns-error\": \"DNS-beállítás frissítése sikertelen: {{message}}\",\n  \"external-drive\": \"Külső meghajtó\",\n  \"factory-reset\": \"Gyári beállítások visszaállítása\",\n  \"factory-reset-description\": \"Minden adatod és alkalmazásod törlése és az umbrelOS visszaállítása alapértelmezett beállításokra\",\n  \"factory-reset-failed\": \"A készülék visszaállítása sikertelen: {{message}}\",\n  \"factory-reset.confirm.body\": \"Erősítsd meg a jelszavad a visszaállításhoz\",\n  \"factory-reset.confirm.ethernet-required-warning\": \"Bizonyosodj meg róla, hogy az eszközöd Ethernet-kábellel csatlakozik az útválasztódhoz (nem Wi-Fi-n keresztül), és a helyi hálózatodról éred el (pl. http://umbrel.local vagy az eszközöd helyi IP címe).\",\n  \"factory-reset.confirm.submit\": \"Mindent törölni és visszaállítani\",\n  \"factory-reset.confirm.submit-callout\": \"Ez a művelet nem vonható vissza.\",\n  \"factory-reset.rebooting.message\": \"Az eszközöd újraindul, és minden adat törlődik. Kérjük, ne zárd be ezt az oldalt.\",\n  \"factory-reset.rebooting.status\": \"Visszaállítás...\",\n  \"factory-reset.rebooting.title\": \"Gyári visszaállítás folyamatban\",\n  \"factory-reset.review.account-info\": \"Fiókinformációk és jelszó\",\n  \"factory-reset.review.apps\": \"Alkalmazások\",\n  \"factory-reset.review.following-will-be-removed\": \"A következő elemek kerülnek eltávolításra az eszközödről\",\n  \"factory-reset.review.installed-apps_one\": \"{{count}} telepített alkalmazás\",\n  \"factory-reset.review.installed-apps_other\": \"{{count}} telepített alkalmazás\",\n  \"factory-reset.review.submit\": \"Folytatás\",\n  \"factory-reset.review.total-data\": \"Összes adat\",\n  \"files\": \"Files\",\n  \"files-action.add-favorite\": \"Hozzáadás a kedvencekhez\",\n  \"files-action.add-network-device\": \"Eszköz hozzáadása\",\n  \"files-action.cancel-upload\": \"Feltöltés visszonása\",\n  \"files-action.compress\": \"Tömörítés\",\n  \"files-action.copy\": \"Másolás\",\n  \"files-action.cut\": \"Kivágás\",\n  \"files-action.delete\": \"Végleges törlés\",\n  \"files-action.download\": \"Letöltés\",\n  \"files-action.download-items\": \"{{count}} elem letöltése\",\n  \"files-action.drop-to-upload\": \"Húzd ide a feltöltéshez\",\n  \"files-action.eject-disk\": \"Kiadás\",\n  \"files-action.empty-trash\": \"Kuka ürítése\",\n  \"files-action.format-drive\": \"Formázás\",\n  \"files-action.go-to-path\": \"Ugrás ide...\",\n  \"files-action.new-folder\": \"Új mappa\",\n  \"files-action.open\": \"Megnyitás\",\n  \"files-action.paste\": \"Beillesztés\",\n  \"files-action.remove-favorite\": \"Eltávolítás a kedvencek közül\",\n  \"files-action.remove-network-host\": \"Hálózati meghajtó leválasztása\",\n  \"files-action.remove-network-share\": \"Hálózati megosztás leválasztása\",\n  \"files-action.rename\": \"Átnevezés\",\n  \"files-action.restore\": \"Visszaállítás\",\n  \"files-action.select\": \"Kiválasztás\",\n  \"files-action.share\": \"Megosztás a hálózaton...\",\n  \"files-action.sharing\": \"Megosztás...\",\n  \"files-action.show-in-folder\": \"Megjelenítés a tartalmazó mappában\",\n  \"files-action.trash\": \"Kuka\",\n  \"files-action.uncompress\": \"Kicsomagolás\",\n  \"files-action.upload\": \"Feltöltés\",\n  \"files-add-network-share.add-manually\": \"Kézi hozzáadás\",\n  \"files-add-network-share.add-share\": \"Megosztás hozzáadása\",\n  \"files-add-network-share.back\": \"Vissza\",\n  \"files-add-network-share.continue\": \"Folytatás\",\n  \"files-add-network-share.description\": \"Csatlakozz egy NAS-hoz vagy más, a hálózatodon megosztott meghajtóhoz, hogy a Fájlokban elérhesd őket.\",\n  \"files-add-network-share.discovering\": \"Keresés...\",\n  \"files-add-network-share.enter-details-manually\": \"Add meg a szerver adatait\",\n  \"files-add-network-share.host-label\": \"Szerver címe\",\n  \"files-add-network-share.host-required\": \"Kötelező megadni a szerver címét\",\n  \"files-add-network-share.manual-share-help\": \"Írd be a megosztás pontos nevét, ahogy az a szervereden szerepel\",\n  \"files-add-network-share.no-shares-found\": \"Ezen a szerveren nem található megosztás\",\n  \"files-add-network-share.not-seeing-share\": \"Nem látod a megosztásodat?\",\n  \"files-add-network-share.password-label\": \"Jelszó\",\n  \"files-add-network-share.password-required\": \"Kötelező megadni a jelszót\",\n  \"files-add-network-share.retrieving-shares\": \"Megosztások lekérése...\",\n  \"files-add-network-share.retry-discovery\": \"Hálózat újrakeresése\",\n  \"files-add-network-share.select-share\": \"Válassz egy hozzáadandó megosztást\",\n  \"files-add-network-share.share-placeholder\": \"shared-documents\",\n  \"files-add-network-share.share-required\": \"Kötelező megadni a megosztást\",\n  \"files-add-network-share.title\": \"Hálózati megosztás hozzáadása\",\n  \"files-add-network-share.username-label\": \"Felhasználónév\",\n  \"files-add-network-share.username-placeholder\": \"admin\",\n  \"files-add-network-share.username-required\": \"Kötelező megadni a felhasználónevet\",\n  \"files-audio-island.now-playing\": \"Most szól\",\n  \"files-audio-island.pause\": \"Szünet\",\n  \"files-audio-island.play\": \"Lejátszás\",\n  \"files-backend-error.base-directory-not-found\": \"Az alapkönyvtár nem található.\",\n  \"files-backend-error.cant-find-root\": \"Nem sikerült ellenőrizni a fájl elérési útját.\",\n  \"files-backend-error.destination-already-exists\": \"A célhelyen már létezik ugyanolyan nevű elem.\",\n  \"files-backend-error.destination-not-exist\": \"A célmappa nem létezik.\",\n  \"files-backend-error.does-not-exist\": \"A fájl vagy mappa nem létezik.\",\n  \"files-backend-error.escapes-base\": \"Az elérési út az engedélyezett könyvtáron kívül esik.\",\n  \"files-backend-error.invalid-base\": \"Az elérési út nem egy érvényes könyvtárhoz tartozik.\",\n  \"files-backend-error.invalid-filename\": \"A fájlnév érvénytelen.\",\n  \"files-backend-error.invalid-path\": \"Az elérési út érvénytelen.\",\n  \"files-backend-error.mkdir-failed\": \"Nem sikerült létrehozni a mappát.\",\n  \"files-backend-error.move-failed\": \"Nem sikerült áthelyezni az elemet.\",\n  \"files-backend-error.not-enough-space\": \"Nincs elegendő tárhely.\",\n  \"files-backend-error.operation-not-allowed\": \"Ez a művelet nem engedélyezett.\",\n  \"files-backend-error.parent-not-directory\": \"A szülő útvonal nem mappa.\",\n  \"files-backend-error.parent-not-exist\": \"A szülőmappa nem létezik.\",\n  \"files-backend-error.path-not-absolute\": \"Az elérési út érvénytelen.\",\n  \"files-backend-error.share-already-exists\": \"Ez a mappa már meg van osztva.\",\n  \"files-backend-error.share-name-generation-failed\": \"Nem sikerült egyedi megosztási nevet generálni.\",\n  \"files-backend-error.source-not-exists\": \"A forrásfájl vagy mappa nem létezik.\",\n  \"files-backend-error.subdir-of-self\": \"Egy mappa nem másolható vagy mozgatható önmagába.\",\n  \"files-backend-error.trash-meta-not-exists\": \"Nem található az elem eredeti helye.\",\n  \"files-backend-error.unique-name-index-exceeded\": \"Nem sikerült egy egyedi nevet generálni. Túl sok hasonló nevű elem létezik.\",\n  \"files-backend-error.upload-failed\": \"Feltöltés sikertelen.\",\n  \"files-collision.action.keep-both\": \"Mindkettő megtartása\",\n  \"files-collision.action.replace\": \"Felülírás\",\n  \"files-collision.action.skip\": \"Kihagyás\",\n  \"files-collision.destination.original-location\": \"az eredeti helyén\",\n  \"files-collision.message\": \"Szeretnéd felülírni a meglévő elemet, vagy megtartani mindkettőt?\",\n  \"files-collision.title\": \"\\\"{{itemName}}\\\" már létezik a(z) {{destinationName}} helyen\",\n  \"files-download.confirm\": \"Letöltés\",\n  \"files-download.description\": \"A Files nem tudja megnyitni ezt a fájltípust. Inkább letöltöd?\",\n  \"files-download.title\": \"Letöltés: {{name}}?\",\n  \"files-empty-trash.confirm\": \"Üres\",\n  \"files-empty-trash.description\": \"Biztosan törölni szeretnéd véglegesen a kukában lévő összes elemet? Ezt a műveletet nem lehet visszavonni.\",\n  \"files-empty-trash.title\": \"Kuka ürítése?\",\n  \"files-empty.directory\": \"Nincsenek elemek ebben a mappában\",\n  \"files-empty.network\": \"Nincsenek hálózati eszközök\",\n  \"files-empty.network-host-offline\": \"Hálózati eszköz offline\",\n  \"files-error.add-favorite\": \"Nem sikerült hozzáadni a kedvencekhez: {{message}}.\",\n  \"files-error.add-share\": \"Nem sikerült megosztani a mappát: {{message}}.\",\n  \"files-error.compress\": \"Nem sikerült tömöríteni: {{message}}.\",\n  \"files-error.copy\": \"Nem sikerült másolni: {{message}}.\",\n  \"files-error.create-folder\": \"Nem sikerült létrehozni a mappát: {{message}}.\",\n  \"files-error.delete\": \"Nem sikerült törölni: {{message}}.\",\n  \"files-error.eject-disk\": \"Nem sikerült a meghajtó kiadása: {{message}}.\",\n  \"files-error.empty-trash\": \"Nem sikerült üríteni a kukát: {{message}}.\",\n  \"files-error.extract\": \"Nem sikerült kicsomagolni: {{message}}.\",\n  \"files-error.folder-already-exists\": \"Már létezik ilyen nevű mappa.\",\n  \"files-error.move\": \"Nem sikerült áthelyezni: {{message}}.\",\n  \"files-error.remove-favorite\": \"Nem sikerült eltávolítani a kedvencekből: {{message}}.\",\n  \"files-error.remove-share\": \"Nem sikerült eltávolítani a megosztott mappát: {{message}}.\",\n  \"files-error.rename\": \"Nem sikerült átnevezni: {{message}}.\",\n  \"files-error.restore\": \"Nem sikerült visszaállítani: {{message}}.\",\n  \"files-error.trash\": \"Nem sikerült áthelyezni a kukába: {{message}}.\",\n  \"files-error.upload\": \"Nem sikerült feltölteni: {{message}}.\",\n  \"files-error.upload-network-error\": \"A {{name}} feltöltése sikertelen: hálózati hiba történt.\",\n  \"files-extension-change.confirm\": \"Folytatás\",\n  \"files-extension-change.description-add\": \"Biztosan meg szeretnéd változtatni a(z) „{{fileName}}” fájlkiterjesztését „{{extension}}”-re? Ez a fájl olvashatatlanságát okozhatja.\",\n  \"files-extension-change.description-remove\": \"Biztosan szeretnéd eltávolítani a(z) „{{fileName}}” fájlkiterjesztését?\",\n  \"files-extension-change.title-add\": \"Kiterjesztés módosítása „{{extension}}”-re?\",\n  \"files-extension-change.title-remove\": \"Kiterjesztés eltávolítása?\",\n  \"files-external-storage.unsupported.description\": \"A csatlakoztatott külső meghajtó áramellátási problémák miatt nem használható Raspberry Pi-n. Külső tárhely elérhető az Umbrel Home-on, az Umbrel Pro-n és minden x86 (Intel vagy AMD) eszközön.\",\n  \"files-external-storage.unsupported.description-general\": \"A Raspberry Pi-n a külső tárhely nem elérhető tápellátási problémák miatt. Külső tárhely elérhető az Umbrel Home-on, az Umbrel Pro-n és minden x86 (Intel vagy AMD) eszközön.\",\n  \"files-external-storage.unsupported.title\": \"Külső tárhely nem támogatott\",\n  \"files-folder\": \"Mappa\",\n  \"files-format.confirm\": \"Formázás\",\n  \"files-format.description\": \"A formázás törli a {{driveName}} összes adatát. Ez a művelet nem vonható vissza.\",\n  \"files-format.description-unreadable\": \"Az umbrelOS nem tudja olvasni a {{driveName}} tartalmát. Formázhatod, így umbrelOS-sel használhatod.\",\n  \"files-format.drive-label\": \"Név\",\n  \"files-format.error\": \"Nem sikerült formázni a meghajtót\",\n  \"files-format.exfat-description\": \"Legnagyobb kompatibilitás Windows, macOS és Linux rendszerekkel\",\n  \"files-format.ext4-description\": \"Jobb teljesítmény umbrelOS és Linux rendszereken\",\n  \"files-format.filesystem\": \"Fájlrendszer\",\n  \"files-format.filesystem-label\": \"Formázás típusa\",\n  \"files-format.formatting\": \"Formázás...\",\n  \"files-format.title\": \"Meghajtó formázása\",\n  \"files-format.title-requires-format\": \"Formázás szükséges\",\n  \"files-formatting-island.formatting\": \"Formázás...\",\n  \"files-formatting-island.formatting-drives\": \"Formázok {{count}} meghajtót\",\n  \"files-listing.empty\": \"Nincsenek elemek\",\n  \"files-listing.error\": \"Hiba történt\",\n  \"files-listing.item-count-truncated\": \"{{formattedCount}}+ elem\",\n  \"files-listing.item-count_one\": \"{{formattedCount}} elem\",\n  \"files-listing.item-count_other\": \"{{formattedCount}} elem\",\n  \"files-listing.loading\": \"Betöltés...\",\n  \"files-listing.no-such-file\": \"Nincs ilyen fájl vagy mappa\",\n  \"files-listing.selected-count\": \"{{selectedCount}} / {{totalCount}} kiválasztva\",\n  \"files-listing.selected-count-truncated\": \"{{selectedCount}} a(z) {{totalCount}}+ elemből kiválasztva\",\n  \"files-name-drawer.new-folder\": \"Új mappa\",\n  \"files-name-drawer.new-folder-description\": \"Adj meg egy nevet az új mappának.\",\n  \"files-name-drawer.new-folder-input\": \"Mappa neve\",\n  \"files-name-drawer.rename-file\": \"Fájl átnevezése\",\n  \"files-name-drawer.rename-file-description\": \"Adj meg egy új nevet ehhez a fájlhoz.\",\n  \"files-name-drawer.rename-file-input\": \"Fájl neve\",\n  \"files-name-drawer.rename-folder\": \"Mappa átnevezése\",\n  \"files-name-drawer.rename-folder-description\": \"Adj meg egy új nevet ehhez a mappához.\",\n  \"files-name-drawer.rename-folder-input\": \"Mappa neve\",\n  \"files-network-storage-error.add-share\": \"Nem sikerült hozzáadni a hálózati megosztást: {{message}}.\",\n  \"files-network-storage-error.discover-servers\": \"Hálózati eszközök felderítése sikertelen: {{message}}.\",\n  \"files-network-storage-error.discover-shares\": \"Hálózati megosztások felderítése sikertelen: {{message}}.\",\n  \"files-network-storage-error.remove-share\": \"Nem sikerült eltávolítani a hálózati megosztást: {{message}}.\",\n  \"files-operations-island.copying\": \"Másolás \\\"{{from}}\\\" ide: \\\"{{to}}\\\"\",\n  \"files-operations-island.moving\": \"Áthelyezés \\\"{{from}}\\\" ide: \\\"{{to}}\\\"\",\n  \"files-operations-island.restoring\": \"\\\"{{from}}\\\" visszaállítása a(z) \\\"{{to}}\\\" helyre\",\n  \"files-path.input-group\": \"Elérési út mező\",\n  \"files-path.input-label\": \"Aktuális elérés\",\n  \"files-permanently-delete.confirm\": \"Végleges törlés\",\n  \"files-permanently-delete.description-multiple\": \"Biztosan véglegesen törölni szeretnéd ezt a(z) {{count}} elemet? Ez a művelet nem vonható vissza.\",\n  \"files-permanently-delete.description-single\": \"Biztosan véglegesen törölni szeretnéd ezt: „{{fileName}}”? Ezt a műveletet nem lehet visszavonni.\",\n  \"files-permanently-delete.title-multiple\": \"Végleges törlés a(z) {{count}} elemhez?\",\n  \"files-permanently-delete.title-single\": \"Véglegesen törlöd?\",\n  \"files-search.default\": \"Fájlok és mappák keresése\",\n  \"files-search.no-results\": \"Nincsenek találatok a(z) \\\"{{query}}\\\" keresésre\",\n  \"files-search.placeholder\": \"Keresés\",\n  \"files-search.searching-label\": \"Keresés {{name}} Umbreljében\",\n  \"files-share.home-description\": \"Érd el a(z) „{{homeDirectoryName}}” összes fájlját a hálózatodon található más eszközökről.\",\n  \"files-share.home-title\": \"„{{homeDirectoryName}}” megosztása a hálózaton\",\n  \"files-share.instructions.how-to-access\": \"Hogyan férj hozzá\",\n  \"files-share.instructions.ios.enter-password\": \"Jelszóként add meg: <field>{{password}}</field>.\",\n  \"files-share.instructions.ios.enter-server\": \"Írd be szervercímként: <field>{{smbUrl}}</field>.\",\n  \"files-share.instructions.ios.enter-username\": \"Felhasználónévként add meg: <field>{{username}}</field>.\",\n  \"files-share.instructions.ios.install-files\": \"Telepítsd a „Files” alkalmazást az App Store-ból, ha még nincs telepítve.\",\n  \"files-share.instructions.ios.tap-connect\": \"Érintsd meg a „Csatlakozás” gombot a hozzáféréshez.\",\n  \"files-share.instructions.ios.tap-dots\": \"Érintsd meg a jobb felső sarokban lévő három pontot (...), majd válaszd a „Csatlakozás szerverhez” lehetőséget.\",\n  \"files-share.instructions.macos.click-connect\": \"Kattints a „Csatlakozás” gombra a hozzáféréshez.\",\n  \"files-share.instructions.macos.enter-password\": \"Jelszóként add meg: <field>{{password}}</field>.\",\n  \"files-share.instructions.macos.enter-url\": \"Írd be: <field>{{smbUrl}}</field>, majd kattints a „Csatlakozás” gombra.\",\n  \"files-share.instructions.macos.enter-username\": \"Felhasználónévként add meg: <field>{{username}}</field>.\",\n  \"files-share.instructions.macos.open-finder\": \"Nyisd meg a „Finder”-t, majd nyomd meg a ⌘ + K billentyűket.\",\n  \"files-share.instructions.macos.select-registered\": \"Amikor a rendszer kéri, válaszd a „Regisztrált felhasználó” lehetőséget.\",\n  \"files-share.instructions.macos.time-machine\": \"Hogyan használd Time Machine mentési helyként\",\n  \"files-share.instructions.macos.time-machine.choose-encryption\": \"Döntsd el, hogy titkosított vagy titkosítatlan mentést szeretnél.\",\n  \"files-share.instructions.macos.time-machine.disk-limit\": \"A „Lemezhasználati korlát” beállításnál add meg, mekkora helyet szeretnél kiosztani az Umbrel-en a Time Machine biztonsági mentésekhez, majd kattints a „Kész” gombra.\",\n  \"files-share.instructions.macos.time-machine.follow-steps\": \"Kövesd a fenti lépéseket, majd nyisd meg a Rendszerbeállításokat a Maceden.\",\n  \"files-share.instructions.macos.time-machine.go-settings\": \"Lépj be a Time Machine-be, majd kattints a „Biztonsági mentési lemez hozzáadása...” gombra.\",\n  \"files-share.instructions.macos.time-machine.select-disk\": \"Válaszd ki a mappát, majd kattints a \\\"Set Up Disk...\\\"-re.\",\n  \"files-share.instructions.umbrelos.backup.follow-onscreen\": \"Kövesd a lépésről lépésre vezetett utasításokat a biztonsági mentés beállításához.\",\n  \"files-share.instructions.umbrelos.backup.follow-then-go-to\": \"Kövesd a fent leírt lépéseket, majd a másik Umbrelen nyisd meg a \\\"{{settings}}\\\" > \\\"{{backups}}\\\" menüpontot.\",\n  \"files-share.instructions.umbrelos.backup.select-add\": \"Válaszd a \\\"{{addUmbrelOrNas}}\\\" opciót.\",\n  \"files-share.instructions.umbrelos.backup.select-connected\": \"Válaszd ki ezt az Umbrel eszközt a csatlakoztatott eszközök listájából.\",\n  \"files-share.instructions.umbrelos.backup.title\": \"Hogyan használd mentési helyként a másik Umbrel számára.\",\n  \"files-share.instructions.umbrelos.cant-find-note\": \"Nem találod? Próbáld meg kiválasztani a \\\"Kézi hozzáadás\\\" opciót, és használd a következő hitelesítő adatokat. Ha még így sem tudod hozzáadni, győződj meg róla, hogy mindkét eszközöd ugyanazon a hálózaton van.\",\n  \"files-share.instructions.umbrelos.enter-password\": \"Add meg <field>{{password}}</field> jelszóként.\",\n  \"files-share.instructions.umbrelos.enter-username\": \"Add meg <field>{{username}}</field> felhasználónévként.\",\n  \"files-share.instructions.umbrelos.open-and-click\": \"A másik Umbreleden nyisd meg \\\"Files\\\"-t, és kattints a <plus/>-ra a \\\"<deviceIcon/> {{deviceLabel}}\\\" mellett az oldalsávban.\",\n  \"files-share.instructions.umbrelos.select-device\": \"Válaszd ki ezt az Umbrel eszközt a hálózatodon automatikusan észlelt eszközök listájából.\",\n  \"files-share.instructions.umbrelos.select-sharename\": \"Válaszd ki a \\\"{{sharename}}\\\"-t, majd kattints a megosztás hozzáadásához.\",\n  \"files-share.instructions.windows.enter-password\": \"Jelszóként add meg: <field>{{password}}</field>.\",\n  \"files-share.instructions.windows.enter-url\": \"Írd be: <field>{{smbUrl}}</field>, majd nyomj Entert.\",\n  \"files-share.instructions.windows.enter-username\": \"Felhasználónévként add meg: <field>{{username}}</field>.\",\n  \"files-share.instructions.windows.open-run\": \"Nyomd meg a Windows + R billentyűket a Futtatás ablak megnyitásához.\",\n  \"files-share.instructions.windows.remember-credentials\": \"Pipáld be a „Remember my credentials” lehetőséget, majd kattints az OK gombra.\",\n  \"files-share.regular-description\": \"Oszd meg ezt a mappát, hogy más eszközökről is elérhesd a hálózatodon keresztül.\",\n  \"files-share.regular-title\": \"Mappa megosztása a hálózaton\",\n  \"files-share.toggle\": \"„{{name}}” megosztása a hálózatodon\",\n  \"files-sidebar.apps\": \"Alkalmazások\",\n  \"files-sidebar.external-storage\": \"Külső tárhely\",\n  \"files-sidebar.favorites\": \"Kedvencek\",\n  \"files-sidebar.home\": \"Saját mappa\",\n  \"files-sidebar.navigation\": \"Fájlböngészés\",\n  \"files-sidebar.network\": \"Hálózat\",\n  \"files-sidebar.network-pathbar\": \"Hálózati eszközök\",\n  \"files-sidebar.network-sidebar\": \"Eszközök\",\n  \"files-sidebar.recents\": \"Legutóbbiak\",\n  \"files-sidebar.shared-folders\": \"Megosztott mappák\",\n  \"files-sidebar.trash\": \"Kuka\",\n  \"files-sidebar.trash.open\": \"Megnyitás\",\n  \"files-sort.created\": \"Hozzáadva\",\n  \"files-sort.modified\": \"Módosítva\",\n  \"files-sort.name\": \"Név\",\n  \"files-sort.size\": \"Méret\",\n  \"files-sort.type\": \"Típus\",\n  \"files-state.uploading\": \"Feltöltés...\",\n  \"files-state.waiting\": \"Várakozás...\",\n  \"files-type.3gp\": \"3GP videó\",\n  \"files-type.3gp2\": \"3GP2 videó\",\n  \"files-type.7z\": \"7Z archívum\",\n  \"files-type.aac\": \"AAC hang\",\n  \"files-type.ai\": \"Illustrator fájl\",\n  \"files-type.aiff\": \"AIFF hang\",\n  \"files-type.au\": \"AU hang\",\n  \"files-type.avi\": \"AVI videó\",\n  \"files-type.avif\": \"AVIF kép\",\n  \"files-type.bmp\": \"BMP kép\",\n  \"files-type.bzip2\": \"BZIP2 archívum\",\n  \"files-type.caf\": \"CAF hang\",\n  \"files-type.compressed\": \"Tömörített archívum\",\n  \"files-type.csv\": \"CSV fájl\",\n  \"files-type.directory\": \"Mappa\",\n  \"files-type.dmg\": \"Lemezkép\",\n  \"files-type.dv\": \"DV videó\",\n  \"files-type.epub\": \"EPUB e-könyv\",\n  \"files-type.excel\": \"Excel táblázat\",\n  \"files-type.exe\": \"Windows futtatható fájl\",\n  \"files-type.executable\": \"Futtatható fájl\",\n  \"files-type.external-drive\": \"Meghajtó\",\n  \"files-type.flac\": \"FLAC hang\",\n  \"files-type.flv\": \"FLV videó\",\n  \"files-type.gif\": \"GIF kép\",\n  \"files-type.gzip\": \"GZIP archívum\",\n  \"files-type.heic\": \"HEIC kép\",\n  \"files-type.ico\": \"ICO kép\",\n  \"files-type.iso\": \"ISO lemezkép\",\n  \"files-type.jpeg\": \"JPEG kép\",\n  \"files-type.keynote\": \"Keynote bemutató\",\n  \"files-type.lzip\": \"LZIP archívum\",\n  \"files-type.lzma\": \"LZMA archívum\",\n  \"files-type.lzop\": \"LZOP archívum\",\n  \"files-type.m3u\": \"M3U lejátszási lista\",\n  \"files-type.m4a\": \"M4A hang\",\n  \"files-type.m4v\": \"M4V videó\",\n  \"files-type.midi\": \"MIDI hang\",\n  \"files-type.mka\": \"MKA hang\",\n  \"files-type.mkv\": \"MKV videó\",\n  \"files-type.mng\": \"MNG videó\",\n  \"files-type.mobi\": \"MOBI e-könyv\",\n  \"files-type.mp3\": \"MP3 hang\",\n  \"files-type.mp4\": \"MP4 videó\",\n  \"files-type.mp4-audio\": \"MP4 hang\",\n  \"files-type.mpeg\": \"MPEG videó\",\n  \"files-type.mpeg-ts\": \"MPEG transport stream\",\n  \"files-type.network-drive\": \"Hálózati meghajtó\",\n  \"files-type.numbers\": \"Numbers táblázat\",\n  \"files-type.ogg\": \"OGG hang\",\n  \"files-type.ogv\": \"OGV videó\",\n  \"files-type.pages\": \"Pages dokumentum\",\n  \"files-type.pdf\": \"PDF dokumentum\",\n  \"files-type.png\": \"PNG kép\",\n  \"files-type.powerpoint\": \"PowerPoint bemutató\",\n  \"files-type.psd\": \"Photoshop dokumentum\",\n  \"files-type.quicktime\": \"QuickTime videó\",\n  \"files-type.rar\": \"RAR archívum\",\n  \"files-type.sgi\": \"SGI videó\",\n  \"files-type.svg\": \"SVG kép\",\n  \"files-type.tar\": \"TAR archívum\",\n  \"files-type.tiff\": \"TIFF kép\",\n  \"files-type.ts\": \"TS videó\",\n  \"files-type.txt\": \"Szöveges fájl\",\n  \"files-type.umbrel-backup\": \"Umbrel Backup\",\n  \"files-type.wav\": \"WAV hang\",\n  \"files-type.webm\": \"WebM videó\",\n  \"files-type.webm-audio\": \"WebM hang\",\n  \"files-type.webp\": \"WebP kép\",\n  \"files-type.wma\": \"WMA hang\",\n  \"files-type.wmv\": \"WMV videó\",\n  \"files-type.word\": \"Word dokumentum\",\n  \"files-type.xz\": \"XZ archívum\",\n  \"files-type.zip\": \"ZIP archívum\",\n  \"files-upload-island.uploading-count\": \"{{count}} elem feltöltése folyamatban\",\n  \"files-view.icons\": \"Ikonok\",\n  \"files-view.list\": \"Lista\",\n  \"files-view.sort-by\": \"Rendezés\",\n  \"files-view.view-as\": \"Megjelenítés\",\n  \"files-widgets.favorites.no-items-text\": \"Adj hozzá egy mappát a Kedvencekhez, hogy itt megjelenjen\",\n  \"files-widgets.recents.no-items-text\": \"Nincsenek legutóbbi fájlok\",\n  \"generic-in\": \"in\",\n  \"hide-details\": \"Részletek elrejtése\",\n  \"install-first.install-app\": \"Telepítsd a(z) {{app}} alkalmazást\",\n  \"install-first.title\": \"{{app}} a következő alkalmazásokat igényli\",\n  \"install-your-first-app\": \"Telepítsd az első alkalmazásod\",\n  \"language\": \"Nyelv\",\n  \"language-description\": \"Az általad preferált umbrelOS nyelv\",\n  \"language.select-description\": \"Válaszd ki az umbrelOS preferált nyelvét\",\n  \"live-usage\": \"Élő használat\",\n  \"loading\": \"Betöltés\",\n  \"local-ip\": \"Helyi IP\",\n  \"login-2fa.subtitle\": \"Add meg a hitelesítő alkalmazásod által megjelenített 2FA kódot\",\n  \"login-2fa.title\": \"Hitelesítés\",\n  \"login-with-umbrel.description\": \"Add meg az Umbrel jelszavad a(z) {{app}} megnyitásához\",\n  \"login-with-umbrel.title\": \"Bejelentkezés Umbrel-lel\",\n  \"login.password-label\": \"Jelszó\",\n  \"login.password.submit\": \"Bejelentkezés\",\n  \"login.subtitle\": \"Add meg az Umbrel jelszavad a bejelentkezéshez\",\n  \"login.title\": \"Üdv újra\",\n  \"logout\": \"Kijelentkezés\",\n  \"logout-error-generic\": \"Hiba: Kijelentkezés sikertelen\",\n  \"logout.confirm.submit\": \"Kijelentkezés\",\n  \"logout.confirm.title\": \"Biztosan kijelentkezel?\",\n  \"memory\": \"Memória\",\n  \"memory.low\": \"Kevés memória\",\n  \"migrate\": \"Átvitel\",\n  \"migrate.callout\": \"Ne kapcsold ki az Umbrel-t, amíg az átvitel be nem fejeződik\",\n  \"migrate.failed.retry\": \"Újrapróbálás\",\n  \"migrate.failed.title\": \"Átvitel sikertelen\",\n  \"migrate.success.description\": \"Az összes alkalmazásod, alkalmazásadataid és fiókadataid át lettek helyezve az Umbrel Home-ba.\",\n  \"migrate.success.title\": \"Sikeres átvitel\",\n  \"migration-assistant\": \"Átvitel Segéd\",\n  \"migration-assistant-description\": \"Az összes alkalmazásod és adataid átvitele egy Raspberry Pi-ről a {{deviceName}}-re.\",\n  \"migration-assistant-unsupported-device-description\": \"A Migration Assistant jelenleg támogatja az összes adat és alkalmazás átvitelét egy umbrelOS-sel futó Raspberry Pi-ről Umbrel Home-ra vagy Umbrel Pro-ra. A folytatáshoz nyisd meg a Migration Assistantet az Umbrel Home vagy Umbrel Pro eszközödön.\",\n  \"migration-assistant.continue-migration.ready.submit\": \"Átvitel indítása\",\n  \"migration-assistant.failed\": \"Valami nem stimmel...\",\n  \"migration-assistant.failed.retrying-message\": \"Újrapróbálkozás...\",\n  \"migration-assistant.mobile.start-button\": \"Átvitel indítása\",\n  \"migration-assistant.prep.body\": \"Készülj fel az átvitelre\",\n  \"migration-assistant.prep.button-continue\": \"Folytatás\",\n  \"migration-assistant.prep.callout\": \"A {{deviceName}}-en található adatok, ha vannak, véglegesen törlődnek.\",\n  \"migration-assistant.prep.connect-disk-to-home\": \"Csatlakoztasd a külső meghajtót a {{deviceName}} bármelyik USB-portjához.\",\n  \"migration-assistant.prep.prep-done-continue-message\": \"Miután végeztél, kattints az alábbi '{{button}}' gombra.\",\n  \"migration-assistant.prep.shut-down-rpi\": \"Kapcsold ki a Raspberry Pi Umbrel-t.\",\n  \"migration-assistant.ready.description\": \"Az összes adatod és alkalmazásod készen áll az átvitelre a {{deviceName}}-re.\",\n  \"migration-assistant.ready.hint-header\": \"Fontos megjegyezni\",\n  \"migration-assistant.ready.hint-keep-pi-off.description\": \"Ez segít megelőzni a problémákat az olyan alkalmazásokkal, mint a Lightning Node\",\n  \"migration-assistant.ready.hint-keep-pi-off.title\": \"Tartsd kikapcsolva a Raspberry Pi-t a frissítés után\",\n  \"migration-assistant.ready.hint-use-same-password.description\": \"Ne feledd: a {{deviceName}}-be való bejelentkezéshez használd a Raspberry Pi Umbrel jelszavadat.\",\n  \"migration-assistant.ready.hint-use-same-password.title\": \"Használd ugyanazt a jelszót\",\n  \"migration-assistant.ready.title\": \"Készen állsz az átvitelre!\",\n  \"mini-browser.default-title\": \"Mappa kiválasztása\",\n  \"mini-browser.empty-external\": \"Csatlakoztass egy külső meghajtót, hogy itt megjelenjen.\",\n  \"mini-browser.empty-network\": \"Adj hozzá egy Umbrel-t vagy NAS-t, hogy itt megjelenjen.\",\n  \"mini-browser.load-more\": \"Tovább\",\n  \"mini-browser.load-more-in-folder\": \"Tovább betöltése itt: {{name}}\",\n  \"mini-browser.loading-more\": \"Továbbiak betöltése…\",\n  \"mini-browser.select\": \"Válassz\",\n  \"mini-browser.select-folder\": \"Mappa kiválasztása\",\n  \"name\": \"Név\",\n  \"nas\": \"NAS\",\n  \"no-forgot-password-message\": \"Ha elveszted a jelszavad, nem fogsz tudni bejelentkezni az Umbrel-be. Biztosítsd, hogy biztonságosan tárolod.\",\n  \"no-results-found\": \"Nincs találat\",\n  \"not-found-404\": \"Hibakód: 404\",\n  \"not-found-404.back\": \"Vissza\",\n  \"not-found-404.home\": \"Vissza a Főoldalra\",\n  \"notifications.backups-failing-location.description\": \"Az automatikus Backups a(z) {{location}}-ra nem sikerültek. Ellenőrizd a csatlakozást, és nézd át a Backups beállításait.\",\n  \"notifications.backups-failing.description\": \"Az automatikus Backups sikertelenek. Ellenőrizd a backup helyét, és nézd át a beállításaidat.\",\n  \"notifications.backups-failing.go-to-backups\": \"Nyisd meg a Backups-ot\",\n  \"notifications.backups-failing.title\": \"Az elmúlt 24 órában nem voltak Backups.\",\n  \"notifications.cpu.too-hot\": \"Magas CPU hőmérséklet\",\n  \"notifications.memory.low\": \"Az eszközöd memóriája alacsony\",\n  \"notifications.new-version-available\": \"{{update}} most elérhető a telepítéshez\",\n  \"notifications.raid.issue.description\": \"Tárolási probléma észlelve. Részletekért ellenőrizd a Storage Managert.\",\n  \"notifications.raid.issue.title\": \"Sürgős intézkedés szükséges\",\n  \"notifications.ssd.health.description\": \"Előfordulhat, hogy egy vagy több SSD figyelmet igényel. Részletekért ellenőrizd a Storage Managert.\",\n  \"notifications.ssd.health.title\": \"SSD állapotfigyelmeztetés\",\n  \"notifications.storage.full\": \"Az eszközöd tárhelye megtelt\",\n  \"notifications.view\": \"Megtekintés\",\n  \"ok\": \"OK\",\n  \"onboarding.account-created.by-clicking-button-you-agree\": \"A 'Tovább' gombra kattintva elfogadod az <linked>umbrelOS Felhasználási feltételeit</linked>\",\n  \"onboarding.account-created.youre-all-set-name\": \"Minden készen áll, {{name}}.\",\n  \"onboarding.contact-support\": \"Támogatás\",\n  \"onboarding.create-account\": \"Fiók létrehozása\",\n  \"onboarding.create-account.confirm-password.input-label\": \"Jelszó megerősítése\",\n  \"onboarding.create-account.failed.name-required\": \"A név megadása kötelező\",\n  \"onboarding.create-account.failed.passwords-dont-match\": \"A jelszavak nem egyeznek\",\n  \"onboarding.create-account.name.input-placeholder\": \"A te neved\",\n  \"onboarding.create-account.password.input-label\": \"Jelszó\",\n  \"onboarding.create-account.submit\": \"Létrehozás\",\n  \"onboarding.create-account.submitting\": \"Létrehozás folyamatban\",\n  \"onboarding.create-account.subtitle\": \"Az fiók információk csak az Umbrel-eden tárolódnak. Biztosítsd, hogy biztonságosan tárolod a jelszavad, mivel nincs mód annak visszaállítására.\",\n  \"onboarding.create-instead-long\": \"Új fiók létrehozása\",\n  \"onboarding.create-instead-short\": \"Új fiók\",\n  \"onboarding.launch-umbrelos\": \"Indítsd el az umbrelOS-t\",\n  \"onboarding.raid.available-storage\": \"Elérhető tárhely\",\n  \"onboarding.raid.change-drives-link\": \"Szeretnél meghajtót hozzáadni vagy cserélni?\",\n  \"onboarding.raid.configuring.subtitle\": \"Ez néhány percet igénybe vehet.\",\n  \"onboarding.raid.configuring.title\": \"A tárhelyed konfigurálása\",\n  \"onboarding.raid.configuring.warning\": \"Kérlek, ne frissítsd az oldalt és ne kapcsold ki az Umbrelt, amíg a tárhelyed konfigurálódik.\",\n  \"onboarding.raid.continue\": \"Tovább\",\n  \"onboarding.raid.error.detection-instructions\": \"Kapcsold ki az Umbrel Pro-t, ellenőrizd, hogy az SSD-k rendesen a helyükön vannak-e, majd próbáld újra.\",\n  \"onboarding.raid.error.no-ssds-detected\": \"Nem találhatók SSD-k\",\n  \"onboarding.raid.error.no-ssds-instructions\": \"Kapcsold ki az Umbrel Pro-t, és helyezz be legalább egy SSD-t a folytatáshoz.\",\n  \"onboarding.raid.failsafe\": \"FailSafe\",\n  \"onboarding.raid.failsafe.cant-enable\": \"A FailSafe még nem kapcsolható be\",\n  \"onboarding.raid.failsafe.enable\": \"FailSafe engedélyezése\",\n  \"onboarding.raid.failsafe.mixed-sizes\": \"A FailSafe a legkisebb SSD-d mérete ({{smallest}}) alapján lesz korlátozva. A nagyobb SSD-ken lévő extra hely nem használható, így {{wasted}} lesz használhatatlan.\",\n  \"onboarding.raid.failsafe.protection-info-2ssds\": \"{{protection}} az adatok védelmét szolgálja. Adj hozzá még egy {{smallest}} SSD-t, hogy az elérhető tárhely {{futureWith3}}-re növekedjen, vagy adj kettőt a {{futureWith4}}-hez. Bármikor hozzáadhatsz további SSD-ket.\",\n  \"onboarding.raid.failsafe.protection-info-3ssds\": \"{{protection}} az adatok védelmét szolgálja. Adj még egy {{smallest}} SSD-t az elérhető tárhely {{futureWith4}}-re növeléséhez. Bármikor hozzáadhatsz további SSD-ket.\",\n  \"onboarding.raid.failsafe.single-ssd-info\": \"Csak egy SSD-d van. Adj hozzá legalább még egy {{size}} SSD-t, hogy bekapcsolhasd a FailSafe védelmet az adataid számára. Bármikor hozzáadhatsz további SSD-ket.\",\n  \"onboarding.raid.failsafe.subtitle\": \"Az adataid biztonságban maradnak, ha egy SSD meghibásodik\",\n  \"onboarding.raid.failsafe.tip\": \"A legtöbb tárhely és nulla használhatatlan hely érdekében használj egyforma méretű SSD-ket.\",\n  \"onboarding.raid.failsafe.warning-now-only\": \"Ha több SSD van, a FailSafe csak az első beállítás során kapcsolható be. Később nem lesz lehetőséged bekapcsolni.\",\n  \"onboarding.raid.health-warning\": \"Ez a meghajtó állapotproblémákat jelez\",\n  \"onboarding.raid.launching\": \"Indítás...\",\n  \"onboarding.raid.no-ssds-alt\": \"Nem találhatók SSD-k\",\n  \"onboarding.raid.recommended\": \"Ajánlott\",\n  \"onboarding.raid.scanning\": \"Az SSD foglalatok ellenőrzése\",\n  \"onboarding.raid.scanning-alt\": \"SSD-k beolvasása\",\n  \"onboarding.raid.setup-failed.description-no-retry\": \"Kapcsold ki az eszközt, majd próbáld újra.\",\n  \"onboarding.raid.setup-failed.description-retry\": \"Próbáld újra, vagy kapcsold ki az eszközt és ellenőrizd a meghajtókat.\",\n  \"onboarding.raid.setup-failed.title\": \"A tárhely beállítása sikertelen\",\n  \"onboarding.raid.shutdown-dialog.description\": \"A meghajtók hozzáadásához vagy cseréjéhez kapcsold ki az Umbrel Pro-t. Ha végeztél, kapcsold vissza és folytathatod a beállítást.\",\n  \"onboarding.raid.shutdown-dialog.title\": \"Meghajtókat cserélsz?\",\n  \"onboarding.raid.ssd-in-slot\": \"Egy <highlight>{{size}}</highlight> SSD a <highlight>Foglalat {{slot}}</highlight>-ban\",\n  \"onboarding.raid.ssd-label\": \"SSD {{number}}\",\n  \"onboarding.raid.ssd-tray-alt\": \"SSD-tálca\",\n  \"onboarding.raid.ssds-found\": \"Az alábbi SSD-ket találtuk az Umbrel Pro-ban\",\n  \"onboarding.raid.storage\": \"Tárhely\",\n  \"onboarding.raid.storage-label\": \"Tárhely\",\n  \"onboarding.raid.success.storage-info\": \"Tárhely {{available}}\",\n  \"onboarding.raid.success.storage-info-failsafe\": \"Tárhely {{available}} · FailSafe {{failsafe}}\",\n  \"onboarding.raid.try-again\": \"Próbáld újra\",\n  \"onboarding.raid.wasted\": \"Használhatatlan\",\n  \"onboarding.restore-long\": \"Umbrel visszaállítása\",\n  \"onboarding.restore-short\": \"Visszaállítás\",\n  \"onboarding.start.continue\": \"Vágj bele\",\n  \"onboarding.start.subtitle\": \"Az otthoni felhő szervered készen áll a beállításra.\",\n  \"onboarding.start.title\": \"Üdvözöl az umbrelOS\",\n  \"open\": \"Megnyitás\",\n  \"open-live-usage\": \"Élő használat megnyitása\",\n  \"password\": \"Jelszó\",\n  \"preferences\": \"Beállítások\",\n  \"raid-error.description\": \"A tárolórendszered nem indult el rendesen. Ellenőrizd az alábbi SSD-k állapotát, és kövesd a hibaelhárítási lépéseket. Ha a probléma továbbra is fennáll, az érintett SSD-ket cserélni kell.\",\n  \"raid-error.factory-reset-dialog.description\": \"Ez töröl minden adatot az Umbrel Pro-ról és visszaállítja a gyári beállításokat. Ezt a műveletet nem lehet visszavonni.\",\n  \"raid-error.factory-reset-dialog.title\": \"Gyári visszaállítás?\",\n  \"raid-error.factory-reset-failed\": \"Nem sikerült a gyári visszaállítás\",\n  \"raid-error.health-warning\": \"Állapotfigyelmeztetés\",\n  \"raid-error.missing-ssd-multiple\": \"{{count}} SSD nem válaszolnak\",\n  \"raid-error.missing-ssd-one\": \"1 SSD nem válaszol\",\n  \"raid-error.shutdown-dialog.description\": \"Kapcsold ki az Umbrel Pro-t, ellenőrizd, hogy minden SSD rendesen a foglalataiban van, majd kapcsold vissza.\",\n  \"raid-error.shutdown-dialog.title\": \"Leállítsuk a készüléket az SSD-k ellenőrzéséhez?\",\n  \"raid-error.ssd-in-slot\": \"Egy <highlight>{{size}}</highlight> SSD a <highlight>{{slot}}. foglalatban</highlight>\",\n  \"raid-error.step-check-connections.button\": \"Leállítás\",\n  \"raid-error.step-check-connections.description\": \"Kapcsold ki a készüléket, és ellenőrizd, hogy minden SSD megfelelően a helyén van.\",\n  \"raid-error.step-check-connections.title\": \"Ellenőrizd az SSD-k csatlakozását\",\n  \"raid-error.step-factory-reset.button\": \"Gyári visszaállítás\",\n  \"raid-error.step-factory-reset.description\": \"Végső megoldás, ha semmi más nem segít. Ez minden adatot töröl.\",\n  \"raid-error.step-factory-reset.title\": \"Gyári visszaállítás\",\n  \"raid-error.step-restart.button\": \"Újraindítás\",\n  \"raid-error.step-restart.description\": \"Gyors első lépés, ami gyakran segít.\",\n  \"raid-error.step-restart.title\": \"Próbáld meg újraindítani\",\n  \"raid-error.title\": \"Tárolási probléma észlelve\",\n  \"read-less\": \"Kevesebb olvasása\",\n  \"read-more\": \"Több olvasása\",\n  \"reconnect\": \"Újracsatlakozás\",\n  \"redirect.to-home\": \"Betöltés...\",\n  \"redirect.to-login\": \"Betöltés...\",\n  \"redirect.to-onboarding\": \"Betöltés...\",\n  \"redirect.to-raid-error\": \"Betöltés...\",\n  \"reload\": \"Újratöltés\",\n  \"remote-tor-access\": \"Távoli Tor hozzáférés\",\n  \"reset\": \"Visszaállítás\",\n  \"restart\": \"Újraindítás\",\n  \"restart.confirm.submit\": \"Újraindítás\",\n  \"restart.confirm.title\": \"Biztosan újra akarod indítani az Umbrel-t?\",\n  \"restart.restarting\": \"Újraindítás\",\n  \"restart.restarting-message\": \"Ne frissítsd ezt az oldalt vagy kapcsold ki az Umbrel-t, amíg újraindul.\",\n  \"rewind\": \"Rewind\",\n  \"rewind.files-as-of\": \"Fájljaid állapota erre a dátumra:\",\n  \"rewind.loading-snapshots\": \"Pillanatképek betöltése...\",\n  \"rewind.now\": \"Most\",\n  \"rewind.preflight.description\": \"Találd meg a fájlokat és mappákat a korábbi backupjaidból, és állítsd vissza őket a jelenbe.\",\n  \"rewind.preflight.enable-backups\": \"Állítsd be a Backups-t a Beállításokban, hogy elkezdhessed a Rewind használatát\",\n  \"rewind.restore-complete\": \"Visszaállítás befejezve\",\n  \"rewind.restore-error-description\": \"Kérlek, próbáld újra.\",\n  \"rewind.restore-failed\": \"Visszaállítás sikertelen\",\n  \"rewind.restore-running-description\": \"Ne zárd be és ne frissítsd az oldalt, amíg a visszaállítás be nem fejeződik.\",\n  \"rewind.restore-selected\": \"Kijelöltek visszaállítása\",\n  \"rewind.restore-success-description\": \"A fájljaid visszaállítva.\",\n  \"rewind.restoring\": \"Visszaállítás folyamatban\",\n  \"rewind.snapshots-count_one\": \"{{count}} backup óta\",\n  \"rewind.snapshots-count_other\": \"{{count}} backup óta\",\n  \"search\": \"Keresés\",\n  \"settings\": \"Beállítások\",\n  \"settings.app-store-preferences.title\": \"Alkalmazásbolt beállításai\",\n  \"settings.contact-support\": \"Segítségre van szükséged? <linked>Lépj kapcsolatba a támogatással.</linked>\",\n  \"settings.file-sharing\": \"Fájlmegosztás\",\n  \"settings.file-sharing.add-folder\": \"Hozzáadás\",\n  \"settings.file-sharing.add-folder-title\": \"Válassz egy mappát megosztáshoz\",\n  \"settings.file-sharing.choice-entire-description\": \"Oszd meg az Umbreleden lévő összes fájlt\",\n  \"settings.file-sharing.choice-entire-title\": \"Minden\",\n  \"settings.file-sharing.choice-heading\": \"Mit szeretnél megosztani?\",\n  \"settings.file-sharing.choice-specific-description\": \"Válaszd ki, mely mappákat szeretnéd megosztani.\",\n  \"settings.file-sharing.choice-specific-title\": \"Kiválasztott mappák\",\n  \"settings.file-sharing.choice-subtitle\": \"Elérheted a fájljaidat és mappáidat Dropbox-szerűen, hálózati mappaként a számítógépeden vagy a telefonodon.\",\n  \"settings.file-sharing.configure\": \"Beállítás\",\n  \"settings.file-sharing.description\": \"Elérheted a fájljaidat Dropbox-szerűen, hálózati mappaként (SMB) más eszközökről.\",\n  \"settings.file-sharing.home-shared-note\": \"Az egész \\\"{{homeDirectoryName}}\\\" mappád meg van osztva. Az egyes mappákat külön nem kell megosztanod.\",\n  \"settings.file-sharing.share-entire-home-dir\": \"Oszd meg az egész Home mappádat\",\n  \"settings.file-sharing.share-entire-home-dir-description\": \"A hálózatodon lévő más eszközökről elérheted a \\\"{{homeDirectoryName}}\\\" összes fájlját és mappáját.\",\n  \"settings.file-sharing.shared-folders\": \"Megosztott mappák\",\n  \"show-details\": \"Részletek megjelenítése\",\n  \"shut-down\": \"Leállítás\",\n  \"shut-down.complete\": \"Leállítás kész\",\n  \"shut-down.complete-text\": \"Most már kihúzhatod az eszközt az áramforrásból.\",\n  \"shut-down.confirm.submit\": \"Leállítás\",\n  \"shut-down.confirm.title\": \"Biztosan le akarod állítani az Umbrel-t?\",\n  \"shut-down.failed\": \"Leállítás sikertelen: {{message}}\",\n  \"shut-down.shutting-down\": \"Leállítás\",\n  \"shut-down.shutting-down-message\": \"Ne frissítsd ezt az oldalt vagy kapcsold ki az Umbrel-t, amíg leáll.\",\n  \"software-update.callout\": \"Ne frissítsd ezt az oldalt vagy kapcsold ki az Umbrel-t, amíg frissít.\",\n  \"software-update.check\": \"Frissítés ellenőrzése\",\n  \"software-update.checking\": \"Frissítés ellenőrzése...\",\n  \"software-update.current-running\": \"Az aktuális verzió\",\n  \"software-update.failed\": \"Frissítés sikertelen\",\n  \"software-update.failed-to-check\": \"Nem sikerült ellenőrizni a frissítéseket\",\n  \"software-update.failed.retry\": \"Újrapróbálás\",\n  \"software-update.install-now\": \"Telepítés most\",\n  \"software-update.new-version\": \"Új {{name}} elérhető a telepítéshez\",\n  \"software-update.on-latest\": \"A legfrissebb umbrelOS verziót használod\",\n  \"software-update.see-whats-new\": \"Nézd meg a <linked>újdonságokat</linked>\",\n  \"software-update.title\": \"Szoftverfrissítés\",\n  \"software-update.updating-to\": \"{{name}} frissítése\",\n  \"software-update.view\": \"Megtekintés\",\n  \"something-left\": \"{{left}} maradt\",\n  \"something-went-wrong\": \"⚠ Valami hiba történt\",\n  \"start\": \"Indítás\",\n  \"stop\": \"Leállítás\",\n  \"storage\": \"Tárhely\",\n  \"storage-manager\": \"Tárolókezelő\",\n  \"storage-manager.add\": \"Hozzáadás\",\n  \"storage-manager.add-to-raid.add-ssd\": \"SSD hozzáadása\",\n  \"storage-manager.add-to-raid.available\": \"Elérhető:\",\n  \"storage-manager.add-to-raid.description\": \"Új SSD lett észlelve, és készen áll a hozzáadásra.\",\n  \"storage-manager.add-to-raid.enable-failsafe\": \"FailSafe engedélyezése\",\n  \"storage-manager.add-to-raid.failed-add\": \"Nem sikerült hozzáadni az SSD-t.\",\n  \"storage-manager.add-to-raid.failed-enable-failsafe\": \"Nem sikerült bekapcsolni a FailSafe-t.\",\n  \"storage-manager.add-to-raid.failsafe-label\": \"FailSafe:\",\n  \"storage-manager.add-to-raid.info-capacity-added\": \"Az új <highlight>{{size}}</highlight> SSD hozzáadódik az elérhető tárhelyhez.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-available\": \"Az új <highlight>{{size}}</highlight> SSD <highlight>{{available}}</highlight> elérhető tárhelyet ad hozzá.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-both\": \"Az új <highlight>{{size}}</highlight> SSD <highlight>{{available}}</highlight> elérhető tárhelyet és <highlight>{{protection}}</highlight> adatvédelmet ad hozzá.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only\": \"Az új <highlight>{{size}}</highlight> SSD <highlight>{{protection}}</highlight> adatvédelmet ad hozzá.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only-full\": \"Az új <highlight>{{size}}</highlight> SSD teljes egészében az adatok védelmére lesz használva.\",\n  \"storage-manager.add-to-raid.info-data-safe\": \"Ha egy SSD meghibásodik, az adataid biztonságban lesznek.\",\n  \"storage-manager.add-to-raid.info-no-protection\": \"Ha egy SSD meghibásodik, elveszítheted az adataidat.\",\n  \"storage-manager.add-to-raid.info-total-wasted\": \"<wasted>{{size}}</wasted> összesen használhatatlan a különböző SSD-méretek miatt.\",\n  \"storage-manager.add-to-raid.info-wasted\": \"<wasted>{{size}}</wasted> használhatatlan lesz a különböző SSD-méretek miatt.\",\n  \"storage-manager.add-to-raid.recommended\": \"Ajánlott\",\n  \"storage-manager.add-to-raid.recommended-inline\": \"(ajánlott)\",\n  \"storage-manager.add-to-raid.restart-active-tasks\": \"Az aktív feladatok megszakadnak\",\n  \"storage-manager.add-to-raid.restart-after\": \"Az újraindítás után a FailSafe beállítása automatikusan befejeződik, és folytathatod a normál használatot.\",\n  \"storage-manager.add-to-raid.restart-during\": \"Az újraindítás közben:\",\n  \"storage-manager.add-to-raid.restart-intro\": \"A folyamat alatt továbbra is normálisan használhatod az umbrelOS-t. Azonban 50% elérésénél az Umbrel automatikusan újraindul.\",\n  \"storage-manager.add-to-raid.restart-required\": \"Rendszer újraindítás szükséges\",\n  \"storage-manager.add-to-raid.restart-ui-inaccessible\": \"Az umbrelOS ideiglenesen nem lesz elérhető\",\n  \"storage-manager.add-to-raid.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD a <highlight>{{slot}}. foglalatban</highlight>\",\n  \"storage-manager.add-to-raid.title\": \"SSD hozzáadása a tárhelyhez\",\n  \"storage-manager.add-to-raid.too-small\": \"Az SSD túl kicsi\",\n  \"storage-manager.add-to-raid.too-small-description\": \"Ez az SSD ({{deviceSize}}) kisebb, mint a jelenleg telepített legkisebb SSD ({{minSize}}). A FailSafe megköveteli, hogy minden SSD legalább akkora legyen, mint a legkisebb használt SSD.\",\n  \"storage-manager.add-to-raid.understand-continue\": \"Értem, folytatom\",\n  \"storage-manager.add-to-raid.warning-failsafe-now-only\": \"Ha több SSD-d van, a FailSafe-et most kell engedélyezni. Később már nem lesz lehetőség bekapcsolni.\",\n  \"storage-manager.add-to-raid.wasted-label\": \"Használhatatlan:\",\n  \"storage-manager.available-storage\": \"Elérhető tárhely\",\n  \"storage-manager.description\": \"Az SSD-k tárhelyének, állapotának és beállításainak megtekintése\",\n  \"storage-manager.empty\": \"Üres\",\n  \"storage-manager.failsafe-transition-failed\": \"Nem sikerült bekapcsolni a FailSafe-t.\",\n  \"storage-manager.for-failsafe\": \"FailSafe-hez\",\n  \"storage-manager.health.checksum-errors\": \"Ellenőrzőösszeg-hibák: {{count}}\",\n  \"storage-manager.health.critical\": \"Kritikus\",\n  \"storage-manager.health.critical-threshold\": \"Kritikus szint\",\n  \"storage-manager.health.current-temperature\": \"Aktuális hőmérséklet\",\n  \"storage-manager.health.estimated-life\": \"Becsült hátralévő élettartam\",\n  \"storage-manager.health.general\": \"Általános\",\n  \"storage-manager.health.health-status\": \"Állapot\",\n  \"storage-manager.health.low\": \"Alacsony\",\n  \"storage-manager.health.model-and-capacity\": \"Modell és méret\",\n  \"storage-manager.health.overheating\": \"Túlmelegedés\",\n  \"storage-manager.health.raid-failed-advice\": \"Ennek az SSD-nek problémája van. Kapcsold ki az Umbrelt, és ellenőrizd az SSD csatlakozását. Ha a hiba továbbra is fennáll, lehet, hogy ki kell cserélni az SSD-t.\",\n  \"storage-manager.health.read-errors\": \"Olvasási hibák: {{count}}\",\n  \"storage-manager.health.serial-number\": \"Sorozatszám\",\n  \"storage-manager.health.status-healthy\": \"Egészséges\",\n  \"storage-manager.health.status-unhealthy\": \"Rossz állapot\",\n  \"storage-manager.health.status-unknown\": \"Ismeretlen\",\n  \"storage-manager.health.temperature\": \"Hőmérséklet\",\n  \"storage-manager.health.title\": \"SSD állapota\",\n  \"storage-manager.health.warning-life-advice\": \"Érdemes lehet hamarosan kicserélni ezt az SSD-t.\",\n  \"storage-manager.health.warning-life-message\": \"Csak {{percent}}% élettartam maradt\",\n  \"storage-manager.health.warning-temp-advice\": \"Győződj meg róla, hogy az Umbrel Pro megfelelő légáramlással rendelkezik, és az SSD rendesen be van helyezve.\",\n  \"storage-manager.health.warning-temp-critical\": \"A hőmérséklet kritikus ({{temperature}})\",\n  \"storage-manager.health.warning-temp-overheating\": \"A meghajtó túlmelegszik ({{temperature}})\",\n  \"storage-manager.health.warning-threshold\": \"Figyelmeztetési küszöb\",\n  \"storage-manager.health.warning-unhealthy-advice\": \"Ez az SSD hamarosan meghibásodhat. Fontold meg a cseréjét.\",\n  \"storage-manager.health.warning-unhealthy-message\": \"Lehet, hogy az SSD-vel probléma van.\",\n  \"storage-manager.health.warnings\": \"Figyelmeztetések\",\n  \"storage-manager.health.wear\": \"Elhasználódás\",\n  \"storage-manager.health.write-errors\": \"Írási hibák: {{count}}\",\n  \"storage-manager.install-ssd.description\": \"Adj hozzá több SSD-t a tárhely bővítéséhez\",\n  \"storage-manager.install-ssd.step-insert\": \"Helyezd be az új SSD-ket az üres foglalatokba\",\n  \"storage-manager.install-ssd.step-power-on\": \"Kapcsold be a {{deviceName}}-t\",\n  \"storage-manager.install-ssd.step-remove-bottom-cover\": \"Távolítsd el a mágneses alsó fedőlapot\",\n  \"storage-manager.install-ssd.step-replace-bottom-cover\": \"Tedd vissza az alsó fedelet\",\n  \"storage-manager.install-ssd.step-return\": \"Térj vissza ide, hogy hozzáadd az SSD-ket a tárhelyhez\",\n  \"storage-manager.install-ssd.step-shut-down\": \"Kapcsold ki a {{deviceName}}-t\",\n  \"storage-manager.install-ssd.title\": \"SSD-k hozzáadása\",\n  \"storage-manager.install-tips.image-alt\": \"SSD beszerelési útmutató\",\n  \"storage-manager.install-tips.instructions\": \"A telepítéshez csavard ki a kézi csavart, majd ferdén csúsztasd be az SSD-t a foglalatba. Nyomd le az SSD-t, amíg rá nem ül a csavartartóra, majd rögzítsd a kézi csavarral.\",\n  \"storage-manager.install-tips.toggle\": \"Elfelejtetted, hogyan kell egy SSD-t behelyezni?\",\n  \"storage-manager.manage\": \"Kezelés\",\n  \"storage-manager.missing-ssd-warning\": \"Úgy tűnik, egy SSD hiányzik. Kapcsold ki az Umbrelt, és ellenőrizd, hogy minden SSD csatlakoztatva van-e. Ha a probléma továbbra is fennáll, lehet, hogy ki kell cserélni az SSD-t.\",\n  \"storage-manager.mode\": \"Mód\",\n  \"storage-manager.mode.failsafe\": \"FailSafe\",\n  \"storage-manager.mode.failsafe.description\": \"Megvédi az adataidat, ha egy SSD meghibásodik. Ha az SSD-k különböző méretűek, a nagyobbakon lévő extra hely nem használható.\",\n  \"storage-manager.mode.failsafe.info-description\": \"A FailSafe megvédi az adataidat azzal, hogy másolatokat tart az SSD-ken. Ha bármelyik SSD meghibásodik, az adataid biztonságban maradnak, és visszaállíthatók, amikor behelyezel egy csere SSD-t.\",\n  \"storage-manager.mode.failsafe.info-title\": \"A FailSafe-ről\",\n  \"storage-manager.mode.full-storage\": \"Full Storage\",\n  \"storage-manager.mode.full-storage.description\": \"Használd az összes SSD helyét egyben. Ha egy SSD meghibásodik, elveszítheted az adataidat.\",\n  \"storage-manager.mode.full-storage.info-description\": \"A Full Storage az összes SSD-t egy nagy közös tárhellyé egyesíti, így maximális tárhelyet kapsz. Azonban ha bármelyik SSD meghibásodik, az összes adatod elveszik.\",\n  \"storage-manager.mode.full-storage.info-title\": \"A Full Storage-ról\",\n  \"storage-manager.mode.switch-from-failsafe-unavailable\": \"A FailSafe-ről Full Storage módra váltáshoz biztonsági mentést kell készítened, gyári visszaállítást kell végrehajtani a készüléken, majd visszaállítani a mentésből.\",\n  \"storage-manager.mode.switch-to-failsafe-unavailable\": \"Több SSD esetén Full Storage módban az adataid az összes meghajtón szétoszlanak. FailSafe módra váltáshoz biztonsági mentést kell készítened, gyári visszaállítást végrehajtani és visszaállítani.\",\n  \"storage-manager.mode.why-cant-switch\": \"Miért nem tudok váltani?\",\n  \"storage-manager.operation-in-progress.shutdown-description\": \"Biztonságos kikapcsolni. A művelet szünetel, és folytatódik az újraindítást követően, de be kell fejeződnie, mielőtt más módosításokat végezhetsz.\",\n  \"storage-manager.operation-in-progress.shutdown-title\": \"A tárhelyed frissítése folyamatban van\",\n  \"storage-manager.operation-in-progress.wait-description\": \"Várj, amíg az aktuális művelet befejeződik, mielőtt további módosításokat végeznél.\",\n  \"storage-manager.operation-in-progress.wait-title\": \"A tárhelyed frissítése folyamatban van\",\n  \"storage-manager.operation.adding-ssd\": \"SSD hozzáadása...\",\n  \"storage-manager.operation.enabling-failsafe\": \"FailSafe engedélyezése...\",\n  \"storage-manager.operation.expanding\": \"Tárhely bővítése...\",\n  \"storage-manager.operation.rebuilding\": \"Adatok újjáépítése...\",\n  \"storage-manager.operation.replacing\": \"Meghajtó cseréje...\",\n  \"storage-manager.operation.restarting\": \"Újraindítás...\",\n  \"storage-manager.operation.starting\": \"Indítás...\",\n  \"storage-manager.operation.syncing-restarts\": \"Adatok szinkronizálása • 50%-nál újraindul\",\n  \"storage-manager.raid-status.degraded\": \"Csökkentett\",\n  \"storage-manager.raid-status.failed\": \"Meghibásodott\",\n  \"storage-manager.raid-status.offline\": \"Offline\",\n  \"storage-manager.raid-status.online\": \"Online\",\n  \"storage-manager.raid-status.removed\": \"Eltávolítva\",\n  \"storage-manager.raid-status.unavailable\": \"Nem elérhető\",\n  \"storage-manager.replace\": \"Cserélés\",\n  \"storage-manager.replace-failed.degraded\": \"A FailSafe védelem csökkent\",\n  \"storage-manager.replace-failed.degraded-description\": \"Egy SSD hiányzik a FailSafe tárolódból. Cseréld ki, hogy visszaálljon a teljes védelem.\",\n  \"storage-manager.replace-failed.description\": \"Használd ezt az SSD-t a FailSafe védelem visszaállításához.\",\n  \"storage-manager.replace-failed.error\": \"Nem sikerült elindítani a cserét\",\n  \"storage-manager.replace-failed.replace-now\": \"Cseréld most\",\n  \"storage-manager.replace-failed.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD a {{slot}}. slotban\",\n  \"storage-manager.replace-failed.step-protected\": \"A befejezés után az adataid ismét teljesen védettek lesznek\",\n  \"storage-manager.replace-failed.step-rebuild\": \"Az adatok visszaépülnek az új SSD-re\",\n  \"storage-manager.replace-failed.step-time\": \"Ez eltarthat egy ideig, attól függően, mennyi adatod van\",\n  \"storage-manager.replace-failed.title\": \"SSD cseréje\",\n  \"storage-manager.replace-failed.too-small\": \"Az SSD túl kicsi\",\n  \"storage-manager.replace-failed.too-small-description\": \"Ez az SSD ({{deviceSize}}) kisebb, mint a FailSafe tárolódhoz szükséges minimum ({{minSize}}).\",\n  \"storage-manager.replace-failed.what-happens\": \"Mi történik ezután:\",\n  \"storage-manager.ssd-failing\": \"Hibásodik\",\n  \"storage-manager.swap\": \"Csere\",\n  \"storage-manager.swap.data-erased-description\": \"A Full Storage módban nincs adatvédelem. A gyári visszaállítás során a {{deviceName}} összes adata törlődni fog. Ments le mindent előtte.\",\n  \"storage-manager.swap.data-protected\": \"Az adataid védettek\",\n  \"storage-manager.swap.data-protected-description\": \"Ha a FailSafe engedélyezve van, bármelyik egyetlen SSD kicserélhető anélkül, hogy adatot veszítenél. Mentés nem szükséges.\",\n  \"storage-manager.swap.data-will-be-erased\": \"Az adatok törlődnek\",\n  \"storage-manager.swap.description-failsafe\": \"Cserélj ki egy meghajtót a FailSafe tárolódban.\",\n  \"storage-manager.swap.description-full-storage\": \"Cserélj ki egy meghajtót a Full Storage beállításodban.\",\n  \"storage-manager.swap.description-no-free-slot\": \"Full Storage módban, ha az összes foglalat foglalt, az SSD cseréje teljes mentést és visszaállítást igényel.\",\n  \"storage-manager.swap.description-replace\": \"Migráld át az adataidat az új SSD-re, majd távolítsd el a régit.\",\n  \"storage-manager.swap.failed-to-start\": \"Nem sikerült elindítani a cserefolyamatot.\",\n  \"storage-manager.swap.no-data-loss\": \"Nincs adatvesztés\",\n  \"storage-manager.swap.no-data-loss-description\": \"Az adataid át lesznek másolva az új SSD-re. Ha kész, biztonságosan eltávolíthatod a régit.\",\n  \"storage-manager.swap.safe-swap-available\": \"Biztonságos csere elérhető\",\n  \"storage-manager.swap.safe-swap-description\": \"Mivel van egy üres foglalatod, először hozzáadhatod az új SSD-t és átmigrálhatod az adatokat a régit eltávolítása előtt. Mentés nem szükséges.\",\n  \"storage-manager.swap.select-new-ssd\": \"Válaszd ki a használni kívánt új SSD-t:\",\n  \"storage-manager.swap.ssd-in-slot\": \"{{size}} SSD a {{slot}}. foglalatban\",\n  \"storage-manager.swap.step-backup\": \"Készíts biztonsági mentést az adataidról\",\n  \"storage-manager.swap.step-backup-description\": \"Lépj a Beállítások → Backups menübe, és készíts mentést az összes adatodról.\",\n  \"storage-manager.swap.step-data-copied\": \"Az adatok át lesznek másolva a régi SSD-ről az újra\",\n  \"storage-manager.swap.step-factory-reset\": \"Gyári visszaállítás\",\n  \"storage-manager.swap.step-factory-reset-description\": \"Nyisd meg a Beállítások → Speciális → Gyári visszaállítás menüpontot, és töröld a {{deviceName}}-t.\",\n  \"storage-manager.swap.step-insert-new-ssd\": \"Helyezd be az új SSD-t egy üres foglalatba\",\n  \"storage-manager.swap.step-may-take-while\": \"Ez az adatmennyiségtől függően eltarthat egy ideig.\",\n  \"storage-manager.swap.step-power-on\": \"Kapcsold be a {{deviceName}}-t\",\n  \"storage-manager.swap.step-remove-bottom-cover\": \"Távolítsd el a mágneses alsó fedőlapot\",\n  \"storage-manager.swap.step-remove-old\": \"Ha kész, kapcsold ki, és távolítsd el a {{ssd}}-t.\",\n  \"storage-manager.swap.step-replace-bottom-cover\": \"Helyezd vissza az alsó fedőlapot\",\n  \"storage-manager.swap.step-restore\": \"Állítsd vissza az adataidat\",\n  \"storage-manager.swap.step-restore-description\": \"Lépj a Beállítások → Backups menübe, és állítsd vissza a mentésedet.\",\n  \"storage-manager.swap.step-return-to-storage-manager\": \"Térj vissza ide a Storage Managerbe, hogy megerősítsd a cserét és hozzáadd az új SSD-t a tárhelyedhez.\",\n  \"storage-manager.swap.step-return-to-swap\": \"Térj vissza ide a Storage Managerbe, és kattints ismét a Cserélés gombra a csere elindításához.\",\n  \"storage-manager.swap.step-setup-new-storage\": \"Állítsd be az új tárhelyet\",\n  \"storage-manager.swap.step-setup-new-storage-description\": \"Kapcsold be a {{deviceName}}-t, és fejezd be a beállítást az új SSD-vel.\",\n  \"storage-manager.swap.step-shut-down\": \"Kapcsold ki a {{deviceName}}-t\",\n  \"storage-manager.swap.step-shut-down-and-swap\": \"Kapcsold ki, majd cseréld ki a {{ssd}}-t\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-other\": \"Kapcsold ki, nyisd fel a készüléket, cseréld ki az SSD-t, majd szereld vissza.\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-pro\": \"Kapcsold ki, távolítsd el az alsó fedőlapot, cseréld ki az SSD-t, majd zárd vissza a fedőlapot.\",\n  \"storage-manager.swap.step-swap-ssd\": \"Cseréld ki a {{ssd}}-t egy azonos méretű új SSD-re.\",\n  \"storage-manager.swap.too-small\": \"Túl kicsi ({{size}} szükséges)\",\n  \"storage-manager.swap.what-happens-next\": \"Mi történik ezután:\",\n  \"storage-manager.total-capacity-added\": \"Hozzáadott teljes kapacitás\",\n  \"storage-manager.umbrel-pro\": \"Umbrel Pro\",\n  \"storage-manager.used\": \"Használt\",\n  \"storage-manager.wasted\": \"Használhatatlan\",\n  \"storage-manager.wasted-size\": \"{{size}} használhatatlan\",\n  \"storage.full\": \"Tárhely megtelt\",\n  \"storage.low\": \"Kevés tárhely\",\n  \"temperature\": \"Hőmérséklet\",\n  \"temperature.dangerously-hot\": \"Nagyon forró\",\n  \"temperature.nice\": \"Kellemes\",\n  \"temperature.normal\": \"Normál\",\n  \"temperature.too-hot-suggestion\": \"Fontold meg az eszköz környezetének megváltoztatását.\",\n  \"temperature.warm\": \"Meleg\",\n  \"terminal\": \"Terminál\",\n  \"terminal-description\": \"Egyedi parancsok futtatása az umbrelOS-ben vagy egy alkalmazáson belül\",\n  \"terminal.app\": \"Alkalmazás\",\n  \"terminal.app-description\": \"Egyedi parancsok futtatása egy adott alkalmazáson belül\",\n  \"terminal.umbrelos-description\": \"Egyedi parancsok futtatása az umbrelOS-ben\",\n  \"tor-description\": \"Hozzáférés az Umbrel-hez bárhonnan egy Tor böngésző segítségével\",\n  \"tor-enabled-description\": \"Az Umbrel-hez bárhonnan hozzáférhetsz a következő URL-en keresztül Tor böngészővel:\",\n  \"tor-error\": \"Tor-beállítás frissítése sikertelen: {{message}}\",\n  \"tor.disable.description\": \"Ez néhány percet igénybe vehet\",\n  \"tor.disable.progress\": \"Távoli Tor-hozzáférés letiltása\",\n  \"tor.enable.description\": \"Ez néhány percet igénybe vehet\",\n  \"tor.enable.mobile.switch-label\": \"Távoli Tor hozzáférés engedélyezése\",\n  \"tor.hidden-service\": \"Tor rejtett szolgáltatás URL\",\n  \"troubleshoot\": \"Hibaelhárítás\",\n  \"troubleshoot-description\": \"umbrelOS vagy egy alkalmazás hibaelhárítása\",\n  \"troubleshoot-no-logs-yet\": \"Nincs napló\",\n  \"troubleshoot-pick-title\": \"Hibaelhárítás\",\n  \"troubleshoot.app\": \"Alkalmazás\",\n  \"troubleshoot.app-description\": \"Egy alkalmazás naplóinak megtekintése az Umbrel-en\",\n  \"troubleshoot.app-download\": \"{{app}} naplók letöltése\",\n  \"troubleshoot.share-with-umbrel-support\": \"Megosztás az Umbrel támogatással\",\n  \"troubleshoot.system-download\": \"{{label}} letöltése\",\n  \"troubleshoot.umbrelos-description\": \"umbrelOS naplók megtekintése\",\n  \"troubleshoot.umbrelos-logs\": \"umbrelOS naplók\",\n  \"trpc.backend-unavailable\": \"Hiba: Nem sikerült kapcsolódni a rendszer API-hoz\",\n  \"trpc.checking-backend\": \"Betöltés...\",\n  \"try-again\": \"Újrapróbálás\",\n  \"umbrel\": \"Umbrel\",\n  \"umbrelos\": \"umbrelOS\",\n  \"unknown\": \"Ismeretlen\",\n  \"unknown-app\": \"Ismeretlen alkalmazás\",\n  \"unknown-error\": \"Ismeretlen hiba\",\n  \"uptime\": \"Üzemidő\",\n  \"url\": \"URL\",\n  \"wallpaper\": \"Háttérkép\",\n  \"wallpaper-description\": \"Az Umbrel háttérképed és témád\",\n  \"whats-new.continue\": \"Folytatás\",\n  \"whats-new.feature-1.description\": \"Állíts be automatikus, titkosított biztonsági mentéseket az egész Umbrelről egy külső USB-meghajtóra, egy NAS-ra vagy egy másik Umbrelre.\",\n  \"whats-new.feature-2.description\": \"Lépj vissza az időben, és állíts vissza konkrét fájlokat és mappákat korábbi mentésekből.\",\n  \"whats-new.feature-3.description\": \"Vagy állítsd vissza az egész Umbreledet, beleértve az összes alkalmazást, fájlt és adatot.\",\n  \"whats-new.feature-4.description\": \"Csatlakoztass egy NAS-t vagy egy másik Umbrelt, majd a Files alkalmazásból elérheted a tárolóját.\",\n  \"whats-new.feature-4.title\": \"Hálózati eszközök\",\n  \"whats-new.feature-5.description\": \"Csatlakoztass külső USB-meghajtókat (Umbrel Home-on vagy bármely Intel vagy AMD eszközön), és érd el őket a Files alkalmazásból.\",\n  \"whats-new.feature-5.helper-text\": \"Raspberry Pi eszközökön nem támogatott a lehetséges áramellátási problémák miatt.\",\n  \"whats-new.feature-5.title\": \"Külső tároló\",\n  \"whats-new.next\": \"Következő\",\n  \"whats-new.title\": \"{{version}} újdonságai\",\n  \"widget.progress.in-progress\": \"Folyamatban\",\n  \"widgets.edit.select-up-to-3-widgets\": \"Válassz ki legfeljebb 3 widgetet\",\n  \"widgets.install-an-app-before-using-widgets\": \"Telepíts egy alkalmazást, hogy elkezdhesd testre szabni a kezdőképernyőt widgetekkel.\",\n  \"wifi\": \"Wi-Fi\",\n  \"wifi-connect-insecure-message\": \"A nyílt hálózatok nem biztonságosak lehetnek\",\n  \"wifi-connection-failed\": \"Nem sikerült csatlakozni\",\n  \"wifi-dangerous-change-confirmation-description\": \"A Wi-Fi hálózat megváltoztatása megszakíthatja az Umbrel-hez való csatlakozásodat. A csatlakozás helyreállításához győződj meg arról, hogy mind az Umbrel, mind az eszköz, amelyről hozzáférsz, ugyanazon a hálózaton van.\",\n  \"wifi-dangerous-change-confirmation-title\": \"Biztosan meg akarod változtatni a Wi-Fi hálózatot?\",\n  \"wifi-dangerous-disable-confirmation-description\": \"A Wi-Fi letiltása megszakíthatja az Umbrel-hez való csatlakozásodat. A csatlakozás helyreállításához csatlakoztass egy Ethernet kábelt az Umbrel-hez, és győződj meg arról, hogy mind az Umbrel, mind az eszköz, amelyről hozzáférsz, ugyanazon a hálózaton van.\",\n  \"wifi-dangerous-disable-confirmation-title\": \"Biztosan le akarod tiltani a Wi-Fi-t?\",\n  \"wifi-description\": \"Csatlakoztasd az eszközödet egy Wi-Fi hálózathoz\",\n  \"wifi-description-long\": \"Az eszközöd a választott Wi-Fi-hez marad csatlakozva, még akkor is, ha az Ethernet kábel eltávolításra kerül, és automatikusan újracsatlakozik a Wi-Fi-hez indításkor.\",\n  \"wifi-no-networks-message\": \"Nem található Wi-Fi hálózat\",\n  \"wifi-searching\": \"Wi-Fi hálózatok keresése...\",\n  \"wifi-unsupported-device-description\": \"Ez az eszköz nem támogatja a Wi-Fi-t. Ez hiányzó vagy inkompatibilis vezeték nélküli adapter miatt lehet.\",\n  \"wifi-view-networks\": \"Hálózatok megtekintése\"\n}"
  },
  {
    "path": "packages/ui/public/locales/it.json",
    "content": "{\n  \"2fa\": \"2FA\",\n  \"2fa-description\": \"Un secondo livello di sicurezza per il tuo login e app Umbrel\",\n  \"2fa.disable.title\": \"Disabilita l'autenticazione a due fattori\",\n  \"2fa.enable.or-paste\": \"Oppure incolla il seguente codice nella tua app di autenticazione\",\n  \"2fa.enable.scan-this\": \"Scansiona questo codice QR utilizzando un'app di autenticazione come Google Authenticator o Authy\",\n  \"2fa.enable.title\": \"Abilita l'autenticazione a due fattori\",\n  \"2fa.enter-code\": \"Inserisci il codice visualizzato nella tua app di autenticazione\",\n  \"account\": \"Account\",\n  \"account-description\": \"Il tuo nome e password\",\n  \"advanced-settings\": \"Impostazioni avanzate\",\n  \"advanced-settings-description\": \"Terminale, Programma Beta di umbrelOS, Cloudflare DNS e altro ancora\",\n  \"app-not-found\": \"App non trovata: {{app}}\",\n  \"app-only-over-tor\": \"L'app {{app}} è utilizzabile solo tramite Tor. Per aprirla, accedi al tuo Umbrel con un browser Tor usando l'URL di accesso remoto (Impostazioni > Impostazioni avanzate > Accesso Tor remoto).\",\n  \"app-page.section.about\": \"Informazioni\",\n  \"app-page.section.credentials.title\": \"Credenziali predefinite\",\n  \"app-page.section.dependencies.n-alternatives\": \"Vedi {{count}} alternative\",\n  \"app-page.section.info.compatibility\": \"Compatibilità\",\n  \"app-page.section.info.compatibility-compatible\": \"Compatibile\",\n  \"app-page.section.info.compatibility-not-compatible\": \"Non compatibile\",\n  \"app-page.section.info.developer\": \"Sviluppatore\",\n  \"app-page.section.info.source-code\": \"Codice Sorgente\",\n  \"app-page.section.info.source-code.public\": \"Pubblico\",\n  \"app-page.section.info.submitted-by\": \"Inviato da\",\n  \"app-page.section.info.support\": \"Ottieni supporto\",\n  \"app-page.section.info.title\": \"Info\",\n  \"app-page.section.info.version\": \"Versione\",\n  \"app-page.section.recommendations.title\": \"Potrebbero interessarti anche\",\n  \"app-page.section.release-notes.title\": \"Novità\",\n  \"app-page.section.release-notes.version\": \"Versione {{version}}\",\n  \"app-page.section.requires\": \"Richiede\",\n  \"app-picker.search\": \"Cerca...\",\n  \"app-picker.select-app\": \"Seleziona app...\",\n  \"app-settings.connected-to\": \"{{appName}} è connesso a queste app\",\n  \"app-settings.save-changes\": \"Salva modifiche\",\n  \"app-settings.title\": \"Impostazioni\",\n  \"app-store.browse-category-apps\": \"Esplora le app {{category}}\",\n  \"app-store.category.ai\": \"AI\",\n  \"app-store.category.all\": \"Tutte le app\",\n  \"app-store.category.automation\": \"Casa & Automazione\",\n  \"app-store.category.bitcoin\": \"Bitcoin\",\n  \"app-store.category.crypto\": \"Cripto\",\n  \"app-store.category.developer\": \"Strumenti per Sviluppatori\",\n  \"app-store.category.discover\": \"Scopri\",\n  \"app-store.category.files\": \"File & Produttività\",\n  \"app-store.category.finance\": \"Finanza\",\n  \"app-store.category.media\": \"Media\",\n  \"app-store.category.networking\": \"Networking\",\n  \"app-store.category.social\": \"Social\",\n  \"app-store.description\": \"Le tue impostazioni di aggiornamento app\",\n  \"app-store.discover.temporarily-unavailable-description\": \"Sfoglia le categorie qui sopra o usa la ricerca per trovare app\",\n  \"app-store.discover.temporarily-unavailable-title\": \"Contenuti in evidenza temporaneamente non disponibili\",\n  \"app-store.menu.community-app-stores\": \"Community App Store\",\n  \"app-store.search-apps\": \"Cerca app\",\n  \"app-store.search.no-results\": \"Nessun risultato\",\n  \"app-store.search.results-for\": \"Risultati per\",\n  \"app-store.title\": \"App Store\",\n  \"app-store.updates\": \"Aggiornamenti\",\n  \"app-updates.less\": \"meno\",\n  \"app-updates.more\": \"più\",\n  \"app-updates.no-updates\": \"Tutte le app sono aggiornate!\",\n  \"app-updates.update\": \"Aggiorna\",\n  \"app-updates.update-all\": \"Aggiorna tutto\",\n  \"app-updates.updates-available-count_one\": \"{{count}} aggiornamento disponibile\",\n  \"app-updates.updates-available-count_other\": \"{{count}} aggiornamenti disponibili\",\n  \"app-updates.updating\": \"Aggiornamento in corso...\",\n  \"app.install\": \"Installa\",\n  \"app.installed\": \"Installata\",\n  \"app.installing\": \"Installazione\",\n  \"app.offline\": \"Non in esecuzione\",\n  \"app.open\": \"Apri\",\n  \"app.optimized-for-umbrel-home\": \"Ottimizzato per Umbrel Home\",\n  \"app.os-update-required.confirm\": \"Controlla aggiornamento di umbrelOS\",\n  \"app.os-update-required.description\": \"{{appName}} richiede umbrelOS {{version}} o successivo\",\n  \"app.os-update-required.title\": \"Aggiorna umbrelOS\",\n  \"app.restarting\": \"Riavvio in corso\",\n  \"app.starting\": \"Avvio in corso\",\n  \"app.stopping\": \"Fermata in corso\",\n  \"app.uninstall.confirm.description\": \"Tutti i dati associati a {{app}} verranno eliminati permanentemente. Questa azione non può essere annullata.\",\n  \"app.uninstall.confirm.submit\": \"Disinstalla\",\n  \"app.uninstall.confirm.title\": \"Disinstallare {{app}}?\",\n  \"app.uninstall.deps.used-by.description_one\": \"Disinstalla prima {{firstAppToUninstall}} per disinstallare {{app}}.\",\n  \"app.uninstall.deps.used-by.description_other\": \"Disinstalla prima queste app per disinstallare {{app}}.\",\n  \"app.uninstall.deps.used-by.title\": \"{{app}} è utilizzata da\",\n  \"app.uninstalling\": \"Disinstallazione\",\n  \"app.updating\": \"Aggiornamento\",\n  \"app.view\": \"Visualizza\",\n  \"app_one\": \"app\",\n  \"app_other\": \"app\",\n  \"apps.uninstall.failed-to-get-required-apps\": \"Impossibile ottenere le app richieste\",\n  \"apps.uninstalled-all.success\": \"Tutte le app disinstallate\",\n  \"auth.checking-backend-for-user\": \"Caricamento...\",\n  \"auth.failed-checking-if-user-logged-in\": \"Errore: Controllo di accesso fallito\",\n  \"auth.failed-to-check-if-user-exists\": \"Errore: Controllo di esistenza fallito\",\n  \"back\": \"Indietro\",\n  \"backups\": \"Backups\",\n  \"backups-configure\": \"Configura\",\n  \"backups-configure.add-backup-location\": \"Aggiungi posizione di backup\",\n  \"backups-configure.available\": \"Disponibile\",\n  \"backups-configure.awaiting-next-backup\": \"In attesa del prossimo backup automatico\",\n  \"backups-configure.back-up-now\": \"Esegui il backup ora\",\n  \"backups-configure.backing-up-now\": \"Backup in corso...\",\n  \"backups-configure.connected\": \"Connesso\",\n  \"backups-configure.connection\": \"Connessione\",\n  \"backups-configure.in-progress\": \"In corso\",\n  \"backups-configure.last-backup\": \"Ultimo backup\",\n  \"backups-configure.locations\": \"Posizioni\",\n  \"backups-configure.no-backup-locations\": \"Aggiungi una posizione di backup per iniziare a eseguire il backup dei tuoi dati\",\n  \"backups-configure.not-connected\": \"Non connesso\",\n  \"backups-configure.path\": \"Percorso\",\n  \"backups-configure.remove-backup-location\": \"Rimuovi posizione di backup\",\n  \"backups-configure.remove-backup-location-confirmation\": \"Sei sicuro?\",\n  \"backups-configure.remove-backup-location-confirmation-description\": \"Questo rimuoverà '{{device}}' dalle tue posizioni di backup. I backup esistenti su questo dispositivo non verranno eliminati, ma i backup automatici si interromperanno.\",\n  \"backups-configure.status\": \"Stato\",\n  \"backups-configure.total-backups\": \"Backups totali\",\n  \"backups-configure.used\": \"Utilizzato\",\n  \"backups-configure.view\": \"Visualizza\",\n  \"backups-description\": \"Esegui il backup di file, app e dati su un altro Umbrel, un NAS o un'unità esterna\",\n  \"backups-error.backup-not-found\": \"Il backup non è stato trovato.\",\n  \"backups-error.generic\": \"Qualcosa è andato storto: {{details}}.\",\n  \"backups-error.in-progress\": \"È già in esecuzione un backup. Attendi che termini.\",\n  \"backups-error.invalid-exclusion-path\": \"Possono essere esclusi dai backup solo i file e le cartelle nella tua cartella Home.\",\n  \"backups-error.invalid-password\": \"La password di crittografia non è corretta.\",\n  \"backups-error.invalid-path\": \"La posizione selezionata non è valida per i backup.\",\n  \"backups-error.mount-failed\": \"Impossibile accedere allo snapshot del backup.\",\n  \"backups-error.mount-timeout\": \"Impossibile accedere allo snapshot del backup. Riprova o verifica che il dispositivo sia collegato correttamente.\",\n  \"backups-error.not-enough-space\": \"Spazio insufficiente sul dispositivo di backup.\",\n  \"backups-error.not-found\": \"Il backup o la posizione di backup non sono stati trovati.\",\n  \"backups-error.repository-exists\": \"Esiste già una posizione di backup in questa cartella.\",\n  \"backups-error.repository-not-found\": \"La posizione di backup non è stata trovata.\",\n  \"backups-exclusions.add\": \"Aggiungi\",\n  \"backups-exclusions.app-paths-cannot-be-modified\": \"Questi file/cartelle sono impostati dallo sviluppatore dell'app e non possono essere modificati:\",\n  \"backups-exclusions.app-paths-explanation\": \"Questa app esclude i seguenti dati dal backup. Questi percorsi di solito contengono elementi non essenziali (come cache o log che possono essere ricreati) o dati che potrebbero causare problemi se ripristinati (ad esempio stati dell'app obsoleti che potrebbero portare a conflitti o incoerenze).\",\n  \"backups-exclusions.auto-excluded\": \"Esclusi automaticamente\",\n  \"backups-exclusions.exclude-entire-app\": \"Escludi l'app completa\",\n  \"backups-exclusions.excluded-apps\": \"App escluse\",\n  \"backups-exclusions.files-and-folders\": \"File e cartelle escluse\",\n  \"backups-exclusions.no-excluded-apps\": \"Nessuna app esclusa\",\n  \"backups-exclusions.no-excluded-files-or-folders\": \"Nessun file o cartella esclusi\",\n  \"backups-exclusions.select-item-to-exclude\": \"Seleziona l'elemento da escludere\",\n  \"backups-exclusions.stop-excluding\": \"Annulla esclusione\",\n  \"backups-floating-island.backing-up\": \"Backup in corso...\",\n  \"backups-floating-island.backing-up-to\": \"Eseguendo il backup del tuo Umbrel...\",\n  \"backups-restore\": \"Ripristina\",\n  \"backups-restore-full\": \"Ripristino completo\",\n  \"backups-restore-full-description\": \"Ripristina completamente il tuo Umbrel da un backup\",\n  \"backups-restore-header\": \"Ripristina il tuo Umbrel\",\n  \"backups-restore-pro.after-restore\": \"Dopo il ripristino, il tuo account temporaneo verrà sostituito dal tuo account di backup e dai relativi dati.\",\n  \"backups-restore-pro.step1\": \"Completa la configurazione iniziale facendo clic su \\\"Get Started\\\" qui sotto. Questo sarà il tuo account temporaneo fino a quando non ripristinerai il tuo account di backup.\",\n  \"backups-restore-pro.step2\": \"Una volta completata la configurazione, vai su <0>Impostazioni → Backups → Ripristina</0>\",\n  \"backups-restore-pro.step3\": \"Segui le istruzioni della procedura guidata di ripristino.\",\n  \"backups-restore-pro.subtitle\": \"Il ripristino da un backup su Umbrel Pro richiede qualche passaggio in più\",\n  \"backups-restore.backup-date\": \"Data del backup\",\n  \"backups-restore.backup-location\": \"Posizione di backup\",\n  \"backups-restore.browse-cloud-subtitle\": \"Ripristina da Umbrel Private Cloud (in arrivo)\",\n  \"backups-restore.browse-cloud-title\": \"Umbrel Private Cloud\",\n  \"backups-restore.browse-external-subtitle\": \"Ripristina da un'unità USB esterna\",\n  \"backups-restore.browse-external-title\": \"Disco esterno\",\n  \"backups-restore.browse-nas-or-external\": \"Sfoglia un altro Umbrel, un NAS o un'unità esterna da cui ripristinare un backup\",\n  \"backups-restore.browse-nas-subtitle\": \"Ripristina da un altro dispositivo Umbrel o NAS sulla tua rete\",\n  \"backups-restore.browse-nas-title\": \"Un altro Umbrel o NAS\",\n  \"backups-restore.choose\": \"Scegli\",\n  \"backups-restore.choose-backup-location\": \"Scegli una posizione di backup\",\n  \"backups-restore.connect-to-backup-location\": \"Connetti a una posizione di backup\",\n  \"backups-restore.encryption-password\": \"Password di crittografia\",\n  \"backups-restore.encryption-password-description\": \"Inserisci la password di crittografia che hai impostato quando hai abilitato i backup\",\n  \"backups-restore.enter-password-to-confirm\": \"Inserisci la password di Umbrel per confermare\",\n  \"backups-restore.final-confirmation\": \"Sei sicuro?\",\n  \"backups-restore.final-confirmation-description\": \"Il ripristino da questo backup sostituirà le app e i dati attuali di umbrelOS con il contenuto del backup selezionato. Eventuali file, cartelle o app esclusi da questo backup verranno rimossi dal tuo Umbrel. Questa azione non può essere annullata.\",\n  \"backups-restore.invalid-password\": \"Password non valida\",\n  \"backups-restore.last-backup\": \"Ultimo backup: {{date}}\",\n  \"backups-restore.latest\": \"Più recente\",\n  \"backups-restore.no-backups-found\": \"Nessun backup trovato\",\n  \"backups-restore.no-backups-yet\": \"Ancora nessun backup\",\n  \"backups-restore.please-select-backup\": \"Seleziona un backup\",\n  \"backups-restore.please-select-repository\": \"Seleziona un repository\",\n  \"backups-restore.restore-from-nas-or-external\": \"Ripristina il tuo Umbrel da un backup presente su un altro Umbrel, su un NAS o su un'unità esterna\",\n  \"backups-restore.restore-from-unlisted\": \"Ripristina da un'altra posizione\",\n  \"backups-restore.restore-umbrel\": \"Ripristina Umbrel\",\n  \"backups-restore.restore-warning\": \"Il ripristino da questo backup sostituirà le app e i dati attuali di umbrelOS con il contenuto del backup selezionato. Eventuali file, cartelle o app esclusi da questo backup verranno rimossi dal tuo Umbrel. Apri <0>Rewind</0> se vuoi ripristinare invece file o cartelle specifici.\",\n  \"backups-restore.restoring-from\": \"Stai per ripristinare dal seguente backup:\",\n  \"backups-restore.review-description\": \"Il ripristino configurerà il tuo Umbrel con l'account, i file, le app e le impostazioni presenti al momento della creazione di questo backup. Potrebbe richiedere un po' di tempo. Al termine, la password di accesso sarà quella che hai usato quando il backup è stato creato.\",\n  \"backups-restore.select-backup\": \"Seleziona un backup\",\n  \"backups-restore.select-backup-description\": \"Seleziona il backup da cui vuoi ripristinare\",\n  \"backups-restore.select-backup-file\": \"Seleziona il file di backup\",\n  \"backups-restore.select-backup-file-only\": \"Puoi selezionare solo <bold>{{backupFileName}}</bold>.\",\n  \"backups-restore.total-size\": \"Dimensione totale\",\n  \"backups-restore.unknown-date\": \"Data sconosciuta\",\n  \"backups-restore.unknown-repository\": \"Repository sconosciuto\",\n  \"backups-rewind\": \"Rewind\",\n  \"backups-rewind-description\": \"Torna indietro nel tempo per ripristinare file e cartelle specifici\",\n  \"backups-rewind.start\": \"Avvia Rewind\",\n  \"backups-setup\": \"Configura\",\n  \"backups-setup-confirm\": \"Completa configurazione\",\n  \"backups-setup-external-description\": \"Esegui il backup su un'unità USB esterna\",\n  \"backups-setup-nas-or-umbrel-description\": \"Esegui il backup su un altro Umbrel o su un dispositivo NAS nella tua rete\",\n  \"backups-setup-umbrel-or-nas\": \"Un altro Umbrel o NAS\",\n  \"backups-setup-umbrel-private-cloud\": \"Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-cta\": \"Estendi la tua tranquillità oltre la casa con <bold>backup end-to-end crittografati</bold> su Umbrel Private Cloud.\",\n  \"backups-setup-umbrel-private-cloud-cta-link\": \"Richiedi accesso anticipato\",\n  \"backups-setup-umbrel-private-cloud-description\": \"Backup end-to-end crittografati su Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-subtitle\": \"Prossimamente\",\n  \"backups.add-umbrel-or-nas\": \"Aggiungi Umbrel o NAS\",\n  \"backups.all-apps-and-data-will-be-backed-up\": \"Tutte le app e i dati verranno sottoposti a backup\",\n  \"backups.apps-and-data\": \"App e dati\",\n  \"backups.backup-location\": \"Posizione di backup\",\n  \"backups.browse\": \"Sfoglia\",\n  \"backups.choose-folder-within-device\": \"Scegli una cartella all'interno di <bold>{{device}}</bold> dove salvare i tuoi backup\",\n  \"backups.confirm-password\": \"Conferma password\",\n  \"backups.copy\": \"Copia\",\n  \"backups.encryption\": \"Crittografia\",\n  \"backups.encryption-password-warning\": \"Assicurati di conservare la password di crittografia in modo sicuro, ad esempio in un gestore di password. Non potrai vederla di nuovo e ti servirà per ripristinare i backup.\",\n  \"backups.exclude-from-backups\": \"Escludi dai Backup\",\n  \"backups.exclude-from-backups-description\": \"Escludi file, cartelle e app specifici dai tuoi backup.\",\n  \"backups.hide\": \"Nascondi\",\n  \"backups.i-understand\": \"Ho capito\",\n  \"backups.location\": \"Posizione\",\n  \"backups.modals.already-in-use.description\": \"Questa posizione di backup è già utilizzata per i Backups di questo Umbrel.\",\n  \"backups.modals.already-in-use.manage\": \"Gestisci in Backups\",\n  \"backups.modals.already-in-use.title\": \"Posizione di backup già in uso\",\n  \"backups.modals.connect-existing.description\": \"Un backup di Umbrel esiste già in questa posizione. Inserisci la sua password di crittografia per aggiungerlo a questo Umbrel.\",\n  \"backups.modals.connect-existing.title\": \"Collega un backup esistente di Umbrel\",\n  \"backups.no-external-drives-detected\": \"Nessuna unità esterna rilevata\",\n  \"backups.no-password-set\": \"Nessuna password impostata\",\n  \"backups.password-is-set\": \"Password impostata\",\n  \"backups.password-minimum-length\": \"La password deve contenere almeno 8 caratteri\",\n  \"backups.password-safety-warning\": \"I tuoi backup verranno crittografati con questa password. Conservala in modo sicuro, perché non potrai rivederla e ti servirà per ripristinare i backup.\",\n  \"backups.passwords-do-not-match\": \"Le password non corrispondono\",\n  \"backups.please-choose-folder\": \"Scegli una cartella\",\n  \"backups.restore-failed.message\": \"Si è verificato un errore durante il ripristino del tuo Umbrel. Le tue app e i tuoi dati attuali non sono stati modificati.\",\n  \"backups.restore-failed.retry\": \"Vai al ripristino\",\n  \"backups.restore-failed.title\": \"Ripristino non riuscito\",\n  \"backups.restoring\": \"Ripristinando il tuo Umbrel\",\n  \"backups.restoring-completing\": \"Quasi fatto. Il tuo Umbrel si riavvierà a breve...\",\n  \"backups.restoring-progress\": \"Ripristinato {{percent}}%\",\n  \"backups.restoring-time-remaining\": \"{{time}} rimanenti\",\n  \"backups.restoring-warning\": \"Non spegnere il tuo Umbrel né scollegare la posizione di backup durante il ripristino\",\n  \"backups.review\": \"Controlla e conferma\",\n  \"backups.review-description\": \"Controlla i dettagli del backup e conferma la tua scelta\",\n  \"backups.scanning-for-external-drives\": \"Ricerca unità esterne...\",\n  \"backups.schedule-description\": \"umbrelOS esegue automaticamente il backup dei tuoi dati ogni ora. Conserve backup orari crittografati per le ultime 24 ore, backup giornalieri per l'ultima settimana, backup settimanali per l'ultimo mese e backup mensili per l'ultimo anno. I backup più vecchi di un anno vengono rimossi automaticamente.\",\n  \"backups.select-backup-folder\": \"Seleziona cartella di backup\",\n  \"backups.select-backup-folder-description\": \"Scegli una cartella dove vuoi memorizzare i tuoi backup.\",\n  \"backups.select-backup-location\": \"Seleziona una posizione di backup\",\n  \"backups.set-encryption-password\": \"Imposta password di crittografia\",\n  \"backups.set-encryption-password-description\": \"Proteggi i tuoi backup con una password. Questo garantisce che i tuoi dati rimangano privati e possano essere ripristinati soltanto con questa password.\",\n  \"backups.show\": \"Mostra\",\n  \"backups.storage-capacity-warning\": \"{{device}} deve avere spazio libero pari ad almeno il doppio della dimensione del tuo backup\",\n  \"backups.store-encryption-password-safely\": \"Conserva la password di crittografia in modo sicuro\",\n  \"beta-program\": \"Programma Beta di umbrelOS\",\n  \"beta-program-description\": \"Opta per ricevere aggiornamenti beta di umbrelOS, accedi in anteprima a nuove funzionalità e aiutaci a perfezionarle fornendo il tuo feedback. Gli aggiornamenti beta possono essere instabili e la risoluzione dei problemi può richiedere familiarità con il terminal.\",\n  \"cancel\": \"Annulla\",\n  \"change\": \"Modifica\",\n  \"change-name\": \"Cambia nome\",\n  \"change-name.failed.name-required\": \"Nome richiesto\",\n  \"change-name.input-placeholder\": \"Il tuo nome\",\n  \"change-password\": \"Cambia password\",\n  \"change-password.callout\": \"Se perdi la tua password, non sarai in grado di accedere al tuo Umbrel. Assicurati di conservarla in modo sicuro.\",\n  \"change-password.current-password\": \"Password attuale\",\n  \"change-password.failed.current-required\": \"Password attuale richiesta\",\n  \"change-password.failed.min-length\": \"La password deve essere di almeno {{characters}} caratteri\",\n  \"change-password.failed.must-be-unique\": \"La nuova password deve essere diversa dalla password attuale\",\n  \"change-password.failed.new-required\": \"Nuova password richiesta\",\n  \"change-password.failed.no-match\": \"Le password non corrispondono\",\n  \"change-password.failed.repeat-required\": \"Ripeti la password richiesta\",\n  \"change-password.new-password\": \"Nuova password\",\n  \"change-password.repeat-password\": \"Ripeti password\",\n  \"check-for-latest-version\": \"Controlla gli aggiornamenti di umbrelOS\",\n  \"clipboard.copied\": \"Copiato\",\n  \"close\": \"Chiudi\",\n  \"cmdk.change-wallpaper\": \"Cambia sfondo\",\n  \"cmdk.frequent-apps\": \"Usati frequentemente\",\n  \"cmdk.input-placeholder\": \"Cerca app, impostazioni o azioni\",\n  \"cmdk.live-usage\": \"Utilizzo in Tempo Reale\",\n  \"cmdk.restart-umbrel\": \"Riavvia Umbrel\",\n  \"cmdk.shutdown-umbrel\": \"Spegni Umbrel\",\n  \"cmdk.update-all-apps\": \"Aggiorna tutte le app\",\n  \"cmdk.widgets\": \"Widget\",\n  \"community-app-store\": \"Community App Store\",\n  \"community-app-store.add-error\": \"Impossibile aggiungere l'App Store: {{message}}\",\n  \"community-app-store.back-to-umbrel-app-store\": \"Torna a Umbrel App Store\",\n  \"community-app-store.open-button\": \"Apri\",\n  \"community-app-store.remove-button\": \"Rimuovi\",\n  \"community-app-store.remove-error\": \"Impossibile rimuovere l'App Store: {{message}}\",\n  \"community-app-stores.add-button\": \"Aggiungi\",\n  \"community-app-stores.description\": \"I Community App Store ti permettono di installare app sul tuo Umbrel che potrebbero non essere disponibili nell'Umbrel App Store ufficiale. Ti permettono anche di testare le versioni beta delle app Umbrel prima che gli sviluppatori le rilascino sull'Umbrel App Store ufficiale.\",\n  \"community-app-stores.learn-more\": \"Scopri di più\",\n  \"community-app-stores.warning\": \"I Community App Store possono essere creati da chiunque. Le app pubblicate non sono verificate o esaminate dal team dell'Umbrel App Store ufficiale e possono potenzialmente essere insicure o dannose. Usa cautela e aggiungi solo app store di sviluppatori di cui ti fidi.\",\n  \"confirm\": \"Conferma\",\n  \"connect\": \"Collega\",\n  \"connecting\": \"Connessione in corso...\",\n  \"connection-lost\": \"Connessione persa\",\n  \"connection-lost-description\": \"Questo può succedere quando la scheda del browser è rimasta inattiva, la connessione di rete è stata interrotta o il tuo dispositivo è offline.\",\n  \"continue\": \"Continua\",\n  \"continue-to-log-in\": \"Continua per accedere\",\n  \"cpu\": \"CPU\",\n  \"cpu-core-count\": \"{{cores}} thread\",\n  \"default-credentials.close\": \"Capito\",\n  \"default-credentials.description\": \"Ecco le credenziali che ti serviranno per accedere all'app.\",\n  \"default-credentials.dont-show-again\": \"Non mostrare di nuovo\",\n  \"default-credentials.dont-show-again-notice\": \"Puoi accedere a queste credenziali in qualsiasi momento in futuro facendo clic con il tasto destro sull'icona dell'app.\",\n  \"default-credentials.open\": \"Apri {{app}}\",\n  \"default-credentials.password\": \"Password predefinita\",\n  \"default-credentials.title\": \"Credenziali per {{app}}\",\n  \"default-credentials.username\": \"Nome utente predefinito\",\n  \"desktop.app.context.go-to-store-page\": \"Visualizza in App Store\",\n  \"desktop.app.context.settings\": \"Impostazioni\",\n  \"desktop.app.context.show-default-credentials\": \"Mostra credenziali predefinite\",\n  \"desktop.app.context.uninstall\": \"Disinstalla\",\n  \"desktop.context-menu.change-wallpaper\": \"Cambia sfondo\",\n  \"desktop.context-menu.edit-widgets\": \"Modifica widget\",\n  \"desktop.context-menu.logout\": \"Esci\",\n  \"desktop.greeting.afternoon\": \"Buon pomeriggio, {{name}}\",\n  \"desktop.greeting.evening\": \"Buona sera, {{name}}\",\n  \"desktop.greeting.morning\": \"Buongiorno, {{name}}\",\n  \"desktop.install-first.for-the-ai-enthusiast\": \"Per Viber\",\n  \"desktop.install-first.for-the-bitcoiner\": \"Per il bitcoiner\",\n  \"desktop.install-first.for-the-self-hoster\": \"Per l'auto-ospitante\",\n  \"desktop.install-first.for-the-streamer\": \"Per lo streamer\",\n  \"desktop.install-first.link-to-app-store\": \"Esplora di più in App Store\",\n  \"desktop.not-enough-room\": \"Usa uno schermo più grande per visualizzare le tue app.\",\n  \"device\": \"Dispositivo\",\n  \"device-info\": \"Info dispositivo\",\n  \"device-info-description\": \"Informazioni sul tuo dispositivo\",\n  \"device-info.device\": \"Dispositivo\",\n  \"device-info.model-number\": \"Numero modello\",\n  \"device-info.serial-number\": \"Numero di serie\",\n  \"device-info.view-info\": \"Visualizza info\",\n  \"device-name.home-or-pro\": \"Umbrel Home o Umbrel Pro\",\n  \"disable\": \"Disabilita\",\n  \"done\": \"Fine\",\n  \"download-logs\": \"Scarica log\",\n  \"enabling-tor\": \"Attivazione dell'accesso remoto tramite Tor\",\n  \"external-dns\": \"DNS di Cloudflare\",\n  \"external-dns-description\": \"Cloudflare DNS offre una maggiore affidabilità della rete. Disabilita per usare le impostazioni DNS del tuo router.\",\n  \"external-dns-error\": \"Impossibile aggiornare l'impostazione DNS: {{message}}\",\n  \"external-drive\": \"Unità esterna\",\n  \"factory-reset\": \"Ripristino Impostazioni di Fabbrica\",\n  \"factory-reset-description\": \"Cancella tutti i tuoi dati e app, ripristinando umbrelOS alle impostazioni predefinite\",\n  \"factory-reset-failed\": \"Impossibile ripristinare il dispositivo: {{message}}\",\n  \"factory-reset.confirm.body\": \"Conferma la tua password per il ripristino\",\n  \"factory-reset.confirm.ethernet-required-warning\": \"Assicurati che il tuo dispositivo sia collegato al router tramite Ethernet (non Wi-Fi) e che tu stia accedendo ad esso dalla tua rete locale (ad es., http://umbrel.local o l'indirizzo IP locale del tuo dispositivo).\",\n  \"factory-reset.confirm.submit\": \"Cancella tutto e resetta\",\n  \"factory-reset.confirm.submit-callout\": \"Questa azione non può essere annullata.\",\n  \"factory-reset.rebooting.message\": \"Il dispositivo si riavvierà e tutti i dati verranno cancellati. Non chiudere questa pagina.\",\n  \"factory-reset.rebooting.status\": \"Ripristino alle impostazioni di fabbrica in corso...\",\n  \"factory-reset.rebooting.title\": \"Ripristino alle impostazioni di fabbrica in corso\",\n  \"factory-reset.review.account-info\": \"Informazioni account e password\",\n  \"factory-reset.review.apps\": \"App\",\n  \"factory-reset.review.following-will-be-removed\": \"I seguenti verranno rimossi dal tuo dispositivo\",\n  \"factory-reset.review.installed-apps_one\": \"{{count}} app installata\",\n  \"factory-reset.review.installed-apps_other\": \"{{count}} app installate\",\n  \"factory-reset.review.submit\": \"Continua\",\n  \"factory-reset.review.total-data\": \"Dati totali\",\n  \"files\": \"Files\",\n  \"files-action.add-favorite\": \"Aggiungi ai preferiti\",\n  \"files-action.add-network-device\": \"Aggiungi dispositivo\",\n  \"files-action.cancel-upload\": \"Annulla caricamento\",\n  \"files-action.compress\": \"Comprimi\",\n  \"files-action.copy\": \"Copia\",\n  \"files-action.cut\": \"Taglia\",\n  \"files-action.delete\": \"Elimina definitivamente\",\n  \"files-action.download\": \"Scarica\",\n  \"files-action.download-items\": \"Scarica {{count}} elementi\",\n  \"files-action.drop-to-upload\": \"Rilascia per caricare\",\n  \"files-action.eject-disk\": \"Espelli\",\n  \"files-action.empty-trash\": \"Svuota il Cestino\",\n  \"files-action.format-drive\": \"Formatta\",\n  \"files-action.go-to-path\": \"Vai a...\",\n  \"files-action.new-folder\": \"Nuova cartella\",\n  \"files-action.open\": \"Apri\",\n  \"files-action.paste\": \"Incolla\",\n  \"files-action.remove-favorite\": \"Rimuovi dai preferiti\",\n  \"files-action.remove-network-host\": \"Espelli unità di rete\",\n  \"files-action.remove-network-share\": \"Espelli condivisione di rete\",\n  \"files-action.rename\": \"Rinomina\",\n  \"files-action.restore\": \"Ripristina\",\n  \"files-action.select\": \"Seleziona\",\n  \"files-action.share\": \"Condividi sulla rete...\",\n  \"files-action.sharing\": \"Condivisione in corso...\",\n  \"files-action.show-in-folder\": \"Mostra nella cartella di origine\",\n  \"files-action.trash\": \"Sposta nel Cestino\",\n  \"files-action.uncompress\": \"Decomprimi\",\n  \"files-action.upload\": \"Carica\",\n  \"files-add-network-share.add-manually\": \"Aggiungi manualmente\",\n  \"files-add-network-share.add-share\": \"Aggiungi condivisione\",\n  \"files-add-network-share.back\": \"Indietro\",\n  \"files-add-network-share.continue\": \"Continua\",\n  \"files-add-network-share.description\": \"Connettiti a un NAS o a un'altra unità condivisa sulla tua rete per accedervi da File.\",\n  \"files-add-network-share.discovering\": \"Ricerca in corso...\",\n  \"files-add-network-share.enter-details-manually\": \"Inserisci i dettagli del server\",\n  \"files-add-network-share.host-label\": \"Indirizzo server\",\n  \"files-add-network-share.host-required\": \"L'indirizzo del server è obbligatorio\",\n  \"files-add-network-share.manual-share-help\": \"Inserisci il nome esatto della condivisione così come appare sul tuo server\",\n  \"files-add-network-share.no-shares-found\": \"Nessuna condivisione trovata su questo server\",\n  \"files-add-network-share.not-seeing-share\": \"Non vedi la tua condivisione?\",\n  \"files-add-network-share.password-label\": \"Password\",\n  \"files-add-network-share.password-required\": \"La password è obbligatoria\",\n  \"files-add-network-share.retrieving-shares\": \"Recupero delle condivisioni...\",\n  \"files-add-network-share.retry-discovery\": \"Ripeti scansione rete\",\n  \"files-add-network-share.select-share\": \"Seleziona una condivisione da aggiungere\",\n  \"files-add-network-share.share-placeholder\": \"shared-documents\",\n  \"files-add-network-share.share-required\": \"La condivisione è obbligatoria\",\n  \"files-add-network-share.title\": \"Aggiungi una condivisione di rete\",\n  \"files-add-network-share.username-label\": \"Nome utente\",\n  \"files-add-network-share.username-placeholder\": \"admin\",\n  \"files-add-network-share.username-required\": \"Il nome utente è obbligatorio\",\n  \"files-audio-island.now-playing\": \"In riproduzione\",\n  \"files-audio-island.pause\": \"Pausa\",\n  \"files-audio-island.play\": \"Riproduci\",\n  \"files-backend-error.base-directory-not-found\": \"Impossibile trovare la directory di base\",\n  \"files-backend-error.cant-find-root\": \"Impossibile verificare il percorso del file\",\n  \"files-backend-error.destination-already-exists\": \"Nella destinazione esiste già un elemento con lo stesso nome\",\n  \"files-backend-error.destination-not-exist\": \"La cartella di destinazione non esiste\",\n  \"files-backend-error.does-not-exist\": \"Il file o la cartella non esiste\",\n  \"files-backend-error.escapes-base\": \"Il percorso è fuori dalla directory consentita\",\n  \"files-backend-error.invalid-base\": \"Il percorso non appartiene a una directory valida\",\n  \"files-backend-error.invalid-filename\": \"Il nome del file non è valido\",\n  \"files-backend-error.invalid-path\": \"Il percorso del file non è valido\",\n  \"files-backend-error.mkdir-failed\": \"Impossibile creare la cartella\",\n  \"files-backend-error.move-failed\": \"Impossibile spostare l'elemento\",\n  \"files-backend-error.not-enough-space\": \"Spazio di archiviazione insufficiente\",\n  \"files-backend-error.operation-not-allowed\": \"Operazione non consentita\",\n  \"files-backend-error.parent-not-directory\": \"Il percorso padre non è una cartella\",\n  \"files-backend-error.parent-not-exist\": \"La cartella padre non esiste\",\n  \"files-backend-error.path-not-absolute\": \"Il percorso del file non è valido\",\n  \"files-backend-error.share-already-exists\": \"Questa cartella è già condivisa\",\n  \"files-backend-error.share-name-generation-failed\": \"Impossibile generare un nome di condivisione univoco\",\n  \"files-backend-error.source-not-exists\": \"Il file o la cartella di origine non esiste\",\n  \"files-backend-error.subdir-of-self\": \"Una cartella non può essere spostata o copiata dentro se stessa\",\n  \"files-backend-error.trash-meta-not-exists\": \"Impossibile trovare la posizione originale di questo elemento\",\n  \"files-backend-error.unique-name-index-exceeded\": \"Impossibile generare un nome univoco. Esistono troppi elementi con nomi simili\",\n  \"files-backend-error.upload-failed\": \"Caricamento non riuscito\",\n  \"files-collision.action.keep-both\": \"Mantieni entrambi\",\n  \"files-collision.action.replace\": \"Sostituisci\",\n  \"files-collision.action.skip\": \"Ignora\",\n  \"files-collision.destination.original-location\": \"la sua posizione originale\",\n  \"files-collision.message\": \"Vuoi sostituire l'elemento esistente o mantenerli entrambi?\",\n  \"files-collision.title\": \"\\\"{{itemName}}\\\" esiste già in {{destinationName}}\",\n  \"files-download.confirm\": \"Scarica\",\n  \"files-download.description\": \"Files non può aprire questo tipo di file. Vuoi scaricarlo invece?\",\n  \"files-download.title\": \"Scaricare {{name}}?\",\n  \"files-empty-trash.confirm\": \"Svuota\",\n  \"files-empty-trash.description\": \"Sei sicuro di voler eliminare definitivamente tutti gli elementi nel Cestino? Non potrai annullare questa azione.\",\n  \"files-empty-trash.title\": \"Svuotare il Cestino?\",\n  \"files-empty.directory\": \"Nessun elemento in questa cartella\",\n  \"files-empty.network\": \"Nessun dispositivo di rete\",\n  \"files-empty.network-host-offline\": \"Dispositivo di rete offline\",\n  \"files-error.add-favorite\": \"Impossibile aggiungere ai preferiti: {{message}}\",\n  \"files-error.add-share\": \"Impossibile condividere la cartella: {{message}}\",\n  \"files-error.compress\": \"Impossibile comprimere: {{message}}\",\n  \"files-error.copy\": \"Impossibile copiare: {{message}}\",\n  \"files-error.create-folder\": \"Impossibile creare la cartella: {{message}}\",\n  \"files-error.delete\": \"Impossibile eliminare: {{message}}\",\n  \"files-error.eject-disk\": \"Impossibile espellere l'unità: {{message}}\",\n  \"files-error.empty-trash\": \"Impossibile svuotare il cestino: {{message}}\",\n  \"files-error.extract\": \"Impossibile estrarre: {{message}}\",\n  \"files-error.folder-already-exists\": \"Esiste già una cartella con questo nome\",\n  \"files-error.move\": \"Impossibile spostare: {{message}}\",\n  \"files-error.remove-favorite\": \"Impossibile rimuovere dai preferiti: {{message}}\",\n  \"files-error.remove-share\": \"Impossibile rimuovere la cartella condivisa: {{message}}\",\n  \"files-error.rename\": \"Impossibile rinominare: {{message}}\",\n  \"files-error.restore\": \"Impossibile ripristinare: {{message}}\",\n  \"files-error.trash\": \"Impossibile spostare nel cestino: {{message}}\",\n  \"files-error.upload\": \"Impossibile caricare: {{message}}\",\n  \"files-error.upload-network-error\": \"Caricamento di {{name}} non riuscito: si è verificato un errore di rete\",\n  \"files-extension-change.confirm\": \"Continua\",\n  \"files-extension-change.description-add\": \"Sei sicuro di voler cambiare l'estensione di '{{fileName}}' in '{{extension}}'? Questo potrebbe rendere il file illeggibile.\",\n  \"files-extension-change.description-remove\": \"Sei sicuro di voler rimuovere l'estensione di '{{fileName}}'?\",\n  \"files-extension-change.title-add\": \"Cambiare l'estensione in '{{extension}}'?\",\n  \"files-extension-change.title-remove\": \"Rimuovere l'estensione?\",\n  \"files-external-storage.unsupported.description\": \"L'unità esterna collegata non può essere utilizzata su un Raspberry Pi a causa di problemi di alimentazione. L'archiviazione esterna è disponibile su Umbrel Home, Umbrel Pro e su tutti i dispositivi x86 (Intel o AMD).\",\n  \"files-external-storage.unsupported.description-general\": \"L'archiviazione esterna non è disponibile sui Raspberry Pi a causa di problemi di alimentazione. L'archiviazione esterna è disponibile su Umbrel Home, Umbrel Pro e su tutti i dispositivi x86 (Intel o AMD).\",\n  \"files-external-storage.unsupported.title\": \"Archiviazione esterna non supportata\",\n  \"files-folder\": \"Cartella\",\n  \"files-format.confirm\": \"Formatta\",\n  \"files-format.description\": \"La formattazione cancellerà tutti i dati su {{driveName}}. Questa operazione non può essere annullata.\",\n  \"files-format.description-unreadable\": \"umbrelOS non riesce a leggere il contenuto di {{driveName}}. Puoi formattare questa unità per usarla con umbrelOS.\",\n  \"files-format.drive-label\": \"Nome\",\n  \"files-format.error\": \"Impossibile formattare l'unità\",\n  \"files-format.exfat-description\": \"Massima compatibilità con Windows, macOS e Linux\",\n  \"files-format.ext4-description\": \"Migliori prestazioni con umbrelOS e Linux\",\n  \"files-format.filesystem\": \"File system\",\n  \"files-format.filesystem-label\": \"Formato\",\n  \"files-format.formatting\": \"Formattazione in corso...\",\n  \"files-format.title\": \"Formatta unità\",\n  \"files-format.title-requires-format\": \"Formattazione richiesta\",\n  \"files-formatting-island.formatting\": \"Formattazione...\",\n  \"files-formatting-island.formatting-drives\": \"Formattazione di {{count}} unità\",\n  \"files-listing.empty\": \"Nessun elemento\",\n  \"files-listing.error\": \"Si è verificato un errore\",\n  \"files-listing.item-count-truncated\": \"{{formattedCount}}+ elementi\",\n  \"files-listing.item-count_one\": \"{{formattedCount}} elemento\",\n  \"files-listing.item-count_other\": \"{{formattedCount}} elementi\",\n  \"files-listing.loading\": \"Caricamento in corso...\",\n  \"files-listing.no-such-file\": \"Nessun file o cartella corrispondente\",\n  \"files-listing.selected-count\": \"{{selectedCount}} di {{totalCount}} selezionati\",\n  \"files-listing.selected-count-truncated\": \"{{selectedCount}} di {{totalCount}}+ selezionati\",\n  \"files-name-drawer.new-folder\": \"Nuova cartella\",\n  \"files-name-drawer.new-folder-description\": \"Inserisci un nome per la nuova cartella.\",\n  \"files-name-drawer.new-folder-input\": \"Nome cartella\",\n  \"files-name-drawer.rename-file\": \"Rinomina file\",\n  \"files-name-drawer.rename-file-description\": \"Inserisci un nuovo nome per questo file.\",\n  \"files-name-drawer.rename-file-input\": \"Nome file\",\n  \"files-name-drawer.rename-folder\": \"Rinomina cartella\",\n  \"files-name-drawer.rename-folder-description\": \"Inserisci un nuovo nome per questa cartella.\",\n  \"files-name-drawer.rename-folder-input\": \"Nome cartella\",\n  \"files-network-storage-error.add-share\": \"Impossibile aggiungere la condivisione di rete: {{message}}\",\n  \"files-network-storage-error.discover-servers\": \"Impossibile rilevare i dispositivi di rete: {{message}}\",\n  \"files-network-storage-error.discover-shares\": \"Impossibile rilevare le condivisioni di rete: {{message}}\",\n  \"files-network-storage-error.remove-share\": \"Impossibile rimuovere la condivisione di rete: {{message}}\",\n  \"files-operations-island.copying\": \"Copiando \\\"{{from}}\\\" in \\\"{{to}}\\\"\",\n  \"files-operations-island.moving\": \"Spostando \\\"{{from}}\\\" in \\\"{{to}}\\\"\",\n  \"files-operations-island.restoring\": \"Ripristino di \\\"{{from}}\\\" in \\\"{{to}}\\\"\",\n  \"files-path.input-group\": \"Campo percorso\",\n  \"files-path.input-label\": \"Percorso attuale\",\n  \"files-permanently-delete.confirm\": \"Elimina definitivamente\",\n  \"files-permanently-delete.description-multiple\": \"Sei sicuro di voler eliminare definitivamente questi {{count}} elementi? Non potrai annullare questa azione.\",\n  \"files-permanently-delete.description-single\": \"Sei sicuro di voler eliminare definitivamente \\\"{{fileName}}\\\"? Non potrai annullare questa azione.\",\n  \"files-permanently-delete.title-multiple\": \"Eliminare definitivamente {{count}} elementi?\",\n  \"files-permanently-delete.title-single\": \"Eliminare definitivamente?\",\n  \"files-search.default\": \"Cerca file e cartelle\",\n  \"files-search.no-results\": \"Nessun risultato trovato per \\\"{{query}}\\\"\",\n  \"files-search.placeholder\": \"Cerca\",\n  \"files-search.searching-label\": \"Cercando l'Umbrel di {{name}}\",\n  \"files-share.home-description\": \"Accedi a tutti i file in \\\"{{homeDirectoryName}}\\\" da altri dispositivi sulla tua rete\",\n  \"files-share.home-title\": \"Condividi \\\"{{homeDirectoryName}}\\\" sulla rete\",\n  \"files-share.instructions.how-to-access\": \"Come accedere\",\n  \"files-share.instructions.ios.enter-password\": \"Inserisci <field>{{password}}</field> come password.\",\n  \"files-share.instructions.ios.enter-server\": \"Inserisci <field>{{smbUrl}}</field> come indirizzo server.\",\n  \"files-share.instructions.ios.enter-username\": \"Inserisci <field>{{username}}</field> come nome utente.\",\n  \"files-share.instructions.ios.install-files\": \"Installa l'app \\\"Files\\\" da App Store se non è già installata.\",\n  \"files-share.instructions.ios.tap-connect\": \"Tocca \\\"Connetti\\\" per accedere.\",\n  \"files-share.instructions.ios.tap-dots\": \"Tocca i tre puntini (...) in alto a destra e seleziona \\\"Collegati al server\\\".\",\n  \"files-share.instructions.macos.click-connect\": \"Fai clic su \\\"Connetti\\\" per accedere.\",\n  \"files-share.instructions.macos.enter-password\": \"Inserisci <field>{{password}}</field> come password.\",\n  \"files-share.instructions.macos.enter-url\": \"Inserisci <field>{{smbUrl}}</field> e fai clic su Connetti.\",\n  \"files-share.instructions.macos.enter-username\": \"Inserisci <field>{{username}}</field> come nome utente.\",\n  \"files-share.instructions.macos.open-finder\": \"Apri \\\"Finder\\\" e premi ⌘ + K.\",\n  \"files-share.instructions.macos.select-registered\": \"Seleziona \\\"Utente registrato\\\" quando richiesto.\",\n  \"files-share.instructions.macos.time-machine\": \"Come usarla come destinazione di backup per Time Machine\",\n  \"files-share.instructions.macos.time-machine.choose-encryption\": \"Scegli tra backup crittografati o non crittografati.\",\n  \"files-share.instructions.macos.time-machine.disk-limit\": \"Per \\\"Limite utilizzo disco\\\", specifica la quantità massima di spazio che vuoi allocare su Umbrel per i backup di Time Machine, quindi fai clic su \\\"Fine\\\".\",\n  \"files-share.instructions.macos.time-machine.follow-steps\": \"Segui i passaggi precedenti e apri Impostazioni di sistema sul tuo Mac.\",\n  \"files-share.instructions.macos.time-machine.go-settings\": \"Vai su Time Machine, fai clic su \\\"Aggiungi disco di backup...\\\".\",\n  \"files-share.instructions.macos.time-machine.select-disk\": \"Seleziona la cartella e fai clic su \\\"Imposta disco...\\\".\",\n  \"files-share.instructions.umbrelos.backup.follow-onscreen\": \"Segui i passaggi guidati per configurare il tuo backup.\",\n  \"files-share.instructions.umbrelos.backup.follow-then-go-to\": \"Segui i passaggi sopra e poi vai su \\\"{{settings}}\\\" > \\\"{{backups}}\\\" sul tuo altro Umbrel.\",\n  \"files-share.instructions.umbrelos.backup.select-add\": \"Seleziona l'opzione \\\"{{addUmbrelOrNas}}\\\".\",\n  \"files-share.instructions.umbrelos.backup.select-connected\": \"Seleziona questo Umbrel dall'elenco dei dispositivi connessi.\",\n  \"files-share.instructions.umbrelos.backup.title\": \"Come usarlo come destinazione di backup per il tuo altro Umbrel\",\n  \"files-share.instructions.umbrelos.cant-find-note\": \"Non riesci a trovarlo? Prova a selezionare \\\"Aggiungi manualmente\\\" e usa le seguenti credenziali. Se ancora non riesci ad aggiungerlo, assicurati che entrambi i dispositivi siano sulla stessa rete.\",\n  \"files-share.instructions.umbrelos.enter-password\": \"Inserisci <field>{{password}}</field> come password.\",\n  \"files-share.instructions.umbrelos.enter-username\": \"Inserisci <field>{{username}}</field> come nome utente.\",\n  \"files-share.instructions.umbrelos.open-and-click\": \"Sul tuo altro Umbrel, apri \\\"Files\\\" e clicca <plus/> accanto a \\\"<deviceIcon/> {{deviceLabel}}\\\" nella barra laterale.\",\n  \"files-share.instructions.umbrelos.select-device\": \"Seleziona questo dispositivo Umbrel dall'elenco dei dispositivi rilevati automaticamente sulla tua rete.\",\n  \"files-share.instructions.umbrelos.select-sharename\": \"Seleziona \\\"{{sharename}}\\\" e clicca per aggiungere la condivisione.\",\n  \"files-share.instructions.windows.enter-password\": \"Inserisci <field>{{password}}</field> come password.\",\n  \"files-share.instructions.windows.enter-url\": \"Digita <field>{{smbUrl}}</field> e premi Invio.\",\n  \"files-share.instructions.windows.enter-username\": \"Inserisci <field>{{username}}</field> come nome utente.\",\n  \"files-share.instructions.windows.open-run\": \"Premi Windows + R per aprire la finestra Esegui.\",\n  \"files-share.instructions.windows.remember-credentials\": \"Spunta \\\"Remember my credentials\\\" e fai clic su OK.\",\n  \"files-share.regular-description\": \"Condividi questa cartella per accedervi da altri dispositivi sulla tua rete\",\n  \"files-share.regular-title\": \"Condividi cartella sulla rete\",\n  \"files-share.toggle\": \"Condividi \\\"{{name}}\\\" sulla tua rete\",\n  \"files-sidebar.apps\": \"App\",\n  \"files-sidebar.external-storage\": \"Archiviazione esterna\",\n  \"files-sidebar.favorites\": \"Preferiti\",\n  \"files-sidebar.home\": \"Home\",\n  \"files-sidebar.navigation\": \"Navigazione file\",\n  \"files-sidebar.network\": \"Rete\",\n  \"files-sidebar.network-pathbar\": \"Dispositivi di rete\",\n  \"files-sidebar.network-sidebar\": \"Dispositivi\",\n  \"files-sidebar.recents\": \"Recenti\",\n  \"files-sidebar.shared-folders\": \"Cartelle condivise\",\n  \"files-sidebar.trash\": \"Cestino\",\n  \"files-sidebar.trash.open\": \"Apri\",\n  \"files-sort.created\": \"Aggiunto\",\n  \"files-sort.modified\": \"Modificato\",\n  \"files-sort.name\": \"Nome\",\n  \"files-sort.size\": \"Dimensione\",\n  \"files-sort.type\": \"Tipo\",\n  \"files-state.uploading\": \"Caricamento in corso...\",\n  \"files-state.waiting\": \"In attesa...\",\n  \"files-type.3gp\": \"Video 3GP\",\n  \"files-type.3gp2\": \"Video 3GP2\",\n  \"files-type.7z\": \"Archivio 7Z\",\n  \"files-type.aac\": \"Audio AAC\",\n  \"files-type.ai\": \"File Illustrator\",\n  \"files-type.aiff\": \"Audio AIFF\",\n  \"files-type.au\": \"Audio AU\",\n  \"files-type.avi\": \"Video AVI\",\n  \"files-type.avif\": \"Immagine AVIF\",\n  \"files-type.bmp\": \"Immagine BMP\",\n  \"files-type.bzip2\": \"Archivio BZIP2\",\n  \"files-type.caf\": \"Audio CAF\",\n  \"files-type.compressed\": \"Archivio compresso\",\n  \"files-type.csv\": \"File CSV\",\n  \"files-type.directory\": \"Cartella\",\n  \"files-type.dmg\": \"Immagine disco\",\n  \"files-type.dv\": \"Video DV\",\n  \"files-type.epub\": \"eBook EPUB\",\n  \"files-type.excel\": \"Foglio di calcolo Excel\",\n  \"files-type.exe\": \"Eseguibile Windows\",\n  \"files-type.executable\": \"Eseguibile\",\n  \"files-type.external-drive\": \"Disco\",\n  \"files-type.flac\": \"Audio FLAC\",\n  \"files-type.flv\": \"Video FLV\",\n  \"files-type.gif\": \"Immagine GIF\",\n  \"files-type.gzip\": \"Archivio GZIP\",\n  \"files-type.heic\": \"Immagine HEIC\",\n  \"files-type.ico\": \"Immagine ICO\",\n  \"files-type.iso\": \"Immagine ISO\",\n  \"files-type.jpeg\": \"Immagine JPEG\",\n  \"files-type.keynote\": \"Presentazione Keynote\",\n  \"files-type.lzip\": \"Archivio LZIP\",\n  \"files-type.lzma\": \"Archivio LZMA\",\n  \"files-type.lzop\": \"Archivio LZOP\",\n  \"files-type.m3u\": \"Playlist M3U\",\n  \"files-type.m4a\": \"Audio M4A\",\n  \"files-type.m4v\": \"Video M4V\",\n  \"files-type.midi\": \"Audio MIDI\",\n  \"files-type.mka\": \"Audio MKA\",\n  \"files-type.mkv\": \"Video MKV\",\n  \"files-type.mng\": \"Video MNG\",\n  \"files-type.mobi\": \"eBook MOBI\",\n  \"files-type.mp3\": \"Audio MP3\",\n  \"files-type.mp4\": \"Video MP4\",\n  \"files-type.mp4-audio\": \"Audio MP4\",\n  \"files-type.mpeg\": \"Video MPEG\",\n  \"files-type.mpeg-ts\": \"Flusso di trasporto MPEG\",\n  \"files-type.network-drive\": \"Unità di rete\",\n  \"files-type.numbers\": \"Foglio di calcolo Numbers\",\n  \"files-type.ogg\": \"Audio OGG\",\n  \"files-type.ogv\": \"Video OGV\",\n  \"files-type.pages\": \"Documento Pages\",\n  \"files-type.pdf\": \"Documento PDF\",\n  \"files-type.png\": \"Immagine PNG\",\n  \"files-type.powerpoint\": \"Presentazione PowerPoint\",\n  \"files-type.psd\": \"Documento Photoshop\",\n  \"files-type.quicktime\": \"Video QuickTime\",\n  \"files-type.rar\": \"Archivio RAR\",\n  \"files-type.sgi\": \"Filmato SGI\",\n  \"files-type.svg\": \"Immagine SVG\",\n  \"files-type.tar\": \"Archivio TAR\",\n  \"files-type.tiff\": \"Immagine TIFF\",\n  \"files-type.ts\": \"Video TS\",\n  \"files-type.txt\": \"File di testo\",\n  \"files-type.umbrel-backup\": \"Umbrel Backup\",\n  \"files-type.wav\": \"Audio WAV\",\n  \"files-type.webm\": \"Video WebM\",\n  \"files-type.webm-audio\": \"Audio WebM\",\n  \"files-type.webp\": \"Immagine WebP\",\n  \"files-type.wma\": \"Audio WMA\",\n  \"files-type.wmv\": \"Video WMV\",\n  \"files-type.word\": \"Documento Word\",\n  \"files-type.xz\": \"Archivio XZ\",\n  \"files-type.zip\": \"Archivio ZIP\",\n  \"files-upload-island.uploading-count\": \"Caricamento di {{count}} elementi...\",\n  \"files-view.icons\": \"Icone\",\n  \"files-view.list\": \"Elenco\",\n  \"files-view.sort-by\": \"Ordina per\",\n  \"files-view.view-as\": \"Visualizza come\",\n  \"files-widgets.favorites.no-items-text\": \"Aggiungi una cartella ai preferiti per visualizzarla qui\",\n  \"files-widgets.recents.no-items-text\": \"Nessun file recente\",\n  \"generic-in\": \"nell’\",\n  \"hide-details\": \"Nascondi dettagli\",\n  \"install-first.install-app\": \"Installa {{app}}\",\n  \"install-first.title\": \"{{app}} richiede queste app\",\n  \"install-your-first-app\": \"Installa la tua prima app\",\n  \"language\": \"Lingua\",\n  \"language-description\": \"La tua lingua preferita per umbrelOS\",\n  \"language.select-description\": \"Seleziona la lingua preferita per umbrelOS\",\n  \"live-usage\": \"Utilizzo in Tempo Reale\",\n  \"loading\": \"Caricamento\",\n  \"local-ip\": \"IP locale\",\n  \"login-2fa.subtitle\": \"Inserisci il codice 2FA visualizzato nella tua app di autenticazione\",\n  \"login-2fa.title\": \"Autentica\",\n  \"login-with-umbrel.description\": \"Inserisci la tua password Umbrel per aprire {{app}}\",\n  \"login-with-umbrel.title\": \"Accedi con Umbrel\",\n  \"login.password-label\": \"Password\",\n  \"login.password.submit\": \"Accedi\",\n  \"login.subtitle\": \"Inserisci la tua password Umbrel per accedere\",\n  \"login.title\": \"Bentornato\",\n  \"logout\": \"Esci\",\n  \"logout-error-generic\": \"Errore: Logout fallito\",\n  \"logout.confirm.submit\": \"Esci\",\n  \"logout.confirm.title\": \"Sei sicuro di voler uscire?\",\n  \"memory\": \"Memoria\",\n  \"memory.low\": \"Memoria bassa\",\n  \"migrate\": \"Migra\",\n  \"migrate.callout\": \"Non spegnere il tuo Umbrel fino al completamento della migrazione\",\n  \"migrate.failed.retry\": \"Riprova\",\n  \"migrate.failed.title\": \"Migrazione fallita\",\n  \"migrate.success.description\": \"Tutte le tue app, i dati delle app e i dettagli dell'account sono stati migrati sul tuo Umbrel Home.\",\n  \"migrate.success.title\": \"Migrazione riuscita\",\n  \"migration-assistant\": \"Assistente di Migrazione\",\n  \"migration-assistant-description\": \"Trasferisci tutte le tue app e i tuoi dati da un Raspberry Pi a {{deviceName}}\",\n  \"migration-assistant-unsupported-device-description\": \"Attualmente Migration Assistant supporta il trasferimento di tutti i dati e delle app da un Raspberry Pi con umbrelOS a Umbrel Home o Umbrel Pro. Apri Migration Assistant sul tuo Umbrel Home o Umbrel Pro per iniziare.\",\n  \"migration-assistant.continue-migration.ready.submit\": \"Inizia la migrazione\",\n  \"migration-assistant.failed\": \"Qualcosa non va...\",\n  \"migration-assistant.failed.retrying-message\": \"Riprova in corso...\",\n  \"migration-assistant.mobile.start-button\": \"Inizia la migrazione\",\n  \"migration-assistant.prep.body\": \"Preparazione alla migrazione\",\n  \"migration-assistant.prep.button-continue\": \"Continua\",\n  \"migration-assistant.prep.callout\": \"I dati sul tuo {{deviceName}}, se presenti, saranno eliminati definitivamente.\",\n  \"migration-assistant.prep.connect-disk-to-home\": \"Collega il suo disco esterno a una qualsiasi porta USB del tuo {{deviceName}}.\",\n  \"migration-assistant.prep.prep-done-continue-message\": \"Una volta fatto, clicca su '{{button}}' qui sotto.\",\n  \"migration-assistant.prep.shut-down-rpi\": \"Spegni il tuo Umbrel su Raspberry Pi.\",\n  \"migration-assistant.ready.description\": \"Tutti i tuoi dati e le tue app sono pronti per essere migrati su {{deviceName}}\",\n  \"migration-assistant.ready.hint-header\": \"Cose da tenere a mente\",\n  \"migration-assistant.ready.hint-keep-pi-off.description\": \"Questo aiuta a prevenire problemi con app come Lightning Node\",\n  \"migration-assistant.ready.hint-keep-pi-off.title\": \"Tieni spento il tuo Raspberry Pi dopo l'aggiornamento\",\n  \"migration-assistant.ready.hint-use-same-password.description\": \"Ricordati di usare la password di Umbrel sul tuo Raspberry Pi per accedere a {{deviceName}}\",\n  \"migration-assistant.ready.hint-use-same-password.title\": \"Usa la stessa password\",\n  \"migration-assistant.ready.title\": \"Tutto pronto per la migrazione!\",\n  \"mini-browser.default-title\": \"Seleziona cartella\",\n  \"mini-browser.empty-external\": \"Collega un'unità esterna per visualizzarla qui.\",\n  \"mini-browser.empty-network\": \"Aggiungi un dispositivo Umbrel o un NAS per visualizzarlo qui.\",\n  \"mini-browser.load-more\": \"Carica altri\",\n  \"mini-browser.load-more-in-folder\": \"Carica altri in {{name}}\",\n  \"mini-browser.loading-more\": \"Caricamento in corso…\",\n  \"mini-browser.select\": \"Seleziona\",\n  \"mini-browser.select-folder\": \"Seleziona cartella\",\n  \"name\": \"Nome\",\n  \"nas\": \"NAS\",\n  \"no-forgot-password-message\": \"Se perdi la tua password, non sarai in grado di accedere al tuo Umbrel. Assicurati di conservarla in modo sicuro.\",\n  \"no-results-found\": \"Nessun risultato trovato\",\n  \"not-found-404\": \"Codice errore: 404\",\n  \"not-found-404.back\": \"Indietro\",\n  \"not-found-404.home\": \"Vai alla Home\",\n  \"notifications.backups-failing-location.description\": \"I Backups automatici su {{location}} non funzionano. Controlla la connessione e rivedi le impostazioni dei Backups.\",\n  \"notifications.backups-failing.description\": \"I backup automatici continuano a non riuscire. Controlla la posizione dei backup e verifica le impostazioni.\",\n  \"notifications.backups-failing.go-to-backups\": \"Vai a Backups\",\n  \"notifications.backups-failing.title\": \"Nessun backup nelle ultime 24 ore\",\n  \"notifications.cpu.too-hot\": \"Temperatura della CPU alta\",\n  \"notifications.memory.low\": \"La memoria del tuo dispositivo è bassa\",\n  \"notifications.new-version-available\": \"{{update}} ora disponibile per l'installazione\",\n  \"notifications.raid.issue.description\": \"Problema di archiviazione rilevato. Controlla Storage Manager per i dettagli.\",\n  \"notifications.raid.issue.title\": \"Azione urgente richiesta\",\n  \"notifications.ssd.health.description\": \"Uno o più SSD potrebbero richiedere attenzione. Controlla Storage Manager per i dettagli.\",\n  \"notifications.ssd.health.title\": \"Avviso integrità SSD\",\n  \"notifications.storage.full\": \"La memoria del tuo dispositivo è piena\",\n  \"notifications.view\": \"Visualizza\",\n  \"ok\": \"OK\",\n  \"onboarding.account-created.by-clicking-button-you-agree\": \"Cliccando su 'Avanti', accetti i <linked>Termini di Servizio di umbrelOS</linked>\",\n  \"onboarding.account-created.youre-all-set-name\": \"Tutto pronto, {{name}}.\",\n  \"onboarding.contact-support\": \"Supporto\",\n  \"onboarding.create-account\": \"Crea account\",\n  \"onboarding.create-account.confirm-password.input-label\": \"Conferma password\",\n  \"onboarding.create-account.failed.name-required\": \"Nome richiesto\",\n  \"onboarding.create-account.failed.passwords-dont-match\": \"Le password non corrispondono\",\n  \"onboarding.create-account.name.input-placeholder\": \"Il tuo nome\",\n  \"onboarding.create-account.password.input-label\": \"Password\",\n  \"onboarding.create-account.submit\": \"Crea\",\n  \"onboarding.create-account.submitting\": \"Creazione\",\n  \"onboarding.create-account.subtitle\": \"Le informazioni del tuo account sono memorizzate solo sul tuo Umbrel. Assicurati di fare un backup della tua password in modo sicuro poiché non c'è modo di reimpostarla.\",\n  \"onboarding.create-instead-long\": \"Crea un nuovo account\",\n  \"onboarding.create-instead-short\": \"Nuovo account\",\n  \"onboarding.launch-umbrelos\": \"Avvia umbrelOS\",\n  \"onboarding.raid.available-storage\": \"Spazio disponibile\",\n  \"onboarding.raid.change-drives-link\": \"Vuoi aggiungere o sostituire le unità?\",\n  \"onboarding.raid.configuring.subtitle\": \"Potrebbe richiedere alcuni minuti.\",\n  \"onboarding.raid.configuring.title\": \"Configurazione dell'archiviazione\",\n  \"onboarding.raid.configuring.warning\": \"Per favore non aggiornare questa pagina né spegnere il tuo Umbrel mentre sta configurando l'archiviazione.\",\n  \"onboarding.raid.continue\": \"Continua\",\n  \"onboarding.raid.error.detection-instructions\": \"Spegni Umbrel Pro, verifica che gli SSD siano inseriti correttamente e riprova.\",\n  \"onboarding.raid.error.no-ssds-detected\": \"Nessun SSD rilevato\",\n  \"onboarding.raid.error.no-ssds-instructions\": \"Spegni Umbrel Pro e inserisci almeno un SSD per continuare.\",\n  \"onboarding.raid.failsafe\": \"FailSafe\",\n  \"onboarding.raid.failsafe.cant-enable\": \"Non puoi ancora abilitare FailSafe\",\n  \"onboarding.raid.failsafe.enable\": \"Abilita FailSafe\",\n  \"onboarding.raid.failsafe.mixed-sizes\": \"FailSafe è limitato dal tuo SSD più piccolo ({{smallest}}). Lo spazio extra sugli SSD più grandi non può essere utilizzato, lasciando {{wasted}} inutilizzabile.\",\n  \"onboarding.raid.failsafe.protection-info-2ssds\": \"{{protection}} viene usato per la protezione dei dati. Aggiungi un altro SSD da {{smallest}} per aumentare lo spazio disponibile a {{futureWith3}}, oppure aggiungi due SSD in più per arrivare a {{futureWith4}}. Puoi aggiungere altri SSD in qualsiasi momento.\",\n  \"onboarding.raid.failsafe.protection-info-3ssds\": \"{{protection}} viene usato per la protezione dei dati. Aggiungi un altro SSD da {{smallest}} per aumentare lo spazio disponibile a {{futureWith4}}. Puoi aggiungere altri SSD in qualsiasi momento.\",\n  \"onboarding.raid.failsafe.single-ssd-info\": \"Hai solo un SSD. Aggiungi almeno un altro SSD da {{size}} per abilitare la protezione FailSafe dei tuoi dati. Puoi aggiungere altri SSD in qualsiasi momento.\",\n  \"onboarding.raid.failsafe.subtitle\": \"I tuoi dati restano al sicuro se un singolo SSD si guasta\",\n  \"onboarding.raid.failsafe.tip\": \"Usa SSD di dimensioni identiche per ottenere la massima capacità e nessuno spazio inutilizzabile.\",\n  \"onboarding.raid.failsafe.warning-now-only\": \"Con più di uno SSD, FailSafe può essere abilitato solo durante la configurazione iniziale. Non potrai abilitarlo in seguito.\",\n  \"onboarding.raid.health-warning\": \"Questa unità segnala problemi di integrità\",\n  \"onboarding.raid.launching\": \"Avvio...\",\n  \"onboarding.raid.no-ssds-alt\": \"Nessun SSD trovato\",\n  \"onboarding.raid.recommended\": \"Consigliato\",\n  \"onboarding.raid.scanning\": \"Verifica degli slot SSD\",\n  \"onboarding.raid.scanning-alt\": \"Scansione degli SSD\",\n  \"onboarding.raid.setup-failed.description-no-retry\": \"Spegni e riprova.\",\n  \"onboarding.raid.setup-failed.description-retry\": \"Riprova oppure spegni per controllare le unità.\",\n  \"onboarding.raid.setup-failed.title\": \"Configurazione dell'archiviazione non riuscita\",\n  \"onboarding.raid.shutdown-dialog.description\": \"Per aggiungere o sostituire le unità, spegni Umbrel Pro. Quando hai finito, riaccendilo e continua la configurazione.\",\n  \"onboarding.raid.shutdown-dialog.title\": \"Cambiare unità?\",\n  \"onboarding.raid.ssd-in-slot\": \"Un <highlight>{{size}}</highlight> SSD in <highlight>Slot {{slot}}</highlight>\",\n  \"onboarding.raid.ssd-label\": \"SSD {{number}}\",\n  \"onboarding.raid.ssd-tray-alt\": \"Vassoio SSD\",\n  \"onboarding.raid.ssds-found\": \"Sono stati trovati i seguenti SSD nel tuo Umbrel Pro\",\n  \"onboarding.raid.storage\": \"Archiviazione\",\n  \"onboarding.raid.storage-label\": \"Archiviazione\",\n  \"onboarding.raid.success.storage-info\": \"Archiviazione {{available}}\",\n  \"onboarding.raid.success.storage-info-failsafe\": \"Archiviazione {{available}} · FailSafe {{failsafe}}\",\n  \"onboarding.raid.try-again\": \"Riprova\",\n  \"onboarding.raid.wasted\": \"Inutilizzabile\",\n  \"onboarding.restore-long\": \"Ripristina il tuo Umbrel\",\n  \"onboarding.restore-short\": \"Ripristina\",\n  \"onboarding.start.continue\": \"Inizia\",\n  \"onboarding.start.subtitle\": \"Il tuo server cloud domestico è pronto per essere configurato.\",\n  \"onboarding.start.title\": \"Benvenuto in umbrelOS\",\n  \"open\": \"Apri\",\n  \"open-live-usage\": \"Apri Utilizzo in Tempo Reale\",\n  \"password\": \"Password\",\n  \"preferences\": \"Preferenze\",\n  \"raid-error.description\": \"Il sistema di archiviazione non è riuscito ad avviarsi correttamente. Controlla lo stato degli SSD qui sotto e segui i passaggi per la risoluzione. Se il problema persiste, gli SSD coinvolti potrebbero dover essere sostituiti.\",\n  \"raid-error.factory-reset-dialog.description\": \"Questo cancellerà tutti i dati sul tuo Umbrel Pro e lo riporterà alle impostazioni di fabbrica. Questa azione non può essere annullata.\",\n  \"raid-error.factory-reset-dialog.title\": \"Ripristino di fabbrica?\",\n  \"raid-error.factory-reset-failed\": \"Impossibile eseguire il ripristino alle impostazioni di fabbrica\",\n  \"raid-error.health-warning\": \"Avviso di integrità\",\n  \"raid-error.missing-ssd-multiple\": \"{{count}} SSD non rispondono\",\n  \"raid-error.missing-ssd-one\": \"1 SSD non risponde\",\n  \"raid-error.shutdown-dialog.description\": \"Spegni il tuo Umbrel Pro, assicurati che tutti gli SSD siano inseriti correttamente negli slot, poi riaccendilo.\",\n  \"raid-error.shutdown-dialog.title\": \"Spegni per controllare le unità?\",\n  \"raid-error.ssd-in-slot\": \"Un SSD da <highlight>{{size}}</highlight> in <highlight>Slot {{slot}}</highlight>\",\n  \"raid-error.step-check-connections.button\": \"Spegni\",\n  \"raid-error.step-check-connections.description\": \"Spegni e verifica che tutti gli SSD siano inseriti correttamente.\",\n  \"raid-error.step-check-connections.title\": \"Controlla le connessioni degli SSD\",\n  \"raid-error.step-factory-reset.button\": \"Ripristino di fabbrica\",\n  \"raid-error.step-factory-reset.description\": \"Ultima risorsa se niente altro funziona. Questo cancella tutti i dati.\",\n  \"raid-error.step-factory-reset.title\": \"Ripristino alle impostazioni di fabbrica\",\n  \"raid-error.step-restart.button\": \"Riavvia\",\n  \"raid-error.step-restart.description\": \"Un primo passo rapido che spesso aiuta\",\n  \"raid-error.step-restart.title\": \"Prova a riavviare\",\n  \"raid-error.title\": \"Problema di archiviazione rilevato\",\n  \"read-less\": \"Leggi meno\",\n  \"read-more\": \"Leggi di più\",\n  \"reconnect\": \"Riconnetti\",\n  \"redirect.to-home\": \"Caricamento...\",\n  \"redirect.to-login\": \"Caricamento...\",\n  \"redirect.to-onboarding\": \"Caricamento...\",\n  \"redirect.to-raid-error\": \"Caricamento...\",\n  \"reload\": \"Ricarica\",\n  \"remote-tor-access\": \"Accesso remoto Tor\",\n  \"reset\": \"Ripristina\",\n  \"restart\": \"Riavvia\",\n  \"restart.confirm.submit\": \"Riavvia\",\n  \"restart.confirm.title\": \"Sei sicuro di voler riavviare il tuo Umbrel?\",\n  \"restart.restarting\": \"Riavvio in corso\",\n  \"restart.restarting-message\": \"Non aggiornare questa pagina o spegnere il tuo Umbrel durante il riavvio.\",\n  \"rewind\": \"Rewind\",\n  \"rewind.files-as-of\": \"I tuoi file al\",\n  \"rewind.loading-snapshots\": \"Caricamento delle istantanee...\",\n  \"rewind.now\": \"Ora\",\n  \"rewind.preflight.description\": \"Trova file e cartelle dai tuoi backup precedenti e ripristinali nel presente.\",\n  \"rewind.preflight.enable-backups\": \"Configura Backups nelle Impostazioni per iniziare a usare Rewind\",\n  \"rewind.restore-complete\": \"Ripristino completato\",\n  \"rewind.restore-error-description\": \"Riprova.\",\n  \"rewind.restore-failed\": \"Ripristino fallito\",\n  \"rewind.restore-running-description\": \"Non chiudere o aggiornare questa pagina finché il ripristino non è completato\",\n  \"rewind.restore-selected\": \"Ripristina selezionati\",\n  \"rewind.restore-success-description\": \"I tuoi file sono stati ripristinati\",\n  \"rewind.restoring\": \"Ripristino in corso\",\n  \"rewind.snapshots-count_one\": \"{{count}} backup da\",\n  \"rewind.snapshots-count_other\": \"{{count}} backup da\",\n  \"search\": \"Cerca\",\n  \"settings\": \"Impostazioni\",\n  \"settings.app-store-preferences.title\": \"Preferenze App Store\",\n  \"settings.contact-support\": \"Hai bisogno di aiuto? <linked>Contatta il supporto.</linked>\",\n  \"settings.file-sharing\": \"Condivisione file\",\n  \"settings.file-sharing.add-folder\": \"Aggiungi\",\n  \"settings.file-sharing.add-folder-title\": \"Seleziona una cartella da condividere\",\n  \"settings.file-sharing.choice-entire-description\": \"Condividi tutti i file sul tuo Umbrel\",\n  \"settings.file-sharing.choice-entire-title\": \"Tutto\",\n  \"settings.file-sharing.choice-heading\": \"Cosa vuoi condividere?\",\n  \"settings.file-sharing.choice-specific-description\": \"Scegli quali cartelle condividere\",\n  \"settings.file-sharing.choice-specific-title\": \"Cartelle specifiche\",\n  \"settings.file-sharing.choice-subtitle\": \"Accedi ai tuoi file e alle tue cartelle in stile Dropbox come cartelle di rete sul computer o sul telefono\",\n  \"settings.file-sharing.configure\": \"Configura\",\n  \"settings.file-sharing.description\": \"Accedi ai tuoi file in stile Dropbox come cartella di rete (SMB) su altri dispositivi\",\n  \"settings.file-sharing.home-shared-note\": \"L'intera cartella \\\"{{homeDirectoryName}}\\\" è condivisa. Le singole cartelle non devono essere condivise separatamente.\",\n  \"settings.file-sharing.share-entire-home-dir\": \"Condividi l'intera cartella Home\",\n  \"settings.file-sharing.share-entire-home-dir-description\": \"Accedi a tutti i file e le cartelle in \\\"{{homeDirectoryName}}\\\" da altri dispositivi sulla tua rete\",\n  \"settings.file-sharing.shared-folders\": \"Cartelle condivise\",\n  \"show-details\": \"Mostra dettagli\",\n  \"shut-down\": \"Spegni\",\n  \"shut-down.complete\": \"Spegnimento completato\",\n  \"shut-down.complete-text\": \"Ora puoi scollegare il tuo dispositivo dalla corrente.\",\n  \"shut-down.confirm.submit\": \"Spegni\",\n  \"shut-down.confirm.title\": \"Sei sicuro di voler spegnere il tuo Umbrel?\",\n  \"shut-down.failed\": \"Impossibile spegnere: {{message}}\",\n  \"shut-down.shutting-down\": \"Spegnimento in corso\",\n  \"shut-down.shutting-down-message\": \"Non aggiornare questa pagina o spegnere il tuo Umbrel durante lo spegnimento.\",\n  \"software-update.callout\": \"Non aggiornare questa pagina o spegnere il tuo Umbrel durante l'aggiornamento.\",\n  \"software-update.check\": \"Verifica aggiornamenti\",\n  \"software-update.checking\": \"Verifica degli aggiornamenti in corso...\",\n  \"software-update.current-running\": \"Sei su\",\n  \"software-update.failed\": \"Aggiornamento non riuscito\",\n  \"software-update.failed-to-check\": \"Impossibile verificare gli aggiornamenti\",\n  \"software-update.failed.retry\": \"Riprova\",\n  \"software-update.install-now\": \"Installa ora\",\n  \"software-update.new-version\": \"Nuova versione {{name}} disponibile per l'installazione\",\n  \"software-update.on-latest\": \"Hai l'ultima versione di umbrelOS\",\n  \"software-update.see-whats-new\": \"Scopri <linked>le novità</linked>\",\n  \"software-update.title\": \"Aggiornamento software\",\n  \"software-update.updating-to\": \"Aggiornamento a {{name}}\",\n  \"software-update.view\": \"Visualizza\",\n  \"something-left\": \"{{left}} rimanenti\",\n  \"something-went-wrong\": \"⚠ Qualcosa è andato storto\",\n  \"start\": \"Avvia\",\n  \"stop\": \"Ferma\",\n  \"storage\": \"Archiviazione\",\n  \"storage-manager\": \"Gestione archiviazione\",\n  \"storage-manager.add\": \"Aggiungi\",\n  \"storage-manager.add-to-raid.add-ssd\": \"Aggiungi SSD\",\n  \"storage-manager.add-to-raid.available\": \"Disponibile:\",\n  \"storage-manager.add-to-raid.description\": \"È stato rilevato un nuovo SSD ed è pronto per essere aggiunto.\",\n  \"storage-manager.add-to-raid.enable-failsafe\": \"Abilita FailSafe\",\n  \"storage-manager.add-to-raid.failed-add\": \"Impossibile aggiungere l'SSD\",\n  \"storage-manager.add-to-raid.failed-enable-failsafe\": \"Impossibile abilitare FailSafe\",\n  \"storage-manager.add-to-raid.failsafe-label\": \"FailSafe:\",\n  \"storage-manager.add-to-raid.info-capacity-added\": \"Il nuovo SSD <highlight>{{size}}</highlight> verrà aggiunto allo spazio disponibile.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-available\": \"Il tuo nuovo SSD <highlight>{{size}}</highlight> aggiungerà <highlight>{{available}}</highlight> di spazio disponibile.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-both\": \"Il tuo nuovo SSD <highlight>{{size}}</highlight> aggiungerà <highlight>{{available}}</highlight> di spazio disponibile e <highlight>{{protection}}</highlight> per la protezione dei dati.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only\": \"Il tuo nuovo SSD <highlight>{{size}}</highlight> aggiungerà <highlight>{{protection}}</highlight> per la protezione dei dati.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only-full\": \"Il tuo nuovo SSD <highlight>{{size}}</highlight> sarà usato interamente per la protezione dei dati.\",\n  \"storage-manager.add-to-raid.info-data-safe\": \"I tuoi dati saranno al sicuro in caso di guasto di un singolo SSD.\",\n  \"storage-manager.add-to-raid.info-no-protection\": \"Se un SSD dovesse guastarsi, potresti perdere i tuoi dati.\",\n  \"storage-manager.add-to-raid.info-total-wasted\": \"<wasted>{{size}}</wasted> inutilizzabile in totale a causa di SSD di dimensioni diverse.\",\n  \"storage-manager.add-to-raid.info-wasted\": \"<wasted>{{size}}</wasted> saranno inutilizzabili a causa di SSD di dimensioni diverse.\",\n  \"storage-manager.add-to-raid.recommended\": \"Consigliato\",\n  \"storage-manager.add-to-raid.recommended-inline\": \"(consigliato)\",\n  \"storage-manager.add-to-raid.restart-active-tasks\": \"Eventuali attività in corso verranno interrotte\",\n  \"storage-manager.add-to-raid.restart-after\": \"Dopo il riavvio, la configurazione di FailSafe verrà completata automaticamente e potrai riprendere l'uso normale.\",\n  \"storage-manager.add-to-raid.restart-during\": \"Durante il riavvio:\",\n  \"storage-manager.add-to-raid.restart-intro\": \"Puoi continuare a usare umbrelOS normalmente durante il processo. Tuttavia, al 50% di avanzamento il tuo Umbrel si riavvierà automaticamente.\",\n  \"storage-manager.add-to-raid.restart-required\": \"Riavvio del sistema richiesto\",\n  \"storage-manager.add-to-raid.restart-ui-inaccessible\": \"umbrelOS sarà temporaneamente inaccessibile\",\n  \"storage-manager.add-to-raid.ssd-in-slot\": \"SSD da <highlight>{{size}}</highlight> in <highlight>Slot {{slot}}</highlight>\",\n  \"storage-manager.add-to-raid.title\": \"Aggiungi SSD all'archiviazione\",\n  \"storage-manager.add-to-raid.too-small\": \"SSD troppo piccolo\",\n  \"storage-manager.add-to-raid.too-small-description\": \"Questo SSD ({{deviceSize}}) è più piccolo dell'SSD più piccolo attualmente installato ({{minSize}}). FailSafe richiede che tutti gli SSD siano almeno grandi quanto lo SSD più piccolo in uso.\",\n  \"storage-manager.add-to-raid.understand-continue\": \"Ho capito, continua\",\n  \"storage-manager.add-to-raid.warning-failsafe-now-only\": \"Avere più di un SSD significa che FailSafe può essere abilitato solo ora. Non potrai abilitarlo in seguito.\",\n  \"storage-manager.add-to-raid.wasted-label\": \"Inutilizzabile:\",\n  \"storage-manager.available-storage\": \"Spazio disponibile\",\n  \"storage-manager.description\": \"Visualizza spazio, stato di salute e impostazioni dei tuoi SSD\",\n  \"storage-manager.empty\": \"Vuoto\",\n  \"storage-manager.failsafe-transition-failed\": \"Impossibile abilitare FailSafe\",\n  \"storage-manager.for-failsafe\": \"Per FailSafe\",\n  \"storage-manager.health.checksum-errors\": \"Errori di checksum: {{count}}\",\n  \"storage-manager.health.critical\": \"Critico\",\n  \"storage-manager.health.critical-threshold\": \"Soglia critica\",\n  \"storage-manager.health.current-temperature\": \"Temperatura attuale\",\n  \"storage-manager.health.estimated-life\": \"Vita residua stimata\",\n  \"storage-manager.health.general\": \"Generale\",\n  \"storage-manager.health.health-status\": \"Stato di salute\",\n  \"storage-manager.health.low\": \"Basso\",\n  \"storage-manager.health.model-and-capacity\": \"Modello e dimensione\",\n  \"storage-manager.health.overheating\": \"Surriscaldamento\",\n  \"storage-manager.health.raid-failed-advice\": \"Questo SSD ha un problema. Spegni il tuo Umbrel e verifica la connessione dell'SSD. Se il problema persiste, l'SSD potrebbe dover essere sostituito.\",\n  \"storage-manager.health.read-errors\": \"Errori di lettura: {{count}}\",\n  \"storage-manager.health.serial-number\": \"Numero di serie\",\n  \"storage-manager.health.status-healthy\": \"Sano\",\n  \"storage-manager.health.status-unhealthy\": \"Non integro\",\n  \"storage-manager.health.status-unknown\": \"Sconosciuto\",\n  \"storage-manager.health.temperature\": \"Temperatura\",\n  \"storage-manager.health.title\": \"Integrità SSD\",\n  \"storage-manager.health.warning-life-advice\": \"Valuta di sostituire presto questo SSD.\",\n  \"storage-manager.health.warning-life-message\": \"Solo il {{percent}}% di vita rimanente\",\n  \"storage-manager.health.warning-temp-advice\": \"Assicurati che il tuo Umbrel Pro abbia un buon flusso d'aria e che l'SSD sia correttamente inserito.\",\n  \"storage-manager.health.warning-temp-critical\": \"La temperatura è critica ({{temperature}})\",\n  \"storage-manager.health.warning-temp-overheating\": \"L'unità si sta surriscaldando ({{temperature}})\",\n  \"storage-manager.health.warning-threshold\": \"Soglia di avviso\",\n  \"storage-manager.health.warning-unhealthy-advice\": \"Questo SSD potrebbe guastarsi a breve. Valuta di sostituirlo.\",\n  \"storage-manager.health.warning-unhealthy-message\": \"Questo SSD potrebbe avere un problema\",\n  \"storage-manager.health.warnings\": \"Avvisi\",\n  \"storage-manager.health.wear\": \"Usura\",\n  \"storage-manager.health.write-errors\": \"Errori di scrittura: {{count}}\",\n  \"storage-manager.install-ssd.description\": \"Aggiungi altri SSD per espandere lo spazio di archiviazione\",\n  \"storage-manager.install-ssd.step-insert\": \"Inserisci i nuovi SSD negli slot vuoti\",\n  \"storage-manager.install-ssd.step-power-on\": \"Accendi il tuo {{deviceName}}\",\n  \"storage-manager.install-ssd.step-remove-bottom-cover\": \"Rimuovi la copertura magnetica inferiore\",\n  \"storage-manager.install-ssd.step-replace-bottom-cover\": \"Rimetti il coperchio inferiore.\",\n  \"storage-manager.install-ssd.step-return\": \"Torna qui per aggiungere gli SSD alla tua archiviazione\",\n  \"storage-manager.install-ssd.step-shut-down\": \"Spegni il tuo {{deviceName}}\",\n  \"storage-manager.install-ssd.title\": \"Aggiunta di SSD\",\n  \"storage-manager.install-tips.image-alt\": \"Istruzioni per l'installazione SSD\",\n  \"storage-manager.install-tips.instructions\": \"Per installare, rimuovi la vite a pollice e inserisci l'SSD nello slot con un angolo. Premi l'SSD verso il basso finché non poggia sul pilastro della vite, quindi fissalo con la vite a pollice.\",\n  \"storage-manager.install-tips.toggle\": \"Hai dimenticato come inserire un SSD?\",\n  \"storage-manager.manage\": \"Gestisci\",\n  \"storage-manager.missing-ssd-warning\": \"Sembra mancare un SSD. Spegni il tuo Umbrel e verifica che tutti gli SSD siano collegati. Se il problema continua, potrebbe essere necessario sostituire l'SSD.\",\n  \"storage-manager.mode\": \"Modalità\",\n  \"storage-manager.mode.failsafe\": \"FailSafe\",\n  \"storage-manager.mode.failsafe.description\": \"Protegge i tuoi dati in caso di guasto di un SSD. Se gli SSD hanno dimensioni diverse, lo spazio extra sui più grandi rimane inutilizzato.\",\n  \"storage-manager.mode.failsafe.info-description\": \"FailSafe protegge i tuoi dati mantenendo copie su più SSD. Se un singolo SSD si guasta, i dati restano al sicuro e possono essere ripristinati quando aggiungi un SSD di ricambio.\",\n  \"storage-manager.mode.failsafe.info-title\": \"Informazioni su FailSafe\",\n  \"storage-manager.mode.full-storage\": \"Archiviazione completa\",\n  \"storage-manager.mode.full-storage.description\": \"Usa tutto lo spazio dei tuoi SSD insieme. Se un SSD si guasta, potresti perdere i dati.\",\n  \"storage-manager.mode.full-storage.info-description\": \"Full Storage combina tutti i tuoi SSD in un unico grande spazio, offrendoti la massima capacità. Tuttavia, se un SSD si guasta, tutti i tuoi dati andranno persi.\",\n  \"storage-manager.mode.full-storage.info-title\": \"Informazioni su Archiviazione completa\",\n  \"storage-manager.mode.switch-from-failsafe-unavailable\": \"Passare da FailSafe alla modalità Full Storage richiede di fare il backup dei dati, ripristinare il dispositivo alle impostazioni di fabbrica e ripristinare dal backup.\",\n  \"storage-manager.mode.switch-to-failsafe-unavailable\": \"Con più SSD in modalità Full Storage, i tuoi dati sono distribuiti su tutte le unità. Passare a FailSafe richiede il backup dei dati, un ripristino alle impostazioni di fabbrica e il ripristino dal backup.\",\n  \"storage-manager.mode.why-cant-switch\": \"Perché non posso cambiare modalità?\",\n  \"storage-manager.operation-in-progress.shutdown-description\": \"È sicuro spegnere il dispositivo. L'operazione verrà messa in pausa e riprenderà dopo il riavvio, ma deve essere completata prima che tu possa apportare altre modifiche.\",\n  \"storage-manager.operation-in-progress.shutdown-title\": \"Il tuo spazio di archiviazione viene aggiornato\",\n  \"storage-manager.operation-in-progress.wait-description\": \"Attendi il completamento dell'operazione in corso prima di apportare altre modifiche.\",\n  \"storage-manager.operation-in-progress.wait-title\": \"Il tuo spazio di archiviazione viene aggiornato\",\n  \"storage-manager.operation.adding-ssd\": \"Aggiunta SSD...\",\n  \"storage-manager.operation.enabling-failsafe\": \"Attivazione di FailSafe...\",\n  \"storage-manager.operation.expanding\": \"Espansione dell'archiviazione...\",\n  \"storage-manager.operation.rebuilding\": \"Ricostruzione dei dati...\",\n  \"storage-manager.operation.replacing\": \"Sostituzione dell'unità...\",\n  \"storage-manager.operation.restarting\": \"Riavvio...\",\n  \"storage-manager.operation.starting\": \"Avvio...\",\n  \"storage-manager.operation.syncing-restarts\": \"Sincronizzazione dati • Riavvio al 50%\",\n  \"storage-manager.raid-status.degraded\": \"Degradato\",\n  \"storage-manager.raid-status.failed\": \"Guasto\",\n  \"storage-manager.raid-status.offline\": \"Offline\",\n  \"storage-manager.raid-status.online\": \"Online\",\n  \"storage-manager.raid-status.removed\": \"Rimosso\",\n  \"storage-manager.raid-status.unavailable\": \"Non disponibile\",\n  \"storage-manager.replace\": \"Sostituisci\",\n  \"storage-manager.replace-failed.degraded\": \"Protezione FailSafe ridotta\",\n  \"storage-manager.replace-failed.degraded-description\": \"Manca un SSD nell'archiviazione FailSafe. Sostituiscilo per ripristinare la protezione completa.\",\n  \"storage-manager.replace-failed.description\": \"Usa questo SSD per ripristinare la protezione FailSafe.\",\n  \"storage-manager.replace-failed.error\": \"Impossibile avviare la sostituzione\",\n  \"storage-manager.replace-failed.replace-now\": \"Sostituisci ora\",\n  \"storage-manager.replace-failed.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD nello slot {{slot}}\",\n  \"storage-manager.replace-failed.step-protected\": \"Al termine, i tuoi dati saranno di nuovo completamente protetti\",\n  \"storage-manager.replace-failed.step-rebuild\": \"I dati verranno ricostruiti sul nuovo SSD\",\n  \"storage-manager.replace-failed.step-time\": \"Potrebbe richiedere un po' di tempo, a seconda della quantità di dati.\",\n  \"storage-manager.replace-failed.title\": \"Sostituisci SSD\",\n  \"storage-manager.replace-failed.too-small\": \"SSD troppo piccolo\",\n  \"storage-manager.replace-failed.too-small-description\": \"Questo SSD ({{deviceSize}}) è più piccolo della dimensione minima richiesta ({{minSize}}) per la tua archiviazione FailSafe.\",\n  \"storage-manager.replace-failed.what-happens\": \"Cosa succede dopo:\",\n  \"storage-manager.ssd-failing\": \"A rischio guasto\",\n  \"storage-manager.swap\": \"Sostituisci\",\n  \"storage-manager.swap.data-erased-description\": \"La modalità Full Storage non offre protezione dei dati. Tutti i dati sul tuo {{deviceName}} saranno cancellati durante il ripristino di fabbrica. Assicurati di eseguire un backup prima.\",\n  \"storage-manager.swap.data-protected\": \"I tuoi dati sono protetti\",\n  \"storage-manager.swap.data-protected-description\": \"Con FailSafe abilitato, puoi sostituire un singolo SSD senza perdere dati. Nessun backup richiesto.\",\n  \"storage-manager.swap.data-will-be-erased\": \"I dati verranno cancellati\",\n  \"storage-manager.swap.description-failsafe\": \"Sostituisci un'unità nello storage FailSafe.\",\n  \"storage-manager.swap.description-full-storage\": \"Sostituisci un'unità nella tua configurazione Full Storage.\",\n  \"storage-manager.swap.description-no-free-slot\": \"In modalità Full Storage, con tutti gli slot occupati, la sostituzione di un SSD richiede un processo completo di backup e ripristino.\",\n  \"storage-manager.swap.description-replace\": \"Migra i tuoi dati su un nuovo SSD, poi rimuovi quello vecchio.\",\n  \"storage-manager.swap.failed-to-start\": \"Impossibile avviare la sostituzione\",\n  \"storage-manager.swap.no-data-loss\": \"Nessuna perdita di dati\",\n  \"storage-manager.swap.no-data-loss-description\": \"I tuoi dati saranno copiati sul nuovo SSD. Una volta completato, potrai rimuovere il vecchio in sicurezza.\",\n  \"storage-manager.swap.safe-swap-available\": \"Sostituzione sicura disponibile\",\n  \"storage-manager.swap.safe-swap-description\": \"Poiché hai uno slot vuoto, puoi aggiungere prima il nuovo SSD e migrare i dati prima di rimuovere quello vecchio. Nessun backup richiesto.\",\n  \"storage-manager.swap.select-new-ssd\": \"Seleziona il nuovo SSD da utilizzare:\",\n  \"storage-manager.swap.ssd-in-slot\": \"SSD da {{size}} in Slot {{slot}}\",\n  \"storage-manager.swap.step-backup\": \"Esegui il backup dei dati\",\n  \"storage-manager.swap.step-backup-description\": \"Vai su Impostazioni → Backups e crea un backup di tutti i tuoi dati.\",\n  \"storage-manager.swap.step-data-copied\": \"I dati saranno copiati dal vecchio SSD a quello nuovo\",\n  \"storage-manager.swap.step-factory-reset\": \"Ripristino di fabbrica\",\n  \"storage-manager.swap.step-factory-reset-description\": \"Vai su Impostazioni → Avanzate → Ripristino di fabbrica per cancellare il tuo {{deviceName}}.\",\n  \"storage-manager.swap.step-insert-new-ssd\": \"Inserisci il nuovo SSD in uno slot vuoto\",\n  \"storage-manager.swap.step-may-take-while\": \"Potrebbe richiedere un po' di tempo a seconda della quantità di dati\",\n  \"storage-manager.swap.step-power-on\": \"Accendi il tuo {{deviceName}}\",\n  \"storage-manager.swap.step-remove-bottom-cover\": \"Rimuovi la copertura magnetica inferiore\",\n  \"storage-manager.swap.step-remove-old\": \"Una volta completato, spegni e rimuovi {{ssd}}\",\n  \"storage-manager.swap.step-replace-bottom-cover\": \"Rimetti la copertura inferiore\",\n  \"storage-manager.swap.step-restore\": \"Ripristina i tuoi dati\",\n  \"storage-manager.swap.step-restore-description\": \"Vai su Impostazioni → Backups e ripristina dal tuo backup.\",\n  \"storage-manager.swap.step-return-to-storage-manager\": \"Torna qui su Storage Manager per confermare la sostituzione e aggiungere il nuovo SSD alla tua archiviazione\",\n  \"storage-manager.swap.step-return-to-swap\": \"Torna qui su Storage Manager e clicca di nuovo su \\\"Swap\\\" per avviare la sostituzione\",\n  \"storage-manager.swap.step-setup-new-storage\": \"Configura la tua nuova archiviazione\",\n  \"storage-manager.swap.step-setup-new-storage-description\": \"Accendi il tuo {{deviceName}} e completa il processo di configurazione con il nuovo SSD.\",\n  \"storage-manager.swap.step-shut-down\": \"Spegni il tuo {{deviceName}}\",\n  \"storage-manager.swap.step-shut-down-and-swap\": \"Spegni e sostituisci {{ssd}}\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-other\": \"Spegni, apri il dispositivo, sostituisci l'SSD e rimonta il dispositivo.\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-pro\": \"Spegni, rimuovi la copertura inferiore, sostituisci l'SSD e richiudi la copertura.\",\n  \"storage-manager.swap.step-swap-ssd\": \"Sostituisci {{ssd}} con uno nuovo delle stesse dimensioni\",\n  \"storage-manager.swap.too-small\": \"Troppo piccolo (richiesto {{size}})\",\n  \"storage-manager.swap.what-happens-next\": \"Cosa succede dopo:\",\n  \"storage-manager.total-capacity-added\": \"Capacità totale aggiunta\",\n  \"storage-manager.umbrel-pro\": \"Umbrel Pro\",\n  \"storage-manager.used\": \"Utilizzato\",\n  \"storage-manager.wasted\": \"Non utilizzabile\",\n  \"storage-manager.wasted-size\": \"Spazio non utilizzabile: {{size}}\",\n  \"storage.full\": \"Archiviazione piena\",\n  \"storage.low\": \"Spazio di archiviazione basso\",\n  \"temperature\": \"Temperatura\",\n  \"temperature.dangerously-hot\": \"Molto caldo\",\n  \"temperature.nice\": \"Piacevole\",\n  \"temperature.normal\": \"Normale\",\n  \"temperature.too-hot-suggestion\": \"Considera di cambiare l'ambiente del tuo dispositivo.\",\n  \"temperature.warm\": \"Tiepido\",\n  \"terminal\": \"Terminal\",\n  \"terminal-description\": \"Esegui comandi personalizzati in umbrelOS o all'interno di un'app\",\n  \"terminal.app\": \"App\",\n  \"terminal.app-description\": \"Esegui comandi personalizzati all'interno di un'app specifica\",\n  \"terminal.umbrelos-description\": \"Esegui comandi personalizzati in umbrelOS\",\n  \"tor-description\": \"Accedi al tuo Umbrel da ovunque usando un browser Tor\",\n  \"tor-enabled-description\": \"Accedi al tuo Umbrel da ovunque usando un browser Tor all'indirizzo seguente:\",\n  \"tor-error\": \"Impossibile aggiornare l'impostazione Tor: {{message}}\",\n  \"tor.disable.description\": \"Questo potrebbe richiedere alcuni minuti\",\n  \"tor.disable.progress\": \"Disattivazione dell'accesso remoto tramite Tor\",\n  \"tor.enable.description\": \"Questo potrebbe richiedere alcuni minuti\",\n  \"tor.enable.mobile.switch-label\": \"Abilita l'accesso remoto Tor\",\n  \"tor.hidden-service\": \"URL del servizio nascosto Tor\",\n  \"troubleshoot\": \"Risoluzione problemi\",\n  \"troubleshoot-description\": \"Risolvi problemi di umbrelOS o di un'app\",\n  \"troubleshoot-no-logs-yet\": \"Nessun log ancora\",\n  \"troubleshoot-pick-title\": \"Risoluzione problemi\",\n  \"troubleshoot.app\": \"App\",\n  \"troubleshoot.app-description\": \"Visualizza i log di un'app installata sul tuo Umbrel\",\n  \"troubleshoot.app-download\": \"Scarica i log di {{app}}\",\n  \"troubleshoot.share-with-umbrel-support\": \"Condividi con il supporto Umbrel\",\n  \"troubleshoot.system-download\": \"Scarica {{label}}\",\n  \"troubleshoot.umbrelos-description\": \"Visualizza i log di umbrelOS\",\n  \"troubleshoot.umbrelos-logs\": \"Log di umbrelOS\",\n  \"trpc.backend-unavailable\": \"Errore: Connessione all'API di sistema fallita\",\n  \"trpc.checking-backend\": \"Caricamento...\",\n  \"try-again\": \"Riprova\",\n  \"umbrel\": \"Umbrel\",\n  \"umbrelos\": \"umbrelOS\",\n  \"unknown\": \"Sconosciuto\",\n  \"unknown-app\": \"App sconosciuta\",\n  \"unknown-error\": \"Errore sconosciuto\",\n  \"uptime\": \"Tempo di attività\",\n  \"url\": \"URL\",\n  \"wallpaper\": \"Sfondo\",\n  \"wallpaper-description\": \"Il tuo sfondo e tema Umbrel\",\n  \"whats-new.continue\": \"Continua\",\n  \"whats-new.feature-1.description\": \"Configura backup automatici e crittografati del tuo intero Umbrel su un'unità USB esterna, su un NAS o su un altro Umbrel.\",\n  \"whats-new.feature-2.description\": \"Torna indietro nel tempo per recuperare file e cartelle specifici da backup precedenti.\",\n  \"whats-new.feature-3.description\": \"Oppure ripristina l'intero Umbrel, comprese tutte le tue app, i file e i dati.\",\n  \"whats-new.feature-4.description\": \"Collega un NAS o un altro Umbrel e accedi al suo spazio di archiviazione da Files.\",\n  \"whats-new.feature-4.title\": \"Dispositivi di rete\",\n  \"whats-new.feature-5.description\": \"Collega unità USB esterne (su Umbrel Home o su qualsiasi dispositivo Intel o AMD) e accedervi da Files.\",\n  \"whats-new.feature-5.helper-text\": \"Non è supportato sui dispositivi Raspberry Pi a causa di possibili problemi di alimentazione.\",\n  \"whats-new.feature-5.title\": \"Archiviazione esterna\",\n  \"whats-new.next\": \"Avanti\",\n  \"whats-new.title\": \"Novità in {{version}}\",\n  \"widget.progress.in-progress\": \"In corso\",\n  \"widgets.edit.select-up-to-3-widgets\": \"Seleziona fino a 3 widget\",\n  \"widgets.install-an-app-before-using-widgets\": \"Installa un'app per iniziare a personalizzare la tua schermata iniziale con i widget.\",\n  \"wifi\": \"Wi-Fi\",\n  \"wifi-connect-insecure-message\": \"Le reti aperte possono essere insicure\",\n  \"wifi-connection-failed\": \"Impossibile connettersi\",\n  \"wifi-dangerous-change-confirmation-description\": \"Cambiare la rete Wi-Fi può disconnetterti dal tuo Umbrel. Per riconnetterti, assicurati che sia il tuo Umbrel che il dispositivo da cui accedi siano sulla stessa rete.\",\n  \"wifi-dangerous-change-confirmation-title\": \"Sei sicuro di voler cambiare la rete Wi-Fi?\",\n  \"wifi-dangerous-disable-confirmation-description\": \"Disabilitare il Wi-Fi può disconnetterti dal tuo Umbrel. Per riconnetterti, collega un cavo Ethernet al tuo Umbrel e assicurati che sia il tuo Umbrel che il dispositivo da cui accedi siano sulla stessa rete.\",\n  \"wifi-dangerous-disable-confirmation-title\": \"Sei sicuro di voler disabilitare il Wi-Fi?\",\n  \"wifi-description\": \"Connetti il tuo dispositivo a una rete Wi-Fi\",\n  \"wifi-description-long\": \"Il tuo dispositivo rimane connesso al Wi-Fi scelto, anche se il cavo Ethernet viene rimosso, e si riconnette automaticamente al Wi-Fi all'avvio.\",\n  \"wifi-no-networks-message\": \"Nessuna rete Wi-Fi trovata\",\n  \"wifi-searching\": \"Ricerca di reti Wi-Fi...\",\n  \"wifi-unsupported-device-description\": \"Il Wi-Fi non è supportato su questo dispositivo. Questo potrebbe essere dovuto a un adattatore wireless mancante o incompatibile.\",\n  \"wifi-view-networks\": \"Visualizza reti\"\n}"
  },
  {
    "path": "packages/ui/public/locales/ja.json",
    "content": "{\n  \"2fa\": \"2FA\",\n  \"2fa-description\": \"Umbrelのログインとアプリのための二段階認証\",\n  \"2fa.disable.title\": \"二段階認証を無効にする\",\n  \"2fa.enable.or-paste\": \"または、以下のコードを認証アプリに貼り付けてください\",\n  \"2fa.enable.scan-this\": \"Google AuthenticatorやAuthyのような認証アプリでこのQRコードをスキャンしてください\",\n  \"2fa.enable.title\": \"二段階認証を有効にする\",\n  \"2fa.enter-code\": \"認証アプリに表示されるコードを入力してください\",\n  \"account\": \"アカウント\",\n  \"account-description\": \"あなたの名前とパスワード\",\n  \"advanced-settings\": \"詳細設定\",\n  \"advanced-settings-description\": \"ターミナル、umbrelOSベータプログラム、Cloudflare DNSなど\",\n  \"app-not-found\": \"アプリが見つかりません: {{app}}\",\n  \"app-only-over-tor\": \"{{app}}はTor経由でのみ利用できます。このアプリを開くには、リモートアクセス用のURL（設定 > 詳細設定 > リモート Tor アクセス）にあるUmbrelへTorブラウザでアクセスしてください。\",\n  \"app-page.section.about\": \"約\",\n  \"app-page.section.credentials.title\": \"デフォルト資格情報\",\n  \"app-page.section.dependencies.n-alternatives\": \"{{count}} つの代替案を表示\",\n  \"app-page.section.info.compatibility\": \"互換性\",\n  \"app-page.section.info.compatibility-compatible\": \"互換あり\",\n  \"app-page.section.info.compatibility-not-compatible\": \"互換性がありません\",\n  \"app-page.section.info.developer\": \"開発者\",\n  \"app-page.section.info.source-code\": \"ソースコード\",\n  \"app-page.section.info.source-code.public\": \"公開\",\n  \"app-page.section.info.submitted-by\": \"提出者\",\n  \"app-page.section.info.support\": \"サポートを受ける\",\n  \"app-page.section.info.title\": \"情報\",\n  \"app-page.section.info.version\": \"バージョン\",\n  \"app-page.section.recommendations.title\": \"おすすめ\",\n  \"app-page.section.release-notes.title\": \"新機能\",\n  \"app-page.section.release-notes.version\": \"バージョン {{version}}\",\n  \"app-page.section.requires\": \"必要条件\",\n  \"app-picker.search\": \"検索...\",\n  \"app-picker.select-app\": \"アプリを選択...\",\n  \"app-settings.connected-to\": \"{{appName}}はこれらのアプリと接続されています\",\n  \"app-settings.save-changes\": \"変更を保存\",\n  \"app-settings.title\": \"設定\",\n  \"app-store.browse-category-apps\": \"{{category}}アプリを閲覧\",\n  \"app-store.category.ai\": \"AI\",\n  \"app-store.category.all\": \"すべてのアプリ\",\n  \"app-store.category.automation\": \"家庭＆自動化\",\n  \"app-store.category.bitcoin\": \"ビットコイン\",\n  \"app-store.category.crypto\": \"暗号資産\",\n  \"app-store.category.developer\": \"開発者ツール\",\n  \"app-store.category.discover\": \"発見\",\n  \"app-store.category.files\": \"ファイル＆生産性\",\n  \"app-store.category.finance\": \"金融\",\n  \"app-store.category.media\": \"メディア\",\n  \"app-store.category.networking\": \"ネットワーキング\",\n  \"app-store.category.social\": \"ソーシャル\",\n  \"app-store.description\": \"あなたのアプリ更新設定\",\n  \"app-store.discover.temporarily-unavailable-description\": \"上のカテゴリをブラウズするか、検索してアプリを見つけてください\",\n  \"app-store.discover.temporarily-unavailable-title\": \"特集コンテンツは一時的に利用できません\",\n  \"app-store.menu.community-app-stores\": \"コミュニティApp Stores\",\n  \"app-store.search-apps\": \"アプリを検索\",\n  \"app-store.search.no-results\": \"結果なし\",\n  \"app-store.search.results-for\": \"の結果\",\n  \"app-store.title\": \"App Store\",\n  \"app-store.updates\": \"アップデート\",\n  \"app-updates.less\": \"少なく\",\n  \"app-updates.more\": \"もっと\",\n  \"app-updates.no-updates\": \"すべてのアプリが最新です！\",\n  \"app-updates.update\": \"更新\",\n  \"app-updates.update-all\": \"すべて更新\",\n  \"app-updates.updates-available-count_one\": \"{{count}}個のアップデートが利用可能\",\n  \"app-updates.updates-available-count_other\": \"{{count}}個のアップデートが利用可能\",\n  \"app-updates.updating\": \"更新中...\",\n  \"app.install\": \"インストール\",\n  \"app.installed\": \"インストール済み\",\n  \"app.installing\": \"インストール中\",\n  \"app.offline\": \"実行されていません\",\n  \"app.open\": \"開く\",\n  \"app.optimized-for-umbrel-home\": \"Umbrel Homeに最適化\",\n  \"app.os-update-required.confirm\": \"umbrelOSのアップデートを確認\",\n  \"app.os-update-required.description\": \"{{appName}}はumbrelOS {{version}}以降が必要です\",\n  \"app.os-update-required.title\": \"umbrelOSをアップデート\",\n  \"app.restarting\": \"再起動中\",\n  \"app.starting\": \"起動中\",\n  \"app.stopping\": \"停止中\",\n  \"app.uninstall.confirm.description\": \"{{app}}に関連するすべてのデータが永久に削除されます。この操作は元に戻せません。\",\n  \"app.uninstall.confirm.submit\": \"アンインストール\",\n  \"app.uninstall.confirm.title\": \"{{app}}をアンインストールしますか？\",\n  \"app.uninstall.deps.used-by.description_one\": \"{{app}}をアンインストールする前に、最初に{{firstAppToUninstall}}をアンインストールしてください。\",\n  \"app.uninstall.deps.used-by.description_other\": \"これらのアプリを最初にアンインストールして、{{app}}をアンインストールしてください。\",\n  \"app.uninstall.deps.used-by.title\": \"{{app}}は以下のアプリで使用されています\",\n  \"app.uninstalling\": \"アンインストール中\",\n  \"app.updating\": \"更新中\",\n  \"app.view\": \"表示\",\n  \"app_one\": \"アプリ\",\n  \"app_other\": \"アプリ\",\n  \"apps.uninstall.failed-to-get-required-apps\": \"必要なアプリの取得に失敗しました\",\n  \"apps.uninstalled-all.success\": \"すべてのアプリがアンインストールされました\",\n  \"auth.checking-backend-for-user\": \"読み込み中...\",\n  \"auth.failed-checking-if-user-logged-in\": \"エラー: Authログインチェックに失敗しました\",\n  \"auth.failed-to-check-if-user-exists\": \"エラー: Auth存在チェックに失敗しました\",\n  \"back\": \"戻る\",\n  \"backups\": \"バックアップ\",\n  \"backups-configure\": \"バックアップを設定\",\n  \"backups-configure.add-backup-location\": \"バックアップ先を追加\",\n  \"backups-configure.available\": \"利用可能\",\n  \"backups-configure.awaiting-next-backup\": \"次の自動バックアップを待機中\",\n  \"backups-configure.back-up-now\": \"今すぐバックアップ\",\n  \"backups-configure.backing-up-now\": \"バックアップ中…\",\n  \"backups-configure.connected\": \"接続済み\",\n  \"backups-configure.connection\": \"接続\",\n  \"backups-configure.in-progress\": \"進行中\",\n  \"backups-configure.last-backup\": \"最終バックアップ\",\n  \"backups-configure.locations\": \"保存先\",\n  \"backups-configure.no-backup-locations\": \"データのバックアップを開始するには、バックアップ先を追加してください\",\n  \"backups-configure.not-connected\": \"未接続\",\n  \"backups-configure.path\": \"パス\",\n  \"backups-configure.remove-backup-location\": \"バックアップ先を削除\",\n  \"backups-configure.remove-backup-location-confirmation\": \"本当に削除しますか？\",\n  \"backups-configure.remove-backup-location-confirmation-description\": \"これにより '{{device}}' がバックアップ先から削除されます。このデバイス上の既存のバックアップは削除されませんが、自動バックアップは停止します。\",\n  \"backups-configure.status\": \"状態\",\n  \"backups-configure.total-backups\": \"Backupsの合計\",\n  \"backups-configure.used\": \"使用済み\",\n  \"backups-configure.view\": \"表示\",\n  \"backups-description\": \"ファイル、アプリ、データを別の Umbrel、NAS、または外付けドライブにバックアップします\",\n  \"backups-error.backup-not-found\": \"バックアップが見つかりませんでした。\",\n  \"backups-error.generic\": \"問題が発生しました: {{details}}\",\n  \"backups-error.in-progress\": \"バックアップ処理は既に実行中です。終了するまでしばらくお待ちください。\",\n  \"backups-error.invalid-exclusion-path\": \"バックアップから除外できるのは、ホームディレクトリ内のファイルとフォルダのみです。\",\n  \"backups-error.invalid-password\": \"暗号化パスワードが正しくありません。\",\n  \"backups-error.invalid-path\": \"選択した場所はバックアップ先として無効です。\",\n  \"backups-error.mount-failed\": \"バックアップスナップショットにアクセスできませんでした。\",\n  \"backups-error.mount-timeout\": \"バックアップスナップショットにアクセスできませんでした。再試行するか、デバイスが正しく接続されているか確認してください。\",\n  \"backups-error.not-enough-space\": \"バックアップ先のデバイスに十分な空き容量がありません。\",\n  \"backups-error.not-found\": \"バックアップまたはバックアップ先が見つかりませんでした。\",\n  \"backups-error.repository-exists\": \"このフォルダには既にバックアップ先が存在します。\",\n  \"backups-error.repository-not-found\": \"バックアップ先が見つかりませんでした。\",\n  \"backups-exclusions.add\": \"追加\",\n  \"backups-exclusions.app-paths-cannot-be-modified\": \"これらのファイル/フォルダはアプリ開発者によって設定されており、変更できません：\",\n  \"backups-exclusions.app-paths-explanation\": \"このアプリは以下のデータをバックアップ対象から除外しています。これらのパスには通常、再生成可能なキャッシュやログなどの重要でない項目、または復元時に競合や不整合を引き起こす可能性のある古いアプリ状態などが含まれます。\",\n  \"backups-exclusions.auto-excluded\": \"自動で除外\",\n  \"backups-exclusions.exclude-entire-app\": \"アプリ全体を除外\",\n  \"backups-exclusions.excluded-apps\": \"除外されたアプリ\",\n  \"backups-exclusions.files-and-folders\": \"除外されたファイルとフォルダ\",\n  \"backups-exclusions.no-excluded-apps\": \"除外されたアプリはありません\",\n  \"backups-exclusions.no-excluded-files-or-folders\": \"除外されたファイルやフォルダはありません\",\n  \"backups-exclusions.select-item-to-exclude\": \"除外する項目を選択\",\n  \"backups-exclusions.stop-excluding\": \"除外を解除\",\n  \"backups-floating-island.backing-up\": \"バックアップ中…\",\n  \"backups-floating-island.backing-up-to\": \"Umbrelをバックアップしています…\",\n  \"backups-restore\": \"復元\",\n  \"backups-restore-full\": \"完全復元\",\n  \"backups-restore-full-description\": \"バックアップからUmbrel全体を復元する\",\n  \"backups-restore-header\": \"Umbrel を復元\",\n  \"backups-restore-pro.after-restore\": \"復元後、一時的なアカウントはバックアップ済みのアカウントとデータに置き換わります。\",\n  \"backups-restore-pro.step1\": \"下の\\\"Get Started\\\"をクリックして初期設定を完了してください。バックアップを復元するまで、このアカウントは一時的なアカウントになります。\",\n  \"backups-restore-pro.step2\": \"セットアップが完了したら、<0>設定 → Backups → 復元</0>へ進んでください。\",\n  \"backups-restore-pro.step3\": \"復元ウィザードの案内に従ってください。\",\n  \"backups-restore-pro.subtitle\": \"Umbrel Proでバックアップから復元するには、いくつか追加の手順が必要です。\",\n  \"backups-restore.backup-date\": \"バックアップ日時\",\n  \"backups-restore.backup-location\": \"バックアップ先\",\n  \"backups-restore.browse-cloud-subtitle\": \"Umbrel Private Cloud から復元（近日対応予定）\",\n  \"backups-restore.browse-cloud-title\": \"Umbrel Private Cloud\",\n  \"backups-restore.browse-external-subtitle\": \"外付けUSBドライブから復元\",\n  \"backups-restore.browse-external-title\": \"外付けドライブ\",\n  \"backups-restore.browse-nas-or-external\": \"別の Umbrel、NAS、または外付けドライブを参照してバックアップから復元します\",\n  \"backups-restore.browse-nas-subtitle\": \"ネットワーク上の別の Umbrel または NAS デバイスから復元\",\n  \"backups-restore.browse-nas-title\": \"別の Umbrel または NAS\",\n  \"backups-restore.choose\": \"選択\",\n  \"backups-restore.choose-backup-location\": \"バックアップ先を選択\",\n  \"backups-restore.connect-to-backup-location\": \"バックアップ先に接続\",\n  \"backups-restore.encryption-password\": \"暗号化パスワード\",\n  \"backups-restore.encryption-password-description\": \"バックアップを有効にしたときに設定した暗号化パスワードを入力してください\",\n  \"backups-restore.enter-password-to-confirm\": \"確認のためUmbrelのパスワードを入力してください\",\n  \"backups-restore.final-confirmation\": \"本当に実行しますか？\",\n  \"backups-restore.final-confirmation-description\": \"このバックアップから復元すると、現在の umbrelOS のアプリとデータは選択したバックアップの内容で上書きされます。このバックアップで除外されているファイル、フォルダ、またはアプリは Umbrel から削除されます。この操作は元に戻せません。\",\n  \"backups-restore.invalid-password\": \"無効なパスワード\",\n  \"backups-restore.last-backup\": \"最終バックアップ：{{date}}\",\n  \"backups-restore.latest\": \"最新\",\n  \"backups-restore.no-backups-found\": \"バックアップが見つかりません\",\n  \"backups-restore.no-backups-yet\": \"まだバックアップがありません\",\n  \"backups-restore.please-select-backup\": \"バックアップを選択してください\",\n  \"backups-restore.please-select-repository\": \"リポジトリを選択してください\",\n  \"backups-restore.restore-from-nas-or-external\": \"別のUmbrel、NAS、または外付けドライブにあるバックアップからUmbrelを復元\",\n  \"backups-restore.restore-from-unlisted\": \"別の場所から復元\",\n  \"backups-restore.restore-umbrel\": \"Umbrelを復元\",\n  \"backups-restore.restore-warning\": \"このバックアップから復元すると、現在の umbrelOS のアプリとデータは選択したバックアップの内容で置き換えられます。このバックアップで除外されているファイル、フォルダ、またはアプリは Umbrel から削除されます。特定のファイルやフォルダだけを復元したい場合は、<0>Rewind</0> を開いてください。\",\n  \"backups-restore.restoring-from\": \"以下のバックアップから復元します：\",\n  \"backups-restore.review-description\": \"復元を行うと、バックアップ作成時に含まれていたアカウント、ファイル、アプリ、設定がUmbrelに復元されます。処理には時間がかかる場合があります。完了すると、ログインパスワードはバックアップ作成時に使用していたものに設定されます。\",\n  \"backups-restore.select-backup\": \"バックアップを選択\",\n  \"backups-restore.select-backup-description\": \"復元元のバックアップを選択してください\",\n  \"backups-restore.select-backup-file\": \"バックアップファイルを選択\",\n  \"backups-restore.select-backup-file-only\": \"選択できるのは<bold>{{backupFileName}}</bold>だけです\",\n  \"backups-restore.total-size\": \"合計サイズ\",\n  \"backups-restore.unknown-date\": \"不明な日付\",\n  \"backups-restore.unknown-repository\": \"不明なリポジトリ\",\n  \"backups-rewind\": \"Rewind\",\n  \"backups-rewind-description\": \"特定のファイルやフォルダを復元するために過去の状態に戻す\",\n  \"backups-rewind.start\": \"Rewindを開始\",\n  \"backups-setup\": \"セットアップ\",\n  \"backups-setup-confirm\": \"セットアップを完了\",\n  \"backups-setup-external-description\": \"外付けUSBドライブにバックアップする\",\n  \"backups-setup-nas-or-umbrel-description\": \"ネットワーク上の別の Umbrel または NAS デバイスにバックアップする\",\n  \"backups-setup-umbrel-or-nas\": \"別の Umbrel または NAS\",\n  \"backups-setup-umbrel-private-cloud\": \"Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-cta\": \"Umbrel Private Cloud に <bold>エンドツーエンドで暗号化されたバックアップ</bold> を保存して、自宅の外でも安心を広げましょう。\",\n  \"backups-setup-umbrel-private-cloud-cta-link\": \"先行アクセスを申し込む\",\n  \"backups-setup-umbrel-private-cloud-description\": \"Umbrel Private Cloud へのエンドツーエンド暗号化バックアップ\",\n  \"backups-setup-umbrel-private-cloud-subtitle\": \"近日公開\",\n  \"backups.add-umbrel-or-nas\": \"Umbrel または NAS を追加\",\n  \"backups.all-apps-and-data-will-be-backed-up\": \"すべてのアプリとデータがバックアップされます\",\n  \"backups.apps-and-data\": \"アプリとデータ\",\n  \"backups.backup-location\": \"バックアップ先\",\n  \"backups.browse\": \"参照\",\n  \"backups.choose-folder-within-device\": \"バックアップを保存するフォルダを<bold>{{device}}</bold>内から選択してください\",\n  \"backups.confirm-password\": \"パスワードを確認\",\n  \"backups.copy\": \"コピー\",\n  \"backups.encryption\": \"暗号化\",\n  \"backups.encryption-password-warning\": \"暗号化パスワードはパスワードマネージャーなどで安全に保管してください。表示は一度きりで、バックアップから復元する際に必要になります。\",\n  \"backups.exclude-from-backups\": \"バックアップから除外\",\n  \"backups.exclude-from-backups-description\": \"バックアップから特定のファイル、フォルダ、アプリを除外します。\",\n  \"backups.hide\": \"非表示\",\n  \"backups.i-understand\": \"理解しました\",\n  \"backups.location\": \"保存先\",\n  \"backups.modals.already-in-use.description\": \"この場所はすでにこのUmbrelのバックアップ用に使われています。\",\n  \"backups.modals.already-in-use.manage\": \"Backupsで管理\",\n  \"backups.modals.already-in-use.title\": \"バックアップ先はすでに使用中です\",\n  \"backups.modals.connect-existing.description\": \"この場所にはすでにUmbrelのバックアップがあります。暗号化パスワードを入力して、このUmbrelに追加してください。\",\n  \"backups.modals.connect-existing.title\": \"既存のUmbrelバックアップを接続\",\n  \"backups.no-external-drives-detected\": \"外付けドライブが検出されませんでした\",\n  \"backups.no-password-set\": \"パスワードが設定されていません\",\n  \"backups.password-is-set\": \"パスワードが設定されています\",\n  \"backups.password-minimum-length\": \"パスワードは最低8文字である必要があります\",\n  \"backups.password-safety-warning\": \"このパスワードでバックアップは暗号化されます。再表示できないため、安全に保管してください。復元時に必要になります。\",\n  \"backups.passwords-do-not-match\": \"パスワードが一致しません\",\n  \"backups.please-choose-folder\": \"フォルダを選択してください\",\n  \"backups.restore-failed.message\": \"Umbrelを復元中にエラーが発生しました。現在のアプリやデータに変更はありません。\",\n  \"backups.restore-failed.retry\": \"復元画面に移動\",\n  \"backups.restore-failed.title\": \"復元に失敗しました\",\n  \"backups.restoring\": \"Umbrelを復元しています\",\n  \"backups.restoring-completing\": \"最終処理中です。Umbrelはまもなく再起動します…\",\n  \"backups.restoring-progress\": \"復元済み {{percent}}%\",\n  \"backups.restoring-time-remaining\": \"残り {{time}}\",\n  \"backups.restoring-warning\": \"復元中は Umbrel の電源を切ったり、バックアップ先の接続を切断したりしないでください\",\n  \"backups.review\": \"確認して承認\",\n  \"backups.review-description\": \"バックアップの詳細を確認して選択を確定してください\",\n  \"backups.scanning-for-external-drives\": \"外付けドライブをスキャン中…\",\n  \"backups.schedule-description\": \"umbrelOS はデータを毎時自動的にバックアップします。過去24時間分の暗号化された時間ごとのバックアップ、過去1週間分の毎日のバックアップ、過去1か月分の毎週のバックアップ、過去1年分の毎月のバックアップを保持します。1年以上前のバックアップは自動的に削除されます。\",\n  \"backups.select-backup-folder\": \"バックアップフォルダを選択\",\n  \"backups.select-backup-folder-description\": \"バックアップを保存するフォルダを選択してください。\",\n  \"backups.select-backup-location\": \"バックアップ先を選択\",\n  \"backups.set-encryption-password\": \"暗号化パスワードを設定\",\n  \"backups.set-encryption-password-description\": \"パスワードでバックアップを保護します。これによりデータがプライベートに保たれ、このパスワードでのみ復元できます。\",\n  \"backups.show\": \"表示\",\n  \"backups.storage-capacity-warning\": \"{{device}} にはバックアップサイズの少なくとも2倍の空き容量が必要です\",\n  \"backups.store-encryption-password-safely\": \"暗号化パスワードを安全に保管してください\",\n  \"beta-program\": \"umbrelOSベータプログラム\",\n  \"beta-program-description\": \"umbrelOSのベータアップデートを受け取ることに同意し、新機能への早期アクセスを得て、フィードバックを提供することでそれらの改善に協力してください。ベータアップデートは不安定な場合があり、トラブルシューティングにはTerminalの知識が必要になることがあります。\",\n  \"cancel\": \"キャンセル\",\n  \"change\": \"変更\",\n  \"change-name\": \"名前を変更\",\n  \"change-name.failed.name-required\": \"名前が必要です\",\n  \"change-name.input-placeholder\": \"あなたの名前\",\n  \"change-password\": \"パスワードを変更\",\n  \"change-password.callout\": \"パスワードを失った場合、Umbrelにログインできなくなります。安全に保管してください。\",\n  \"change-password.current-password\": \"現在のパスワード\",\n  \"change-password.failed.current-required\": \"現在のパスワードが必要です\",\n  \"change-password.failed.min-length\": \"パスワードは少なくとも{{characters}}文字である必要があります\",\n  \"change-password.failed.must-be-unique\": \"新しいパスワードは現在のパスワードと異なる必要があります\",\n  \"change-password.failed.new-required\": \"新しいパスワードが必要です\",\n  \"change-password.failed.no-match\": \"パスワードが一致しません\",\n  \"change-password.failed.repeat-required\": \"パスワードの再入力が必要です\",\n  \"change-password.new-password\": \"新しいパスワード\",\n  \"change-password.repeat-password\": \"パスワードを再入力\",\n  \"check-for-latest-version\": \"最新のumbrelOSアップデートを確認\",\n  \"clipboard.copied\": \"コピーしました\",\n  \"close\": \"閉じる\",\n  \"cmdk.change-wallpaper\": \"壁紙を変更\",\n  \"cmdk.frequent-apps\": \"よく使用されるアプリ\",\n  \"cmdk.input-placeholder\": \"アプリ、設定、またはアクションを検索\",\n  \"cmdk.live-usage\": \"ライブ使用状況\",\n  \"cmdk.restart-umbrel\": \"Umbrelを再起動\",\n  \"cmdk.shutdown-umbrel\": \"Umbrelをシャットダウン\",\n  \"cmdk.update-all-apps\": \"すべてのアプリをアップデート\",\n  \"cmdk.widgets\": \"ウィジェット\",\n  \"community-app-store\": \"コミュニティApp Store\",\n  \"community-app-store.add-error\": \"App Store の追加に失敗しました: {{message}}\",\n  \"community-app-store.back-to-umbrel-app-store\": \"Umbrel App Storeに戻る\",\n  \"community-app-store.open-button\": \"開く\",\n  \"community-app-store.remove-button\": \"削除\",\n  \"community-app-store.remove-error\": \"App Store の削除に失敗しました: {{message}}\",\n  \"community-app-stores.add-button\": \"追加\",\n  \"community-app-stores.description\": \"コミュニティApp Storesを使用すると、公式Umbrel App Storeでは入手できないアプリをUmbrelにインストールできます。これにより、開発者が公式Umbrel App Storeでリリースする前にUmbrelアプリのベータ版をテストするのも簡単になります。\",\n  \"community-app-stores.learn-more\": \"もっと学ぶ\",\n  \"community-app-stores.warning\": \"コミュニティApp Storesは誰でも作成できます。そこに公開されているアプリは、公式Umbrel App Storeチームによって検証されたり、審査されたりすることはありません。不安定または悪意のある可能性があります。注意して使用し、信頼できる開発者からのアプリストアのみを追加してください。\",\n  \"confirm\": \"確認\",\n  \"connect\": \"接続\",\n  \"connecting\": \"接続中...\",\n  \"connection-lost\": \"接続が切断されました\",\n  \"connection-lost-description\": \"ブラウザのタブが非アクティブになっている、ネットワーク接続が中断された、またはデバイスがオフラインになっている場合に発生することがあります。\",\n  \"continue\": \"続行\",\n  \"continue-to-log-in\": \"ログインを続ける\",\n  \"cpu\": \"CPU\",\n  \"cpu-core-count\": \"{{cores}}スレッド\",\n  \"default-credentials.close\": \"了解\",\n  \"default-credentials.description\": \"アプリにログインするために必要な資格情報です。\",\n  \"default-credentials.dont-show-again\": \"これを再度表示しない\",\n  \"default-credentials.dont-show-again-notice\": \"将来いつでもアプリアイコンを右クリックすることで、これらの資格情報にアクセスできます。\",\n  \"default-credentials.open\": \"{{app}}を開く\",\n  \"default-credentials.password\": \"デフォルトパスワード\",\n  \"default-credentials.title\": \"{{app}}の資格情報\",\n  \"default-credentials.username\": \"デフォルトユーザー名\",\n  \"desktop.app.context.go-to-store-page\": \"App Storeで表示\",\n  \"desktop.app.context.settings\": \"設定\",\n  \"desktop.app.context.show-default-credentials\": \"デフォルトの資格情報を表示\",\n  \"desktop.app.context.uninstall\": \"アンインストール\",\n  \"desktop.context-menu.change-wallpaper\": \"壁紙を変更\",\n  \"desktop.context-menu.edit-widgets\": \"ウィジェットを編集\",\n  \"desktop.context-menu.logout\": \"ログアウト\",\n  \"desktop.greeting.afternoon\": \"こんにちは、{{name}}\",\n  \"desktop.greeting.evening\": \"こんばんは、{{name}}\",\n  \"desktop.greeting.morning\": \"おはようございます、{{name}}\",\n  \"desktop.install-first.for-the-ai-enthusiast\": \"バイブを楽しむ人向け\",\n  \"desktop.install-first.for-the-bitcoiner\": \"ビットコイナー用\",\n  \"desktop.install-first.for-the-self-hoster\": \"セルフホスティング用\",\n  \"desktop.install-first.for-the-streamer\": \"ストリーマー用\",\n  \"desktop.install-first.link-to-app-store\": \"App Storeでさらに探索\",\n  \"desktop.not-enough-room\": \"アプリを表示するにはもっと大きな画面を使用してください。\",\n  \"device\": \"デバイス\",\n  \"device-info\": \"デバイス情報\",\n  \"device-info-description\": \"あなたのデバイスに関する情報\",\n  \"device-info.device\": \"デバイス\",\n  \"device-info.model-number\": \"モデル番号\",\n  \"device-info.serial-number\": \"シリアル番号\",\n  \"device-info.view-info\": \"情報を見る\",\n  \"device-name.home-or-pro\": \"Umbrel Home または Umbrel Pro\",\n  \"disable\": \"無効にする\",\n  \"done\": \"完了\",\n  \"download-logs\": \"ログをダウンロード\",\n  \"enabling-tor\": \"リモートTorアクセスを有効にしています\",\n  \"external-dns\": \"Cloudflare DNS\",\n  \"external-dns-description\": \"Cloudflare DNSは、より良いネットワークの信頼性を提供します。無効にすると、ルーターのDNS設定を使用します。\",\n  \"external-dns-error\": \"DNS 設定の更新に失敗しました: {{message}}\",\n  \"external-drive\": \"外付けドライブ\",\n  \"factory-reset\": \"工場出荷時リセット\",\n  \"factory-reset-description\": \"すべてのデータとアプリを消去し、umbrelOSを初期設定に戻します\",\n  \"factory-reset-failed\": \"デバイスのリセットに失敗しました: {{message}}\",\n  \"factory-reset.confirm.body\": \"リセットするにはパスワードを確認してください\",\n  \"factory-reset.confirm.ethernet-required-warning\": \"デバイスがルーターにイーサネットで接続されていることを確認し（Wi-Fiではなく）、ローカルネットワークからアクセスしていることを確認してください（例：http://umbrel.local またはデバイスのローカルIPアドレス）。\",\n  \"factory-reset.confirm.submit\": \"すべてを消去してリセット\",\n  \"factory-reset.confirm.submit-callout\": \"この操作は元に戻せません。\",\n  \"factory-reset.rebooting.message\": \"デバイスが再起動し、すべてのデータが消去されます。このページを閉じないでください。\",\n  \"factory-reset.rebooting.status\": \"リセット中…\",\n  \"factory-reset.rebooting.title\": \"工場出荷時リセットを実行中\",\n  \"factory-reset.review.account-info\": \"アカウント情報とパスワード\",\n  \"factory-reset.review.apps\": \"アプリ\",\n  \"factory-reset.review.following-will-be-removed\": \"以下のデータがあなたのデバイスから削除されます\",\n  \"factory-reset.review.installed-apps_one\": \"{{count}}個のインストール済みアプリ\",\n  \"factory-reset.review.installed-apps_other\": \"{{count}}個のインストール済みアプリ\",\n  \"factory-reset.review.submit\": \"続行\",\n  \"factory-reset.review.total-data\": \"総データ\",\n  \"files\": \"Files\",\n  \"files-action.add-favorite\": \"お気に入りに追加\",\n  \"files-action.add-network-device\": \"デバイスを追加\",\n  \"files-action.cancel-upload\": \"アップロードをキャンセル\",\n  \"files-action.compress\": \"圧縮\",\n  \"files-action.copy\": \"コピー\",\n  \"files-action.cut\": \"切り取り\",\n  \"files-action.delete\": \"完全に削除\",\n  \"files-action.download\": \"ダウンロード\",\n  \"files-action.download-items\": \"アイテム{{count}}件をダウンロード\",\n  \"files-action.drop-to-upload\": \"ここにドロップしてアップロード\",\n  \"files-action.eject-disk\": \"取り出す\",\n  \"files-action.empty-trash\": \"ゴミ箱を空にする\",\n  \"files-action.format-drive\": \"フォーマット\",\n  \"files-action.go-to-path\": \"移動...\",\n  \"files-action.new-folder\": \"新規フォルダ\",\n  \"files-action.open\": \"開く\",\n  \"files-action.paste\": \"貼り付け\",\n  \"files-action.remove-favorite\": \"お気に入りから削除\",\n  \"files-action.remove-network-host\": \"ネットワークドライブを取り出す\",\n  \"files-action.remove-network-share\": \"ネットワーク共有を取り出す\",\n  \"files-action.rename\": \"名前の変更\",\n  \"files-action.restore\": \"復元\",\n  \"files-action.select\": \"選択\",\n  \"files-action.share\": \"ネットワークで共有...\",\n  \"files-action.sharing\": \"共有処理中...\",\n  \"files-action.show-in-folder\": \"所在フォルダを表示\",\n  \"files-action.trash\": \"ゴミ箱に移動\",\n  \"files-action.uncompress\": \"展開\",\n  \"files-action.upload\": \"アップロード\",\n  \"files-add-network-share.add-manually\": \"手動で追加\",\n  \"files-add-network-share.add-share\": \"共有を追加\",\n  \"files-add-network-share.back\": \"戻る\",\n  \"files-add-network-share.continue\": \"続ける\",\n  \"files-add-network-share.description\": \"NASやネットワーク上の他の共有ドライブに接続して、Files内でアクセスできます。\",\n  \"files-add-network-share.discovering\": \"検出中...\",\n  \"files-add-network-share.enter-details-manually\": \"サーバーの詳細を手動で入力\",\n  \"files-add-network-share.host-label\": \"サーバアドレス\",\n  \"files-add-network-share.host-required\": \"サーバアドレスは必須です\",\n  \"files-add-network-share.manual-share-help\": \"サーバーに表示されている共有名を正確に入力してください\",\n  \"files-add-network-share.no-shares-found\": \"このサーバーでは共有が見つかりませんでした\",\n  \"files-add-network-share.not-seeing-share\": \"共有が見つかりませんか？\",\n  \"files-add-network-share.password-label\": \"パスワード\",\n  \"files-add-network-share.password-required\": \"パスワードは必須です\",\n  \"files-add-network-share.retrieving-shares\": \"共有を取得中...\",\n  \"files-add-network-share.retry-discovery\": \"ネットワークを再検索\",\n  \"files-add-network-share.select-share\": \"追加する共有を選択\",\n  \"files-add-network-share.share-placeholder\": \"shared-documents\",\n  \"files-add-network-share.share-required\": \"共有名は必須です\",\n  \"files-add-network-share.title\": \"ネットワーク共有を追加\",\n  \"files-add-network-share.username-label\": \"ユーザ名\",\n  \"files-add-network-share.username-placeholder\": \"admin\",\n  \"files-add-network-share.username-required\": \"ユーザ名は必須です\",\n  \"files-audio-island.now-playing\": \"再生中\",\n  \"files-audio-island.pause\": \"一時停止\",\n  \"files-audio-island.play\": \"再生\",\n  \"files-backend-error.base-directory-not-found\": \"ベースディレクトリが見つかりませんでした\",\n  \"files-backend-error.cant-find-root\": \"ファイルパスを確認できませんでした\",\n  \"files-backend-error.destination-already-exists\": \"移動先に同じ名前の項目が既に存在します\",\n  \"files-backend-error.destination-not-exist\": \"移動先のフォルダが存在しません\",\n  \"files-backend-error.does-not-exist\": \"ファイルまたはフォルダが存在しません\",\n  \"files-backend-error.escapes-base\": \"パスが許可されたディレクトリの範囲外です\",\n  \"files-backend-error.invalid-base\": \"パスが有効なディレクトリに属していません\",\n  \"files-backend-error.invalid-filename\": \"ファイル名が無効です\",\n  \"files-backend-error.invalid-path\": \"ファイルパスが無効です\",\n  \"files-backend-error.mkdir-failed\": \"フォルダの作成に失敗しました\",\n  \"files-backend-error.move-failed\": \"項目の移動に失敗しました\",\n  \"files-backend-error.not-enough-space\": \"ストレージの空き容量が足りません\",\n  \"files-backend-error.operation-not-allowed\": \"この操作は許可されていません\",\n  \"files-backend-error.parent-not-directory\": \"親パスがフォルダではありません\",\n  \"files-backend-error.parent-not-exist\": \"親フォルダが存在しません\",\n  \"files-backend-error.path-not-absolute\": \"ファイルパスが無効です\",\n  \"files-backend-error.share-already-exists\": \"このフォルダは既に共有されています\",\n  \"files-backend-error.share-name-generation-failed\": \"一意の共有名を生成できませんでした\",\n  \"files-backend-error.source-not-exists\": \"元のファイルまたはフォルダが存在しません\",\n  \"files-backend-error.subdir-of-self\": \"フォルダを自身の中に移動またはコピーすることはできません\",\n  \"files-backend-error.trash-meta-not-exists\": \"この項目の元の場所が見つかりませんでした\",\n  \"files-backend-error.unique-name-index-exceeded\": \"一意の名前を生成できませんでした。類似の名前の項目が多すぎます\",\n  \"files-backend-error.upload-failed\": \"アップロードに失敗しました\",\n  \"files-collision.action.keep-both\": \"両方とも残す\",\n  \"files-collision.action.replace\": \"置き換える\",\n  \"files-collision.action.skip\": \"スキップ\",\n  \"files-collision.destination.original-location\": \"元の場所\",\n  \"files-collision.message\": \"既存のアイテムを置き換えるか、両方とも残しますか？\",\n  \"files-collision.title\": \"「{{itemName}}」はすでに{{destinationName}}に存在します\",\n  \"files-download.confirm\": \"ダウンロード\",\n  \"files-download.description\": \"「Files」ではこの種類のファイルを開けません。代わりにダウンロードしますか？\",\n  \"files-download.title\": \"「{{name}}」をダウンロードしますか？\",\n  \"files-empty-trash.confirm\": \"空にする\",\n  \"files-empty-trash.description\": \"本当にゴミ箱内のすべてのアイテムを完全に削除しますか？ この操作は元に戻せません。\",\n  \"files-empty-trash.title\": \"ゴミ箱を空にしますか？\",\n  \"files-empty.directory\": \"このフォルダにはアイテムがありません\",\n  \"files-empty.network\": \"ネットワークデバイスがありません\",\n  \"files-empty.network-host-offline\": \"ネットワークデバイスがオフラインです\",\n  \"files-error.add-favorite\": \"お気に入りに追加できませんでした: {{message}}\",\n  \"files-error.add-share\": \"フォルダの共有に失敗しました: {{message}}\",\n  \"files-error.compress\": \"圧縮に失敗しました: {{message}}\",\n  \"files-error.copy\": \"コピーに失敗しました: {{message}}\",\n  \"files-error.create-folder\": \"フォルダの作成に失敗しました: {{message}}\",\n  \"files-error.delete\": \"削除に失敗しました: {{message}}\",\n  \"files-error.eject-disk\": \"ドライブの取り出しに失敗しました: {{message}}\",\n  \"files-error.empty-trash\": \"ゴミ箱を空にできませんでした: {{message}}\",\n  \"files-error.extract\": \"展開に失敗しました: {{message}}\",\n  \"files-error.folder-already-exists\": \"同名のフォルダが既に存在します\",\n  \"files-error.move\": \"移動に失敗しました: {{message}}\",\n  \"files-error.remove-favorite\": \"お気に入りから削除できませんでした: {{message}}\",\n  \"files-error.remove-share\": \"共有フォルダの削除に失敗しました: {{message}}\",\n  \"files-error.rename\": \"名前の変更に失敗しました: {{message}}\",\n  \"files-error.restore\": \"復元に失敗しました: {{message}}\",\n  \"files-error.trash\": \"ゴミ箱へ移動できませんでした: {{message}}\",\n  \"files-error.upload\": \"アップロードに失敗しました: {{message}}\",\n  \"files-error.upload-network-error\": \"{{name}} のアップロードに失敗しました: ネットワークエラーが発生しました\",\n  \"files-extension-change.confirm\": \"続行\",\n  \"files-extension-change.description-add\": \"「{{fileName}}」の拡張子を「{{extension}}」に変更してもよろしいですか？ ファイルが読み込めなくなる可能性があります。\",\n  \"files-extension-change.description-remove\": \"「{{fileName}}」の拡張子を削除してもよろしいですか？\",\n  \"files-extension-change.title-add\": \"拡張子を「{{extension}}」に変更しますか？\",\n  \"files-extension-change.title-remove\": \"拡張子を削除しますか？\",\n  \"files-external-storage.unsupported.description\": \"接続した外付けドライブは電力の問題によりRaspberry Piでは使用できません。外付けストレージはUmbrel Home、Umbrel Pro、およびすべてのx86（IntelまたはAMD）デバイスで利用できます。\",\n  \"files-external-storage.unsupported.description-general\": \"電力の問題によりRaspberry Piでは外付けストレージは利用できません。外付けストレージはUmbrel Home、Umbrel Pro、およびすべての x86 (Intel または AMD) デバイスで利用できます。\",\n  \"files-external-storage.unsupported.title\": \"外部ストレージには対応していません\",\n  \"files-folder\": \"フォルダ\",\n  \"files-format.confirm\": \"フォーマット\",\n  \"files-format.description\": \"{{driveName}}のすべてのデータが消去されます。この操作は元に戻せません。\",\n  \"files-format.description-unreadable\": \"umbrelOSは{{driveName}}の内容を読み取れません。umbrelOSで使用するにはフォーマットしてください。\",\n  \"files-format.drive-label\": \"名前\",\n  \"files-format.error\": \"ドライブのフォーマットに失敗しました\",\n  \"files-format.exfat-description\": \"Windows、macOS、Linuxとの互換性が最も高い\",\n  \"files-format.ext4-description\": \"umbrelOSおよびLinuxでのパフォーマンスに優れる\",\n  \"files-format.filesystem\": \"ファイルシステム\",\n  \"files-format.filesystem-label\": \"フォーマット形式\",\n  \"files-format.formatting\": \"フォーマット中...\",\n  \"files-format.title\": \"ドライブをフォーマット\",\n  \"files-format.title-requires-format\": \"フォーマットが必要です\",\n  \"files-formatting-island.formatting\": \"フォーマット中…\",\n  \"files-formatting-island.formatting-drives\": \"{{count}}個のドライブをフォーマットしています\",\n  \"files-listing.empty\": \"アイテムがありません\",\n  \"files-listing.error\": \"エラーが発生しました\",\n  \"files-listing.item-count-truncated\": \"{{formattedCount}}+ 項目\",\n  \"files-listing.item-count_one\": \"{{formattedCount}} 件の項目\",\n  \"files-listing.item-count_other\": \"{{formattedCount}} 件の項目\",\n  \"files-listing.loading\": \"読み込み中...\",\n  \"files-listing.no-such-file\": \"そのようなファイルまたはフォルダはありません\",\n  \"files-listing.selected-count\": \"全{{totalCount}}件のうち{{selectedCount}}件を選択\",\n  \"files-listing.selected-count-truncated\": \"{{totalCount}}+ 項目中 {{selectedCount}} 項目を選択\",\n  \"files-name-drawer.new-folder\": \"新規フォルダ\",\n  \"files-name-drawer.new-folder-description\": \"新しいフォルダの名前を入力してください。\",\n  \"files-name-drawer.new-folder-input\": \"フォルダ名\",\n  \"files-name-drawer.rename-file\": \"ファイル名の変更\",\n  \"files-name-drawer.rename-file-description\": \"このファイルの新しい名前を入力してください。\",\n  \"files-name-drawer.rename-file-input\": \"ファイル名\",\n  \"files-name-drawer.rename-folder\": \"フォルダ名の変更\",\n  \"files-name-drawer.rename-folder-description\": \"このフォルダの新しい名前を入力してください。\",\n  \"files-name-drawer.rename-folder-input\": \"フォルダ名\",\n  \"files-network-storage-error.add-share\": \"ネットワーク共有の追加に失敗しました: {{message}}\",\n  \"files-network-storage-error.discover-servers\": \"ネットワーク機器の検出に失敗しました: {{message}}\",\n  \"files-network-storage-error.discover-shares\": \"ネットワーク共有の検出に失敗しました: {{message}}\",\n  \"files-network-storage-error.remove-share\": \"ネットワーク共有の削除に失敗しました: {{message}}\",\n  \"files-operations-island.copying\": \"「{{from}}」を「{{to}}」にコピーしています\",\n  \"files-operations-island.moving\": \"「{{from}}」を「{{to}}」に移動しています\",\n  \"files-operations-island.restoring\": \"「{{from}}」を「{{to}}」に復元しています\",\n  \"files-path.input-group\": \"パス入力\",\n  \"files-path.input-label\": \"現在のパス\",\n  \"files-permanently-delete.confirm\": \"完全に削除\",\n  \"files-permanently-delete.description-multiple\": \"これら{{count}}個のアイテムを完全に削除してよろしいですか？ この操作は元に戻せません。\",\n  \"files-permanently-delete.description-single\": \"「{{fileName}}」を完全に削除してよろしいですか？ この操作は元に戻せません。\",\n  \"files-permanently-delete.title-multiple\": \"{{count}}個のアイテムを完全に削除しますか？\",\n  \"files-permanently-delete.title-single\": \"完全に削除しますか？\",\n  \"files-search.default\": \"ファイルとフォルダを検索\",\n  \"files-search.no-results\": \"「{{query}}」の検索結果は見つかりませんでした\",\n  \"files-search.placeholder\": \"検索\",\n  \"files-search.searching-label\": \"{{name}}のUmbrelを検索中\",\n  \"files-share.home-description\": \"ネットワーク上の他のデバイスから「{{homeDirectoryName}}」内のすべてのファイルにアクセスできるようにします。\",\n  \"files-share.home-title\": \"ネットワーク経由で「{{homeDirectoryName}}」を共有\",\n  \"files-share.instructions.how-to-access\": \"アクセス方法\",\n  \"files-share.instructions.ios.enter-password\": \"パスワードとして <field>{{password}}</field> を入力します。\",\n  \"files-share.instructions.ios.enter-server\": \"サーバアドレスとして <field>{{smbUrl}}</field> を入力します。\",\n  \"files-share.instructions.ios.enter-username\": \"ユーザ名として <field>{{username}}</field> を入力します。\",\n  \"files-share.instructions.ios.install-files\": \"「Files」アプリがインストールされていない場合は、App Store からインストールしてください。\",\n  \"files-share.instructions.ios.tap-connect\": \"「接続」をタップしてアクセスします。\",\n  \"files-share.instructions.ios.tap-dots\": \"画面右上の三点ボタン（...）をタップし、「サーバに接続」を選択します。\",\n  \"files-share.instructions.macos.click-connect\": \"「接続」をクリックしてアクセスします。\",\n  \"files-share.instructions.macos.enter-password\": \"パスワードとして <field>{{password}}</field> を入力します。\",\n  \"files-share.instructions.macos.enter-url\": \"<field>{{smbUrl}}</field> を入力して「接続」をクリックします。\",\n  \"files-share.instructions.macos.enter-username\": \"ユーザ名として <field>{{username}}</field> を入力します。\",\n  \"files-share.instructions.macos.open-finder\": \"「Finder」を開き、⌘ + K を押します。\",\n  \"files-share.instructions.macos.select-registered\": \"表示されたら「登録ユーザ」を選択します。\",\n  \"files-share.instructions.macos.time-machine\": \"Time Machine のバックアップ先としての使い方\",\n  \"files-share.instructions.macos.time-machine.choose-encryption\": \"暗号化するか、暗号化しないかを選択します。\",\n  \"files-share.instructions.macos.time-machine.disk-limit\": \"「ディスク使用制限」には、Time Machine バックアップに Umbrel 上で割り当てたい最大容量を入力し、「完了」をクリックします。\",\n  \"files-share.instructions.macos.time-machine.follow-steps\": \"上記の手順に従った後、Mac のシステム設定を開きます。\",\n  \"files-share.instructions.macos.time-machine.go-settings\": \"Time Machine に移動し、「バックアップディスクを追加...」をクリックします。\",\n  \"files-share.instructions.macos.time-machine.select-disk\": \"フォルダを選択して「ディスクを設定...」をクリックしてください。\",\n  \"files-share.instructions.umbrelos.backup.follow-onscreen\": \"画面の案内に従ってバックアップを設定してください。\",\n  \"files-share.instructions.umbrelos.backup.follow-then-go-to\": \"上の手順に従った後、別のUmbrelで\\\"{{settings}}\\\" > \\\"{{backups}}\\\"に移動してください。\",\n  \"files-share.instructions.umbrelos.backup.select-add\": \"\\\"{{addUmbrelOrNas}}\\\"のオプションを選択してください。\",\n  \"files-share.instructions.umbrelos.backup.select-connected\": \"接続されているデバイスの一覧からこのUmbrelデバイスを選択してください。\",\n  \"files-share.instructions.umbrelos.backup.title\": \"他の Umbrel のバックアップ先としての使い方\",\n  \"files-share.instructions.umbrelos.cant-find-note\": \"見つかりませんか？ \\\"手動で追加\\\" を選択し、以下の認証情報を使用してみてください。それでも追加できない場合は、両方のデバイスが同じネットワークに接続されていることを確認してください。\",\n  \"files-share.instructions.umbrelos.enter-password\": \"パスワードとして <field>{{password}}</field> を入力してください。\",\n  \"files-share.instructions.umbrelos.enter-username\": \"ユーザー名として <field>{{username}}</field> を入力してください。\",\n  \"files-share.instructions.umbrelos.open-and-click\": \"別のUmbrelで、\\\"Files\\\"を開き、サイドバーの\\\"<deviceIcon/> {{deviceLabel}}\\\"の横にある<plus/>をクリックしてください。\",\n  \"files-share.instructions.umbrelos.select-device\": \"ネットワーク上で自動検出されたデバイス一覧からこのUmbrelデバイスを選択してください。\",\n  \"files-share.instructions.umbrelos.select-sharename\": \"「{{sharename}}」を選択して共有を追加してください。\",\n  \"files-share.instructions.windows.enter-password\": \"パスワードとして <field>{{password}}</field> を入力します。\",\n  \"files-share.instructions.windows.enter-url\": \"<field>{{smbUrl}}</field> と入力して Enter キーを押します。\",\n  \"files-share.instructions.windows.enter-username\": \"ユーザー名として <field>{{username}}</field> を入力します。\",\n  \"files-share.instructions.windows.open-run\": \"Windows + R を押して「ファイル名を指定して実行」ダイアログを開きます。\",\n  \"files-share.instructions.windows.remember-credentials\": \"「Remember my credentials」にチェックを入れて「OK」をクリックします。\",\n  \"files-share.regular-description\": \"ネットワーク上の他のデバイスからこのフォルダにアクセスできるようにします。\",\n  \"files-share.regular-title\": \"ネットワーク経由でフォルダを共有\",\n  \"files-share.toggle\": \"「{{name}}」をネットワーク共有する\",\n  \"files-sidebar.apps\": \"アプリ\",\n  \"files-sidebar.external-storage\": \"外部ストレージ\",\n  \"files-sidebar.favorites\": \"お気に入り\",\n  \"files-sidebar.home\": \"ホーム\",\n  \"files-sidebar.navigation\": \"ファイルナビゲーション\",\n  \"files-sidebar.network\": \"ネットワーク\",\n  \"files-sidebar.network-pathbar\": \"ネットワークデバイス\",\n  \"files-sidebar.network-sidebar\": \"デバイス\",\n  \"files-sidebar.recents\": \"最近\",\n  \"files-sidebar.shared-folders\": \"共有フォルダ\",\n  \"files-sidebar.trash\": \"ゴミ箱\",\n  \"files-sidebar.trash.open\": \"開く\",\n  \"files-sort.created\": \"追加日\",\n  \"files-sort.modified\": \"更新日時\",\n  \"files-sort.name\": \"名前\",\n  \"files-sort.size\": \"サイズ\",\n  \"files-sort.type\": \"種類\",\n  \"files-state.uploading\": \"アップロード中...\",\n  \"files-state.waiting\": \"待機中...\",\n  \"files-type.3gp\": \"3GPビデオ\",\n  \"files-type.3gp2\": \"3GP2ビデオ\",\n  \"files-type.7z\": \"7Zアーカイブ\",\n  \"files-type.aac\": \"AACオーディオ\",\n  \"files-type.ai\": \"Illustratorファイル\",\n  \"files-type.aiff\": \"AIFFオーディオ\",\n  \"files-type.au\": \"AUオーディオ\",\n  \"files-type.avi\": \"AVIビデオ\",\n  \"files-type.avif\": \"AVIF画像\",\n  \"files-type.bmp\": \"BMP画像\",\n  \"files-type.bzip2\": \"BZIP2アーカイブ\",\n  \"files-type.caf\": \"CAFオーディオ\",\n  \"files-type.compressed\": \"圧縮アーカイブ\",\n  \"files-type.csv\": \"CSVファイル\",\n  \"files-type.directory\": \"フォルダ\",\n  \"files-type.dmg\": \"ディスクイメージ\",\n  \"files-type.dv\": \"DVビデオ\",\n  \"files-type.epub\": \"EPUB電子書籍\",\n  \"files-type.excel\": \"Excelスプレッドシート\",\n  \"files-type.exe\": \"Windows実行ファイル\",\n  \"files-type.executable\": \"実行ファイル\",\n  \"files-type.external-drive\": \"ドライブ\",\n  \"files-type.flac\": \"FLACオーディオ\",\n  \"files-type.flv\": \"FLVビデオ\",\n  \"files-type.gif\": \"GIF画像\",\n  \"files-type.gzip\": \"GZIPアーカイブ\",\n  \"files-type.heic\": \"HEIC画像\",\n  \"files-type.ico\": \"ICO画像\",\n  \"files-type.iso\": \"ISOイメージ\",\n  \"files-type.jpeg\": \"JPEG画像\",\n  \"files-type.keynote\": \"Keynoteプレゼンテーション\",\n  \"files-type.lzip\": \"LZIPアーカイブ\",\n  \"files-type.lzma\": \"LZMAアーカイブ\",\n  \"files-type.lzop\": \"LZOPアーカイブ\",\n  \"files-type.m3u\": \"M3Uプレイリスト\",\n  \"files-type.m4a\": \"M4Aオーディオ\",\n  \"files-type.m4v\": \"M4Vビデオ\",\n  \"files-type.midi\": \"MIDIオーディオ\",\n  \"files-type.mka\": \"MKAオーディオ\",\n  \"files-type.mkv\": \"MKVビデオ\",\n  \"files-type.mng\": \"MNGビデオ\",\n  \"files-type.mobi\": \"MOBI電子書籍\",\n  \"files-type.mp3\": \"MP3オーディオ\",\n  \"files-type.mp4\": \"MP4ビデオ\",\n  \"files-type.mp4-audio\": \"MP4オーディオ\",\n  \"files-type.mpeg\": \"MPEGビデオ\",\n  \"files-type.mpeg-ts\": \"MPEGトランスポートストリーム\",\n  \"files-type.network-drive\": \"ネットワークドライブ\",\n  \"files-type.numbers\": \"Numbersスプレッドシート\",\n  \"files-type.ogg\": \"OGGオーディオ\",\n  \"files-type.ogv\": \"OGVビデオ\",\n  \"files-type.pages\": \"Pagesドキュメント\",\n  \"files-type.pdf\": \"PDFドキュメント\",\n  \"files-type.png\": \"PNG画像\",\n  \"files-type.powerpoint\": \"PowerPointプレゼンテーション\",\n  \"files-type.psd\": \"Photoshopドキュメント\",\n  \"files-type.quicktime\": \"QuickTimeビデオ\",\n  \"files-type.rar\": \"RARアーカイブ\",\n  \"files-type.sgi\": \"SGIムービー\",\n  \"files-type.svg\": \"SVG画像\",\n  \"files-type.tar\": \"TARアーカイブ\",\n  \"files-type.tiff\": \"TIFF画像\",\n  \"files-type.ts\": \"TSビデオ\",\n  \"files-type.txt\": \"テキストファイル\",\n  \"files-type.umbrel-backup\": \"Umbrel バックアップ\",\n  \"files-type.wav\": \"WAVオーディオ\",\n  \"files-type.webm\": \"WebMビデオ\",\n  \"files-type.webm-audio\": \"WebMオーディオ\",\n  \"files-type.webp\": \"WebP画像\",\n  \"files-type.wma\": \"WMAオーディオ\",\n  \"files-type.wmv\": \"WMVビデオ\",\n  \"files-type.word\": \"Wordドキュメント\",\n  \"files-type.xz\": \"XZアーカイブ\",\n  \"files-type.zip\": \"ZIPアーカイブ\",\n  \"files-upload-island.uploading-count\": \"{{count}} 個のアイテムをアップロード中\",\n  \"files-view.icons\": \"アイコン\",\n  \"files-view.list\": \"リスト\",\n  \"files-view.sort-by\": \"並び替え\",\n  \"files-view.view-as\": \"表示形式\",\n  \"files-widgets.favorites.no-items-text\": \"お気に入りにフォルダを追加すると、ここに表示されます\",\n  \"files-widgets.recents.no-items-text\": \"最近使用したファイルはありません\",\n  \"generic-in\": \"in\",\n  \"hide-details\": \"詳細を非表示\",\n  \"install-first.install-app\": \"{{app}}をインストール\",\n  \"install-first.title\": \"{{app}}はこれらのアプリを必要とします\",\n  \"install-your-first-app\": \"最初のアプリをインストール\",\n  \"language\": \"言語\",\n  \"language-description\": \"あなたが好むumbrelOSの言語\",\n  \"language.select-description\": \"好みのumbrelOS言語を選択\",\n  \"live-usage\": \"ライブ使用状況\",\n  \"loading\": \"読み込み中\",\n  \"local-ip\": \"ローカルIP\",\n  \"login-2fa.subtitle\": \"認証アプリに表示される2FAコードを入力してください\",\n  \"login-2fa.title\": \"認証\",\n  \"login-with-umbrel.description\": \"{{app}}を開くためにUmbrelのパスワードを入力してください\",\n  \"login-with-umbrel.title\": \"Umbrelでログイン\",\n  \"login.password-label\": \"パスワード\",\n  \"login.password.submit\": \"ログイン\",\n  \"login.subtitle\": \"Umbrelのパスワードを入力してログインしてください\",\n  \"login.title\": \"おかえりなさい\",\n  \"logout\": \"ログアウト\",\n  \"logout-error-generic\": \"エラー: ログアウトに失敗しました\",\n  \"logout.confirm.submit\": \"ログアウト\",\n  \"logout.confirm.title\": \"ログアウトしてもよろしいですか？\",\n  \"memory\": \"メモリ\",\n  \"memory.low\": \"メモリ不足\",\n  \"migrate\": \"移行\",\n  \"migrate.callout\": \"移行が完了するまでUmbrelをオフにしないでください\",\n  \"migrate.failed.retry\": \"再試行\",\n  \"migrate.failed.title\": \"移行に失敗しました\",\n  \"migrate.success.description\": \"あなたのすべてのアプリ、アプリデータ、アカウント詳細がUmbrel Homeに移行されました。\",\n  \"migrate.success.title\": \"移行成功\",\n  \"migration-assistant\": \"移行アシスタント\",\n  \"migration-assistant-description\": \"Raspberry Pi から {{deviceName}} へアプリとデータをすべて移行します\",\n  \"migration-assistant-unsupported-device-description\": \"Migration Assistant は現在、umbrelOS を実行している Raspberry Pi から Umbrel Home または Umbrel Pro へ、すべてのデータとアプリを転送することをサポートしています。始めるには、Umbrel Home または Umbrel Pro で Migration Assistant を開いてください。\",\n  \"migration-assistant.continue-migration.ready.submit\": \"移行を開始\",\n  \"migration-assistant.failed\": \"何か問題が発生しました...\",\n  \"migration-assistant.failed.retrying-message\": \"再試行中...\",\n  \"migration-assistant.mobile.start-button\": \"移行を開始\",\n  \"migration-assistant.prep.body\": \"移行の準備\",\n  \"migration-assistant.prep.button-continue\": \"続行\",\n  \"migration-assistant.prep.callout\": \"{{deviceName}} にあるデータは、ある場合でも完全に削除されます。\",\n  \"migration-assistant.prep.connect-disk-to-home\": \"{{deviceName}} の任意の USB ポートに外付けドライブを接続してください。\",\n  \"migration-assistant.prep.prep-done-continue-message\": \"完了したら、以下の'{{button}}'をクリックしてください。\",\n  \"migration-assistant.prep.shut-down-rpi\": \"Raspberry Pi Umbrelをシャットダウンしてください。\",\n  \"migration-assistant.ready.description\": \"すべてのデータとアプリが {{deviceName}} に移行する準備ができました\",\n  \"migration-assistant.ready.hint-header\": \"留意事項\",\n  \"migration-assistant.ready.hint-keep-pi-off.description\": \"Lightning Nodeなどのアプリで問題が発生しないようにするためです\",\n  \"migration-assistant.ready.hint-keep-pi-off.title\": \"アップデート後はRaspberry Piをオフに保つ\",\n  \"migration-assistant.ready.hint-use-same-password.description\": \"Raspberry Pi の Umbrel パスワードを使って {{deviceName}} にログインするのを忘れないでください。\",\n  \"migration-assistant.ready.hint-use-same-password.title\": \"同じパスワードを使用\",\n  \"migration-assistant.ready.title\": \"移行の準備が整いました！\",\n  \"mini-browser.default-title\": \"フォルダを選択\",\n  \"mini-browser.empty-external\": \"ここに表示するには外付けドライブを接続しましょう。\",\n  \"mini-browser.empty-network\": \"ここに表示するにはUmbrelかNASを追加しましょう。\",\n  \"mini-browser.load-more\": \"さらに読み込む\",\n  \"mini-browser.load-more-in-folder\": \"{{name}} でさらに読み込む\",\n  \"mini-browser.loading-more\": \"さらに読み込み中…\",\n  \"mini-browser.select\": \"選択\",\n  \"mini-browser.select-folder\": \"フォルダを選択\",\n  \"name\": \"名前\",\n  \"nas\": \"NAS\",\n  \"no-forgot-password-message\": \"パスワードを失った場合、Umbrelにログインできなくなります。安全に保管してください。\",\n  \"no-results-found\": \"結果が見つかりませんでした\",\n  \"not-found-404\": \"エラーコード: 404\",\n  \"not-found-404.back\": \"戻る\",\n  \"not-found-404.home\": \"ホームへ行く\",\n  \"notifications.backups-failing-location.description\": \"自動Backupsが{{location}}への保存に失敗しているよ。接続を確認して、Backupsの設定を見直してね。\",\n  \"notifications.backups-failing.description\": \"自動Backupsの作成に失敗しています。Backupsの保存先を確認し、設定を見直してください。\",\n  \"notifications.backups-failing.go-to-backups\": \"Backupsを開く\",\n  \"notifications.backups-failing.title\": \"過去24時間にBackupsが作成されていません\",\n  \"notifications.cpu.too-hot\": \"CPU温度が高い\",\n  \"notifications.memory.low\": \"デバイスのメモリが不足しています\",\n  \"notifications.new-version-available\": \"{{update}}がインストール可能になりました\",\n  \"notifications.raid.issue.description\": \"ストレージの問題が検出されました。詳しくは Storage Manager を確認してください。\",\n  \"notifications.raid.issue.title\": \"至急対応が必要\",\n  \"notifications.ssd.health.description\": \"1台以上のSSDが注意を要する可能性があります。詳しくは Storage Manager を確認してください。\",\n  \"notifications.ssd.health.title\": \"SSDの健全性警告\",\n  \"notifications.storage.full\": \"デバイスのストレージがいっぱいです\",\n  \"notifications.view\": \"表示\",\n  \"ok\": \"OK\",\n  \"onboarding.account-created.by-clicking-button-you-agree\": \"'次へ'をクリックすることで、<linked>umbrelOSの利用規約</linked>に同意することになります\",\n  \"onboarding.account-created.youre-all-set-name\": \"準備完了、{{name}}。\",\n  \"onboarding.contact-support\": \"サポート\",\n  \"onboarding.create-account\": \"アカウントを作成\",\n  \"onboarding.create-account.confirm-password.input-label\": \"パスワードの確認\",\n  \"onboarding.create-account.failed.name-required\": \"名前が必要です\",\n  \"onboarding.create-account.failed.passwords-dont-match\": \"パスワードが一致しません\",\n  \"onboarding.create-account.name.input-placeholder\": \"あなたの名前\",\n  \"onboarding.create-account.password.input-label\": \"パスワード\",\n  \"onboarding.create-account.submit\": \"作成\",\n  \"onboarding.create-account.submitting\": \"作成中\",\n  \"onboarding.create-account.subtitle\": \"あなたのアカウント情報はあなたのUmbrelにのみ保存されます。パスワードをリセットする方法がないので、安全にバックアップしてください。\",\n  \"onboarding.create-instead-long\": \"新しいアカウントを作成する\",\n  \"onboarding.create-instead-short\": \"新しいアカウント\",\n  \"onboarding.launch-umbrelos\": \"umbrelOS を起動\",\n  \"onboarding.raid.available-storage\": \"利用可能なストレージ\",\n  \"onboarding.raid.change-drives-link\": \"ドライブを追加または交換する必要がありますか？\",\n  \"onboarding.raid.configuring.subtitle\": \"数分かかる場合があります。\",\n  \"onboarding.raid.configuring.title\": \"ストレージを構成しています\",\n  \"onboarding.raid.configuring.warning\": \"ストレージを構成している間は、このページを更新したり Umbrel の電源を切ったりしないでください。\",\n  \"onboarding.raid.continue\": \"続ける\",\n  \"onboarding.raid.error.detection-instructions\": \"Umbrel Pro の電源を切り、SSDが正しく装着されているか確認してから、再試行してください。\",\n  \"onboarding.raid.error.no-ssds-detected\": \"SSDが検出されませんでした\",\n  \"onboarding.raid.error.no-ssds-instructions\": \"Umbrel Pro の電源を切り、続行するには少なくとも1台のSSDを挿入してください。\",\n  \"onboarding.raid.failsafe\": \"FailSafe\",\n  \"onboarding.raid.failsafe.cant-enable\": \"まだ FailSafe を有効にできません\",\n  \"onboarding.raid.failsafe.enable\": \"FailSafe を有効にする\",\n  \"onboarding.raid.failsafe.mixed-sizes\": \"FailSafeは最も小さいSSD（{{smallest}}）に制限されます。より大きいSSDの余剰領域は使用されず、{{wasted}}が使用不可になります。\",\n  \"onboarding.raid.failsafe.protection-info-2ssds\": \"{{protection}} はデータ保護のために使われます。さらにもう1台（{{smallest}}）のSSDを追加すると、使用可能なストレージは {{futureWith3}} に増えます。さらに2台追加すると {{futureWith4}} になります。SSDはいつでも追加できます。\",\n  \"onboarding.raid.failsafe.protection-info-3ssds\": \"{{protection}} はデータ保護のために使われます。さらにもう1台（{{smallest}}）のSSDを追加すると、使用可能なストレージは {{futureWith4}} に増えます。SSDはいつでも追加できます。\",\n  \"onboarding.raid.failsafe.single-ssd-info\": \"現在 SSD は1台だけです。データ保護のために FailSafe を有効にするには、少なくとももう1台の {{size}} SSD を追加してください。SSDはいつでも追加できます。\",\n  \"onboarding.raid.failsafe.subtitle\": \"どれか1台のSSDが故障してもデータは安全です。\",\n  \"onboarding.raid.failsafe.tip\": \"最大容量を得て無駄な領域をゼロにするには、同じサイズのSSDを使ってください。\",\n  \"onboarding.raid.failsafe.warning-now-only\": \"複数のSSDがある場合、FailSafeは初回セットアップ中にのみ有効にできます。後から有効にすることはできません。\",\n  \"onboarding.raid.health-warning\": \"このドライブに異常が報告されています\",\n  \"onboarding.raid.launching\": \"起動中...\",\n  \"onboarding.raid.no-ssds-alt\": \"SSDが見つかりません\",\n  \"onboarding.raid.recommended\": \"推奨\",\n  \"onboarding.raid.scanning\": \"SSDスロットを確認しています\",\n  \"onboarding.raid.scanning-alt\": \"SSDをスキャン中\",\n  \"onboarding.raid.setup-failed.description-no-retry\": \"電源を切ってから再度お試しください。\",\n  \"onboarding.raid.setup-failed.description-retry\": \"再試行するか、電源を切ってドライブを確認してください。\",\n  \"onboarding.raid.setup-failed.title\": \"ストレージのセットアップに失敗しました\",\n  \"onboarding.raid.shutdown-dialog.description\": \"ドライブを追加・交換するには Umbrel Pro の電源を切ってください。作業が終わったら電源を入れ直してセットアップを続行できます。\",\n  \"onboarding.raid.shutdown-dialog.title\": \"ドライブを変更しますか？\",\n  \"onboarding.raid.ssd-in-slot\": \"<highlight>{{size}}</highlight> の SSD が <highlight>Slot {{slot}}</highlight> に1台あります\",\n  \"onboarding.raid.ssd-label\": \"SSD {{number}}\",\n  \"onboarding.raid.ssd-tray-alt\": \"SSDトレイ\",\n  \"onboarding.raid.ssds-found\": \"Umbrel Pro で次の SSD が見つかりました\",\n  \"onboarding.raid.storage\": \"ストレージ\",\n  \"onboarding.raid.storage-label\": \"ストレージ\",\n  \"onboarding.raid.success.storage-info\": \"ストレージ {{available}}\",\n  \"onboarding.raid.success.storage-info-failsafe\": \"ストレージ {{available}} · FailSafe {{failsafe}}\",\n  \"onboarding.raid.try-again\": \"もう一度試す\",\n  \"onboarding.raid.wasted\": \"使用不可\",\n  \"onboarding.restore-long\": \"Umbrelを復元する\",\n  \"onboarding.restore-short\": \"復元\",\n  \"onboarding.start.continue\": \"始めよう\",\n  \"onboarding.start.subtitle\": \"あなたのホームクラウドサーバーのセットアップが準備できました。\",\n  \"onboarding.start.title\": \"umbrelOSへようこそ\",\n  \"open\": \"開く\",\n  \"open-live-usage\": \"ライブ使用状況を開く\",\n  \"password\": \"パスワード\",\n  \"preferences\": \"環境設定\",\n  \"raid-error.description\": \"ストレージシステムが正常に起動できませんでした。下のSSDの状態を確認し、トラブルシューティング手順に従ってください。問題が続く場合、影響を受けているSSDの交換が必要になることがあります。\",\n  \"raid-error.factory-reset-dialog.description\": \"これによりUmbrel Proのすべてのデータが消去され、工場出荷時の設定に戻ります。この操作は元に戻せません。\",\n  \"raid-error.factory-reset-dialog.title\": \"工場出荷時リセットしますか？\",\n  \"raid-error.factory-reset-failed\": \"工場出荷時リセットに失敗しました\",\n  \"raid-error.health-warning\": \"ヘルス警告\",\n  \"raid-error.missing-ssd-multiple\": \"{{count}}台のSSDが応答していません\",\n  \"raid-error.missing-ssd-one\": \"1台のSSDが応答していません\",\n  \"raid-error.shutdown-dialog.description\": \"Umbrel Proの電源を切り、すべてのSSDがスロットに正しく装着されていることを確認してから、再度電源を入れてください。\",\n  \"raid-error.shutdown-dialog.title\": \"ドライブを確認するためにシャットダウンしますか？\",\n  \"raid-error.ssd-in-slot\": \"<highlight>スロット {{slot}}</highlight>に<highlight>{{size}}</highlight>のSSDが1台あります\",\n  \"raid-error.step-check-connections.button\": \"シャットダウン\",\n  \"raid-error.step-check-connections.description\": \"電源を切り、すべてのSSDが正しく挿入されているか確認してください。\",\n  \"raid-error.step-check-connections.title\": \"SSDの接続を確認\",\n  \"raid-error.step-factory-reset.button\": \"工場出荷時リセット\",\n  \"raid-error.step-factory-reset.description\": \"他に手がない場合の最終手段です。これにより全てのデータが消去されます。\",\n  \"raid-error.step-factory-reset.title\": \"工場出荷時リセット\",\n  \"raid-error.step-restart.button\": \"再起動\",\n  \"raid-error.step-restart.description\": \"最初に試す簡単な方法で、よく効果があります。\",\n  \"raid-error.step-restart.title\": \"再起動を試す\",\n  \"raid-error.title\": \"ストレージに問題が検出されました\",\n  \"read-less\": \"もっと少なく読む\",\n  \"read-more\": \"もっと読む\",\n  \"reconnect\": \"再接続\",\n  \"redirect.to-home\": \"読み込み中...\",\n  \"redirect.to-login\": \"読み込み中...\",\n  \"redirect.to-onboarding\": \"読み込み中...\",\n  \"redirect.to-raid-error\": \"読み込み中…\",\n  \"reload\": \"再読み込み\",\n  \"remote-tor-access\": \"リモートTorアクセス\",\n  \"reset\": \"リセット\",\n  \"restart\": \"再起動\",\n  \"restart.confirm.submit\": \"再起動\",\n  \"restart.confirm.title\": \"Umbrelを再起動してもよろしいですか？\",\n  \"restart.restarting\": \"再起動中\",\n  \"restart.restarting-message\": \"このページを更新したり、再起動中にUmbrelをオフにしないでください。\",\n  \"rewind\": \"Rewind\",\n  \"rewind.files-as-of\": \"時点のファイル\",\n  \"rewind.loading-snapshots\": \"スナップショットを読み込み中…\",\n  \"rewind.now\": \"今\",\n  \"rewind.preflight.description\": \"過去のバックアップからファイルやフォルダを見つけ、現在に復元できます。\",\n  \"rewind.preflight.enable-backups\": \"Rewind を使い始めるには設定でバックアップをセットアップしてください\",\n  \"rewind.restore-complete\": \"復元が完了しました\",\n  \"rewind.restore-error-description\": \"もう一度お試しください。\",\n  \"rewind.restore-failed\": \"復元に失敗しました\",\n  \"rewind.restore-running-description\": \"復元が完了するまで、このページを閉じたり更新したりしないでください\",\n  \"rewind.restore-selected\": \"選択した項目を復元\",\n  \"rewind.restore-success-description\": \"ファイルは復元されました\",\n  \"rewind.restoring\": \"復元中\",\n  \"rewind.snapshots-count_one\": \"{{count}} 件のバックアップ\",\n  \"rewind.snapshots-count_other\": \"{{count}} 件のバックアップ\",\n  \"search\": \"検索\",\n  \"settings\": \"設定\",\n  \"settings.app-store-preferences.title\": \"App Storeの設定\",\n  \"settings.contact-support\": \"助けが必要ですか？<linked>サポートに連絡する。</linked>\",\n  \"settings.file-sharing\": \"ファイル共有\",\n  \"settings.file-sharing.add-folder\": \"追加\",\n  \"settings.file-sharing.add-folder-title\": \"共有するフォルダを選択\",\n  \"settings.file-sharing.choice-entire-description\": \"Umbrel 上のすべてのファイルを共有します\",\n  \"settings.file-sharing.choice-entire-title\": \"すべて\",\n  \"settings.file-sharing.choice-heading\": \"何を共有しますか？\",\n  \"settings.file-sharing.choice-specific-description\": \"共有するフォルダを選択してください\",\n  \"settings.file-sharing.choice-specific-title\": \"特定のフォルダ\",\n  \"settings.file-sharing.choice-subtitle\": \"コンピュータやスマートフォンから、Dropboxのようにネットワークフォルダとしてファイルやフォルダにアクセスできます\",\n  \"settings.file-sharing.configure\": \"設定\",\n  \"settings.file-sharing.description\": \"他のデバイスから、Dropboxのようにネットワークフォルダ（SMB）としてファイルにアクセスできます\",\n  \"settings.file-sharing.home-shared-note\": \"あなたの \\\"{{homeDirectoryName}}\\\" フォルダ全体が共有されています。個々のフォルダを別途共有する必要はありません。\",\n  \"settings.file-sharing.share-entire-home-dir\": \"ホームフォルダ全体を共有する\",\n  \"settings.file-sharing.share-entire-home-dir-description\": \"ネットワーク上の他のデバイスから \\\"{{homeDirectoryName}}\\\" のすべてのファイルとフォルダにアクセスできます\",\n  \"settings.file-sharing.shared-folders\": \"共有フォルダ\",\n  \"show-details\": \"詳細を表示\",\n  \"shut-down\": \"シャットダウン\",\n  \"shut-down.complete\": \"シャットダウン完了\",\n  \"shut-down.complete-text\": \"これでデバイスの電源を抜くことができます。\",\n  \"shut-down.confirm.submit\": \"シャットダウン\",\n  \"shut-down.confirm.title\": \"Umbrelをシャットダウンしてもよろしいですか？\",\n  \"shut-down.failed\": \"シャットダウンに失敗しました: {{message}}\",\n  \"shut-down.shutting-down\": \"シャットダウン中\",\n  \"shut-down.shutting-down-message\": \"このページを更新したり、シャットダウン中にUmbrelをオフにしないでください。\",\n  \"software-update.callout\": \"このページを更新したり、アップデート中にUmbrelをオフにしないでください。\",\n  \"software-update.check\": \"アップデートを確認\",\n  \"software-update.checking\": \"アップデートを確認中...\",\n  \"software-update.current-running\": \"現在のバージョン\",\n  \"software-update.failed\": \"更新に失敗しました\",\n  \"software-update.failed-to-check\": \"アップデートの確認に失敗しました\",\n  \"software-update.failed.retry\": \"再試行\",\n  \"software-update.install-now\": \"今すぐインストール\",\n  \"software-update.new-version\": \"新しい{{name}}がインストール可能です\",\n  \"software-update.on-latest\": \"最新のumbrelOSを使用中\",\n  \"software-update.see-whats-new\": \"<linked>新機能</linked>を確認する\",\n  \"software-update.title\": \"ソフトウェアアップデート\",\n  \"software-update.updating-to\": \"{{name}}に更新中\",\n  \"software-update.view\": \"表示\",\n  \"something-left\": \"{{left}}残っています\",\n  \"something-went-wrong\": \"⚠ 何か問題が発生しました\",\n  \"start\": \"開始\",\n  \"stop\": \"停止\",\n  \"storage\": \"ストレージ\",\n  \"storage-manager\": \"ストレージ管理\",\n  \"storage-manager.add\": \"追加\",\n  \"storage-manager.add-to-raid.add-ssd\": \"SSDを追加\",\n  \"storage-manager.add-to-raid.available\": \"利用可能：\",\n  \"storage-manager.add-to-raid.description\": \"新しいSSDが検出され、追加する準備ができています。\",\n  \"storage-manager.add-to-raid.enable-failsafe\": \"FailSafeを有効にする\",\n  \"storage-manager.add-to-raid.failed-add\": \"SSDを追加できませんでした\",\n  \"storage-manager.add-to-raid.failed-enable-failsafe\": \"FailSafeを有効にできませんでした\",\n  \"storage-manager.add-to-raid.failsafe-label\": \"FailSafe：\",\n  \"storage-manager.add-to-raid.info-capacity-added\": \"新しい<highlight>{{size}}</highlight>のSSDが利用可能なストレージに追加されます。\",\n  \"storage-manager.add-to-raid.info-capacity-adds-available\": \"新しい<highlight>{{size}}</highlight>のSSDにより、<highlight>{{available}}</highlight>の利用可能ストレージが追加されます。\",\n  \"storage-manager.add-to-raid.info-capacity-adds-both\": \"新しい<highlight>{{size}}</highlight>のSSDにより、<highlight>{{available}}</highlight>の利用可能ストレージと<highlight>{{protection}}</highlight>のデータ保護容量が追加されます。\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only\": \"新しい<highlight>{{size}}</highlight>のSSDにより、データ保護用として<highlight>{{protection}}</highlight>が追加されます。\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only-full\": \"新しい<highlight>{{size}}</highlight>のSSDはデータ保護のために全容量が使用されます。\",\n  \"storage-manager.add-to-raid.info-data-safe\": \"SSDが1台故障してもデータは安全です。\",\n  \"storage-manager.add-to-raid.info-no-protection\": \"SSDが故障するとデータを失う可能性があります。\",\n  \"storage-manager.add-to-raid.info-total-wasted\": \"異なるSSDサイズのため、合計で<wasted>{{size}}</wasted>が使用不可です。\",\n  \"storage-manager.add-to-raid.info-wasted\": \"異なるSSDサイズのため<wasted>{{size}}</wasted>は使用不可になります。\",\n  \"storage-manager.add-to-raid.recommended\": \"推奨\",\n  \"storage-manager.add-to-raid.recommended-inline\": \"(推奨)\",\n  \"storage-manager.add-to-raid.restart-active-tasks\": \"進行中のタスクは中断されます\",\n  \"storage-manager.add-to-raid.restart-after\": \"再起動後、FailSafeの設定は自動で完了し、通常通り使用を再開できます。\",\n  \"storage-manager.add-to-raid.restart-during\": \"再起動中：\",\n  \"storage-manager.add-to-raid.restart-intro\": \"この処理中は umbrelOS を通常通り使用できます。ただし、進捗が50%に達した時点で自動的にUmbrelが再起動します。\",\n  \"storage-manager.add-to-raid.restart-required\": \"システムの再起動が必要です\",\n  \"storage-manager.add-to-raid.restart-ui-inaccessible\": \"umbrelOSは一時的に利用できなくなります\",\n  \"storage-manager.add-to-raid.ssd-in-slot\": \"<highlight>スロット {{slot}}</highlight>に<highlight>{{size}}</highlight>のSSDがあります\",\n  \"storage-manager.add-to-raid.title\": \"ストレージにSSDを追加\",\n  \"storage-manager.add-to-raid.too-small\": \"SSDが小さすぎます\",\n  \"storage-manager.add-to-raid.too-small-description\": \"このSSD（{{deviceSize}}）は、現在インストールされている最小のSSD（{{minSize}}）より小さいです。FailSafeは、すべてのSSDが使用中の最小SSD以上の容量であることを要求します。\",\n  \"storage-manager.add-to-raid.understand-continue\": \"理解しました、続行する\",\n  \"storage-manager.add-to-raid.warning-failsafe-now-only\": \"SSDが複数ある場合、FailSafeは今だけ有効化できます。後から有効化することはできません。\",\n  \"storage-manager.add-to-raid.wasted-label\": \"使用不可:\",\n  \"storage-manager.available-storage\": \"利用可能なストレージ\",\n  \"storage-manager.description\": \"SSDの容量、状態、設定を表示\",\n  \"storage-manager.empty\": \"空\",\n  \"storage-manager.failsafe-transition-failed\": \"FailSafeを有効にできませんでした\",\n  \"storage-manager.for-failsafe\": \"FailSafe用\",\n  \"storage-manager.health.checksum-errors\": \"チェックサムエラー: {{count}}\",\n  \"storage-manager.health.critical\": \"重大\",\n  \"storage-manager.health.critical-threshold\": \"重大しきい値\",\n  \"storage-manager.health.current-temperature\": \"現在の温度\",\n  \"storage-manager.health.estimated-life\": \"推定残り寿命\",\n  \"storage-manager.health.general\": \"全般\",\n  \"storage-manager.health.health-status\": \"ヘルスステータス\",\n  \"storage-manager.health.low\": \"低い\",\n  \"storage-manager.health.model-and-capacity\": \"モデルと容量\",\n  \"storage-manager.health.overheating\": \"過熱\",\n  \"storage-manager.health.raid-failed-advice\": \"このSSDに問題があります。UmbrelをシャットダウンしてSSDの接続を確認してください。問題が解決しない場合はSSDを交換する必要があるかもしれません。\",\n  \"storage-manager.health.read-errors\": \"読み取りエラー: {{count}}\",\n  \"storage-manager.health.serial-number\": \"シリアル番号\",\n  \"storage-manager.health.status-healthy\": \"良好\",\n  \"storage-manager.health.status-unhealthy\": \"異常\",\n  \"storage-manager.health.status-unknown\": \"不明\",\n  \"storage-manager.health.temperature\": \"温度\",\n  \"storage-manager.health.title\": \"SSDの状態\",\n  \"storage-manager.health.warning-life-advice\": \"このSSDは早めに交換を検討してください。\",\n  \"storage-manager.health.warning-life-message\": \"残り寿命は{{percent}}％です\",\n  \"storage-manager.health.warning-temp-advice\": \"Umbrel Proの通気性を確保し、SSDが正しく装着されていることを確認してください。\",\n  \"storage-manager.health.warning-temp-critical\": \"温度が危険域です（{{temperature}}）\",\n  \"storage-manager.health.warning-temp-overheating\": \"ドライブが過熱しています（{{temperature}}）\",\n  \"storage-manager.health.warning-threshold\": \"警告のしきい値\",\n  \"storage-manager.health.warning-unhealthy-advice\": \"このSSDは間もなく故障する可能性があります。交換を検討してください。\",\n  \"storage-manager.health.warning-unhealthy-message\": \"このSSDに問題がある可能性があります\",\n  \"storage-manager.health.warnings\": \"警告\",\n  \"storage-manager.health.wear\": \"摩耗\",\n  \"storage-manager.health.write-errors\": \"書き込みエラー: {{count}}\",\n  \"storage-manager.install-ssd.description\": \"ストレージを拡張するにはSSDを追加してください\",\n  \"storage-manager.install-ssd.step-insert\": \"空いているスロットに新しいSSDを挿入します\",\n  \"storage-manager.install-ssd.step-power-on\": \"{{deviceName}}の電源を入れてください\",\n  \"storage-manager.install-ssd.step-remove-bottom-cover\": \"マグネット式底面カバーを取り外します\",\n  \"storage-manager.install-ssd.step-replace-bottom-cover\": \"底面カバーを元に戻してください\",\n  \"storage-manager.install-ssd.step-return\": \"ここに戻ってSSDをストレージに追加してください\",\n  \"storage-manager.install-ssd.step-shut-down\": \"{{deviceName}}の電源を切ってください\",\n  \"storage-manager.install-ssd.title\": \"SSDの追加\",\n  \"storage-manager.install-tips.image-alt\": \"SSD取り付け手順\",\n  \"storage-manager.install-tips.instructions\": \"取り付け方法: サムスクリューを外し、SSDを斜めにスロットに差し込みます。SSDをスクリューピラーに当てるまで押し下げ、サムスクリューで固定してください。\",\n  \"storage-manager.install-tips.toggle\": \"SSDの取り付け方を忘れた？\",\n  \"storage-manager.manage\": \"管理\",\n  \"storage-manager.missing-ssd-warning\": \"SSDが見つからないようです。Umbrelをシャットダウンして全てのSSDが接続されているか確認してください。問題が続く場合はSSDを交換する必要があるかもしれません。\",\n  \"storage-manager.mode\": \"モード\",\n  \"storage-manager.mode.failsafe\": \"FailSafe\",\n  \"storage-manager.mode.failsafe.description\": \"SSDが故障した場合にデータを安全に保ちます。SSDのサイズが異なる場合、大きいSSDの余剰容量は使用されません。\",\n  \"storage-manager.mode.failsafe.info-description\": \"FailSafeはデータのコピーをSSD間で保持してデータを保護します。単一のSSDが故障してもデータは保護され、交換用のSSDを追加すれば復元できます。\",\n  \"storage-manager.mode.failsafe.info-title\": \"FailSafeについて\",\n  \"storage-manager.mode.full-storage\": \"Full Storage\",\n  \"storage-manager.mode.full-storage.description\": \"すべてのSSD容量をまとめて使用します。SSDが故障するとデータを失う可能性があります。\",\n  \"storage-manager.mode.full-storage.info-description\": \"Full StorageはすべてのSSDを一つの大容量スペースに統合し、最大のストレージ容量を提供します。ただし、どれか1つのSSDが故障すると全データが失われます。\",\n  \"storage-manager.mode.full-storage.info-title\": \"Full Storageについて\",\n  \"storage-manager.mode.switch-from-failsafe-unavailable\": \"FailSafeからFull Storageモードに切り替えるには、データのバックアップ、デバイスの工場出荷時リセット、バックアップからの復元が必要です。\",\n  \"storage-manager.mode.switch-to-failsafe-unavailable\": \"複数のSSDがFull Storageモードで使用されている場合、データはすべてのドライブに分散されます。FailSafeに切り替えるには、データのバックアップ、工場出荷時リセット、復元が必要です。\",\n  \"storage-manager.mode.why-cant-switch\": \"なぜ切り替えられないの？\",\n  \"storage-manager.operation-in-progress.shutdown-description\": \"シャットダウンしても大丈夫です。操作は一時停止し、再起動後に再開しますが、他の変更を行う前に完了する必要があります。\",\n  \"storage-manager.operation-in-progress.shutdown-title\": \"ストレージを更新しています\",\n  \"storage-manager.operation-in-progress.wait-description\": \"現在の操作が完了するまでお待ちください。完了するまでは、他の変更は行わないでください。\",\n  \"storage-manager.operation-in-progress.wait-title\": \"ストレージを更新しています\",\n  \"storage-manager.operation.adding-ssd\": \"SSDを追加しています...\",\n  \"storage-manager.operation.enabling-failsafe\": \"FailSafeを有効にしています…\",\n  \"storage-manager.operation.expanding\": \"ストレージを拡張しています…\",\n  \"storage-manager.operation.rebuilding\": \"データを再構築しています…\",\n  \"storage-manager.operation.replacing\": \"ドライブを交換しています…\",\n  \"storage-manager.operation.restarting\": \"再起動中...\",\n  \"storage-manager.operation.starting\": \"起動中...\",\n  \"storage-manager.operation.syncing-restarts\": \"データを同期中 • 進捗50%で再起動します\",\n  \"storage-manager.raid-status.degraded\": \"劣化\",\n  \"storage-manager.raid-status.failed\": \"故障\",\n  \"storage-manager.raid-status.offline\": \"オフライン\",\n  \"storage-manager.raid-status.online\": \"オンライン\",\n  \"storage-manager.raid-status.removed\": \"取り外し済み\",\n  \"storage-manager.raid-status.unavailable\": \"利用不可\",\n  \"storage-manager.replace\": \"交換\",\n  \"storage-manager.replace-failed.degraded\": \"FailSafe 保護が低下しています\",\n  \"storage-manager.replace-failed.degraded-description\": \"FailSafe ストレージから SSD が外れています。交換して完全な保護を回復してください。\",\n  \"storage-manager.replace-failed.description\": \"この SSD を使って FailSafe の保護を回復できます。\",\n  \"storage-manager.replace-failed.error\": \"交換を開始できませんでした\",\n  \"storage-manager.replace-failed.replace-now\": \"今すぐ交換\",\n  \"storage-manager.replace-failed.ssd-in-slot\": \"スロット {{slot}} の <highlight>{{size}}</highlight> SSD\",\n  \"storage-manager.replace-failed.step-protected\": \"完了後、データは再び完全に保護されます\",\n  \"storage-manager.replace-failed.step-rebuild\": \"データは新しい SSD に再構築されます\",\n  \"storage-manager.replace-failed.step-time\": \"データ量によっては時間がかかることがあります\",\n  \"storage-manager.replace-failed.title\": \"SSD を交換\",\n  \"storage-manager.replace-failed.too-small\": \"SSD が小さすぎます\",\n  \"storage-manager.replace-failed.too-small-description\": \"この SSD（{{deviceSize}}）は FailSafe ストレージに必要な最小容量（{{minSize}}）より小さいです。\",\n  \"storage-manager.replace-failed.what-happens\": \"今後の流れ：\",\n  \"storage-manager.ssd-failing\": \"故障の可能性あり\",\n  \"storage-manager.swap\": \"交換\",\n  \"storage-manager.swap.data-erased-description\": \"Full Storageモードにはデータ保護がありません。工場出荷時リセット中に{{deviceName}}上の全データが消去されます。事前に必ずすべてをバックアップしてください。\",\n  \"storage-manager.swap.data-protected\": \"データは保護されています\",\n  \"storage-manager.swap.data-protected-description\": \"FailSafeが有効な場合、単一のSSDを交換してもデータを失いません。バックアップは不要です。\",\n  \"storage-manager.swap.data-will-be-erased\": \"データが消去されます\",\n  \"storage-manager.swap.description-failsafe\": \"FailSafeストレージのドライブを交換します。\",\n  \"storage-manager.swap.description-full-storage\": \"Full Storage構成のドライブを交換します。\",\n  \"storage-manager.swap.description-no-free-slot\": \"Full Storageモードで全スロットが使用中の場合、SSDの交換には完全なバックアップと復元が必要です。\",\n  \"storage-manager.swap.description-replace\": \"データを新しいSSDに移行してから、古いSSDを取り外してください。\",\n  \"storage-manager.swap.failed-to-start\": \"交換の開始に失敗しました\",\n  \"storage-manager.swap.no-data-loss\": \"データ損失なし\",\n  \"storage-manager.swap.no-data-loss-description\": \"データは新しいSSDにコピーされます。完了後に古いSSDを安全に取り外せます。\",\n  \"storage-manager.swap.safe-swap-available\": \"安全な交換が可能です\",\n  \"storage-manager.swap.safe-swap-description\": \"空きスロットがあるため、先に新しいSSDを追加してデータを移行してから古いSSDを取り外せます。バックアップは不要です。\",\n  \"storage-manager.swap.select-new-ssd\": \"使用する新しいSSDを選択してください：\",\n  \"storage-manager.swap.ssd-in-slot\": \"{{size}}のSSDがスロット {{slot}} にあります\",\n  \"storage-manager.swap.step-backup\": \"データをバックアップする\",\n  \"storage-manager.swap.step-backup-description\": \"設定 → Backups に移動し、すべてのデータのバックアップを作成してください。\",\n  \"storage-manager.swap.step-data-copied\": \"データは古いSSDから新しいSSDにコピーされます\",\n  \"storage-manager.swap.step-factory-reset\": \"工場出荷時リセット\",\n  \"storage-manager.swap.step-factory-reset-description\": \"設定 → Advanced → Factory Reset に移動し、{{deviceName}}を消去してください。\",\n  \"storage-manager.swap.step-insert-new-ssd\": \"新しいSSDを空きスロットに挿入してください\",\n  \"storage-manager.swap.step-may-take-while\": \"データ量によっては時間がかかる場合があります\",\n  \"storage-manager.swap.step-power-on\": \"{{deviceName}}の電源を入れてください\",\n  \"storage-manager.swap.step-remove-bottom-cover\": \"マグネット式底面カバーを取り外してください\",\n  \"storage-manager.swap.step-remove-old\": \"完了したら、電源を切って{{ssd}}を取り外してください\",\n  \"storage-manager.swap.step-replace-bottom-cover\": \"底面カバーを元に戻してください\",\n  \"storage-manager.swap.step-restore\": \"データを復元する\",\n  \"storage-manager.swap.step-restore-description\": \"設定 → Backups に移動し、バックアップから復元してください。\",\n  \"storage-manager.swap.step-return-to-storage-manager\": \"Storage Managerに戻り、交換を確認して新しいSSDをストレージに追加してください\",\n  \"storage-manager.swap.step-return-to-swap\": \"ここに戻ってStorage Managerで \\\"Swap\\\" をもう一度クリックして交換を開始してください\",\n  \"storage-manager.swap.step-setup-new-storage\": \"新しいストレージを設定する\",\n  \"storage-manager.swap.step-setup-new-storage-description\": \"{{deviceName}}の電源を入れ、新しいSSDでセットアップを完了してください。\",\n  \"storage-manager.swap.step-shut-down\": \"{{deviceName}}の電源を切ってください\",\n  \"storage-manager.swap.step-shut-down-and-swap\": \"電源を切って{{ssd}}を交換してください\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-other\": \"電源を切り、デバイスを開けてSSDを交換し、再組み立てしてください。\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-pro\": \"電源を切り、底面カバーを取り外してSSDを交換し、カバーを閉めてください。\",\n  \"storage-manager.swap.step-swap-ssd\": \"{{ssd}}を同じ容量の新しいものと交換してください\",\n  \"storage-manager.swap.too-small\": \"小さすぎます（{{size}}が必要です）\",\n  \"storage-manager.swap.what-happens-next\": \"次に行われること：\",\n  \"storage-manager.total-capacity-added\": \"追加された総容量\",\n  \"storage-manager.umbrel-pro\": \"Umbrel Pro\",\n  \"storage-manager.used\": \"使用済み\",\n  \"storage-manager.wasted\": \"使用不可\",\n  \"storage-manager.wasted-size\": \"{{size}} 使用不可\",\n  \"storage.full\": \"ストレージがいっぱいです\",\n  \"storage.low\": \"ストレージ不足\",\n  \"temperature\": \"温度\",\n  \"temperature.dangerously-hot\": \"非常に熱い\",\n  \"temperature.nice\": \"快適\",\n  \"temperature.normal\": \"正常\",\n  \"temperature.too-hot-suggestion\": \"デバイスの環境を変更することを検討してください。\",\n  \"temperature.warm\": \"暖かい\",\n  \"terminal\": \"Terminal\",\n  \"terminal-description\": \"umbrelOSまたはアプリ内でカスタムコマンドを実行\",\n  \"terminal.app\": \"アプリ\",\n  \"terminal.app-description\": \"特定のアプリ内でカスタムコマンドを実行\",\n  \"terminal.umbrelos-description\": \"umbrelOSでカスタムコマンドを実行\",\n  \"tor-description\": \"Torブラウザを使用してどこからでもあなたのUmbrelにアクセス\",\n  \"tor-enabled-description\": \"次のURLをTorブラウザで開けば、どこからでもUmbrelにアクセスできます：\",\n  \"tor-error\": \"Tor 設定の更新に失敗しました: {{message}}\",\n  \"tor.disable.description\": \"これには数分かかる場合があります\",\n  \"tor.disable.progress\": \"リモートTorアクセスを無効にしています\",\n  \"tor.enable.description\": \"これには数分かかる場合があります\",\n  \"tor.enable.mobile.switch-label\": \"リモートTorアクセスを有効にする\",\n  \"tor.hidden-service\": \"Tor非公開サービスURL\",\n  \"troubleshoot\": \"トラブルシューティング\",\n  \"troubleshoot-description\": \"umbrelOSまたはアプリのトラブルシューティング\",\n  \"troubleshoot-no-logs-yet\": \"まだログはありません\",\n  \"troubleshoot-pick-title\": \"トラブルシューティング\",\n  \"troubleshoot.app\": \"アプリ\",\n  \"troubleshoot.app-description\": \"Umbrelにインストールされているアプリのログを表示\",\n  \"troubleshoot.app-download\": \"{{app}}ログのダウンロード\",\n  \"troubleshoot.share-with-umbrel-support\": \"Umbrelサポートと共有\",\n  \"troubleshoot.system-download\": \"{{label}}のダウンロード\",\n  \"troubleshoot.umbrelos-description\": \"umbrelOSのログを表示\",\n  \"troubleshoot.umbrelos-logs\": \"umbrelOSログ\",\n  \"trpc.backend-unavailable\": \"エラー: システムAPIへの接続に失敗しました\",\n  \"trpc.checking-backend\": \"読み込み中...\",\n  \"try-again\": \"再試行\",\n  \"umbrel\": \"Umbrel\",\n  \"umbrelos\": \"umbrelOS\",\n  \"unknown\": \"不明\",\n  \"unknown-app\": \"不明なアプリ\",\n  \"unknown-error\": \"不明なエラー\",\n  \"uptime\": \"稼働時間\",\n  \"url\": \"URL\",\n  \"wallpaper\": \"壁紙\",\n  \"wallpaper-description\": \"あなたのUmbrelの壁紙とテーマ\",\n  \"whats-new.continue\": \"続ける\",\n  \"whats-new.feature-1.description\": \"Umbrel全体を外付けUSBドライブ、NAS、または別のUmbrelに自動で暗号化してバックアップするよう設定できます。\",\n  \"whats-new.feature-2.description\": \"過去のバックアップに遡って特定のファイルやフォルダを復元できます。\",\n  \"whats-new.feature-3.description\": \"または、アプリ、ファイル、データを含むUmbrel全体を丸ごと復元できます。\",\n  \"whats-new.feature-4.description\": \"NASや別のUmbrelを接続して、Filesからそのストレージにアクセスできます。\",\n  \"whats-new.feature-4.title\": \"ネットワークデバイス\",\n  \"whats-new.feature-5.description\": \"外付けのUSBドライブを接続（Umbrel Home、またはIntelやAMD搭載のデバイスで）して、Filesからアクセスできます。\",\n  \"whats-new.feature-5.helper-text\": \"電力の問題が起きる可能性があるため、Raspberry Piではサポートされていません。\",\n  \"whats-new.feature-5.title\": \"外付けストレージ\",\n  \"whats-new.next\": \"次へ\",\n  \"whats-new.title\": \"{{version}} の新機能\",\n  \"widget.progress.in-progress\": \"進行中\",\n  \"widgets.edit.select-up-to-3-widgets\": \"最大3つのウィジェットを選択\",\n  \"widgets.install-an-app-before-using-widgets\": \"ウィジェットを使ってホームスクリーンをカスタマイズするには、アプリをインストールしてください。\",\n  \"wifi\": \"Wi-Fi\",\n  \"wifi-connect-insecure-message\": \"オープンネットワークは安全でない可能性があります\",\n  \"wifi-connection-failed\": \"接続できません\",\n  \"wifi-dangerous-change-confirmation-description\": \"Wi-Fiネットワークを変更すると、Umbrelから切断される可能性があります。再接続するには、Umbrelとアクセスしているデバイスの両方が同じネットワーク上にあることを確認してください。\",\n  \"wifi-dangerous-change-confirmation-title\": \"本当にWi-Fiネットワークを変更しますか？\",\n  \"wifi-dangerous-disable-confirmation-description\": \"Wi-Fiを無効にすると、Umbrelから切断される可能性があります。再接続するには、UmbrelにEthernetケーブルを接続し、Umbrelとアクセスしているデバイスの両方が同じネットワーク上にあることを確認してください。\",\n  \"wifi-dangerous-disable-confirmation-title\": \"本当にWi-Fiを無効にしますか？\",\n  \"wifi-description\": \"デバイスをWi-Fiネットワークに接続します\",\n  \"wifi-description-long\": \"デバイスは選択したWi-Fiに接続されたままになります。Ethernetケーブルが取り外されても、起動時に自動的にWi-Fiに再接続されます。\",\n  \"wifi-no-networks-message\": \"Wi-Fiネットワークが見つかりません\",\n  \"wifi-searching\": \"Wi-Fiネットワークを検索しています...\",\n  \"wifi-unsupported-device-description\": \"このデバイスではWi-Fiがサポートされていません。これは、ワイヤレスアダプタがないか、互換性がないためです。\",\n  \"wifi-view-networks\": \"ネットワークを表示\"\n}"
  },
  {
    "path": "packages/ui/public/locales/ko.json",
    "content": "{\n  \"2fa\": \"2FA\",\n  \"2fa-description\": \"Umbrel 로그인과 앱을 위한 2단계 인증\",\n  \"2fa.disable.title\": \"2단계 인증 해제\",\n  \"2fa.enable.or-paste\": \"또는 인증 앱에 아래 코드를 붙여넣으세요\",\n  \"2fa.enable.scan-this\": \"Google Authenticator나 Authy 같은 인증 앱으로 이 QR 코드를 스캔하세요\",\n  \"2fa.enable.title\": \"2단계 인증 활성화\",\n  \"2fa.enter-code\": \"인증 앱에 표시된 코드를 입력하세요\",\n  \"account\": \"계정\",\n  \"account-description\": \"이름과 비밀번호\",\n  \"advanced-settings\": \"고급 설정\",\n  \"advanced-settings-description\": \"터미널, umbrelOS Beta Program, Cloudflare DNS 등\",\n  \"app-not-found\": \"앱을 찾을 수 없습니다: {{app}}\",\n  \"app-only-over-tor\": \"{{app}} 앱은 Tor 브라우저에서만 사용할 수 있어요. 이 앱을 열려면 원격 접속 URL(설정 > 고급 설정 > 원격 Tor 접속)에서 Tor 브라우저로 Umbrel에 접속하세요.\",\n  \"app-page.section.about\": \"정보\",\n  \"app-page.section.credentials.title\": \"기본 자격 증명\",\n  \"app-page.section.dependencies.n-alternatives\": \"{{count}}개 대안을 확인하세요\",\n  \"app-page.section.info.compatibility\": \"호환성\",\n  \"app-page.section.info.compatibility-compatible\": \"호환됨\",\n  \"app-page.section.info.compatibility-not-compatible\": \"호환되지 않음\",\n  \"app-page.section.info.developer\": \"개발자\",\n  \"app-page.section.info.source-code\": \"소스 코드\",\n  \"app-page.section.info.source-code.public\": \"공개\",\n  \"app-page.section.info.submitted-by\": \"제공자\",\n  \"app-page.section.info.support\": \"지원 받기\",\n  \"app-page.section.info.title\": \"정보\",\n  \"app-page.section.info.version\": \"버전\",\n  \"app-page.section.recommendations.title\": \"이 앱들도 추천해요\",\n  \"app-page.section.release-notes.title\": \"새로운 점\",\n  \"app-page.section.release-notes.version\": \"버전 {{version}}\",\n  \"app-page.section.requires\": \"필요\",\n  \"app-picker.search\": \"검색...\",\n  \"app-picker.select-app\": \"앱 선택...\",\n  \"app-settings.connected-to\": \"{{appName}}가(이) 다음 앱들과 연결되어 있습니다\",\n  \"app-settings.save-changes\": \"변경사항 저장\",\n  \"app-settings.title\": \"설정\",\n  \"app-store.browse-category-apps\": \"{{category}} 앱 둘러보기\",\n  \"app-store.category.ai\": \"AI\",\n  \"app-store.category.all\": \"전체 앱\",\n  \"app-store.category.automation\": \"홈 & 자동화\",\n  \"app-store.category.bitcoin\": \"Bitcoin\",\n  \"app-store.category.crypto\": \"암호화폐\",\n  \"app-store.category.developer\": \"개발자 도구\",\n  \"app-store.category.discover\": \"새로 발견하기\",\n  \"app-store.category.files\": \"파일 & 생산성\",\n  \"app-store.category.finance\": \"금융\",\n  \"app-store.category.media\": \"미디어\",\n  \"app-store.category.networking\": \"네트워킹\",\n  \"app-store.category.social\": \"소셜\",\n  \"app-store.description\": \"앱 업데이트 설정\",\n  \"app-store.discover.temporarily-unavailable-description\": \"위 카테고리를 둘러보거나 검색해서 앱을 찾아보세요\",\n  \"app-store.discover.temporarily-unavailable-title\": \"추천 콘텐츠를 일시적으로 이용할 수 없어요\",\n  \"app-store.menu.community-app-stores\": \"Community App Stores\",\n  \"app-store.search-apps\": \"앱 검색\",\n  \"app-store.search.no-results\": \"결과가 없습니다\",\n  \"app-store.search.results-for\": \"다음에 대한 결과\",\n  \"app-store.title\": \"App Store\",\n  \"app-store.updates\": \"업데이트\",\n  \"app-updates.less\": \"간단히\",\n  \"app-updates.more\": \"자세히\",\n  \"app-updates.no-updates\": \"모든 앱이 최신 버전이에요!\",\n  \"app-updates.update\": \"업데이트\",\n  \"app-updates.update-all\": \"모두 업데이트\",\n  \"app-updates.updates-available-count_one\": \"{{count}}개 업데이트가 가능해요\",\n  \"app-updates.updates-available-count_other\": \"{{count}}개 업데이트가 가능해요\",\n  \"app-updates.updating\": \"업데이트 중...\",\n  \"app.install\": \"설치\",\n  \"app.installed\": \"설치됨\",\n  \"app.installing\": \"설치 중...\",\n  \"app.offline\": \"실행 중이 아님\",\n  \"app.open\": \"열기\",\n  \"app.optimized-for-umbrel-home\": \"Umbrel Home에 최적화됨\",\n  \"app.os-update-required.confirm\": \"umbrelOS 업데이트 확인\",\n  \"app.os-update-required.description\": \"{{appName}}을(를) 사용하려면 umbrelOS {{version}} 이상이 필요합니다\",\n  \"app.os-update-required.title\": \"umbrelOS 업데이트\",\n  \"app.restarting\": \"다시 시작 중...\",\n  \"app.starting\": \"시작하는 중...\",\n  \"app.stopping\": \"중지 중...\",\n  \"app.uninstall.confirm.description\": \"{{app}}와 관련된 모든 데이터가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.\",\n  \"app.uninstall.confirm.submit\": \"제거\",\n  \"app.uninstall.confirm.title\": \"{{app}} 제거?\",\n  \"app.uninstall.deps.used-by.description_one\": \"{{app}}을(를) 제거하려면 우선 {{firstAppToUninstall}}을(를) 제거해주세요.\",\n  \"app.uninstall.deps.used-by.description_other\": \"{{app}}을(를) 제거하려면 먼저 다음 앱들을 제거해주세요.\",\n  \"app.uninstall.deps.used-by.title\": \"{{app}}을(를) 사용하는 앱\",\n  \"app.uninstalling\": \"제거 중...\",\n  \"app.updating\": \"업데이트 중...\",\n  \"app.view\": \"보기\",\n  \"app_one\": \"앱\",\n  \"app_other\": \"앱\",\n  \"apps.uninstall.failed-to-get-required-apps\": \"필요한 앱을 가져오는 데 실패했습니다\",\n  \"apps.uninstalled-all.success\": \"모든 앱을 제거했습니다\",\n  \"auth.checking-backend-for-user\": \"불러오는 중...\",\n  \"auth.failed-checking-if-user-logged-in\": \"오류: 로그인 확인에 실패했습니다\",\n  \"auth.failed-to-check-if-user-exists\": \"오류: 사용자 존재 여부 확인 실패\",\n  \"back\": \"뒤로\",\n  \"backups\": \"Backups\",\n  \"backups-configure\": \"설정\",\n  \"backups-configure.add-backup-location\": \"백업 위치 추가\",\n  \"backups-configure.available\": \"사용 가능\",\n  \"backups-configure.awaiting-next-backup\": \"다음 자동 백업 대기 중\",\n  \"backups-configure.back-up-now\": \"지금 백업\",\n  \"backups-configure.backing-up-now\": \"백업 중...\",\n  \"backups-configure.connected\": \"연결됨\",\n  \"backups-configure.connection\": \"연결\",\n  \"backups-configure.in-progress\": \"진행 중\",\n  \"backups-configure.last-backup\": \"마지막 백업\",\n  \"backups-configure.locations\": \"위치\",\n  \"backups-configure.no-backup-locations\": \"데이터 백업을 시작하려면 백업 위치를 추가하세요\",\n  \"backups-configure.not-connected\": \"연결되지 않음\",\n  \"backups-configure.path\": \"경로\",\n  \"backups-configure.remove-backup-location\": \"백업 위치 제거\",\n  \"backups-configure.remove-backup-location-confirmation\": \"정말 삭제하시겠어요?\",\n  \"backups-configure.remove-backup-location-confirmation-description\": \"이 작업은 백업 위치 목록에서 '{{device}}'를 제거합니다. 이 장치에 있는 기존 백업은 삭제되지 않지만 자동 백업은 중지됩니다.\",\n  \"backups-configure.status\": \"상태\",\n  \"backups-configure.total-backups\": \"총 Backups\",\n  \"backups-configure.used\": \"사용됨\",\n  \"backups-configure.view\": \"보기\",\n  \"backups-description\": \"파일, 앱 및 데이터를 다른 Umbrel, NAS 또는 외장 드라이브에 백업하세요\",\n  \"backups-error.backup-not-found\": \"백업을 찾을 수 없습니다.\",\n  \"backups-error.generic\": \"문제가 발생했어요: {{details}}\",\n  \"backups-error.in-progress\": \"백업이 이미 진행 중입니다. 완료될 때까지 기다려 주세요.\",\n  \"backups-error.invalid-exclusion-path\": \"백업에서 제외할 수 있는 항목은 홈 디렉터리 내의 파일과 폴더뿐입니다.\",\n  \"backups-error.invalid-password\": \"암호화 비밀번호가 올바르지 않습니다.\",\n  \"backups-error.invalid-path\": \"선택한 위치는 백업에 적합하지 않습니다.\",\n  \"backups-error.mount-failed\": \"백업 스냅샷에 접근할 수 없습니다.\",\n  \"backups-error.mount-timeout\": \"백업 스냅샷에 접근할 수 없습니다. 다시 시도하거나 장치가 제대로 연결되어 있는지 확인하세요.\",\n  \"backups-error.not-enough-space\": \"백업 장치에 사용 가능한 공간이 부족합니다.\",\n  \"backups-error.not-found\": \"백업 또는 백업 위치를 찾을 수 없습니다.\",\n  \"backups-error.repository-exists\": \"이 폴더에 이미 백업 위치가 존재합니다.\",\n  \"backups-error.repository-not-found\": \"백업 위치를 찾을 수 없습니다.\",\n  \"backups-exclusions.add\": \"추가\",\n  \"backups-exclusions.app-paths-cannot-be-modified\": \"다음 파일/폴더는 앱 개발자가 설정한 항목으로 수정할 수 없습니다:\",\n  \"backups-exclusions.app-paths-explanation\": \"이 앱은 다음 데이터를 백업에서 제외합니다. 이러한 경로는 일반적으로 재생성 가능한 캐시나 로그 같은 비필수 항목이거나, 복원 시 충돌이나 불일치를 일으킬 수 있는 오래된 앱 상태 등 문제를 일으킬 수 있는 데이터가 포함되어 있습니다.\",\n  \"backups-exclusions.auto-excluded\": \"자동 제외됨\",\n  \"backups-exclusions.exclude-entire-app\": \"앱 전체 제외\",\n  \"backups-exclusions.excluded-apps\": \"제외된 앱\",\n  \"backups-exclusions.files-and-folders\": \"제외된 파일 및 폴더\",\n  \"backups-exclusions.no-excluded-apps\": \"제외된 앱이 없습니다\",\n  \"backups-exclusions.no-excluded-files-or-folders\": \"제외된 파일이나 폴더가 없습니다\",\n  \"backups-exclusions.select-item-to-exclude\": \"제외할 항목 선택\",\n  \"backups-exclusions.stop-excluding\": \"제외 중지\",\n  \"backups-floating-island.backing-up\": \"백업 중...\",\n  \"backups-floating-island.backing-up-to\": \"Umbrel을 백업 중...\",\n  \"backups-restore\": \"복원\",\n  \"backups-restore-full\": \"전체 복원\",\n  \"backups-restore-full-description\": \"백업에서 Umbrel 전체를 복원해보세요\",\n  \"backups-restore-header\": \"Umbrel 복원\",\n  \"backups-restore-pro.after-restore\": \"복원 후 임시 계정은 백업된 계정과 데이터로 교체됩니다.\",\n  \"backups-restore-pro.step1\": \"아래의 \\\"시작하기\\\"를 클릭해 온보딩을 완료하세요. 복원할 때까지 이 계정은 임시 계정으로 유지됩니다.\",\n  \"backups-restore-pro.step2\": \"설정이 완료되면 <0>설정 → Backups → Restore</0>로 이동하세요\",\n  \"backups-restore-pro.step3\": \"복원 마법사의 안내에 따라 진행하세요.\",\n  \"backups-restore-pro.subtitle\": \"Umbrel Pro에서 백업을 복원하려면 몇 가지 추가 단계가 필요합니다\",\n  \"backups-restore.backup-date\": \"백업 날짜\",\n  \"backups-restore.backup-location\": \"백업 위치\",\n  \"backups-restore.browse-cloud-subtitle\": \"Umbrel Private Cloud에서 복원(곧 제공 예정)\",\n  \"backups-restore.browse-cloud-title\": \"Umbrel Private Cloud\",\n  \"backups-restore.browse-external-subtitle\": \"외부 USB 드라이브에서 복원\",\n  \"backups-restore.browse-external-title\": \"외장 드라이브\",\n  \"backups-restore.browse-nas-or-external\": \"다른 Umbrel, NAS 또는 외장 드라이브에서 복원할 백업을 찾아보세요\",\n  \"backups-restore.browse-nas-subtitle\": \"네트워크에 있는 다른 Umbrel 또는 NAS 기기에서 복원하세요\",\n  \"backups-restore.browse-nas-title\": \"다른 Umbrel 또는 NAS\",\n  \"backups-restore.choose\": \"선택\",\n  \"backups-restore.choose-backup-location\": \"백업 위치 선택\",\n  \"backups-restore.connect-to-backup-location\": \"백업 위치에 연결\",\n  \"backups-restore.encryption-password\": \"암호화 비밀번호\",\n  \"backups-restore.encryption-password-description\": \"백업을 활성화할 때 설정한 암호를 입력하세요\",\n  \"backups-restore.enter-password-to-confirm\": \"확인하려면 Umbrel 비밀번호를 입력하세요\",\n  \"backups-restore.final-confirmation\": \"정말 진행하시겠어요?\",\n  \"backups-restore.final-confirmation-description\": \"이 백업에서 복원하면 현재의 umbrelOS 앱과 데이터가 선택한 백업의 내용으로 대체됩니다. 이 백업에서 제외된 파일, 폴더 또는 앱은 Umbrel에서 제거됩니다. 이 작업은 되돌릴 수 없습니다.\",\n  \"backups-restore.invalid-password\": \"잘못된 비밀번호\",\n  \"backups-restore.last-backup\": \"마지막 백업: {{date}}\",\n  \"backups-restore.latest\": \"최신\",\n  \"backups-restore.no-backups-found\": \"백업을 찾을 수 없음\",\n  \"backups-restore.no-backups-yet\": \"아직 백업이 없습니다\",\n  \"backups-restore.please-select-backup\": \"백업을 선택하세요\",\n  \"backups-restore.please-select-repository\": \"저장소를 선택하세요\",\n  \"backups-restore.restore-from-nas-or-external\": \"다른 Umbrel, NAS 또는 외부 드라이브에 있는 백업에서 Umbrel을 복원하세요\",\n  \"backups-restore.restore-from-unlisted\": \"다른 위치에서 복원\",\n  \"backups-restore.restore-umbrel\": \"Umbrel 복원\",\n  \"backups-restore.restore-warning\": \"이 백업에서 복원하면 현재의 umbrelOS 앱과 데이터가 선택한 백업의 내용으로 대체됩니다. 이 백업에서 제외된 파일, 폴더 또는 앱은 Umbrel에서 제거됩니다. 대신 특정 파일이나 폴더만 복원하려면 <0>Rewind</0>를 열어보세요.\",\n  \"backups-restore.restoring-from\": \"다음 백업에서 복원하려고 합니다:\",\n  \"backups-restore.review-description\": \"복원하면 이 백업이 만들어졌을 당시 포함되어 있던 계정, 파일, 앱 및 설정으로 Umbrel이 구성됩니다. 이 작업은 다소 시간이 걸릴 수 있습니다. 완료되면 로그인 비밀번호는 백업 생성 시 사용한 비밀번호로 설정됩니다.\",\n  \"backups-restore.select-backup\": \"백업 선택\",\n  \"backups-restore.select-backup-description\": \"복원하려는 백업을 선택하세요\",\n  \"backups-restore.select-backup-file\": \"백업 파일 선택\",\n  \"backups-restore.select-backup-file-only\": \"선택할 수 있는 항목은 <bold>{{backupFileName}}</bold>뿐이에요\",\n  \"backups-restore.total-size\": \"전체 크기\",\n  \"backups-restore.unknown-date\": \"알 수 없는 날짜\",\n  \"backups-restore.unknown-repository\": \"알 수 없는 저장소\",\n  \"backups-rewind\": \"Rewind\",\n  \"backups-rewind-description\": \"특정 파일과 폴더를 복원하려면 시간을 되돌려보세요\",\n  \"backups-rewind.start\": \"Rewind 시작\",\n  \"backups-setup\": \"설정\",\n  \"backups-setup-confirm\": \"설정 완료\",\n  \"backups-setup-external-description\": \"외부 USB 드라이브에 백업\",\n  \"backups-setup-nas-or-umbrel-description\": \"네트워크의 다른 Umbrel 또는 NAS 장치에 백업하세요\",\n  \"backups-setup-umbrel-or-nas\": \"다른 Umbrel 또는 NAS\",\n  \"backups-setup-umbrel-private-cloud\": \"Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-cta\": \"Umbrel Private Cloud로 <bold>종단 간 암호화된 백업</bold>을 보내 집 밖에서도 안심하세요.\",\n  \"backups-setup-umbrel-private-cloud-cta-link\": \"조기 액세스 신청\",\n  \"backups-setup-umbrel-private-cloud-description\": \"Umbrel Private Cloud로의 종단 간 암호화된 백업\",\n  \"backups-setup-umbrel-private-cloud-subtitle\": \"곧 제공 예정\",\n  \"backups.add-umbrel-or-nas\": \"Umbrel 또는 NAS 추가\",\n  \"backups.all-apps-and-data-will-be-backed-up\": \"모든 앱과 데이터가 백업됩니다\",\n  \"backups.apps-and-data\": \"앱 및 데이터\",\n  \"backups.backup-location\": \"백업 위치\",\n  \"backups.browse\": \"찾아보기\",\n  \"backups.choose-folder-within-device\": \"백업을 저장할 <bold>{{device}}</bold> 내 폴더를 선택하세요\",\n  \"backups.confirm-password\": \"비밀번호 확인\",\n  \"backups.copy\": \"복사\",\n  \"backups.encryption\": \"암호화\",\n  \"backups.encryption-password-warning\": \"암호화 비밀번호는 비밀번호 관리자 등 안전한 곳에 보관하세요. 다시 확인할 수 없으며 백업을 복원하려면 필요합니다.\",\n  \"backups.exclude-from-backups\": \"Backups에서 제외\",\n  \"backups.exclude-from-backups-description\": \"백업에서 특정 파일, 폴더 및 앱을 제외하세요.\",\n  \"backups.hide\": \"숨기기\",\n  \"backups.i-understand\": \"이해했습니다\",\n  \"backups.location\": \"위치\",\n  \"backups.modals.already-in-use.description\": \"이 백업 위치는 이미 이 Umbrel의 Backups에서 사용 중이에요.\",\n  \"backups.modals.already-in-use.manage\": \"Backups에서 관리\",\n  \"backups.modals.already-in-use.title\": \"백업 위치가 이미 사용 중이에요\",\n  \"backups.modals.connect-existing.description\": \"이 위치에는 이미 Umbrel 백업이 있습니다. 이 Umbrel에 추가하려면 암호(암호화 비밀번호)를 입력해 주세요.\",\n  \"backups.modals.connect-existing.title\": \"기존 Umbrel 백업 연결\",\n  \"backups.no-external-drives-detected\": \"외장 드라이브를 찾을 수 없음\",\n  \"backups.no-password-set\": \"비밀번호가 설정되어 있지 않습니다\",\n  \"backups.password-is-set\": \"비밀번호가 설정되어 있습니다\",\n  \"backups.password-minimum-length\": \"비밀번호는 최소 8자 이상이어야 합니다\",\n  \"backups.password-safety-warning\": \"이 비밀번호로 백업이 암호화됩니다. 다시 확인할 수 없으니 안전하게 보관하세요. 복원 시 이 비밀번호가 필요합니다.\",\n  \"backups.passwords-do-not-match\": \"비밀번호가 일치하지 않습니다\",\n  \"backups.please-choose-folder\": \"폴더를 선택하세요\",\n  \"backups.restore-failed.message\": \"Umbrel을 복원하는 동안 오류가 발생했어요. 현재 앱과 데이터는 변경되지 않았어요.\",\n  \"backups.restore-failed.retry\": \"복원으로 가기\",\n  \"backups.restore-failed.title\": \"복원 실패\",\n  \"backups.restoring\": \"Umbrel 복원 중\",\n  \"backups.restoring-completing\": \"마무리 중이에요. Umbrel이 곧 재시작될 거예요...\",\n  \"backups.restoring-progress\": \"복원됨 {{percent}}%\",\n  \"backups.restoring-time-remaining\": \"{{time}} 남음\",\n  \"backups.restoring-warning\": \"복원 중에는 Umbrel의 전원을 끄거나 백업 위치의 연결을 끊지 마세요\",\n  \"backups.review\": \"검토 및 확인\",\n  \"backups.review-description\": \"백업 세부 정보를 검토하고 선택을 확인하세요\",\n  \"backups.scanning-for-external-drives\": \"외장 드라이브 검색 중...\",\n  \"backups.schedule-description\": \"umbrelOS는 데이터를 자동으로 시간별로 백업합니다. 지난 24시간은 암호화된 시간별 백업을 보관하고, 지난 주는 일별 백업을, 지난 달은 주별 백업을, 지난 해는 월별 백업을 보관합니다. 1년을 초과한 백업은 자동으로 삭제됩니다.\",\n  \"backups.select-backup-folder\": \"백업 폴더 선택\",\n  \"backups.select-backup-folder-description\": \"백업을 저장할 폴더를 선택하세요.\",\n  \"backups.select-backup-location\": \"백업 위치 선택\",\n  \"backups.set-encryption-password\": \"암호화 비밀번호 설정\",\n  \"backups.set-encryption-password-description\": \"비밀번호로 백업을 보호하세요. 이렇게 하면 데이터가 비공개로 유지되며 이 비밀번호로만 복원할 수 있습니다.\",\n  \"backups.show\": \"표시\",\n  \"backups.storage-capacity-warning\": \"{{device}}에는 백업 용량의 최소 2배에 해당하는 여유 공간이 필요합니다\",\n  \"backups.store-encryption-password-safely\": \"암호화 비밀번호를 안전하게 보관하세요\",\n  \"beta-program\": \"umbrelOS Beta Program\",\n  \"beta-program-description\": \"umbrelOS 베타 업데이트를 받아서 새로운 기능을 미리 체험하고 피드백을 통해 개선에 참여해보세요. 베타 업데이트는 안정적이지 않을 수 있으며, 문제 해결에는 터미널에 대한 이해가 필요할 수 있습니다.\",\n  \"cancel\": \"취소\",\n  \"change\": \"변경\",\n  \"change-name\": \"이름 바꾸기\",\n  \"change-name.failed.name-required\": \"이름은 필수입니다\",\n  \"change-name.input-placeholder\": \"이름\",\n  \"change-password\": \"비밀번호 바꾸기\",\n  \"change-password.callout\": \"비밀번호를 분실하면 Umbrel에 로그인할 수 없습니다. 안전하게 보관하세요.\",\n  \"change-password.current-password\": \"현재 비밀번호\",\n  \"change-password.failed.current-required\": \"현재 비밀번호가 필요합니다\",\n  \"change-password.failed.min-length\": \"비밀번호는 최소 {{characters}}자 이상이어야 합니다\",\n  \"change-password.failed.must-be-unique\": \"새 비밀번호는 현재 비밀번호와 달라야 합니다\",\n  \"change-password.failed.new-required\": \"새 비밀번호가 필요합니다\",\n  \"change-password.failed.no-match\": \"비밀번호가 일치하지 않습니다\",\n  \"change-password.failed.repeat-required\": \"비밀번호 확인이 필요합니다\",\n  \"change-password.new-password\": \"새 비밀번호\",\n  \"change-password.repeat-password\": \"비밀번호 확인\",\n  \"check-for-latest-version\": \"최신 umbrelOS 업데이트 확인\",\n  \"clipboard.copied\": \"복사됨\",\n  \"close\": \"닫기\",\n  \"cmdk.change-wallpaper\": \"배경화면 변경\",\n  \"cmdk.frequent-apps\": \"자주 사용하는 앱\",\n  \"cmdk.input-placeholder\": \"앱, 설정 또는 작업 검색\",\n  \"cmdk.live-usage\": \"Live Usage\",\n  \"cmdk.restart-umbrel\": \"Umbrel 다시 시작\",\n  \"cmdk.shutdown-umbrel\": \"Umbrel 종료\",\n  \"cmdk.update-all-apps\": \"모든 앱 업데이트\",\n  \"cmdk.widgets\": \"위젯\",\n  \"community-app-store\": \"Community App Store\",\n  \"community-app-store.add-error\": \"앱 스토어 추가에 실패했습니다: {{message}}\",\n  \"community-app-store.back-to-umbrel-app-store\": \"Umbrel App Store로 돌아가기\",\n  \"community-app-store.open-button\": \"열기\",\n  \"community-app-store.remove-button\": \"삭제\",\n  \"community-app-store.remove-error\": \"앱 스토어 제거에 실패했습니다: {{message}}\",\n  \"community-app-stores.add-button\": \"추가\",\n  \"community-app-stores.description\": \"Community App Stores를 통해 공식 Umbrel App Store에 없는 앱도 설치할 수 있어요. 또한 개발자가 공식 Umbrel App Store에 출시하기 전 Umbrel 앱의 베타 버전을 간편하게 테스트해볼 수도 있습니다.\",\n  \"community-app-stores.learn-more\": \"자세히 알아보기\",\n  \"community-app-stores.warning\": \"Community App Stores는 누구나 만들 수 있습니다. 이곳에 게시된 앱들은 공식 Umbrel App Store 팀이 검증하지 않았으므로 보안 또는 악성 앱일 수 있습니다. 신뢰할 수 있는 개발자의 앱 스토어만 추가하세요.\",\n  \"confirm\": \"확인\",\n  \"connect\": \"연결\",\n  \"connecting\": \"연결 중...\",\n  \"connection-lost\": \"연결 끊김\",\n  \"connection-lost-description\": \"브라우저 탭이 비활성 상태였거나 네트워크 연결이 끊기거나 기기가 오프라인일 때 발생할 수 있어요.\",\n  \"continue\": \"계속\",\n  \"continue-to-log-in\": \"로그인 계속\",\n  \"cpu\": \"CPU\",\n  \"cpu-core-count\": \"{{cores}} 스레드\",\n  \"default-credentials.close\": \"알겠어요\",\n  \"default-credentials.description\": \"앱 로그인에 필요한 기본 자격 증명입니다.\",\n  \"default-credentials.dont-show-again\": \"다시 표시하지 않기\",\n  \"default-credentials.dont-show-again-notice\": \"앱 아이콘을 마우스 오른쪽 버튼으로 클릭하면 언제든 이 자격 증명을 다시 볼 수 있습니다.\",\n  \"default-credentials.open\": \"{{app}} 열기\",\n  \"default-credentials.password\": \"기본 비밀번호\",\n  \"default-credentials.title\": \"{{app}} 자격 증명\",\n  \"default-credentials.username\": \"기본 사용자명\",\n  \"desktop.app.context.go-to-store-page\": \"App Store에서 보기\",\n  \"desktop.app.context.settings\": \"설정\",\n  \"desktop.app.context.show-default-credentials\": \"기본 자격 증명 보기\",\n  \"desktop.app.context.uninstall\": \"제거\",\n  \"desktop.context-menu.change-wallpaper\": \"배경화면 변경\",\n  \"desktop.context-menu.edit-widgets\": \"위젯 편집\",\n  \"desktop.context-menu.logout\": \"로그아웃\",\n  \"desktop.greeting.afternoon\": \"안녕하세요, {{name}}님. 좋은 오후예요!\",\n  \"desktop.greeting.evening\": \"안녕하세요, {{name}}님. 좋은 저녁이에요!\",\n  \"desktop.greeting.morning\": \"안녕하세요, {{name}}님. 좋은 아침이에요!\",\n  \"desktop.install-first.for-the-ai-enthusiast\": \"Viber를 위한\",\n  \"desktop.install-first.for-the-bitcoiner\": \"비트코인 사용자에게 추천\",\n  \"desktop.install-first.for-the-self-hoster\": \"셀프 호스팅 사용자에게 추천\",\n  \"desktop.install-first.for-the-streamer\": \"스트리머에게 추천\",\n  \"desktop.install-first.link-to-app-store\": \"더 많은 앱을 App Store에서 둘러보기\",\n  \"desktop.not-enough-room\": \"더 큰 화면에서 앱을 확인하세요.\",\n  \"device\": \"디바이스\",\n  \"device-info\": \"디바이스 정보\",\n  \"device-info-description\": \"디바이스에 대한 정보\",\n  \"device-info.device\": \"디바이스\",\n  \"device-info.model-number\": \"모델 번호\",\n  \"device-info.serial-number\": \"일련번호\",\n  \"device-info.view-info\": \"정보 보기\",\n  \"device-name.home-or-pro\": \"Umbrel Home or Umbrel Pro\",\n  \"disable\": \"비활성화\",\n  \"done\": \"완료\",\n  \"download-logs\": \"로그 다운로드\",\n  \"enabling-tor\": \"원격 Tor 접속 활성화 중\",\n  \"external-dns\": \"Cloudflare DNS\",\n  \"external-dns-description\": \"Cloudflare DNS를 사용하면 네트워크 안정성이 향상됩니다. 비활성화하면 라우터의 DNS 설정이 사용됩니다.\",\n  \"external-dns-error\": \"DNS 설정 업데이트에 실패했습니다: {{message}}\",\n  \"external-drive\": \"외장 드라이브\",\n  \"factory-reset\": \"공장 초기화\",\n  \"factory-reset-description\": \"모든 데이터와 앱을 삭제하고 umbrelOS를 초기 설정으로 복원합니다.\",\n  \"factory-reset-failed\": \"기기 초기화에 실패했습니다: {{message}}\",\n  \"factory-reset.confirm.body\": \"비밀번호를 입력해 초기화를 진행하세요\",\n  \"factory-reset.confirm.ethernet-required-warning\": \"디바이스가 라우터에 Ethernet(와이파이가 아닌)으로 연결되어 있고, 로컬 네트워크(예: http://umbrel.local 또는 디바이스의 로컬 IP 주소)에서 접속 중인지 확인해주세요.\",\n  \"factory-reset.confirm.submit\": \"모두 삭제하고 초기화\",\n  \"factory-reset.confirm.submit-callout\": \"이 작업은 되돌릴 수 없습니다.\",\n  \"factory-reset.rebooting.message\": \"기기가 재시작되며 모든 데이터가 삭제됩니다. 이 페이지를 닫지 마세요.\",\n  \"factory-reset.rebooting.status\": \"초기화 중...\",\n  \"factory-reset.rebooting.title\": \"공장 초기화 진행 중\",\n  \"factory-reset.review.account-info\": \"계정 정보와 비밀번호\",\n  \"factory-reset.review.apps\": \"앱\",\n  \"factory-reset.review.following-will-be-removed\": \"다음 항목들이 디바이스에서 삭제됩니다\",\n  \"factory-reset.review.installed-apps_one\": \"설치된 앱 {{count}}개\",\n  \"factory-reset.review.installed-apps_other\": \"설치된 앱 {{count}}개\",\n  \"factory-reset.review.submit\": \"계속\",\n  \"factory-reset.review.total-data\": \"전체 데이터\",\n  \"files\": \"Files\",\n  \"files-action.add-favorite\": \"즐겨찾기에 추가\",\n  \"files-action.add-network-device\": \"기기 추가\",\n  \"files-action.cancel-upload\": \"업로드 취소\",\n  \"files-action.compress\": \"압축\",\n  \"files-action.copy\": \"복사\",\n  \"files-action.cut\": \"잘라내기\",\n  \"files-action.delete\": \"영구 삭제\",\n  \"files-action.download\": \"다운로드\",\n  \"files-action.download-items\": \"항목 {{count}}개 다운로드\",\n  \"files-action.drop-to-upload\": \"업로드하려면 여기로 끌어다 놓으세요\",\n  \"files-action.eject-disk\": \"꺼내기\",\n  \"files-action.empty-trash\": \"휴지통 비우기\",\n  \"files-action.format-drive\": \"포맷\",\n  \"files-action.go-to-path\": \"이동...\",\n  \"files-action.new-folder\": \"새 폴더\",\n  \"files-action.open\": \"열기\",\n  \"files-action.paste\": \"붙여넣기\",\n  \"files-action.remove-favorite\": \"즐겨찾기에서 제거\",\n  \"files-action.remove-network-host\": \"네트워크 드라이브 꺼내기\",\n  \"files-action.remove-network-share\": \"네트워크 공유 꺼내기\",\n  \"files-action.rename\": \"이름 바꾸기\",\n  \"files-action.restore\": \"복원\",\n  \"files-action.select\": \"선택\",\n  \"files-action.share\": \"네트워크로 공유...\",\n  \"files-action.sharing\": \"공유 중...\",\n  \"files-action.show-in-folder\": \"포함 폴더에서 보기\",\n  \"files-action.trash\": \"휴지통으로 이동\",\n  \"files-action.uncompress\": \"압축 해제\",\n  \"files-action.upload\": \"업로드\",\n  \"files-add-network-share.add-manually\": \"직접 추가\",\n  \"files-add-network-share.add-share\": \"공유 추가\",\n  \"files-add-network-share.back\": \"뒤로\",\n  \"files-add-network-share.continue\": \"계속\",\n  \"files-add-network-share.description\": \"네트워크에 있는 NAS나 다른 공유 드라이브를 Files에 연결해 바로 접근할 수 있어요.\",\n  \"files-add-network-share.discovering\": \"검색 중...\",\n  \"files-add-network-share.enter-details-manually\": \"서버 정보를 입력하세요\",\n  \"files-add-network-share.host-label\": \"서버 주소\",\n  \"files-add-network-share.host-required\": \"서버 주소를 입력해주세요\",\n  \"files-add-network-share.manual-share-help\": \"서버에 표시된 정확한 공유 폴더 이름을 입력하세요\",\n  \"files-add-network-share.no-shares-found\": \"이 서버에서 공유 폴더를 찾을 수 없습니다\",\n  \"files-add-network-share.not-seeing-share\": \"공유 폴더가 보이지 않나요?\",\n  \"files-add-network-share.password-label\": \"비밀번호\",\n  \"files-add-network-share.password-required\": \"비밀번호를 입력해주세요\",\n  \"files-add-network-share.retrieving-shares\": \"공유를 불러오는 중…\",\n  \"files-add-network-share.retry-discovery\": \"네트워크 다시 검색\",\n  \"files-add-network-share.select-share\": \"추가할 공유를 선택하세요\",\n  \"files-add-network-share.share-placeholder\": \"shared-documents\",\n  \"files-add-network-share.share-required\": \"공유 이름을 입력해주세요\",\n  \"files-add-network-share.title\": \"네트워크 공유 추가\",\n  \"files-add-network-share.username-label\": \"사용자 이름\",\n  \"files-add-network-share.username-placeholder\": \"admin\",\n  \"files-add-network-share.username-required\": \"사용자 이름을 입력해주세요\",\n  \"files-audio-island.now-playing\": \"재생 중\",\n  \"files-audio-island.pause\": \"일시정지\",\n  \"files-audio-island.play\": \"재생\",\n  \"files-backend-error.base-directory-not-found\": \"기본 디렉터리를 찾을 수 없습니다\",\n  \"files-backend-error.cant-find-root\": \"파일 경로를 확인할 수 없습니다\",\n  \"files-backend-error.destination-already-exists\": \"대상 위치에 같은 이름의 항목이 이미 존재합니다\",\n  \"files-backend-error.destination-not-exist\": \"대상 폴더가 존재하지 않습니다\",\n  \"files-backend-error.does-not-exist\": \"파일 또는 폴더가 존재하지 않습니다\",\n  \"files-backend-error.escapes-base\": \"경로가 허용된 디렉터리 범위를 벗어났습니다\",\n  \"files-backend-error.invalid-base\": \"경로가 유효한 디렉터리에 속하지 않습니다\",\n  \"files-backend-error.invalid-filename\": \"파일 이름이 유효하지 않습니다\",\n  \"files-backend-error.invalid-path\": \"파일 경로가 유효하지 않습니다\",\n  \"files-backend-error.mkdir-failed\": \"폴더 생성에 실패했습니다\",\n  \"files-backend-error.move-failed\": \"항목 이동에 실패했습니다\",\n  \"files-backend-error.not-enough-space\": \"저장 공간이 부족합니다\",\n  \"files-backend-error.operation-not-allowed\": \"이 작업은 허용되지 않습니다\",\n  \"files-backend-error.parent-not-directory\": \"상위 경로가 폴더가 아닙니다\",\n  \"files-backend-error.parent-not-exist\": \"상위 폴더가 존재하지 않습니다\",\n  \"files-backend-error.path-not-absolute\": \"파일 경로가 유효하지 않습니다\",\n  \"files-backend-error.share-already-exists\": \"이 폴더는 이미 공유되어 있습니다\",\n  \"files-backend-error.share-name-generation-failed\": \"고유한 공유 이름을 생성할 수 없습니다\",\n  \"files-backend-error.source-not-exists\": \"원본 파일 또는 폴더가 존재하지 않습니다\",\n  \"files-backend-error.subdir-of-self\": \"폴더를 자기 자신의 하위 폴더로 이동하거나 복사할 수 없습니다\",\n  \"files-backend-error.trash-meta-not-exists\": \"이 항목의 원래 위치를 찾을 수 없습니다\",\n  \"files-backend-error.unique-name-index-exceeded\": \"고유한 이름을 생성할 수 없습니다. 유사한 이름의 항목이 너무 많습니다\",\n  \"files-backend-error.upload-failed\": \"업로드에 실패했습니다\",\n  \"files-collision.action.keep-both\": \"둘 다 유지\",\n  \"files-collision.action.replace\": \"교체하기\",\n  \"files-collision.action.skip\": \"건너뛰기\",\n  \"files-collision.destination.original-location\": \"원래 위치\",\n  \"files-collision.message\": \"기존 항목을 교체할까요, 아니면 둘 다 유지할까요?\",\n  \"files-collision.title\": \"“{{itemName}}”이(가) 이미 {{destinationName}}에 있어요\",\n  \"files-download.confirm\": \"다운로드\",\n  \"files-download.description\": \"파일 앱에서 이 유형의 파일을 열 수 없어요. 대신 다운로드하시겠어요?\",\n  \"files-download.title\": \"{{name}}을(를) 다운로드할까요?\",\n  \"files-empty-trash.confirm\": \"비우기\",\n  \"files-empty-trash.description\": \"정말 휴지통의 모든 항목을 영구적으로 삭제할까요? 이 작업은 되돌릴 수 없어요.\",\n  \"files-empty-trash.title\": \"휴지통 비우기?\",\n  \"files-empty.directory\": \"이 폴더에는 항목이 없어요\",\n  \"files-empty.network\": \"네트워크 기기가 없어요\",\n  \"files-empty.network-host-offline\": \"네트워크 기기가 오프라인입니다\",\n  \"files-error.add-favorite\": \"즐겨찾기 추가에 실패했습니다: {{message}}\",\n  \"files-error.add-share\": \"폴더 공유에 실패했습니다: {{message}}\",\n  \"files-error.compress\": \"압축에 실패했습니다: {{message}}\",\n  \"files-error.copy\": \"복사에 실패했습니다: {{message}}\",\n  \"files-error.create-folder\": \"폴더 생성에 실패했습니다: {{message}}\",\n  \"files-error.delete\": \"삭제에 실패했습니다: {{message}}\",\n  \"files-error.eject-disk\": \"드라이브 꺼내기에 실패했습니다: {{message}}\",\n  \"files-error.empty-trash\": \"휴지통 비우기에 실패했습니다: {{message}}\",\n  \"files-error.extract\": \"압축 해제에 실패했습니다: {{message}}\",\n  \"files-error.folder-already-exists\": \"같은 이름의 폴더가 이미 존재합니다\",\n  \"files-error.move\": \"이동에 실패했습니다: {{message}}\",\n  \"files-error.remove-favorite\": \"즐겨찾기 제거에 실패했습니다: {{message}}\",\n  \"files-error.remove-share\": \"공유 폴더 제거에 실패했습니다: {{message}}\",\n  \"files-error.rename\": \"이름 변경에 실패했습니다: {{message}}\",\n  \"files-error.restore\": \"복원에 실패했습니다: {{message}}\",\n  \"files-error.trash\": \"휴지통으로 이동에 실패했습니다: {{message}}\",\n  \"files-error.upload\": \"업로드에 실패했습니다: {{message}}\",\n  \"files-error.upload-network-error\": \"{{name}} 업로드에 실패했습니다: 네트워크 오류가 발생했습니다\",\n  \"files-extension-change.confirm\": \"계속\",\n  \"files-extension-change.description-add\": \"정말 '{{fileName}}'의 확장자를 '{{extension}}'(으)로 바꾸시겠어요? 이로 인해 파일을 읽을 수 없게 될 수도 있어요.\",\n  \"files-extension-change.description-remove\": \"정말 '{{fileName}}'의 확장자를 제거하시겠어요?\",\n  \"files-extension-change.title-add\": \"확장자를 '{{extension}}'(으)로 바꾸기?\",\n  \"files-extension-change.title-remove\": \"확장자 제거하기?\",\n  \"files-external-storage.unsupported.description\": \"연결된 외장 저장소는 전력 문제로 Raspberry Pi에서 사용할 수 없습니다. 외장 저장소는 Umbrel Home, Umbrel Pro 및 모든 x86 (Intel 또는 AMD) 기기에서 사용할 수 있습니다.\",\n  \"files-external-storage.unsupported.description-general\": \"전력 문제로 Raspberry Pi에서는 외장 저장소를 사용할 수 없습니다. 외장 저장소는 Umbrel Home, Umbrel Pro 및 모든 x86 (Intel 또는 AMD) 장치에서 사용할 수 있습니다.\",\n  \"files-external-storage.unsupported.title\": \"외장 스토리지를 지원하지 않아요\",\n  \"files-folder\": \"폴더\",\n  \"files-format.confirm\": \"포맷\",\n  \"files-format.description\": \"포맷하면 {{driveName}}의 모든 데이터가 삭제됩니다. 이 작업은 되돌릴 수 없습니다.\",\n  \"files-format.description-unreadable\": \"umbrelOS가 {{driveName}}의 내용을 읽을 수 없습니다. umbrelOS에서 사용하려면 포맷하세요.\",\n  \"files-format.drive-label\": \"이름\",\n  \"files-format.error\": \"드라이브 포맷에 실패했어요\",\n  \"files-format.exfat-description\": \"Windows, macOS, Linux와의 최대 호환성\",\n  \"files-format.ext4-description\": \"umbrelOS와 Linux에서 더 나은 성능\",\n  \"files-format.filesystem\": \"파일 시스템\",\n  \"files-format.filesystem-label\": \"포맷 형식\",\n  \"files-format.formatting\": \"포맷 중...\",\n  \"files-format.title\": \"드라이브 포맷\",\n  \"files-format.title-requires-format\": \"포맷 필요\",\n  \"files-formatting-island.formatting\": \"포맷 중...\",\n  \"files-formatting-island.formatting-drives\": \"드라이브 {{count}}개 포맷 중\",\n  \"files-listing.empty\": \"항목이 없습니다\",\n  \"files-listing.error\": \"오류가 발생했어요\",\n  \"files-listing.item-count-truncated\": \"{{formattedCount}}+개 항목\",\n  \"files-listing.item-count_one\": \"{{formattedCount}}개 항목\",\n  \"files-listing.item-count_other\": \"{{formattedCount}}개 항목\",\n  \"files-listing.loading\": \"불러오는 중...\",\n  \"files-listing.no-such-file\": \"해당 파일이나 폴더가 없어요\",\n  \"files-listing.selected-count\": \"{{totalCount}}개 중 {{selectedCount}}개 선택됨\",\n  \"files-listing.selected-count-truncated\": \"전체 {{totalCount}}+개 중 {{selectedCount}}개 선택\",\n  \"files-name-drawer.new-folder\": \"새 폴더\",\n  \"files-name-drawer.new-folder-description\": \"새 폴더의 이름을 입력하세요.\",\n  \"files-name-drawer.new-folder-input\": \"폴더 이름\",\n  \"files-name-drawer.rename-file\": \"파일 이름 바꾸기\",\n  \"files-name-drawer.rename-file-description\": \"이 파일의 새 이름을 입력하세요.\",\n  \"files-name-drawer.rename-file-input\": \"파일 이름\",\n  \"files-name-drawer.rename-folder\": \"폴더 이름 바꾸기\",\n  \"files-name-drawer.rename-folder-description\": \"이 폴더의 새 이름을 입력하세요.\",\n  \"files-name-drawer.rename-folder-input\": \"폴더 이름\",\n  \"files-network-storage-error.add-share\": \"네트워크 공유 추가에 실패했습니다: {{message}}\",\n  \"files-network-storage-error.discover-servers\": \"네트워크 장치 검색에 실패했습니다: {{message}}\",\n  \"files-network-storage-error.discover-shares\": \"네트워크 공유 검색에 실패했습니다: {{message}}\",\n  \"files-network-storage-error.remove-share\": \"네트워크 공유 제거에 실패했습니다: {{message}}\",\n  \"files-operations-island.copying\": \"\\\"{{from}}\\\"을 \\\"{{to}}\\\"로 복사 중이에요\",\n  \"files-operations-island.moving\": \"\\\"{{from}}\\\"을 \\\"{{to}}\\\"로 이동 중이에요\",\n  \"files-operations-island.restoring\": \"\\\"{{from}}\\\"을 \\\"{{to}}\\\"로 복원 중\",\n  \"files-path.input-group\": \"경로 입력\",\n  \"files-path.input-label\": \"현재 경로\",\n  \"files-permanently-delete.confirm\": \"영구적으로 삭제\",\n  \"files-permanently-delete.description-multiple\": \"정말 이 {{count}}개의 항목을 영구적으로 삭제할까요? 이 작업은 되돌릴 수 없어요.\",\n  \"files-permanently-delete.description-single\": \"정말 \\\"{{fileName}}\\\"을(를) 영구적으로 삭제할까요? 이 작업은 되돌릴 수 없어요.\",\n  \"files-permanently-delete.title-multiple\": \"{{count}}개 항목을 영구적으로 삭제할까요?\",\n  \"files-permanently-delete.title-single\": \"영구적으로 삭제할까요?\",\n  \"files-search.default\": \"파일 및 폴더 검색\",\n  \"files-search.no-results\": \"\\\"{{query}}\\\"에 대한 검색 결과가 없습니다\",\n  \"files-search.placeholder\": \"검색\",\n  \"files-search.searching-label\": \"{{name}}님의 Umbrel을 검색 중입니다\",\n  \"files-share.home-description\": \"네트워크의 다른 기기에서 \\\"{{homeDirectoryName}}\\\" 안의 모든 파일에 접근할 수 있어요.\",\n  \"files-share.home-title\": \"\\\"{{homeDirectoryName}}\\\"을(를) 네트워크에서 공유하기\",\n  \"files-share.instructions.how-to-access\": \"접근 방법\",\n  \"files-share.instructions.ios.enter-password\": \"비밀번호로 <field>{{password}}</field>을(를) 입력하세요.\",\n  \"files-share.instructions.ios.enter-server\": \"서버 주소로 <field>{{smbUrl}}</field>을(를) 입력하세요.\",\n  \"files-share.instructions.ios.enter-username\": \"사용자 이름으로 <field>{{username}}</field>을(를) 입력하세요.\",\n  \"files-share.instructions.ios.install-files\": \"App Store에서 \\\"Files\\\" 앱을 설치하세요(설치하지 않았다면).\",\n  \"files-share.instructions.ios.tap-connect\": \"접속하려면 \\\"Connect\\\"를 탭하세요.\",\n  \"files-share.instructions.ios.tap-dots\": \"오른쪽 상단의 세 점 (...)을 탭하고 \\\"Connect to Server\\\"를 선택하세요.\",\n  \"files-share.instructions.macos.click-connect\": \"\\\"Connect\\\"를 클릭하여 접속하세요.\",\n  \"files-share.instructions.macos.enter-password\": \"비밀번호로 <field>{{password}}</field>을(를) 입력하세요.\",\n  \"files-share.instructions.macos.enter-url\": \"<field>{{smbUrl}}</field>을(를) 입력하고 Connect를 클릭하세요.\",\n  \"files-share.instructions.macos.enter-username\": \"사용자 이름으로 <field>{{username}}</field>을(를) 입력하세요.\",\n  \"files-share.instructions.macos.open-finder\": \"\\\"Finder\\\"를 열고, ⌘ + K를 누르세요.\",\n  \"files-share.instructions.macos.select-registered\": \"프롬프트가 표시되면 \\\"Registered User\\\"를 선택하세요.\",\n  \"files-share.instructions.macos.time-machine\": \"Time Machine 백업 위치로 사용하는 방법\",\n  \"files-share.instructions.macos.time-machine.choose-encryption\": \"암호화된 백업 또는 암호화되지 않은 백업 중에서 선택하세요.\",\n  \"files-share.instructions.macos.time-machine.disk-limit\": \"'Disk Usage Limit'에서 Time Machine 백업을 위해 Umbrel에 할당할 최대 용량을 지정하고, \\\"Done\\\"을 클릭하세요.\",\n  \"files-share.instructions.macos.time-machine.follow-steps\": \"위 단계를 따라 Mac의 System Settings를 여세요.\",\n  \"files-share.instructions.macos.time-machine.go-settings\": \"Time Machine으로 이동해 \\\"Add Backup Disk...\\\"를 클릭하세요.\",\n  \"files-share.instructions.macos.time-machine.select-disk\": \"폴더를 선택한 다음 \\\"디스크 설정...\\\"을 클릭하세요.\",\n  \"files-share.instructions.umbrelos.backup.follow-onscreen\": \"화면 안내에 따라 백업을 설정하세요.\",\n  \"files-share.instructions.umbrelos.backup.follow-then-go-to\": \"위 단계를 따라한 다음 다른 Umbrel에서 \\\"{{settings}}\\\" > \\\"{{backups}}\\\"로 이동하세요.\",\n  \"files-share.instructions.umbrelos.backup.select-add\": \"\\\"{{addUmbrelOrNas}}\\\" 옵션을 선택하세요.\",\n  \"files-share.instructions.umbrelos.backup.select-connected\": \"연결된 기기 목록에서 이 Umbrel 기기를 선택하세요.\",\n  \"files-share.instructions.umbrelos.backup.title\": \"다른 Umbrel의 백업 위치로 사용하는 방법\",\n  \"files-share.instructions.umbrelos.cant-find-note\": \"찾을 수 없나요? \\\"수동으로 추가\\\"를 선택한 후 아래의 자격 증명을 사용해 보세요. 그래도 추가할 수 없다면 두 기기가 같은 네트워크에 연결되어 있는지 확인해 보세요.\",\n  \"files-share.instructions.umbrelos.enter-password\": \"<field>{{password}}</field>를 비밀번호로 입력하세요.\",\n  \"files-share.instructions.umbrelos.enter-username\": \"<field>{{username}}</field>를 사용자 이름으로 입력하세요.\",\n  \"files-share.instructions.umbrelos.open-and-click\": \"다른 Umbrel에서 \\\"Files\\\"를 열고 사이드바에서 \\\"<deviceIcon/> {{deviceLabel}}\\\" 옆의 <plus/>를 클릭하세요.\",\n  \"files-share.instructions.umbrelos.select-device\": \"네트워크에서 자동으로 감지된 기기 목록에서 이 Umbrel 기기를 선택하세요.\",\n  \"files-share.instructions.umbrelos.select-sharename\": \"\\\"{{sharename}}\\\"을 선택한 다음 클릭해 공유를 추가하세요.\",\n  \"files-share.instructions.windows.enter-password\": \"비밀번호로 <field>{{password}}</field>을(를) 입력하세요.\",\n  \"files-share.instructions.windows.enter-url\": \"<field>{{smbUrl}}</field>을(를) 입력하고 Enter 키를 누르세요.\",\n  \"files-share.instructions.windows.enter-username\": \"사용자 이름으로 <field>{{username}}</field>을(를) 입력하세요.\",\n  \"files-share.instructions.windows.open-run\": \"Windows + R 키를 눌러 Run 대화 상자를 여세요.\",\n  \"files-share.instructions.windows.remember-credentials\": \"\\\"Remember my credentials\\\"를 체크하고 OK를 클릭하세요.\",\n  \"files-share.regular-description\": \"네트워크의 다른 기기에서 이 폴더에 접근할 수 있도록 공유하세요.\",\n  \"files-share.regular-title\": \"폴더를 네트워크로 공유하기\",\n  \"files-share.toggle\": \"네트워크에서 \\\"{{name}}\\\"(을)를 공유하기\",\n  \"files-sidebar.apps\": \"앱\",\n  \"files-sidebar.external-storage\": \"외장 스토리지\",\n  \"files-sidebar.favorites\": \"즐겨찾기\",\n  \"files-sidebar.home\": \"홈\",\n  \"files-sidebar.navigation\": \"파일 탐색\",\n  \"files-sidebar.network\": \"네트워크\",\n  \"files-sidebar.network-pathbar\": \"네트워크 기기\",\n  \"files-sidebar.network-sidebar\": \"기기\",\n  \"files-sidebar.recents\": \"최근 항목\",\n  \"files-sidebar.shared-folders\": \"공유된 폴더\",\n  \"files-sidebar.trash\": \"휴지통\",\n  \"files-sidebar.trash.open\": \"열기\",\n  \"files-sort.created\": \"추가됨\",\n  \"files-sort.modified\": \"수정됨\",\n  \"files-sort.name\": \"이름\",\n  \"files-sort.size\": \"크기\",\n  \"files-sort.type\": \"유형\",\n  \"files-state.uploading\": \"업로드 중...\",\n  \"files-state.waiting\": \"대기 중...\",\n  \"files-type.3gp\": \"3GP 비디오\",\n  \"files-type.3gp2\": \"3GP2 비디오\",\n  \"files-type.7z\": \"7Z 압축 파일\",\n  \"files-type.aac\": \"AAC 오디오\",\n  \"files-type.ai\": \"Illustrator 파일\",\n  \"files-type.aiff\": \"AIFF 오디오\",\n  \"files-type.au\": \"AU 오디오\",\n  \"files-type.avi\": \"AVI 비디오\",\n  \"files-type.avif\": \"AVIF 이미지\",\n  \"files-type.bmp\": \"BMP 이미지\",\n  \"files-type.bzip2\": \"BZIP2 압축 파일\",\n  \"files-type.caf\": \"CAF 오디오\",\n  \"files-type.compressed\": \"압축 파일\",\n  \"files-type.csv\": \"CSV 파일\",\n  \"files-type.directory\": \"폴더\",\n  \"files-type.dmg\": \"디스크 이미지\",\n  \"files-type.dv\": \"DV 비디오\",\n  \"files-type.epub\": \"EPUB 전자책\",\n  \"files-type.excel\": \"Excel 스프레드시트\",\n  \"files-type.exe\": \"Windows 실행 파일\",\n  \"files-type.executable\": \"실행 파일\",\n  \"files-type.external-drive\": \"드라이브\",\n  \"files-type.flac\": \"FLAC 오디오\",\n  \"files-type.flv\": \"FLV 비디오\",\n  \"files-type.gif\": \"GIF 이미지\",\n  \"files-type.gzip\": \"GZIP 압축 파일\",\n  \"files-type.heic\": \"HEIC 이미지\",\n  \"files-type.ico\": \"ICO 이미지\",\n  \"files-type.iso\": \"ISO 이미지\",\n  \"files-type.jpeg\": \"JPEG 이미지\",\n  \"files-type.keynote\": \"Keynote 프레젠테이션\",\n  \"files-type.lzip\": \"LZIP 압축 파일\",\n  \"files-type.lzma\": \"LZMA 압축 파일\",\n  \"files-type.lzop\": \"LZOP 압축 파일\",\n  \"files-type.m3u\": \"M3U 재생목록\",\n  \"files-type.m4a\": \"M4A 오디오\",\n  \"files-type.m4v\": \"M4V 비디오\",\n  \"files-type.midi\": \"MIDI 오디오\",\n  \"files-type.mka\": \"MKA 오디오\",\n  \"files-type.mkv\": \"MKV 비디오\",\n  \"files-type.mng\": \"MNG 비디오\",\n  \"files-type.mobi\": \"MOBI 전자책\",\n  \"files-type.mp3\": \"MP3 오디오\",\n  \"files-type.mp4\": \"MP4 비디오\",\n  \"files-type.mp4-audio\": \"MP4 오디오\",\n  \"files-type.mpeg\": \"MPEG 비디오\",\n  \"files-type.mpeg-ts\": \"MPEG 전송 스트림\",\n  \"files-type.network-drive\": \"네트워크 드라이브\",\n  \"files-type.numbers\": \"Numbers 스프레드시트\",\n  \"files-type.ogg\": \"OGG 오디오\",\n  \"files-type.ogv\": \"OGV 비디오\",\n  \"files-type.pages\": \"Pages 문서\",\n  \"files-type.pdf\": \"PDF 문서\",\n  \"files-type.png\": \"PNG 이미지\",\n  \"files-type.powerpoint\": \"PowerPoint 프레젠테이션\",\n  \"files-type.psd\": \"Photoshop 문서\",\n  \"files-type.quicktime\": \"QuickTime 비디오\",\n  \"files-type.rar\": \"RAR 압축 파일\",\n  \"files-type.sgi\": \"SGI 동영상\",\n  \"files-type.svg\": \"SVG 이미지\",\n  \"files-type.tar\": \"TAR 압축 파일\",\n  \"files-type.tiff\": \"TIFF 이미지\",\n  \"files-type.ts\": \"TS 비디오\",\n  \"files-type.txt\": \"텍스트 파일\",\n  \"files-type.umbrel-backup\": \"Umbrel 백업\",\n  \"files-type.wav\": \"WAV 오디오\",\n  \"files-type.webm\": \"WebM 비디오\",\n  \"files-type.webm-audio\": \"WebM 오디오\",\n  \"files-type.webp\": \"WebP 이미지\",\n  \"files-type.wma\": \"WMA 오디오\",\n  \"files-type.wmv\": \"WMV 비디오\",\n  \"files-type.word\": \"Word 문서\",\n  \"files-type.xz\": \"XZ 압축 파일\",\n  \"files-type.zip\": \"ZIP 압축 파일\",\n  \"files-upload-island.uploading-count\": \"{{count}}개 항목 업로드 중\",\n  \"files-view.icons\": \"아이콘\",\n  \"files-view.list\": \"목록\",\n  \"files-view.sort-by\": \"정렬 기준\",\n  \"files-view.view-as\": \"보기 방식\",\n  \"files-widgets.favorites.no-items-text\": \"폴더를 즐겨찾기에 추가하면 여기에서 볼 수 있어요\",\n  \"files-widgets.recents.no-items-text\": \"최근에 사용한 파일이 없어요\",\n  \"generic-in\": \"에서\",\n  \"hide-details\": \"세부 정보 숨기기\",\n  \"install-first.install-app\": \"{{app}} 설치\",\n  \"install-first.title\": \"{{app}}을(를) 사용하려면 다음 앱들이 필요합니다\",\n  \"install-your-first-app\": \"처음 앱을 설치해보세요\",\n  \"language\": \"언어\",\n  \"language-description\": \"원하는 umbrelOS 언어\",\n  \"language.select-description\": \"원하는 umbrelOS 언어를 선택하세요\",\n  \"live-usage\": \"Live Usage\",\n  \"loading\": \"불러오는 중\",\n  \"local-ip\": \"로컬 IP\",\n  \"login-2fa.subtitle\": \"인증 앱에 표시된 2FA 코드를 입력하세요\",\n  \"login-2fa.title\": \"인증\",\n  \"login-with-umbrel.description\": \"{{app}}을(를) 열려면 Umbrel 비밀번호를 입력하세요\",\n  \"login-with-umbrel.title\": \"Umbrel로 로그인\",\n  \"login.password-label\": \"비밀번호\",\n  \"login.password.submit\": \"로그인\",\n  \"login.subtitle\": \"Umbrel 비밀번호를 입력해 로그인하세요\",\n  \"login.title\": \"다시 오신 걸 환영해요\",\n  \"logout\": \"로그아웃\",\n  \"logout-error-generic\": \"오류: 로그아웃 실패\",\n  \"logout.confirm.submit\": \"로그아웃\",\n  \"logout.confirm.title\": \"정말 로그아웃하시겠어요?\",\n  \"memory\": \"메모리\",\n  \"memory.low\": \"메모리 부족\",\n  \"migrate\": \"이전\",\n  \"migrate.callout\": \"마이그레이션이 완료될 때까지 Umbrel 전원을 끄지 마세요\",\n  \"migrate.failed.retry\": \"다시 시도\",\n  \"migrate.failed.title\": \"마이그레이션 실패\",\n  \"migrate.success.description\": \"모든 앱과 앱 데이터, 계정 정보가 Umbrel Home으로 마이그레이션되었습니다.\",\n  \"migrate.success.title\": \"마이그레이션 완료\",\n  \"migration-assistant\": \"Migration Assistant\",\n  \"migration-assistant-description\": \"Raspberry Pi에서 {{deviceName}}로 모든 앱과 데이터를 전송합니다.\",\n  \"migration-assistant-unsupported-device-description\": \"Migration Assistant는 현재 umbrelOS가 설치된 Raspberry Pi에서 Umbrel Home 또는 Umbrel Pro로 모든 데이터와 앱을 전송하는 것을 지원합니다. 시작하려면 Umbrel Home 또는 Umbrel Pro에서 Migration Assistant를 여세요.\",\n  \"migration-assistant.continue-migration.ready.submit\": \"마이그레이션 시작\",\n  \"migration-assistant.failed\": \"문제가 발생했어요...\",\n  \"migration-assistant.failed.retrying-message\": \"다시 시도 중...\",\n  \"migration-assistant.mobile.start-button\": \"마이그레이션 시작\",\n  \"migration-assistant.prep.body\": \"마이그레이션 준비\",\n  \"migration-assistant.prep.button-continue\": \"계속\",\n  \"migration-assistant.prep.callout\": \"{{deviceName}}에 데이터가 있다면 영구적으로 삭제됩니다.\",\n  \"migration-assistant.prep.connect-disk-to-home\": \"외장 드라이브를 {{deviceName}}의 아무 USB 포트에 연결하세요.\",\n  \"migration-assistant.prep.prep-done-continue-message\": \"준비가 완료되면 아래 '{{button}}'을(를) 클릭하세요.\",\n  \"migration-assistant.prep.shut-down-rpi\": \"Raspberry Pi Umbrel을(를) 종료하세요.\",\n  \"migration-assistant.ready.description\": \"모든 데이터와 앱이 {{deviceName}}로 이전할 준비가 되었습니다.\",\n  \"migration-assistant.ready.hint-header\": \"주의 사항\",\n  \"migration-assistant.ready.hint-keep-pi-off.description\": \"이는 Lightning Node와 같은 앱에서 문제가 발생하는 것을 예방합니다\",\n  \"migration-assistant.ready.hint-keep-pi-off.title\": \"Raspberry Pi는 업데이트 뒤에 계속 꺼두세요\",\n  \"migration-assistant.ready.hint-use-same-password.description\": \"Raspberry Pi에서 사용하던 Umbrel 비밀번호로 {{deviceName}}에 로그인하세요.\",\n  \"migration-assistant.ready.hint-use-same-password.title\": \"같은 비밀번호를 사용하세요\",\n  \"migration-assistant.ready.title\": \"마이그레이션할 준비가 완료되었습니다!\",\n  \"mini-browser.default-title\": \"폴더 선택\",\n  \"mini-browser.empty-external\": \"여기에 표시하려면 외장 드라이브를 연결해 보세요.\",\n  \"mini-browser.empty-network\": \"여기에 표시하려면 Umbrel 또는 NAS를 추가해 보세요.\",\n  \"mini-browser.load-more\": \"더 보기\",\n  \"mini-browser.load-more-in-folder\": \"{{name}}에서 더 보기\",\n  \"mini-browser.loading-more\": \"더 불러오는 중…\",\n  \"mini-browser.select\": \"선택\",\n  \"mini-browser.select-folder\": \"폴더 선택\",\n  \"name\": \"이름\",\n  \"nas\": \"NAS\",\n  \"no-forgot-password-message\": \"비밀번호를 분실하면 Umbrel에 로그인할 수 없습니다. 안전하게 보관하세요.\",\n  \"no-results-found\": \"결과가 없습니다\",\n  \"not-found-404\": \"오류 코드: 404\",\n  \"not-found-404.back\": \"뒤로 가기\",\n  \"not-found-404.home\": \"홈으로 가기\",\n  \"notifications.backups-failing-location.description\": \"{{location}}로의 자동 Backups가 계속 실패하고 있어요. 연결을 확인하고 백업 설정을 검토해보세요.\",\n  \"notifications.backups-failing.description\": \"자동 Backups가 실패하고 있어요. Backups 위치를 확인하고 설정을 검토해 보세요.\",\n  \"notifications.backups-failing.go-to-backups\": \"Backups로 이동\",\n  \"notifications.backups-failing.title\": \"최근 24시간 동안 Backups가 없어요\",\n  \"notifications.cpu.too-hot\": \"CPU 온도가 너무 높습니다\",\n  \"notifications.memory.low\": \"디바이스 메모리가 부족합니다\",\n  \"notifications.new-version-available\": \"{{update}} 설치 가능\",\n  \"notifications.raid.issue.description\": \"스토리지 문제가 감지되었습니다. 자세한 내용은 Storage Manager에서 확인하세요.\",\n  \"notifications.raid.issue.title\": \"긴급 조치 필요\",\n  \"notifications.ssd.health.description\": \"하나 이상의 SSD에 조치가 필요할 수 있습니다. 자세한 내용은 Storage Manager에서 확인하세요.\",\n  \"notifications.ssd.health.title\": \"SSD 상태 경고\",\n  \"notifications.storage.full\": \"디바이스 저장 공간이 가득 찼습니다\",\n  \"notifications.view\": \"보기\",\n  \"ok\": \"확인\",\n  \"onboarding.account-created.by-clicking-button-you-agree\": \"'다음'을 누르면 <linked>umbrelOS 이용 약관</linked>에 동의하는 것으로 간주됩니다\",\n  \"onboarding.account-created.youre-all-set-name\": \"이제 다 됐어요, {{name}}!\",\n  \"onboarding.contact-support\": \"지원\",\n  \"onboarding.create-account\": \"계정 생성\",\n  \"onboarding.create-account.confirm-password.input-label\": \"비밀번호 확인\",\n  \"onboarding.create-account.failed.name-required\": \"이름은 필수입니다\",\n  \"onboarding.create-account.failed.passwords-dont-match\": \"비밀번호가 일치하지 않습니다\",\n  \"onboarding.create-account.name.input-placeholder\": \"이름\",\n  \"onboarding.create-account.password.input-label\": \"비밀번호\",\n  \"onboarding.create-account.submit\": \"생성\",\n  \"onboarding.create-account.submitting\": \"생성 중...\",\n  \"onboarding.create-account.subtitle\": \"계정 정보는 오직 Umbrel에만 저장됩니다. 비밀번호를 안전하게 백업하세요. 재설정 방식이 없으니까요.\",\n  \"onboarding.create-instead-long\": \"새 계정 만들기\",\n  \"onboarding.create-instead-short\": \"새 계정\",\n  \"onboarding.launch-umbrelos\": \"umbrelOS 시작하기\",\n  \"onboarding.raid.available-storage\": \"사용 가능한 저장 공간\",\n  \"onboarding.raid.change-drives-link\": \"드라이브를 추가하거나 교체해야 하나요?\",\n  \"onboarding.raid.configuring.subtitle\": \"몇 분 정도 걸릴 수 있어요.\",\n  \"onboarding.raid.configuring.title\": \"저장 공간 구성 중\",\n  \"onboarding.raid.configuring.warning\": \"저장 공간을 구성하는 동안에는 이 페이지를 새로 고치거나 Umbrel의 전원을 끄지 마세요.\",\n  \"onboarding.raid.continue\": \"계속\",\n  \"onboarding.raid.error.detection-instructions\": \"Umbrel Pro의 전원을 끄고 SSD가 제대로 장착되었는지 확인한 다음 다시 시도하세요.\",\n  \"onboarding.raid.error.no-ssds-detected\": \"SSD가 감지되지 않았습니다\",\n  \"onboarding.raid.error.no-ssds-instructions\": \"Umbrel Pro의 전원을 끄고 계속하려면 최소 하나의 SSD를 장착하세요.\",\n  \"onboarding.raid.failsafe\": \"FailSafe\",\n  \"onboarding.raid.failsafe.cant-enable\": \"아직 FailSafe를 활성화할 수 없어요\",\n  \"onboarding.raid.failsafe.enable\": \"FailSafe 활성화\",\n  \"onboarding.raid.failsafe.mixed-sizes\": \"FailSafe는 가장 작은 SSD ({{smallest}}) 용량에 맞춰집니다. 더 큰 SSD의 남는 공간은 사용되지 않아 {{wasted}}가 사용 불가로 남습니다.\",\n  \"onboarding.raid.failsafe.protection-info-2ssds\": \"{{protection}}는 데이터 보호에 사용됩니다. 추가로 {{smallest}} SSD 하나를 더 넣으면 사용 가능한 저장 공간이 {{futureWith3}}로 늘어나고, 두 개를 더 추가하면 {{futureWith4}}가 됩니다. 언제든 SSD를 추가할 수 있어요.\",\n  \"onboarding.raid.failsafe.protection-info-3ssds\": \"{{protection}}는 데이터 보호에 사용됩니다. 추가로 {{smallest}} SSD 하나를 더 넣으면 사용 가능한 저장 공간이 {{futureWith4}}로 늘어납니다. 언제든 SSD를 추가할 수 있어요.\",\n  \"onboarding.raid.failsafe.single-ssd-info\": \"현재 SSD가 하나뿐이에요. 데이터 보호를 위해 최소 하나 이상의 {{size}} SSD를 추가하세요. 언제든 SSD를 추가할 수 있어요.\",\n  \"onboarding.raid.failsafe.subtitle\": \"SSD 한 개가 고장 나더라도 데이터는 안전해요\",\n  \"onboarding.raid.failsafe.tip\": \"최대 저장공간과 사용 불가 공간 0을 원한다면 같은 크기의 SSD를 사용하세요.\",\n  \"onboarding.raid.failsafe.warning-now-only\": \"SSD가 2개 이상인 경우 FailSafe는 초기 설정 중에만 활성화할 수 있어요. 나중에는 활성화할 수 없습니다.\",\n  \"onboarding.raid.health-warning\": \"이 드라이브에서 상태 이상이 보고되었어요\",\n  \"onboarding.raid.launching\": \"실행 중...\",\n  \"onboarding.raid.no-ssds-alt\": \"SSD가 없습니다\",\n  \"onboarding.raid.recommended\": \"권장\",\n  \"onboarding.raid.scanning\": \"SSD 슬롯 확인 중\",\n  \"onboarding.raid.scanning-alt\": \"SSD 스캔 중\",\n  \"onboarding.raid.setup-failed.description-no-retry\": \"시스템을 종료한 후 다시 시도하세요.\",\n  \"onboarding.raid.setup-failed.description-retry\": \"다시 시도하거나, 드라이브를 확인하려면 시스템을 종료하세요.\",\n  \"onboarding.raid.setup-failed.title\": \"저장 공간 설정 실패\",\n  \"onboarding.raid.shutdown-dialog.description\": \"드라이브를 추가하거나 교체하려면 Umbrel Pro의 전원을 끄세요. 변경을 마친 후 전원을 켜면 설정을 계속할 수 있어요.\",\n  \"onboarding.raid.shutdown-dialog.title\": \"드라이브를 변경하시겠어요?\",\n  \"onboarding.raid.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD 1개가 <highlight>슬롯 {{slot}}</highlight>에 장착되어 있어요.\",\n  \"onboarding.raid.ssd-label\": \"SSD {{number}}\",\n  \"onboarding.raid.ssd-tray-alt\": \"SSD 트레이\",\n  \"onboarding.raid.ssds-found\": \"다음 SSD가 Umbrel Pro에서 발견되었습니다\",\n  \"onboarding.raid.storage\": \"저장 공간\",\n  \"onboarding.raid.storage-label\": \"저장 공간\",\n  \"onboarding.raid.success.storage-info\": \"저장 공간 {{available}}\",\n  \"onboarding.raid.success.storage-info-failsafe\": \"저장 공간 {{available}} · FailSafe {{failsafe}}\",\n  \"onboarding.raid.try-again\": \"다시 시도\",\n  \"onboarding.raid.wasted\": \"사용 불가\",\n  \"onboarding.restore-long\": \"내 Umbrel 복원하기\",\n  \"onboarding.restore-short\": \"복원\",\n  \"onboarding.start.continue\": \"시작하기\",\n  \"onboarding.start.subtitle\": \"당신의 홈 클라우드 서버를 설정할 준비가 되었습니다.\",\n  \"onboarding.start.title\": \"umbrelOS에 오신 걸 환영해요\",\n  \"open\": \"열기\",\n  \"open-live-usage\": \"Live Usage 열기\",\n  \"password\": \"비밀번호\",\n  \"preferences\": \"환경설정\",\n  \"raid-error.description\": \"스토리지 시스템이 정상적으로 시작되지 않았습니다. 아래에서 SSD 상태를 확인하고 문제 해결 단계를 따라주세요. 문제가 계속되면 영향받은 SSD를 교체해야 할 수 있습니다.\",\n  \"raid-error.factory-reset-dialog.description\": \"이 작업은 Umbrel Pro의 모든 데이터를 지우고 공장 설정으로 초기화합니다. 되돌릴 수 없습니다.\",\n  \"raid-error.factory-reset-dialog.title\": \"공장 초기화를 진행할까요?\",\n  \"raid-error.factory-reset-failed\": \"공장 초기화에 실패했습니다.\",\n  \"raid-error.health-warning\": \"상태 경고\",\n  \"raid-error.missing-ssd-multiple\": \"{{count}}개의 SSD가 응답하지 않습니다.\",\n  \"raid-error.missing-ssd-one\": \"SSD 1개가 응답하지 않습니다.\",\n  \"raid-error.shutdown-dialog.description\": \"Umbrel Pro의 전원을 끄고 모든 SSD가 슬롯에 제대로 장착되어 있는지 확인한 뒤 전원을 다시 켜세요.\",\n  \"raid-error.shutdown-dialog.title\": \"드라이브를 확인하려면 전원을 끌까요?\",\n  \"raid-error.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD 1개가 <highlight>슬롯 {{slot}}</highlight>에 있습니다.\",\n  \"raid-error.step-check-connections.button\": \"전원 끄기\",\n  \"raid-error.step-check-connections.description\": \"전원을 끄고 모든 SSD가 제대로 장착되어 있는지 확인하세요.\",\n  \"raid-error.step-check-connections.title\": \"SSD 연결 확인\",\n  \"raid-error.step-factory-reset.button\": \"공장 초기화\",\n  \"raid-error.step-factory-reset.description\": \"다른 방법이 모두 실패했을 때의 마지막 수단입니다. 이 작업은 모든 데이터를 지웁니다.\",\n  \"raid-error.step-factory-reset.title\": \"공장 초기화\",\n  \"raid-error.step-restart.button\": \"다시 시작\",\n  \"raid-error.step-restart.description\": \"자주 도움이 되는 간단한 첫 단계입니다.\",\n  \"raid-error.step-restart.title\": \"다시 시작해 보기\",\n  \"raid-error.title\": \"스토리지 문제 감지\",\n  \"read-less\": \"간단히 보기\",\n  \"read-more\": \"자세히 보기\",\n  \"reconnect\": \"다시 연결\",\n  \"redirect.to-home\": \"불러오는 중...\",\n  \"redirect.to-login\": \"불러오는 중...\",\n  \"redirect.to-onboarding\": \"불러오는 중...\",\n  \"redirect.to-raid-error\": \"로딩 중...\",\n  \"reload\": \"새로고침\",\n  \"remote-tor-access\": \"원격 Tor 액세스\",\n  \"reset\": \"초기화\",\n  \"restart\": \"다시 시작\",\n  \"restart.confirm.submit\": \"다시 시작\",\n  \"restart.confirm.title\": \"Umbrel을(를) 다시 시작하시겠어요?\",\n  \"restart.restarting\": \"다시 시작 중...\",\n  \"restart.restarting-message\": \"재시작 중에는 이 페이지를 새로고침하거나 Umbrel 전원을 끄지 마세요.\",\n  \"rewind\": \"Rewind\",\n  \"rewind.files-as-of\": \"기준 시점의 파일\",\n  \"rewind.loading-snapshots\": \"스냅샷을 불러오는 중...\",\n  \"rewind.now\": \"지금\",\n  \"rewind.preflight.description\": \"과거 백업에서 파일과 폴더를 찾아 현재로 복원하세요.\",\n  \"rewind.preflight.enable-backups\": \"Rewind를 사용하려면 설정에서 Backups를 설정하세요\",\n  \"rewind.restore-complete\": \"복원 완료\",\n  \"rewind.restore-error-description\": \"다시 시도해 주세요.\",\n  \"rewind.restore-failed\": \"복원 실패\",\n  \"rewind.restore-running-description\": \"복원이 완료될 때까지 이 페이지를 닫거나 새로고침하지 마세요\",\n  \"rewind.restore-selected\": \"선택 항목 복원\",\n  \"rewind.restore-success-description\": \"파일이 복원되었습니다\",\n  \"rewind.restoring\": \"복원 중\",\n  \"rewind.snapshots-count_one\": \"{{count}}개의 백업 이후\",\n  \"rewind.snapshots-count_other\": \"{{count}}개의 백업 이후\",\n  \"search\": \"검색\",\n  \"settings\": \"설정\",\n  \"settings.app-store-preferences.title\": \"App Store 환경설정\",\n  \"settings.contact-support\": \"도움이 필요하신가요? <linked>지원 문의</linked>\",\n  \"settings.file-sharing\": \"파일 공유\",\n  \"settings.file-sharing.add-folder\": \"추가\",\n  \"settings.file-sharing.add-folder-title\": \"공유할 폴더 선택\",\n  \"settings.file-sharing.choice-entire-description\": \"Umbrel의 모든 파일을 공유합니다.\",\n  \"settings.file-sharing.choice-entire-title\": \"전체\",\n  \"settings.file-sharing.choice-heading\": \"무엇을 공유하시겠어요?\",\n  \"settings.file-sharing.choice-specific-description\": \"공유할 폴더를 선택하세요.\",\n  \"settings.file-sharing.choice-specific-title\": \"특정 폴더\",\n  \"settings.file-sharing.choice-subtitle\": \"컴퓨터나 휴대폰에서 Dropbox처럼 네트워크 폴더(SMB)로 파일과 폴더에 접근하세요.\",\n  \"settings.file-sharing.configure\": \"구성\",\n  \"settings.file-sharing.description\": \"다른 기기에서 네트워크 폴더(SMB)로 Dropbox처럼 파일에 접근하세요.\",\n  \"settings.file-sharing.home-shared-note\": \"전체 \\\"{{homeDirectoryName}}\\\" 폴더가 공유되어 있습니다. 개별 폴더를 따로 공유할 필요가 없습니다.\",\n  \"settings.file-sharing.share-entire-home-dir\": \"Home 폴더 전체 공유\",\n  \"settings.file-sharing.share-entire-home-dir-description\": \"네트워크에 연결된 다른 기기에서 \\\"{{homeDirectoryName}}\\\"의 모든 파일과 폴더에 접근할 수 있습니다.\",\n  \"settings.file-sharing.shared-folders\": \"공유 폴더\",\n  \"show-details\": \"세부 정보 표시\",\n  \"shut-down\": \"종료\",\n  \"shut-down.complete\": \"종료 완료\",\n  \"shut-down.complete-text\": \"이제 디바이스 전원을 분리해도 좋습니다.\",\n  \"shut-down.confirm.submit\": \"종료\",\n  \"shut-down.confirm.title\": \"정말 Umbrel을 종료하시겠어요?\",\n  \"shut-down.failed\": \"시스템 종료에 실패했습니다: {{message}}\",\n  \"shut-down.shutting-down\": \"종료 중...\",\n  \"shut-down.shutting-down-message\": \"종료 중에는 이 페이지를 새로고침하거나 Umbrel 전원을 끄지 마세요.\",\n  \"software-update.callout\": \"업데이트 중에는 이 페이지를 새로고침하거나 Umbrel 전원을 끄지 마세요.\",\n  \"software-update.check\": \"업데이트 확인\",\n  \"software-update.checking\": \"업데이트 확인 중...\",\n  \"software-update.current-running\": \"현재 버전:\",\n  \"software-update.failed\": \"업데이트에 실패했습니다\",\n  \"software-update.failed-to-check\": \"업데이트 확인에 실패했습니다\",\n  \"software-update.failed.retry\": \"다시 시도\",\n  \"software-update.install-now\": \"지금 설치\",\n  \"software-update.new-version\": \"새로운 {{name}}을(를) 설치할 수 있습니다\",\n  \"software-update.on-latest\": \"최신 umbrelOS를 사용 중입니다\",\n  \"software-update.see-whats-new\": \"자세히 보기 <linked>새로운 기능</linked>\",\n  \"software-update.title\": \"소프트웨어 업데이트\",\n  \"software-update.updating-to\": \"{{name}}으로 업데이트 중...\",\n  \"software-update.view\": \"보기\",\n  \"something-left\": \"{{left}} 남음\",\n  \"something-went-wrong\": \"⚠ 문제가 발생했어요\",\n  \"start\": \"시작\",\n  \"stop\": \"중지\",\n  \"storage\": \"저장 공간\",\n  \"storage-manager\": \"저장소 관리자\",\n  \"storage-manager.add\": \"추가\",\n  \"storage-manager.add-to-raid.add-ssd\": \"SSD 추가\",\n  \"storage-manager.add-to-raid.available\": \"사용 가능:\",\n  \"storage-manager.add-to-raid.description\": \"새 SSD가 감지되었으며 추가할 준비가 되었습니다.\",\n  \"storage-manager.add-to-raid.enable-failsafe\": \"FailSafe 활성화\",\n  \"storage-manager.add-to-raid.failed-add\": \"SSD를 추가할 수 없습니다.\",\n  \"storage-manager.add-to-raid.failed-enable-failsafe\": \"FailSafe를 활성화할 수 없습니다.\",\n  \"storage-manager.add-to-raid.failsafe-label\": \"FailSafe:\",\n  \"storage-manager.add-to-raid.info-capacity-added\": \"새 <highlight>{{size}}</highlight> SSD가 사용 가능한 저장공간에 추가됩니다.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-available\": \"새 <highlight>{{size}}</highlight> SSD가 <highlight>{{available}}</highlight>의 사용 가능한 저장공간을 추가합니다.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-both\": \"새 <highlight>{{size}}</highlight> SSD가 <highlight>{{available}}</highlight>의 사용 가능한 저장공간과 <highlight>{{protection}}</highlight>의 데이터 보호 용량을 추가합니다.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only\": \"새 <highlight>{{size}}</highlight> SSD가 데이터 보호를 위해 <highlight>{{protection}}</highlight>를 추가합니다.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only-full\": \"새 <highlight>{{size}}</highlight> SSD는 데이터 보호 용도로 전부 사용됩니다.\",\n  \"storage-manager.add-to-raid.info-data-safe\": \"SSD가 하나만 고장 나도 데이터는 안전합니다.\",\n  \"storage-manager.add-to-raid.info-no-protection\": \"SSD가 고장나면 데이터가 손실될 수 있습니다.\",\n  \"storage-manager.add-to-raid.info-total-wasted\": \"SSD 크기 차이로 인해 총 <wasted>{{size}}</wasted>가 사용 불가합니다.\",\n  \"storage-manager.add-to-raid.info-wasted\": \"SSD 크기 차이로 인해 <wasted>{{size}}</wasted>가 사용 불가합니다.\",\n  \"storage-manager.add-to-raid.recommended\": \"권장\",\n  \"storage-manager.add-to-raid.recommended-inline\": \"(권장)\",\n  \"storage-manager.add-to-raid.restart-active-tasks\": \"진행 중인 작업은 중단됩니다\",\n  \"storage-manager.add-to-raid.restart-after\": \"재시작 후 FailSafe 설정이 자동으로 완료되며 정상 사용을 재개할 수 있습니다.\",\n  \"storage-manager.add-to-raid.restart-during\": \"재시작 중:\",\n  \"storage-manager.add-to-raid.restart-intro\": \"이 과정 동안 umbrelOS를 정상적으로 계속 사용할 수 있습니다. 다만 진행률이 50%에 도달하면 Umbrel이 자동으로 재시작합니다.\",\n  \"storage-manager.add-to-raid.restart-required\": \"시스템 재시작 필요\",\n  \"storage-manager.add-to-raid.restart-ui-inaccessible\": \"umbrelOS에 일시적으로 접근할 수 없습니다.\",\n  \"storage-manager.add-to-raid.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD가 <highlight>슬롯 {{slot}}</highlight>에 있습니다.\",\n  \"storage-manager.add-to-raid.title\": \"스토리지에 SSD 추가\",\n  \"storage-manager.add-to-raid.too-small\": \"SSD가 너무 작음\",\n  \"storage-manager.add-to-raid.too-small-description\": \"이 SSD ({{deviceSize}})는 현재 설치된 가장 작은 SSD ({{minSize}})보다 작습니다. FailSafe는 모든 SSD가 사용 중인 가장 작은 SSD와 같거나 더 커야 합니다.\",\n  \"storage-manager.add-to-raid.understand-continue\": \"이해했어요, 계속하기\",\n  \"storage-manager.add-to-raid.warning-failsafe-now-only\": \"SSD가 둘 이상이면 FailSafe는 지금만 활성화할 수 있습니다. 나중에는 활성화할 수 없습니다.\",\n  \"storage-manager.add-to-raid.wasted-label\": \"사용 불가:\",\n  \"storage-manager.available-storage\": \"사용 가능 저장 공간\",\n  \"storage-manager.description\": \"SSD의 저장공간, 상태 및 설정을 확인하세요.\",\n  \"storage-manager.empty\": \"비어 있음\",\n  \"storage-manager.failsafe-transition-failed\": \"FailSafe를 활성화할 수 없습니다.\",\n  \"storage-manager.for-failsafe\": \"FailSafe용\",\n  \"storage-manager.health.checksum-errors\": \"체크섬 오류: {{count}}\",\n  \"storage-manager.health.critical\": \"심각\",\n  \"storage-manager.health.critical-threshold\": \"심각 임계값\",\n  \"storage-manager.health.current-temperature\": \"현재 온도\",\n  \"storage-manager.health.estimated-life\": \"예상 남은 수명\",\n  \"storage-manager.health.general\": \"일반\",\n  \"storage-manager.health.health-status\": \"상태\",\n  \"storage-manager.health.low\": \"낮음\",\n  \"storage-manager.health.model-and-capacity\": \"모델 및 용량\",\n  \"storage-manager.health.overheating\": \"과열\",\n  \"storage-manager.health.raid-failed-advice\": \"이 SSD에 문제가 있습니다. Umbrel의 전원을 끄고 SSD 연결을 확인하세요. 문제가 계속되면 SSD를 교체해야 할 수 있습니다.\",\n  \"storage-manager.health.read-errors\": \"읽기 오류: {{count}}\",\n  \"storage-manager.health.serial-number\": \"일련번호\",\n  \"storage-manager.health.status-healthy\": \"정상\",\n  \"storage-manager.health.status-unhealthy\": \"비정상\",\n  \"storage-manager.health.status-unknown\": \"알 수 없음\",\n  \"storage-manager.health.temperature\": \"온도\",\n  \"storage-manager.health.title\": \"SSD 상태\",\n  \"storage-manager.health.warning-life-advice\": \"이 SSD를 곧 교체하는 것을 고려하세요.\",\n  \"storage-manager.health.warning-life-message\": \"수명 {{percent}}%만 남음\",\n  \"storage-manager.health.warning-temp-advice\": \"Umbrel Pro의 통풍이 잘 되는지, SSD가 제대로 장착되어 있는지 확인하세요.\",\n  \"storage-manager.health.warning-temp-critical\": \"온도가 위험 수준입니다 ({{temperature}})\",\n  \"storage-manager.health.warning-temp-overheating\": \"드라이브가 과열되고 있습니다 ({{temperature}})\",\n  \"storage-manager.health.warning-threshold\": \"경고 임계값\",\n  \"storage-manager.health.warning-unhealthy-advice\": \"이 SSD는 곧 고장날 수 있습니다. 교체를 고려하세요.\",\n  \"storage-manager.health.warning-unhealthy-message\": \"이 SSD에 문제가 있을 수 있습니다.\",\n  \"storage-manager.health.warnings\": \"경고\",\n  \"storage-manager.health.wear\": \"마모\",\n  \"storage-manager.health.write-errors\": \"쓰기 오류: {{count}}\",\n  \"storage-manager.install-ssd.description\": \"저장공간을 확장하려면 SSD를 추가하세요.\",\n  \"storage-manager.install-ssd.step-insert\": \"빈 슬롯에 새 SSD를 삽입하세요.\",\n  \"storage-manager.install-ssd.step-power-on\": \"{{deviceName}}의 전원을 켜세요\",\n  \"storage-manager.install-ssd.step-remove-bottom-cover\": \"자석식 하단 커버를 제거하세요.\",\n  \"storage-manager.install-ssd.step-replace-bottom-cover\": \"하단 커버를 다시 끼워주세요.\",\n  \"storage-manager.install-ssd.step-return\": \"여기로 돌아와 SSD를 스토리지에 추가하세요.\",\n  \"storage-manager.install-ssd.step-shut-down\": \"{{deviceName}}의 전원을 끄세요\",\n  \"storage-manager.install-ssd.title\": \"SSD 추가\",\n  \"storage-manager.install-tips.image-alt\": \"SSD 설치 안내\",\n  \"storage-manager.install-tips.instructions\": \"설치하려면 손나사를 빼고 SSD를 약간 각도로 슬롯에 밀어 넣으세요. SSD를 나사 기둥 위에 얹힌 뒤 손나사로 고정하세요.\",\n  \"storage-manager.install-tips.toggle\": \"SSD를 어떻게 넣는지 기억이 안 나세요?\",\n  \"storage-manager.manage\": \"관리\",\n  \"storage-manager.missing-ssd-warning\": \"SSD가 없어 보입니다. Umbrel의 전원을 끄고 모든 SSD가 연결되어 있는지 확인하세요. 문제가 계속되면 SSD를 교체해야 할 수 있습니다.\",\n  \"storage-manager.mode\": \"모드\",\n  \"storage-manager.mode.failsafe\": \"FailSafe\",\n  \"storage-manager.mode.failsafe.description\": \"SSD가 고장나더라도 데이터를 안전하게 지켜줍니다. SSD 크기가 다르면 큰 쪽의 남는 공간은 사용되지 않습니다.\",\n  \"storage-manager.mode.failsafe.info-description\": \"FailSafe는 SSD들에 데이터를 복제하여 보호합니다. SSD 하나가 고장나더라도 데이터는 안전하며 교체 SSD를 추가하면 복구할 수 있습니다.\",\n  \"storage-manager.mode.failsafe.info-title\": \"FailSafe에 대해\",\n  \"storage-manager.mode.full-storage\": \"Full Storage\",\n  \"storage-manager.mode.full-storage.description\": \"모든 SSD 용량을 하나로 합쳐 사용합니다. SSD가 고장나면 데이터가 손실될 수 있습니다.\",\n  \"storage-manager.mode.full-storage.info-description\": \"Full Storage는 모든 SSD를 하나의 큰 저장공간으로 결합하여 최대 용량을 제공합니다. 하지만 SSD 하나라도 고장나면 모든 데이터가 손실됩니다.\",\n  \"storage-manager.mode.full-storage.info-title\": \"Full Storage에 대해\",\n  \"storage-manager.mode.switch-from-failsafe-unavailable\": \"FailSafe에서 Full Storage 모드로 전환하려면 데이터를 백업하고 기기를 공장 초기화한 뒤 백업에서 복원해야 합니다.\",\n  \"storage-manager.mode.switch-to-failsafe-unavailable\": \"Full Storage 모드에서 SSD가 여러 개이면 데이터가 모든 드라이브에 분산됩니다. FailSafe로 전환하려면 데이터를 백업하고 공장 초기화한 뒤 복원해야 합니다.\",\n  \"storage-manager.mode.why-cant-switch\": \"왜 전환할 수 없나요?\",\n  \"storage-manager.operation-in-progress.shutdown-description\": \"지금 종료해도 안전해요. 작업은 일시 중지되었다가 재시작 후에 다시 진행되지만, 다른 변경을 하려면 작업이 완료되어야 해요.\",\n  \"storage-manager.operation-in-progress.shutdown-title\": \"저장소가 업데이트되고 있어요\",\n  \"storage-manager.operation-in-progress.wait-description\": \"다른 변경을 하기 전에 현재 작업이 완료될 때까지 기다려 주세요.\",\n  \"storage-manager.operation-in-progress.wait-title\": \"저장소가 업데이트되고 있어요\",\n  \"storage-manager.operation.adding-ssd\": \"SSD 추가 중...\",\n  \"storage-manager.operation.enabling-failsafe\": \"FailSafe 활성화 중...\",\n  \"storage-manager.operation.expanding\": \"저장소 확장 중...\",\n  \"storage-manager.operation.rebuilding\": \"데이터 재구성 중...\",\n  \"storage-manager.operation.replacing\": \"드라이브 교체 중...\",\n  \"storage-manager.operation.restarting\": \"재시작 중...\",\n  \"storage-manager.operation.starting\": \"시작 중...\",\n  \"storage-manager.operation.syncing-restarts\": \"데이터 동기화 중 • 50%에서 재시작됩니다\",\n  \"storage-manager.raid-status.degraded\": \"저하됨\",\n  \"storage-manager.raid-status.failed\": \"고장\",\n  \"storage-manager.raid-status.offline\": \"오프라인\",\n  \"storage-manager.raid-status.online\": \"온라인\",\n  \"storage-manager.raid-status.removed\": \"제거됨\",\n  \"storage-manager.raid-status.unavailable\": \"사용 불가\",\n  \"storage-manager.replace\": \"교체\",\n  \"storage-manager.replace-failed.degraded\": \"FailSafe 보호 수준이 저하되었습니다\",\n  \"storage-manager.replace-failed.degraded-description\": \"FailSafe 저장소에서 SSD가 하나 없습니다. 교체하면 보호가 다시 완전히 복원됩니다.\",\n  \"storage-manager.replace-failed.description\": \"이 SSD를 사용해 FailSafe 보호를 복원하세요.\",\n  \"storage-manager.replace-failed.error\": \"교체를 시작할 수 없습니다.\",\n  \"storage-manager.replace-failed.replace-now\": \"지금 교체\",\n  \"storage-manager.replace-failed.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD — 슬롯 {{slot}}\",\n  \"storage-manager.replace-failed.step-protected\": \"완료되면 데이터는 다시 완전히 보호됩니다.\",\n  \"storage-manager.replace-failed.step-rebuild\": \"데이터가 새 SSD에 재구축됩니다.\",\n  \"storage-manager.replace-failed.step-time\": \"데이터 양에 따라 시간이 오래 걸릴 수 있습니다.\",\n  \"storage-manager.replace-failed.title\": \"SSD 교체\",\n  \"storage-manager.replace-failed.too-small\": \"SSD 용량 부족\",\n  \"storage-manager.replace-failed.too-small-description\": \"이 SSD ({{deviceSize}})는 FailSafe 저장소에 필요한 최소 용량 ({{minSize}})보다 작습니다.\",\n  \"storage-manager.replace-failed.what-happens\": \"다음에 일어날 일:\",\n  \"storage-manager.ssd-failing\": \"문제 있음\",\n  \"storage-manager.swap\": \"교체\",\n  \"storage-manager.swap.data-erased-description\": \"Full Storage 모드는 데이터 보호가 없습니다. 공장 초기화하는 동안 {{deviceName}}의 모든 데이터가 삭제됩니다. 먼저 반드시 모든 데이터를 백업하세요.\",\n  \"storage-manager.swap.data-protected\": \"데이터가 보호됩니다\",\n  \"storage-manager.swap.data-protected-description\": \"FailSafe가 활성화되어 있으면 단일 SSD를 교체해도 데이터가 손실되지 않습니다. 백업이 필요 없습니다.\",\n  \"storage-manager.swap.data-will-be-erased\": \"데이터가 삭제됩니다\",\n  \"storage-manager.swap.description-failsafe\": \"FailSafe 스토리지에서 드라이브를 교체하세요.\",\n  \"storage-manager.swap.description-full-storage\": \"Full Storage 구성에서 드라이브를 교체하세요.\",\n  \"storage-manager.swap.description-no-free-slot\": \"Full Storage 모드에서 모든 슬롯이 사용 중이면 SSD 교체는 전체 백업 및 복원 과정이 필요합니다.\",\n  \"storage-manager.swap.description-replace\": \"데이터를 새 SSD로 옮긴 다음 기존 SSD를 제거하세요.\",\n  \"storage-manager.swap.failed-to-start\": \"교체를 시작할 수 없습니다.\",\n  \"storage-manager.swap.no-data-loss\": \"데이터 손실 없음\",\n  \"storage-manager.swap.no-data-loss-description\": \"데이터가 새 SSD로 복사됩니다. 완료되면 기존 SSD를 안전하게 제거할 수 있습니다.\",\n  \"storage-manager.swap.safe-swap-available\": \"안전 교체 가능\",\n  \"storage-manager.swap.safe-swap-description\": \"빈 슬롯이 있으므로 먼저 새 SSD를 추가하고 데이터를 이전한 뒤 기존 SSD를 제거할 수 있습니다. 백업 불필요.\",\n  \"storage-manager.swap.select-new-ssd\": \"사용할 새 SSD를 선택하세요:\",\n  \"storage-manager.swap.ssd-in-slot\": \"{{size}} SSD가 슬롯 {{slot}}에 있습니다\",\n  \"storage-manager.swap.step-backup\": \"데이터 백업하기\",\n  \"storage-manager.swap.step-backup-description\": \"설정 → Backups로 이동해 모든 데이터의 백업을 생성하세요.\",\n  \"storage-manager.swap.step-data-copied\": \"데이터가 기존 SSD에서 새 SSD로 복사됩니다\",\n  \"storage-manager.swap.step-factory-reset\": \"공장 초기화\",\n  \"storage-manager.swap.step-factory-reset-description\": \"설정 → Advanced → Factory Reset으로 이동해 {{deviceName}}를 초기화하세요.\",\n  \"storage-manager.swap.step-insert-new-ssd\": \"새 SSD를 빈 슬롯에 삽입하세요\",\n  \"storage-manager.swap.step-may-take-while\": \"데이터 양에 따라 시간이 걸릴 수 있습니다.\",\n  \"storage-manager.swap.step-power-on\": \"{{deviceName}}의 전원을 켜세요\",\n  \"storage-manager.swap.step-remove-bottom-cover\": \"자석식 하단 커버를 제거하세요.\",\n  \"storage-manager.swap.step-remove-old\": \"완료되면 전원을 끄고 {{ssd}}를 제거하세요\",\n  \"storage-manager.swap.step-replace-bottom-cover\": \"하단 커버를 다시 장착하세요.\",\n  \"storage-manager.swap.step-restore\": \"데이터 복원하기\",\n  \"storage-manager.swap.step-restore-description\": \"설정 → Backups로 이동해 백업에서 복원하세요.\",\n  \"storage-manager.swap.step-return-to-storage-manager\": \"여기로 돌아와 Storage Manager에서 교체를 확인하고 새 SSD를 스토리지에 추가하세요.\",\n  \"storage-manager.swap.step-return-to-swap\": \"여기로 돌아와 Storage Manager에서 \\\"Swap\\\"을 다시 클릭해 교체를 시작하세요.\",\n  \"storage-manager.swap.step-setup-new-storage\": \"새 스토리지 설정\",\n  \"storage-manager.swap.step-setup-new-storage-description\": \"{{deviceName}}의 전원을 켜고 새 SSD로 설정 과정을 완료하세요.\",\n  \"storage-manager.swap.step-shut-down\": \"{{deviceName}}의 전원을 끄세요\",\n  \"storage-manager.swap.step-shut-down-and-swap\": \"전원을 끄고 {{ssd}}를 교체하세요\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-other\": \"전원을 끄고 기기를 열어 SSD를 교체한 다음 다시 조립하세요.\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-pro\": \"전원을 끄고 하단 커버를 제거한 다음 SSD를 교체하고 커버를 닫으세요.\",\n  \"storage-manager.swap.step-swap-ssd\": \"{{ssd}}를 같은 용량의 새 SSD로 교체하세요.\",\n  \"storage-manager.swap.too-small\": \"너무 작음 (필요: {{size}})\",\n  \"storage-manager.swap.what-happens-next\": \"다음에 일어나는 일:\",\n  \"storage-manager.total-capacity-added\": \"추가된 총 용량\",\n  \"storage-manager.umbrel-pro\": \"Umbrel Pro\",\n  \"storage-manager.used\": \"사용 중\",\n  \"storage-manager.wasted\": \"사용 불가\",\n  \"storage-manager.wasted-size\": \"{{size}} 사용 불가\",\n  \"storage.full\": \"저장 공간 가득 참\",\n  \"storage.low\": \"저장 공간 부족\",\n  \"temperature\": \"온도\",\n  \"temperature.dangerously-hot\": \"매우 뜨거움\",\n  \"temperature.nice\": \"양호\",\n  \"temperature.normal\": \"정상\",\n  \"temperature.too-hot-suggestion\": \"디바이스 주위 환경을 바꿔보세요.\",\n  \"temperature.warm\": \"따뜻함\",\n  \"terminal\": \"터미널\",\n  \"terminal-description\": \"umbrelOS나 앱 내에서 직접 명령어를 실행해보세요\",\n  \"terminal.app\": \"앱\",\n  \"terminal.app-description\": \"특정 앱 내에서 직접 명령어를 실행\",\n  \"terminal.umbrelos-description\": \"umbrelOS에서 직접 명령어를 실행\",\n  \"tor-description\": \"Tor 브라우저를 사용해 어디서든 Umbrel에 접속할 수 있어요\",\n  \"tor-enabled-description\": \"다음 URL에서 Tor 브라우저를 사용해 어디서든 Umbrel에 접속할 수 있어요:\",\n  \"tor-error\": \"Tor 설정 업데이트에 실패했습니다: {{message}}\",\n  \"tor.disable.description\": \"몇 분 정도 걸릴 수 있습니다\",\n  \"tor.disable.progress\": \"원격 Tor 접속 비활성화 중\",\n  \"tor.enable.description\": \"몇 분 정도 걸릴 수 있습니다\",\n  \"tor.enable.mobile.switch-label\": \"원격 Tor 액세스 활성화\",\n  \"tor.hidden-service\": \"Tor hidden service URL\",\n  \"troubleshoot\": \"문제 해결\",\n  \"troubleshoot-description\": \"umbrelOS 또는 앱 문제를 해결해보세요\",\n  \"troubleshoot-no-logs-yet\": \"아직 로그가 없습니다\",\n  \"troubleshoot-pick-title\": \"문제 해결\",\n  \"troubleshoot.app\": \"앱\",\n  \"troubleshoot.app-description\": \"Umbrel에 설치된 앱의 로그를 확인\",\n  \"troubleshoot.app-download\": \"{{app}} 로그 다운로드\",\n  \"troubleshoot.share-with-umbrel-support\": \"Umbrel Support와 공유\",\n  \"troubleshoot.system-download\": \"{{label}} 다운로드\",\n  \"troubleshoot.umbrelos-description\": \"umbrelOS 로그 보기\",\n  \"troubleshoot.umbrelos-logs\": \"umbrelOS 로그\",\n  \"trpc.backend-unavailable\": \"오류: 시스템 API 연결 실패\",\n  \"trpc.checking-backend\": \"불러오는 중...\",\n  \"try-again\": \"다시 시도\",\n  \"umbrel\": \"Umbrel\",\n  \"umbrelos\": \"umbrelOS\",\n  \"unknown\": \"알 수 없음\",\n  \"unknown-app\": \"알 수 없는 앱\",\n  \"unknown-error\": \"알 수 없는 오류\",\n  \"uptime\": \"가동 시간\",\n  \"url\": \"URL\",\n  \"wallpaper\": \"배경화면\",\n  \"wallpaper-description\": \"Umbrel 배경화면과 테마\",\n  \"whats-new.continue\": \"계속\",\n  \"whats-new.feature-1.description\": \"전체 Umbrel의 자동 암호화 Backups를 외장 USB 드라이브, NAS 또는 다른 Umbrel에 설정하세요.\",\n  \"whats-new.feature-2.description\": \"이전 Backups에서 특정 파일과 폴더를 골라 복구할 수 있어요.\",\n  \"whats-new.feature-3.description\": \"또는 모든 앱, 파일 및 데이터를 포함해 전체 Umbrel을 복원할 수 있어요.\",\n  \"whats-new.feature-4.description\": \"NAS 또는 다른 Umbrel을 연결하면 Files에서 해당 저장소에 액세스할 수 있어요.\",\n  \"whats-new.feature-4.title\": \"네트워크 장치\",\n  \"whats-new.feature-5.description\": \"Umbrel Home 또는 Intel 또는 AMD 기반 장치에서 외장 USB 드라이브를 연결하고 Files에서 접근하세요.\",\n  \"whats-new.feature-5.helper-text\": \"전력 문제 가능성 때문에 Raspberry Pi 장치에서는 지원되지 않아요.\",\n  \"whats-new.feature-5.title\": \"외장 저장소\",\n  \"whats-new.next\": \"다음\",\n  \"whats-new.title\": \"{{version}}의 새로운 기능\",\n  \"widget.progress.in-progress\": \"진행 중\",\n  \"widgets.edit.select-up-to-3-widgets\": \"최대 3개의 위젯을 선택하세요\",\n  \"widgets.install-an-app-before-using-widgets\": \"위젯으로 홈 화면을 꾸미려면 먼저 앱을 설치하세요.\",\n  \"wifi\": \"Wi-Fi\",\n  \"wifi-connect-insecure-message\": \"공개 네트워크는 안전하지 않을 수 있습니다\",\n  \"wifi-connection-failed\": \"연결할 수 없습니다\",\n  \"wifi-dangerous-change-confirmation-description\": \"Wi-Fi 네트워크를 변경하면 Umbrel과 연결이 끊길 수 있습니다. 다시 연결하려면 Umbrel과 접속 중인 디바이스가 동일한 네트워크에 있어야 합니다.\",\n  \"wifi-dangerous-change-confirmation-title\": \"정말 Wi-Fi 네트워크를 변경하시겠어요?\",\n  \"wifi-dangerous-disable-confirmation-description\": \"Wi-Fi를 비활성화하면 Umbrel과 연결이 끊길 수 있습니다. 다시 연결하려면 Umbrel에 Ethernet 케이블을 연결하고 Umbrel과 접속 중인 디바이스가 동일한 네트워크에 있는지 확인하세요.\",\n  \"wifi-dangerous-disable-confirmation-title\": \"정말 Wi-Fi를 비활성화하시겠어요?\",\n  \"wifi-description\": \"디바이스를 Wi-Fi 네트워크에 연결하세요\",\n  \"wifi-description-long\": \"이더넷 케이블을 제거해도 디바이스는 선택한 Wi-Fi 네트워크에 연결된 상태를 유지하며, 부팅 시 자동으로 다시 Wi-Fi에 연결됩니다.\",\n  \"wifi-no-networks-message\": \"Wi-Fi 네트워크를 찾을 수 없습니다\",\n  \"wifi-searching\": \"Wi-Fi 네트워크 검색 중...\",\n  \"wifi-unsupported-device-description\": \"이 디바이스는 Wi-Fi를 지원하지 않습니다. 무선 어댑터가 없거나 호환되지 않을 수 있습니다.\",\n  \"wifi-view-networks\": \"네트워크 보기\"\n}"
  },
  {
    "path": "packages/ui/public/locales/nl.json",
    "content": "{\n  \"2fa\": \"2FA\",\n  \"2fa-description\": \"Een tweede beveiligingslaag voor je Umbrel login en apps\",\n  \"2fa.disable.title\": \"Twee-factor authenticatie uitschakelen\",\n  \"2fa.enable.or-paste\": \"Of plak de volgende code in je authenticator-app\",\n  \"2fa.enable.scan-this\": \"Scan deze QR-code met een authenticator-app zoals Google Authenticator of Authy\",\n  \"2fa.enable.title\": \"Twee-factor authenticatie inschakelen\",\n  \"2fa.enter-code\": \"Voer de code in die wordt weergegeven in je authenticator-app\",\n  \"account\": \"Account\",\n  \"account-description\": \"Je naam en wachtwoord\",\n  \"advanced-settings\": \"Geavanceerde instellingen\",\n  \"advanced-settings-description\": \"Terminal, umbrelOS Beta Programma, Cloudflare DNS en meer\",\n  \"app-not-found\": \"App niet gevonden: {{app}}\",\n  \"app-only-over-tor\": \"{{app}} kan alleen via Tor worden gebruikt. Open deze app door je Umbrel in een Tor-browser te benaderen via je URL voor externe toegang (Instellingen > Geavanceerde instellingen > Tor-toegang op afstand).\",\n  \"app-page.section.about\": \"Over\",\n  \"app-page.section.credentials.title\": \"Standaard inloggegevens\",\n  \"app-page.section.dependencies.n-alternatives\": \"Zie {{count}} alternatieven\",\n  \"app-page.section.info.compatibility\": \"Compatibiliteit\",\n  \"app-page.section.info.compatibility-compatible\": \"Compatibel\",\n  \"app-page.section.info.compatibility-not-compatible\": \"Niet compatibel\",\n  \"app-page.section.info.developer\": \"Ontwikkelaar\",\n  \"app-page.section.info.source-code\": \"Broncode\",\n  \"app-page.section.info.source-code.public\": \"Publiek\",\n  \"app-page.section.info.submitted-by\": \"Ingediend door\",\n  \"app-page.section.info.support\": \"Ondersteuning krijgen\",\n  \"app-page.section.info.title\": \"Info\",\n  \"app-page.section.info.version\": \"Versie\",\n  \"app-page.section.recommendations.title\": \"Dit vind je misschien ook leuk\",\n  \"app-page.section.release-notes.title\": \"Wat is nieuw\",\n  \"app-page.section.release-notes.version\": \"Versie {{version}}\",\n  \"app-page.section.requires\": \"Vereist\",\n  \"app-picker.search\": \"Zoeken...\",\n  \"app-picker.select-app\": \"Selecteer app...\",\n  \"app-settings.connected-to\": \"{{appName}} is verbonden met deze apps\",\n  \"app-settings.save-changes\": \"Wijzigingen opslaan\",\n  \"app-settings.title\": \"Instellingen\",\n  \"app-store.browse-category-apps\": \"Blader door {{category}} apps\",\n  \"app-store.category.ai\": \"AI\",\n  \"app-store.category.all\": \"Alle apps\",\n  \"app-store.category.automation\": \"Huis & Automatisering\",\n  \"app-store.category.bitcoin\": \"Bitcoin\",\n  \"app-store.category.crypto\": \"Crypto\",\n  \"app-store.category.developer\": \"Ontwikkelaarshulpmiddelen\",\n  \"app-store.category.discover\": \"Ontdekken\",\n  \"app-store.category.files\": \"Bestanden & Productiviteit\",\n  \"app-store.category.finance\": \"Financiën\",\n  \"app-store.category.media\": \"Media\",\n  \"app-store.category.networking\": \"Netwerken\",\n  \"app-store.category.social\": \"Sociaal\",\n  \"app-store.description\": \"Je app-update instellingen\",\n  \"app-store.discover.temporarily-unavailable-description\": \"Blader door de categorieën hierboven of zoek om apps te vinden\",\n  \"app-store.discover.temporarily-unavailable-title\": \"Uitgelichte inhoud tijdelijk niet beschikbaar\",\n  \"app-store.menu.community-app-stores\": \"Community App Stores\",\n  \"app-store.search-apps\": \"Apps zoeken\",\n  \"app-store.search.no-results\": \"Geen resultaten\",\n  \"app-store.search.results-for\": \"Resultaten voor\",\n  \"app-store.title\": \"App Store\",\n  \"app-store.updates\": \"Updates\",\n  \"app-updates.less\": \"minder\",\n  \"app-updates.more\": \"meer\",\n  \"app-updates.no-updates\": \"Alle apps zijn up-to-date!\",\n  \"app-updates.update\": \"Update\",\n  \"app-updates.update-all\": \"Alles updaten\",\n  \"app-updates.updates-available-count_one\": \"{{count}} update beschikbaar\",\n  \"app-updates.updates-available-count_other\": \"{{count}} updates beschikbaar\",\n  \"app-updates.updating\": \"Updaten...\",\n  \"app.install\": \"Installeren\",\n  \"app.installed\": \"Geïnstalleerd\",\n  \"app.installing\": \"Installeren\",\n  \"app.offline\": \"Niet actief\",\n  \"app.open\": \"Open\",\n  \"app.optimized-for-umbrel-home\": \"Geoptimaliseerd voor Umbrel Home\",\n  \"app.os-update-required.confirm\": \"Controleer op umbrelOS-update\",\n  \"app.os-update-required.description\": \"{{appName}} vereist umbrelOS {{version}} of later\",\n  \"app.os-update-required.title\": \"Update umbrelOS\",\n  \"app.restarting\": \"Herstarten\",\n  \"app.starting\": \"Starten\",\n  \"app.stopping\": \"Stoppen\",\n  \"app.uninstall.confirm.description\": \"Alle gegevens geassocieerd met {{app}} worden permanent verwijderd. Deze actie kan niet ongedaan worden gemaakt.\",\n  \"app.uninstall.confirm.submit\": \"Deïnstalleren\",\n  \"app.uninstall.confirm.title\": \"{{app}} deïnstalleren?\",\n  \"app.uninstall.deps.used-by.description_one\": \"Deïnstalleer eerst {{firstAppToUninstall}} om {{app}} te deïnstalleren.\",\n  \"app.uninstall.deps.used-by.description_other\": \"Deïnstalleer deze apps eerst om {{app}} te deïnstalleren.\",\n  \"app.uninstall.deps.used-by.title\": \"{{app}} wordt gebruikt door\",\n  \"app.uninstalling\": \"Verwijderen\",\n  \"app.updating\": \"Updaten\",\n  \"app.view\": \"Bekijk\",\n  \"app_one\": \"app\",\n  \"app_other\": \"apps\",\n  \"apps.uninstall.failed-to-get-required-apps\": \"Kon vereiste apps niet ophalen\",\n  \"apps.uninstalled-all.success\": \"Alle apps gedeïnstalleerd\",\n  \"auth.checking-backend-for-user\": \"Laden...\",\n  \"auth.failed-checking-if-user-logged-in\": \"Fout: Controle op inloggen mislukt\",\n  \"auth.failed-to-check-if-user-exists\": \"Fout: Controle op bestaan mislukt\",\n  \"back\": \"Terug\",\n  \"backups\": \"Back-ups\",\n  \"backups-configure\": \"Configureren\",\n  \"backups-configure.add-backup-location\": \"Back-uplocatie toevoegen\",\n  \"backups-configure.available\": \"Beschikbaar\",\n  \"backups-configure.awaiting-next-backup\": \"Wacht op de volgende automatische back-up\",\n  \"backups-configure.back-up-now\": \"Nu back-up maken\",\n  \"backups-configure.backing-up-now\": \"Bezig met back-up...\",\n  \"backups-configure.connected\": \"Verbonden\",\n  \"backups-configure.connection\": \"Verbinding\",\n  \"backups-configure.in-progress\": \"Bezig\",\n  \"backups-configure.last-backup\": \"Laatste back-up\",\n  \"backups-configure.locations\": \"Locaties\",\n  \"backups-configure.no-backup-locations\": \"Voeg een back-uplocatie toe om je gegevens te back-uppen\",\n  \"backups-configure.not-connected\": \"Niet verbonden\",\n  \"backups-configure.path\": \"Pad\",\n  \"backups-configure.remove-backup-location\": \"Back-uplocatie verwijderen\",\n  \"backups-configure.remove-backup-location-confirmation\": \"Weet je het zeker?\",\n  \"backups-configure.remove-backup-location-confirmation-description\": \"Hierdoor wordt '{{device}}' verwijderd uit je back-uplocaties. Bestaande back-ups op dit apparaat worden niet verwijderd, maar automatische back-ups stoppen.\",\n  \"backups-configure.status\": \"Status\",\n  \"backups-configure.total-backups\": \"Totaal Backups\",\n  \"backups-configure.used\": \"Gebruikt\",\n  \"backups-configure.view\": \"Bekijken\",\n  \"backups-description\": \"Maak back-ups van je bestanden, apps en gegevens naar een andere Umbrel, NAS of externe schijf\",\n  \"backups-error.backup-not-found\": \"De backup kon niet worden gevonden.\",\n  \"backups-error.generic\": \"Er is iets misgegaan: {{details}}\",\n  \"backups-error.in-progress\": \"Er is al een backup-proces bezig. Wacht tot het klaar is.\",\n  \"backups-error.invalid-exclusion-path\": \"Alleen bestanden en mappen in je Home-map kunnen van backups worden uitgesloten.\",\n  \"backups-error.invalid-password\": \"Het encryptiewachtwoord is onjuist.\",\n  \"backups-error.invalid-path\": \"De geselecteerde locatie is niet geschikt voor backups.\",\n  \"backups-error.mount-failed\": \"Kon geen toegang krijgen tot de backup-snapshot.\",\n  \"backups-error.mount-timeout\": \"Kon geen toegang krijgen tot de backup-snapshot. Probeer het opnieuw of controleer of het apparaat goed is aangesloten.\",\n  \"backups-error.not-enough-space\": \"Er is niet genoeg vrije ruimte op het backup-apparaat.\",\n  \"backups-error.not-found\": \"De backup of backup-locatie kon niet worden gevonden.\",\n  \"backups-error.repository-exists\": \"Er bestaat al een backup-locatie in deze map.\",\n  \"backups-error.repository-not-found\": \"De backup-locatie kon niet worden gevonden.\",\n  \"backups-exclusions.add\": \"Toevoegen\",\n  \"backups-exclusions.app-paths-cannot-be-modified\": \"Deze bestanden/mappen worden door de app-ontwikkelaar ingesteld en kunnen niet worden gewijzigd:\",\n  \"backups-exclusions.app-paths-explanation\": \"Deze app sluit de volgende data uit van back-ups. Deze paden bevatten meestal niet-essentiële items (zoals caches of logbestanden die opnieuw aangemaakt kunnen worden) of data die problemen kan veroorzaken bij het terugzetten (bijvoorbeeld verouderde app-statussen die conflicten of inconsistenties kunnen geven).\",\n  \"backups-exclusions.auto-excluded\": \"Automatisch uitgesloten\",\n  \"backups-exclusions.exclude-entire-app\": \"Hele app uitsluiten\",\n  \"backups-exclusions.excluded-apps\": \"Uitgesloten apps\",\n  \"backups-exclusions.files-and-folders\": \"Uitgesloten bestanden en mappen\",\n  \"backups-exclusions.no-excluded-apps\": \"Geen uitgesloten apps\",\n  \"backups-exclusions.no-excluded-files-or-folders\": \"Geen uitgesloten bestanden of mappen\",\n  \"backups-exclusions.select-item-to-exclude\": \"Selecteer een item om uit te sluiten\",\n  \"backups-exclusions.stop-excluding\": \"Niet meer uitsluiten\",\n  \"backups-floating-island.backing-up\": \"Back-uppen...\",\n  \"backups-floating-island.backing-up-to\": \"Back-uppen van je Umbrel...\",\n  \"backups-restore\": \"Herstellen\",\n  \"backups-restore-full\": \"Volledig herstel\",\n  \"backups-restore-full-description\": \"Zet je hele Umbrel terug vanaf een back-up\",\n  \"backups-restore-header\": \"Herstel je Umbrel\",\n  \"backups-restore-pro.after-restore\": \"Na het terugzetten wordt je tijdelijke account vervangen door je geback-upte account en bijbehorende gegevens.\",\n  \"backups-restore-pro.step1\": \"Rond de onboarding af door hieronder op \\\"Aan de slag\\\" te klikken. Dit wordt je tijdelijke account totdat je je geback-upte account hebt hersteld.\",\n  \"backups-restore-pro.step2\": \"Zodra de installatie is voltooid, ga naar <0>Instellingen → Backups → Terugzetten</0>\",\n  \"backups-restore-pro.step3\": \"Volg de aanwijzingen in de Herstelassistent.\",\n  \"backups-restore-pro.subtitle\": \"Het terugzetten van een backup op Umbrel Pro vereist een paar extra stappen\",\n  \"backups-restore.backup-date\": \"Back-updatum\",\n  \"backups-restore.backup-location\": \"Back-uplocatie\",\n  \"backups-restore.browse-cloud-subtitle\": \"Herstel vanaf Umbrel Private Cloud (binnenkort beschikbaar)\",\n  \"backups-restore.browse-cloud-title\": \"Umbrel Private Cloud\",\n  \"backups-restore.browse-external-subtitle\": \"Herstellen vanaf een externe USB-drive\",\n  \"backups-restore.browse-external-title\": \"Externe schijf\",\n  \"backups-restore.browse-nas-or-external\": \"Doorzoek een andere Umbrel, NAS of externe schijf om een back-up van te herstellen\",\n  \"backups-restore.browse-nas-subtitle\": \"Herstel vanaf een andere Umbrel of NAS op je netwerk\",\n  \"backups-restore.browse-nas-title\": \"Een andere Umbrel of NAS\",\n  \"backups-restore.choose\": \"Kies\",\n  \"backups-restore.choose-backup-location\": \"Kies een back-uplocatie\",\n  \"backups-restore.connect-to-backup-location\": \"Verbinden met een back-uplocatie\",\n  \"backups-restore.encryption-password\": \"Encryptiewachtwoord\",\n  \"backups-restore.encryption-password-description\": \"Voer het wachtwoord voor encryptie in dat je hebt ingesteld toen je back-ups inschakelde\",\n  \"backups-restore.enter-password-to-confirm\": \"Voer je Umbrel-wachtwoord in om te bevestigen\",\n  \"backups-restore.final-confirmation\": \"Weet je het zeker?\",\n  \"backups-restore.final-confirmation-description\": \"Het terugzetten van deze back-up zal je huidige umbrelOS-apps en -gegevens vervangen door de inhoud van de geselecteerde back-up. Alle bestanden, mappen of apps die uitgesloten zijn van deze back-up worden van je Umbrel verwijderd. Deze actie kan niet ongedaan worden gemaakt.\",\n  \"backups-restore.invalid-password\": \"Ongeldig wachtwoord\",\n  \"backups-restore.last-backup\": \"Laatste back-up: {{date}}\",\n  \"backups-restore.latest\": \"Nieuwste\",\n  \"backups-restore.no-backups-found\": \"Geen back-ups gevonden\",\n  \"backups-restore.no-backups-yet\": \"Nog geen back-ups\",\n  \"backups-restore.please-select-backup\": \"Selecteer een back-up\",\n  \"backups-restore.please-select-repository\": \"Selecteer een repository\",\n  \"backups-restore.restore-from-nas-or-external\": \"Herstel je Umbrel vanaf een back-up op een andere Umbrel, een NAS of een externe schijf\",\n  \"backups-restore.restore-from-unlisted\": \"Terugzetten vanaf een andere locatie\",\n  \"backups-restore.restore-umbrel\": \"Umbrel herstellen\",\n  \"backups-restore.restore-warning\": \"Het terugzetten van deze back-up vervangt je huidige umbrelOS-apps en -gegevens door de inhoud van de geselecteerde back-up. Alle bestanden, mappen of apps die van deze back-up zijn uitgesloten, worden van je Umbrel verwijderd. Open <0>Rewind</0> als je in plaats daarvan specifieke bestanden of mappen wilt terugzetten.\",\n  \"backups-restore.restoring-from\": \"Je staat op het punt de volgende back-up terug te zetten:\",\n  \"backups-restore.review-description\": \"Het herstellen zet je Umbrel terug naar het account, de bestanden, apps en instellingen die in deze back-up zaten. Dit kan even duren. Als het klaar is, wordt je inlogwachtwoord ingesteld op het wachtwoord dat je gebruikte toen je de back-up maakte.\",\n  \"backups-restore.select-backup\": \"Selecteer een back-up\",\n  \"backups-restore.select-backup-description\": \"Selecteer de back-up die je wilt terugzetten\",\n  \"backups-restore.select-backup-file\": \"Selecteer je back-upbestand\",\n  \"backups-restore.select-backup-file-only\": \"Je kunt alleen <bold>{{backupFileName}}</bold> selecteren.\",\n  \"backups-restore.total-size\": \"Totale grootte\",\n  \"backups-restore.unknown-date\": \"Onbekende datum\",\n  \"backups-restore.unknown-repository\": \"Onbekende repository\",\n  \"backups-rewind\": \"Rewind\",\n  \"backups-rewind-description\": \"Ga terug in de tijd om specifieke bestanden en mappen te herstellen\",\n  \"backups-rewind.start\": \"Start Rewind\",\n  \"backups-setup\": \"Instellen\",\n  \"backups-setup-confirm\": \"Instellen voltooien\",\n  \"backups-setup-external-description\": \"Maak een back-up naar een externe USB-drive\",\n  \"backups-setup-nas-or-umbrel-description\": \"Maak back-ups naar een andere Umbrel of een NAS op je netwerk\",\n  \"backups-setup-umbrel-or-nas\": \"Een andere Umbrel of NAS\",\n  \"backups-setup-umbrel-private-cloud\": \"Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-cta\": \"Vergroot je gemoedsrust buiten je huis met <bold>end-to-end versleutelde back-ups</bold> naar Umbrel Private Cloud.\",\n  \"backups-setup-umbrel-private-cloud-cta-link\": \"Vraag vroege toegang aan\",\n  \"backups-setup-umbrel-private-cloud-description\": \"End-to-end versleutelde back-ups naar Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-subtitle\": \"Komt binnenkort\",\n  \"backups.add-umbrel-or-nas\": \"Voeg Umbrel of NAS toe\",\n  \"backups.all-apps-and-data-will-be-backed-up\": \"Alle apps en gegevens worden geback-upt\",\n  \"backups.apps-and-data\": \"Apps & gegevens\",\n  \"backups.backup-location\": \"Back-uplocatie\",\n  \"backups.browse\": \"Bladeren\",\n  \"backups.choose-folder-within-device\": \"Kies een map binnen <bold>{{device}}</bold> om je back-ups op te slaan\",\n  \"backups.confirm-password\": \"Bevestig wachtwoord\",\n  \"backups.copy\": \"Kopiëren\",\n  \"backups.encryption\": \"Encryptie\",\n  \"backups.encryption-password-warning\": \"Zorg dat je encryptiewachtwoord veilig is opgeslagen, bijvoorbeeld in een wachtwoordmanager. Je kunt het niet opnieuw bekijken en je hebt het nodig om je back-ups te herstellen.\",\n  \"backups.exclude-from-backups\": \"Uitsluiten van back-ups\",\n  \"backups.exclude-from-backups-description\": \"Sluit specifieke bestanden, mappen en apps uit van je back-ups.\",\n  \"backups.hide\": \"Verbergen\",\n  \"backups.i-understand\": \"Ik begrijp het\",\n  \"backups.location\": \"Locatie\",\n  \"backups.modals.already-in-use.description\": \"Deze back-uplocatie wordt al gebruikt voor Backups op deze Umbrel.\",\n  \"backups.modals.already-in-use.manage\": \"Beheren in Backups\",\n  \"backups.modals.already-in-use.title\": \"Back-uplocatie al in gebruik\",\n  \"backups.modals.connect-existing.description\": \"Op deze locatie staat al een Umbrel-backup. Voer het versleutelingswachtwoord in om deze aan deze Umbrel toe te voegen.\",\n  \"backups.modals.connect-existing.title\": \"Bestaande Umbrel-backup verbinden\",\n  \"backups.no-external-drives-detected\": \"Geen externe schijven gedetecteerd\",\n  \"backups.no-password-set\": \"Geen wachtwoord ingesteld\",\n  \"backups.password-is-set\": \"Wachtwoord is ingesteld\",\n  \"backups.password-minimum-length\": \"Wachtwoord moet minstens 8 tekens lang zijn\",\n  \"backups.password-safety-warning\": \"Je back-ups worden met dit wachtwoord versleuteld. Bewaar het goed, je kunt het niet opnieuw bekijken en je hebt het nodig om je back-ups te herstellen.\",\n  \"backups.passwords-do-not-match\": \"Wachtwoorden komen niet overeen\",\n  \"backups.please-choose-folder\": \"Kies een map\",\n  \"backups.restore-failed.message\": \"Er is een fout opgetreden tijdens het herstellen van je Umbrel. Je huidige apps en gegevens zijn niet gewijzigd.\",\n  \"backups.restore-failed.retry\": \"Ga naar Herstel\",\n  \"backups.restore-failed.title\": \"Herstel mislukt\",\n  \"backups.restoring\": \"Je Umbrel wordt hersteld\",\n  \"backups.restoring-completing\": \"Bezig met afronden. Je Umbrel wordt binnenkort opnieuw opgestart...\",\n  \"backups.restoring-progress\": \"Hersteld {{percent}}%\",\n  \"backups.restoring-time-remaining\": \"Nog {{time}}\",\n  \"backups.restoring-warning\": \"Zet je Umbrel niet uit en ontkoppel je back-uplocatie niet tijdens het terugzetten\",\n  \"backups.review\": \"Controleren en bevestigen\",\n  \"backups.review-description\": \"Bekijk de details van je back-up en bevestig je keuze\",\n  \"backups.scanning-for-external-drives\": \"Zoeken naar externe schijven...\",\n  \"backups.schedule-description\": \"umbrelOS maakt elk uur automatisch een back-up van je gegevens. Het bewaart versleutelde uur-back-ups van de afgelopen 24 uur, dagelijkse back-ups van de afgelopen week, wekelijkse back-ups van de afgelopen maand en maandelijkse back-ups van het afgelopen jaar. Back-ups ouder dan een jaar worden automatisch verwijderd.\",\n  \"backups.select-backup-folder\": \"Selecteer back-upmap\",\n  \"backups.select-backup-folder-description\": \"Kies een map waarin je je back-ups wilt opslaan.\",\n  \"backups.select-backup-location\": \"Selecteer een back-uplocatie\",\n  \"backups.set-encryption-password\": \"Stel encryptiewachtwoord in\",\n  \"backups.set-encryption-password-description\": \"Beveilig je back-ups met een wachtwoord. Zo blijven je gegevens privé en kunnen ze alleen met dit wachtwoord hersteld worden.\",\n  \"backups.show\": \"Tonen\",\n  \"backups.storage-capacity-warning\": \"{{device}} moet vrije ruimte hebben van minimaal twee keer de grootte van je back-up\",\n  \"backups.store-encryption-password-safely\": \"Bewaar je encryptiewachtwoord veilig\",\n  \"beta-program\": \"umbrelOS Beta Programma\",\n  \"beta-program-description\": \"Meld je aan om beta-updates van umbrelOS te ontvangen, krijg vroegtijdig toegang tot nieuwe functies en help ons deze te verfijnen door jouw feedback te geven. Beta-updates kunnen onstabiel zijn en het oplossen van problemen kan bekendheid met de terminal vereisen.\",\n  \"cancel\": \"Annuleren\",\n  \"change\": \"Veranderen\",\n  \"change-name\": \"Naam wijzigen\",\n  \"change-name.failed.name-required\": \"Naam is vereist\",\n  \"change-name.input-placeholder\": \"Jouw naam\",\n  \"change-password\": \"Wachtwoord wijzigen\",\n  \"change-password.callout\": \"Als je je wachtwoord verliest, kun je niet inloggen op je Umbrel. Zorg ervoor dat je het veilig bewaart.\",\n  \"change-password.current-password\": \"Huidig wachtwoord\",\n  \"change-password.failed.current-required\": \"Huidig wachtwoord is vereist\",\n  \"change-password.failed.min-length\": \"Wachtwoord moet minstens {{characters}} tekens lang zijn\",\n  \"change-password.failed.must-be-unique\": \"Nieuw wachtwoord moet anders zijn dan het huidige wachtwoord\",\n  \"change-password.failed.new-required\": \"Nieuw wachtwoord is vereist\",\n  \"change-password.failed.no-match\": \"Wachtwoorden komen niet overeen\",\n  \"change-password.failed.repeat-required\": \"Herhaal wachtwoord is vereist\",\n  \"change-password.new-password\": \"Nieuw wachtwoord\",\n  \"change-password.repeat-password\": \"Herhaal wachtwoord\",\n  \"check-for-latest-version\": \"Controleer op de nieuwste update van umbrelOS\",\n  \"clipboard.copied\": \"Gekopieerd\",\n  \"close\": \"Sluiten\",\n  \"cmdk.change-wallpaper\": \"Wijzig achtergrond\",\n  \"cmdk.frequent-apps\": \"Veel gebruikt\",\n  \"cmdk.input-placeholder\": \"Zoek naar apps, instellingen of acties\",\n  \"cmdk.live-usage\": \"Live Usage\",\n  \"cmdk.restart-umbrel\": \"Herstart Umbrel\",\n  \"cmdk.shutdown-umbrel\": \"Umbrel afsluiten\",\n  \"cmdk.update-all-apps\": \"Update alle apps\",\n  \"cmdk.widgets\": \"Widgets\",\n  \"community-app-store\": \"Community App Store\",\n  \"community-app-store.add-error\": \"Kon de App Store niet toevoegen: {{message}}\",\n  \"community-app-store.back-to-umbrel-app-store\": \"Terug naar Umbrel App Store\",\n  \"community-app-store.open-button\": \"Open\",\n  \"community-app-store.remove-button\": \"Verwijderen\",\n  \"community-app-store.remove-error\": \"Kon de App Store niet verwijderen: {{message}}\",\n  \"community-app-stores.add-button\": \"Toevoegen\",\n  \"community-app-stores.description\": \"Community App Stores stellen je in staat om apps op je Umbrel te installeren die mogelijk niet beschikbaar zijn in de officiële Umbrel App Store. Ze maken het ook makkelijk om bètaversies van Umbrel-apps te testen voordat ontwikkelaars deze uitbrengen in de officiële Umbrel App Store.\",\n  \"community-app-stores.learn-more\": \"Meer informatie\",\n  \"community-app-stores.warning\": \"Community App Stores kunnen door iedereen worden gemaakt. De apps die erin worden gepubliceerd zijn niet geverifieerd of gecontroleerd door het officiële Umbrel App Store-team en kunnen mogelijk onveilig of kwaadaardig zijn. Wees voorzichtig en voeg alleen app-winkels toe van ontwikkelaars die je vertrouwt.\",\n  \"confirm\": \"Bevestigen\",\n  \"connect\": \"Verbinden\",\n  \"connecting\": \"Verbinden...\",\n  \"connection-lost\": \"Verbinding verloren\",\n  \"connection-lost-description\": \"Dit kan gebeuren als je tabblad inactief was, je netwerkverbinding onderbroken werd of je apparaat offline staat.\",\n  \"continue\": \"Doorgaan\",\n  \"continue-to-log-in\": \"Ga verder om in te loggen\",\n  \"cpu\": \"CPU\",\n  \"cpu-core-count\": \"{{cores}} threads\",\n  \"default-credentials.close\": \"Begrepen\",\n  \"default-credentials.description\": \"Hier zijn de inloggegevens die je nodig hebt om in te loggen op de app.\",\n  \"default-credentials.dont-show-again\": \"Niet meer tonen\",\n  \"default-credentials.dont-show-again-notice\": \"Je kunt deze inloggegevens op elk moment in de toekomst inzien door met de rechtermuisknop op het app-pictogram te klikken.\",\n  \"default-credentials.open\": \"Open {{app}}\",\n  \"default-credentials.password\": \"Standaard wachtwoord\",\n  \"default-credentials.title\": \"Inloggegevens voor {{app}}\",\n  \"default-credentials.username\": \"Standaard gebruikersnaam\",\n  \"desktop.app.context.go-to-store-page\": \"Bekijk in App Store\",\n  \"desktop.app.context.settings\": \"Instellingen\",\n  \"desktop.app.context.show-default-credentials\": \"Standaard inloggegevens tonen\",\n  \"desktop.app.context.uninstall\": \"Deïnstalleren\",\n  \"desktop.context-menu.change-wallpaper\": \"Achtergrond wijzigen\",\n  \"desktop.context-menu.edit-widgets\": \"Widgets bewerken\",\n  \"desktop.context-menu.logout\": \"Uitloggen\",\n  \"desktop.greeting.afternoon\": \"Goedemiddag, {{name}}\",\n  \"desktop.greeting.evening\": \"Goedenavond, {{name}}\",\n  \"desktop.greeting.morning\": \"Goedemorgen, {{name}}\",\n  \"desktop.install-first.for-the-ai-enthusiast\": \"Voor Viber\",\n  \"desktop.install-first.for-the-bitcoiner\": \"Voor de Bitcoiner\",\n  \"desktop.install-first.for-the-self-hoster\": \"Voor de zelf-hostende\",\n  \"desktop.install-first.for-the-streamer\": \"Voor de streamer\",\n  \"desktop.install-first.link-to-app-store\": \"Ontdek meer in App Store\",\n  \"desktop.not-enough-room\": \"Gebruik een groter scherm om je apps te bekijken.\",\n  \"device\": \"Apparaat\",\n  \"device-info\": \"Apparaatinformatie\",\n  \"device-info-description\": \"Informatie over je apparaat\",\n  \"device-info.device\": \"Apparaat\",\n  \"device-info.model-number\": \"Modelnummer\",\n  \"device-info.serial-number\": \"Serienummer\",\n  \"device-info.view-info\": \"Bekijk info\",\n  \"device-name.home-or-pro\": \"Umbrel Home of Umbrel Pro\",\n  \"disable\": \"Uitschakelen\",\n  \"done\": \"Gereed\",\n  \"download-logs\": \"Logbestanden downloaden\",\n  \"enabling-tor\": \"Tor-toegang op afstand inschakelen\",\n  \"external-dns\": \"Cloudflare DNS\",\n  \"external-dns-description\": \"Cloudflare DNS biedt betere netwerkbetrouwbaarheid. Uitschakelen om de DNS-instellingen van je router te gebruiken.\",\n  \"external-dns-error\": \"Kon DNS-instelling niet bijwerken: {{message}}\",\n  \"external-drive\": \"Externe schijf\",\n  \"factory-reset\": \"Fabrieksinstellingen\",\n  \"factory-reset-description\": \"Wis al je data en apps en herstel umbrelOS naar de standaardinstellingen\",\n  \"factory-reset-failed\": \"Het terugzetten van je apparaat naar fabrieksinstellingen is mislukt: {{message}}\",\n  \"factory-reset.confirm.body\": \"Bevestig je wachtwoord om te resetten\",\n  \"factory-reset.confirm.ethernet-required-warning\": \"Zorg ervoor dat je apparaat is verbonden met je router via Ethernet (niet via Wi-Fi) en dat je er toegang toe hebt vanaf je lokale netwerk (bijv. http://umbrel.local of het lokale IP-adres van je apparaat).\",\n  \"factory-reset.confirm.submit\": \"Alles wissen en resetten\",\n  \"factory-reset.confirm.submit-callout\": \"Deze actie kan niet ongedaan worden gemaakt.\",\n  \"factory-reset.rebooting.message\": \"Je apparaat wordt opnieuw opgestart en alle gegevens worden gewist. Sluit deze pagina niet.\",\n  \"factory-reset.rebooting.status\": \"Bezig met resetten...\",\n  \"factory-reset.rebooting.title\": \"Fabrieksreset wordt uitgevoerd\",\n  \"factory-reset.review.account-info\": \"Accountinformatie en wachtwoord\",\n  \"factory-reset.review.apps\": \"Apps\",\n  \"factory-reset.review.following-will-be-removed\": \"Het volgende wordt verwijderd van je apparaat\",\n  \"factory-reset.review.installed-apps_one\": \"{{count}} geïnstalleerde app\",\n  \"factory-reset.review.installed-apps_other\": \"{{count}} geïnstalleerde apps\",\n  \"factory-reset.review.submit\": \"Doorgaan\",\n  \"factory-reset.review.total-data\": \"Totale data\",\n  \"files\": \"Files\",\n  \"files-action.add-favorite\": \"Toevoegen aan favorieten\",\n  \"files-action.add-network-device\": \"Apparaat toevoegen\",\n  \"files-action.cancel-upload\": \"Upload annuleren\",\n  \"files-action.compress\": \"Comprimeren\",\n  \"files-action.copy\": \"Kopiëren\",\n  \"files-action.cut\": \"Knippen\",\n  \"files-action.delete\": \"Permanent verwijderen\",\n  \"files-action.download\": \"Downloaden\",\n  \"files-action.download-items\": \"Download {{count}} onderdelen\",\n  \"files-action.drop-to-upload\": \"Sleep hierheen om te uploaden\",\n  \"files-action.eject-disk\": \"Uitwerpen\",\n  \"files-action.empty-trash\": \"Prullenmand legen\",\n  \"files-action.format-drive\": \"Formatteren\",\n  \"files-action.go-to-path\": \"Ga naar...\",\n  \"files-action.new-folder\": \"Nieuwe map\",\n  \"files-action.open\": \"Openen\",\n  \"files-action.paste\": \"Plakken\",\n  \"files-action.remove-favorite\": \"Verwijderen uit favorieten\",\n  \"files-action.remove-network-host\": \"Netwerkschijf uitwerpen\",\n  \"files-action.remove-network-share\": \"Netwerkshare uitwerpen\",\n  \"files-action.rename\": \"Hernoemen\",\n  \"files-action.restore\": \"Herstellen\",\n  \"files-action.select\": \"Selecteren\",\n  \"files-action.share\": \"Delen over het netwerk...\",\n  \"files-action.sharing\": \"Bezig met delen...\",\n  \"files-action.show-in-folder\": \"Toon in bovenliggende map\",\n  \"files-action.trash\": \"Naar prullenmand\",\n  \"files-action.uncompress\": \"Uitpakken\",\n  \"files-action.upload\": \"Uploaden\",\n  \"files-add-network-share.add-manually\": \"Handmatig toevoegen\",\n  \"files-add-network-share.add-share\": \"Share toevoegen\",\n  \"files-add-network-share.back\": \"Terug\",\n  \"files-add-network-share.continue\": \"Doorgaan\",\n  \"files-add-network-share.description\": \"Maak verbinding met een NAS of een andere gedeelde schijf op je netwerk om er in Bestanden toegang toe te krijgen.\",\n  \"files-add-network-share.discovering\": \"Bezig met zoeken...\",\n  \"files-add-network-share.enter-details-manually\": \"Voer servergegevens in\",\n  \"files-add-network-share.host-label\": \"Serveradres\",\n  \"files-add-network-share.host-required\": \"Serveradres is vereist\",\n  \"files-add-network-share.manual-share-help\": \"Voer exact de naam in van de gedeelde map zoals die op je server staat\",\n  \"files-add-network-share.no-shares-found\": \"Geen gedeelde mappen gevonden op deze server\",\n  \"files-add-network-share.not-seeing-share\": \"Zie je je gedeelde map niet?\",\n  \"files-add-network-share.password-label\": \"Wachtwoord\",\n  \"files-add-network-share.password-required\": \"Wachtwoord is vereist\",\n  \"files-add-network-share.retrieving-shares\": \"Shares ophalen...\",\n  \"files-add-network-share.retry-discovery\": \"Netwerk opnieuw scannen\",\n  \"files-add-network-share.select-share\": \"Selecteer een share om toe te voegen\",\n  \"files-add-network-share.share-placeholder\": \"shared-documents\",\n  \"files-add-network-share.share-required\": \"Share is vereist\",\n  \"files-add-network-share.title\": \"Een netwerkshare toevoegen\",\n  \"files-add-network-share.username-label\": \"Gebruikersnaam\",\n  \"files-add-network-share.username-placeholder\": \"admin\",\n  \"files-add-network-share.username-required\": \"Gebruikersnaam is vereist\",\n  \"files-audio-island.now-playing\": \"Nu aan het afspelen\",\n  \"files-audio-island.pause\": \"Pauzeren\",\n  \"files-audio-island.play\": \"Afspelen\",\n  \"files-backend-error.base-directory-not-found\": \"De basismap kon niet gevonden worden\",\n  \"files-backend-error.cant-find-root\": \"Kon het bestandspad niet verifiëren\",\n  \"files-backend-error.destination-already-exists\": \"Er bestaat al een item met dezelfde naam op de bestemming\",\n  \"files-backend-error.destination-not-exist\": \"De doelmap bestaat niet\",\n  \"files-backend-error.does-not-exist\": \"Het bestand of de map bestaat niet\",\n  \"files-backend-error.escapes-base\": \"Het pad bevindt zich buiten de toegestane map\",\n  \"files-backend-error.invalid-base\": \"Het pad behoort niet tot een geldige map\",\n  \"files-backend-error.invalid-filename\": \"De bestandsnaam is ongeldig\",\n  \"files-backend-error.invalid-path\": \"Het bestandspad is ongeldig\",\n  \"files-backend-error.mkdir-failed\": \"Het aanmaken van de map is mislukt\",\n  \"files-backend-error.move-failed\": \"Het verplaatsen van het item is mislukt\",\n  \"files-backend-error.not-enough-space\": \"Niet genoeg opslagruimte beschikbaar\",\n  \"files-backend-error.operation-not-allowed\": \"Deze bewerking is niet toegestaan\",\n  \"files-backend-error.parent-not-directory\": \"Het bovenliggende pad is geen map\",\n  \"files-backend-error.parent-not-exist\": \"De bovenliggende map bestaat niet\",\n  \"files-backend-error.path-not-absolute\": \"Het bestandspad is ongeldig\",\n  \"files-backend-error.share-already-exists\": \"Deze map is al gedeeld\",\n  \"files-backend-error.share-name-generation-failed\": \"Kon geen unieke naam voor de gedeelde map genereren\",\n  \"files-backend-error.source-not-exists\": \"Het bronbestand of de map bestaat niet\",\n  \"files-backend-error.subdir-of-self\": \"Een map kan niet in zichzelf worden verplaatst of gekopieerd\",\n  \"files-backend-error.trash-meta-not-exists\": \"Kon de oorspronkelijke locatie van dit item niet vinden\",\n  \"files-backend-error.unique-name-index-exceeded\": \"Kon geen unieke naam genereren. Er bestaan te veel items met vergelijkbare namen\",\n  \"files-backend-error.upload-failed\": \"Upload mislukt\",\n  \"files-collision.action.keep-both\": \"Beide behouden\",\n  \"files-collision.action.replace\": \"Vervangen\",\n  \"files-collision.action.skip\": \"Overslaan\",\n  \"files-collision.destination.original-location\": \"de oorspronkelijke locatie\",\n  \"files-collision.message\": \"Wil je het bestaande item vervangen of beide behouden?\",\n  \"files-collision.title\": \"\\\"{{itemName}}\\\" bestaat al in {{destinationName}}\",\n  \"files-download.confirm\": \"Downloaden\",\n  \"files-download.description\": \"Bestanden kan dit bestandstype niet openen. Wil je het in plaats daarvan downloaden?\",\n  \"files-download.title\": \"'{{name}}' downloaden?\",\n  \"files-empty-trash.confirm\": \"Leeg\",\n  \"files-empty-trash.description\": \"Weet je zeker dat je alle items in de prullenmand definitief wilt verwijderen? Je kunt dit niet ongedaan maken.\",\n  \"files-empty-trash.title\": \"Prullenmand leegmaken?\",\n  \"files-empty.directory\": \"Geen items in deze map\",\n  \"files-empty.network\": \"Geen netwerkapparaten\",\n  \"files-empty.network-host-offline\": \"Netwerkapparaat offline\",\n  \"files-error.add-favorite\": \"Toevoegen aan favorieten mislukt: {{message}}\",\n  \"files-error.add-share\": \"Delen van map mislukt: {{message}}\",\n  \"files-error.compress\": \"Comprimeren mislukt: {{message}}\",\n  \"files-error.copy\": \"Kopiëren mislukt: {{message}}\",\n  \"files-error.create-folder\": \"Map aanmaken mislukt: {{message}}\",\n  \"files-error.delete\": \"Verwijderen mislukt: {{message}}\",\n  \"files-error.eject-disk\": \"Uitwerpen van schijf mislukt: {{message}}\",\n  \"files-error.empty-trash\": \"Prullenbak legen mislukt: {{message}}\",\n  \"files-error.extract\": \"Uitpakken mislukt: {{message}}\",\n  \"files-error.folder-already-exists\": \"Er bestaat al een map met deze naam\",\n  \"files-error.move\": \"Verplaatsen mislukt: {{message}}\",\n  \"files-error.remove-favorite\": \"Verwijderen uit favorieten mislukt: {{message}}\",\n  \"files-error.remove-share\": \"Verwijderen van gedeelde map mislukt: {{message}}\",\n  \"files-error.rename\": \"Hernoemen mislukt: {{message}}\",\n  \"files-error.restore\": \"Herstellen mislukt: {{message}}\",\n  \"files-error.trash\": \"Verplaatsen naar prullenbak mislukt: {{message}}\",\n  \"files-error.upload\": \"Upload mislukt: {{message}}\",\n  \"files-error.upload-network-error\": \"Upload voor {{name}} mislukt: er is een netwerkfout opgetreden\",\n  \"files-extension-change.confirm\": \"Doorgaan\",\n  \"files-extension-change.description-add\": \"Weet je zeker dat je de extensie van '{{fileName}}' wilt wijzigen in '{{extension}}'? Dit kan ervoor zorgen dat het bestand onleesbaar wordt.\",\n  \"files-extension-change.description-remove\": \"Weet je zeker dat je de extensie van '{{fileName}}' wilt verwijderen?\",\n  \"files-extension-change.title-add\": \"Extensie wijzigen in '{{extension}}'?\",\n  \"files-extension-change.title-remove\": \"Extensie verwijderen?\",\n  \"files-external-storage.unsupported.description\": \"Je aangesloten externe schijf kan niet worden gebruikt op een Raspberry Pi vanwege stroomproblemen. Externe opslag is beschikbaar op Umbrel Home, Umbrel Pro en alle x86 (Intel or AMD) apparaten.\",\n  \"files-external-storage.unsupported.description-general\": \"Externe opslag is niet beschikbaar op een Raspberry Pi vanwege stroomproblemen. Externe opslag is beschikbaar op Umbrel Home, Umbrel Pro en alle x86 (Intel of AMD) apparaten.\",\n  \"files-external-storage.unsupported.title\": \"Externe opslag niet ondersteund\",\n  \"files-folder\": \"Map\",\n  \"files-format.confirm\": \"Formatteren\",\n  \"files-format.description\": \"Formatteren verwijdert alle gegevens op {{driveName}}. Deze actie kan niet ongedaan worden gemaakt.\",\n  \"files-format.description-unreadable\": \"umbrelOS kan de inhoud van {{driveName}} niet lezen. Je kunt de schijf formatteren om hem met umbrelOS te gebruiken.\",\n  \"files-format.drive-label\": \"Naam\",\n  \"files-format.error\": \"Formatteren van de schijf is mislukt\",\n  \"files-format.exfat-description\": \"Maximale compatibiliteit met Windows, macOS en Linux\",\n  \"files-format.ext4-description\": \"Betere prestaties met umbrelOS en Linux\",\n  \"files-format.filesystem\": \"Bestandssysteem\",\n  \"files-format.filesystem-label\": \"Formatteren als\",\n  \"files-format.formatting\": \"Formatteren...\",\n  \"files-format.title\": \"Schijf formatteren\",\n  \"files-format.title-requires-format\": \"Formatteren vereist\",\n  \"files-formatting-island.formatting\": \"Formatteren...\",\n  \"files-formatting-island.formatting-drives\": \"Formatteren van {{count}} schijven\",\n  \"files-listing.empty\": \"Geen items\",\n  \"files-listing.error\": \"Er is een fout opgetreden\",\n  \"files-listing.item-count-truncated\": \"{{formattedCount}}+ onderdelen\",\n  \"files-listing.item-count_one\": \"{{formattedCount}} bestand\",\n  \"files-listing.item-count_other\": \"{{formattedCount}} bestanden\",\n  \"files-listing.loading\": \"Laden...\",\n  \"files-listing.no-such-file\": \"Het bestand of de map bestaat niet\",\n  \"files-listing.selected-count\": \"{{selectedCount}} van {{totalCount}} geselecteerd\",\n  \"files-listing.selected-count-truncated\": \"{{selectedCount}} van de {{totalCount}}+ geselecteerd\",\n  \"files-name-drawer.new-folder\": \"Nieuwe map\",\n  \"files-name-drawer.new-folder-description\": \"Voer een naam in voor de nieuwe map.\",\n  \"files-name-drawer.new-folder-input\": \"Mapnaam\",\n  \"files-name-drawer.rename-file\": \"Bestand hernoemen\",\n  \"files-name-drawer.rename-file-description\": \"Voer een nieuwe naam in voor dit bestand.\",\n  \"files-name-drawer.rename-file-input\": \"Bestandsnaam\",\n  \"files-name-drawer.rename-folder\": \"Map hernoemen\",\n  \"files-name-drawer.rename-folder-description\": \"Voer een nieuwe naam in voor deze map.\",\n  \"files-name-drawer.rename-folder-input\": \"Mapnaam\",\n  \"files-network-storage-error.add-share\": \"Netwerkshare toevoegen mislukt: {{message}}\",\n  \"files-network-storage-error.discover-servers\": \"Ontdekken van netwerkapparaten mislukt: {{message}}\",\n  \"files-network-storage-error.discover-shares\": \"Ontdekking van netwerkshares mislukt: {{message}}\",\n  \"files-network-storage-error.remove-share\": \"Verwijderen van netwerkshare mislukt: {{message}}\",\n  \"files-operations-island.copying\": \"Kopiëren van \\\"{{from}}\\\" naar \\\"{{to}}\\\"\",\n  \"files-operations-island.moving\": \"Verplaatsen van \\\"{{from}}\\\" naar \\\"{{to}}\\\"\",\n  \"files-operations-island.restoring\": \"Herstellen van \\\"{{from}}\\\" naar \\\"{{to}}\\\"\",\n  \"files-path.input-group\": \"Padinvoer\",\n  \"files-path.input-label\": \"Huidig pad\",\n  \"files-permanently-delete.confirm\": \"Definitief verwijderen\",\n  \"files-permanently-delete.description-multiple\": \"Weet je zeker dat je deze {{count}} items definitief wilt verwijderen? Dit kan niet ongedaan worden gemaakt.\",\n  \"files-permanently-delete.description-single\": \"Weet je zeker dat je \\\"{{fileName}}\\\" definitief wilt verwijderen? Dit kan niet ongedaan worden gemaakt.\",\n  \"files-permanently-delete.title-multiple\": \"{{count}} items definitief verwijderen?\",\n  \"files-permanently-delete.title-single\": \"Definitief verwijderen?\",\n  \"files-search.default\": \"Zoek naar bestanden en mappen\",\n  \"files-search.no-results\": \"Geen resultaten gevonden voor \\\"{{query}}\\\"\",\n  \"files-search.placeholder\": \"Zoeken\",\n  \"files-search.searching-label\": \"Zoeken in de Umbrel van {{name}}\",\n  \"files-share.home-description\": \"Toegang tot alle bestanden in \\\"{{homeDirectoryName}}\\\" vanaf andere apparaten in je netwerk\",\n  \"files-share.home-title\": \"\\\"{{homeDirectoryName}}\\\" delen over het netwerk\",\n  \"files-share.instructions.how-to-access\": \"Hoe krijg je toegang\",\n  \"files-share.instructions.ios.enter-password\": \"Voer <field>{{password}}</field> in als wachtwoord.\",\n  \"files-share.instructions.ios.enter-server\": \"Voer <field>{{smbUrl}}</field> in als serveradres.\",\n  \"files-share.instructions.ios.enter-username\": \"Voer <field>{{username}}</field> in als gebruikersnaam.\",\n  \"files-share.instructions.ios.install-files\": \"Installeer de app \\\"Bestanden\\\" uit de App Store als deze nog niet is geïnstalleerd.\",\n  \"files-share.instructions.ios.tap-connect\": \"Tik op \\\"Verbind\\\" om toegang te krijgen.\",\n  \"files-share.instructions.ios.tap-dots\": \"Tik op de drie puntjes (...) rechtsboven en kies \\\"Verbind met server\\\".\",\n  \"files-share.instructions.macos.click-connect\": \"Klik op \\\"Verbind\\\" om er toegang toe te krijgen.\",\n  \"files-share.instructions.macos.enter-password\": \"Voer <field>{{password}}</field> in als wachtwoord.\",\n  \"files-share.instructions.macos.enter-url\": \"Voer <field>{{smbUrl}}</field> in en klik op \\\"Verbind\\\".\",\n  \"files-share.instructions.macos.enter-username\": \"Voer <field>{{username}}</field> in als gebruikersnaam.\",\n  \"files-share.instructions.macos.open-finder\": \"Open \\\"Finder\\\" en druk op ⌘ + K.\",\n  \"files-share.instructions.macos.select-registered\": \"Selecteer \\\"Geregistreerde gebruiker\\\" wanneer daarom wordt gevraagd.\",\n  \"files-share.instructions.macos.time-machine\": \"Hoe te gebruiken als Time Machine-backuplocatie\",\n  \"files-share.instructions.macos.time-machine.choose-encryption\": \"Kies tussen een gecodeerde of niet-gecodeerde reservekopie.\",\n  \"files-share.instructions.macos.time-machine.disk-limit\": \"Geef bij 'Disk Usage Limit' op hoeveel ruimte je op je Umbrel wilt gebruiken voor Time Machine-reservekopieën en klik vervolgens op \\\"Gereed\\\".\",\n  \"files-share.instructions.macos.time-machine.follow-steps\": \"Volg de bovenstaande stappen en open Systeeminstellingen op je Mac.\",\n  \"files-share.instructions.macos.time-machine.go-settings\": \"Ga naar Time Machine en klik op \\\"Voeg reservekopieschijf toe...\\\".\",\n  \"files-share.instructions.macos.time-machine.select-disk\": \"Selecteer de map en klik op \\\"Set Up Disk...\\\".\",\n  \"files-share.instructions.umbrelos.backup.follow-onscreen\": \"Volg de stappen om je back-up in te stellen.\",\n  \"files-share.instructions.umbrelos.backup.follow-then-go-to\": \"Volg bovenstaande stappen en ga daarna op je andere Umbrel naar \\\"{{settings}}\\\" > \\\"{{backups}}\\\".\",\n  \"files-share.instructions.umbrelos.backup.select-add\": \"Selecteer de optie \\\"{{addUmbrelOrNas}}\\\".\",\n  \"files-share.instructions.umbrelos.backup.select-connected\": \"Selecteer dit Umbrel-apparaat in de lijst met verbonden apparaten.\",\n  \"files-share.instructions.umbrelos.backup.title\": \"Hoe te gebruiken als backuplocatie voor je andere Umbrel\",\n  \"files-share.instructions.umbrelos.cant-find-note\": \"Kun je het niet vinden? Probeer \\\"Handmatig toevoegen\\\" te selecteren en gebruik de volgende inloggegevens. Als je het nog steeds niet kunt toevoegen, controleer dan of beide apparaten op hetzelfde netwerk zitten.\",\n  \"files-share.instructions.umbrelos.enter-password\": \"Vul <field>{{password}}</field> in als wachtwoord.\",\n  \"files-share.instructions.umbrelos.enter-username\": \"Vul <field>{{username}}</field> in als gebruikersnaam.\",\n  \"files-share.instructions.umbrelos.open-and-click\": \"Open op je andere Umbrel \\\"Files\\\" en klik op <plus/> naast \\\"<deviceIcon/> {{deviceLabel}}\\\" in de zijbalk.\",\n  \"files-share.instructions.umbrelos.select-device\": \"Selecteer dit Umbrel-apparaat uit de lijst met automatisch gedetecteerde apparaten op je netwerk.\",\n  \"files-share.instructions.umbrelos.select-sharename\": \"Selecteer \\\"{{sharename}}\\\" en klik om de gedeelde map toe te voegen.\",\n  \"files-share.instructions.windows.enter-password\": \"Voer <field>{{password}}</field> in als wachtwoord.\",\n  \"files-share.instructions.windows.enter-url\": \"Typ <field>{{smbUrl}}</field> en druk op Enter.\",\n  \"files-share.instructions.windows.enter-username\": \"Voer <field>{{username}}</field> in als gebruikersnaam.\",\n  \"files-share.instructions.windows.open-run\": \"Druk op Windows + R om het Uitvoeren-venster te openen.\",\n  \"files-share.instructions.windows.remember-credentials\": \"Vink \\\"Remember my credentials\\\" aan en klik op OK.\",\n  \"files-share.regular-description\": \"Deel deze map om er vanaf andere apparaten in je netwerk toegang toe te krijgen\",\n  \"files-share.regular-title\": \"Map delen over het netwerk\",\n  \"files-share.toggle\": \"\\\"{{name}}\\\" delen over je netwerk\",\n  \"files-sidebar.apps\": \"Apps\",\n  \"files-sidebar.external-storage\": \"Externe opslag\",\n  \"files-sidebar.favorites\": \"Favorieten\",\n  \"files-sidebar.home\": \"Thuismap\",\n  \"files-sidebar.navigation\": \"Bestandsnavigatie\",\n  \"files-sidebar.network\": \"Netwerk\",\n  \"files-sidebar.network-pathbar\": \"Netwerkapparaten\",\n  \"files-sidebar.network-sidebar\": \"Apparaten\",\n  \"files-sidebar.recents\": \"Recente items\",\n  \"files-sidebar.shared-folders\": \"Gedeelde mappen\",\n  \"files-sidebar.trash\": \"Prullenmand\",\n  \"files-sidebar.trash.open\": \"Openen\",\n  \"files-sort.created\": \"Toegevoegd\",\n  \"files-sort.modified\": \"Gewijzigd\",\n  \"files-sort.name\": \"Naam\",\n  \"files-sort.size\": \"Grootte\",\n  \"files-sort.type\": \"Type\",\n  \"files-state.uploading\": \"Uploaden...\",\n  \"files-state.waiting\": \"Wachten...\",\n  \"files-type.3gp\": \"3GP-video\",\n  \"files-type.3gp2\": \"3GP2-video\",\n  \"files-type.7z\": \"7Z-archief\",\n  \"files-type.aac\": \"AAC-audio\",\n  \"files-type.ai\": \"Illustrator-bestand\",\n  \"files-type.aiff\": \"AIFF-audio\",\n  \"files-type.au\": \"AU-audio\",\n  \"files-type.avi\": \"AVI-video\",\n  \"files-type.avif\": \"AVIF-afbeelding\",\n  \"files-type.bmp\": \"BMP-afbeelding\",\n  \"files-type.bzip2\": \"BZIP2-archief\",\n  \"files-type.caf\": \"CAF-audio\",\n  \"files-type.compressed\": \"Gecomprimeerd archief\",\n  \"files-type.csv\": \"CSV-bestand\",\n  \"files-type.directory\": \"Map\",\n  \"files-type.dmg\": \"Schijfkopie\",\n  \"files-type.dv\": \"DV-video\",\n  \"files-type.epub\": \"EPUB-e-boek\",\n  \"files-type.excel\": \"Excel-werkblad\",\n  \"files-type.exe\": \"Windows-uitvoerbaar bestand\",\n  \"files-type.executable\": \"Uitvoerbaar bestand\",\n  \"files-type.external-drive\": \"Schijf\",\n  \"files-type.flac\": \"FLAC-audio\",\n  \"files-type.flv\": \"FLV-video\",\n  \"files-type.gif\": \"GIF-afbeelding\",\n  \"files-type.gzip\": \"GZIP-archief\",\n  \"files-type.heic\": \"HEIC-afbeelding\",\n  \"files-type.ico\": \"ICO-afbeelding\",\n  \"files-type.iso\": \"ISO-schijfkopie\",\n  \"files-type.jpeg\": \"JPEG-afbeelding\",\n  \"files-type.keynote\": \"Keynote-presentatie\",\n  \"files-type.lzip\": \"LZIP-archief\",\n  \"files-type.lzma\": \"LZMA-archief\",\n  \"files-type.lzop\": \"LZOP-archief\",\n  \"files-type.m3u\": \"M3U-afspeellijst\",\n  \"files-type.m4a\": \"M4A-audio\",\n  \"files-type.m4v\": \"M4V-video\",\n  \"files-type.midi\": \"MIDI-audio\",\n  \"files-type.mka\": \"MKA-audio\",\n  \"files-type.mkv\": \"MKV-video\",\n  \"files-type.mng\": \"MNG-video\",\n  \"files-type.mobi\": \"MOBI-e-boek\",\n  \"files-type.mp3\": \"MP3-audio\",\n  \"files-type.mp4\": \"MP4-video\",\n  \"files-type.mp4-audio\": \"MP4-audio\",\n  \"files-type.mpeg\": \"MPEG-video\",\n  \"files-type.mpeg-ts\": \"MPEG-transportstream\",\n  \"files-type.network-drive\": \"Netwerkschijf\",\n  \"files-type.numbers\": \"Numbers-werkblad\",\n  \"files-type.ogg\": \"OGG-audio\",\n  \"files-type.ogv\": \"OGV-video\",\n  \"files-type.pages\": \"Pages-document\",\n  \"files-type.pdf\": \"PDF-document\",\n  \"files-type.png\": \"PNG-afbeelding\",\n  \"files-type.powerpoint\": \"PowerPoint-presentatie\",\n  \"files-type.psd\": \"Photoshop-document\",\n  \"files-type.quicktime\": \"QuickTime-video\",\n  \"files-type.rar\": \"RAR-archief\",\n  \"files-type.sgi\": \"SGI-video\",\n  \"files-type.svg\": \"SVG-afbeelding\",\n  \"files-type.tar\": \"TAR-archief\",\n  \"files-type.tiff\": \"TIFF-afbeelding\",\n  \"files-type.ts\": \"TS-video\",\n  \"files-type.txt\": \"Tekstbestand\",\n  \"files-type.umbrel-backup\": \"Umbrel Back-up\",\n  \"files-type.wav\": \"WAV-audio\",\n  \"files-type.webm\": \"WebM-video\",\n  \"files-type.webm-audio\": \"WebM-audio\",\n  \"files-type.webp\": \"WebP-afbeelding\",\n  \"files-type.wma\": \"WMA-audio\",\n  \"files-type.wmv\": \"WMV-video\",\n  \"files-type.word\": \"Word-document\",\n  \"files-type.xz\": \"XZ-archief\",\n  \"files-type.zip\": \"ZIP-archief\",\n  \"files-upload-island.uploading-count\": \"Bezig met uploaden van {{count}} items\",\n  \"files-view.icons\": \"Pictogrammen\",\n  \"files-view.list\": \"Lijst\",\n  \"files-view.sort-by\": \"Sorteren op\",\n  \"files-view.view-as\": \"Weergeven als\",\n  \"files-widgets.favorites.no-items-text\": \"Voeg een map toe aan je favorieten om deze hier te zien\",\n  \"files-widgets.recents.no-items-text\": \"Geen recente bestanden\",\n  \"generic-in\": \"in\",\n  \"hide-details\": \"Verberg details\",\n  \"install-first.install-app\": \"Installeer {{app}}\",\n  \"install-first.title\": \"{{app}} vereist deze apps\",\n  \"install-your-first-app\": \"Installeer je eerste app\",\n  \"language\": \"Taal\",\n  \"language-description\": \"Je voorkeurstaal voor umbrelOS\",\n  \"language.select-description\": \"Selecteer voorkeurstaal voor umbrelOS\",\n  \"live-usage\": \"Live Usage\",\n  \"loading\": \"Laden\",\n  \"local-ip\": \"Lokale IP\",\n  \"login-2fa.subtitle\": \"Voer de 2FA-code in die wordt weergegeven in je authenticator-app\",\n  \"login-2fa.title\": \"Authenticeren\",\n  \"login-with-umbrel.description\": \"Voer je Umbrel wachtwoord in om {{app}} te openen\",\n  \"login-with-umbrel.title\": \"Inloggen met Umbrel\",\n  \"login.password-label\": \"Wachtwoord\",\n  \"login.password.submit\": \"Inloggen\",\n  \"login.subtitle\": \"Voer je Umbrel wachtwoord in om in te loggen\",\n  \"login.title\": \"Welkom terug\",\n  \"logout\": \"Uitloggen\",\n  \"logout-error-generic\": \"Fout: Uitloggen mislukt\",\n  \"logout.confirm.submit\": \"Uitloggen\",\n  \"logout.confirm.title\": \"Weet je zeker dat je wilt uitloggen?\",\n  \"memory\": \"Geheugen\",\n  \"memory.low\": \"Weinig geheugen\",\n  \"migrate\": \"Migreren\",\n  \"migrate.callout\": \"Zet je Umbrel niet uit tot de migratie voltooid is\",\n  \"migrate.failed.retry\": \"Probeer opnieuw\",\n  \"migrate.failed.title\": \"Migratie mislukt\",\n  \"migrate.success.description\": \"Al je apps, app-gegevens en accountgegevens zijn gemigreerd naar je Umbrel Home.\",\n  \"migrate.success.title\": \"Migratie succesvol\",\n  \"migration-assistant\": \"Migratieassistent\",\n  \"migration-assistant-description\": \"Verplaats al je apps en gegevens van een Raspberry Pi naar {{deviceName}}\",\n  \"migration-assistant-unsupported-device-description\": \"Migration Assistant ondersteunt momenteel het overzetten van alle gegevens en apps van een Raspberry Pi met umbrelOS naar Umbrel Home of Umbrel Pro. Open Migration Assistant op je Umbrel Home of Umbrel Pro om te beginnen.\",\n  \"migration-assistant.continue-migration.ready.submit\": \"Start migratie\",\n  \"migration-assistant.failed\": \"Er klopt iets niet...\",\n  \"migration-assistant.failed.retrying-message\": \"Opnieuw proberen...\",\n  \"migration-assistant.mobile.start-button\": \"Start migratie\",\n  \"migration-assistant.prep.body\": \"Voorbereiden voor migratie\",\n  \"migration-assistant.prep.button-continue\": \"Doorgaan\",\n  \"migration-assistant.prep.callout\": \"De gegevens op je {{deviceName}}, indien aanwezig, worden permanent verwijderd.\",\n  \"migration-assistant.prep.connect-disk-to-home\": \"Sluit de externe schijf ervan aan op een willekeurige USB-poort van je {{deviceName}}.\",\n  \"migration-assistant.prep.prep-done-continue-message\": \"Klik op '{{button}}' hieronder als je klaar bent.\",\n  \"migration-assistant.prep.shut-down-rpi\": \"Zet je Raspberry Pi Umbrel uit.\",\n  \"migration-assistant.ready.description\": \"Al je gegevens en apps zijn klaar om naar je {{deviceName}} overgezet te worden.\",\n  \"migration-assistant.ready.hint-header\": \"Dingen om in gedachten te houden\",\n  \"migration-assistant.ready.hint-keep-pi-off.description\": \"Dit helpt problemen met apps zoals Lightning Node te voorkomen\",\n  \"migration-assistant.ready.hint-keep-pi-off.title\": \"Houd je Raspberry Pi uit na de update\",\n  \"migration-assistant.ready.hint-use-same-password.description\": \"Vergeet niet het Umbrel-wachtwoord van je Raspberry Pi te gebruiken om in te loggen op je {{deviceName}}\",\n  \"migration-assistant.ready.hint-use-same-password.title\": \"Gebruik hetzelfde wachtwoord\",\n  \"migration-assistant.ready.title\": \"Je bent klaar voor migratie!\",\n  \"mini-browser.default-title\": \"Map selecteren\",\n  \"mini-browser.empty-external\": \"Sluit een externe schijf aan zodat deze hier verschijnt.\",\n  \"mini-browser.empty-network\": \"Voeg een Umbrel of NAS toe zodat deze hier verschijnt.\",\n  \"mini-browser.load-more\": \"Meer laden\",\n  \"mini-browser.load-more-in-folder\": \"Meer laden in {{name}}\",\n  \"mini-browser.loading-more\": \"Meer laden…\",\n  \"mini-browser.select\": \"Selecteer\",\n  \"mini-browser.select-folder\": \"Map selecteren\",\n  \"name\": \"Naam\",\n  \"nas\": \"NAS\",\n  \"no-forgot-password-message\": \"Als je je wachtwoord verliest, kun je niet inloggen op je Umbrel. Zorg ervoor dat je het veilig bewaart.\",\n  \"no-results-found\": \"Geen resultaten gevonden\",\n  \"not-found-404\": \"Foutcode: 404\",\n  \"not-found-404.back\": \"Terug\",\n  \"not-found-404.home\": \"Ga naar Home\",\n  \"notifications.backups-failing-location.description\": \"Automatische Backups naar {{location}} slagen niet. Controleer de verbinding en bekijk je Backups-instellingen.\",\n  \"notifications.backups-failing.description\": \"Automatische Backups zijn mislukt. Controleer je Backups-locatie en bekijk je instellingen.\",\n  \"notifications.backups-failing.go-to-backups\": \"Ga naar Backups\",\n  \"notifications.backups-failing.title\": \"Geen Backups in de afgelopen 24 uur\",\n  \"notifications.cpu.too-hot\": \"Hoge CPU-temperatuur\",\n  \"notifications.memory.low\": \"Het geheugen van je apparaat is laag\",\n  \"notifications.new-version-available\": \"{{update}} is nu beschikbaar om te installeren\",\n  \"notifications.raid.issue.description\": \"Er is een opslagprobleem gedetecteerd. Kijk in Opslagbeheer voor details.\",\n  \"notifications.raid.issue.title\": \"Dringende actie vereist\",\n  \"notifications.ssd.health.description\": \"Een of meer SSD's hebben mogelijk aandacht nodig. Kijk in Opslagbeheer voor details.\",\n  \"notifications.ssd.health.title\": \"Waarschuwing: SSD-gezondheid\",\n  \"notifications.storage.full\": \"De opslag van je apparaat is vol\",\n  \"notifications.view\": \"Bekijken\",\n  \"ok\": \"Oké\",\n  \"onboarding.account-created.by-clicking-button-you-agree\": \"Door op 'Volgende' te klikken, ga je akkoord met de <linked>umbrelOS Servicevoorwaarden</linked>\",\n  \"onboarding.account-created.youre-all-set-name\": \"Je bent helemaal klaar, {{name}}.\",\n  \"onboarding.contact-support\": \"Ondersteuning\",\n  \"onboarding.create-account\": \"Account aanmaken\",\n  \"onboarding.create-account.confirm-password.input-label\": \"Bevestig wachtwoord\",\n  \"onboarding.create-account.failed.name-required\": \"Naam is vereist\",\n  \"onboarding.create-account.failed.passwords-dont-match\": \"Wachtwoorden komen niet overeen\",\n  \"onboarding.create-account.name.input-placeholder\": \"Jouw naam\",\n  \"onboarding.create-account.password.input-label\": \"Wachtwoord\",\n  \"onboarding.create-account.submit\": \"Aanmaken\",\n  \"onboarding.create-account.submitting\": \"Wordt aangemaakt\",\n  \"onboarding.create-account.subtitle\": \"Je accountgegevens worden alleen op je Umbrel opgeslagen. Zorg ervoor dat je je wachtwoord veilig back-upt, want er is geen manier om het te resetten.\",\n  \"onboarding.create-instead-long\": \"Maak een nieuw account aan\",\n  \"onboarding.create-instead-short\": \"Nieuw account\",\n  \"onboarding.launch-umbrelos\": \"Start umbrelOS\",\n  \"onboarding.raid.available-storage\": \"Beschikbare opslag\",\n  \"onboarding.raid.change-drives-link\": \"Wil je schijven toevoegen of vervangen?\",\n  \"onboarding.raid.configuring.subtitle\": \"Dit kan een paar minuten duren.\",\n  \"onboarding.raid.configuring.title\": \"Je opslag configureren\",\n  \"onboarding.raid.configuring.warning\": \"Ververs deze pagina niet en zet je Umbrel niet uit terwijl je opslag wordt geconfigureerd.\",\n  \"onboarding.raid.continue\": \"Doorgaan\",\n  \"onboarding.raid.error.detection-instructions\": \"Zet Umbrel Pro uit, controleer of je SSD's goed zijn geplaatst en probeer het opnieuw.\",\n  \"onboarding.raid.error.no-ssds-detected\": \"Geen SSD's gevonden\",\n  \"onboarding.raid.error.no-ssds-instructions\": \"Zet Umbrel Pro uit en plaats minstens één SSD om door te gaan.\",\n  \"onboarding.raid.failsafe\": \"FailSafe\",\n  \"onboarding.raid.failsafe.cant-enable\": \"Je kunt FailSafe nog niet inschakelen\",\n  \"onboarding.raid.failsafe.enable\": \"FailSafe inschakelen\",\n  \"onboarding.raid.failsafe.mixed-sizes\": \"FailSafe wordt beperkt door je kleinste SSD ({{smallest}}). Extra ruimte op grotere SSD's kan niet worden gebruikt, waardoor {{wasted}} niet bruikbaar is.\",\n  \"onboarding.raid.failsafe.protection-info-2ssds\": \"{{protection}} wordt gebruikt voor gegevensbescherming. Voeg nog een {{smallest}} SSD toe om de beschikbare opslag te verhogen naar {{futureWith3}}, of voeg er twee extra toe voor {{futureWith4}}. Je kunt op elk moment meer SSD's toevoegen.\",\n  \"onboarding.raid.failsafe.protection-info-3ssds\": \"{{protection}} wordt gebruikt voor gegevensbescherming. Voeg nog een {{smallest}} SSD toe om de beschikbare opslag te verhogen naar {{futureWith4}}. Je kunt op elk moment meer SSD's toevoegen.\",\n  \"onboarding.raid.failsafe.single-ssd-info\": \"Je hebt maar één SSD. Voeg minstens één extra {{size}} SSD toe om FailSafe-bescherming voor je gegevens in te schakelen. Je kunt op elk moment meer SSD's toevoegen.\",\n  \"onboarding.raid.failsafe.subtitle\": \"Je data blijft veilig als één SSD uitvalt\",\n  \"onboarding.raid.failsafe.tip\": \"Gebruik SSD's van gelijke grootte voor maximale opslag en geen onbruikbare ruimte.\",\n  \"onboarding.raid.failsafe.warning-now-only\": \"Bij meer dan één SSD kan FailSafe alleen tijdens de eerste installatie worden ingeschakeld. Je kunt het later niet meer inschakelen.\",\n  \"onboarding.raid.health-warning\": \"Deze schijf meldt gezondheidsproblemen\",\n  \"onboarding.raid.launching\": \"Starten...\",\n  \"onboarding.raid.no-ssds-alt\": \"Geen SSD's gevonden\",\n  \"onboarding.raid.recommended\": \"Aanbevolen\",\n  \"onboarding.raid.scanning\": \"Je SSD-sleuven controleren\",\n  \"onboarding.raid.scanning-alt\": \"SSD's scannen\",\n  \"onboarding.raid.setup-failed.description-no-retry\": \"Zet Umbrel Pro uit en probeer het opnieuw.\",\n  \"onboarding.raid.setup-failed.description-retry\": \"Probeer het opnieuw, of zet Umbrel Pro uit om je schijven te controleren.\",\n  \"onboarding.raid.setup-failed.title\": \"Opslagconfiguratie mislukt\",\n  \"onboarding.raid.shutdown-dialog.description\": \"Om schijven toe te voegen of te vervangen, zet Umbrel Pro uit. Zodra je klaar bent, kun je hem weer aanzetten en doorgaan met de installatie.\",\n  \"onboarding.raid.shutdown-dialog.title\": \"Schijven wijzigen?\",\n  \"onboarding.raid.ssd-in-slot\": \"Eén <highlight>{{size}}</highlight> SSD in <highlight>Slot {{slot}}</highlight>\",\n  \"onboarding.raid.ssd-label\": \"SSD {{number}}\",\n  \"onboarding.raid.ssd-tray-alt\": \"SSD-lade\",\n  \"onboarding.raid.ssds-found\": \"De volgende SSD's zijn gevonden in je Umbrel Pro\",\n  \"onboarding.raid.storage\": \"Opslag\",\n  \"onboarding.raid.storage-label\": \"Opslag\",\n  \"onboarding.raid.success.storage-info\": \"Opslag {{available}}\",\n  \"onboarding.raid.success.storage-info-failsafe\": \"Opslag {{available}} · FailSafe {{failsafe}}\",\n  \"onboarding.raid.try-again\": \"Opnieuw proberen\",\n  \"onboarding.raid.wasted\": \"Niet bruikbaar\",\n  \"onboarding.restore-long\": \"Herstel mijn Umbrel\",\n  \"onboarding.restore-short\": \"Herstellen\",\n  \"onboarding.start.continue\": \"Aan de slag\",\n  \"onboarding.start.subtitle\": \"Je thuis-cloudserver is klaar voor installatie.\",\n  \"onboarding.start.title\": \"Welkom bij umbrelOS\",\n  \"open\": \"Openen\",\n  \"open-live-usage\": \"Open Live Usage\",\n  \"password\": \"Wachtwoord\",\n  \"preferences\": \"Voorkeuren\",\n  \"raid-error.description\": \"Je opslag systeem kon niet goed starten. Controleer hieronder de status van je SSD's en volg de stappen voor probleemoplossing. Als het probleem blijft bestaan, moeten mogelijk de aangedane SSD's worden vervangen.\",\n  \"raid-error.factory-reset-dialog.description\": \"Dit wist alle data op je Umbrel Pro en zet hem terug naar de fabrieksinstellingen. Deze actie kan niet ongedaan worden gemaakt.\",\n  \"raid-error.factory-reset-dialog.title\": \"Fabrieksreset?\",\n  \"raid-error.factory-reset-failed\": \"Kon de fabrieksreset niet uitvoeren\",\n  \"raid-error.health-warning\": \"Gezondheidswaarschuwing\",\n  \"raid-error.missing-ssd-multiple\": \"{{count}} SSD's reageren niet\",\n  \"raid-error.missing-ssd-one\": \"1 SSD reageert niet\",\n  \"raid-error.shutdown-dialog.description\": \"Schakel je Umbrel Pro uit, zorg dat alle SSD's goed in hun sleuven zitten en zet hem daarna weer aan.\",\n  \"raid-error.shutdown-dialog.title\": \"Uitschakelen om schijven te controleren?\",\n  \"raid-error.ssd-in-slot\": \"One <highlight>{{size}}</highlight> SSD in <highlight>Slot {{slot}}</highlight>\",\n  \"raid-error.step-check-connections.button\": \"Uitschakelen\",\n  \"raid-error.step-check-connections.description\": \"Schakel uit en controleer of alle SSD's goed zijn geplaatst.\",\n  \"raid-error.step-check-connections.title\": \"Controleer SSD-verbindingen\",\n  \"raid-error.step-factory-reset.button\": \"Fabrieksreset\",\n  \"raid-error.step-factory-reset.description\": \"Laatste redmiddel als niets anders werkt. Dit zal alle gegevens wissen.\",\n  \"raid-error.step-factory-reset.title\": \"Fabrieksreset\",\n  \"raid-error.step-restart.button\": \"Opnieuw opstarten\",\n  \"raid-error.step-restart.description\": \"Een snelle eerste stap die vaak helpt\",\n  \"raid-error.step-restart.title\": \"Probeer opnieuw op te starten\",\n  \"raid-error.title\": \"Opslagprobleem gedetecteerd\",\n  \"read-less\": \"Lees minder\",\n  \"read-more\": \"Lees meer\",\n  \"reconnect\": \"Opnieuw verbinden\",\n  \"redirect.to-home\": \"Laden...\",\n  \"redirect.to-login\": \"Laden...\",\n  \"redirect.to-onboarding\": \"Laden...\",\n  \"redirect.to-raid-error\": \"Bezig met laden...\",\n  \"reload\": \"Opnieuw laden\",\n  \"remote-tor-access\": \"Toegang op afstand via Tor\",\n  \"reset\": \"Reset\",\n  \"restart\": \"Herstarten\",\n  \"restart.confirm.submit\": \"Herstart\",\n  \"restart.confirm.title\": \"Weet je zeker dat je je Umbrel opnieuw wilt opstarten?\",\n  \"restart.restarting\": \"Opnieuw opstarten\",\n  \"restart.restarting-message\": \"Vernieuw deze pagina niet of zet je Umbrel niet uit terwijl het opnieuw opstart.\",\n  \"rewind\": \"Rewind\",\n  \"rewind.files-as-of\": \"Je bestanden per\",\n  \"rewind.loading-snapshots\": \"Snapshots laden...\",\n  \"rewind.now\": \"Nu\",\n  \"rewind.preflight.description\": \"Vind bestanden en mappen uit je eerdere back-ups en zet ze terug naar het heden.\",\n  \"rewind.preflight.enable-backups\": \"Stel Back-ups in via Instellingen om Rewind te gebruiken\",\n  \"rewind.restore-complete\": \"Herstel voltooid\",\n  \"rewind.restore-error-description\": \"Probeer het opnieuw.\",\n  \"rewind.restore-failed\": \"Herstel mislukt\",\n  \"rewind.restore-running-description\": \"Sluit of ververs deze pagina niet totdat het herstel is voltooid\",\n  \"rewind.restore-selected\": \"Geselecteerde items herstellen\",\n  \"rewind.restore-success-description\": \"Je bestanden zijn hersteld\",\n  \"rewind.restoring\": \"Bezig met herstellen\",\n  \"rewind.snapshots-count_one\": \"{{count}} back-up sinds\",\n  \"rewind.snapshots-count_other\": \"{{count}} back-ups sinds\",\n  \"search\": \"Zoeken\",\n  \"settings\": \"Instellingen\",\n  \"settings.app-store-preferences.title\": \"App Store voorkeuren\",\n  \"settings.contact-support\": \"Hulp nodig? <linked>Contact opnemen met ondersteuning.</linked>\",\n  \"settings.file-sharing\": \"Bestandsdeling\",\n  \"settings.file-sharing.add-folder\": \"Toevoegen\",\n  \"settings.file-sharing.add-folder-title\": \"Selecteer een map om te delen\",\n  \"settings.file-sharing.choice-entire-description\": \"Deel alle bestanden op je Umbrel\",\n  \"settings.file-sharing.choice-entire-title\": \"Alles\",\n  \"settings.file-sharing.choice-heading\": \"Wat wil je delen?\",\n  \"settings.file-sharing.choice-specific-description\": \"Kies welke mappen je wilt delen\",\n  \"settings.file-sharing.choice-specific-title\": \"Specifieke mappen\",\n  \"settings.file-sharing.choice-subtitle\": \"Toegang tot je bestanden en mappen, net als in Dropbox, als netwerkmappen op je computer of telefoon\",\n  \"settings.file-sharing.configure\": \"Instellen\",\n  \"settings.file-sharing.description\": \"Toegang tot je bestanden, zoals in Dropbox, als netwerkmap (SMB) op andere apparaten\",\n  \"settings.file-sharing.home-shared-note\": \"Je hele map \\\"{{homeDirectoryName}}\\\" is gedeeld. Individuele mappen hoeven niet apart gedeeld te worden.\",\n  \"settings.file-sharing.share-entire-home-dir\": \"Deel je hele thuismap\",\n  \"settings.file-sharing.share-entire-home-dir-description\": \"Toegang tot alle bestanden en mappen in \\\"{{homeDirectoryName}}\\\" vanaf andere apparaten op je netwerk\",\n  \"settings.file-sharing.shared-folders\": \"Gedeelde mappen\",\n  \"show-details\": \"Toon details\",\n  \"shut-down\": \"Afsluiten\",\n  \"shut-down.complete\": \"Afsluiten voltooid\",\n  \"shut-down.complete-text\": \"Je kunt je apparaat nu loskoppelen van de stroom.\",\n  \"shut-down.confirm.submit\": \"Afsluiten\",\n  \"shut-down.confirm.title\": \"Weet je zeker dat je je Umbrel wilt afsluiten?\",\n  \"shut-down.failed\": \"Afsluiten mislukt: {{message}}\",\n  \"shut-down.shutting-down\": \"Afsluiten\",\n  \"shut-down.shutting-down-message\": \"Vernieuw deze pagina niet of zet je Umbrel niet uit terwijl het afsluit.\",\n  \"software-update.callout\": \"Vernieuw deze pagina niet of zet je Umbrel niet uit terwijl het aan het updaten is.\",\n  \"software-update.check\": \"Controleer op update\",\n  \"software-update.checking\": \"Controleren op updates...\",\n  \"software-update.current-running\": \"Je gebruikt\",\n  \"software-update.failed\": \"Update mislukt\",\n  \"software-update.failed-to-check\": \"Kon niet controleren op updates\",\n  \"software-update.failed.retry\": \"Opnieuw proberen\",\n  \"software-update.install-now\": \"Nu installeren\",\n  \"software-update.new-version\": \"Nieuwe versie {{name}} is beschikbaar om te installeren\",\n  \"software-update.on-latest\": \"Je gebruikt de nieuwste versie van umbrelOS\",\n  \"software-update.see-whats-new\": \"Bekijk <linked>wat er nieuw is</linked>\",\n  \"software-update.title\": \"Software-update\",\n  \"software-update.updating-to\": \"Update naar {{name}}\",\n  \"software-update.view\": \"Bekijken\",\n  \"something-left\": \"{{left}} over\",\n  \"something-went-wrong\": \"⚠ Er ging iets mis\",\n  \"start\": \"Starten\",\n  \"stop\": \"Stoppen\",\n  \"storage\": \"Opslag\",\n  \"storage-manager\": \"Opslagbeheer\",\n  \"storage-manager.add\": \"Toevoegen\",\n  \"storage-manager.add-to-raid.add-ssd\": \"SSD toevoegen\",\n  \"storage-manager.add-to-raid.available\": \"Beschikbaar:\",\n  \"storage-manager.add-to-raid.description\": \"Er is een nieuwe SSD gedetecteerd en deze is klaar om toe te voegen.\",\n  \"storage-manager.add-to-raid.enable-failsafe\": \"FailSafe inschakelen\",\n  \"storage-manager.add-to-raid.failed-add\": \"Kon de SSD niet toevoegen\",\n  \"storage-manager.add-to-raid.failed-enable-failsafe\": \"Kon FailSafe niet inschakelen\",\n  \"storage-manager.add-to-raid.failsafe-label\": \"FailSafe:\",\n  \"storage-manager.add-to-raid.info-capacity-added\": \"Je nieuwe <highlight>{{size}}</highlight> SSD wordt toegevoegd aan de beschikbare opslag.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-available\": \"Je nieuwe <highlight>{{size}}</highlight> SSD voegt <highlight>{{available}}</highlight> toe aan de beschikbare opslag.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-both\": \"Je nieuwe <highlight>{{size}}</highlight> SSD voegt <highlight>{{available}}</highlight> toe aan de beschikbare opslag en <highlight>{{protection}}</highlight> voor gegevensbescherming.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only\": \"Je nieuwe <highlight>{{size}}</highlight> SSD voegt <highlight>{{protection}}</highlight> toe voor gegevensbescherming.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only-full\": \"Je nieuwe <highlight>{{size}}</highlight> SSD wordt volledig gebruikt voor gegevensbescherming.\",\n  \"storage-manager.add-to-raid.info-data-safe\": \"Je gegevens zijn veilig als één SSD uitvalt.\",\n  \"storage-manager.add-to-raid.info-no-protection\": \"Als een SSD uitvalt, kun je je gegevens kwijtraken.\",\n  \"storage-manager.add-to-raid.info-total-wasted\": \"<wasted>{{size}}</wasted> in totaal niet bruikbaar door verschillende SSD-groottes.\",\n  \"storage-manager.add-to-raid.info-wasted\": \"<wasted>{{size}}</wasted> zal niet bruikbaar zijn vanwege verschillende SSD-groottes.\",\n  \"storage-manager.add-to-raid.recommended\": \"Aanbevolen\",\n  \"storage-manager.add-to-raid.recommended-inline\": \"(aanbevolen)\",\n  \"storage-manager.add-to-raid.restart-active-tasks\": \"Eventuele actieve taken worden onderbroken\",\n  \"storage-manager.add-to-raid.restart-after\": \"Na de herstart voltooit de FailSafe-configuratie automatisch en kun je de normale werking hervatten.\",\n  \"storage-manager.add-to-raid.restart-during\": \"Tijdens de herstart:\",\n  \"storage-manager.add-to-raid.restart-intro\": \"Je kunt umbrelOS normaal blijven gebruiken tijdens dit proces. Bij 50% voortgang start je Umbrel automatisch opnieuw op.\",\n  \"storage-manager.add-to-raid.restart-required\": \"Systeemherstart vereist\",\n  \"storage-manager.add-to-raid.restart-ui-inaccessible\": \"umbrelOS is tijdelijk niet toegankelijk\",\n  \"storage-manager.add-to-raid.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD in <highlight>Slot {{slot}}</highlight>\",\n  \"storage-manager.add-to-raid.title\": \"SSD toevoegen aan opslag\",\n  \"storage-manager.add-to-raid.too-small\": \"SSD te klein\",\n  \"storage-manager.add-to-raid.too-small-description\": \"Deze SSD ({{deviceSize}}) is kleiner dan de kleinste momenteel geïnstalleerde SSD ({{minSize}}). FailSafe vereist dat alle SSD's minstens even groot zijn als de kleinste gebruikte SSD.\",\n  \"storage-manager.add-to-raid.understand-continue\": \"Ik begrijp het, ga door\",\n  \"storage-manager.add-to-raid.warning-failsafe-now-only\": \"Als je meer dan één SSD hebt, kan FailSafe nu alleen nog worden ingeschakeld. Je kunt het later niet meer inschakelen.\",\n  \"storage-manager.add-to-raid.wasted-label\": \"Niet bruikbaar:\",\n  \"storage-manager.available-storage\": \"Beschikbare opslag\",\n  \"storage-manager.description\": \"Bekijk opslag, gezondheid en instellingen van je SSD's\",\n  \"storage-manager.empty\": \"Leeg\",\n  \"storage-manager.failsafe-transition-failed\": \"Kon FailSafe niet inschakelen\",\n  \"storage-manager.for-failsafe\": \"Voor FailSafe\",\n  \"storage-manager.health.checksum-errors\": \"Checksum-fouten: {{count}}\",\n  \"storage-manager.health.critical\": \"Kritiek\",\n  \"storage-manager.health.critical-threshold\": \"Kritieke drempel\",\n  \"storage-manager.health.current-temperature\": \"Huidige temperatuur\",\n  \"storage-manager.health.estimated-life\": \"Geschatte resterende levensduur\",\n  \"storage-manager.health.general\": \"Algemeen\",\n  \"storage-manager.health.health-status\": \"Status\",\n  \"storage-manager.health.low\": \"Laag\",\n  \"storage-manager.health.model-and-capacity\": \"Model & capaciteit\",\n  \"storage-manager.health.overheating\": \"Oververhitting\",\n  \"storage-manager.health.raid-failed-advice\": \"Deze SSD heeft een probleem. Schakel je Umbrel uit en controleer de SSD-verbinding. Als het probleem blijft, moet de SSD mogelijk worden vervangen.\",\n  \"storage-manager.health.read-errors\": \"Leesfouten: {{count}}\",\n  \"storage-manager.health.serial-number\": \"Serienummer\",\n  \"storage-manager.health.status-healthy\": \"Gezond\",\n  \"storage-manager.health.status-unhealthy\": \"Ongezond\",\n  \"storage-manager.health.status-unknown\": \"Onbekend\",\n  \"storage-manager.health.temperature\": \"Temperatuur\",\n  \"storage-manager.health.title\": \"SSD-status\",\n  \"storage-manager.health.warning-life-advice\": \"Overweeg deze SSD binnenkort te vervangen.\",\n  \"storage-manager.health.warning-life-message\": \"Nog maar {{percent}}% levensduur over\",\n  \"storage-manager.health.warning-temp-advice\": \"Zorg dat je Umbrel Pro goede luchtstroom heeft en dat de SSD goed is geplaatst.\",\n  \"storage-manager.health.warning-temp-critical\": \"Temperatuur is kritiek ({{temperature}})\",\n  \"storage-manager.health.warning-temp-overheating\": \"Schijf oververhit ({{temperature}})\",\n  \"storage-manager.health.warning-threshold\": \"Waarschuwingsdrempel\",\n  \"storage-manager.health.warning-unhealthy-advice\": \"Deze SSD kan binnenkort uitvallen. Overweeg deze te vervangen.\",\n  \"storage-manager.health.warning-unhealthy-message\": \"Deze SSD heeft mogelijk een probleem\",\n  \"storage-manager.health.warnings\": \"Waarschuwingen\",\n  \"storage-manager.health.wear\": \"Slijtage\",\n  \"storage-manager.health.write-errors\": \"Schrijffouten: {{count}}\",\n  \"storage-manager.install-ssd.description\": \"Voeg meer SSD's toe om je opslag uit te breiden\",\n  \"storage-manager.install-ssd.step-insert\": \"Plaats nieuwe SSD's in de lege sleuven\",\n  \"storage-manager.install-ssd.step-power-on\": \"Zet je {{deviceName}} aan\",\n  \"storage-manager.install-ssd.step-remove-bottom-cover\": \"Verwijder de magnetische bodemkap\",\n  \"storage-manager.install-ssd.step-replace-bottom-cover\": \"Plaats de bodemplaat terug\",\n  \"storage-manager.install-ssd.step-return\": \"Kom hier terug om de SSD's aan je opslag toe te voegen\",\n  \"storage-manager.install-ssd.step-shut-down\": \"Schakel je {{deviceName}} uit\",\n  \"storage-manager.install-ssd.title\": \"SSD's toevoegen\",\n  \"storage-manager.install-tips.image-alt\": \"SSD-installatie instructie\",\n  \"storage-manager.install-tips.instructions\": \"Om te installeren: verwijder de duimschroef en schuif de SSD onder een hoek in de sleuf. Druk de SSD naar beneden totdat deze rust op de schroefpilaar en zet hem vast met de duimschroef.\",\n  \"storage-manager.install-tips.toggle\": \"Weet je niet meer hoe je een SSD moet plaatsen?\",\n  \"storage-manager.manage\": \"Beheren\",\n  \"storage-manager.missing-ssd-warning\": \"Het lijkt alsof er een SSD ontbreekt. Schakel je Umbrel uit en controleer of alle SSD's aangesloten zijn. Als het probleem blijft, moet de SSD mogelijk worden vervangen.\",\n  \"storage-manager.mode\": \"Modus\",\n  \"storage-manager.mode.failsafe\": \"FailSafe\",\n  \"storage-manager.mode.failsafe.description\": \"Houdt je gegevens veilig als een SSD uitvalt. Als je SSD's verschillende groottes hebben, blijft extra ruimte op de grotere ongebruikt.\",\n  \"storage-manager.mode.failsafe.info-description\": \"FailSafe beschermt je gegevens door kopieën over je SSD's te bewaren. Als één SSD uitvalt, blijven je gegevens veilig en kunnen ze worden hersteld wanneer je een vervangende SSD toevoegt.\",\n  \"storage-manager.mode.failsafe.info-title\": \"Over FailSafe\",\n  \"storage-manager.mode.full-storage\": \"Volledige opslag\",\n  \"storage-manager.mode.full-storage.description\": \"Gebruik al je SSD-ruimte samen. Als een SSD uitvalt, kun je je gegevens kwijtraken.\",\n  \"storage-manager.mode.full-storage.info-description\": \"Full Storage combineert al je SSD's tot één grote ruimte voor maximale opslag. Als één SSD uitvalt, gaan al je gegevens verloren.\",\n  \"storage-manager.mode.full-storage.info-title\": \"Over Volledige opslag\",\n  \"storage-manager.mode.switch-from-failsafe-unavailable\": \"Overschakelen van FailSafe naar Full Storage vereist dat je je gegevens back-upt, het apparaat terugzet naar de fabrieksinstellingen en vervolgens herstelt vanaf een back-up.\",\n  \"storage-manager.mode.switch-to-failsafe-unavailable\": \"Met meerdere SSD's in Full Storage-modus zijn je gegevens verspreid over alle schijven. Overschakelen naar FailSafe vereist dat je je gegevens back-upt, het apparaat naar fabrieksinstellingen terugzet en herstelt.\",\n  \"storage-manager.mode.why-cant-switch\": \"Waarom kan ik niet overschakelen?\",\n  \"storage-manager.operation-in-progress.shutdown-description\": \"Het is veilig om uit te schakelen. De bewerking wordt gepauzeerd en hervat na het opnieuw opstarten, maar moet voltooid zijn voordat je andere wijzigingen kunt aanbrengen.\",\n  \"storage-manager.operation-in-progress.shutdown-title\": \"Je opslag wordt bijgewerkt\",\n  \"storage-manager.operation-in-progress.wait-description\": \"Wacht tot de huidige bewerking is voltooid voordat je meer wijzigingen aanbrengt.\",\n  \"storage-manager.operation-in-progress.wait-title\": \"Je opslag wordt bijgewerkt\",\n  \"storage-manager.operation.adding-ssd\": \"SSD toevoegen...\",\n  \"storage-manager.operation.enabling-failsafe\": \"FailSafe inschakelen...\",\n  \"storage-manager.operation.expanding\": \"Opslag uitbreiden...\",\n  \"storage-manager.operation.rebuilding\": \"Gegevens herstellen...\",\n  \"storage-manager.operation.replacing\": \"Schijf vervangen...\",\n  \"storage-manager.operation.restarting\": \"Opnieuw opstarten...\",\n  \"storage-manager.operation.starting\": \"Starten...\",\n  \"storage-manager.operation.syncing-restarts\": \"Data synchroniseren • Herstart bij 50%\",\n  \"storage-manager.raid-status.degraded\": \"Gedegradeerd\",\n  \"storage-manager.raid-status.failed\": \"Uitgevallen\",\n  \"storage-manager.raid-status.offline\": \"Offline\",\n  \"storage-manager.raid-status.online\": \"Online\",\n  \"storage-manager.raid-status.removed\": \"Verwijderd\",\n  \"storage-manager.raid-status.unavailable\": \"Niet beschikbaar\",\n  \"storage-manager.replace\": \"Vervangen\",\n  \"storage-manager.replace-failed.degraded\": \"FailSafe-bescherming verminderd\",\n  \"storage-manager.replace-failed.degraded-description\": \"Er ontbreekt een SSD in je FailSafe-opslag. Vervang deze om de volledige bescherming te herstellen.\",\n  \"storage-manager.replace-failed.description\": \"Gebruik deze SSD om je FailSafe-bescherming te herstellen.\",\n  \"storage-manager.replace-failed.error\": \"Kon vervanging niet starten\",\n  \"storage-manager.replace-failed.replace-now\": \"Nu vervangen\",\n  \"storage-manager.replace-failed.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD in sleuf {{slot}}\",\n  \"storage-manager.replace-failed.step-protected\": \"Als het klaar is, zijn je gegevens weer volledig beschermd\",\n  \"storage-manager.replace-failed.step-rebuild\": \"Je gegevens worden op de nieuwe SSD herbouwd\",\n  \"storage-manager.replace-failed.step-time\": \"Dit kan even duren, afhankelijk van hoeveel data je hebt\",\n  \"storage-manager.replace-failed.title\": \"SSD vervangen\",\n  \"storage-manager.replace-failed.too-small\": \"SSD te klein\",\n  \"storage-manager.replace-failed.too-small-description\": \"Deze SSD ({{deviceSize}}) is kleiner dan het minimaal vereiste ({{minSize}}) voor je FailSafe-opslag.\",\n  \"storage-manager.replace-failed.what-happens\": \"Wat gebeurt er nu:\",\n  \"storage-manager.ssd-failing\": \"Defect\",\n  \"storage-manager.swap\": \"Wissel\",\n  \"storage-manager.swap.data-erased-description\": \"In Full Storage-modus is er geen databeveiliging. Alle gegevens op je {{deviceName}} worden tijdens de fabrieksreset gewist. Zorg dat je eerst alles back-upt.\",\n  \"storage-manager.swap.data-protected\": \"Je gegevens zijn beschermd\",\n  \"storage-manager.swap.data-protected-description\": \"Met FailSafe ingeschakeld kun je elke enkele SSD vervangen zonder je gegevens te verliezen. Geen back-up nodig.\",\n  \"storage-manager.swap.data-will-be-erased\": \"Gegevens worden gewist\",\n  \"storage-manager.swap.description-failsafe\": \"Vervang een schijf in je FailSafe-opslag.\",\n  \"storage-manager.swap.description-full-storage\": \"Vervang een schijf in je Full Storage-configuratie.\",\n  \"storage-manager.swap.description-no-free-slot\": \"In Full Storage-modus met alle sleuven in gebruik vereist het wisselen van een SSD een volledige back-up- en herstelprocedure.\",\n  \"storage-manager.swap.description-replace\": \"Migreer je gegevens naar een nieuwe SSD en verwijder daarna de oude.\",\n  \"storage-manager.swap.failed-to-start\": \"Kon de vervanging niet starten\",\n  \"storage-manager.swap.no-data-loss\": \"Geen dataverlies\",\n  \"storage-manager.swap.no-data-loss-description\": \"Je gegevens worden naar de nieuwe SSD gekopieerd. Zodra dat klaar is, kun je de oude veilig verwijderen.\",\n  \"storage-manager.swap.safe-swap-available\": \"Veilige wissel beschikbaar\",\n  \"storage-manager.swap.safe-swap-description\": \"Omdat je een lege sleuf hebt, kun je eerst de nieuwe SSD toevoegen en je gegevens migreren voordat je de oude verwijdert. Geen back-up nodig.\",\n  \"storage-manager.swap.select-new-ssd\": \"Selecteer de nieuwe SSD die je wilt gebruiken:\",\n  \"storage-manager.swap.ssd-in-slot\": \"{{size}} SSD in Slot {{slot}}\",\n  \"storage-manager.swap.step-backup\": \"Maak een back-up van je gegevens\",\n  \"storage-manager.swap.step-backup-description\": \"Ga naar Instellingen → Backups en maak een back-up van alle gegevens.\",\n  \"storage-manager.swap.step-data-copied\": \"Gegevens worden van de oude SSD naar de nieuwe gekopieerd\",\n  \"storage-manager.swap.step-factory-reset\": \"Fabrieksreset\",\n  \"storage-manager.swap.step-factory-reset-description\": \"Ga naar Instellingen → Geavanceerd → Fabrieksreset om je {{deviceName}} te wissen.\",\n  \"storage-manager.swap.step-insert-new-ssd\": \"Plaats de nieuwe SSD in een lege sleuf\",\n  \"storage-manager.swap.step-may-take-while\": \"Dit kan even duren, afhankelijk van hoeveel gegevens je hebt\",\n  \"storage-manager.swap.step-power-on\": \"Zet je {{deviceName}} aan\",\n  \"storage-manager.swap.step-remove-bottom-cover\": \"Verwijder de magnetische bodemkap\",\n  \"storage-manager.swap.step-remove-old\": \"Als het klaar is, schakel je uit en verwijder je {{ssd}}\",\n  \"storage-manager.swap.step-replace-bottom-cover\": \"Plaats de bodemkap terug\",\n  \"storage-manager.swap.step-restore\": \"Herstel je gegevens\",\n  \"storage-manager.swap.step-restore-description\": \"Ga naar Instellingen → Backups en herstel vanaf je back-up.\",\n  \"storage-manager.swap.step-return-to-storage-manager\": \"Ga terug naar Opslagbeheer om de wissel te bevestigen en de nieuwe SSD aan je opslag toe te voegen\",\n  \"storage-manager.swap.step-return-to-swap\": \"Ga terug naar Opslagbeheer en klik opnieuw op \\\"Wissel\\\" om de vervanging te starten\",\n  \"storage-manager.swap.step-setup-new-storage\": \"Stel je nieuwe opslag in\",\n  \"storage-manager.swap.step-setup-new-storage-description\": \"Zet je {{deviceName}} aan en doorloop de setup met je nieuwe SSD.\",\n  \"storage-manager.swap.step-shut-down\": \"Schakel je {{deviceName}} uit\",\n  \"storage-manager.swap.step-shut-down-and-swap\": \"Schakel uit en vervang {{ssd}}\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-other\": \"Uitschakelen, open je apparaat, vervang de SSD en zet alles weer in elkaar.\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-pro\": \"Uitschakelen, verwijder de bodemkap, vervang de SSD en plaats de kap terug.\",\n  \"storage-manager.swap.step-swap-ssd\": \"Vervang {{ssd}} door een nieuwe van dezelfde grootte\",\n  \"storage-manager.swap.too-small\": \"Te klein (vereist: {{size}})\",\n  \"storage-manager.swap.what-happens-next\": \"Wat gebeurt er daarna:\",\n  \"storage-manager.total-capacity-added\": \"Totaal toegevoegde capaciteit\",\n  \"storage-manager.umbrel-pro\": \"Umbrel Pro\",\n  \"storage-manager.used\": \"Gebruikt\",\n  \"storage-manager.wasted\": \"Onbruikbaar\",\n  \"storage-manager.wasted-size\": \"{{size}} Onbruikbaar\",\n  \"storage.full\": \"Opslag vol\",\n  \"storage.low\": \"Weinig opslag\",\n  \"temperature\": \"Temperatuur\",\n  \"temperature.dangerously-hot\": \"Erg heet\",\n  \"temperature.nice\": \"Aangenaam\",\n  \"temperature.normal\": \"Normaal\",\n  \"temperature.too-hot-suggestion\": \"Overweeg om de omgeving van je apparaat te veranderen.\",\n  \"temperature.warm\": \"Warm\",\n  \"terminal\": \"Terminal\",\n  \"terminal-description\": \"Voer aangepaste commando's uit in umbrelOS of binnen een app\",\n  \"terminal.app\": \"App\",\n  \"terminal.app-description\": \"Voer aangepaste commando's uit binnen een specifieke app\",\n  \"terminal.umbrelos-description\": \"Voer aangepaste commando's uit in umbrelOS\",\n  \"tor-description\": \"Toegang tot je Umbrel van overal met een Tor browser\",\n  \"tor-enabled-description\": \"Toegang tot je Umbrel van overal met een Tor-browser op de volgende URL:\",\n  \"tor-error\": \"Kon Tor-instelling niet bijwerken: {{message}}\",\n  \"tor.disable.description\": \"Dit kan enkele minuten duren\",\n  \"tor.disable.progress\": \"Tor-toegang op afstand uitschakelen\",\n  \"tor.enable.description\": \"Dit kan enkele minuten duren\",\n  \"tor.enable.mobile.switch-label\": \"Toegang op afstand via Tor inschakelen\",\n  \"tor.hidden-service\": \"Tor verborgen dienst URL\",\n  \"troubleshoot\": \"Probleemoplossing\",\n  \"troubleshoot-description\": \"Los problemen op met umbrelOS of een app\",\n  \"troubleshoot-no-logs-yet\": \"Nog geen logbestanden\",\n  \"troubleshoot-pick-title\": \"Probleemoplossing\",\n  \"troubleshoot.app\": \"App\",\n  \"troubleshoot.app-description\": \"Bekijk logbestanden van een app geïnstalleerd op je Umbrel\",\n  \"troubleshoot.app-download\": \"Download {{app}} logbestanden\",\n  \"troubleshoot.share-with-umbrel-support\": \"Delen met Umbrel ondersteuning\",\n  \"troubleshoot.system-download\": \"Download {{label}}\",\n  \"troubleshoot.umbrelos-description\": \"Bekijk umbrelOS-logboeken\",\n  \"troubleshoot.umbrelos-logs\": \"umbrelOS logbestanden\",\n  \"trpc.backend-unavailable\": \"Fout: Verbinding met de systeem-API mislukt\",\n  \"trpc.checking-backend\": \"Laden...\",\n  \"try-again\": \"Probeer opnieuw\",\n  \"umbrel\": \"Umbrel\",\n  \"umbrelos\": \"umbrelOS\",\n  \"unknown\": \"Onbekend\",\n  \"unknown-app\": \"Onbekende app\",\n  \"unknown-error\": \"Onbekende fout\",\n  \"uptime\": \"Uptime\",\n  \"url\": \"URL\",\n  \"wallpaper\": \"Achtergrond\",\n  \"wallpaper-description\": \"Je Umbrel achtergrond en thema\",\n  \"whats-new.continue\": \"Doorgaan\",\n  \"whats-new.feature-1.description\": \"Stel geautomatiseerde, versleutelde back-ups in van je hele Umbrel naar een externe USB-drive, een NAS of een andere Umbrel.\",\n  \"whats-new.feature-2.description\": \"Ga terug in de tijd om specifieke bestanden en mappen uit eerdere back-ups te herstellen.\",\n  \"whats-new.feature-3.description\": \"Of herstel je hele Umbrel, inclusief al je apps, bestanden en gegevens.\",\n  \"whats-new.feature-4.description\": \"Sluit een NAS of een andere Umbrel aan en krijg toegang tot de opslag ervan vanuit Files.\",\n  \"whats-new.feature-4.title\": \"Netwerkapparaten\",\n  \"whats-new.feature-5.description\": \"Sluit externe USB-schijven aan (op de Umbrel Home of op elk Intel- of AMD-apparaat) en toegang ze via Files.\",\n  \"whats-new.feature-5.helper-text\": \"Niet ondersteund op Raspberry Pi-apparaten vanwege mogelijke stroomproblemen.\",\n  \"whats-new.feature-5.title\": \"Externe opslag\",\n  \"whats-new.next\": \"Volgende\",\n  \"whats-new.title\": \"Nieuw in {{version}}\",\n  \"widget.progress.in-progress\": \"Bezig\",\n  \"widgets.edit.select-up-to-3-widgets\": \"Selecteer tot 3 widgets\",\n  \"widgets.install-an-app-before-using-widgets\": \"Installeer een app om je startscherm aan te passen met widgets.\",\n  \"wifi\": \"Wi-Fi\",\n  \"wifi-connect-insecure-message\": \"Open netwerken kunnen onveilig zijn\",\n  \"wifi-connection-failed\": \"Kan niet verbinden\",\n  \"wifi-dangerous-change-confirmation-description\": \"Het veranderen van het Wi-Fi-netwerk kan je loskoppelen van je Umbrel. Om opnieuw verbinding te maken, zorg ervoor dat zowel je Umbrel als het apparaat waarmee je toegang hebt tot hetzelfde netwerk behoren.\",\n  \"wifi-dangerous-change-confirmation-title\": \"Weet je zeker dat je van Wi-Fi-netwerk wilt veranderen?\",\n  \"wifi-dangerous-disable-confirmation-description\": \"Het uitschakelen van Wi-Fi kan je loskoppelen van je Umbrel. Om opnieuw verbinding te maken, sluit je een Ethernet-kabel aan op je Umbrel en zorg je ervoor dat zowel je Umbrel als het apparaat waarmee je toegang hebt tot hetzelfde netwerk behoren.\",\n  \"wifi-dangerous-disable-confirmation-title\": \"Weet je zeker dat je Wi-Fi wilt uitschakelen?\",\n  \"wifi-description\": \"Verbind je apparaat met een Wi-Fi-netwerk\",\n  \"wifi-description-long\": \"Je apparaat blijft verbonden met je gekozen Wi-Fi, zelfs als de Ethernet-kabel wordt verwijderd, en verbindt automatisch opnieuw met Wi-Fi bij het opstarten.\",\n  \"wifi-no-networks-message\": \"Geen Wi-Fi-netwerken gevonden\",\n  \"wifi-searching\": \"Zoeken naar Wi-Fi-netwerken...\",\n  \"wifi-unsupported-device-description\": \"Wi-Fi wordt niet ondersteund op dit apparaat. Dit kan te wijten zijn aan een ontbrekende of incompatibele draadloze adapter.\",\n  \"wifi-view-networks\": \"Netwerken bekijken\"\n}"
  },
  {
    "path": "packages/ui/public/locales/pt.json",
    "content": "{\n  \"2fa\": \"2FA\",\n  \"2fa-description\": \"Uma segunda camada de segurança para o seu login no Umbrel e aplicativos\",\n  \"2fa.disable.title\": \"Desativar autenticação de dois fatores\",\n  \"2fa.enable.or-paste\": \"Ou cole o seguinte código no seu aplicativo autenticador\",\n  \"2fa.enable.scan-this\": \"Escanee este código QR usando um aplicativo autenticador como Google Authenticator ou Authy\",\n  \"2fa.enable.title\": \"Ativar autenticação de dois fatores\",\n  \"2fa.enter-code\": \"Digite o código exibido no seu aplicativo autenticador\",\n  \"account\": \"Conta\",\n  \"account-description\": \"Seu nome e senha\",\n  \"advanced-settings\": \"Configurações avançadas\",\n  \"advanced-settings-description\": \"Terminal, Programa Beta do umbrelOS, Cloudflare DNS e mais\",\n  \"app-not-found\": \"Aplicativo não encontrado: {{app}}\",\n  \"app-only-over-tor\": \"{{app}} só pode ser usado via Tor. Por favor, acesse seu Umbrel em um navegador Tor pela sua URL de acesso remoto (Settings > Advanced settings > Remote Tor access) para abrir este aplicativo.\",\n  \"app-page.section.about\": \"Sobre\",\n  \"app-page.section.credentials.title\": \"Credenciais padrão\",\n  \"app-page.section.dependencies.n-alternatives\": \"Ver {{count}} alternativas\",\n  \"app-page.section.info.compatibility\": \"Compatibilidade\",\n  \"app-page.section.info.compatibility-compatible\": \"Compatível\",\n  \"app-page.section.info.compatibility-not-compatible\": \"Não compatível\",\n  \"app-page.section.info.developer\": \"Desenvolvedor\",\n  \"app-page.section.info.source-code\": \"Código-fonte\",\n  \"app-page.section.info.source-code.public\": \"Público\",\n  \"app-page.section.info.submitted-by\": \"Enviado por\",\n  \"app-page.section.info.support\": \"Obter suporte\",\n  \"app-page.section.info.title\": \"Informações\",\n  \"app-page.section.info.version\": \"Versão\",\n  \"app-page.section.recommendations.title\": \"Você também pode gostar\",\n  \"app-page.section.release-notes.title\": \"O que há de novo\",\n  \"app-page.section.release-notes.version\": \"Versão {{version}}\",\n  \"app-page.section.requires\": \"Requer\",\n  \"app-picker.search\": \"Pesquisar...\",\n  \"app-picker.select-app\": \"Selecionar aplicativo...\",\n  \"app-settings.connected-to\": \"{{appName}} está conectado a estes aplicativos\",\n  \"app-settings.save-changes\": \"Salvar alterações\",\n  \"app-settings.title\": \"Configurações\",\n  \"app-store.browse-category-apps\": \"Navegar por aplicativos {{category}}\",\n  \"app-store.category.ai\": \"IA\",\n  \"app-store.category.all\": \"Todos os aplicativos\",\n  \"app-store.category.automation\": \"Casa & Automação\",\n  \"app-store.category.bitcoin\": \"Bitcoin\",\n  \"app-store.category.crypto\": \"Cripto\",\n  \"app-store.category.developer\": \"Ferramentas de Desenvolvedor\",\n  \"app-store.category.discover\": \"Descobrir\",\n  \"app-store.category.files\": \"Arquivos & Produtividade\",\n  \"app-store.category.finance\": \"Finanças\",\n  \"app-store.category.media\": \"Mídia\",\n  \"app-store.category.networking\": \"Redes\",\n  \"app-store.category.social\": \"Social\",\n  \"app-store.description\": \"Suas configurações de atualização de aplicativos\",\n  \"app-store.discover.temporarily-unavailable-description\": \"Navegue pelas categorias acima ou pesquise para encontrar apps\",\n  \"app-store.discover.temporarily-unavailable-title\": \"Conteúdo em destaque temporariamente indisponível\",\n  \"app-store.menu.community-app-stores\": \"Lojas de Aplicativos da Comunidade\",\n  \"app-store.search-apps\": \"Pesquisar aplicativos\",\n  \"app-store.search.no-results\": \"Nenhum resultado\",\n  \"app-store.search.results-for\": \"Resultados para\",\n  \"app-store.title\": \"App Store\",\n  \"app-store.updates\": \"Atualizações\",\n  \"app-updates.less\": \"menos\",\n  \"app-updates.more\": \"mais\",\n  \"app-updates.no-updates\": \"Todos os aplicativos estão atualizados!\",\n  \"app-updates.update\": \"Atualizar\",\n  \"app-updates.update-all\": \"Atualizar todos\",\n  \"app-updates.updates-available-count_one\": \"{{count}} atualização disponível\",\n  \"app-updates.updates-available-count_other\": \"{{count}} atualizações disponíveis\",\n  \"app-updates.updating\": \"Atualizando...\",\n  \"app.install\": \"Instalar\",\n  \"app.installed\": \"Instalado\",\n  \"app.installing\": \"Instalando\",\n  \"app.offline\": \"Não está em execução\",\n  \"app.open\": \"Abrir\",\n  \"app.optimized-for-umbrel-home\": \"Otimizado para Umbrel Home\",\n  \"app.os-update-required.confirm\": \"Verificar atualização do umbrelOS\",\n  \"app.os-update-required.description\": \"{{appName}} requer o umbrelOS {{version}} ou posterior\",\n  \"app.os-update-required.title\": \"Atualizar umbrelOS\",\n  \"app.restarting\": \"Reiniciando\",\n  \"app.starting\": \"Iniciando\",\n  \"app.stopping\": \"Parando\",\n  \"app.uninstall.confirm.description\": \"Todos os dados associados a {{app}} serão permanentemente excluídos. Esta ação não pode ser desfeita.\",\n  \"app.uninstall.confirm.submit\": \"Desinstalar\",\n  \"app.uninstall.confirm.title\": \"Desinstalar {{app}}?\",\n  \"app.uninstall.deps.used-by.description_one\": \"Desinstale {{firstAppToUninstall}} primeiro para desinstalar {{app}}.\",\n  \"app.uninstall.deps.used-by.description_other\": \"Desinstale estes aplicativos primeiro para desinstalar {{app}}.\",\n  \"app.uninstall.deps.used-by.title\": \"{{app}} é usado por\",\n  \"app.uninstalling\": \"Desinstalando\",\n  \"app.updating\": \"Atualizando\",\n  \"app.view\": \"Visualizar\",\n  \"app_one\": \"aplicativo\",\n  \"app_other\": \"aplicativos\",\n  \"apps.uninstall.failed-to-get-required-apps\": \"Falha ao obter aplicativos necessários\",\n  \"apps.uninstalled-all.success\": \"Todos os aplicativos foram desinstalados\",\n  \"auth.checking-backend-for-user\": \"Carregando...\",\n  \"auth.failed-checking-if-user-logged-in\": \"Erro: A verificação de login falhou\",\n  \"auth.failed-to-check-if-user-exists\": \"Erro: A verificação de existência falhou\",\n  \"back\": \"Voltar\",\n  \"backups\": \"Backups\",\n  \"backups-configure\": \"Configurar\",\n  \"backups-configure.add-backup-location\": \"Adicionar local de backup\",\n  \"backups-configure.available\": \"Disponível\",\n  \"backups-configure.awaiting-next-backup\": \"Aguardando o próximo backup automático\",\n  \"backups-configure.back-up-now\": \"Fazer backup agora\",\n  \"backups-configure.backing-up-now\": \"Fazendo backup...\",\n  \"backups-configure.connected\": \"Conectado\",\n  \"backups-configure.connection\": \"Conexão\",\n  \"backups-configure.in-progress\": \"Em andamento\",\n  \"backups-configure.last-backup\": \"Último backup\",\n  \"backups-configure.locations\": \"Locais\",\n  \"backups-configure.no-backup-locations\": \"Adicione um local de backup para começar a fazer backup dos seus dados\",\n  \"backups-configure.not-connected\": \"Não conectado\",\n  \"backups-configure.path\": \"Caminho\",\n  \"backups-configure.remove-backup-location\": \"Remover local de backup\",\n  \"backups-configure.remove-backup-location-confirmation\": \"Tem certeza?\",\n  \"backups-configure.remove-backup-location-confirmation-description\": \"Isso removerá '{{device}}' dos seus locais de backup. Os backups existentes neste dispositivo não serão excluídos, mas os backups automáticos serão interrompidos.\",\n  \"backups-configure.status\": \"Status\",\n  \"backups-configure.total-backups\": \"Total de backups\",\n  \"backups-configure.used\": \"Usado\",\n  \"backups-configure.view\": \"Ver\",\n  \"backups-description\": \"Faça backup dos seus arquivos, apps e dados para outro Umbrel, NAS ou disco externo\",\n  \"backups-error.backup-not-found\": \"Não foi possível encontrar o backup.\",\n  \"backups-error.generic\": \"Algo deu errado: {{details}}\",\n  \"backups-error.in-progress\": \"Já existe um processo de backup em andamento. Aguarde até ele terminar.\",\n  \"backups-error.invalid-exclusion-path\": \"Só é possível excluir dos backups arquivos e pastas do seu diretório Home.\",\n  \"backups-error.invalid-password\": \"A senha de criptografia está incorreta.\",\n  \"backups-error.invalid-path\": \"O local selecionado não é válido para backups.\",\n  \"backups-error.mount-failed\": \"Não foi possível acessar o snapshot de backup.\",\n  \"backups-error.mount-timeout\": \"Não foi possível acessar o snapshot de backup. Tente novamente ou verifique se o dispositivo está conectado corretamente.\",\n  \"backups-error.not-enough-space\": \"Espaço insuficiente disponível no dispositivo de backup.\",\n  \"backups-error.not-found\": \"Não foi possível encontrar o backup ou o local de backup.\",\n  \"backups-error.repository-exists\": \"Já existe um local de backup nesta pasta.\",\n  \"backups-error.repository-not-found\": \"Não foi possível encontrar o local de backup.\",\n  \"backups-exclusions.add\": \"Adicionar\",\n  \"backups-exclusions.app-paths-cannot-be-modified\": \"Estes arquivos/pastas são definidos pelo desenvolvedor do app e não podem ser modificados:\",\n  \"backups-exclusions.app-paths-explanation\": \"Este app exclui os seguintes dados do backup. Esses caminhos geralmente contêm itens não essenciais (como caches ou logs que podem ser recriados) ou dados que podem causar problemas se forem restaurados (como estados antigos do app que podem gerar conflitos ou inconsistências).\",\n  \"backups-exclusions.auto-excluded\": \"Excluído automaticamente\",\n  \"backups-exclusions.exclude-entire-app\": \"Excluir o app inteiro\",\n  \"backups-exclusions.excluded-apps\": \"Apps excluídos\",\n  \"backups-exclusions.files-and-folders\": \"Arquivos e pastas excluídos\",\n  \"backups-exclusions.no-excluded-apps\": \"Nenhum app excluído\",\n  \"backups-exclusions.no-excluded-files-or-folders\": \"Nenhum arquivo ou pasta excluído\",\n  \"backups-exclusions.select-item-to-exclude\": \"Selecione um item para excluir\",\n  \"backups-exclusions.stop-excluding\": \"Parar de excluir\",\n  \"backups-floating-island.backing-up\": \"Fazendo backup...\",\n  \"backups-floating-island.backing-up-to\": \"Fazendo backup do seu Umbrel...\",\n  \"backups-restore\": \"Restaurar\",\n  \"backups-restore-full\": \"Restauração completa\",\n  \"backups-restore-full-description\": \"Restaure todo o seu Umbrel a partir de um backup\",\n  \"backups-restore-header\": \"Restaurar seu Umbrel\",\n  \"backups-restore-pro.after-restore\": \"Após a restauração, sua conta temporária será substituída pela sua conta e dados do backup.\",\n  \"backups-restore-pro.step1\": \"Complete o processo de configuração clicando em \\\"Começar\\\" abaixo. Essa será sua conta temporária até você restaurar a sua conta do backup.\",\n  \"backups-restore-pro.step2\": \"Quando a configuração estiver concluída, vá para <0>Configurações → Backups → Restaurar</0>\",\n  \"backups-restore-pro.step3\": \"Siga as instruções do Assistente de Restauração.\",\n  \"backups-restore-pro.subtitle\": \"Restaurar um backup no Umbrel Pro exige alguns passos a mais\",\n  \"backups-restore.backup-date\": \"Data do backup\",\n  \"backups-restore.backup-location\": \"Local do backup\",\n  \"backups-restore.browse-cloud-subtitle\": \"Restaurar a partir do Umbrel Private Cloud (em breve)\",\n  \"backups-restore.browse-cloud-title\": \"Umbrel Private Cloud\",\n  \"backups-restore.browse-external-subtitle\": \"Restaurar a partir de um disco USB externo\",\n  \"backups-restore.browse-external-title\": \"Disco externo\",\n  \"backups-restore.browse-nas-or-external\": \"Procure outro Umbrel, NAS ou disco externo para restaurar a partir de um backup\",\n  \"backups-restore.browse-nas-subtitle\": \"Restaurar a partir de outro Umbrel ou dispositivo NAS na sua rede\",\n  \"backups-restore.browse-nas-title\": \"Outro Umbrel ou NAS\",\n  \"backups-restore.choose\": \"Escolher\",\n  \"backups-restore.choose-backup-location\": \"Escolha um local de backup\",\n  \"backups-restore.connect-to-backup-location\": \"Conectar a um local de backup\",\n  \"backups-restore.encryption-password\": \"Senha de criptografia\",\n  \"backups-restore.encryption-password-description\": \"Digite a senha de criptografia que você definiu quando ativou os backups\",\n  \"backups-restore.enter-password-to-confirm\": \"Insira a senha do Umbrel para confirmar\",\n  \"backups-restore.final-confirmation\": \"Tem certeza?\",\n  \"backups-restore.final-confirmation-description\": \"Restaurar a partir deste backup substituirá os apps e dados atuais do umbrelOS pelo conteúdo do backup selecionado. Quaisquer arquivos, pastas ou apps excluídos deste backup serão removidos do seu Umbrel. Esta ação não pode ser desfeita.\",\n  \"backups-restore.invalid-password\": \"Senha inválida\",\n  \"backups-restore.last-backup\": \"Último backup: {{date}}\",\n  \"backups-restore.latest\": \"Mais recente\",\n  \"backups-restore.no-backups-found\": \"Nenhum backup encontrado\",\n  \"backups-restore.no-backups-yet\": \"Ainda não há backups\",\n  \"backups-restore.please-select-backup\": \"Por favor, selecione um backup\",\n  \"backups-restore.please-select-repository\": \"Por favor, selecione um repositório\",\n  \"backups-restore.restore-from-nas-or-external\": \"Restaure seu Umbrel a partir de um backup em outro Umbrel, em um NAS ou em um disco externo\",\n  \"backups-restore.restore-from-unlisted\": \"Restaurar de outro local\",\n  \"backups-restore.restore-umbrel\": \"Restaurar Umbrel\",\n  \"backups-restore.restore-warning\": \"Restaurar a partir deste backup substituirá os apps e dados atuais do umbrelOS pelo conteúdo do backup selecionado. Quaisquer arquivos, pastas ou apps excluídos deste backup serão removidos do seu Umbrel. Abra <0>Rewind</0> se quiser restaurar arquivos ou pastas específicos em vez disso.\",\n  \"backups-restore.restoring-from\": \"Você está prestes a restaurar a partir do seguinte backup:\",\n  \"backups-restore.review-description\": \"A restauração configurará seu Umbrel com a conta, arquivos, apps e configurações que estavam incluídos na época deste backup. Isso pode levar algum tempo. Quando for concluída, sua senha de login será definida para a que você usou quando o backup foi criado.\",\n  \"backups-restore.select-backup\": \"Selecione um backup\",\n  \"backups-restore.select-backup-description\": \"Selecione o backup do qual você deseja restaurar\",\n  \"backups-restore.select-backup-file\": \"Selecione seu arquivo de backup\",\n  \"backups-restore.select-backup-file-only\": \"Somente <bold>{{backupFileName}}</bold> pode ser selecionado\",\n  \"backups-restore.total-size\": \"Tamanho total\",\n  \"backups-restore.unknown-date\": \"Data desconhecida\",\n  \"backups-restore.unknown-repository\": \"Repositório desconhecido\",\n  \"backups-rewind\": \"Rewind\",\n  \"backups-rewind-description\": \"Volte no tempo para restaurar arquivos e pastas específicos\",\n  \"backups-rewind.start\": \"Iniciar Rewind\",\n  \"backups-setup\": \"Configurar\",\n  \"backups-setup-confirm\": \"Concluir configuração\",\n  \"backups-setup-external-description\": \"Fazer backup em um disco USB externo\",\n  \"backups-setup-nas-or-umbrel-description\": \"Faça backup para outro Umbrel ou um dispositivo NAS na sua rede\",\n  \"backups-setup-umbrel-or-nas\": \"Outro Umbrel ou NAS\",\n  \"backups-setup-umbrel-private-cloud\": \"Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-cta\": \"Amplie sua tranquilidade além de casa com <bold>backups criptografados de ponta a ponta</bold> para Umbrel Private Cloud.\",\n  \"backups-setup-umbrel-private-cloud-cta-link\": \"Peça acesso antecipado\",\n  \"backups-setup-umbrel-private-cloud-description\": \"Backups criptografados de ponta a ponta para Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-subtitle\": \"Em breve\",\n  \"backups.add-umbrel-or-nas\": \"Adicionar Umbrel ou NAS\",\n  \"backups.all-apps-and-data-will-be-backed-up\": \"Todos os apps e dados serão incluídos no backup\",\n  \"backups.apps-and-data\": \"Apps e dados\",\n  \"backups.backup-location\": \"Local de backup\",\n  \"backups.browse\": \"Procurar\",\n  \"backups.choose-folder-within-device\": \"Escolha uma pasta dentro de <bold>{{device}}</bold> para salvar seus backups\",\n  \"backups.confirm-password\": \"Confirme a senha\",\n  \"backups.copy\": \"Copiar\",\n  \"backups.encryption\": \"Criptografia\",\n  \"backups.encryption-password-warning\": \"Certifique-se de armazenar sua senha de criptografia em um local seguro, como um gerenciador de senhas. Você não poderá vê-la novamente e precisará dela para restaurar seus backups.\",\n  \"backups.exclude-from-backups\": \"Excluir do Backups\",\n  \"backups.exclude-from-backups-description\": \"Exclua arquivos, pastas e apps específicos dos seus backups.\",\n  \"backups.hide\": \"Ocultar\",\n  \"backups.i-understand\": \"Entendi\",\n  \"backups.location\": \"Local\",\n  \"backups.modals.already-in-use.description\": \"Este local de backup já está sendo usado para os Backups deste Umbrel.\",\n  \"backups.modals.already-in-use.manage\": \"Gerenciar em Backups\",\n  \"backups.modals.already-in-use.title\": \"Local de backup já em uso\",\n  \"backups.modals.connect-existing.description\": \"Um backup do Umbrel já existe neste local. Digite a senha de criptografia para adicioná-lo a este Umbrel.\",\n  \"backups.modals.connect-existing.title\": \"Conectar backup existente do Umbrel\",\n  \"backups.no-external-drives-detected\": \"Nenhum disco externo detectado\",\n  \"backups.no-password-set\": \"Nenhuma senha definida\",\n  \"backups.password-is-set\": \"Senha definida\",\n  \"backups.password-minimum-length\": \"A senha deve ter pelo menos 8 caracteres\",\n  \"backups.password-safety-warning\": \"Seus backups serão criptografados com esta senha. Guarde-a em segurança, pois você não poderá vê-la novamente e precisará dela para restaurar seus backups.\",\n  \"backups.passwords-do-not-match\": \"As senhas não coincidem\",\n  \"backups.please-choose-folder\": \"Por favor, escolha uma pasta\",\n  \"backups.restore-failed.message\": \"Ocorreu um erro ao restaurar seu Umbrel. Seus aplicativos e dados atuais não foram alterados.\",\n  \"backups.restore-failed.retry\": \"Ir para Restaurar\",\n  \"backups.restore-failed.title\": \"Falha na restauração\",\n  \"backups.restoring\": \"Restaurando seu Umbrel\",\n  \"backups.restoring-completing\": \"Finalizando. Seu Umbrel será reiniciado em breve...\",\n  \"backups.restoring-progress\": \"Restaurado {{percent}}%\",\n  \"backups.restoring-time-remaining\": \"{{time}} restantes\",\n  \"backups.restoring-warning\": \"Não desligue seu Umbrel nem desconecte o local de backup durante a restauração\",\n  \"backups.review\": \"Revisar e confirmar\",\n  \"backups.review-description\": \"Revise os detalhes do seu backup e confirme sua seleção\",\n  \"backups.scanning-for-external-drives\": \"Procurando discos externos...\",\n  \"backups.schedule-description\": \"umbrelOS faz backup dos seus dados automaticamente a cada hora. Ele mantém backups criptografados por hora das últimas 24 horas, backups diários da última semana, backups semanais do último mês e backups mensais do último ano. Backups com mais de um ano são removidos automaticamente.\",\n  \"backups.select-backup-folder\": \"Selecione a pasta de backup\",\n  \"backups.select-backup-folder-description\": \"Escolha uma pasta onde você gostaria de armazenar seus backups.\",\n  \"backups.select-backup-location\": \"Selecione um local de backup\",\n  \"backups.set-encryption-password\": \"Definir senha de criptografia\",\n  \"backups.set-encryption-password-description\": \"Proteja seus backups com uma senha. Isso garante que seus dados permaneçam privados e só possam ser restaurados com essa senha.\",\n  \"backups.show\": \"Mostrar\",\n  \"backups.storage-capacity-warning\": \"{{device}} deve ter espaço livre igual a, no mínimo, duas vezes o tamanho do seu backup\",\n  \"backups.store-encryption-password-safely\": \"Armazene sua senha de criptografia em local seguro\",\n  \"beta-program\": \"Programa Beta do umbrelOS\",\n  \"beta-program-description\": \"Opte por receber atualizações beta do umbrelOS, obtenha acesso antecipado a novas funcionalidades e ajude-nos a refiná-las fornecendo seu feedback. Atualizações beta podem ser instáveis e a solução de problemas pode requerer familiaridade com o terminal.\",\n  \"cancel\": \"Cancelar\",\n  \"change\": \"Mudar\",\n  \"change-name\": \"Mudar nome\",\n  \"change-name.failed.name-required\": \"O nome é necessário\",\n  \"change-name.input-placeholder\": \"Seu nome\",\n  \"change-password\": \"Mudar senha\",\n  \"change-password.callout\": \"Se você perder sua senha, não será possível fazer login no seu Umbrel. Certifique-se de guardá-la em segurança.\",\n  \"change-password.current-password\": \"Senha atual\",\n  \"change-password.failed.current-required\": \"A senha atual é necessária\",\n  \"change-password.failed.min-length\": \"A senha deve ter pelo menos {{characters}} caracteres\",\n  \"change-password.failed.must-be-unique\": \"A nova senha deve ser diferente da senha atual\",\n  \"change-password.failed.new-required\": \"A nova senha é necessária\",\n  \"change-password.failed.no-match\": \"As senhas não coincidem\",\n  \"change-password.failed.repeat-required\": \"É necessário repetir a senha\",\n  \"change-password.new-password\": \"Nova senha\",\n  \"change-password.repeat-password\": \"Repetir senha\",\n  \"check-for-latest-version\": \"Verificar a última atualização do umbrelOS\",\n  \"clipboard.copied\": \"Copiado\",\n  \"close\": \"Fechar\",\n  \"cmdk.change-wallpaper\": \"Mudar papel de parede\",\n  \"cmdk.frequent-apps\": \"Usados frequentemente\",\n  \"cmdk.input-placeholder\": \"Pesquise por aplicativos, configurações ou ações\",\n  \"cmdk.live-usage\": \"Uso ao Vivo\",\n  \"cmdk.restart-umbrel\": \"Reiniciar Umbrel\",\n  \"cmdk.shutdown-umbrel\": \"Desligar Umbrel\",\n  \"cmdk.update-all-apps\": \"Atualizar todos os aplicativos\",\n  \"cmdk.widgets\": \"Widgets\",\n  \"community-app-store\": \"Loja de Aplicativos da Comunidade\",\n  \"community-app-store.add-error\": \"Falha ao adicionar App Store: {{message}}\",\n  \"community-app-store.back-to-umbrel-app-store\": \"Voltar para a App Store do Umbrel\",\n  \"community-app-store.open-button\": \"Abrir\",\n  \"community-app-store.remove-button\": \"Remover\",\n  \"community-app-store.remove-error\": \"Falha ao remover App Store: {{message}}\",\n  \"community-app-stores.add-button\": \"Adicionar\",\n  \"community-app-stores.description\": \"As Lojas de Aplicativos da Comunidade permitem instalar aplicativos no seu Umbrel que podem não estar disponíveis na App Store oficial do Umbrel. Eles também facilitam o teste de versões beta de aplicativos Umbrel antes que os desenvolvedores os lancem na App Store oficial do Umbrel.\",\n  \"community-app-stores.learn-more\": \"Saiba mais\",\n  \"community-app-stores.warning\": \"As Lojas de Aplicativos da Comunidade podem ser criadas por qualquer pessoa. Os aplicativos publicados nelas não são verificados ou avaliados pela equipe da App Store oficial do Umbrel e podem ser potencialmente inseguros ou maliciosos. Use cautela e adicione apenas lojas de aplicativos de desenvolvedores em quem você confia.\",\n  \"confirm\": \"Confirmar\",\n  \"connect\": \"Conectar\",\n  \"connecting\": \"Conectando...\",\n  \"connection-lost\": \"Conexão perdida\",\n  \"connection-lost-description\": \"Isso pode acontecer quando a aba do navegador ficou inativa, sua conexão de rede foi interrompida ou seu dispositivo está offline.\",\n  \"continue\": \"Continuar\",\n  \"continue-to-log-in\": \"Continuar para o login\",\n  \"cpu\": \"CPU\",\n  \"cpu-core-count\": \"{{cores}} núcleos\",\n  \"default-credentials.close\": \"Entendi\",\n  \"default-credentials.description\": \"Aqui estão as credenciais que você precisará para fazer login no aplicativo.\",\n  \"default-credentials.dont-show-again\": \"Não mostrar isso novamente\",\n  \"default-credentials.dont-show-again-notice\": \"Você pode acessar estas credenciais a qualquer momento no futuro clicando com o botão direito no ícone do app.\",\n  \"default-credentials.open\": \"Abrir {{app}}\",\n  \"default-credentials.password\": \"Senha padrão\",\n  \"default-credentials.title\": \"Credenciais para {{app}}\",\n  \"default-credentials.username\": \"Nome de usuário padrão\",\n  \"desktop.app.context.go-to-store-page\": \"Ver na App Store\",\n  \"desktop.app.context.settings\": \"Configurações\",\n  \"desktop.app.context.show-default-credentials\": \"Mostrar credenciais padrão\",\n  \"desktop.app.context.uninstall\": \"Desinstalar\",\n  \"desktop.context-menu.change-wallpaper\": \"Mudar papel de parede\",\n  \"desktop.context-menu.edit-widgets\": \"Editar widgets\",\n  \"desktop.context-menu.logout\": \"Sair\",\n  \"desktop.greeting.afternoon\": \"Boa tarde, {{name}}\",\n  \"desktop.greeting.evening\": \"Boa noite, {{name}}\",\n  \"desktop.greeting.morning\": \"Bom dia, {{name}}\",\n  \"desktop.install-first.for-the-ai-enthusiast\": \"Para o viber\",\n  \"desktop.install-first.for-the-bitcoiner\": \"Para o bitcoiner\",\n  \"desktop.install-first.for-the-self-hoster\": \"Para o auto-hospedeiro\",\n  \"desktop.install-first.for-the-streamer\": \"Para o streamer\",\n  \"desktop.install-first.link-to-app-store\": \"Explorar mais na App Store\",\n  \"desktop.not-enough-room\": \"Use uma tela maior para ver seus aplicativos.\",\n  \"device\": \"Dispositivo\",\n  \"device-info\": \"Informações do dispositivo\",\n  \"device-info-description\": \"Informações sobre o seu dispositivo\",\n  \"device-info.device\": \"Dispositivo\",\n  \"device-info.model-number\": \"Número do modelo\",\n  \"device-info.serial-number\": \"Número de série\",\n  \"device-info.view-info\": \"Ver informações\",\n  \"device-name.home-or-pro\": \"Umbrel Home or Umbrel Pro\",\n  \"disable\": \"Desativar\",\n  \"done\": \"Concluído\",\n  \"download-logs\": \"Baixar registros\",\n  \"enabling-tor\": \"Ativando o acesso remoto via Tor\",\n  \"external-dns\": \"Cloudflare DNS\",\n  \"external-dns-description\": \"O DNS da Cloudflare oferece melhor confiabilidade de rede. Desative para usar as configurações de DNS do seu roteador.\",\n  \"external-dns-error\": \"Falha ao atualizar a configuração de DNS: {{message}}\",\n  \"external-drive\": \"Disco Externo\",\n  \"factory-reset\": \"Restauração de Fábrica\",\n  \"factory-reset-description\": \"Apague todos os seus dados e aplicativos, restaurando o umbrelOS para as configurações padrão\",\n  \"factory-reset-failed\": \"Falha ao restaurar as configurações de fábrica do seu dispositivo: {{message}}\",\n  \"factory-reset.confirm.body\": \"Confirme sua senha para redefinir\",\n  \"factory-reset.confirm.ethernet-required-warning\": \"Certifique-se de que seu dispositivo esteja conectado ao roteador via Ethernet (não Wi-Fi) e que você esteja acessando a partir da sua rede local (por exemplo, http://umbrel.local ou o endereço IP local do seu dispositivo).\",\n  \"factory-reset.confirm.submit\": \"Apagar tudo e resetar\",\n  \"factory-reset.confirm.submit-callout\": \"Esta ação não pode ser desfeita.\",\n  \"factory-reset.rebooting.message\": \"Seu dispositivo será reiniciado e todos os dados serão apagados. Por favor, não feche esta página.\",\n  \"factory-reset.rebooting.status\": \"Redefinindo...\",\n  \"factory-reset.rebooting.title\": \"Redefinição de fábrica em andamento\",\n  \"factory-reset.review.account-info\": \"Informações da conta e senha\",\n  \"factory-reset.review.apps\": \"Aplicativos\",\n  \"factory-reset.review.following-will-be-removed\": \"O seguinte será removido do seu dispositivo\",\n  \"factory-reset.review.installed-apps_one\": \"{{count}} aplicativo instalado\",\n  \"factory-reset.review.installed-apps_other\": \"{{count}} aplicativos instalados\",\n  \"factory-reset.review.submit\": \"Continuar\",\n  \"factory-reset.review.total-data\": \"Dados totais\",\n  \"files\": \"Files\",\n  \"files-action.add-favorite\": \"Adicionar aos favoritos\",\n  \"files-action.add-network-device\": \"Adicionar dispositivo\",\n  \"files-action.cancel-upload\": \"Cancelar envio\",\n  \"files-action.compress\": \"Comprimir\",\n  \"files-action.copy\": \"Copiar\",\n  \"files-action.cut\": \"Recortar\",\n  \"files-action.delete\": \"Excluir permanentemente\",\n  \"files-action.download\": \"Baixar\",\n  \"files-action.download-items\": \"Baixar {{count}} itens\",\n  \"files-action.drop-to-upload\": \"Solte para enviar\",\n  \"files-action.eject-disk\": \"Ejetar\",\n  \"files-action.empty-trash\": \"Esvaziar Lixeira\",\n  \"files-action.format-drive\": \"Formatar\",\n  \"files-action.go-to-path\": \"Ir para...\",\n  \"files-action.new-folder\": \"Nova pasta\",\n  \"files-action.open\": \"Abrir\",\n  \"files-action.paste\": \"Colar\",\n  \"files-action.remove-favorite\": \"Remover dos favoritos\",\n  \"files-action.remove-network-host\": \"Ejetar unidade de rede\",\n  \"files-action.remove-network-share\": \"Ejetar compartilhamento de rede\",\n  \"files-action.rename\": \"Renomear\",\n  \"files-action.restore\": \"Restaurar\",\n  \"files-action.select\": \"Selecionar\",\n  \"files-action.share\": \"Compartilhar pela rede...\",\n  \"files-action.sharing\": \"Compartilhando...\",\n  \"files-action.show-in-folder\": \"Mostrar na pasta correspondente\",\n  \"files-action.trash\": \"Lixeira\",\n  \"files-action.uncompress\": \"Descomprimir\",\n  \"files-action.upload\": \"Enviar\",\n  \"files-add-network-share.add-manually\": \"Adicionar manualmente\",\n  \"files-add-network-share.add-share\": \"Adicionar compartilhamento\",\n  \"files-add-network-share.back\": \"Voltar\",\n  \"files-add-network-share.continue\": \"Continuar\",\n  \"files-add-network-share.description\": \"Conecte-se a um NAS ou outro drive compartilhado na sua rede para acessá-los no Files.\",\n  \"files-add-network-share.discovering\": \"Descobrindo...\",\n  \"files-add-network-share.enter-details-manually\": \"Digite os detalhes do servidor\",\n  \"files-add-network-share.host-label\": \"Endereço do servidor\",\n  \"files-add-network-share.host-required\": \"Endereço do servidor é obrigatório\",\n  \"files-add-network-share.manual-share-help\": \"Digite o nome exato do compartilhamento como aparece no seu servidor\",\n  \"files-add-network-share.no-shares-found\": \"Nenhum compartilhamento encontrado neste servidor\",\n  \"files-add-network-share.not-seeing-share\": \"Não está vendo seu compartilhamento?\",\n  \"files-add-network-share.password-label\": \"Senha\",\n  \"files-add-network-share.password-required\": \"Senha é obrigatória\",\n  \"files-add-network-share.retrieving-shares\": \"Obtendo compartilhamentos...\",\n  \"files-add-network-share.retry-discovery\": \"Escanear rede novamente\",\n  \"files-add-network-share.select-share\": \"Selecione um compartilhamento para adicionar\",\n  \"files-add-network-share.share-placeholder\": \"shared-documents\",\n  \"files-add-network-share.share-required\": \"Compartilhamento é obrigatório\",\n  \"files-add-network-share.title\": \"Adicionar um compartilhamento de rede\",\n  \"files-add-network-share.username-label\": \"Nome de usuário\",\n  \"files-add-network-share.username-placeholder\": \"admin\",\n  \"files-add-network-share.username-required\": \"Nome de usuário é obrigatório\",\n  \"files-audio-island.now-playing\": \"Reproduzindo agora\",\n  \"files-audio-island.pause\": \"Pausar\",\n  \"files-audio-island.play\": \"Reproduzir\",\n  \"files-backend-error.base-directory-not-found\": \"Não foi possível encontrar o diretório base\",\n  \"files-backend-error.cant-find-root\": \"Não foi possível verificar o caminho do arquivo\",\n  \"files-backend-error.destination-already-exists\": \"Já existe um item com o mesmo nome no destino\",\n  \"files-backend-error.destination-not-exist\": \"A pasta de destino não existe\",\n  \"files-backend-error.does-not-exist\": \"O arquivo ou a pasta não existe\",\n  \"files-backend-error.escapes-base\": \"O caminho está fora do diretório permitido\",\n  \"files-backend-error.invalid-base\": \"O caminho não pertence a um diretório válido\",\n  \"files-backend-error.invalid-filename\": \"O nome do arquivo não é válido\",\n  \"files-backend-error.invalid-path\": \"O caminho do arquivo não é válido\",\n  \"files-backend-error.mkdir-failed\": \"Não foi possível criar a pasta\",\n  \"files-backend-error.move-failed\": \"Não foi possível mover o item\",\n  \"files-backend-error.not-enough-space\": \"Não há espaço de armazenamento suficiente\",\n  \"files-backend-error.operation-not-allowed\": \"Esta operação não é permitida\",\n  \"files-backend-error.parent-not-directory\": \"O caminho pai não é uma pasta\",\n  \"files-backend-error.parent-not-exist\": \"A pasta pai não existe\",\n  \"files-backend-error.path-not-absolute\": \"O caminho do arquivo não é válido\",\n  \"files-backend-error.share-already-exists\": \"Esta pasta já está compartilhada\",\n  \"files-backend-error.share-name-generation-failed\": \"Não foi possível gerar um nome único para o compartilhamento\",\n  \"files-backend-error.source-not-exists\": \"O arquivo ou pasta de origem não existe\",\n  \"files-backend-error.subdir-of-self\": \"Uma pasta não pode ser movida ou copiada para dentro de si mesma\",\n  \"files-backend-error.trash-meta-not-exists\": \"Não foi possível encontrar a localização original deste item\",\n  \"files-backend-error.unique-name-index-exceeded\": \"Não foi possível gerar um nome único. Existem muitos itens com nomes semelhantes\",\n  \"files-backend-error.upload-failed\": \"Falha no upload\",\n  \"files-collision.action.keep-both\": \"Manter ambos\",\n  \"files-collision.action.replace\": \"Substituir\",\n  \"files-collision.action.skip\": \"Ignorar\",\n  \"files-collision.destination.original-location\": \"Localização original\",\n  \"files-collision.message\": \"Deseja substituir o item existente ou manter ambos?\",\n  \"files-collision.title\": \"\\\"{{itemName}}\\\" já existe em {{destinationName}}\",\n  \"files-download.confirm\": \"Fazer download\",\n  \"files-download.description\": \"O Files não consegue abrir este tipo de arquivo. Deseja baixá-lo em vez disso?\",\n  \"files-download.title\": \"Baixar {{name}}?\",\n  \"files-empty-trash.confirm\": \"Esvaziar\",\n  \"files-empty-trash.description\": \"Tem certeza de que deseja excluir permanentemente todos os itens na lixeira? Você não pode desfazer essa ação.\",\n  \"files-empty-trash.title\": \"Esvaziar Lixeira?\",\n  \"files-empty.directory\": \"Nenhum item nesta pasta\",\n  \"files-empty.network\": \"Nenhum dispositivo de rede\",\n  \"files-empty.network-host-offline\": \"Dispositivo de rede offline\",\n  \"files-error.add-favorite\": \"Falha ao adicionar aos favoritos: {{message}}\",\n  \"files-error.add-share\": \"Falha ao compartilhar a pasta: {{message}}\",\n  \"files-error.compress\": \"Falha na compactação: {{message}}\",\n  \"files-error.copy\": \"Falha ao copiar: {{message}}\",\n  \"files-error.create-folder\": \"Falha ao criar a pasta: {{message}}\",\n  \"files-error.delete\": \"Falha ao excluir: {{message}}\",\n  \"files-error.eject-disk\": \"Falha ao ejetar a unidade: {{message}}\",\n  \"files-error.empty-trash\": \"Falha ao esvaziar a lixeira: {{message}}\",\n  \"files-error.extract\": \"Falha na extração: {{message}}\",\n  \"files-error.folder-already-exists\": \"Uma pasta com esse nome já existe\",\n  \"files-error.move\": \"Falha ao mover: {{message}}\",\n  \"files-error.remove-favorite\": \"Falha ao remover dos favoritos: {{message}}\",\n  \"files-error.remove-share\": \"Falha ao remover a pasta compartilhada: {{message}}\",\n  \"files-error.rename\": \"Falha ao renomear: {{message}}\",\n  \"files-error.restore\": \"Falha ao restaurar: {{message}}\",\n  \"files-error.trash\": \"Falha ao mover para a lixeira: {{message}}\",\n  \"files-error.upload\": \"Falha no upload: {{message}}\",\n  \"files-error.upload-network-error\": \"Falha no upload de {{name}}: ocorreu um erro de rede\",\n  \"files-extension-change.confirm\": \"Continuar\",\n  \"files-extension-change.description-add\": \"Tem certeza de que deseja alterar a extensão de '{{fileName}}' para '{{extension}}'? Isso pode tornar o arquivo ilegível.\",\n  \"files-extension-change.description-remove\": \"Tem certeza de que deseja remover a extensão de '{{fileName}}'?\",\n  \"files-extension-change.title-add\": \"Alterar extensão para '{{extension}}'?\",\n  \"files-extension-change.title-remove\": \"Remover extensão?\",\n  \"files-external-storage.unsupported.description\": \"O disco externo conectado não pode ser usado em um Raspberry Pi devido a problemas de energia. O armazenamento externo está disponível no Umbrel Home, Umbrel Pro e em todos os dispositivos x86 (Intel ou AMD).\",\n  \"files-external-storage.unsupported.description-general\": \"O armazenamento externo não está disponível no Raspberry Pi devido a problemas de energia. O armazenamento externo está disponível no Umbrel Home, Umbrel Pro e em todos os dispositivos x86 (Intel ou AMD).\",\n  \"files-external-storage.unsupported.title\": \"Armazenamento Externo Não É Suportado\",\n  \"files-folder\": \"Pasta\",\n  \"files-format.confirm\": \"Formatar\",\n  \"files-format.description\": \"A formatação apagará todos os dados em {{driveName}}. Esta ação não pode ser desfeita.\",\n  \"files-format.description-unreadable\": \"O umbrelOS não consegue ler o conteúdo de {{driveName}}. Você pode formatá-la para usar com o umbrelOS.\",\n  \"files-format.drive-label\": \"Nome\",\n  \"files-format.error\": \"Não foi possível formatar a unidade\",\n  \"files-format.exfat-description\": \"Máxima compatibilidade com Windows, macOS e Linux\",\n  \"files-format.ext4-description\": \"Melhor desempenho com o umbrelOS e Linux\",\n  \"files-format.filesystem\": \"Sistema de arquivos\",\n  \"files-format.filesystem-label\": \"Formatar como\",\n  \"files-format.formatting\": \"Formatando...\",\n  \"files-format.title\": \"Formatar unidade\",\n  \"files-format.title-requires-format\": \"Formatação necessária\",\n  \"files-formatting-island.formatting\": \"Formatando...\",\n  \"files-formatting-island.formatting-drives\": \"Formatando {{count}} unidades\",\n  \"files-listing.empty\": \"Nenhum item\",\n  \"files-listing.error\": \"Ocorreu um erro\",\n  \"files-listing.item-count-truncated\": \"{{formattedCount}}+ itens\",\n  \"files-listing.item-count_one\": \"{{formattedCount}} item\",\n  \"files-listing.item-count_other\": \"{{formattedCount}} itens\",\n  \"files-listing.loading\": \"Carregando...\",\n  \"files-listing.no-such-file\": \"Não há tal arquivo ou pasta\",\n  \"files-listing.selected-count\": \"{{selectedCount}} de {{totalCount}} selecionados\",\n  \"files-listing.selected-count-truncated\": \"{{selectedCount}} de {{totalCount}}+ selecionados\",\n  \"files-name-drawer.new-folder\": \"Nova pasta\",\n  \"files-name-drawer.new-folder-description\": \"Digite um nome para a nova pasta.\",\n  \"files-name-drawer.new-folder-input\": \"Nome da pasta\",\n  \"files-name-drawer.rename-file\": \"Renomear arquivo\",\n  \"files-name-drawer.rename-file-description\": \"Digite um novo nome para este arquivo.\",\n  \"files-name-drawer.rename-file-input\": \"Nome do arquivo\",\n  \"files-name-drawer.rename-folder\": \"Renomear pasta\",\n  \"files-name-drawer.rename-folder-description\": \"Digite um novo nome para esta pasta.\",\n  \"files-name-drawer.rename-folder-input\": \"Nome da pasta\",\n  \"files-network-storage-error.add-share\": \"Falha ao adicionar um compartilhamento de rede: {{message}}\",\n  \"files-network-storage-error.discover-servers\": \"Falha na descoberta de dispositivos de rede: {{message}}\",\n  \"files-network-storage-error.discover-shares\": \"Falha na descoberta de compartilhamentos de rede: {{message}}\",\n  \"files-network-storage-error.remove-share\": \"Falha ao remover o compartilhamento de rede: {{message}}\",\n  \"files-operations-island.copying\": \"Copiando \\\"{{from}}\\\" para \\\"{{to}}\\\"\",\n  \"files-operations-island.moving\": \"Movendo \\\"{{from}}\\\" para \\\"{{to}}\\\"\",\n  \"files-operations-island.restoring\": \"Restaurando \\\"{{from}}\\\" para \\\"{{to}}\\\"\",\n  \"files-path.input-group\": \"Campo de caminho\",\n  \"files-path.input-label\": \"Caminho atual\",\n  \"files-permanently-delete.confirm\": \"Excluir permanentemente\",\n  \"files-permanently-delete.description-multiple\": \"Tem certeza de que deseja excluir permanentemente esses {{count}} itens? Você não pode desfazer essa ação.\",\n  \"files-permanently-delete.description-single\": \"Tem certeza de que deseja excluir permanentemente \\\"{{fileName}}\\\"? Você não pode desfazer essa ação.\",\n  \"files-permanently-delete.title-multiple\": \"Excluir {{count}} itens permanentemente?\",\n  \"files-permanently-delete.title-single\": \"Excluir permanentemente?\",\n  \"files-search.default\": \"Pesquisar arquivos e pastas\",\n  \"files-search.no-results\": \"Nenhum resultado encontrado para \\\"{{query}}\\\"\",\n  \"files-search.placeholder\": \"Pesquisar\",\n  \"files-search.searching-label\": \"Procurando o Umbrel de {{name}}\",\n  \"files-share.home-description\": \"Acesse todos os arquivos em \\\"{{homeDirectoryName}}\\\" de outros dispositivos na sua rede\",\n  \"files-share.home-title\": \"Compartilhar \\\"{{homeDirectoryName}}\\\" pela rede\",\n  \"files-share.instructions.how-to-access\": \"Como acessar\",\n  \"files-share.instructions.ios.enter-password\": \"Insira <field>{{password}}</field> como senha.\",\n  \"files-share.instructions.ios.enter-server\": \"Insira <field>{{smbUrl}}</field> como endereço do servidor.\",\n  \"files-share.instructions.ios.enter-username\": \"Insira <field>{{username}}</field> como nome de usuário.\",\n  \"files-share.instructions.ios.install-files\": \"Instale o app \\\"Arquivos\\\" da App Store, se ainda não estiver instalado.\",\n  \"files-share.instructions.ios.tap-connect\": \"Toque em \\\"Conectar\\\" para acessá-lo.\",\n  \"files-share.instructions.ios.tap-dots\": \"Toque nos três pontos (...) no canto superior direito e selecione \\\"Conectar ao Servidor\\\".\",\n  \"files-share.instructions.macos.click-connect\": \"Clique em \\\"Conectar\\\" para acessá-lo.\",\n  \"files-share.instructions.macos.enter-password\": \"Insira <field>{{password}}</field> como senha.\",\n  \"files-share.instructions.macos.enter-url\": \"Insira <field>{{smbUrl}}</field> e clique em Conectar.\",\n  \"files-share.instructions.macos.enter-username\": \"Insira <field>{{username}}</field> como nome de usuário.\",\n  \"files-share.instructions.macos.open-finder\": \"Abra o Finder e pressione ⌘ + K.\",\n  \"files-share.instructions.macos.select-registered\": \"Selecione \\\"Usuário Registrado\\\" quando solicitado.\",\n  \"files-share.instructions.macos.time-machine\": \"Como usar como local de backup do Time Machine\",\n  \"files-share.instructions.macos.time-machine.choose-encryption\": \"Escolha entre backups criptografados ou não criptografados.\",\n  \"files-share.instructions.macos.time-machine.disk-limit\": \"Em \\\"Limite de Uso do Disco\\\", especifique a quantidade máxima de espaço que deseja reservar no seu Umbrel para backups do Time Machine, depois clique em \\\"Concluído\\\".\",\n  \"files-share.instructions.macos.time-machine.follow-steps\": \"Siga os passos acima e abra Ajustes do Sistema no seu Mac.\",\n  \"files-share.instructions.macos.time-machine.go-settings\": \"Vá em Time Machine e clique em \\\"Adicionar Disco de Backup...\\\".\",\n  \"files-share.instructions.macos.time-machine.select-disk\": \"Selecione a pasta e clique em \\\"Configurar Disco...\\\".\",\n  \"files-share.instructions.umbrelos.backup.follow-onscreen\": \"Siga os passos guiados para configurar seu backup.\",\n  \"files-share.instructions.umbrelos.backup.follow-then-go-to\": \"Siga os passos acima e depois vá para \\\"{{settings}}\\\" > \\\"{{backups}}\\\" no seu outro Umbrel.\",\n  \"files-share.instructions.umbrelos.backup.select-add\": \"Selecione a opção \\\"{{addUmbrelOrNas}}\\\".\",\n  \"files-share.instructions.umbrelos.backup.select-connected\": \"Selecione este dispositivo Umbrel na lista de dispositivos conectados.\",\n  \"files-share.instructions.umbrelos.backup.title\": \"Como usar como local de backup para seu outro Umbrel\",\n  \"files-share.instructions.umbrelos.cant-find-note\": \"Não consegue encontrar? Tente selecionar \\\"Adicionar manualmente\\\" e usar as credenciais abaixo. Se ainda não conseguir adicioná-lo, verifique se os dois dispositivos estão na mesma rede.\",\n  \"files-share.instructions.umbrelos.enter-password\": \"Digite <field>{{password}}</field> como senha.\",\n  \"files-share.instructions.umbrelos.enter-username\": \"Digite <field>{{username}}</field> como nome de usuário.\",\n  \"files-share.instructions.umbrelos.open-and-click\": \"No seu outro Umbrel, abra \\\"Files\\\" e clique em <plus/> ao lado de \\\"<deviceIcon/> {{deviceLabel}}\\\" na barra lateral.\",\n  \"files-share.instructions.umbrelos.select-device\": \"Selecione este dispositivo Umbrel na lista de dispositivos detectados automaticamente na sua rede.\",\n  \"files-share.instructions.umbrelos.select-sharename\": \"Selecione \\\"{{sharename}}\\\" e clique para adicionar o compartilhamento.\",\n  \"files-share.instructions.windows.enter-password\": \"Insira <field>{{password}}</field> como senha.\",\n  \"files-share.instructions.windows.enter-url\": \"Digite <field>{{smbUrl}}</field> e pressione Enter.\",\n  \"files-share.instructions.windows.enter-username\": \"Insira <field>{{username}}</field> como nome de usuário.\",\n  \"files-share.instructions.windows.open-run\": \"Pressione Windows + R para abrir a janela Executar.\",\n  \"files-share.instructions.windows.remember-credentials\": \"Marque \\\"Remember my credentials\\\" e clique em OK.\",\n  \"files-share.regular-description\": \"Compartilhe esta pasta para acessá-la de outros dispositivos na sua rede\",\n  \"files-share.regular-title\": \"Compartilhar pasta pela rede\",\n  \"files-share.toggle\": \"Compartilhar \\\"{{name}}\\\" na sua rede\",\n  \"files-sidebar.apps\": \"Apps\",\n  \"files-sidebar.external-storage\": \"Armazenamento externo\",\n  \"files-sidebar.favorites\": \"Favoritos\",\n  \"files-sidebar.home\": \"Início\",\n  \"files-sidebar.navigation\": \"Navegação de arquivos\",\n  \"files-sidebar.network\": \"Rede\",\n  \"files-sidebar.network-pathbar\": \"Dispositivos de rede\",\n  \"files-sidebar.network-sidebar\": \"Dispositivos\",\n  \"files-sidebar.recents\": \"Recentes\",\n  \"files-sidebar.shared-folders\": \"Pastas compartilhadas\",\n  \"files-sidebar.trash\": \"Lixeira\",\n  \"files-sidebar.trash.open\": \"Abrir\",\n  \"files-sort.created\": \"Adicionado\",\n  \"files-sort.modified\": \"Modificado\",\n  \"files-sort.name\": \"Nome\",\n  \"files-sort.size\": \"Tamanho\",\n  \"files-sort.type\": \"Tipo\",\n  \"files-state.uploading\": \"Enviando...\",\n  \"files-state.waiting\": \"Aguardando...\",\n  \"files-type.3gp\": \"Vídeo 3GP\",\n  \"files-type.3gp2\": \"Vídeo 3GP2\",\n  \"files-type.7z\": \"Arquivo 7Z\",\n  \"files-type.aac\": \"Áudio AAC\",\n  \"files-type.ai\": \"Arquivo do Illustrator\",\n  \"files-type.aiff\": \"Áudio AIFF\",\n  \"files-type.au\": \"Áudio AU\",\n  \"files-type.avi\": \"Vídeo AVI\",\n  \"files-type.avif\": \"Imagem AVIF\",\n  \"files-type.bmp\": \"Imagem BMP\",\n  \"files-type.bzip2\": \"Arquivo BZIP2\",\n  \"files-type.caf\": \"Áudio CAF\",\n  \"files-type.compressed\": \"Arquivo compactado\",\n  \"files-type.csv\": \"Arquivo CSV\",\n  \"files-type.directory\": \"Pasta\",\n  \"files-type.dmg\": \"Imagem de disco\",\n  \"files-type.dv\": \"Vídeo DV\",\n  \"files-type.epub\": \"eBook EPUB\",\n  \"files-type.excel\": \"Planilha do Excel\",\n  \"files-type.exe\": \"Executável do Windows\",\n  \"files-type.executable\": \"Executável\",\n  \"files-type.external-drive\": \"Unidade\",\n  \"files-type.flac\": \"Áudio FLAC\",\n  \"files-type.flv\": \"Vídeo FLV\",\n  \"files-type.gif\": \"Imagem GIF\",\n  \"files-type.gzip\": \"Arquivo GZIP\",\n  \"files-type.heic\": \"Imagem HEIC\",\n  \"files-type.ico\": \"Imagem ICO\",\n  \"files-type.iso\": \"Imagem ISO\",\n  \"files-type.jpeg\": \"Imagem JPEG\",\n  \"files-type.keynote\": \"Apresentação do Keynote\",\n  \"files-type.lzip\": \"Arquivo LZIP\",\n  \"files-type.lzma\": \"Arquivo LZMA\",\n  \"files-type.lzop\": \"Arquivo LZOP\",\n  \"files-type.m3u\": \"Playlist M3U\",\n  \"files-type.m4a\": \"Áudio M4A\",\n  \"files-type.m4v\": \"Vídeo M4V\",\n  \"files-type.midi\": \"Áudio MIDI\",\n  \"files-type.mka\": \"Áudio MKA\",\n  \"files-type.mkv\": \"Vídeo MKV\",\n  \"files-type.mng\": \"Vídeo MNG\",\n  \"files-type.mobi\": \"eBook MOBI\",\n  \"files-type.mp3\": \"Áudio MP3\",\n  \"files-type.mp4\": \"Vídeo MP4\",\n  \"files-type.mp4-audio\": \"Áudio MP4\",\n  \"files-type.mpeg\": \"Vídeo MPEG\",\n  \"files-type.mpeg-ts\": \"Fluxo de transporte MPEG\",\n  \"files-type.network-drive\": \"Unidade de rede\",\n  \"files-type.numbers\": \"Planilha do Numbers\",\n  \"files-type.ogg\": \"Áudio OGG\",\n  \"files-type.ogv\": \"Vídeo OGV\",\n  \"files-type.pages\": \"Documento do Pages\",\n  \"files-type.pdf\": \"Documento PDF\",\n  \"files-type.png\": \"Imagem PNG\",\n  \"files-type.powerpoint\": \"Apresentação do PowerPoint\",\n  \"files-type.psd\": \"Documento do Photoshop\",\n  \"files-type.quicktime\": \"Vídeo QuickTime\",\n  \"files-type.rar\": \"Arquivo RAR\",\n  \"files-type.sgi\": \"Vídeo SGI\",\n  \"files-type.svg\": \"Imagem SVG\",\n  \"files-type.tar\": \"Arquivo TAR\",\n  \"files-type.tiff\": \"Imagem TIFF\",\n  \"files-type.ts\": \"Vídeo TS\",\n  \"files-type.txt\": \"Arquivo de texto\",\n  \"files-type.umbrel-backup\": \"Umbrel Backup\",\n  \"files-type.wav\": \"Áudio WAV\",\n  \"files-type.webm\": \"Vídeo WebM\",\n  \"files-type.webm-audio\": \"Áudio WebM\",\n  \"files-type.webp\": \"Imagem WebP\",\n  \"files-type.wma\": \"Áudio WMA\",\n  \"files-type.wmv\": \"Vídeo WMV\",\n  \"files-type.word\": \"Documento do Word\",\n  \"files-type.xz\": \"Arquivo XZ\",\n  \"files-type.zip\": \"Arquivo ZIP\",\n  \"files-upload-island.uploading-count\": \"Enviando {{count}} itens\",\n  \"files-view.icons\": \"Ícones\",\n  \"files-view.list\": \"Lista\",\n  \"files-view.sort-by\": \"Classificar por\",\n  \"files-view.view-as\": \"Exibir como\",\n  \"files-widgets.favorites.no-items-text\": \"Adicione uma pasta aos favoritos para vê-la aqui\",\n  \"files-widgets.recents.no-items-text\": \"Nenhum arquivo recente\",\n  \"generic-in\": \"em\",\n  \"hide-details\": \"Ocultar detalhes\",\n  \"install-first.install-app\": \"Instalar {{app}}\",\n  \"install-first.title\": \"{{app}} requer estes aplicativos\",\n  \"install-your-first-app\": \"Instale seu primeiro aplicativo\",\n  \"language\": \"Idioma\",\n  \"language-description\": \"Seu idioma preferido do umbrelOS\",\n  \"language.select-description\": \"Selecionar o idioma preferido do umbrelOS\",\n  \"live-usage\": \"Uso ao Vivo\",\n  \"loading\": \"Carregando\",\n  \"local-ip\": \"IP Local\",\n  \"login-2fa.subtitle\": \"Digite o código 2FA exibido no seu aplicativo autenticador\",\n  \"login-2fa.title\": \"Autenticar\",\n  \"login-with-umbrel.description\": \"Digite sua senha do Umbrel para abrir {{app}}\",\n  \"login-with-umbrel.title\": \"Entrar com Umbrel\",\n  \"login.password-label\": \"Senha\",\n  \"login.password.submit\": \"Entrar\",\n  \"login.subtitle\": \"Digite sua senha do Umbrel para fazer login\",\n  \"login.title\": \"Bem-vindo de volta\",\n  \"logout\": \"Sair\",\n  \"logout-error-generic\": \"Erro: Falha ao sair\",\n  \"logout.confirm.submit\": \"Sair\",\n  \"logout.confirm.title\": \"Você tem certeza que deseja sair?\",\n  \"memory\": \"Memória\",\n  \"memory.low\": \"Memória baixa\",\n  \"migrate\": \"Migrar\",\n  \"migrate.callout\": \"Não desligue seu Umbrel até a migração estar completa\",\n  \"migrate.failed.retry\": \"Tentar novamente\",\n  \"migrate.failed.title\": \"Falha na migração\",\n  \"migrate.success.description\": \"Todos os seus aplicativos, dados de aplicativos e detalhes da conta foram migrados para o seu Umbrel Home.\",\n  \"migrate.success.title\": \"Migração bem-sucedida\",\n  \"migration-assistant\": \"Assistente de Migração\",\n  \"migration-assistant-description\": \"Transfira todos os seus aplicativos e dados de um Raspberry Pi para {{deviceName}}\",\n  \"migration-assistant-unsupported-device-description\": \"O Assistente de Migração atualmente suporta a transferência de todos os dados e aplicativos de um Raspberry Pi com umbrelOS para Umbrel Home ou Umbrel Pro. Abra o Assistente de Migração no seu Umbrel Home ou Umbrel Pro para começar.\",\n  \"migration-assistant.continue-migration.ready.submit\": \"Iniciar migração\",\n  \"migration-assistant.failed\": \"Algo não está certo...\",\n  \"migration-assistant.failed.retrying-message\": \"Tentando novamente...\",\n  \"migration-assistant.mobile.start-button\": \"Iniciar migração\",\n  \"migration-assistant.prep.body\": \"Preparar para migração\",\n  \"migration-assistant.prep.button-continue\": \"Continuar\",\n  \"migration-assistant.prep.callout\": \"Os dados no seu {{deviceName}}, se houver, serão excluídos permanentemente.\",\n  \"migration-assistant.prep.connect-disk-to-home\": \"Conecte o disco externo a qualquer porta USB do seu {{deviceName}}.\",\n  \"migration-assistant.prep.prep-done-continue-message\": \"Uma vez feito, clique em '{{button}}' abaixo.\",\n  \"migration-assistant.prep.shut-down-rpi\": \"Desligue seu Umbrel Raspberry Pi.\",\n  \"migration-assistant.ready.description\": \"Todos os seus dados e aplicativos estão prontos para serem migrados para o seu {{deviceName}}\",\n  \"migration-assistant.ready.hint-header\": \"Coisas a ter em mente\",\n  \"migration-assistant.ready.hint-keep-pi-off.description\": \"Isso ajuda a evitar problemas com aplicativos como o Lightning Node\",\n  \"migration-assistant.ready.hint-keep-pi-off.title\": \"Mantenha seu Raspberry Pi desligado após a atualização\",\n  \"migration-assistant.ready.hint-use-same-password.description\": \"Lembre-se de usar a senha do seu Umbrel no Raspberry Pi para fazer login no seu {{deviceName}}\",\n  \"migration-assistant.ready.hint-use-same-password.title\": \"Use a mesma senha\",\n  \"migration-assistant.ready.title\": \"Tudo pronto para migrar!\",\n  \"mini-browser.default-title\": \"Selecionar pasta\",\n  \"mini-browser.empty-external\": \"Conecte uma unidade externa para que ela apareça aqui.\",\n  \"mini-browser.empty-network\": \"Adicione um Umbrel ou um NAS para que ele apareça aqui.\",\n  \"mini-browser.load-more\": \"Carregar mais\",\n  \"mini-browser.load-more-in-folder\": \"Carregar mais em {{name}}\",\n  \"mini-browser.loading-more\": \"Carregando mais…\",\n  \"mini-browser.select\": \"Selecionar\",\n  \"mini-browser.select-folder\": \"Selecionar pasta\",\n  \"name\": \"Nome\",\n  \"nas\": \"NAS\",\n  \"no-forgot-password-message\": \"Se você perder sua senha, não será possível fazer login no seu Umbrel. Certifique-se de guardá-la em segurança.\",\n  \"no-results-found\": \"Nenhum resultado encontrado\",\n  \"not-found-404\": \"Código de erro: 404\",\n  \"not-found-404.back\": \"Voltar\",\n  \"not-found-404.home\": \"Ir para Início\",\n  \"notifications.backups-failing-location.description\": \"Backups automáticos para {{location}} estão falhando. Verifique a conexão e reveja suas configurações de Backups.\",\n  \"notifications.backups-failing.description\": \"Os Backups automáticos estão falhando. Verifique o local dos Backups e reveja suas configurações.\",\n  \"notifications.backups-failing.go-to-backups\": \"Ir para Backups\",\n  \"notifications.backups-failing.title\": \"Sem Backups nas últimas 24 horas\",\n  \"notifications.cpu.too-hot\": \"Temperatura alta da CPU\",\n  \"notifications.memory.low\": \"A memória do seu dispositivo está baixa\",\n  \"notifications.new-version-available\": \"{{update}} agora está disponível para instalação\",\n  \"notifications.raid.issue.description\": \"Detectado um problema de armazenamento. Verifique o Storage Manager para obter detalhes.\",\n  \"notifications.raid.issue.title\": \"Ação urgente necessária\",\n  \"notifications.ssd.health.description\": \"Um ou mais SSDs podem precisar de atenção. Verifique o Storage Manager para obter detalhes.\",\n  \"notifications.ssd.health.title\": \"Aviso de saúde do SSD\",\n  \"notifications.storage.full\": \"O armazenamento do seu dispositivo está cheio\",\n  \"notifications.view\": \"Visualizar\",\n  \"ok\": \"OK\",\n  \"onboarding.account-created.by-clicking-button-you-agree\": \"Ao clicar em 'Próximo', você concorda com os <linked>Termos de Serviço do umbrelOS</linked>\",\n  \"onboarding.account-created.youre-all-set-name\": \"Tudo pronto, {{name}}.\",\n  \"onboarding.contact-support\": \"Suporte\",\n  \"onboarding.create-account\": \"Criar conta\",\n  \"onboarding.create-account.confirm-password.input-label\": \"Confirmar senha\",\n  \"onboarding.create-account.failed.name-required\": \"Nome é necessário\",\n  \"onboarding.create-account.failed.passwords-dont-match\": \"As senhas não coincidem\",\n  \"onboarding.create-account.name.input-placeholder\": \"Seu nome\",\n  \"onboarding.create-account.password.input-label\": \"Senha\",\n  \"onboarding.create-account.submit\": \"Criar\",\n  \"onboarding.create-account.submitting\": \"Criando\",\n  \"onboarding.create-account.subtitle\": \"Suas informações de conta são armazenadas apenas no seu Umbrel. Certifique-se de fazer um backup seguro da sua senha, pois não há como redefini-la.\",\n  \"onboarding.create-instead-long\": \"Criar nova conta\",\n  \"onboarding.create-instead-short\": \"Nova conta\",\n  \"onboarding.launch-umbrelos\": \"Iniciar umbrelOS\",\n  \"onboarding.raid.available-storage\": \"Armazenamento disponível\",\n  \"onboarding.raid.change-drives-link\": \"Precisa adicionar ou trocar as unidades?\",\n  \"onboarding.raid.configuring.subtitle\": \"Isso pode levar alguns minutos.\",\n  \"onboarding.raid.configuring.title\": \"Configurando seu armazenamento\",\n  \"onboarding.raid.configuring.warning\": \"Por favor, não atualize esta página nem desligue seu Umbrel enquanto ele estiver configurando seu armazenamento.\",\n  \"onboarding.raid.continue\": \"Continuar\",\n  \"onboarding.raid.error.detection-instructions\": \"Desligue o Umbrel Pro, verifique se os SSDs estão corretamente encaixados e tente novamente.\",\n  \"onboarding.raid.error.no-ssds-detected\": \"Nenhum SSD detectado\",\n  \"onboarding.raid.error.no-ssds-instructions\": \"Desligue o Umbrel Pro e insira pelo menos um SSD para continuar.\",\n  \"onboarding.raid.failsafe\": \"FailSafe\",\n  \"onboarding.raid.failsafe.cant-enable\": \"Ainda não é possível ativar o FailSafe\",\n  \"onboarding.raid.failsafe.enable\": \"Ativar FailSafe\",\n  \"onboarding.raid.failsafe.mixed-sizes\": \"O FailSafe é limitado pelo seu menor SSD ({{smallest}}). Espaço extra em SSDs maiores não pode ser usado, deixando {{wasted}} inutilizável.\",\n  \"onboarding.raid.failsafe.protection-info-2ssds\": \"O {{protection}} é usado para proteção de dados. Adicione mais um SSD de {{smallest}} para aumentar o armazenamento disponível para {{futureWith3}}, ou adicione mais dois para {{futureWith4}}. Você pode adicionar mais SSDs a qualquer momento.\",\n  \"onboarding.raid.failsafe.protection-info-3ssds\": \"O {{protection}} é usado para proteção de dados. Adicione mais um SSD de {{smallest}} para aumentar o armazenamento disponível para {{futureWith4}}. Você pode adicionar mais SSDs a qualquer momento.\",\n  \"onboarding.raid.failsafe.single-ssd-info\": \"Você tem apenas um SSD. Adicione pelo menos mais um SSD de {{size}} para ativar a proteção FailSafe dos seus dados. Você pode adicionar mais SSDs a qualquer momento.\",\n  \"onboarding.raid.failsafe.subtitle\": \"Se qualquer SSD falhar, seus dados ficam protegidos\",\n  \"onboarding.raid.failsafe.tip\": \"Use SSDs do mesmo tamanho para obter o máximo de armazenamento e zero espaço inutilizável.\",\n  \"onboarding.raid.failsafe.warning-now-only\": \"Com mais de um SSD, o FailSafe só pode ser ativado durante a configuração inicial. Você não poderá ativá-lo depois.\",\n  \"onboarding.raid.health-warning\": \"Esta unidade está apresentando problemas de integridade\",\n  \"onboarding.raid.launching\": \"Iniciando...\",\n  \"onboarding.raid.no-ssds-alt\": \"Nenhum SSD encontrado\",\n  \"onboarding.raid.recommended\": \"Recomendado\",\n  \"onboarding.raid.scanning\": \"Verificando os slots de SSD\",\n  \"onboarding.raid.scanning-alt\": \"Escaneando SSDs\",\n  \"onboarding.raid.setup-failed.description-no-retry\": \"Desligue o Umbrel Pro e tente novamente.\",\n  \"onboarding.raid.setup-failed.description-retry\": \"Tente novamente ou desligue o Umbrel Pro para verificar seus SSDs.\",\n  \"onboarding.raid.setup-failed.title\": \"Falha na configuração do armazenamento\",\n  \"onboarding.raid.shutdown-dialog.description\": \"Para adicionar ou trocar unidades, desligue o Umbrel Pro. Depois de terminar, você pode ligá‑lo novamente e continuar a configuração.\",\n  \"onboarding.raid.shutdown-dialog.title\": \"Trocar unidades?\",\n  \"onboarding.raid.ssd-in-slot\": \"Um <highlight>{{size}}</highlight> SSD em <highlight>Slot {{slot}}</highlight>\",\n  \"onboarding.raid.ssd-label\": \"SSD {{number}}\",\n  \"onboarding.raid.ssd-tray-alt\": \"Bandeja do SSD\",\n  \"onboarding.raid.ssds-found\": \"Os seguintes SSDs foram encontrados no seu Umbrel Pro\",\n  \"onboarding.raid.storage\": \"Armazenamento\",\n  \"onboarding.raid.storage-label\": \"Armazenamento\",\n  \"onboarding.raid.success.storage-info\": \"Armazenamento {{available}}\",\n  \"onboarding.raid.success.storage-info-failsafe\": \"Armazenamento {{available}} · FailSafe {{failsafe}}\",\n  \"onboarding.raid.try-again\": \"Tentar novamente\",\n  \"onboarding.raid.wasted\": \"Inutilizável\",\n  \"onboarding.restore-long\": \"Restaurar meu Umbrel\",\n  \"onboarding.restore-short\": \"Restaurar\",\n  \"onboarding.start.continue\": \"Começar\",\n  \"onboarding.start.subtitle\": \"Seu servidor de nuvem doméstico está pronto para ser configurado.\",\n  \"onboarding.start.title\": \"Bem-vindo ao umbrelOS\",\n  \"open\": \"Abrir\",\n  \"open-live-usage\": \"Abrir Uso ao Vivo\",\n  \"password\": \"Senha\",\n  \"preferences\": \"Preferências\",\n  \"raid-error.description\": \"Seu sistema de armazenamento não pôde iniciar corretamente. Verifique o status dos seus SSDs abaixo e siga os passos de solução. Se o problema persistir, os SSDs afetados podem precisar ser substituídos.\",\n  \"raid-error.factory-reset-dialog.description\": \"Isso apagará todos os dados do seu Umbrel Pro e o resetará para as configurações de fábrica. Esta ação não pode ser desfeita.\",\n  \"raid-error.factory-reset-dialog.title\": \"Restaurar de fábrica?\",\n  \"raid-error.factory-reset-failed\": \"Não foi possível restaurar as configurações de fábrica\",\n  \"raid-error.health-warning\": \"Aviso de saúde\",\n  \"raid-error.missing-ssd-multiple\": \"{{count}} SSDs não estão respondendo\",\n  \"raid-error.missing-ssd-one\": \"1 SSD não está respondendo\",\n  \"raid-error.shutdown-dialog.description\": \"Desligue seu Umbrel Pro, verifique se todos os SSDs estão corretamente encaixados em seus slots e ligue novamente.\",\n  \"raid-error.shutdown-dialog.title\": \"Desligar para verificar os SSDs?\",\n  \"raid-error.ssd-in-slot\": \"Um SSD de <highlight>{{size}}</highlight> em <highlight>Slot {{slot}}</highlight>\",\n  \"raid-error.step-check-connections.button\": \"Desligar\",\n  \"raid-error.step-check-connections.description\": \"Desligue e verifique se todos os SSDs estão devidamente encaixados.\",\n  \"raid-error.step-check-connections.title\": \"Verifique as conexões dos SSDs\",\n  \"raid-error.step-factory-reset.button\": \"Restaurar de fábrica\",\n  \"raid-error.step-factory-reset.description\": \"Último recurso se nada mais funcionar. Isso apaga todos os dados.\",\n  \"raid-error.step-factory-reset.title\": \"Restaurar de fábrica\",\n  \"raid-error.step-restart.button\": \"Reiniciar\",\n  \"raid-error.step-restart.description\": \"Um primeiro passo rápido que costuma ajudar\",\n  \"raid-error.step-restart.title\": \"Tente reiniciar\",\n  \"raid-error.title\": \"Problema de armazenamento detectado\",\n  \"read-less\": \"Ler menos\",\n  \"read-more\": \"Ler mais\",\n  \"reconnect\": \"Reconectar\",\n  \"redirect.to-home\": \"Carregando...\",\n  \"redirect.to-login\": \"Carregando...\",\n  \"redirect.to-onboarding\": \"Carregando...\",\n  \"redirect.to-raid-error\": \"Carregando...\",\n  \"reload\": \"Recarregar\",\n  \"remote-tor-access\": \"Acesso remoto Tor\",\n  \"reset\": \"Restaurar\",\n  \"restart\": \"Reiniciar\",\n  \"restart.confirm.submit\": \"Reiniciar\",\n  \"restart.confirm.title\": \"Você tem certeza de que deseja reiniciar seu Umbrel?\",\n  \"restart.restarting\": \"Reiniciando\",\n  \"restart.restarting-message\": \"Por favor, não atualize esta página ou desligue seu Umbrel enquanto ele está reiniciando.\",\n  \"rewind\": \"Rewind\",\n  \"rewind.files-as-of\": \"Seus arquivos em\",\n  \"rewind.loading-snapshots\": \"Carregando instantâneos...\",\n  \"rewind.now\": \"Agora\",\n  \"rewind.preflight.description\": \"Encontre arquivos e pastas dos seus backups antigos e recupere-os para o presente.\",\n  \"rewind.preflight.enable-backups\": \"Configure Backups em Configurações para começar a usar Rewind\",\n  \"rewind.restore-complete\": \"Restauração concluída\",\n  \"rewind.restore-error-description\": \"Por favor, tente novamente.\",\n  \"rewind.restore-failed\": \"Falha na restauração\",\n  \"rewind.restore-running-description\": \"Não feche nem atualize esta página até que a restauração seja concluída\",\n  \"rewind.restore-selected\": \"Restaurar selecionados\",\n  \"rewind.restore-success-description\": \"Seus arquivos foram restaurados\",\n  \"rewind.restoring\": \"Restaurando\",\n  \"rewind.snapshots-count_one\": \"{{count}} backup desde\",\n  \"rewind.snapshots-count_other\": \"{{count}} backups desde\",\n  \"search\": \"Pesquisar\",\n  \"settings\": \"Configurações\",\n  \"settings.app-store-preferences.title\": \"Preferências da App Store\",\n  \"settings.contact-support\": \"Precisa de ajuda? <linked>Contate o suporte.</linked>\",\n  \"settings.file-sharing\": \"Compartilhamento de arquivos\",\n  \"settings.file-sharing.add-folder\": \"Adicionar\",\n  \"settings.file-sharing.add-folder-title\": \"Selecione uma pasta para compartilhar\",\n  \"settings.file-sharing.choice-entire-description\": \"Compartilhe todos os arquivos no seu Umbrel\",\n  \"settings.file-sharing.choice-entire-title\": \"Tudo\",\n  \"settings.file-sharing.choice-heading\": \"O que você gostaria de compartilhar?\",\n  \"settings.file-sharing.choice-specific-description\": \"Escolha quais pastas compartilhar\",\n  \"settings.file-sharing.choice-specific-title\": \"Pastas específicas\",\n  \"settings.file-sharing.choice-subtitle\": \"Acesse seus arquivos e pastas no estilo Dropbox como pastas de rede no seu computador ou celular\",\n  \"settings.file-sharing.configure\": \"Configurar\",\n  \"settings.file-sharing.description\": \"Acesse seus arquivos no estilo Dropbox como uma pasta de rede (SMB) em outros dispositivos\",\n  \"settings.file-sharing.home-shared-note\": \"Sua pasta inteira \\\"{{homeDirectoryName}}\\\" está compartilhada. Pastas individuais não precisam ser compartilhadas separadamente.\",\n  \"settings.file-sharing.share-entire-home-dir\": \"Compartilhar toda a pasta Home\",\n  \"settings.file-sharing.share-entire-home-dir-description\": \"Acesse todos os arquivos e pastas em \\\"{{homeDirectoryName}}\\\" em outros dispositivos da sua rede\",\n  \"settings.file-sharing.shared-folders\": \"Pastas compartilhadas\",\n  \"show-details\": \"Mostrar detalhes\",\n  \"shut-down\": \"Desligar\",\n  \"shut-down.complete\": \"Desligamento completo\",\n  \"shut-down.complete-text\": \"Agora você pode desconectar seu dispositivo da energia.\",\n  \"shut-down.confirm.submit\": \"Desligar\",\n  \"shut-down.confirm.title\": \"Você tem certeza de que deseja desligar seu Umbrel?\",\n  \"shut-down.failed\": \"Falha ao desligar: {{message}}\",\n  \"shut-down.shutting-down\": \"Desligando\",\n  \"shut-down.shutting-down-message\": \"Por favor, não atualize esta página ou desligue seu Umbrel enquanto ele está desligando.\",\n  \"software-update.callout\": \"Por favor, não atualize esta página ou desligue seu Umbrel enquanto estiver atualizando.\",\n  \"software-update.check\": \"Verificar atualização\",\n  \"software-update.checking\": \"Verificando atualização...\",\n  \"software-update.current-running\": \"Você está em\",\n  \"software-update.failed\": \"Falha ao atualizar\",\n  \"software-update.failed-to-check\": \"Falha ao verificar atualizações\",\n  \"software-update.failed.retry\": \"Tentar novamente\",\n  \"software-update.install-now\": \"Instalar agora\",\n  \"software-update.new-version\": \"Nova versão do {{name}} disponível para instalação\",\n  \"software-update.on-latest\": \"Você está na última versão do umbrelOS\",\n  \"software-update.see-whats-new\": \"Veja <linked>o que há de novo</linked>\",\n  \"software-update.title\": \"Atualização de software\",\n  \"software-update.updating-to\": \"Atualizando para {{name}}\",\n  \"software-update.view\": \"Visualizar\",\n  \"something-left\": \"{{left}} restantes\",\n  \"something-went-wrong\": \"⚠ Algo deu errado\",\n  \"start\": \"Iniciar\",\n  \"stop\": \"Parar\",\n  \"storage\": \"Armazenamento\",\n  \"storage-manager\": \"Gerenciador de Armazenamento\",\n  \"storage-manager.add\": \"Adicionar\",\n  \"storage-manager.add-to-raid.add-ssd\": \"Adicionar SSD\",\n  \"storage-manager.add-to-raid.available\": \"Disponível:\",\n  \"storage-manager.add-to-raid.description\": \"Um novo SSD foi detectado e está pronto para ser adicionado.\",\n  \"storage-manager.add-to-raid.enable-failsafe\": \"Ativar FailSafe\",\n  \"storage-manager.add-to-raid.failed-add\": \"Não foi possível adicionar o SSD\",\n  \"storage-manager.add-to-raid.failed-enable-failsafe\": \"Não foi possível ativar o FailSafe\",\n  \"storage-manager.add-to-raid.failsafe-label\": \"FailSafe:\",\n  \"storage-manager.add-to-raid.info-capacity-added\": \"Seu novo SSD de <highlight>{{size}}</highlight> será adicionado ao armazenamento disponível.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-available\": \"Seu novo SSD de <highlight>{{size}}</highlight> adicionará <highlight>{{available}}</highlight> ao armazenamento disponível.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-both\": \"Seu novo SSD de <highlight>{{size}}</highlight> adicionará <highlight>{{available}}</highlight> ao armazenamento disponível e <highlight>{{protection}}</highlight> para proteção de dados.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only\": \"Seu novo SSD de <highlight>{{size}}</highlight> adicionará <highlight>{{protection}}</highlight> para proteção de dados.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only-full\": \"Seu novo SSD de <highlight>{{size}}</highlight> será usado inteiramente para proteção de dados.\",\n  \"storage-manager.add-to-raid.info-data-safe\": \"Se qualquer SSD falhar, seus dados estarão protegidos.\",\n  \"storage-manager.add-to-raid.info-no-protection\": \"Se um SSD falhar, você pode perder seus dados.\",\n  \"storage-manager.add-to-raid.info-total-wasted\": \"<wasted>{{size}}</wasted> total inutilizável devido a tamanhos diferentes de SSD.\",\n  \"storage-manager.add-to-raid.info-wasted\": \"<wasted>{{size}}</wasted> ficará inutilizável devido a tamanhos diferentes de SSD.\",\n  \"storage-manager.add-to-raid.recommended\": \"Recomendado\",\n  \"storage-manager.add-to-raid.recommended-inline\": \"(recomendado)\",\n  \"storage-manager.add-to-raid.restart-active-tasks\": \"Quaisquer tarefas ativas serão interrompidas\",\n  \"storage-manager.add-to-raid.restart-after\": \"Após a reinicialização, a configuração do FailSafe será concluída automaticamente e você poderá retomar o uso normal.\",\n  \"storage-manager.add-to-raid.restart-during\": \"Durante a reinicialização:\",\n  \"storage-manager.add-to-raid.restart-intro\": \"Você pode continuar usando o umbrelOS normalmente durante este processo. Entretanto, ao atingir 50% de progresso seu Umbrel reiniciará automaticamente.\",\n  \"storage-manager.add-to-raid.restart-required\": \"Reinicialização do sistema necessária\",\n  \"storage-manager.add-to-raid.restart-ui-inaccessible\": \"O umbrelOS ficará temporariamente inacessível\",\n  \"storage-manager.add-to-raid.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD em <highlight>Slot {{slot}}</highlight>\",\n  \"storage-manager.add-to-raid.title\": \"Adicionar SSD ao armazenamento\",\n  \"storage-manager.add-to-raid.too-small\": \"SSD pequeno demais\",\n  \"storage-manager.add-to-raid.too-small-description\": \"Este SSD ({{deviceSize}}) é menor que o menor SSD atualmente instalado ({{minSize}}). O FailSafe exige que todos os SSDs sejam, no mínimo, do mesmo tamanho do menor SSD em uso.\",\n  \"storage-manager.add-to-raid.understand-continue\": \"Entendi, continuar\",\n  \"storage-manager.add-to-raid.warning-failsafe-now-only\": \"Ter mais de um SSD significa que o FailSafe só pode ser ativado agora. Você não poderá ativá-lo depois.\",\n  \"storage-manager.add-to-raid.wasted-label\": \"Inutilizável:\",\n  \"storage-manager.available-storage\": \"Armazenamento disponível\",\n  \"storage-manager.description\": \"Veja o armazenamento, a saúde e as configurações dos seus SSDs\",\n  \"storage-manager.empty\": \"Vazio\",\n  \"storage-manager.failsafe-transition-failed\": \"Não foi possível ativar o FailSafe\",\n  \"storage-manager.for-failsafe\": \"Para FailSafe\",\n  \"storage-manager.health.checksum-errors\": \"Erros de checksum: {{count}}\",\n  \"storage-manager.health.critical\": \"Crítico\",\n  \"storage-manager.health.critical-threshold\": \"Limite crítico\",\n  \"storage-manager.health.current-temperature\": \"Temperatura atual\",\n  \"storage-manager.health.estimated-life\": \"Vida útil estimada restante\",\n  \"storage-manager.health.general\": \"Geral\",\n  \"storage-manager.health.health-status\": \"Estado de saúde\",\n  \"storage-manager.health.low\": \"Baixo\",\n  \"storage-manager.health.model-and-capacity\": \"Modelo e tamanho\",\n  \"storage-manager.health.overheating\": \"Superaquecimento\",\n  \"storage-manager.health.raid-failed-advice\": \"Este SSD apresenta um problema. Desligue seu Umbrel e verifique a conexão do SSD. Se o problema continuar, pode ser necessário substituir o SSD.\",\n  \"storage-manager.health.read-errors\": \"Erros de leitura: {{count}}\",\n  \"storage-manager.health.serial-number\": \"Número de série\",\n  \"storage-manager.health.status-healthy\": \"Saudável\",\n  \"storage-manager.health.status-unhealthy\": \"Não saudável\",\n  \"storage-manager.health.status-unknown\": \"Desconhecido\",\n  \"storage-manager.health.temperature\": \"Temperatura\",\n  \"storage-manager.health.title\": \"Saúde do SSD\",\n  \"storage-manager.health.warning-life-advice\": \"Considere substituir este SSD em breve.\",\n  \"storage-manager.health.warning-life-message\": \"Apenas {{percent}}% de vida restante\",\n  \"storage-manager.health.warning-temp-advice\": \"Certifique-se de que seu Umbrel Pro tenha boa ventilação e que o SSD esteja bem encaixado.\",\n  \"storage-manager.health.warning-temp-critical\": \"Temperatura crítica ({{temperature}})\",\n  \"storage-manager.health.warning-temp-overheating\": \"A unidade está superaquecendo ({{temperature}})\",\n  \"storage-manager.health.warning-threshold\": \"Limite de aviso\",\n  \"storage-manager.health.warning-unhealthy-advice\": \"Este SSD pode falhar em breve. Considere substituí-lo.\",\n  \"storage-manager.health.warning-unhealthy-message\": \"Este SSD pode ter um problema\",\n  \"storage-manager.health.warnings\": \"Avisos\",\n  \"storage-manager.health.wear\": \"Desgaste\",\n  \"storage-manager.health.write-errors\": \"Erros de gravação: {{count}}\",\n  \"storage-manager.install-ssd.description\": \"Adicione mais SSDs para expandir seu armazenamento\",\n  \"storage-manager.install-ssd.step-insert\": \"Insira os novos SSDs nos slots vazios\",\n  \"storage-manager.install-ssd.step-power-on\": \"Ligue seu {{deviceName}}\",\n  \"storage-manager.install-ssd.step-remove-bottom-cover\": \"Remova a tampa inferior magnética\",\n  \"storage-manager.install-ssd.step-replace-bottom-cover\": \"Recoloque a tampa inferior\",\n  \"storage-manager.install-ssd.step-return\": \"Volte aqui para adicionar os SSDs ao seu armazenamento\",\n  \"storage-manager.install-ssd.step-shut-down\": \"Desligue seu {{deviceName}}\",\n  \"storage-manager.install-ssd.title\": \"Adicionando SSDs\",\n  \"storage-manager.install-tips.image-alt\": \"Instrução de instalação do SSD\",\n  \"storage-manager.install-tips.instructions\": \"Para instalar, remova o parafuso de aperto e deslize o SSD no slot em um ângulo. Pressione o SSD para baixo até que ele encoste no pilar do parafuso e então fixe-o com o parafuso de aperto.\",\n  \"storage-manager.install-tips.toggle\": \"Esqueceu como inserir um SSD?\",\n  \"storage-manager.manage\": \"Gerenciar\",\n  \"storage-manager.missing-ssd-warning\": \"Parece que um SSD está faltando. Desligue seu Umbrel e verifique se todos os SSDs estão conectados. Se o problema persistir, pode ser necessário substituir o SSD.\",\n  \"storage-manager.mode\": \"Modo\",\n  \"storage-manager.mode.failsafe\": \"FailSafe\",\n  \"storage-manager.mode.failsafe.description\": \"Mantém seus dados seguros se um SSD falhar. Se seus SSDs tiverem tamanhos diferentes, o espaço extra nos maiores fica inutilizável.\",\n  \"storage-manager.mode.failsafe.info-description\": \"O FailSafe protege seus dados mantendo cópias deles entre seus SSDs. Se qualquer SSD falhar, seus dados permanecem seguros e podem ser restaurados ao adicionar um SSD de substituição.\",\n  \"storage-manager.mode.failsafe.info-title\": \"Sobre FailSafe\",\n  \"storage-manager.mode.full-storage\": \"Full Storage\",\n  \"storage-manager.mode.full-storage.description\": \"Use todo o espaço dos seus SSDs em conjunto. Se um SSD falhar, você pode perder seus dados.\",\n  \"storage-manager.mode.full-storage.info-description\": \"Full Storage combina todos os seus SSDs em um único espaço grande, oferecendo o máximo de armazenamento. No entanto, se qualquer SSD falhar, todos os seus dados serão perdidos.\",\n  \"storage-manager.mode.full-storage.info-title\": \"Sobre Full Storage\",\n  \"storage-manager.mode.switch-from-failsafe-unavailable\": \"Mudar de FailSafe para Full Storage requer fazer backup dos seus dados, restaurar o dispositivo para as configurações de fábrica e restaurar a partir de um backup.\",\n  \"storage-manager.mode.switch-to-failsafe-unavailable\": \"Com múltiplos SSDs em Full Storage, seus dados ficam distribuídos por todas as unidades. Mudar para FailSafe exige fazer backup dos dados, restaurar o dispositivo para as configurações de fábrica e restaurar o backup.\",\n  \"storage-manager.mode.why-cant-switch\": \"Por que não consigo alternar?\",\n  \"storage-manager.operation-in-progress.shutdown-description\": \"É seguro desligar. A operação será pausada e retomada após a reinicialização, mas precisa ser concluída antes que você possa fazer outras alterações.\",\n  \"storage-manager.operation-in-progress.shutdown-title\": \"Seu armazenamento está sendo atualizado\",\n  \"storage-manager.operation-in-progress.wait-description\": \"Aguarde a conclusão da operação atual antes de fazer mais alterações.\",\n  \"storage-manager.operation-in-progress.wait-title\": \"Seu armazenamento está sendo atualizado\",\n  \"storage-manager.operation.adding-ssd\": \"Adicionando SSD...\",\n  \"storage-manager.operation.enabling-failsafe\": \"Ativando FailSafe...\",\n  \"storage-manager.operation.expanding\": \"Expandindo o armazenamento...\",\n  \"storage-manager.operation.rebuilding\": \"Reconstruindo dados...\",\n  \"storage-manager.operation.replacing\": \"Substituindo unidade...\",\n  \"storage-manager.operation.restarting\": \"Reiniciando...\",\n  \"storage-manager.operation.starting\": \"Iniciando...\",\n  \"storage-manager.operation.syncing-restarts\": \"Sincronizando dados • Reinicia aos 50%\",\n  \"storage-manager.raid-status.degraded\": \"Degradado\",\n  \"storage-manager.raid-status.failed\": \"Falha\",\n  \"storage-manager.raid-status.offline\": \"Offline\",\n  \"storage-manager.raid-status.online\": \"Online\",\n  \"storage-manager.raid-status.removed\": \"Removido\",\n  \"storage-manager.raid-status.unavailable\": \"Indisponível\",\n  \"storage-manager.replace\": \"Substituir\",\n  \"storage-manager.replace-failed.degraded\": \"Proteção FailSafe reduzida\",\n  \"storage-manager.replace-failed.degraded-description\": \"Falta um SSD no seu armazenamento FailSafe. Substitua-o para restaurar a proteção completa.\",\n  \"storage-manager.replace-failed.description\": \"Use este SSD para restaurar a proteção FailSafe.\",\n  \"storage-manager.replace-failed.error\": \"Não foi possível iniciar a substituição\",\n  \"storage-manager.replace-failed.replace-now\": \"Substituir agora\",\n  \"storage-manager.replace-failed.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD na baia {{slot}}\",\n  \"storage-manager.replace-failed.step-protected\": \"Ao terminar, seus dados estarão totalmente protegidos novamente\",\n  \"storage-manager.replace-failed.step-rebuild\": \"Os dados serão reconstruídos no novo SSD\",\n  \"storage-manager.replace-failed.step-time\": \"Isso pode levar algum tempo, dependendo de quanto dados você tem\",\n  \"storage-manager.replace-failed.title\": \"Substituir SSD\",\n  \"storage-manager.replace-failed.too-small\": \"SSD pequeno demais\",\n  \"storage-manager.replace-failed.too-small-description\": \"Este SSD ({{deviceSize}}) é menor que o mínimo exigido ({{minSize}}) para o seu armazenamento FailSafe.\",\n  \"storage-manager.replace-failed.what-happens\": \"O que acontece a seguir:\",\n  \"storage-manager.ssd-failing\": \"Com falha\",\n  \"storage-manager.swap\": \"Trocar\",\n  \"storage-manager.swap.data-erased-description\": \"O modo Full Storage não oferece proteção de dados. Todos os dados no seu {{deviceName}} serão apagados durante a restauração de fábrica. Certifique-se de fazer backup de tudo primeiro.\",\n  \"storage-manager.swap.data-protected\": \"Seus dados estão protegidos\",\n  \"storage-manager.swap.data-protected-description\": \"Com o FailSafe ativado, você pode trocar qualquer SSD individual sem perder seus dados. Nenhum backup necessário.\",\n  \"storage-manager.swap.data-will-be-erased\": \"Os dados serão apagados\",\n  \"storage-manager.swap.description-failsafe\": \"Substitua uma unidade no seu armazenamento FailSafe.\",\n  \"storage-manager.swap.description-full-storage\": \"Substitua uma unidade na sua configuração Full Storage.\",\n  \"storage-manager.swap.description-no-free-slot\": \"No modo Full Storage com todos os slots ocupados, trocar um SSD requer um processo completo de backup e restauração.\",\n  \"storage-manager.swap.description-replace\": \"Migre seus dados para um novo SSD e então remova o antigo.\",\n  \"storage-manager.swap.failed-to-start\": \"Não foi possível iniciar a substituição\",\n  \"storage-manager.swap.no-data-loss\": \"Sem perda de dados\",\n  \"storage-manager.swap.no-data-loss-description\": \"Seus dados serão copiados para o novo SSD. Quando concluído, você poderá remover o antigo com segurança.\",\n  \"storage-manager.swap.safe-swap-available\": \"Troca segura disponível\",\n  \"storage-manager.swap.safe-swap-description\": \"Como você tem um slot vazio, pode adicionar o novo SSD primeiro e migrar seus dados antes de remover o antigo. Nenhum backup necessário.\",\n  \"storage-manager.swap.select-new-ssd\": \"Selecione o novo SSD a ser usado:\",\n  \"storage-manager.swap.ssd-in-slot\": \"{{size}} SSD no Slot {{slot}}\",\n  \"storage-manager.swap.step-backup\": \"Faça backup dos seus dados\",\n  \"storage-manager.swap.step-backup-description\": \"Vá em Configurações → Backups e crie um backup de todos os seus dados.\",\n  \"storage-manager.swap.step-data-copied\": \"Os dados serão copiados do SSD antigo para o novo\",\n  \"storage-manager.swap.step-factory-reset\": \"Restaurar de fábrica\",\n  \"storage-manager.swap.step-factory-reset-description\": \"Vá em Configurações → Avançado → Restaurar de fábrica para apagar seu {{deviceName}}.\",\n  \"storage-manager.swap.step-insert-new-ssd\": \"Insira o novo SSD em um slot vazio\",\n  \"storage-manager.swap.step-may-take-while\": \"Isso pode demorar um pouco dependendo da quantidade de dados que você tem\",\n  \"storage-manager.swap.step-power-on\": \"Ligue seu {{deviceName}}\",\n  \"storage-manager.swap.step-remove-bottom-cover\": \"Remova a tampa inferior magnética\",\n  \"storage-manager.swap.step-remove-old\": \"Quando concluído, desligue e remova {{ssd}}\",\n  \"storage-manager.swap.step-replace-bottom-cover\": \"Recoloque a tampa inferior\",\n  \"storage-manager.swap.step-restore\": \"Restaure seus dados\",\n  \"storage-manager.swap.step-restore-description\": \"Vá em Configurações → Backups e restaure a partir do seu backup.\",\n  \"storage-manager.swap.step-return-to-storage-manager\": \"Volte aqui ao Gerenciador de Armazenamento para confirmar a troca e adicionar o novo SSD ao seu armazenamento\",\n  \"storage-manager.swap.step-return-to-swap\": \"Volte aqui ao Gerenciador de Armazenamento e clique em \\\"Trocar\\\" novamente para iniciar a substituição\",\n  \"storage-manager.swap.step-setup-new-storage\": \"Configure seu novo armazenamento\",\n  \"storage-manager.swap.step-setup-new-storage-description\": \"Ligue seu {{deviceName}} e conclua o processo de configuração com seu novo SSD.\",\n  \"storage-manager.swap.step-shut-down\": \"Desligue seu {{deviceName}}\",\n  \"storage-manager.swap.step-shut-down-and-swap\": \"Desligue e troque {{ssd}}\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-other\": \"Desligue, abra seu dispositivo, substitua o SSD e remonte-o.\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-pro\": \"Desligue, remova a tampa inferior, substitua o SSD e feche a tampa.\",\n  \"storage-manager.swap.step-swap-ssd\": \"Troque {{ssd}} por um novo do mesmo tamanho\",\n  \"storage-manager.swap.too-small\": \"Pequeno demais (requer {{size}})\",\n  \"storage-manager.swap.what-happens-next\": \"O que acontece a seguir:\",\n  \"storage-manager.total-capacity-added\": \"Capacidade total adicionada\",\n  \"storage-manager.umbrel-pro\": \"Umbrel Pro\",\n  \"storage-manager.used\": \"Usado\",\n  \"storage-manager.wasted\": \"Inutilizável\",\n  \"storage-manager.wasted-size\": \"{{size}} Inutilizável\",\n  \"storage.full\": \"Armazenamento cheio\",\n  \"storage.low\": \"Armazenamento baixo\",\n  \"temperature\": \"Temperatura\",\n  \"temperature.dangerously-hot\": \"Muito quente\",\n  \"temperature.nice\": \"Agradável\",\n  \"temperature.normal\": \"Normal\",\n  \"temperature.too-hot-suggestion\": \"Considere mudar o ambiente do seu dispositivo.\",\n  \"temperature.warm\": \"Morno\",\n  \"terminal\": \"Terminal\",\n  \"terminal-description\": \"Execute comandos personalizados no umbrelOS ou dentro de um app\",\n  \"terminal.app\": \"App\",\n  \"terminal.app-description\": \"Execute comandos personalizados dentro de um app específico\",\n  \"terminal.umbrelos-description\": \"Execute comandos personalizados no umbrelOS\",\n  \"tor-description\": \"Acesse seu Umbrel de qualquer lugar usando um navegador Tor\",\n  \"tor-enabled-description\": \"Acesse seu Umbrel de qualquer lugar usando um navegador Tor na URL abaixo:\",\n  \"tor-error\": \"Falha ao atualizar a configuração do Tor: {{message}}\",\n  \"tor.disable.description\": \"Isso pode levar alguns minutos\",\n  \"tor.disable.progress\": \"Desativando o acesso remoto via Tor\",\n  \"tor.enable.description\": \"Isso pode levar alguns minutos\",\n  \"tor.enable.mobile.switch-label\": \"Ativar acesso remoto Tor\",\n  \"tor.hidden-service\": \"URL do serviço oculto Tor\",\n  \"troubleshoot\": \"Solucionar problemas\",\n  \"troubleshoot-description\": \"Solucione problemas do umbrelOS ou de um aplicativo\",\n  \"troubleshoot-no-logs-yet\": \"Ainda não há registros\",\n  \"troubleshoot-pick-title\": \"Solucionar problemas\",\n  \"troubleshoot.app\": \"Aplicativo\",\n  \"troubleshoot.app-description\": \"Ver registros de um aplicativo instalado no seu Umbrel\",\n  \"troubleshoot.app-download\": \"Baixar registros do {{app}}\",\n  \"troubleshoot.share-with-umbrel-support\": \"Compartilhar com o Suporte Umbrel\",\n  \"troubleshoot.system-download\": \"Baixar {{label}}\",\n  \"troubleshoot.umbrelos-description\": \"Ver logs do umbrelOS\",\n  \"troubleshoot.umbrelos-logs\": \"Registros do umbrelOS\",\n  \"trpc.backend-unavailable\": \"Erro: A conexão com a API do sistema falhou\",\n  \"trpc.checking-backend\": \"Carregando...\",\n  \"try-again\": \"Tentar novamente\",\n  \"umbrel\": \"Umbrel\",\n  \"umbrelos\": \"umbrelOS\",\n  \"unknown\": \"Desconhecido\",\n  \"unknown-app\": \"Aplicativo desconhecido\",\n  \"unknown-error\": \"Erro desconhecido\",\n  \"uptime\": \"Tempo de funcionamento\",\n  \"url\": \"URL\",\n  \"wallpaper\": \"Papel de parede\",\n  \"wallpaper-description\": \"Seu papel de parede e tema do Umbrel\",\n  \"whats-new.continue\": \"Continuar\",\n  \"whats-new.feature-1.description\": \"Configure backups automáticos e criptografados de todo o seu Umbrel para um disco USB externo, um NAS ou outro Umbrel.\",\n  \"whats-new.feature-2.description\": \"Volte no tempo para recuperar arquivos e pastas específicos de backups anteriores.\",\n  \"whats-new.feature-3.description\": \"Ou restaure todo o seu Umbrel, incluindo todos os seus aplicativos, arquivos e dados.\",\n  \"whats-new.feature-4.description\": \"Conecte um NAS ou outro Umbrel e acesse o armazenamento dele pelo Files.\",\n  \"whats-new.feature-4.title\": \"Dispositivos de Rede\",\n  \"whats-new.feature-5.description\": \"Conecte unidades USB externas (no Umbrel Home ou em qualquer dispositivo Intel ou AMD) e acesse-as pelo Files.\",\n  \"whats-new.feature-5.helper-text\": \"Não é compatível com dispositivos Raspberry Pi devido a possíveis problemas de energia.\",\n  \"whats-new.feature-5.title\": \"Armazenamento externo\",\n  \"whats-new.next\": \"Próximo\",\n  \"whats-new.title\": \"Novidades em {{version}}\",\n  \"widget.progress.in-progress\": \"Em andamento\",\n  \"widgets.edit.select-up-to-3-widgets\": \"Selecione até 3 widgets\",\n  \"widgets.install-an-app-before-using-widgets\": \"Instale um aplicativo para começar a personalizar sua tela inicial com widgets.\",\n  \"wifi\": \"Wi-Fi\",\n  \"wifi-connect-insecure-message\": \"Redes abertas podem ser inseguras\",\n  \"wifi-connection-failed\": \"Não foi possível conectar\",\n  \"wifi-dangerous-change-confirmation-description\": \"Mudar a rede Wi-Fi pode desconectá-lo do seu Umbrel. Para reconectar, certifique-se de que tanto o seu Umbrel quanto o dispositivo de onde você está acessando estejam na mesma rede.\",\n  \"wifi-dangerous-change-confirmation-title\": \"Tem certeza de que deseja mudar a rede Wi-Fi?\",\n  \"wifi-dangerous-disable-confirmation-description\": \"Desativar o Wi-Fi pode desconectá-lo do seu Umbrel. Para reconectar, conecte um cabo Ethernet ao seu Umbrel e certifique-se de que tanto o seu Umbrel quanto o dispositivo de onde você está acessando estejam na mesma rede.\",\n  \"wifi-dangerous-disable-confirmation-title\": \"Tem certeza de que deseja desativar o Wi-Fi?\",\n  \"wifi-description\": \"Conecte seu dispositivo a uma rede Wi-Fi\",\n  \"wifi-description-long\": \"Seu dispositivo permanece conectado ao Wi-Fi escolhido, mesmo que o cabo Ethernet seja removido, e reconecta-se automaticamente ao Wi-Fi ao iniciar.\",\n  \"wifi-no-networks-message\": \"Nenhuma rede Wi-Fi encontrada\",\n  \"wifi-searching\": \"Procurando redes Wi-Fi...\",\n  \"wifi-unsupported-device-description\": \"O Wi-Fi não é suportado neste dispositivo. Isso pode ser devido a um adaptador sem fio ausente ou incompatível.\",\n  \"wifi-view-networks\": \"Ver redes\"\n}"
  },
  {
    "path": "packages/ui/public/locales/tr.json",
    "content": "{\n  \"2fa\": \"2FA\",\n  \"2fa-description\": \"Umbrel girişiniz ve uygulamalarınız için ikinci bir güvenlik katmanı\",\n  \"2fa.disable.title\": \"İki faktörlü kimlik doğrulamayı devre dışı bırak\",\n  \"2fa.enable.or-paste\": \"Veya aşağıdaki kodu kimlik doğrulama uygulamanıza yapıştırın\",\n  \"2fa.enable.scan-this\": \"Google Authenticator veya Authy gibi bir kimlik doğrulama uygulamasıyla bu QR kodunu tarayın\",\n  \"2fa.enable.title\": \"İki faktörlü kimlik doğrulamayı etkinleştir\",\n  \"2fa.enter-code\": \"Kimlik doğrulama uygulamanızda görüntülenen kodu girin\",\n  \"account\": \"Hesap\",\n  \"account-description\": \"Adınız ve şifreniz\",\n  \"advanced-settings\": \"Gelişmiş ayarlar\",\n  \"advanced-settings-description\": \"Terminal, umbrelOS Beta Programı, Cloudflare DNS ve daha fazlası\",\n  \"app-not-found\": \"Uygulama bulunamadı: {{app}}\",\n  \"app-only-over-tor\": \"{{app}} sadece Tor üzerinden kullanılabilir. Bu uygulamayı açmak için lütfen uzak erişim URL'nizde (Ayarlar > Gelişmiş ayarlar > Uzaktan Tor erişimi) Tor tarayıcısı ile Umbrel'inize erişin.\",\n  \"app-page.section.about\": \"Hakkında\",\n  \"app-page.section.credentials.title\": \"Varsayılan kimlik bilgileri\",\n  \"app-page.section.dependencies.n-alternatives\": \"{{count}} alternatiflere bak\",\n  \"app-page.section.info.compatibility\": \"Uyumluluk\",\n  \"app-page.section.info.compatibility-compatible\": \"Uyumlu\",\n  \"app-page.section.info.compatibility-not-compatible\": \"Uyumlu değil\",\n  \"app-page.section.info.developer\": \"Geliştirici\",\n  \"app-page.section.info.source-code\": \"Kaynak Kodu\",\n  \"app-page.section.info.source-code.public\": \"Herkese açık\",\n  \"app-page.section.info.submitted-by\": \"Gönderen\",\n  \"app-page.section.info.support\": \"Destek al\",\n  \"app-page.section.info.title\": \"Bilgi\",\n  \"app-page.section.info.version\": \"Sürüm\",\n  \"app-page.section.recommendations.title\": \"Bunları da beğenebilirsiniz\",\n  \"app-page.section.release-notes.title\": \"Yenilikler\",\n  \"app-page.section.release-notes.version\": \"Sürüm {{version}}\",\n  \"app-page.section.requires\": \"Gereksinimler\",\n  \"app-picker.search\": \"Ara...\",\n  \"app-picker.select-app\": \"Uygulama seç...\",\n  \"app-settings.connected-to\": \"{{appName}} bu uygulamalara bağlı\",\n  \"app-settings.save-changes\": \"Değişiklikleri kaydet\",\n  \"app-settings.title\": \"Ayarlar\",\n  \"app-store.browse-category-apps\": \"{{category}} uygulamalarına göz at\",\n  \"app-store.category.ai\": \"Yapay Zeka\",\n  \"app-store.category.all\": \"Tüm uygulamalar\",\n  \"app-store.category.automation\": \"Ev ve Otomasyon\",\n  \"app-store.category.bitcoin\": \"Bitcoin\",\n  \"app-store.category.crypto\": \"Kripto\",\n  \"app-store.category.developer\": \"Geliştirici Araçları\",\n  \"app-store.category.discover\": \"Keşfet\",\n  \"app-store.category.files\": \"Dosyalar ve Üretkenlik\",\n  \"app-store.category.finance\": \"Finans\",\n  \"app-store.category.media\": \"Medya\",\n  \"app-store.category.networking\": \"Ağ\",\n  \"app-store.category.social\": \"Sosyal\",\n  \"app-store.description\": \"Uygulama güncelleme ayarlarınız\",\n  \"app-store.discover.temporarily-unavailable-description\": \"Uygulama bulmak için yukarıdaki kategorilere göz atabilir veya arama yapabilirsiniz\",\n  \"app-store.discover.temporarily-unavailable-title\": \"Öne çıkan içerik şu anda kullanılamıyor\",\n  \"app-store.menu.community-app-stores\": \"Topluluk Uygulama Mağazaları\",\n  \"app-store.search-apps\": \"Uygulama ara\",\n  \"app-store.search.no-results\": \"Sonuç yok\",\n  \"app-store.search.results-for\": \"Sonuçlar\",\n  \"app-store.title\": \"Uygulama Mağazası\",\n  \"app-store.updates\": \"Güncellemeler\",\n  \"app-updates.less\": \"daha az\",\n  \"app-updates.more\": \"daha fazla\",\n  \"app-updates.no-updates\": \"Tüm uygulamalar güncel!\",\n  \"app-updates.update\": \"Güncelle\",\n  \"app-updates.update-all\": \"Hepsini güncelle\",\n  \"app-updates.updates-available-count_one\": \"{{count}} güncelleme mevcut\",\n  \"app-updates.updates-available-count_other\": \"{{count}} güncelleme mevcut\",\n  \"app-updates.updating\": \"Güncelleniyor...\",\n  \"app.install\": \"Yükle\",\n  \"app.installed\": \"Yüklendi\",\n  \"app.installing\": \"Yükleniyor\",\n  \"app.offline\": \"Çalışmıyor\",\n  \"app.open\": \"Aç\",\n  \"app.optimized-for-umbrel-home\": \"Umbrel Home için optimize edilmiş\",\n  \"app.os-update-required.confirm\": \"umbrelOS güncellemelerini kontrol et\",\n  \"app.os-update-required.description\": \"{{appName}} için umbrelOS {{version}} veya daha yenisi gerekiyor\",\n  \"app.os-update-required.title\": \"umbrelOS'u güncelle\",\n  \"app.restarting\": \"Yeniden başlatılıyor\",\n  \"app.starting\": \"Başlatılıyor\",\n  \"app.stopping\": \"Durduruluyor\",\n  \"app.uninstall.confirm.description\": \"{{app}} ile ilgili tüm veriler kalıcı olarak silinecek. Bu işlem geri alınamaz.\",\n  \"app.uninstall.confirm.submit\": \"Kaldır\",\n  \"app.uninstall.confirm.title\": \"{{app}} kaldırılacak mı?\",\n  \"app.uninstall.deps.used-by.description_one\": \"{{app}}'i kaldırmak için önce {{firstAppToUninstall}}'i kaldırın.\",\n  \"app.uninstall.deps.used-by.description_other\": \"{{app}}'i kaldırmak için önce bu uygulamaları kaldırın.\",\n  \"app.uninstall.deps.used-by.title\": \"{{app}} kullanılıyor\",\n  \"app.uninstalling\": \"Kaldırılıyor\",\n  \"app.updating\": \"Güncelleniyor\",\n  \"app.view\": \"Görünüm\",\n  \"app_one\": \"uygulama\",\n  \"app_other\": \"uygulamalar\",\n  \"apps.uninstall.failed-to-get-required-apps\": \"Gerekli uygulamalar alınamadı\",\n  \"apps.uninstalled-all.success\": \"Tüm uygulamalar kaldırıldı\",\n  \"auth.checking-backend-for-user\": \"Yükleniyor...\",\n  \"auth.failed-checking-if-user-logged-in\": \"Hata: Kimlik doğrulama giriş kontrolü başarısız\",\n  \"auth.failed-to-check-if-user-exists\": \"Hata: Kimlik doğrulama varlık kontrolü başarısız oldu\",\n  \"back\": \"Geri\",\n  \"backups\": \"Backups\",\n  \"backups-configure\": \"Yapılandır\",\n  \"backups-configure.add-backup-location\": \"Yedek konumu ekle\",\n  \"backups-configure.available\": \"Kullanılabilir\",\n  \"backups-configure.awaiting-next-backup\": \"Bir sonraki otomatik yedeği bekleniyor\",\n  \"backups-configure.back-up-now\": \"Şimdi yedekle\",\n  \"backups-configure.backing-up-now\": \"Yedekleniyor...\",\n  \"backups-configure.connected\": \"Bağlı\",\n  \"backups-configure.connection\": \"Bağlantı\",\n  \"backups-configure.in-progress\": \"Devam ediyor\",\n  \"backups-configure.last-backup\": \"Son yedek\",\n  \"backups-configure.locations\": \"Konumlar\",\n  \"backups-configure.no-backup-locations\": \"Verilerinizi yedeklemeye başlamak için bir yedek konumu ekleyin\",\n  \"backups-configure.not-connected\": \"Bağlı değil\",\n  \"backups-configure.path\": \"Yol\",\n  \"backups-configure.remove-backup-location\": \"Yedek konumunu kaldır\",\n  \"backups-configure.remove-backup-location-confirmation\": \"Emin misin?\",\n  \"backups-configure.remove-backup-location-confirmation-description\": \"Bu, '{{device}}' öğesini yedek konumlarınızdan kaldıracaktır. Bu cihazdaki mevcut yedekler silinmeyecek, ancak otomatik yedeklemeler duracaktır.\",\n  \"backups-configure.status\": \"Durum\",\n  \"backups-configure.total-backups\": \"Toplam Backups\",\n  \"backups-configure.used\": \"Kullanılan\",\n  \"backups-configure.view\": \"Görüntüle\",\n  \"backups-description\": \"Dosyalarınızı, uygulamalarınızı ve verilerinizi başka bir Umbrel'e, NAS'a veya harici sürücüye yedekleyin\",\n  \"backups-error.backup-not-found\": \"Yedek bulunamadı.\",\n  \"backups-error.generic\": \"Bir şeyler ters gitti: {{details}}\",\n  \"backups-error.in-progress\": \"Bir yedekleme işlemi zaten çalışıyor. Bitmesini bekleyin lütfen.\",\n  \"backups-error.invalid-exclusion-path\": \"Yedeklerden yalnızca Home dizininizdeki dosya ve klasörler hariç tutulabilir.\",\n  \"backups-error.invalid-password\": \"Şifreleme parolası yanlış.\",\n  \"backups-error.invalid-path\": \"Seçilen konum yedeklemeler için geçerli değil.\",\n  \"backups-error.mount-failed\": \"Yedek anlık görüntüsüne erişilemedi.\",\n  \"backups-error.mount-timeout\": \"Yedek anlık görüntüsüne erişilemedi. Tekrar deneyin veya cihazın düzgün bağlandığından emin olun.\",\n  \"backups-error.not-enough-space\": \"Yedekleme cihazında yeterli alan yok.\",\n  \"backups-error.not-found\": \"Yedek veya yedekleme konumu bulunamadı.\",\n  \"backups-error.repository-exists\": \"Bu klasörde zaten bir yedekleme konumu var.\",\n  \"backups-error.repository-not-found\": \"Yedekleme konumu bulunamadı.\",\n  \"backups-exclusions.add\": \"Ekle\",\n  \"backups-exclusions.app-paths-cannot-be-modified\": \"Bu dosya/klasör yolları uygulama geliştiricisi tarafından belirlenmiştir ve değiştirilemez:\",\n  \"backups-exclusions.app-paths-explanation\": \"Bu uygulama aşağıdaki verileri yedeklemelerden hariç tutar. Bu yollar genellikle yeniden oluşturulabilecek önemsiz öğeler (ör. önbellekler veya günlükler) veya geri yüklendiğinde sorun yaratabilecek veriler (ör. çakışmalara veya tutarsızlıklara yol açabilecek eski uygulama durumları) içerir.\",\n  \"backups-exclusions.auto-excluded\": \"Otomatik hariç tutuldu\",\n  \"backups-exclusions.exclude-entire-app\": \"Uygulamayı tamamen hariç tut\",\n  \"backups-exclusions.excluded-apps\": \"Hariç tutulan uygulamalar\",\n  \"backups-exclusions.files-and-folders\": \"Hariç tutulan dosya ve klasörler\",\n  \"backups-exclusions.no-excluded-apps\": \"Hariç tutulan uygulama yok\",\n  \"backups-exclusions.no-excluded-files-or-folders\": \"Hariç tutulan dosya veya klasör yok\",\n  \"backups-exclusions.select-item-to-exclude\": \"Hariç tutulacak öğeyi seç\",\n  \"backups-exclusions.stop-excluding\": \"Hariç tutmayı bırak\",\n  \"backups-floating-island.backing-up\": \"Yedekleniyor...\",\n  \"backups-floating-island.backing-up-to\": \"Umbrel'iniz yedekleniyor...\",\n  \"backups-restore\": \"Geri yükle\",\n  \"backups-restore-full\": \"Tam Geri Yükleme\",\n  \"backups-restore-full-description\": \"Umbrel'in tamamını bir yedekten geri yükleyin\",\n  \"backups-restore-header\": \"Umbrel'inizi geri yükleyin\",\n  \"backups-restore-pro.after-restore\": \"Geri yüklemeden sonra geçici hesabınız yedeklenmiş hesabınız ve verilerinizle değiştirilecek.\",\n  \"backups-restore-pro.step1\": \"Aşağıdaki \\\"Başlayın\\\" düğmesine tıklayarak ilk kurulumu tamamlayın. Bu, yedeklenmiş hesabınızı geri yükleyene kadar geçici hesabınız olacak.\",\n  \"backups-restore-pro.step2\": \"Kurulum tamamlandıktan sonra <0>Ayarlar → Yedekler → Geri Yükle</0> yolunu izleyin.\",\n  \"backups-restore-pro.step3\": \"Geri Yükleme Sihirbazı'ndaki yönergeleri izleyin.\",\n  \"backups-restore-pro.subtitle\": \"Umbrel Pro'da bir yedekten geri yükleme birkaç ek adım gerektirir\",\n  \"backups-restore.backup-date\": \"Yedek tarihi\",\n  \"backups-restore.backup-location\": \"Yedek konumu\",\n  \"backups-restore.browse-cloud-subtitle\": \"Umbrel Private Cloud'dan geri yükleyin (yakında)\",\n  \"backups-restore.browse-cloud-title\": \"Umbrel Private Cloud\",\n  \"backups-restore.browse-external-subtitle\": \"Harici bir USB sürücüden geri yükle\",\n  \"backups-restore.browse-external-title\": \"Harici Disk\",\n  \"backups-restore.browse-nas-or-external\": \"Bir yedekten geri yüklemek için başka bir Umbrel, NAS veya harici sürücüye göz atın\",\n  \"backups-restore.browse-nas-subtitle\": \"Ağınızdaki başka bir Umbrel veya NAS cihazından geri yükleyin.\",\n  \"backups-restore.browse-nas-title\": \"Başka bir Umbrel veya NAS\",\n  \"backups-restore.choose\": \"Seç\",\n  \"backups-restore.choose-backup-location\": \"Bir yedek konumu seçin\",\n  \"backups-restore.connect-to-backup-location\": \"Bir yedek konumuna bağlanın\",\n  \"backups-restore.encryption-password\": \"Şifreleme parolası\",\n  \"backups-restore.encryption-password-description\": \"Backups'ı etkinleştirdiğinizde belirlediğiniz şifreleme parolasını girin\",\n  \"backups-restore.enter-password-to-confirm\": \"Onaylamak için Umbrel parolanızı girin\",\n  \"backups-restore.final-confirmation\": \"Emin misin?\",\n  \"backups-restore.final-confirmation-description\": \"Bu yedekten geri yükleme, mevcut umbrelOS uygulamalarınızı ve verilerinizi seçili yedeğin içeriğiyle değiştirecektir. Bu yedekten hariç tutulan dosya, klasör veya uygulamalar Umbrel'inizden kaldırılacaktır. Bu işlem geri alınamaz.\",\n  \"backups-restore.invalid-password\": \"Geçersiz parola\",\n  \"backups-restore.last-backup\": \"Son yedek: {{date}}\",\n  \"backups-restore.latest\": \"En son\",\n  \"backups-restore.no-backups-found\": \"Yedek bulunamadı\",\n  \"backups-restore.no-backups-yet\": \"Henüz yedek yok\",\n  \"backups-restore.please-select-backup\": \"Lütfen bir yedek seçin\",\n  \"backups-restore.please-select-repository\": \"Lütfen bir depo seçin\",\n  \"backups-restore.restore-from-nas-or-external\": \"Umbrel'inizi başka bir Umbrel'deki, bir NAS'taki veya harici bir sürücüdeki backup'tan geri yükleyin\",\n  \"backups-restore.restore-from-unlisted\": \"Başka bir konumdan geri yükle\",\n  \"backups-restore.restore-umbrel\": \"Umbrel'i geri yükle\",\n  \"backups-restore.restore-warning\": \"Bu yedekten geri yükleme, mevcut umbrelOS uygulamalarınızı ve verilerinizi seçili yedeğin içeriğiyle değiştirecektir. Bu yedekten hariç tutulan dosya, klasör veya uygulamalar Umbrel'inizden kaldırılacaktır. Eğer belirli dosya veya klasörleri geri yüklemek istiyorsanız, <0>Rewind</0>'i açın.\",\n  \"backups-restore.restoring-from\": \"Aşağıdaki backup'tan geri yükleme yapacaksınız:\",\n  \"backups-restore.review-description\": \"Geri yükleme, Umbrel'inizi bu backup'ın oluşturulduğu zamandaki hesap, dosyalar, uygulamalar ve ayarlarla yapılandırır. Bu biraz zaman alabilir. İşlem tamamlandığında, giriş şifreniz backup oluşturulurken kullandığınız şifre olarak ayarlanacaktır.\",\n  \"backups-restore.select-backup\": \"Bir yedek seçin\",\n  \"backups-restore.select-backup-description\": \"Geri yüklemek istediğiniz yedeği seçin\",\n  \"backups-restore.select-backup-file\": \"Yedek dosyanızı seç\",\n  \"backups-restore.select-backup-file-only\": \"Sadece <bold>{{backupFileName}}</bold> seçilebilir\",\n  \"backups-restore.total-size\": \"Toplam boyut\",\n  \"backups-restore.unknown-date\": \"Bilinmeyen tarih\",\n  \"backups-restore.unknown-repository\": \"Bilinmeyen depo\",\n  \"backups-rewind\": \"Rewind\",\n  \"backups-rewind-description\": \"Zamanda geriye giderek belirli dosya ve klasörleri geri yükleyin\",\n  \"backups-rewind.start\": \"Rewind'i başlat\",\n  \"backups-setup\": \"Kur\",\n  \"backups-setup-confirm\": \"Kurulumu tamamla\",\n  \"backups-setup-external-description\": \"Harici bir USB sürücüye yedekleyin\",\n  \"backups-setup-nas-or-umbrel-description\": \"Ağınızdaki başka bir Umbrel'e veya NAS cihazına yedekleyin\",\n  \"backups-setup-umbrel-or-nas\": \"Başka bir Umbrel veya NAS\",\n  \"backups-setup-umbrel-private-cloud\": \"Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-cta\": \"Evden dışarıda da içinizi rahatlatın: Umbrel Private Cloud'a <bold>uçtan uca şifrelenmiş yedekler</bold> gönderin.\",\n  \"backups-setup-umbrel-private-cloud-cta-link\": \"Erken erişim alın\",\n  \"backups-setup-umbrel-private-cloud-description\": \"Umbrel Private Cloud'a uçtan uca şifrelenmiş yedekler\",\n  \"backups-setup-umbrel-private-cloud-subtitle\": \"Yakında\",\n  \"backups.add-umbrel-or-nas\": \"Umbrel veya NAS ekle\",\n  \"backups.all-apps-and-data-will-be-backed-up\": \"Tüm uygulamalar ve veriler yedeklenecek\",\n  \"backups.apps-and-data\": \"Uygulamalar ve veriler\",\n  \"backups.backup-location\": \"Yedek konumu\",\n  \"backups.browse\": \"Göz at\",\n  \"backups.choose-folder-within-device\": \"Yedeklerinizi kaydetmek için <bold>{{device}}</bold> içindeki bir klasörü seçin\",\n  \"backups.confirm-password\": \"Parolayı onayla\",\n  \"backups.copy\": \"Kopyala\",\n  \"backups.encryption\": \"Şifreleme\",\n  \"backups.encryption-password-warning\": \"Şifreleme parolanızı bir parola yöneticisi gibi güvenli bir yerde sakladığınızdan emin olun. Bu parolayı bir daha göremeyeceksiniz ve yedeklerden geri yüklemek için buna ihtiyacınız olacak.\",\n  \"backups.exclude-from-backups\": \"Backups'tan hariç tut\",\n  \"backups.exclude-from-backups-description\": \"Belirli dosya, klasör ve uygulamaları yedeklerinizden hariç tutun.\",\n  \"backups.hide\": \"Gizle\",\n  \"backups.i-understand\": \"Anladım\",\n  \"backups.location\": \"Konum\",\n  \"backups.modals.already-in-use.description\": \"Bu yedekleme konumu zaten bu Umbrel'de Backups için kullanılıyor.\",\n  \"backups.modals.already-in-use.manage\": \"Backups'ta yönet\",\n  \"backups.modals.already-in-use.title\": \"Yedekleme konumu zaten kullanılıyor\",\n  \"backups.modals.connect-existing.description\": \"Bu konumda zaten bir Umbrel yedeği var. Bunu bu Umbrel'e eklemek için şifreleme parolasını girin.\",\n  \"backups.modals.connect-existing.title\": \"Mevcut Umbrel yedeğini bağla\",\n  \"backups.no-external-drives-detected\": \"Hiçbir harici sürücü tespit edilmedi\",\n  \"backups.no-password-set\": \"Parola ayarlı değil\",\n  \"backups.password-is-set\": \"Parola ayarlı\",\n  \"backups.password-minimum-length\": \"Parola en az 8 karakter olmalı\",\n  \"backups.password-safety-warning\": \"Yedekleriniz bu parola ile şifrelenecek. Parolayı güvende tutun; bir daha göremeyeceksiniz ve yedeklerden geri yüklemek için bu parolaya ihtiyacınız olacak.\",\n  \"backups.passwords-do-not-match\": \"Parolalar eşleşmiyor\",\n  \"backups.please-choose-folder\": \"Lütfen bir klasör seçin\",\n  \"backups.restore-failed.message\": \"Umbrel'inizi geri yüklerken bir hata oluştu. Mevcut uygulamalarınız ve verileriniz değişmedi.\",\n  \"backups.restore-failed.retry\": \"Geri yüklemeye git\",\n  \"backups.restore-failed.title\": \"Geri yükleme başarısız oldu\",\n  \"backups.restoring\": \"Umbrel'iniz geri yükleniyor\",\n  \"backups.restoring-completing\": \"İşlemler tamamlanıyor. Birazdan Umbrel'iniz yeniden başlatılacak...\",\n  \"backups.restoring-progress\": \"{{percent}}% geri yüklendi\",\n  \"backups.restoring-time-remaining\": \"{{time}} kaldı\",\n  \"backups.restoring-warning\": \"Geri yükleme sırasında Umbrel'inizin güç kaynağını kapatmayın veya yedek konumunuzun bağlantısını kesmeyin\",\n  \"backups.review\": \"Gözden geçir ve onayla\",\n  \"backups.review-description\": \"Yedeğinizin ayrıntılarını gözden geçirin ve seçiminizi onaylayın\",\n  \"backups.scanning-for-external-drives\": \"Harici sürücüler aranıyor...\",\n  \"backups.schedule-description\": \"umbrelOS verilerinizi saatlik olarak otomatik yedekler. Şifrelenmiş saatlik yedekleri son 24 saat için, günlük yedekleri son hafta için, haftalık yedekleri son ay için ve aylık yedekleri son yıl için tutar. Bir yıldan eski yedekler otomatik olarak silinir.\",\n  \"backups.select-backup-folder\": \"Yedek klasörünü seçin\",\n  \"backups.select-backup-folder-description\": \"Yedeklerinizi depolamak istediğiniz klasörü seçin.\",\n  \"backups.select-backup-location\": \"Bir yedek konumu seçin\",\n  \"backups.set-encryption-password\": \"Şifreleme parolası belirle\",\n  \"backups.set-encryption-password-description\": \"Yedeklerinizi bir parolayla koruyun. Bu, verilerinizin gizli kalmasını sağlar ve yalnızca bu parola ile geri yüklenebilir.\",\n  \"backups.show\": \"Göster\",\n  \"backups.storage-capacity-warning\": \"{{device}}'de en az yedek boyutunuzun iki katı kadar boş alan olmalıdır\",\n  \"backups.store-encryption-password-safely\": \"Şifreleme parolanızı güvenli bir yerde saklayın\",\n  \"beta-program\": \"umbrelOS Beta Programı\",\n  \"beta-program-description\": \"umbrelOS beta güncellemelerini almaya kaydolun, yeni özelliklere erken erişim kazanın ve geri bildirim sağlayarak bunları geliştirmemize yardımcı olun. Beta güncellemeleri kararsız olabilir ve sorun giderme terminal bilgisi gerektirebilir.\",\n  \"cancel\": \"İptal\",\n  \"change\": \"Değiştir\",\n  \"change-name\": \"Adı değiştir\",\n  \"change-name.failed.name-required\": \"Ad gerekli\",\n  \"change-name.input-placeholder\": \"Adınız\",\n  \"change-password\": \"Şifre değiştir\",\n  \"change-password.callout\": \"Şifrenizi kaybederseniz, Umbrel'e giriş yapamazsınız. Şifrenizi güvenli bir şekilde sakladığınızdan emin olun.\",\n  \"change-password.current-password\": \"Mevcut şifre\",\n  \"change-password.failed.current-required\": \"Mevcut şifre gerekli\",\n  \"change-password.failed.min-length\": \"Şifre en az {{characters}} karakter olmalıdır\",\n  \"change-password.failed.must-be-unique\": \"Yeni şifre mevcut şifreden farklı olmalıdır\",\n  \"change-password.failed.new-required\": \"Yeni şifre gerekli\",\n  \"change-password.failed.no-match\": \"Şifreler eşleşmiyor\",\n  \"change-password.failed.repeat-required\": \"Şifreyi tekrar girin\",\n  \"change-password.new-password\": \"Yeni şifre\",\n  \"change-password.repeat-password\": \"Şifreyi tekrar girin\",\n  \"check-for-latest-version\": \"En son umbrelOS güncellemesini kontrol et\",\n  \"clipboard.copied\": \"Kopyalandı\",\n  \"close\": \"Kapat\",\n  \"cmdk.change-wallpaper\": \"Duvar kağıdını değiştir\",\n  \"cmdk.frequent-apps\": \"Sık kullanılanlar\",\n  \"cmdk.input-placeholder\": \"Uygulamalar, ayarlar veya eylemler için ara\",\n  \"cmdk.live-usage\": \"Canlı Kullanım\",\n  \"cmdk.restart-umbrel\": \"Umbrel'i yeniden başlat\",\n  \"cmdk.shutdown-umbrel\": \"Umbrel'i kapat\",\n  \"cmdk.update-all-apps\": \"Tüm uygulamaları güncelle\",\n  \"cmdk.widgets\": \"Widget'lar\",\n  \"community-app-store\": \"Topluluk Uygulama Mağazası\",\n  \"community-app-store.add-error\": \"Topluluk App Store'u ekleyemedik: {{message}}\",\n  \"community-app-store.back-to-umbrel-app-store\": \"Umbrel Uygulama Mağazasına geri dön\",\n  \"community-app-store.open-button\": \"Aç\",\n  \"community-app-store.remove-button\": \"Kaldır\",\n  \"community-app-store.remove-error\": \"Topluluk App Store'u kaldıramadık: {{message}}\",\n  \"community-app-stores.add-button\": \"Ekle\",\n  \"community-app-stores.description\": \"Topluluk Uygulama Mağazaları, Umbrel'inize resmi Umbrel Uygulama Mağazasında bulunmayan uygulamaları yüklemenizi sağlar.\",\n  \"community-app-stores.learn-more\": \"Daha fazla bilgi edinin\",\n  \"community-app-stores.warning\": \"Topluluk Uygulama Mağazaları herhangi biri tarafından oluşturulabilir. Bu mağazalarda yayımlanan uygulamalar, resmi Umbrel Uygulama Mağazası ekibi tarafından doğrulanmaz veya denetlenmez ve potansiyel olarak güvensiz veya kötü amaçlı olabilir. Dikkatli olun ve yalnızca güvendiğiniz geliştiricilerin uygulama mağazalarını ekleyin.\",\n  \"confirm\": \"Onayla\",\n  \"connect\": \"Bağlan\",\n  \"connecting\": \"Bağlanıyor...\",\n  \"connection-lost\": \"Bağlantı koptu\",\n  \"connection-lost-description\": \"Bu, tarayıcı sekmeniz uzun süredir etkin olmadığında, ağ bağlantınız kesildiğinde veya cihazınız çevrimdışıysa olabilir.\",\n  \"continue\": \"Devam et\",\n  \"continue-to-log-in\": \"Girişe devam et\",\n  \"cpu\": \"CPU\",\n  \"cpu-core-count\": \"{{cores}} çekirdek\",\n  \"default-credentials.close\": \"Anladım\",\n  \"default-credentials.description\": \"Uygulamaya giriş yapmak için gerekli kimlik bilgileri burada.\",\n  \"default-credentials.dont-show-again\": \"Bunu bir daha gösterme\",\n  \"default-credentials.dont-show-again-notice\": \"Bu kimlik bilgilerine gelecekte herhangi bir zamanda uygulama simgesine sağ tıklayarak erişebilirsiniz.\",\n  \"default-credentials.open\": \"{{app}}'i aç\",\n  \"default-credentials.password\": \"Varsayılan şifre\",\n  \"default-credentials.title\": \"{{app}} için kimlik bilgileri\",\n  \"default-credentials.username\": \"Varsayılan kullanıcı adı\",\n  \"desktop.app.context.go-to-store-page\": \"App Store'da görüntüle\",\n  \"desktop.app.context.settings\": \"Ayarlar\",\n  \"desktop.app.context.show-default-credentials\": \"Varsayılan kimlik bilgilerini göster\",\n  \"desktop.app.context.uninstall\": \"Kaldır\",\n  \"desktop.context-menu.change-wallpaper\": \"Duvar kağıdını değiştir\",\n  \"desktop.context-menu.edit-widgets\": \"Widget'ları düzenle\",\n  \"desktop.context-menu.logout\": \"Çıkış yap\",\n  \"desktop.greeting.afternoon\": \"Tünaydın, {{name}}\",\n  \"desktop.greeting.evening\": \"İyi akşamlar, {{name}}\",\n  \"desktop.greeting.morning\": \"Günaydın, {{name}}\",\n  \"desktop.install-first.for-the-ai-enthusiast\": \"Viber için\",\n  \"desktop.install-first.for-the-bitcoiner\": \"Bitcoin kullanıcıları için\",\n  \"desktop.install-first.for-the-self-hoster\": \"Kendi sunucusunu barındıranlar için\",\n  \"desktop.install-first.for-the-streamer\": \"Yayıncılar için\",\n  \"desktop.install-first.link-to-app-store\": \"App Store'da daha fazlasını keşfet\",\n  \"desktop.not-enough-room\": \"Uygulamalarınızı görüntülemek için daha büyük bir ekran kullanın.\",\n  \"device\": \"Cihaz\",\n  \"device-info\": \"Cihaz bilgisi\",\n  \"device-info-description\": \"Cihazınız hakkında bilgi\",\n  \"device-info.device\": \"Cihaz\",\n  \"device-info.model-number\": \"Model numarası\",\n  \"device-info.serial-number\": \"Seri numarası\",\n  \"device-info.view-info\": \"Bilgileri görüntüle\",\n  \"device-name.home-or-pro\": \"Umbrel Home veya Umbrel Pro\",\n  \"disable\": \"Devre dışı bırak\",\n  \"done\": \"Bitti\",\n  \"download-logs\": \"Günlükleri indir\",\n  \"enabling-tor\": \"Uzaktan Tor erişimi etkinleştiriliyor\",\n  \"external-dns\": \"Cloudflare DNS\",\n  \"external-dns-description\": \"Cloudflare DNS, daha iyi ağ güvenilirliği sunar. Yönlendiricinizin DNS ayarlarını kullanmak için devre dışı bırakın.\",\n  \"external-dns-error\": \"DNS ayarını güncelleyemedik: {{message}}\",\n  \"external-drive\": \"Harici Sürücü\",\n  \"factory-reset\": \"Fabrika ayarlarına sıfırla\",\n  \"factory-reset-description\": \"Tüm verilerinizi ve uygulamalarınızı silerek umbrelOS'i varsayılan ayarlara geri yükleyin\",\n  \"factory-reset-failed\": \"Cihazınızı sıfırlayamadık: {{message}}\",\n  \"factory-reset.confirm.body\": \"Sıfırlamak için şifrenizi doğrulayın\",\n  \"factory-reset.confirm.ethernet-required-warning\": \"Cihazınızın, Wi-Fi yerine Ethernet ile router'a bağlı olduğundan ve yerel ağınızdan (örneğin, http://umbrel.local veya cihazınızın yerel IP adresi) eriştiğinizden emin olun.\",\n  \"factory-reset.confirm.submit\": \"Her şeyi sil ve sıfırla\",\n  \"factory-reset.confirm.submit-callout\": \"Bu işlem geri alınamaz.\",\n  \"factory-reset.rebooting.message\": \"Cihazınız yeniden başlatılacak ve tüm veriler silinecek. Lütfen bu sayfayı kapatmayın.\",\n  \"factory-reset.rebooting.status\": \"Sıfırlanıyor...\",\n  \"factory-reset.rebooting.title\": \"Fabrika ayarlarına sıfırlanıyor\",\n  \"factory-reset.review.account-info\": \"Hesap bilgileri ve şifre\",\n  \"factory-reset.review.apps\": \"Uygulamalar\",\n  \"factory-reset.review.following-will-be-removed\": \"Cihazınızdan aşağıdakiler kaldırılacak\",\n  \"factory-reset.review.installed-apps_one\": \"{{count}} yüklü uygulama\",\n  \"factory-reset.review.installed-apps_other\": \"{{count}} yüklü uygulama\",\n  \"factory-reset.review.submit\": \"Devam et\",\n  \"factory-reset.review.total-data\": \"Toplam veri\",\n  \"files\": \"Files\",\n  \"files-action.add-favorite\": \"Favorilere ekle\",\n  \"files-action.add-network-device\": \"Cihaz ekle\",\n  \"files-action.cancel-upload\": \"Yüklemeyi iptal et\",\n  \"files-action.compress\": \"Sıkıştır\",\n  \"files-action.copy\": \"Kopyala\",\n  \"files-action.cut\": \"Kes\",\n  \"files-action.delete\": \"Kalıcı olarak sil\",\n  \"files-action.download\": \"İndir\",\n  \"files-action.download-items\": \"{{count}} öğeyi indir\",\n  \"files-action.drop-to-upload\": \"Yüklemek için bırakın\",\n  \"files-action.eject-disk\": \"Çıkar\",\n  \"files-action.empty-trash\": \"Çöpü boşalt\",\n  \"files-action.format-drive\": \"Biçimlendir\",\n  \"files-action.go-to-path\": \"Git...\",\n  \"files-action.new-folder\": \"Yeni klasör\",\n  \"files-action.open\": \"Aç\",\n  \"files-action.paste\": \"Yapıştır\",\n  \"files-action.remove-favorite\": \"Favorilerden kaldır\",\n  \"files-action.remove-network-host\": \"Ağ sürücüsünü çıkar\",\n  \"files-action.remove-network-share\": \"Ağ paylaşımını çıkar\",\n  \"files-action.rename\": \"Yeniden adlandır\",\n  \"files-action.restore\": \"Geri yükle\",\n  \"files-action.select\": \"Seç\",\n  \"files-action.share\": \"Ağ üzerinden paylaş...\",\n  \"files-action.sharing\": \"Paylaşılıyor...\",\n  \"files-action.show-in-folder\": \"İçeren klasörde göster\",\n  \"files-action.trash\": \"Çöp kutusu\",\n  \"files-action.uncompress\": \"Sıkıştırmayı aç\",\n  \"files-action.upload\": \"Yükle\",\n  \"files-add-network-share.add-manually\": \"Elle ekle\",\n  \"files-add-network-share.add-share\": \"Paylaşımı ekle\",\n  \"files-add-network-share.back\": \"Geri\",\n  \"files-add-network-share.continue\": \"Devam et\",\n  \"files-add-network-share.description\": \"Ağınızdaki bir NAS veya başka bir paylaşılan sürücüye bağlanarak onlara Dosyalar içinden erişin.\",\n  \"files-add-network-share.discovering\": \"Ağ taranıyor...\",\n  \"files-add-network-share.enter-details-manually\": \"Sunucu bilgilerini girin\",\n  \"files-add-network-share.host-label\": \"Sunucu adresi\",\n  \"files-add-network-share.host-required\": \"Sunucu adresi gerekli\",\n  \"files-add-network-share.manual-share-help\": \"Sunucunuzda göründüğü gibi paylaşımın tam adını girin\",\n  \"files-add-network-share.no-shares-found\": \"Bu sunucuda paylaşım bulunamadı\",\n  \"files-add-network-share.not-seeing-share\": \"Paylaşımınızı göremiyor musunuz?\",\n  \"files-add-network-share.password-label\": \"Şifre\",\n  \"files-add-network-share.password-required\": \"Şifre gerekli\",\n  \"files-add-network-share.retrieving-shares\": \"Paylaşımlar alınıyor...\",\n  \"files-add-network-share.retry-discovery\": \"Ağı yeniden tara\",\n  \"files-add-network-share.select-share\": \"Eklenecek bir paylaşım seç\",\n  \"files-add-network-share.share-placeholder\": \"shared-documents\",\n  \"files-add-network-share.share-required\": \"Paylaşım adı gerekli\",\n  \"files-add-network-share.title\": \"Ağ paylaşımı ekle\",\n  \"files-add-network-share.username-label\": \"Kullanıcı adı\",\n  \"files-add-network-share.username-placeholder\": \"admin\",\n  \"files-add-network-share.username-required\": \"Kullanıcı adı gerekli\",\n  \"files-audio-island.now-playing\": \"Şu An Çalıyor\",\n  \"files-audio-island.pause\": \"Duraklat\",\n  \"files-audio-island.play\": \"Oynat\",\n  \"files-backend-error.base-directory-not-found\": \"Temel dizin bulunamadı\",\n  \"files-backend-error.cant-find-root\": \"Dosya yolu doğrulanamadı\",\n  \"files-backend-error.destination-already-exists\": \"Hedefte aynı isimde bir öğe zaten var\",\n  \"files-backend-error.destination-not-exist\": \"Hedef klasör mevcut değil\",\n  \"files-backend-error.does-not-exist\": \"Dosya veya klasör mevcut değil\",\n  \"files-backend-error.escapes-base\": \"Yol izin verilen dizinin dışında\",\n  \"files-backend-error.invalid-base\": \"Yol geçerli bir dizine ait değil\",\n  \"files-backend-error.invalid-filename\": \"Dosya adı geçersiz\",\n  \"files-backend-error.invalid-path\": \"Dosya yolu geçersiz\",\n  \"files-backend-error.mkdir-failed\": \"Klasör oluşturulamadı\",\n  \"files-backend-error.move-failed\": \"Öğe taşınamadı\",\n  \"files-backend-error.not-enough-space\": \"Yeterli depolama alanı yok\",\n  \"files-backend-error.operation-not-allowed\": \"Bu işlem izin verilmiyor\",\n  \"files-backend-error.parent-not-directory\": \"Üst yol bir klasör değil\",\n  \"files-backend-error.parent-not-exist\": \"Üst klasör mevcut değil\",\n  \"files-backend-error.path-not-absolute\": \"Dosya yolu mutlak değil\",\n  \"files-backend-error.share-already-exists\": \"Bu klasör zaten paylaşılıyor\",\n  \"files-backend-error.share-name-generation-failed\": \"Benzersiz paylaşım adı oluşturulamadı\",\n  \"files-backend-error.source-not-exists\": \"Kaynak dosya veya klasör mevcut değil\",\n  \"files-backend-error.subdir-of-self\": \"Bir klasör kendisinin alt dizinine taşınamaz veya kopyalanamaz\",\n  \"files-backend-error.trash-meta-not-exists\": \"Bu öğenin orijinal konumu bulunamadı\",\n  \"files-backend-error.unique-name-index-exceeded\": \"Benzersiz isim oluşturulamadı. Benzer isimde çok fazla öğe var\",\n  \"files-backend-error.upload-failed\": \"Yükleme başarısız oldu\",\n  \"files-collision.action.keep-both\": \"İkisini de tut\",\n  \"files-collision.action.replace\": \"Değiştir\",\n  \"files-collision.action.skip\": \"Atla\",\n  \"files-collision.destination.original-location\": \"orijinal konumu\",\n  \"files-collision.message\": \"Mevcut öğeyi değiştirmek mi yoksa ikisini de tutmak mı istiyorsunuz?\",\n  \"files-collision.title\": \"\\\"{{itemName}}\\\" zaten {{destinationName}}'de mevcut\",\n  \"files-download.confirm\": \"İndir\",\n  \"files-download.description\": \"Files bu dosya türünü açamıyor. Bunun yerine indirmek ister misiniz?\",\n  \"files-download.title\": \"{{name}} dosyasını indirmek ister misiniz?\",\n  \"files-empty-trash.confirm\": \"Boşalt\",\n  \"files-empty-trash.description\": \"Çöp’teki tüm öğeleri kalıcı olarak silmek istediğinizden emin misiniz? Bu işlemi geri alamazsınız.\",\n  \"files-empty-trash.title\": \"Çöp’ü Boşalt?\",\n  \"files-empty.directory\": \"Bu klasörde öğe yok\",\n  \"files-empty.network\": \"Ağda cihaz yok\",\n  \"files-empty.network-host-offline\": \"Ağ cihazı çevrimdışı\",\n  \"files-error.add-favorite\": \"Favorilere ekleme başarısız oldu: {{message}}\",\n  \"files-error.add-share\": \"Klasör paylaşma başarısız oldu: {{message}}\",\n  \"files-error.compress\": \"Sıkıştırma başarısız oldu: {{message}}\",\n  \"files-error.copy\": \"Kopyalama başarısız oldu: {{message}}\",\n  \"files-error.create-folder\": \"Klasör oluşturma başarısız oldu: {{message}}\",\n  \"files-error.delete\": \"Silme başarısız oldu: {{message}}\",\n  \"files-error.eject-disk\": \"Sürücü çıkarma başarısız oldu: {{message}}\",\n  \"files-error.empty-trash\": \"Çöpü boşaltma başarısız oldu: {{message}}\",\n  \"files-error.extract\": \"Arşivden çıkarma başarısız oldu: {{message}}\",\n  \"files-error.folder-already-exists\": \"Bu isimde bir klasör zaten var\",\n  \"files-error.move\": \"Taşıma başarısız oldu: {{message}}\",\n  \"files-error.remove-favorite\": \"Favorilerden kaldırma başarısız oldu: {{message}}\",\n  \"files-error.remove-share\": \"Paylaşılan klasörü kaldırma başarısız oldu: {{message}}\",\n  \"files-error.rename\": \"Yeniden adlandırma başarısız oldu: {{message}}\",\n  \"files-error.restore\": \"Geri yükleme başarısız oldu: {{message}}\",\n  \"files-error.trash\": \"Çöpe taşıma başarısız oldu: {{message}}\",\n  \"files-error.upload\": \"Yükleme başarısız oldu: {{message}}\",\n  \"files-error.upload-network-error\": \"Yükleme başarısız oldu: {{name}} için ağ hatası oluştu\",\n  \"files-extension-change.confirm\": \"Devam et\",\n  \"files-extension-change.description-add\": \"“{{fileName}}” dosyasının uzantısını “{{extension}}” olarak değiştirmek istediğinizden emin misiniz? Bu, dosyanın okunamaz olmasına neden olabilir.\",\n  \"files-extension-change.description-remove\": \"“{{fileName}}” dosyasının uzantısını kaldırmak istediğinizden emin misiniz?\",\n  \"files-extension-change.title-add\": \"Uzantı “{{extension}}” olarak değiştirilsin mi?\",\n  \"files-extension-change.title-remove\": \"Uzantı kaldırılsın mı?\",\n  \"files-external-storage.unsupported.description\": \"Bağlı harici sürücünüz güç sorunları nedeniyle bir Raspberry Pi üzerinde kullanılamıyor. Harici depolama Umbrel Home, Umbrel Pro ve tüm x86 (Intel veya AMD) cihazlarda kullanılabilir.\",\n  \"files-external-storage.unsupported.description-general\": \"Maalesef güç sorunları nedeniyle Raspberry Pi'de harici depolama kullanılamıyor. Umbrel Home, Umbrel Pro ve tüm x86 (Intel veya AMD) cihazlarda harici depolama kullanılabilir.\",\n  \"files-external-storage.unsupported.title\": \"Harici Depolama Desteklenmiyor\",\n  \"files-folder\": \"Klasör\",\n  \"files-format.confirm\": \"Biçimlendir\",\n  \"files-format.description\": \"Biçimlendirme, {{driveName}} içindeki tüm verileri silecektir. Bu işlem geri alınamaz.\",\n  \"files-format.description-unreadable\": \"umbrelOS, {{driveName}} içeriğini okuyamıyor. umbrelOS ile kullanmak için biçimlendirebilirsiniz.\",\n  \"files-format.drive-label\": \"Ad\",\n  \"files-format.error\": \"Sürücü biçimlendirilemedi\",\n  \"files-format.exfat-description\": \"Windows, macOS ve Linux ile maksimum uyumluluk\",\n  \"files-format.ext4-description\": \"umbrelOS ve Linux ile daha iyi performans\",\n  \"files-format.filesystem\": \"Dosya sistemi\",\n  \"files-format.filesystem-label\": \"Biçimlendirme türü\",\n  \"files-format.formatting\": \"Biçimlendiriliyor...\",\n  \"files-format.title\": \"Sürücüyü Biçimlendir\",\n  \"files-format.title-requires-format\": \"Biçimlendirme gerekli\",\n  \"files-formatting-island.formatting\": \"Biçimlendiriliyor...\",\n  \"files-formatting-island.formatting-drives\": \"{{count}} sürücü biçimlendiriliyor\",\n  \"files-listing.empty\": \"Öğe yok\",\n  \"files-listing.error\": \"Bir hata oluştu\",\n  \"files-listing.item-count-truncated\": \"{{formattedCount}}+ öğe\",\n  \"files-listing.item-count_one\": \"{{formattedCount}} öğe\",\n  \"files-listing.item-count_other\": \"{{formattedCount}} öğe\",\n  \"files-listing.loading\": \"Yükleniyor...\",\n  \"files-listing.no-such-file\": \"Böyle bir dosya veya klasör yok\",\n  \"files-listing.selected-count\": \"{{selectedCount}}/{{totalCount}} öğe seçili\",\n  \"files-listing.selected-count-truncated\": \"Toplam {{totalCount}}+ öğeden {{selectedCount}}'i seçili\",\n  \"files-name-drawer.new-folder\": \"Yeni Klasör\",\n  \"files-name-drawer.new-folder-description\": \"Yeni klasör için bir ad girin.\",\n  \"files-name-drawer.new-folder-input\": \"Klasör Adı\",\n  \"files-name-drawer.rename-file\": \"Dosyayı Yeniden Adlandır\",\n  \"files-name-drawer.rename-file-description\": \"Bu dosya için yeni bir ad girin.\",\n  \"files-name-drawer.rename-file-input\": \"Dosya Adı\",\n  \"files-name-drawer.rename-folder\": \"Klasörü Yeniden Adlandır\",\n  \"files-name-drawer.rename-folder-description\": \"Bu klasör için yeni bir ad girin.\",\n  \"files-name-drawer.rename-folder-input\": \"Klasör Adı\",\n  \"files-network-storage-error.add-share\": \"Ağ paylaşımı ekleme başarısız oldu: {{message}}\",\n  \"files-network-storage-error.discover-servers\": \"Ağ cihazı keşfi başarısız oldu: {{message}}\",\n  \"files-network-storage-error.discover-shares\": \"Ağ paylaşımlarının keşfi başarısız oldu: {{message}}\",\n  \"files-network-storage-error.remove-share\": \"Ağ paylaşımını kaldırma başarısız oldu: {{message}}\",\n  \"files-operations-island.copying\": \"\\\"{{from}}\\\" öğesi \\\"{{to}}\\\" konumuna kopyalanıyor\",\n  \"files-operations-island.moving\": \"\\\"{{from}}\\\" öğesi \\\"{{to}}\\\" konumuna taşınıyor\",\n  \"files-operations-island.restoring\": \"\\\"{{from}}\\\" öğesi \\\"{{to}}\\\" konumuna geri yükleniyor\",\n  \"files-path.input-group\": \"Yol girişi\",\n  \"files-path.input-label\": \"Geçerli yol\",\n  \"files-permanently-delete.confirm\": \"Kalıcı olarak sil\",\n  \"files-permanently-delete.description-multiple\": \"Bu {{count}} öğeyi kalıcı olarak silmek istediğinizden emin misiniz? Bu işlemi geri alamazsınız.\",\n  \"files-permanently-delete.description-single\": \"“{{fileName}}” öğesini kalıcı olarak silmek istediğinizden emin misiniz? Bu işlemi geri alamazsınız.\",\n  \"files-permanently-delete.title-multiple\": \"{{count}} öğe kalıcı olarak silinsin mi?\",\n  \"files-permanently-delete.title-single\": \"Kalıcı olarak silinsin mi?\",\n  \"files-search.default\": \"Dosya ve klasörlerde ara\",\n  \"files-search.no-results\": \"\\\"{{query}}\\\" için sonuç bulunamadı\",\n  \"files-search.placeholder\": \"Ara\",\n  \"files-search.searching-label\": \"{{name}}'in Umbrel'ini arıyor\",\n  \"files-share.home-description\": \"Ağınızdaki diğer cihazlardan “{{homeDirectoryName}}” içindeki tüm dosyalara erişin\",\n  \"files-share.home-title\": \"“{{homeDirectoryName}}” klasörünü ağ üzerinden paylaş\",\n  \"files-share.instructions.how-to-access\": \"Nasıl erişilir\",\n  \"files-share.instructions.ios.enter-password\": \"Şifre olarak <field>{{password}}</field> girin.\",\n  \"files-share.instructions.ios.enter-server\": \"Sunucu adresi olarak <field>{{smbUrl}}</field> girin.\",\n  \"files-share.instructions.ios.enter-username\": \"Kullanıcı adı olarak <field>{{username}}</field> girin.\",\n  \"files-share.instructions.ios.install-files\": \"\\\"Files\\\" uygulaması yüklü değilse App Store’dan yükleyin.\",\n  \"files-share.instructions.ios.tap-connect\": \"Erişim için \\\"Connect\\\" düğmesine dokunun.\",\n  \"files-share.instructions.ios.tap-dots\": \"Sağ üstteki üç noktaya (...) dokunun ve \\\"Connect to Server\\\" öğesini seçin.\",\n  \"files-share.instructions.macos.click-connect\": \"Erişim için \\\"Connect\\\" düğmesine tıklayın.\",\n  \"files-share.instructions.macos.enter-password\": \"Şifre olarak <field>{{password}}</field> girin.\",\n  \"files-share.instructions.macos.enter-url\": \"<field>{{smbUrl}}</field> girin ve \\\"Connect\\\"e tıklayın.\",\n  \"files-share.instructions.macos.enter-username\": \"Kullanıcı adı olarak <field>{{username}}</field> girin.\",\n  \"files-share.instructions.macos.open-finder\": \"\\\"Finder\\\"ı açın ve ⌘ + K tuşlarına basın.\",\n  \"files-share.instructions.macos.select-registered\": \"İstendiğinde \\\"Registered User\\\"ı seçin.\",\n  \"files-share.instructions.macos.time-machine\": \"Time Machine yedekleme konumu olarak nasıl kullanılır\",\n  \"files-share.instructions.macos.time-machine.choose-encryption\": \"Şifrelenmiş veya şifrelenmemiş yedekler arasında seçim yapın.\",\n  \"files-share.instructions.macos.time-machine.disk-limit\": \"'Disk Usage Limit' için, Time Machine yedekleri için Umbrel üzerinde ayırmak istediğiniz maksimum alanı belirleyin ve ardından \\\"Done\\\" düğmesine tıklayın.\",\n  \"files-share.instructions.macos.time-machine.follow-steps\": \"Yukarıdaki adımları izleyin ve Mac’inizde Sistem Ayarları’nı açın.\",\n  \"files-share.instructions.macos.time-machine.go-settings\": \"Time Machine’e gidin, \\\"Add Backup Disk...\\\" öğesine tıklayın.\",\n  \"files-share.instructions.macos.time-machine.select-disk\": \"Klasörü seçin ve \\\"Set Up Disk...\\\" öğesine tıklayın.\",\n  \"files-share.instructions.umbrelos.backup.follow-onscreen\": \"Yedeklemeyi ayarlamak için ekrandaki adımları takip et.\",\n  \"files-share.instructions.umbrelos.backup.follow-then-go-to\": \"Yukarıdaki adımları uyguladıktan sonra diğer Umbrel'inde \\\"{{settings}}\\\" > \\\"{{backups}}\\\" bölümüne git.\",\n  \"files-share.instructions.umbrelos.backup.select-add\": \"\\\"{{addUmbrelOrNas}}\\\" seçeneğini seç.\",\n  \"files-share.instructions.umbrelos.backup.select-connected\": \"Bağlı cihazlar listesinden bu Umbrel cihazını seç.\",\n  \"files-share.instructions.umbrelos.backup.title\": \"Diğer Umbrel cihazlarınız için yedekleme konumu olarak nasıl kullanılır\",\n  \"files-share.instructions.umbrelos.cant-find-note\": \"Bulamıyor musunuz? \\\"Elle ekle\\\" seçeneğini deneyin ve aşağıdaki kimlik bilgilerini kullanın. Yine ekleyemiyorsanız, her iki cihazınızın da aynı ağa bağlı olduğundan emin olun.\",\n  \"files-share.instructions.umbrelos.enter-password\": \"Şifre olarak <field>{{password}}</field> gir.\",\n  \"files-share.instructions.umbrelos.enter-username\": \"Kullanıcı adı olarak <field>{{username}}</field> gir.\",\n  \"files-share.instructions.umbrelos.open-and-click\": \"Diğer Umbrel'inde \\\"Files\\\"'ı aç ve kenar çubuğunda \\\"<deviceIcon/> {{deviceLabel}}\\\"'in yanındaki <plus/> öğesine tıkla.\",\n  \"files-share.instructions.umbrelos.select-device\": \"Ağınızdaki otomatik algılanan cihazlar listesinden bu Umbrel cihazını seç.\",\n  \"files-share.instructions.umbrelos.select-sharename\": \"\\\"{{sharename}}\\\"'ı seç ve paylaşımı eklemek için tıkla.\",\n  \"files-share.instructions.windows.enter-password\": \"Şifre olarak <field>{{password}}</field> girin.\",\n  \"files-share.instructions.windows.enter-url\": \"<field>{{smbUrl}}</field> yazın ve Enter’a basın.\",\n  \"files-share.instructions.windows.enter-username\": \"Kullanıcı adı olarak <field>{{username}}</field> girin.\",\n  \"files-share.instructions.windows.open-run\": \"Windows + R tuşuna basarak Çalıştır iletişim kutusunu açın.\",\n  \"files-share.instructions.windows.remember-credentials\": \"\\\"Remember my credentials\\\" seçeneğini işaretleyin ve OK'ye tıklayın.\",\n  \"files-share.regular-description\": \"Bu klasörü paylaşarak ağınızdaki diğer cihazlardan erişin\",\n  \"files-share.regular-title\": \"Klasörü ağ üzerinde paylaş\",\n  \"files-share.toggle\": \"“{{name}}” öğesini ağınız üzerinden paylaş\",\n  \"files-sidebar.apps\": \"Uygulamalar\",\n  \"files-sidebar.external-storage\": \"Harici depolama\",\n  \"files-sidebar.favorites\": \"Favoriler\",\n  \"files-sidebar.home\": \"Ev\",\n  \"files-sidebar.navigation\": \"Dosya gezintisi\",\n  \"files-sidebar.network\": \"Ağ\",\n  \"files-sidebar.network-pathbar\": \"Ağ Cihazları\",\n  \"files-sidebar.network-sidebar\": \"Cihazlar\",\n  \"files-sidebar.recents\": \"Son açılanlar\",\n  \"files-sidebar.shared-folders\": \"Paylaşılan klasörler\",\n  \"files-sidebar.trash\": \"Çöp\",\n  \"files-sidebar.trash.open\": \"Aç\",\n  \"files-sort.created\": \"Eklendi\",\n  \"files-sort.modified\": \"Değiştirildi\",\n  \"files-sort.name\": \"Ad\",\n  \"files-sort.size\": \"Boyut\",\n  \"files-sort.type\": \"Tür\",\n  \"files-state.uploading\": \"Yükleniyor...\",\n  \"files-state.waiting\": \"Bekleniyor...\",\n  \"files-type.3gp\": \"3GP Video Dosyası\",\n  \"files-type.3gp2\": \"3GP2 Video Dosyası\",\n  \"files-type.7z\": \"7Z Arşivi\",\n  \"files-type.aac\": \"AAC Ses Dosyası\",\n  \"files-type.ai\": \"Illustrator Dosyası\",\n  \"files-type.aiff\": \"AIFF Ses Dosyası\",\n  \"files-type.au\": \"AU Ses Dosyası\",\n  \"files-type.avi\": \"AVI Video Dosyası\",\n  \"files-type.avif\": \"AVIF Görseli\",\n  \"files-type.bmp\": \"BMP Görseli\",\n  \"files-type.bzip2\": \"BZIP2 Arşivi\",\n  \"files-type.caf\": \"CAF Ses Dosyası\",\n  \"files-type.compressed\": \"Sıkıştırılmış Arşiv\",\n  \"files-type.csv\": \"CSV Dosyası\",\n  \"files-type.directory\": \"Klasör\",\n  \"files-type.dmg\": \"Disk İmajı\",\n  \"files-type.dv\": \"DV Video Dosyası\",\n  \"files-type.epub\": \"EPUB e-Kitap\",\n  \"files-type.excel\": \"Excel Çalışma Sayfası\",\n  \"files-type.exe\": \"Windows Yürütülebilir Dosyası\",\n  \"files-type.executable\": \"Yürütülebilir Dosya\",\n  \"files-type.external-drive\": \"Sürücü\",\n  \"files-type.flac\": \"FLAC Ses Dosyası\",\n  \"files-type.flv\": \"FLV Video Dosyası\",\n  \"files-type.gif\": \"GIF Görseli\",\n  \"files-type.gzip\": \"GZIP Arşivi\",\n  \"files-type.heic\": \"HEIC Görseli\",\n  \"files-type.ico\": \"ICO Görseli\",\n  \"files-type.iso\": \"ISO Kalıbı\",\n  \"files-type.jpeg\": \"JPEG Görseli\",\n  \"files-type.keynote\": \"Keynote Sunumu\",\n  \"files-type.lzip\": \"LZIP Arşivi\",\n  \"files-type.lzma\": \"LZMA Arşivi\",\n  \"files-type.lzop\": \"LZOP Arşivi\",\n  \"files-type.m3u\": \"M3U Çalma Listesi\",\n  \"files-type.m4a\": \"M4A Ses Dosyası\",\n  \"files-type.m4v\": \"M4V Video Dosyası\",\n  \"files-type.midi\": \"MIDI Ses Dosyası\",\n  \"files-type.mka\": \"MKA Ses Dosyası\",\n  \"files-type.mkv\": \"MKV Video Dosyası\",\n  \"files-type.mng\": \"MNG Video Dosyası\",\n  \"files-type.mobi\": \"MOBI e-Kitap\",\n  \"files-type.mp3\": \"MP3 Ses Dosyası\",\n  \"files-type.mp4\": \"MP4 Video Dosyası\",\n  \"files-type.mp4-audio\": \"MP4 Ses Dosyası\",\n  \"files-type.mpeg\": \"MPEG Video Dosyası\",\n  \"files-type.mpeg-ts\": \"MPEG Taşıma Akışı Dosyası\",\n  \"files-type.network-drive\": \"Ağ Sürücüsü\",\n  \"files-type.numbers\": \"Numbers E-Tablosu\",\n  \"files-type.ogg\": \"OGG Ses Dosyası\",\n  \"files-type.ogv\": \"OGV Video Dosyası\",\n  \"files-type.pages\": \"Pages Belgesi\",\n  \"files-type.pdf\": \"PDF Belgesi\",\n  \"files-type.png\": \"PNG Görseli\",\n  \"files-type.powerpoint\": \"PowerPoint Sunumu\",\n  \"files-type.psd\": \"Photoshop Belgesi\",\n  \"files-type.quicktime\": \"QuickTime Video Dosyası\",\n  \"files-type.rar\": \"RAR Arşivi\",\n  \"files-type.sgi\": \"SGI Film Dosyası\",\n  \"files-type.svg\": \"SVG Görseli\",\n  \"files-type.tar\": \"TAR Arşivi\",\n  \"files-type.tiff\": \"TIFF Görseli\",\n  \"files-type.ts\": \"TS Video Dosyası\",\n  \"files-type.txt\": \"Metin Dosyası\",\n  \"files-type.umbrel-backup\": \"Umbrel Backup\",\n  \"files-type.wav\": \"WAV Ses Dosyası\",\n  \"files-type.webm\": \"WebM Video Dosyası\",\n  \"files-type.webm-audio\": \"WebM Ses Dosyası\",\n  \"files-type.webp\": \"WebP Görseli\",\n  \"files-type.wma\": \"WMA Ses Dosyası\",\n  \"files-type.wmv\": \"WMV Video Dosyası\",\n  \"files-type.word\": \"Word Belgesi\",\n  \"files-type.xz\": \"XZ Arşivi\",\n  \"files-type.zip\": \"ZIP Arşivi\",\n  \"files-upload-island.uploading-count\": \"{{count}} öğe yükleniyor\",\n  \"files-view.icons\": \"Simgeler\",\n  \"files-view.list\": \"Liste\",\n  \"files-view.sort-by\": \"Şuna göre sırala\",\n  \"files-view.view-as\": \"Görünüm biçimi\",\n  \"files-widgets.favorites.no-items-text\": \"Burada görmek için bir klasörü favorilere ekleyin\",\n  \"files-widgets.recents.no-items-text\": \"Son kullanılan dosya yok\",\n  \"generic-in\": \"in\",\n  \"hide-details\": \"Ayrıntıları gizle\",\n  \"install-first.install-app\": \"{{app}} yükle\",\n  \"install-first.title\": \"{{app}} bu uygulamalara ihtiyaç duyuyor\",\n  \"install-your-first-app\": \"İlk uygulamanızı yükleyin\",\n  \"language\": \"Dil\",\n  \"language-description\": \"Tercih ettiğiniz umbrelOS dili\",\n  \"language.select-description\": \"Tercih edilen umbrelOS dilini seçin\",\n  \"live-usage\": \"Canlı Kullanım\",\n  \"loading\": \"Yükleniyor\",\n  \"local-ip\": \"Yerel IP\",\n  \"login-2fa.subtitle\": \"Kimlik doğrulama uygulamanızda görüntülenen 2FA kodunu girin\",\n  \"login-2fa.title\": \"Kimlik doğrulama\",\n  \"login-with-umbrel.description\": \"{{app}}'i açmak için Umbrel şifrenizi girin\",\n  \"login-with-umbrel.title\": \"Umbrel ile giriş yap\",\n  \"login.password-label\": \"Şifre\",\n  \"login.password.submit\": \"Giriş yap\",\n  \"login.subtitle\": \"Giriş yapmak için Umbrel şifrenizi girin\",\n  \"login.title\": \"Tekrar hoş geldiniz\",\n  \"logout\": \"Çıkış yap\",\n  \"logout-error-generic\": \"Hata: Çıkış yapılamadı\",\n  \"logout.confirm.submit\": \"Çıkış yap\",\n  \"logout.confirm.title\": \"Çıkış yapmak istediğinize emin misiniz?\",\n  \"memory\": \"Bellek\",\n  \"memory.low\": \"Düşük bellek\",\n  \"migrate\": \"Geçiş yap\",\n  \"migrate.callout\": \"Geçiş tamamlanana kadar Umbrel'inizi kapatmayın\",\n  \"migrate.failed.retry\": \"Tekrar dene\",\n  \"migrate.failed.title\": \"Geçiş başarısız\",\n  \"migrate.success.description\": \"Tüm uygulamalarınız, uygulama verileriniz ve hesap detaylarınız Umbrel Home'a aktarıldı.\",\n  \"migrate.success.title\": \"Geçiş başarılı\",\n  \"migration-assistant\": \"Geçiş Asistanı\",\n  \"migration-assistant-description\": \"Tüm uygulamalarını ve verilerini bir Raspberry Pi'den {{deviceName}}'e aktar\",\n  \"migration-assistant-unsupported-device-description\": \"Migration Assistant şu anda umbrelOS yüklü bir Raspberry Pi'den Umbrel Home veya Umbrel Pro'ya tüm verileri ve uygulamaları aktarmayı destekliyor. Başlamak için Umbrel Home veya Umbrel Pro'da Migration Assistant'ı aç.\",\n  \"migration-assistant.continue-migration.ready.submit\": \"Geçişi başlat\",\n  \"migration-assistant.failed\": \"Bir şeyler doğru değil...\",\n  \"migration-assistant.failed.retrying-message\": \"Tekrar deneniyor...\",\n  \"migration-assistant.mobile.start-button\": \"Geçişi başlat\",\n  \"migration-assistant.prep.body\": \"Geçiş için hazırlanın\",\n  \"migration-assistant.prep.button-continue\": \"Devam et\",\n  \"migration-assistant.prep.callout\": \"Varsa, {{deviceName}} üzerindeki verilerin kalıcı olarak silinecek.\",\n  \"migration-assistant.prep.connect-disk-to-home\": \"Harici sürücüsünü {{deviceName}}'deki herhangi bir USB portuna tak.\",\n  \"migration-assistant.prep.prep-done-continue-message\": \"İşlem tamamlandığında aşağıdaki '{{button}}' butonuna tıklayın.\",\n  \"migration-assistant.prep.shut-down-rpi\": \"Raspberry Pi Umbrel'inizi kapatın.\",\n  \"migration-assistant.ready.description\": \"Tüm verilerin ve uygulamaların {{deviceName}}'e taşınmaya hazır.\",\n  \"migration-assistant.ready.hint-header\": \"Dikkat edilmesi gerekenler\",\n  \"migration-assistant.ready.hint-keep-pi-off.description\": \"Bu, Lightning Node gibi uygulamalardaki sorunları önlemeye yardımcı olur\",\n  \"migration-assistant.ready.hint-keep-pi-off.title\": \"Güncellemeden sonra Raspberry Pi'yi kapalı tutun\",\n  \"migration-assistant.ready.hint-use-same-password.description\": \"Raspberry Pi'deki Umbrel şifreni {{deviceName}}'e giriş yapmak için kullanmayı unutma.\",\n  \"migration-assistant.ready.hint-use-same-password.title\": \"Aynı şifreyi kullanın\",\n  \"migration-assistant.ready.title\": \"Geçiş yapmaya hazırsınız!\",\n  \"mini-browser.default-title\": \"Klasör seç\",\n  \"mini-browser.empty-external\": \"Burada görünmesi için bir harici sürücü bağlayın.\",\n  \"mini-browser.empty-network\": \"Burada görünmesi için bir Umbrel veya NAS ekleyin.\",\n  \"mini-browser.load-more\": \"Daha fazla yükle\",\n  \"mini-browser.load-more-in-folder\": \"{{name}} içinde daha fazla yükle\",\n  \"mini-browser.loading-more\": \"Daha fazla yükleniyor…\",\n  \"mini-browser.select\": \"Seç\",\n  \"mini-browser.select-folder\": \"Klasör seç\",\n  \"name\": \"Ad\",\n  \"nas\": \"NAS\",\n  \"no-forgot-password-message\": \"Şifrenizi kaybederseniz, Umbrel'e giriş yapamazsınız. Şifrenizi güvenli bir şekilde sakladığınızdan emin olun.\",\n  \"no-results-found\": \"Sonuç bulunamadı\",\n  \"not-found-404\": \"Hata kodu: 404\",\n  \"not-found-404.back\": \"Geri\",\n  \"not-found-404.home\": \"Ana sayfaya git\",\n  \"notifications.backups-failing-location.description\": \"Otomatik Backups {{location}}'a yapılamıyor. Bağlantıyı kontrol et ve Backups ayarlarını gözden geçir.\",\n  \"notifications.backups-failing.description\": \"Otomatik Backups başarısız oluyor. Backup konumunu kontrol et ve ayarlarını gözden geçir.\",\n  \"notifications.backups-failing.go-to-backups\": \"Backups'a git\",\n  \"notifications.backups-failing.title\": \"Son 24 saatte Backups yok\",\n  \"notifications.cpu.too-hot\": \"Yüksek CPU sıcaklığı\",\n  \"notifications.memory.low\": \"Cihazınızın belleği düşük\",\n  \"notifications.new-version-available\": \"{{update}} artık indirilebilir\",\n  \"notifications.raid.issue.description\": \"Depolama sorunu tespit edildi. Detaylar için Storage Manager'a göz atın.\",\n  \"notifications.raid.issue.title\": \"Acil işlem gerekiyor\",\n  \"notifications.ssd.health.description\": \"Bir veya daha fazla SSD dikkat gerektirebilir. Detaylar için Storage Manager'a göz atın.\",\n  \"notifications.ssd.health.title\": \"SSD sağlık uyarısı\",\n  \"notifications.storage.full\": \"Cihazınızın depolama alanı dolu\",\n  \"notifications.view\": \"Görüntüle\",\n  \"ok\": \"Tamam\",\n  \"onboarding.account-created.by-clicking-button-you-agree\": \"'İleri' butonuna tıklayarak <linked>umbrelOS Hizmet Şartları</linked>'nı kabul ediyorsunuz\",\n  \"onboarding.account-created.youre-all-set-name\": \"Hazırsınız, {{name}}.\",\n  \"onboarding.contact-support\": \"Destek\",\n  \"onboarding.create-account\": \"Hesap oluştur\",\n  \"onboarding.create-account.confirm-password.input-label\": \"Şifreyi onayla\",\n  \"onboarding.create-account.failed.name-required\": \"Ad gerekli\",\n  \"onboarding.create-account.failed.passwords-dont-match\": \"Şifreler eşleşmiyor\",\n  \"onboarding.create-account.name.input-placeholder\": \"Adınız\",\n  \"onboarding.create-account.password.input-label\": \"Şifre\",\n  \"onboarding.create-account.submit\": \"Oluştur\",\n  \"onboarding.create-account.submitting\": \"Oluşturuluyor\",\n  \"onboarding.create-account.subtitle\": \"Hesap bilgileriniz yalnızca Umbrel'inizde saklanır. Şifrenizi güvenli bir şekilde yedeklediğinizden emin olun çünkü sıfırlama imkanı yoktur.\",\n  \"onboarding.create-instead-long\": \"Yeni hesap oluştur\",\n  \"onboarding.create-instead-short\": \"Yeni hesap\",\n  \"onboarding.launch-umbrelos\": \"umbrelOS'u başlat\",\n  \"onboarding.raid.available-storage\": \"Kullanılabilir depolama alanı\",\n  \"onboarding.raid.change-drives-link\": \"Sürücü eklemeniz veya değiştirmemiz mi gerekiyor?\",\n  \"onboarding.raid.configuring.subtitle\": \"Bu birkaç dakika sürebilir.\",\n  \"onboarding.raid.configuring.title\": \"Depolamanız yapılandırılıyor\",\n  \"onboarding.raid.configuring.warning\": \"Depolamanız yapılandırılırken lütfen bu sayfayı yenilemeyin veya Umbrel'inizi kapatmayın.\",\n  \"onboarding.raid.continue\": \"Devam et\",\n  \"onboarding.raid.error.detection-instructions\": \"Umbrel Pro'yu kapatın, SSD'lerin doğru takıldığını kontrol edin ve tekrar deneyin.\",\n  \"onboarding.raid.error.no-ssds-detected\": \"Hiç SSD tespit edilmedi\",\n  \"onboarding.raid.error.no-ssds-instructions\": \"Devam etmek için Umbrel Pro'yu kapatın ve en az bir SSD takın.\",\n  \"onboarding.raid.failsafe\": \"FailSafe\",\n  \"onboarding.raid.failsafe.cant-enable\": \"FailSafe henüz etkinleştirilemiyor\",\n  \"onboarding.raid.failsafe.enable\": \"FailSafe'i etkinleştir\",\n  \"onboarding.raid.failsafe.mixed-sizes\": \"FailSafe, en küçük SSD'niz ({{smallest}}) ile sınırlıdır. Daha büyük SSD'lerdeki ekstra alan kullanılamaz ve toplam {{wasted}} kullanılamaz hale gelir.\",\n  \"onboarding.raid.failsafe.protection-info-2ssds\": \"{{protection}} veri koruması için kullanılır. Kullanılabilir depolamayı {{futureWith3}}'e artırmak için başka bir {{smallest}} SSD ekleyin veya {{futureWith4}} için iki tane daha ekleyin. Daha fazla SSD'yi istediğiniz zaman ekleyebilirsiniz.\",\n  \"onboarding.raid.failsafe.protection-info-3ssds\": \"{{protection}} veri koruması için kullanılır. Kullanılabilir depolamayı {{futureWith4}}'e artırmak için başka bir {{smallest}} SSD ekleyin. Daha fazla SSD'yi istediğiniz zaman ekleyebilirsiniz.\",\n  \"onboarding.raid.failsafe.single-ssd-info\": \"Sadece bir SSD'niz var. Verileriniz için FailSafe korumasını etkinleştirmek üzere en az bir {{size}} SSD daha ekleyin. Daha fazla SSD'yi istediğiniz zaman ekleyebilirsiniz.\",\n  \"onboarding.raid.failsafe.subtitle\": \"Herhangi bir tek SSD arızalansa bile verileriniz güvende kalır\",\n  \"onboarding.raid.failsafe.tip\": \"Maksimum depolama ve hiç kullanılmayan alan olmaması için aynı boyutta SSD'ler kullanın.\",\n  \"onboarding.raid.failsafe.warning-now-only\": \"Birden fazla SSD varsa FailSafe yalnızca ilk kurulum sırasında etkinleştirilebilir. Daha sonra etkinleştiremezsiniz.\",\n  \"onboarding.raid.health-warning\": \"Bu sürücü sağlık sorunları bildiriyor\",\n  \"onboarding.raid.launching\": \"Başlatılıyor...\",\n  \"onboarding.raid.no-ssds-alt\": \"SSD bulunamadı\",\n  \"onboarding.raid.recommended\": \"Önerilen\",\n  \"onboarding.raid.scanning\": \"SSD yuvalarınız kontrol ediliyor\",\n  \"onboarding.raid.scanning-alt\": \"SSD'ler taranıyor\",\n  \"onboarding.raid.setup-failed.description-no-retry\": \"Lütfen kapatıp tekrar deneyin.\",\n  \"onboarding.raid.setup-failed.description-retry\": \"Tekrar deneyin veya sürücüleri kontrol etmek için kapatın.\",\n  \"onboarding.raid.setup-failed.title\": \"Depolama kurulumu başarısız oldu\",\n  \"onboarding.raid.shutdown-dialog.description\": \"Sürücü eklemek veya değiştirmek için Umbrel Pro'yu kapatın. İşlem tamamlandıktan sonra tekrar açıp kuruluma devam edebilirsiniz.\",\n  \"onboarding.raid.shutdown-dialog.title\": \"Sürücüleri değiştirmek istiyor musunuz?\",\n  \"onboarding.raid.ssd-in-slot\": \"<highlight>Slot {{slot}}</highlight>'ta bir <highlight>{{size}}</highlight> SSD\",\n  \"onboarding.raid.ssd-label\": \"SSD {{number}}\",\n  \"onboarding.raid.ssd-tray-alt\": \"SSD tepsisi\",\n  \"onboarding.raid.ssds-found\": \"Umbrel Pro'da aşağıdaki SSD'ler bulundu\",\n  \"onboarding.raid.storage\": \"Depolama\",\n  \"onboarding.raid.storage-label\": \"Depolama\",\n  \"onboarding.raid.success.storage-info\": \"Depolama {{available}}\",\n  \"onboarding.raid.success.storage-info-failsafe\": \"Depolama {{available}} · FailSafe {{failsafe}}\",\n  \"onboarding.raid.try-again\": \"Tekrar dene\",\n  \"onboarding.raid.wasted\": \"Kullanılamaz\",\n  \"onboarding.restore-long\": \"Umbrel'imi geri yükle\",\n  \"onboarding.restore-short\": \"Geri yükle\",\n  \"onboarding.start.continue\": \"Hadi başlayalım\",\n  \"onboarding.start.subtitle\": \"Ev bulut sunucunuz kuruluma hazır.\",\n  \"onboarding.start.title\": \"umbrelOS'a hoş geldiniz\",\n  \"open\": \"Aç\",\n  \"open-live-usage\": \"Canlı Kullanımı Aç\",\n  \"password\": \"Şifre\",\n  \"preferences\": \"Tercihler\",\n  \"raid-error.description\": \"Depolama sisteminiz düzgün başlatılamadı. Aşağıdan SSD'lerinizin durumunu kontrol et ve sorun giderme adımlarını uygula. Sorun devam ederse, etkilenen SSD'lerin değiştirilmesi gerekebilir.\",\n  \"raid-error.factory-reset-dialog.description\": \"Bu işlem Umbrel Pro üzerindeki tüm verileri silecek ve cihazı fabrika ayarlarına döndürecek. Bu işlem geri alınamaz.\",\n  \"raid-error.factory-reset-dialog.title\": \"Fabrika ayarlarına sıfırlansın mı?\",\n  \"raid-error.factory-reset-failed\": \"Fabrika ayarlarına sıfırlama başarısız oldu\",\n  \"raid-error.health-warning\": \"Sağlık uyarısı\",\n  \"raid-error.missing-ssd-multiple\": \"{{count}} SSD yanıt vermiyor\",\n  \"raid-error.missing-ssd-one\": \"1 SSD yanıt vermiyor\",\n  \"raid-error.shutdown-dialog.description\": \"Umbrel Pro'nu kapat, tüm SSD'lerin yuvalarına düzgün oturduğundan emin ol ve ardından tekrar aç.\",\n  \"raid-error.shutdown-dialog.title\": \"Sürücüleri kontrol etmek için kapat?\",\n  \"raid-error.ssd-in-slot\": \"<highlight>{{size}}</highlight> boyutunda bir SSD <highlight>Slot {{slot}}</highlight>\",\n  \"raid-error.step-check-connections.button\": \"Kapat\",\n  \"raid-error.step-check-connections.description\": \"Kapat ve tüm SSD'lerin düzgün şekilde oturduğunu kontrol et.\",\n  \"raid-error.step-check-connections.title\": \"SSD bağlantılarını kontrol et\",\n  \"raid-error.step-factory-reset.button\": \"Fabrika Ayarlarına Sıfırla\",\n  \"raid-error.step-factory-reset.description\": \"Diğer her şey işe yaramazsa son çare. Bu tüm verileri siler.\",\n  \"raid-error.step-factory-reset.title\": \"Fabrika ayarlarına sıfırla\",\n  \"raid-error.step-restart.button\": \"Yeniden Başlat\",\n  \"raid-error.step-restart.description\": \"Genellikle işe yarayan hızlı bir ilk adım\",\n  \"raid-error.step-restart.title\": \"Yeniden başlatmayı dene\",\n  \"raid-error.title\": \"Depolama Sorunu Tespit Edildi\",\n  \"read-less\": \"Daha az oku\",\n  \"read-more\": \"Daha fazla oku\",\n  \"reconnect\": \"Yeniden bağlan\",\n  \"redirect.to-home\": \"Yükleniyor...\",\n  \"redirect.to-login\": \"Yükleniyor...\",\n  \"redirect.to-onboarding\": \"Yükleniyor...\",\n  \"redirect.to-raid-error\": \"Yükleniyor...\",\n  \"reload\": \"Yeniden yükle\",\n  \"remote-tor-access\": \"Uzaktan Tor erişimi\",\n  \"reset\": \"Sıfırla\",\n  \"restart\": \"Yeniden başlat\",\n  \"restart.confirm.submit\": \"Yeniden başlat\",\n  \"restart.confirm.title\": \"Umbrel'inizi yeniden başlatmak istediğinizden emin misiniz?\",\n  \"restart.restarting\": \"Yeniden başlatılıyor\",\n  \"restart.restarting-message\": \"Yeniden başlatılırken bu sayfayı yenilemeyin veya Umbrel'inizi kapatmayın.\",\n  \"rewind\": \"Rewind\",\n  \"rewind.files-as-of\": \"Dosyalarınız şu tarihteki hali:\",\n  \"rewind.loading-snapshots\": \"Anlık görüntüler yükleniyor...\",\n  \"rewind.now\": \"Şimdi\",\n  \"rewind.preflight.description\": \"Geçmiş yedeklerinizdeki dosya ve klasörleri bulun ve bunları güncel hâle geri getirin.\",\n  \"rewind.preflight.enable-backups\": \"Rewind kullanmaya başlamak için Ayarlar'da Backups'ı kurun\",\n  \"rewind.restore-complete\": \"Geri yükleme tamamlandı\",\n  \"rewind.restore-error-description\": \"Lütfen tekrar deneyin.\",\n  \"rewind.restore-failed\": \"Geri yükleme başarısız oldu\",\n  \"rewind.restore-running-description\": \"Geri yükleme tamamlanana kadar bu sayfayı kapatmayın veya yenilemeyin\",\n  \"rewind.restore-selected\": \"Seçileni geri yükle\",\n  \"rewind.restore-success-description\": \"Dosyalarınız geri yüklendi\",\n  \"rewind.restoring\": \"Geri yükleniyor\",\n  \"rewind.snapshots-count_one\": \"{{count}} yedekten beri\",\n  \"rewind.snapshots-count_other\": \"{{count}} yedekten beri\",\n  \"search\": \"Ara\",\n  \"settings\": \"Ayarlar\",\n  \"settings.app-store-preferences.title\": \"Uygulama Mağazası Tercihleri\",\n  \"settings.contact-support\": \"Yardım mı lazım? <linked>Destek ile iletişime geçin.</linked>\",\n  \"settings.file-sharing\": \"Dosya paylaşımı\",\n  \"settings.file-sharing.add-folder\": \"Ekle\",\n  \"settings.file-sharing.add-folder-title\": \"Paylaşmak için bir klasör seçin\",\n  \"settings.file-sharing.choice-entire-description\": \"Umbrel'inizdeki tüm dosyaları paylaşın\",\n  \"settings.file-sharing.choice-entire-title\": \"Her şey\",\n  \"settings.file-sharing.choice-heading\": \"Ne paylaşmak istersiniz?\",\n  \"settings.file-sharing.choice-specific-description\": \"Hangi klasörleri paylaşmak istediğinizi seçin\",\n  \"settings.file-sharing.choice-specific-title\": \"Belirli klasörler\",\n  \"settings.file-sharing.choice-subtitle\": \"Bilgisayarınızda veya telefonunuzda dosya ve klasörlerinize Dropbox tarzında, ağ klasörleri olarak erişin\",\n  \"settings.file-sharing.configure\": \"Yapılandır\",\n  \"settings.file-sharing.description\": \"Diğer cihazlarda dosyalarınıza Dropbox tarzında bir ağ klasörü (SMB) olarak erişin\",\n  \"settings.file-sharing.home-shared-note\": \"Tüm \\\"{{homeDirectoryName}}\\\" klasörünüz paylaşılıyor. Alt klasörleri ayrı ayrı paylaşmanıza gerek yok.\",\n  \"settings.file-sharing.share-entire-home-dir\": \"Home klasörünüzün tamamını paylaşın\",\n  \"settings.file-sharing.share-entire-home-dir-description\": \"Ağınızdaki diğer cihazlardan \\\"{{homeDirectoryName}}\\\" içindeki tüm dosya ve klasörlere erişin\",\n  \"settings.file-sharing.shared-folders\": \"Paylaşılan klasörler\",\n  \"show-details\": \"Ayrıntıları göster\",\n  \"shut-down\": \"Kapat\",\n  \"shut-down.complete\": \"Kapatma tamamlandı\",\n  \"shut-down.complete-text\": \"Cihazınızı şimdi güçten çekebilirsiniz.\",\n  \"shut-down.confirm.submit\": \"Kapat\",\n  \"shut-down.confirm.title\": \"Umbrel'inizi kapatmak istediğinizden emin misiniz?\",\n  \"shut-down.failed\": \"Kapatmayı başaramadık: {{message}}\",\n  \"shut-down.shutting-down\": \"Kapatılıyor\",\n  \"shut-down.shutting-down-message\": \"Kapatılırken bu sayfayı yenilemeyin veya Umbrel'inizi kapatmayın.\",\n  \"software-update.callout\": \"Güncelleme sırasında bu sayfayı yenilemeyin veya Umbrel'inizi kapatmayın.\",\n  \"software-update.check\": \"Güncellemeleri kontrol et\",\n  \"software-update.checking\": \"Güncellemeler kontrol ediliyor...\",\n  \"software-update.current-running\": \"Mevcut sürümünüz\",\n  \"software-update.failed\": \"Güncelleme başarısız\",\n  \"software-update.failed-to-check\": \"Güncellemeler kontrol edilemedi\",\n  \"software-update.failed.retry\": \"Tekrar dene\",\n  \"software-update.install-now\": \"Şimdi yükle\",\n  \"software-update.new-version\": \"Yeni {{name}} indirilebilir\",\n  \"software-update.on-latest\": \"En son umbrelOS sürümündesiniz\",\n  \"software-update.see-whats-new\": \"Bak, <linked>neler yeni</linked>\",\n  \"software-update.title\": \"Yazılım güncellemesi\",\n  \"software-update.updating-to\": \"{{name}} sürümüne güncelleniyor\",\n  \"software-update.view\": \"Görüntüle\",\n  \"something-left\": \"{{left}} kaldı\",\n  \"something-went-wrong\": \"⚠ Bir şeyler ters gitti\",\n  \"start\": \"Başlat\",\n  \"stop\": \"Durdur\",\n  \"storage\": \"Depolama\",\n  \"storage-manager\": \"Depolama Yöneticisi\",\n  \"storage-manager.add\": \"Ekle\",\n  \"storage-manager.add-to-raid.add-ssd\": \"SSD ekle\",\n  \"storage-manager.add-to-raid.available\": \"Kullanılabilir:\",\n  \"storage-manager.add-to-raid.description\": \"Yeni bir SSD algılandı ve eklemeye hazır.\",\n  \"storage-manager.add-to-raid.enable-failsafe\": \"FailSafe'i Etkinleştir\",\n  \"storage-manager.add-to-raid.failed-add\": \"SSD eklenemedi\",\n  \"storage-manager.add-to-raid.failed-enable-failsafe\": \"FailSafe etkinleştirilemedi\",\n  \"storage-manager.add-to-raid.failsafe-label\": \"FailSafe:\",\n  \"storage-manager.add-to-raid.info-capacity-added\": \"Yeni <highlight>{{size}}</highlight> SSD'niz kullanılabilir depolamaya eklenecek.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-available\": \"Yeni <highlight>{{size}}</highlight> SSD'niz kullanılabilir depolamaya <highlight>{{available}}</highlight> ekleyecek.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-both\": \"Yeni <highlight>{{size}}</highlight> SSD'niz kullanılabilir depolamaya <highlight>{{available}}</highlight> ve veri koruması için <highlight>{{protection}}</highlight> ekleyecek.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only\": \"Yeni <highlight>{{size}}</highlight> SSD'niz veri koruması için <highlight>{{protection}}</highlight> sağlayacak.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only-full\": \"Yeni <highlight>{{size}}</highlight> SSD tamamen veri koruması için kullanılacak.\",\n  \"storage-manager.add-to-raid.info-data-safe\": \"Tek bir SSD arızalansa bile verilerin güvende olacak.\",\n  \"storage-manager.add-to-raid.info-no-protection\": \"Bir SSD arızalanırsa verilerinizi kaybedebilirsiniz.\",\n  \"storage-manager.add-to-raid.info-total-wasted\": \"<wasted>{{size}}</wasted> farklı SSD boyutları nedeniyle toplam kullanılamaz.\",\n  \"storage-manager.add-to-raid.info-wasted\": \"<wasted>{{size}}</wasted> farklı SSD boyutları nedeniyle kullanılamaz olacak.\",\n  \"storage-manager.add-to-raid.recommended\": \"Önerilen\",\n  \"storage-manager.add-to-raid.recommended-inline\": \"(önerilen)\",\n  \"storage-manager.add-to-raid.restart-active-tasks\": \"Aktif görevlerin hepsi kesintiye uğrayacak\",\n  \"storage-manager.add-to-raid.restart-after\": \"Yeniden başlatmadan sonra FailSafe kurulumu otomatik tamamlanacak ve normal kullanıma devam edebileceksin.\",\n  \"storage-manager.add-to-raid.restart-during\": \"Yeniden başlatma sırasında:\",\n  \"storage-manager.add-to-raid.restart-intro\": \"Bu işlem sırasında umbrelOS'u normal şekilde kullanmaya devam edebilirsin. Ancak %50 ilerlemede Umbrel otomatik olarak yeniden başlatılacak.\",\n  \"storage-manager.add-to-raid.restart-required\": \"Yeniden başlatma gerekli\",\n  \"storage-manager.add-to-raid.restart-ui-inaccessible\": \"umbrelOS geçici olarak erişilemez olacak\",\n  \"storage-manager.add-to-raid.ssd-in-slot\": \"<highlight>{{size}}</highlight> boyutunda SSD <highlight>Slot {{slot}}</highlight>\",\n  \"storage-manager.add-to-raid.title\": \"Depolamaya SSD Ekle\",\n  \"storage-manager.add-to-raid.too-small\": \"SSD çok küçük\",\n  \"storage-manager.add-to-raid.too-small-description\": \"Bu SSD ({{deviceSize}}), şu anda takılı en küçük SSD'den ({{minSize}}) daha küçük. FailSafe, tüm SSD'lerin en küçük kullanılan SSD ile aynı boyutta ya da daha büyük olmasını gerektirir.\",\n  \"storage-manager.add-to-raid.understand-continue\": \"Anladım, devam et\",\n  \"storage-manager.add-to-raid.warning-failsafe-now-only\": \"Birden fazla SSD olması, FailSafe'in yalnızca şimdi etkinleştirilebileceği anlamına gelir. Daha sonra etkinleştiremezsin.\",\n  \"storage-manager.add-to-raid.wasted-label\": \"Kullanılamaz:\",\n  \"storage-manager.available-storage\": \"Kullanılabilir depolama\",\n  \"storage-manager.description\": \"SSD'lerinizin depolama alanını, sağlık durumunu ve ayarlarını görüntüleyin\",\n  \"storage-manager.empty\": \"Boş\",\n  \"storage-manager.failsafe-transition-failed\": \"FailSafe etkinleştirilemedi\",\n  \"storage-manager.for-failsafe\": \"FailSafe için\",\n  \"storage-manager.health.checksum-errors\": \"Checksum hataları: {{count}}\",\n  \"storage-manager.health.critical\": \"Kritik\",\n  \"storage-manager.health.critical-threshold\": \"Kritik eşik\",\n  \"storage-manager.health.current-temperature\": \"Mevcut sıcaklık\",\n  \"storage-manager.health.estimated-life\": \"Tahmini kalan ömür\",\n  \"storage-manager.health.general\": \"Genel\",\n  \"storage-manager.health.health-status\": \"Sağlık durumu\",\n  \"storage-manager.health.low\": \"Düşük\",\n  \"storage-manager.health.model-and-capacity\": \"Model ve boyut\",\n  \"storage-manager.health.overheating\": \"Aşırı ısınma\",\n  \"storage-manager.health.raid-failed-advice\": \"Bu SSD'de bir sorun var. Umbrel'inizi kapatıp SSD bağlantısını kontrol edin. Sorun devam ederse SSD'nin değiştirilmesi gerekebilir.\",\n  \"storage-manager.health.read-errors\": \"Okuma hataları: {{count}}\",\n  \"storage-manager.health.serial-number\": \"Seri numarası\",\n  \"storage-manager.health.status-healthy\": \"Sağlıklı\",\n  \"storage-manager.health.status-unhealthy\": \"Sağlıksız\",\n  \"storage-manager.health.status-unknown\": \"Bilinmiyor\",\n  \"storage-manager.health.temperature\": \"Sıcaklık\",\n  \"storage-manager.health.title\": \"SSD Sağlığı\",\n  \"storage-manager.health.warning-life-advice\": \"Bu SSD'yi yakında değiştirmeyi düşünün.\",\n  \"storage-manager.health.warning-life-message\": \"Kalan ömür yalnızca %{{percent}}\",\n  \"storage-manager.health.warning-temp-advice\": \"Umbrel Pro'nuzun iyi hava akışına sahip olduğundan ve SSD'nin düzgün oturduğundan emin olun.\",\n  \"storage-manager.health.warning-temp-critical\": \"Sıcaklık kritik ({{temperature}})\",\n  \"storage-manager.health.warning-temp-overheating\": \"Sürücü aşırı ısınıyor ({{temperature}})\",\n  \"storage-manager.health.warning-threshold\": \"Uyarı eşiği\",\n  \"storage-manager.health.warning-unhealthy-advice\": \"Bu SSD yakında arızalanabilir. Değiştirmeyi düşünün.\",\n  \"storage-manager.health.warning-unhealthy-message\": \"Bu SSD'de sorun olabilir\",\n  \"storage-manager.health.warnings\": \"Uyarılar\",\n  \"storage-manager.health.wear\": \"Aşınma\",\n  \"storage-manager.health.write-errors\": \"Yazma hataları: {{count}}\",\n  \"storage-manager.install-ssd.description\": \"Depolamanızı genişletmek için daha fazla SSD ekleyin\",\n  \"storage-manager.install-ssd.step-insert\": \"Yeni SSD'leri boş yuvalara tak\",\n  \"storage-manager.install-ssd.step-power-on\": \"{{deviceName}}'ni aç\",\n  \"storage-manager.install-ssd.step-remove-bottom-cover\": \"Manyetik alt kapağı çıkar\",\n  \"storage-manager.install-ssd.step-replace-bottom-cover\": \"Alt kapağı yerine tak\",\n  \"storage-manager.install-ssd.step-return\": \"Buraya dön ve SSD'leri depolamana ekle\",\n  \"storage-manager.install-ssd.step-shut-down\": \"{{deviceName}}'ni kapat\",\n  \"storage-manager.install-ssd.title\": \"SSD Ekleme\",\n  \"storage-manager.install-tips.image-alt\": \"SSD kurulum talimatı\",\n  \"storage-manager.install-tips.instructions\": \"Kurmak için başparmak vidasını çıkar ve SSD'yi eğik bir açıyla slota kaydır. SSD'yi vida ayağına oturana kadar bastır, sonra başparmak vidasıyla sabitle.\",\n  \"storage-manager.install-tips.toggle\": \"SSD'yi nasıl takacağınızı mı unuttunuz?\",\n  \"storage-manager.manage\": \"Yönet\",\n  \"storage-manager.missing-ssd-warning\": \"Bir SSD eksik gibi görünüyor. Umbrel'inizi kapatıp tüm SSD'lerin bağlı olduğunu kontrol edin. Sorun devam ederse SSD'nin değiştirilmesi gerekebilir.\",\n  \"storage-manager.mode\": \"Mod\",\n  \"storage-manager.mode.failsafe\": \"Arıza Koruması\",\n  \"storage-manager.mode.failsafe.description\": \"Bir SSD arızalanırsa verilerinizi güvende tutar. SSD'leriniz farklı boyutlardaysa, daha büyük olanlardaki ekstra alan kullanılmaz.\",\n  \"storage-manager.mode.failsafe.info-description\": \"FailSafe, verilerinizin kopyalarını SSD'ler arasında tutarak korur. Tek bir SSD arızalanırsa verileriniz güvende kalır ve yerine yenisini eklediğinizde geri yüklenebilir.\",\n  \"storage-manager.mode.failsafe.info-title\": \"Arıza Koruması Hakkında\",\n  \"storage-manager.mode.full-storage\": \"Tam Depolama\",\n  \"storage-manager.mode.full-storage.description\": \"Tüm SSD alanınızı birlikte kullanın. Bir SSD arızalanırsa verilerinizi kaybedebilirsiniz.\",\n  \"storage-manager.mode.full-storage.info-description\": \"Full Storage tüm SSD'lerinizi tek bir geniş alanda birleştirir ve maksimum depolama sağlar. Ancak herhangi bir SSD arızalanırsa tüm verileriniz kaybolur.\",\n  \"storage-manager.mode.full-storage.info-title\": \"Tam Depolama Hakkında\",\n  \"storage-manager.mode.switch-from-failsafe-unavailable\": \"FailSafe'den Full Storage moduna geçmek için verilerini yedeklemen, cihazı fabrika ayarlarına sıfırlaman ve yedekten geri yüklemen gerekir.\",\n  \"storage-manager.mode.switch-to-failsafe-unavailable\": \"Birden fazla SSD ile Full Storage modunda verilerin tüm sürücülere dağıtılır. FailSafe'e geçmek için verilerini yedeklemen, fabrika ayarlarına sıfırlaman ve geri yüklemen gerekir.\",\n  \"storage-manager.mode.why-cant-switch\": \"Neden geçiş yapamıyorum?\",\n  \"storage-manager.operation-in-progress.shutdown-description\": \"Kapatmak güvenli. İşlem duraklatılacak ve yeniden başlatma sonrası devam edecek; ancak başka değişiklikler yapabilmeniz için işlemin tamamlanması gerekiyor.\",\n  \"storage-manager.operation-in-progress.shutdown-title\": \"Depolamanız güncelleniyor\",\n  \"storage-manager.operation-in-progress.wait-description\": \"Lütfen daha fazla değişiklik yapmadan önce mevcut işlemin bitmesini bekleyin.\",\n  \"storage-manager.operation-in-progress.wait-title\": \"Depolamanız güncelleniyor\",\n  \"storage-manager.operation.adding-ssd\": \"SSD ekleniyor...\",\n  \"storage-manager.operation.enabling-failsafe\": \"Arıza Koruması etkinleştiriliyor...\",\n  \"storage-manager.operation.expanding\": \"Depolama genişletiliyor...\",\n  \"storage-manager.operation.rebuilding\": \"Veriler yeniden oluşturuluyor...\",\n  \"storage-manager.operation.replacing\": \"Sürücü değiştiriliyor...\",\n  \"storage-manager.operation.restarting\": \"Yeniden başlatılıyor...\",\n  \"storage-manager.operation.starting\": \"Başlatılıyor...\",\n  \"storage-manager.operation.syncing-restarts\": \"Veriler senkronize ediliyor • %50'de yeniden başlatılacak\",\n  \"storage-manager.raid-status.degraded\": \"Kısmi arızalı\",\n  \"storage-manager.raid-status.failed\": \"Arızalı\",\n  \"storage-manager.raid-status.offline\": \"Çevrimdışı\",\n  \"storage-manager.raid-status.online\": \"Çevrimiçi\",\n  \"storage-manager.raid-status.removed\": \"Kaldırıldı\",\n  \"storage-manager.raid-status.unavailable\": \"Kullanılamıyor\",\n  \"storage-manager.replace\": \"Değiştir\",\n  \"storage-manager.replace-failed.degraded\": \"FailSafe koruması azaldı\",\n  \"storage-manager.replace-failed.degraded-description\": \"FailSafe depolamanızdan bir SSD eksik. Tam korumayı geri yüklemek için onu değiştirin.\",\n  \"storage-manager.replace-failed.description\": \"FailSafe korumanızı geri yüklemek için bu SSD'yi kullanın.\",\n  \"storage-manager.replace-failed.error\": \"Değiştirme başlatılamadı\",\n  \"storage-manager.replace-failed.replace-now\": \"Şimdi değiştir\",\n  \"storage-manager.replace-failed.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD, Yuva {{slot}}\",\n  \"storage-manager.replace-failed.step-protected\": \"Tamamlandığında, verileriniz tekrar tamamen korunmuş olacak\",\n  \"storage-manager.replace-failed.step-rebuild\": \"Veriler yeni SSD'ye yeniden oluşturulacak\",\n  \"storage-manager.replace-failed.step-time\": \"Bu, sahip olduğunuz veri miktarına bağlı olarak biraz zaman alabilir\",\n  \"storage-manager.replace-failed.title\": \"SSD'yi değiştir\",\n  \"storage-manager.replace-failed.too-small\": \"SSD çok küçük\",\n  \"storage-manager.replace-failed.too-small-description\": \"Bu SSD ({{deviceSize}}), FailSafe depolamanız için gerekli minimum ({{minSize}}) boyutundan daha küçük.\",\n  \"storage-manager.replace-failed.what-happens\": \"Sonra ne olacak:\",\n  \"storage-manager.ssd-failing\": \"Arızalanıyor\",\n  \"storage-manager.swap\": \"Değiştir\",\n  \"storage-manager.swap.data-erased-description\": \"Full Storage modunda veri koruması yok. Fabrika ayarlarına sıfırlama sırasında {{deviceName}} üzerindeki tüm veriler silinecek. Önce her şeyi yedeklediğinden emin ol.\",\n  \"storage-manager.swap.data-protected\": \"Verilerin korunuyor\",\n  \"storage-manager.swap.data-protected-description\": \"FailSafe etkinse, herhangi bir tek SSD'yi verilerini kaybetmeden değiştirebilirsin. Yedek gerekmez.\",\n  \"storage-manager.swap.data-will-be-erased\": \"Veriler silinecek\",\n  \"storage-manager.swap.description-failsafe\": \"FailSafe depolamada bir sürücüyü değiştir.\",\n  \"storage-manager.swap.description-full-storage\": \"Full Storage kurulumunda bir sürücüyü değiştir.\",\n  \"storage-manager.swap.description-no-free-slot\": \"Tüm yuvaların dolu olduğu Full Storage modunda, bir SSD'yi değiştirmek tam bir yedekleme ve geri yükleme gerektirir.\",\n  \"storage-manager.swap.description-replace\": \"Verilerini yeni bir SSD'ye taşı, sonra eskisini çıkar.\",\n  \"storage-manager.swap.failed-to-start\": \"Değiştirme işlemi başlatılamadı\",\n  \"storage-manager.swap.no-data-loss\": \"Veri kaybı yok\",\n  \"storage-manager.swap.no-data-loss-description\": \"Verilerin yeni SSD'ye kopyalanacak. İşlem tamamlandığında eskiyi güvenle çıkarabilirsin.\",\n  \"storage-manager.swap.safe-swap-available\": \"Güvenli değiştirme mevcut\",\n  \"storage-manager.swap.safe-swap-description\": \"Boş bir yuva olduğundan yeni SSD'yi önce ekleyip verilerini taşıyabilir, sonra eskisini çıkarabilirsin. Yedek gerekmez.\",\n  \"storage-manager.swap.select-new-ssd\": \"Kullanılacak yeni SSD'yi seç:\",\n  \"storage-manager.swap.ssd-in-slot\": \"{{size}} SSD Slot {{slot}}'te\",\n  \"storage-manager.swap.step-backup\": \"Verilerini yedekle\",\n  \"storage-manager.swap.step-backup-description\": \"Ayarlar → Backups'a git ve tüm verilerinin yedeğini oluştur.\",\n  \"storage-manager.swap.step-data-copied\": \"Veriler eski SSD'den yenisine kopyalanacak\",\n  \"storage-manager.swap.step-factory-reset\": \"Fabrika ayarlarına sıfırla\",\n  \"storage-manager.swap.step-factory-reset-description\": \"Ayarlar → Gelişmiş → Fabrika Ayarlarına Sıfırla'ya git ve {{deviceName}}'ni sil.\",\n  \"storage-manager.swap.step-insert-new-ssd\": \"Yeni SSD'yi boş bir yuvaya tak\",\n  \"storage-manager.swap.step-may-take-while\": \"Bu, sahip olduğunuz veri miktarına bağlı olarak biraz zaman alabilir\",\n  \"storage-manager.swap.step-power-on\": \"{{deviceName}}'ni aç\",\n  \"storage-manager.swap.step-remove-bottom-cover\": \"Manyetik alt kapağı çıkar\",\n  \"storage-manager.swap.step-remove-old\": \"İşlem tamamlandığında, kapat ve {{ssd}}'yi çıkar\",\n  \"storage-manager.swap.step-replace-bottom-cover\": \"Alt kapağı geri tak\",\n  \"storage-manager.swap.step-restore\": \"Verilerini geri yükle\",\n  \"storage-manager.swap.step-restore-description\": \"Ayarlar → Backups'a git ve yedeğinden geri yükle.\",\n  \"storage-manager.swap.step-return-to-storage-manager\": \"Buraya dön, Depolama Yöneticisi'nde değişimi onayla ve yeni SSD'yi depolamana ekle.\",\n  \"storage-manager.swap.step-return-to-swap\": \"Buraya dön, Depolama Yöneticisi'ne git ve değiştirmeyi başlatmak için tekrar \\\"Değiştir\\\"e tıkla.\",\n  \"storage-manager.swap.step-setup-new-storage\": \"Yeni depolamanı kur\",\n  \"storage-manager.swap.step-setup-new-storage-description\": \"{{deviceName}}'ni aç ve yeni SSD ile kurulum işlemini tamamla.\",\n  \"storage-manager.swap.step-shut-down\": \"{{deviceName}}'ni kapat\",\n  \"storage-manager.swap.step-shut-down-and-swap\": \"Kapat ve {{ssd}}'yi değiştir\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-other\": \"Kapat, cihazını aç, SSD'yi değiştir ve tekrar monte et.\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-pro\": \"Kapat, alt kapağı çıkar, SSD'yi değiştir ve kapağı kapat.\",\n  \"storage-manager.swap.step-swap-ssd\": \"{{ssd}}'yi aynı boyutta yeni bir SSD ile değiştir\",\n  \"storage-manager.swap.too-small\": \"Çok küçük (gereken: {{size}})\",\n  \"storage-manager.swap.what-happens-next\": \"Sonrasında ne olacak:\",\n  \"storage-manager.total-capacity-added\": \"Toplam eklenen kapasite\",\n  \"storage-manager.umbrel-pro\": \"Umbrel Pro\",\n  \"storage-manager.used\": \"Kullanılan\",\n  \"storage-manager.wasted\": \"Kullanılamaz\",\n  \"storage-manager.wasted-size\": \"{{size}} Kullanılamaz\",\n  \"storage.full\": \"Depolama dolu\",\n  \"storage.low\": \"Düşük depolama alanı\",\n  \"temperature\": \"Sıcaklık\",\n  \"temperature.dangerously-hot\": \"Çok sıcak\",\n  \"temperature.nice\": \"Güzel\",\n  \"temperature.normal\": \"Normal\",\n  \"temperature.too-hot-suggestion\": \"Cihazınızın ortamını değiştirmeyi düşünün.\",\n  \"temperature.warm\": \"Ilık\",\n  \"terminal\": \"Terminal\",\n  \"terminal-description\": \"umbrelOS'ta veya bir uygulama içinde özel komutlar çalıştırın\",\n  \"terminal.app\": \"Uygulama\",\n  \"terminal.app-description\": \"Belirli bir uygulama içinde özel komutlar çalıştırın\",\n  \"terminal.umbrelos-description\": \"umbrelOS'ta özel komutlar çalıştırın\",\n  \"tor-description\": \"Tor tarayıcı kullanarak Umbrel'inize her yerden erişin\",\n  \"tor-enabled-description\": \"Aşağıdaki URL üzerinden bir Tor tarayıcısı kullanarak Umbrel'inize her yerden erişin:\",\n  \"tor-error\": \"Tor ayarını güncelleyemedik: {{message}}\",\n  \"tor.disable.description\": \"Bu birkaç dakika sürebilir\",\n  \"tor.disable.progress\": \"Uzaktan Tor erişimi devre dışı bırakılıyor\",\n  \"tor.enable.description\": \"Bu birkaç dakika sürebilir\",\n  \"tor.enable.mobile.switch-label\": \"Uzaktan Tor erişimini etkinleştir\",\n  \"tor.hidden-service\": \"Tor gizli servis URL'si\",\n  \"troubleshoot\": \"Sorun giderme\",\n  \"troubleshoot-description\": \"umbrelOS veya bir uygulama sorunlarını giderin\",\n  \"troubleshoot-no-logs-yet\": \"Henüz günlük yok\",\n  \"troubleshoot-pick-title\": \"Sorun giderme\",\n  \"troubleshoot.app\": \"Uygulama\",\n  \"troubleshoot.app-description\": \"Umbrel'inize yüklü bir uygulamanın günlüklerini görüntüleyin\",\n  \"troubleshoot.app-download\": \"{{app}} günlüklerini indir\",\n  \"troubleshoot.share-with-umbrel-support\": \"Umbrel Destek ile paylaş\",\n  \"troubleshoot.system-download\": \"{{label}} indir\",\n  \"troubleshoot.umbrelos-description\": \"umbrelOS günlüklerini görüntüleyin\",\n  \"troubleshoot.umbrelos-logs\": \"umbrelOS günlükleri\",\n  \"trpc.backend-unavailable\": \"Hata: Sistem API bağlantısı başarısız oldu\",\n  \"trpc.checking-backend\": \"Yükleniyor...\",\n  \"try-again\": \"Tekrar deneyin\",\n  \"umbrel\": \"Umbrel\",\n  \"umbrelos\": \"umbrelOS\",\n  \"unknown\": \"Bilinmeyen\",\n  \"unknown-app\": \"Bilinmeyen uygulama\",\n  \"unknown-error\": \"Bilinmeyen hata\",\n  \"uptime\": \"Çalışma süresi\",\n  \"url\": \"URL\",\n  \"wallpaper\": \"Duvar kağıdı\",\n  \"wallpaper-description\": \"Umbrel duvar kağıdınız ve temanız\",\n  \"whats-new.continue\": \"Devam\",\n  \"whats-new.feature-1.description\": \"Tüm Umbrel'inizin otomatik, şifreli yedeklerini harici bir USB sürücüsüne, bir NAS'a veya başka bir Umbrel'e ayarlayın.\",\n  \"whats-new.feature-2.description\": \"Önceki yedeklerden belirli dosya ve klasörleri kurtarmak için zamanda geri gidin.\",\n  \"whats-new.feature-3.description\": \"Veya tüm uygulamalarınız, dosyalarınız ve verileriniz dahil olmak üzere tüm Umbrel'inizi geri yükleyin.\",\n  \"whats-new.feature-4.description\": \"Bir NAS veya başka bir Umbrel bağlayın ve depolamasına Files uygulamasından erişin.\",\n  \"whats-new.feature-4.title\": \"Ağ cihazları\",\n  \"whats-new.feature-5.description\": \"Harici USB sürücüler bağlayın (Umbrel Home'da veya herhangi bir Intel veya AMD cihazında) ve Files uygulamasından erişin.\",\n  \"whats-new.feature-5.helper-text\": \"Raspberry Pi cihazlarında olası güç sorunları nedeniyle desteklenmez.\",\n  \"whats-new.feature-5.title\": \"Harici Depolama\",\n  \"whats-new.next\": \"İleri\",\n  \"whats-new.title\": \"{{version}} sürümündeki yenilikler\",\n  \"widget.progress.in-progress\": \"Devam ediyor\",\n  \"widgets.edit.select-up-to-3-widgets\": \"En fazla 3 widget seçin\",\n  \"widgets.install-an-app-before-using-widgets\": \"Ana ekranınızı widget'larla özelleştirmeye başlamak için bir uygulama yükleyin.\",\n  \"wifi\": \"Wi-Fi\",\n  \"wifi-connect-insecure-message\": \"Açık ağlar güvensiz olabilir\",\n  \"wifi-connection-failed\": \"Bağlanılamadı\",\n  \"wifi-dangerous-change-confirmation-description\": \"Wi-Fi ağını değiştirmek, Umbrel'iniz ile olan bağlantınızı kesebilir. Yeniden bağlanmak için, Umbrel'inizin ve erişim sağladığınız cihazın aynı ağda olduğundan emin olun.\",\n  \"wifi-dangerous-change-confirmation-title\": \"Wi-Fi ağını değiştirmek istediğinizden emin misiniz?\",\n  \"wifi-dangerous-disable-confirmation-description\": \"Wi-Fi'yi devre dışı bırakmak, Umbrel'iniz ile olan bağlantınızı kesebilir. Yeniden bağlanmak için, Umbrel'inize bir Ethernet kablosu takın ve Umbrel'iniz ile erişim sağladığınız cihazın aynı ağda olduğundan emin olun.\",\n  \"wifi-dangerous-disable-confirmation-title\": \"Wi-Fi'yi devre dışı bırakmak istediğinizden emin misiniz?\",\n  \"wifi-description\": \"Cihazınızı bir Wi-Fi ağına bağlayın\",\n  \"wifi-description-long\": \"Ethernet kablosu çıkarılsa bile cihazınız seçtiğiniz Wi-Fi'ye bağlı kalır ve başlatıldığında otomatik olarak Wi-Fi'ye yeniden bağlanır.\",\n  \"wifi-no-networks-message\": \"Wi-Fi ağı bulunamadı\",\n  \"wifi-searching\": \"Wi-Fi ağları aranıyor...\",\n  \"wifi-unsupported-device-description\": \"Bu cihaz Wi-Fi'yi desteklemiyor. Bu, eksik veya uyumsuz bir kablosuz adaptörden kaynaklanabilir.\",\n  \"wifi-view-networks\": \"Ağları görüntüle\"\n}"
  },
  {
    "path": "packages/ui/public/locales/uk.json",
    "content": "{\n  \"2fa\": \"2FA\",\n  \"2fa-description\": \"Другий рівень безпеки для вашого входу в Umbrel і програм\",\n  \"2fa.disable.title\": \"Вимкнути двофакторну автентифікацію\",\n  \"2fa.enable.or-paste\": \"Або вставте наступний код у вашу програму-аутентифікатор\",\n  \"2fa.enable.scan-this\": \"Скануйте цей QR-код за допомогою програми-аутентифікатора, такого як Google Authenticator або Authy\",\n  \"2fa.enable.title\": \"Увімкнути двофакторну автентифікацію\",\n  \"2fa.enter-code\": \"Введіть код, показаний у вашій програмі-аутентифікаторі\",\n  \"account\": \"Акаунт\",\n  \"account-description\": \"Ваше ім'я та пароль\",\n  \"advanced-settings\": \"Розширені налаштування\",\n  \"advanced-settings-description\": \"Термінал, програма бета-тестування umbrelOS, Cloudflare DNS та інше\",\n  \"app-not-found\": \"Програму не знайдено: {{app}}\",\n  \"app-only-over-tor\": \"{{app}} можна використовувати лише через Tor. Будь ласка, відкрийте свій Umbrel у браузері Tor за URL-адресою віддаленого доступу (Налаштування > Розширені налаштування > Віддалений Tor-доступ), щоб відкрити цей додаток.\",\n  \"app-page.section.about\": \"Про\",\n  \"app-page.section.credentials.title\": \"Стандартні облікові дані\",\n  \"app-page.section.dependencies.n-alternatives\": \"Переглянути {{count}} альтернатив\",\n  \"app-page.section.info.compatibility\": \"Сумісність\",\n  \"app-page.section.info.compatibility-compatible\": \"Сумісний\",\n  \"app-page.section.info.compatibility-not-compatible\": \"Не сумісний\",\n  \"app-page.section.info.developer\": \"Розробник\",\n  \"app-page.section.info.source-code\": \"Вихідний код\",\n  \"app-page.section.info.source-code.public\": \"Публічний\",\n  \"app-page.section.info.submitted-by\": \"Подано\",\n  \"app-page.section.info.support\": \"Отримати підтримку\",\n  \"app-page.section.info.title\": \"Інформація\",\n  \"app-page.section.info.version\": \"Версія\",\n  \"app-page.section.recommendations.title\": \"Вам також може сподобатися\",\n  \"app-page.section.release-notes.title\": \"Що нового\",\n  \"app-page.section.release-notes.version\": \"Версія {{version}}\",\n  \"app-page.section.requires\": \"Вимагає\",\n  \"app-picker.search\": \"Пошук...\",\n  \"app-picker.select-app\": \"Виберіть програму...\",\n  \"app-settings.connected-to\": \"{{appName}} підключено до цих додатків\",\n  \"app-settings.save-changes\": \"Зберегти зміни\",\n  \"app-settings.title\": \"Налаштування\",\n  \"app-store.browse-category-apps\": \"Перегляд програм у категорії {{category}}\",\n  \"app-store.category.ai\": \"Штучний інтелект\",\n  \"app-store.category.all\": \"Всі програми\",\n  \"app-store.category.automation\": \"Дім і автоматизація\",\n  \"app-store.category.bitcoin\": \"Біткойн\",\n  \"app-store.category.crypto\": \"Крипто\",\n  \"app-store.category.developer\": \"Інструменти розробника\",\n  \"app-store.category.discover\": \"Відкривати\",\n  \"app-store.category.files\": \"Файли та продуктивність\",\n  \"app-store.category.finance\": \"Фінанси\",\n  \"app-store.category.media\": \"Медіа\",\n  \"app-store.category.networking\": \"Мережі\",\n  \"app-store.category.social\": \"Соціальні мережі\",\n  \"app-store.description\": \"Налаштування оновлення ваших програм\",\n  \"app-store.discover.temporarily-unavailable-description\": \"Перегляньте категорії вище або скористайтеся пошуком, щоб знайти застосунки\",\n  \"app-store.discover.temporarily-unavailable-title\": \"Рекомендований контент тимчасово недоступний\",\n  \"app-store.menu.community-app-stores\": \"Спільнота магазинів програм\",\n  \"app-store.search-apps\": \"Пошук програм\",\n  \"app-store.search.no-results\": \"Немає результатів\",\n  \"app-store.search.results-for\": \"Результати для\",\n  \"app-store.title\": \"App Store\",\n  \"app-store.updates\": \"Оновлення\",\n  \"app-updates.less\": \"менше\",\n  \"app-updates.more\": \"більше\",\n  \"app-updates.no-updates\": \"Всі програми оновлені!\",\n  \"app-updates.update\": \"Оновити\",\n  \"app-updates.update-all\": \"Оновити все\",\n  \"app-updates.updates-available-count_one\": \"Доступно {{count}} оновлення\",\n  \"app-updates.updates-available-count_other\": \"Доступно {{count}} оновлень\",\n  \"app-updates.updating\": \"Оновлення...\",\n  \"app.install\": \"Встановити\",\n  \"app.installed\": \"Встановлено\",\n  \"app.installing\": \"Встановлення\",\n  \"app.offline\": \"Не працює\",\n  \"app.open\": \"Відкрити\",\n  \"app.optimized-for-umbrel-home\": \"Оптимізовано для Umbrel Home\",\n  \"app.os-update-required.confirm\": \"Перевірити оновлення umbrelOS\",\n  \"app.os-update-required.description\": \"{{appName}} потребує umbrelOS версії {{version}} або новішої\",\n  \"app.os-update-required.title\": \"Оновити umbrelOS\",\n  \"app.restarting\": \"Перезапуск\",\n  \"app.starting\": \"Запуск\",\n  \"app.stopping\": \"Зупинка\",\n  \"app.uninstall.confirm.description\": \"Всі дані, пов'язані з {{app}}, будуть безповоротно видалені. Ця дія не може бути скасована.\",\n  \"app.uninstall.confirm.submit\": \"Видалити\",\n  \"app.uninstall.confirm.title\": \"Видалити {{app}}?\",\n  \"app.uninstall.deps.used-by.description_one\": \"Спочатку видаліть {{firstAppToUninstall}}, щоб видалити {{app}}.\",\n  \"app.uninstall.deps.used-by.description_other\": \"Спочатку видаліть ці програми, щоб видалити {{app}}.\",\n  \"app.uninstall.deps.used-by.title\": \"{{app}} використовується\",\n  \"app.uninstalling\": \"Видалення\",\n  \"app.updating\": \"Оновлення\",\n  \"app.view\": \"Переглянути\",\n  \"app_one\": \"програма\",\n  \"app_other\": \"програми\",\n  \"apps.uninstall.failed-to-get-required-apps\": \"Не вдалося отримати необхідні програми\",\n  \"apps.uninstalled-all.success\": \"Всі програми видалено\",\n  \"auth.checking-backend-for-user\": \"Завантаження...\",\n  \"auth.failed-checking-if-user-logged-in\": \"Помилка: перевірка входу не вдалася\",\n  \"auth.failed-to-check-if-user-exists\": \"Помилка: перевірка існування не вдалася\",\n  \"back\": \"Назад\",\n  \"backups\": \"Backups\",\n  \"backups-configure\": \"Налаштувати\",\n  \"backups-configure.add-backup-location\": \"Додати місце для резервних копій\",\n  \"backups-configure.available\": \"Доступно\",\n  \"backups-configure.awaiting-next-backup\": \"Очікування наступної автоматичної резервної копії\",\n  \"backups-configure.back-up-now\": \"Створити резервну копію зараз\",\n  \"backups-configure.backing-up-now\": \"Створюється резервна копія...\",\n  \"backups-configure.connected\": \"Підключено\",\n  \"backups-configure.connection\": \"Підключення\",\n  \"backups-configure.in-progress\": \"Виконується\",\n  \"backups-configure.last-backup\": \"Остання резервна копія\",\n  \"backups-configure.locations\": \"Місця збереження\",\n  \"backups-configure.no-backup-locations\": \"Додайте місце для резервних копій, щоб почати зберігати дані\",\n  \"backups-configure.not-connected\": \"Не підключено\",\n  \"backups-configure.path\": \"Шлях\",\n  \"backups-configure.remove-backup-location\": \"Видалити місце для резервних копій\",\n  \"backups-configure.remove-backup-location-confirmation\": \"Ви впевнені?\",\n  \"backups-configure.remove-backup-location-confirmation-description\": \"Це видалить «{{device}}» зі списку місць для резервного копіювання. Існуючі резервні копії на цьому пристрої не будуть видалені, але автоматичне резервне копіювання припиниться.\",\n  \"backups-configure.status\": \"Статус\",\n  \"backups-configure.total-backups\": \"Всього Backups\",\n  \"backups-configure.used\": \"Використано\",\n  \"backups-configure.view\": \"Переглянути\",\n  \"backups-description\": \"Зберігайте файли, програми та дані на іншому Umbrel, NAS або зовнішньому диску\",\n  \"backups-error.backup-not-found\": \"Не вдалося знайти резервну копію.\",\n  \"backups-error.generic\": \"Щось пішло не так: {{details}}\",\n  \"backups-error.in-progress\": \"Вже виконується резервне копіювання. Будь ласка, зачекайте, поки воно завершиться.\",\n  \"backups-error.invalid-exclusion-path\": \"Виключати з резервних копій можна лише файли та папки в домашньому каталозі.\",\n  \"backups-error.invalid-password\": \"Невірний пароль шифрування.\",\n  \"backups-error.invalid-path\": \"Обране місце не підходить для резервних копій.\",\n  \"backups-error.mount-failed\": \"Не вдалося отримати доступ до знімка резервної копії.\",\n  \"backups-error.mount-timeout\": \"Не вдалося отримати доступ до знімка резервної копії. Спробуйте ще раз або перевірте, чи пристрій правильно підключено.\",\n  \"backups-error.not-enough-space\": \"На пристрої для резервного копіювання недостатньо місця.\",\n  \"backups-error.not-found\": \"Не вдалося знайти резервну копію або місце її збереження.\",\n  \"backups-error.repository-exists\": \"У цій папці вже існує місце для резервного копіювання.\",\n  \"backups-error.repository-not-found\": \"Не вдалося знайти місце збереження резервної копії.\",\n  \"backups-exclusions.add\": \"Додати\",\n  \"backups-exclusions.app-paths-cannot-be-modified\": \"Ці файли/папки задає розробник програми і їх не можна змінити:\",\n  \"backups-exclusions.app-paths-explanation\": \"Цей додаток виключає наступні дані з резервного копіювання. Ці шляхи зазвичай містять неважливі елементи (наприклад, кеші або логи, які можна відновити) або дані, відновлення яких може викликати проблеми (наприклад, застарілі стани додатків, що можуть призвести до конфліктів або невідповідностей).\",\n  \"backups-exclusions.auto-excluded\": \"Автовиключено\",\n  \"backups-exclusions.exclude-entire-app\": \"Виключити всю програму\",\n  \"backups-exclusions.excluded-apps\": \"Виключені програми\",\n  \"backups-exclusions.files-and-folders\": \"Виключені файли та папки\",\n  \"backups-exclusions.no-excluded-apps\": \"Виключених програм немає\",\n  \"backups-exclusions.no-excluded-files-or-folders\": \"Виключених файлів або папок немає\",\n  \"backups-exclusions.select-item-to-exclude\": \"Виберіть елемент для виключення\",\n  \"backups-exclusions.stop-excluding\": \"Прибрати з виключень\",\n  \"backups-floating-island.backing-up\": \"Виконується резервне копіювання...\",\n  \"backups-floating-island.backing-up-to\": \"Створюється резервна копія вашого Umbrel...\",\n  \"backups-restore\": \"Restore\",\n  \"backups-restore-full\": \"Повне відновлення\",\n  \"backups-restore-full-description\": \"Відновити весь Umbrel із резервної копії\",\n  \"backups-restore-header\": \"Відновити Umbrel\",\n  \"backups-restore-pro.after-restore\": \"Після відновлення ваш тимчасовий обліковий запис буде замінено на обліковий запис і дані з резервної копії.\",\n  \"backups-restore-pro.step1\": \"Завершіть початкове налаштування, натиснувши «Почати» нижче. Це буде ваш тимчасовий обліковий запис, поки ви не відновите свій обліковий запис із резервної копії.\",\n  \"backups-restore-pro.step2\": \"Після завершення налаштування перейдіть у <0>Налаштування → Backups → Restore</0>\",\n  \"backups-restore-pro.step3\": \"Дотримуйтесь підказок у Restore Wizard.\",\n  \"backups-restore-pro.subtitle\": \"Відновлення з резервної копії на Umbrel Pro потребує кількох додаткових кроків\",\n  \"backups-restore.backup-date\": \"Дата резервної копії\",\n  \"backups-restore.backup-location\": \"Розташування резервної копії\",\n  \"backups-restore.browse-cloud-subtitle\": \"Відновити з Umbrel Private Cloud (незабаром)\",\n  \"backups-restore.browse-cloud-title\": \"Umbrel Private Cloud\",\n  \"backups-restore.browse-external-subtitle\": \"Відновити з зовнішнього USB-накопичувача\",\n  \"backups-restore.browse-external-title\": \"Зовнішній диск\",\n  \"backups-restore.browse-nas-or-external\": \"Перегляньте інший Umbrel, NAS або зовнішній диск, щоб відновити резервну копію\",\n  \"backups-restore.browse-nas-subtitle\": \"Відновити з іншого пристрою Umbrel або NAS у вашій мережі\",\n  \"backups-restore.browse-nas-title\": \"Інший Umbrel або NAS\",\n  \"backups-restore.choose\": \"Вибрати\",\n  \"backups-restore.choose-backup-location\": \"Оберіть місце збереження резервних копій\",\n  \"backups-restore.connect-to-backup-location\": \"Підключитися до місця резервного копіювання\",\n  \"backups-restore.encryption-password\": \"Пароль шифрування\",\n  \"backups-restore.encryption-password-description\": \"Введіть пароль шифрування, який ви встановили, коли ввімкнули резервне копіювання\",\n  \"backups-restore.enter-password-to-confirm\": \"Введіть свій пароль Umbrel, щоб підтвердити\",\n  \"backups-restore.final-confirmation\": \"Ви впевнені?\",\n  \"backups-restore.final-confirmation-description\": \"Відновлення з цієї резервної копії замінить ваші поточні додатки та дані umbrelOS вмістом обраної резервної копії. Будь-які файли, папки або програми, виключені з цієї резервної копії, будуть видалені з вашого Umbrel. Цю дію неможливо скасувати.\",\n  \"backups-restore.invalid-password\": \"Невірний пароль\",\n  \"backups-restore.last-backup\": \"Остання резервна копія: {{date}}\",\n  \"backups-restore.latest\": \"Остання\",\n  \"backups-restore.no-backups-found\": \"Резервні копії не знайдено\",\n  \"backups-restore.no-backups-yet\": \"Поки що немає резервних копій\",\n  \"backups-restore.please-select-backup\": \"Будь ласка, виберіть резервну копію\",\n  \"backups-restore.please-select-repository\": \"Будь ласка, оберіть репозиторій\",\n  \"backups-restore.restore-from-nas-or-external\": \"Відновіть Umbrel із резервної копії на іншому Umbrel, NAS або зовнішньому диску\",\n  \"backups-restore.restore-from-unlisted\": \"Відновити з іншого місця\",\n  \"backups-restore.restore-umbrel\": \"Відновити Umbrel\",\n  \"backups-restore.restore-warning\": \"Відновлення з цієї резервної копії замінить ваші поточні додатки та дані umbrelOS вмістом обраної резервної копії. Будь-які файли, папки або програми, виключені з цієї резервної копії, будуть видалені з вашого Umbrel. Відкрийте <0>Rewind</0>, якщо хочете відновити окремі файли чи папки замість цього.\",\n  \"backups-restore.restoring-from\": \"Ви збираєтеся відновити з наступної резервної копії:\",\n  \"backups-restore.review-description\": \"Відновлення налаштує ваш Umbrel з обліковим записом, файлами, додатками та налаштуваннями, які були включені на момент створення цієї резервної копії. Це може зайняти деякий час. Після завершення пароль для входу буде встановлено на той, який ви використовували під час створення резервної копії.\",\n  \"backups-restore.select-backup\": \"Оберіть резервну копію\",\n  \"backups-restore.select-backup-description\": \"Оберіть резервну копію, з якої хочете відновити\",\n  \"backups-restore.select-backup-file\": \"Виберіть файл резервної копії\",\n  \"backups-restore.select-backup-file-only\": \"Можна вибрати тільки <bold>{{backupFileName}}</bold>\",\n  \"backups-restore.total-size\": \"Загальний розмір\",\n  \"backups-restore.unknown-date\": \"Невідома дата\",\n  \"backups-restore.unknown-repository\": \"Невідомий репозиторій\",\n  \"backups-rewind\": \"Rewind\",\n  \"backups-rewind-description\": \"Повернутися назад у часі, щоб відновити окремі файли та папки\",\n  \"backups-rewind.start\": \"Розпочати Rewind\",\n  \"backups-setup\": \"Налаштувати\",\n  \"backups-setup-confirm\": \"Завершити налаштування\",\n  \"backups-setup-external-description\": \"Резервне копіювання на зовнішній USB-накопичувач\",\n  \"backups-setup-nas-or-umbrel-description\": \"Резервне копіювання на інший Umbrel або пристрій NAS у вашій мережі\",\n  \"backups-setup-umbrel-or-nas\": \"Інший Umbrel або NAS\",\n  \"backups-setup-umbrel-private-cloud\": \"Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-cta\": \"Продовжте спокій за межами дому з <bold>шифрованими «end-to-end» резервними копіями</bold> в Umbrel Private Cloud.\",\n  \"backups-setup-umbrel-private-cloud-cta-link\": \"Отримати ранній доступ\",\n  \"backups-setup-umbrel-private-cloud-description\": \"End-to-end зашифровані резервні копії в Umbrel Private Cloud\",\n  \"backups-setup-umbrel-private-cloud-subtitle\": \"Скоро буде\",\n  \"backups.add-umbrel-or-nas\": \"Додати Umbrel або NAS\",\n  \"backups.all-apps-and-data-will-be-backed-up\": \"Усі програми та дані будуть збережені в резервних копіях\",\n  \"backups.apps-and-data\": \"Apps & data\",\n  \"backups.backup-location\": \"Місце збереження резервних копій\",\n  \"backups.browse\": \"Огляд\",\n  \"backups.choose-folder-within-device\": \"Оберіть папку в <bold>{{device}}</bold>, куди зберігати резервні копії\",\n  \"backups.confirm-password\": \"Підтвердити пароль\",\n  \"backups.copy\": \"Копіювати\",\n  \"backups.encryption\": \"Шифрування\",\n  \"backups.encryption-password-warning\": \"Переконайтеся, що пароль шифрування збережено у надійному місці, наприклад у менеджері паролів. Ви не зможете побачити його повторно, і він потрібен для відновлення резервних копій.\",\n  \"backups.exclude-from-backups\": \"Виключити з Backups\",\n  \"backups.exclude-from-backups-description\": \"Виключіть конкретні файли, папки та програми з ваших резервних копій.\",\n  \"backups.hide\": \"Приховати\",\n  \"backups.i-understand\": \"Я розумію\",\n  \"backups.location\": \"Місце\",\n  \"backups.modals.already-in-use.description\": \"Це розташування резервних копій вже використовується для резервного копіювання на цьому Umbrel.\",\n  \"backups.modals.already-in-use.manage\": \"Керувати в Backups\",\n  \"backups.modals.already-in-use.title\": \"Розташування резервних копій вже використовується\",\n  \"backups.modals.connect-existing.description\": \"У цьому розташуванні вже є резервна копія Umbrel. Введіть пароль шифрування цієї копії, щоб додати її до цього Umbrel.\",\n  \"backups.modals.connect-existing.title\": \"Підключити існуючу резервну копію Umbrel\",\n  \"backups.no-external-drives-detected\": \"Зовнішні диски не виявлено\",\n  \"backups.no-password-set\": \"Пароль не встановлено\",\n  \"backups.password-is-set\": \"Пароль встановлено\",\n  \"backups.password-minimum-length\": \"Пароль має містити щонайменше 8 символів\",\n  \"backups.password-safety-warning\": \"Ваші резервні копії будуть зашифровані за допомогою цього пароля. Збережіть його в безпечному місці — ви не зможете побачити його знову, і він знадобиться для відновлення резервних копій.\",\n  \"backups.passwords-do-not-match\": \"Паролі не збігаються\",\n  \"backups.please-choose-folder\": \"Будь ласка, оберіть папку\",\n  \"backups.restore-failed.message\": \"Під час відновлення Umbrel сталася помилка. Ваші поточні додатки та дані не були змінені.\",\n  \"backups.restore-failed.retry\": \"Перейти до відновлення\",\n  \"backups.restore-failed.title\": \"Не вдалося відновити\",\n  \"backups.restoring\": \"Відновлення вашого Umbrel\",\n  \"backups.restoring-completing\": \"Майже готово. Ваш Umbrel незабаром перезавантажиться...\",\n  \"backups.restoring-progress\": \"Відновлено {{percent}}%\",\n  \"backups.restoring-time-remaining\": \"Залишилось: {{time}}\",\n  \"backups.restoring-warning\": \"Не вимикайте Umbrel і не відключайте місце резервного копіювання під час відновлення\",\n  \"backups.review\": \"Переглянути та підтвердити\",\n  \"backups.review-description\": \"Перегляньте деталі резервного копіювання та підтвердіть свій вибір\",\n  \"backups.scanning-for-external-drives\": \"Пошук зовнішніх дисків...\",\n  \"backups.schedule-description\": \"umbrelOS автоматично створює резервні копії даних щогодини. Він зберігає зашифровані щогодинні копії за останні 24 години, щоденні копії за минулий тиждень, щотижневі — за минулий місяць, і щомісячні — за минулий рік. Резервні копії старші одного року видаляються автоматично.\",\n  \"backups.select-backup-folder\": \"Оберіть папку для резервних копій\",\n  \"backups.select-backup-folder-description\": \"Виберіть папку, куди ви хочете зберігати резервні копії.\",\n  \"backups.select-backup-location\": \"Оберіть місце резервного копіювання\",\n  \"backups.set-encryption-password\": \"Встановити пароль шифрування\",\n  \"backups.set-encryption-password-description\": \"Захистіть резервні копії паролем. Це гарантує приватність ваших даних і дозволить відновити їх лише з цим паролем.\",\n  \"backups.show\": \"Показати\",\n  \"backups.storage-capacity-warning\": \"На {{device}} має бути вільного місця принаймні вдвічі більше за розмір резервної копії\",\n  \"backups.store-encryption-password-safely\": \"Збережіть пароль шифрування в надійному місці\",\n  \"beta-program\": \"umbrelOS Beta Program\",\n  \"beta-program-description\": \"Оптимізуйте отримання бета-оновлень umbrelOS, отримуйте ранній доступ до нових функцій та допомагайте нам удосконалювати їх, надаючи свій зворотній зв'язок. Бета-оновлення можуть бути нестабільними, а вирішення проблем може вимагати знайомства з терміналом.\",\n  \"cancel\": \"Скасувати\",\n  \"change\": \"Змінити\",\n  \"change-name\": \"Змінити ім'я\",\n  \"change-name.failed.name-required\": \"Ім'я обов'язкове\",\n  \"change-name.input-placeholder\": \"Ваше ім'я\",\n  \"change-password\": \"Змінити пароль\",\n  \"change-password.callout\": \"Якщо ви забудете свій пароль, ви не зможете увійти до свого Umbrel. Переконайтеся, що ви надійно зберегли його.\",\n  \"change-password.current-password\": \"Поточний пароль\",\n  \"change-password.failed.current-required\": \"Поточний пароль обов'язковий\",\n  \"change-password.failed.min-length\": \"Пароль повинен містити щонайменше {{characters}} символів\",\n  \"change-password.failed.must-be-unique\": \"Новий пароль повинен відрізнятися від поточного пароля\",\n  \"change-password.failed.new-required\": \"Новий пароль обов'язковий\",\n  \"change-password.failed.no-match\": \"Паролі не співпадають\",\n  \"change-password.failed.repeat-required\": \"Повторіть пароль обов'язково\",\n  \"change-password.new-password\": \"Новий пароль\",\n  \"change-password.repeat-password\": \"Повторіть пароль\",\n  \"check-for-latest-version\": \"Перевірити останнє оновлення umbrelOS\",\n  \"clipboard.copied\": \"Скопійовано\",\n  \"close\": \"Закрити\",\n  \"cmdk.change-wallpaper\": \"Змінити фон\",\n  \"cmdk.frequent-apps\": \"Часто використовувані\",\n  \"cmdk.input-placeholder\": \"Пошук програм, налаштувань або дій\",\n  \"cmdk.live-usage\": \"Живий використання\",\n  \"cmdk.restart-umbrel\": \"Перезапустити Umbrel\",\n  \"cmdk.shutdown-umbrel\": \"Вимкнути Umbrel\",\n  \"cmdk.update-all-apps\": \"Оновити всі програми\",\n  \"cmdk.widgets\": \"Віджети\",\n  \"community-app-store\": \"Community App Store\",\n  \"community-app-store.add-error\": \"Не вдалося додати App Store: {{message}}\",\n  \"community-app-store.back-to-umbrel-app-store\": \"Назад до Umbrel App Store\",\n  \"community-app-store.open-button\": \"Відкрити\",\n  \"community-app-store.remove-button\": \"Видалити\",\n  \"community-app-store.remove-error\": \"Не вдалося видалити App Store: {{message}}\",\n  \"community-app-stores.add-button\": \"Додати\",\n  \"community-app-stores.description\": \"Community App Stores дозволяють встановлювати програми на ваш Umbrel, які можуть бути недоступні в офіційному Umbrel App Store. Вони також спрощують тестування бета-версій програм Umbrel перед їх випуском в офіційному Umbrel App Store.\",\n  \"community-app-stores.learn-more\": \"Дізнатися більше\",\n  \"community-app-stores.warning\": \"Community App Stores можуть бути створені будь-ким. Програми, опубліковані в них, не перевірені та не затверджені командою офіційного Umbrel App Store і можуть бути небезпечними або зловмисними. Використовуйте обережно та додавайте магазини програм тільки від розробників, яким ви довіряєте.\",\n  \"confirm\": \"Підтвердити\",\n  \"connect\": \"Підключити\",\n  \"connecting\": \"Підключення...\",\n  \"connection-lost\": \"З'єднання втрачено\",\n  \"connection-lost-description\": \"Це може статися, коли вкладка вашого браузера була неактивною, мережеве з'єднання перервалося або пристрій не підключено до мережі.\",\n  \"continue\": \"Продовжити\",\n  \"continue-to-log-in\": \"Продовжити вхід\",\n  \"cpu\": \"ЦП\",\n  \"cpu-core-count\": \"{{cores}} потоки\",\n  \"default-credentials.close\": \"Зрозуміло\",\n  \"default-credentials.description\": \"Ось облікові дані, які вам знадобляться для входу в програму.\",\n  \"default-credentials.dont-show-again\": \"Більше не показувати це\",\n  \"default-credentials.dont-show-again-notice\": \"Ви можете отримати доступ до цих облікових даних у будь-який час, натиснувши правою кнопкою миші на значок програми.\",\n  \"default-credentials.open\": \"Відкрити {{app}}\",\n  \"default-credentials.password\": \"Пароль за замовчуванням\",\n  \"default-credentials.title\": \"Облікові дані для {{app}}\",\n  \"default-credentials.username\": \"Ім'я користувача за замовчуванням\",\n  \"desktop.app.context.go-to-store-page\": \"Переглянути в App Store\",\n  \"desktop.app.context.settings\": \"Налаштування\",\n  \"desktop.app.context.show-default-credentials\": \"Показати облікові дані за замовчуванням\",\n  \"desktop.app.context.uninstall\": \"Видалити\",\n  \"desktop.context-menu.change-wallpaper\": \"Змінити фон\",\n  \"desktop.context-menu.edit-widgets\": \"Редагувати віджети\",\n  \"desktop.context-menu.logout\": \"Вийти\",\n  \"desktop.greeting.afternoon\": \"Доброго дня, {{name}}\",\n  \"desktop.greeting.evening\": \"Добрий вечір, {{name}}\",\n  \"desktop.greeting.morning\": \"Доброго ранку, {{name}}\",\n  \"desktop.install-first.for-the-ai-enthusiast\": \"Для Viber\",\n  \"desktop.install-first.for-the-bitcoiner\": \"Для Біткойнера\",\n  \"desktop.install-first.for-the-self-hoster\": \"Для власного хостингу\",\n  \"desktop.install-first.for-the-streamer\": \"Для стрімера\",\n  \"desktop.install-first.link-to-app-store\": \"Дізнатися більше в App Store\",\n  \"desktop.not-enough-room\": \"Використовуйте більший екран для перегляду програм.\",\n  \"device\": \"Пристрій\",\n  \"device-info\": \"Інформація про пристрій\",\n  \"device-info-description\": \"Інформація про ваш пристрій\",\n  \"device-info.device\": \"Пристрій\",\n  \"device-info.model-number\": \"Номер моделі\",\n  \"device-info.serial-number\": \"Серійний номер\",\n  \"device-info.view-info\": \"Переглянути інформацію\",\n  \"device-name.home-or-pro\": \"Umbrel Home або Umbrel Pro\",\n  \"disable\": \"Вимкнути\",\n  \"done\": \"Готово\",\n  \"download-logs\": \"Завантажити журнали\",\n  \"enabling-tor\": \"Увімкнення віддаленого доступу через Tor\",\n  \"external-dns\": \"Cloudflare DNS\",\n  \"external-dns-description\": \"Cloudflare DNS пропонує кращу надійність мережі. Вимкніть, щоб використовувати налаштування DNS вашого маршрутизатора.\",\n  \"external-dns-error\": \"Не вдалося оновити налаштування DNS: {{message}}\",\n  \"external-drive\": \"Зовнішній диск\",\n  \"factory-reset\": \"Скидання до заводських налаштувань\",\n  \"factory-reset-description\": \"Стерти всі ваші дані та програми, відновити umbrelOS до стандартних налаштувань\",\n  \"factory-reset-failed\": \"Не вдалося скинути пристрій до заводських налаштувань: {{message}}\",\n  \"factory-reset.confirm.body\": \"Підтвердьте свій пароль для скидання\",\n  \"factory-reset.confirm.ethernet-required-warning\": \"Переконайтеся, що ваш пристрій підключений до маршрутизатора через Ethernet (не Wi-Fi) і ви отримуєте до нього доступ з вашої локальної мережі (наприклад, http://umbrel.local або локальна IP-адреса вашого пристрою).\",\n  \"factory-reset.confirm.submit\": \"Стерти все і скинути\",\n  \"factory-reset.confirm.submit-callout\": \"Цю дію не можна скасувати.\",\n  \"factory-reset.rebooting.message\": \"Ваш пристрій буде перезавантажено, і всі дані будуть видалені. Будь ласка, не закривайте цю сторінку.\",\n  \"factory-reset.rebooting.status\": \"Виконується скидання...\",\n  \"factory-reset.rebooting.title\": \"Виконується скидання до заводських налаштувань\",\n  \"factory-reset.review.account-info\": \"Інформація про акаунт та пароль\",\n  \"factory-reset.review.apps\": \"Програми\",\n  \"factory-reset.review.following-will-be-removed\": \"Наступне буде видалено з вашого пристрою\",\n  \"factory-reset.review.installed-apps_one\": \"{{count}} встановлена програма\",\n  \"factory-reset.review.installed-apps_other\": \"{{count}} встановлених програм\",\n  \"factory-reset.review.submit\": \"Продовжити\",\n  \"factory-reset.review.total-data\": \"Загальний обсяг даних\",\n  \"files\": \"Files\",\n  \"files-action.add-favorite\": \"Додати у вибране\",\n  \"files-action.add-network-device\": \"Додати пристрій\",\n  \"files-action.cancel-upload\": \"Скасувати завантаження\",\n  \"files-action.compress\": \"Стиснути\",\n  \"files-action.copy\": \"Копіювати\",\n  \"files-action.cut\": \"Вирізати\",\n  \"files-action.delete\": \"Видалити назавжди\",\n  \"files-action.download\": \"Завантажити\",\n  \"files-action.download-items\": \"Завантажити {{count}} елементів\",\n  \"files-action.drop-to-upload\": \"Перетягніть сюди, щоб завантажити\",\n  \"files-action.eject-disk\": \"Вийняти\",\n  \"files-action.empty-trash\": \"Очистити кошик\",\n  \"files-action.format-drive\": \"Форматувати\",\n  \"files-action.go-to-path\": \"Перейти до...\",\n  \"files-action.new-folder\": \"Нова папка\",\n  \"files-action.open\": \"Відкрити\",\n  \"files-action.paste\": \"Вставити\",\n  \"files-action.remove-favorite\": \"Вилучити з вибраного\",\n  \"files-action.remove-network-host\": \"Від’єднати мережевий диск\",\n  \"files-action.remove-network-share\": \"Від’єднати мережевий ресурс\",\n  \"files-action.rename\": \"Перейменувати\",\n  \"files-action.restore\": \"Відновити\",\n  \"files-action.select\": \"Вибрати\",\n  \"files-action.share\": \"Надати спільний доступ через мережу…\",\n  \"files-action.sharing\": \"Надається спільний доступ…\",\n  \"files-action.show-in-folder\": \"Показати в папці\",\n  \"files-action.trash\": \"Кошик\",\n  \"files-action.uncompress\": \"Розпакувати\",\n  \"files-action.upload\": \"Завантажити\",\n  \"files-add-network-share.add-manually\": \"Додати вручну\",\n  \"files-add-network-share.add-share\": \"Додати ресурс\",\n  \"files-add-network-share.back\": \"Назад\",\n  \"files-add-network-share.continue\": \"Продовжити\",\n  \"files-add-network-share.description\": \"Під’єднайтесь до NAS або іншого спільного диска у вашій мережі, щоб отримати до них доступ у Files.\",\n  \"files-add-network-share.discovering\": \"Виявлення...\",\n  \"files-add-network-share.enter-details-manually\": \"Введіть дані сервера\",\n  \"files-add-network-share.host-label\": \"Адреса сервера\",\n  \"files-add-network-share.host-required\": \"Потрібна адреса сервера\",\n  \"files-add-network-share.manual-share-help\": \"Введіть точну назву мережевої папки так, як вона вказана на вашому сервері\",\n  \"files-add-network-share.no-shares-found\": \"На цьому сервері не знайдено мережевих папок\",\n  \"files-add-network-share.not-seeing-share\": \"Не бачите свою мережеву папку?\",\n  \"files-add-network-share.password-label\": \"Пароль\",\n  \"files-add-network-share.password-required\": \"Потрібен пароль\",\n  \"files-add-network-share.retrieving-shares\": \"Отримання ресурсів…\",\n  \"files-add-network-share.retry-discovery\": \"Пересканувати мережу\",\n  \"files-add-network-share.select-share\": \"Виберіть ресурс для додавання\",\n  \"files-add-network-share.share-placeholder\": \"shared-documents\",\n  \"files-add-network-share.share-required\": \"Потрібна назва ресурсу\",\n  \"files-add-network-share.title\": \"Додати мережевий ресурс\",\n  \"files-add-network-share.username-label\": \"Ім’я користувача\",\n  \"files-add-network-share.username-placeholder\": \"admin\",\n  \"files-add-network-share.username-required\": \"Потрібне ім’я користувача\",\n  \"files-audio-island.now-playing\": \"Зараз відтворюється\",\n  \"files-audio-island.pause\": \"Пауза\",\n  \"files-audio-island.play\": \"Відтворити\",\n  \"files-backend-error.base-directory-not-found\": \"Не вдалося знайти базовий каталог\",\n  \"files-backend-error.cant-find-root\": \"Не вдалося перевірити шлях до файлу\",\n  \"files-backend-error.destination-already-exists\": \"У папці призначення вже існує елемент з такою назвою\",\n  \"files-backend-error.destination-not-exist\": \"Папка призначення не існує\",\n  \"files-backend-error.does-not-exist\": \"Файл або папка не існує\",\n  \"files-backend-error.escapes-base\": \"Шлях виходить за межі дозволеного каталогу\",\n  \"files-backend-error.invalid-base\": \"Шлях не належить до допустимого каталогу\",\n  \"files-backend-error.invalid-filename\": \"Недопустиме ім'я файлу\",\n  \"files-backend-error.invalid-path\": \"Недійсний шлях до файлу\",\n  \"files-backend-error.mkdir-failed\": \"Не вдалося створити папку\",\n  \"files-backend-error.move-failed\": \"Не вдалося перемістити елемент\",\n  \"files-backend-error.not-enough-space\": \"Недостатньо вільного місця\",\n  \"files-backend-error.operation-not-allowed\": \"Ця операція заборонена\",\n  \"files-backend-error.parent-not-directory\": \"Батьківський шлях не є папкою\",\n  \"files-backend-error.parent-not-exist\": \"Батьківська папка не існує\",\n  \"files-backend-error.path-not-absolute\": \"Недійсний шлях до файлу\",\n  \"files-backend-error.share-already-exists\": \"До цієї папки вже надано спільний доступ\",\n  \"files-backend-error.share-name-generation-failed\": \"Не вдалося згенерувати унікальну назву для спільного доступу\",\n  \"files-backend-error.source-not-exists\": \"Файл або папка-джерело не існує\",\n  \"files-backend-error.subdir-of-self\": \"Папку неможливо перемістити або скопіювати всередину самої себе\",\n  \"files-backend-error.trash-meta-not-exists\": \"Не вдалося знайти оригінальне розташування цього елемента\",\n  \"files-backend-error.unique-name-index-exceeded\": \"Не вдалося згенерувати унікальне ім'я. Існує занадто багато елементів з подібними назвами\",\n  \"files-backend-error.upload-failed\": \"Не вдалося завантажити\",\n  \"files-collision.action.keep-both\": \"Зберегти обидва\",\n  \"files-collision.action.replace\": \"Замінити\",\n  \"files-collision.action.skip\": \"Пропустити\",\n  \"files-collision.destination.original-location\": \"його початкове розташування\",\n  \"files-collision.message\": \"Ви хочете замінити наявний елемент чи зберегти обидва?\",\n  \"files-collision.title\": \"\\\"{{itemName}}\\\" уже існує в {{destinationName}}\",\n  \"files-download.confirm\": \"Завантажити\",\n  \"files-download.description\": \"Files не можуть відкрити цей тип файлу. Бажаєте замість цього завантажити його?\",\n  \"files-download.title\": \"Завантажити {{name}}?\",\n  \"files-empty-trash.confirm\": \"Очистити\",\n  \"files-empty-trash.description\": \"Ви впевнені, що хочете назавжди видалити всі елементи з кошика? Ви не зможете скасувати цю дію.\",\n  \"files-empty-trash.title\": \"Очистити кошик?\",\n  \"files-empty.directory\": \"У цій папці немає елементів\",\n  \"files-empty.network\": \"Немає мережевих пристроїв\",\n  \"files-empty.network-host-offline\": \"Мережевий пристрій офлайн\",\n  \"files-error.add-favorite\": \"Не вдалося додати до вибраного: {{message}}\",\n  \"files-error.add-share\": \"Не вдалося надати спільний доступ до папки: {{message}}\",\n  \"files-error.compress\": \"Не вдалося стиснути: {{message}}\",\n  \"files-error.copy\": \"Не вдалося скопіювати: {{message}}\",\n  \"files-error.create-folder\": \"Не вдалося створити папку: {{message}}\",\n  \"files-error.delete\": \"Не вдалося видалити: {{message}}\",\n  \"files-error.eject-disk\": \"Не вдалося від'єднати диск: {{message}}\",\n  \"files-error.empty-trash\": \"Не вдалося очистити кошик: {{message}}\",\n  \"files-error.extract\": \"Не вдалося розпакувати: {{message}}\",\n  \"files-error.folder-already-exists\": \"Папка з такою назвою вже існує\",\n  \"files-error.move\": \"Не вдалося перемістити: {{message}}\",\n  \"files-error.remove-favorite\": \"Не вдалося видалити з вибраного: {{message}}\",\n  \"files-error.remove-share\": \"Не вдалося видалити спільну папку: {{message}}\",\n  \"files-error.rename\": \"Не вдалося перейменувати: {{message}}\",\n  \"files-error.restore\": \"Не вдалося відновити: {{message}}\",\n  \"files-error.trash\": \"Не вдалося перемістити в кошик: {{message}}\",\n  \"files-error.upload\": \"Не вдалося завантажити: {{message}}\",\n  \"files-error.upload-network-error\": \"Не вдалося завантажити {{name}}: сталася помилка мережі\",\n  \"files-extension-change.confirm\": \"Продовжити\",\n  \"files-extension-change.description-add\": \"Ви впевнені, що хочете змінити розширення для '{{fileName}}' на '{{extension}}'? Це може зробити файл нерозпізнаваним.\",\n  \"files-extension-change.description-remove\": \"Ви впевнені, що хочете вилучити розширення у '{{fileName}}'?\",\n  \"files-extension-change.title-add\": \"Змінити розширення на '{{extension}}'?\",\n  \"files-extension-change.title-remove\": \"Вилучити розширення?\",\n  \"files-external-storage.unsupported.description\": \"Ваш підключений зовнішній диск не можна використовувати на Raspberry Pi через проблеми з живленням. Зовнішнє сховище доступне на Umbrel Home, Umbrel Pro та на всіх пристроях x86 (Intel або AMD).\",\n  \"files-external-storage.unsupported.description-general\": \"Зовнішнє сховище недоступне на Raspberry Pi через проблеми з живленням. Зовнішнє сховище доступне на Umbrel Home, Umbrel Pro та на всіх пристроях x86 (Intel або AMD).\",\n  \"files-external-storage.unsupported.title\": \"Зовнішнє сховище не підтримується\",\n  \"files-folder\": \"Папка\",\n  \"files-format.confirm\": \"Форматувати\",\n  \"files-format.description\": \"Форматування зітре всі дані на {{driveName}}. Цю дію неможливо скасувати.\",\n  \"files-format.description-unreadable\": \"umbrelOS не може прочитати вміст {{driveName}}. Ви можете відформатувати його для використання з umbrelOS.\",\n  \"files-format.drive-label\": \"Назва\",\n  \"files-format.error\": \"Не вдалося відформатувати диск\",\n  \"files-format.exfat-description\": \"Максимальна сумісність з Windows, macOS та Linux\",\n  \"files-format.ext4-description\": \"Оптимізовано для umbrelOS та Linux\",\n  \"files-format.filesystem\": \"Файлова система\",\n  \"files-format.filesystem-label\": \"Форматувати як\",\n  \"files-format.formatting\": \"Форматування...\",\n  \"files-format.title\": \"Форматувати диск\",\n  \"files-format.title-requires-format\": \"Потрібне форматування\",\n  \"files-formatting-island.formatting\": \"Форматування...\",\n  \"files-formatting-island.formatting-drives\": \"Форматування {{count}} дисків\",\n  \"files-listing.empty\": \"Немає елементів\",\n  \"files-listing.error\": \"Сталася помилка\",\n  \"files-listing.item-count-truncated\": \"{{formattedCount}}+ елементів\",\n  \"files-listing.item-count_one\": \"{{formattedCount}} елемент\",\n  \"files-listing.item-count_other\": \"{{formattedCount}} елементів\",\n  \"files-listing.loading\": \"Завантаження...\",\n  \"files-listing.no-such-file\": \"Такого файлу чи папки не існує\",\n  \"files-listing.selected-count\": \"{{selectedCount}} із {{totalCount}} вибрано\",\n  \"files-listing.selected-count-truncated\": \"{{selectedCount}} з {{totalCount}}+ вибрано\",\n  \"files-name-drawer.new-folder\": \"Нова папка\",\n  \"files-name-drawer.new-folder-description\": \"Введіть назву для нової папки.\",\n  \"files-name-drawer.new-folder-input\": \"Назва папки\",\n  \"files-name-drawer.rename-file\": \"Перейменувати файл\",\n  \"files-name-drawer.rename-file-description\": \"Введіть нову назву для цього файлу.\",\n  \"files-name-drawer.rename-file-input\": \"Назва файлу\",\n  \"files-name-drawer.rename-folder\": \"Перейменувати папку\",\n  \"files-name-drawer.rename-folder-description\": \"Введіть нову назву для цієї папки.\",\n  \"files-name-drawer.rename-folder-input\": \"Назва папки\",\n  \"files-network-storage-error.add-share\": \"Не вдалося додати мережеву папку: {{message}}\",\n  \"files-network-storage-error.discover-servers\": \"Не вдалося знайти мережеві пристрої: {{message}}\",\n  \"files-network-storage-error.discover-shares\": \"Не вдалося знайти мережеві спільні папки: {{message}}\",\n  \"files-network-storage-error.remove-share\": \"Не вдалося видалити мережеву папку: {{message}}\",\n  \"files-operations-island.copying\": \"Копіювання \\\"{{from}}\\\" до \\\"{{to}}\\\"\",\n  \"files-operations-island.moving\": \"Переміщення \\\"{{from}}\\\" до \\\"{{to}}\\\"\",\n  \"files-operations-island.restoring\": \"Відновлюємо \\\"{{from}}\\\" до \\\"{{to}}\\\"\",\n  \"files-path.input-group\": \"Введення шляху\",\n  \"files-path.input-label\": \"Поточний шлях\",\n  \"files-permanently-delete.confirm\": \"Видалити назавжди\",\n  \"files-permanently-delete.description-multiple\": \"Ви впевнені, що хочете назавжди видалити ці {{count}} елементів? Ви не зможете скасувати цю дію.\",\n  \"files-permanently-delete.description-single\": \"Ви впевнені, що хочете назавжди видалити \\\"{{fileName}}\\\"? Ви не зможете скасувати цю дію.\",\n  \"files-permanently-delete.title-multiple\": \"Видалити {{count}} елементів назавжди?\",\n  \"files-permanently-delete.title-single\": \"Видалити назавжди?\",\n  \"files-search.default\": \"Пошук файлів і папок\",\n  \"files-search.no-results\": \"Нічого не знайдено за запитом \\\"{{query}}\\\"\",\n  \"files-search.placeholder\": \"Пошук\",\n  \"files-search.searching-label\": \"Шукаємо Umbrel користувача {{name}}\",\n  \"files-share.home-description\": \"Отримуйте доступ до всіх файлів у «{{homeDirectoryName}}» з інших пристроїв у вашій мережі\",\n  \"files-share.home-title\": \"Надати спільний доступ до «{{homeDirectoryName}}» через мережу\",\n  \"files-share.instructions.how-to-access\": \"Як отримати доступ\",\n  \"files-share.instructions.ios.enter-password\": \"Введіть <field>{{password}}</field> як пароль.\",\n  \"files-share.instructions.ios.enter-server\": \"Введіть <field>{{smbUrl}}</field> як адресу сервера.\",\n  \"files-share.instructions.ios.enter-username\": \"Введіть <field>{{username}}</field> як ім'я користувача.\",\n  \"files-share.instructions.ios.install-files\": \"Встановіть додаток «Files» з App Store, якщо ще не встановлений.\",\n  \"files-share.instructions.ios.tap-connect\": \"Торкніться «Підключитися», щоб отримати доступ.\",\n  \"files-share.instructions.ios.tap-dots\": \"Торкніться трьох крапок (…) у верхньому правому куті й виберіть «Connect to Server».\",\n  \"files-share.instructions.macos.click-connect\": \"Натисніть «Підключитися», щоб отримати доступ.\",\n  \"files-share.instructions.macos.enter-password\": \"Введіть <field>{{password}}</field> як пароль.\",\n  \"files-share.instructions.macos.enter-url\": \"Введіть <field>{{smbUrl}}</field> і натисніть «Підключитися».\",\n  \"files-share.instructions.macos.enter-username\": \"Введіть <field>{{username}}</field> як ім'я користувача.\",\n  \"files-share.instructions.macos.open-finder\": \"Відкрийте «Finder», і натисніть ⌘ + K.\",\n  \"files-share.instructions.macos.select-registered\": \"Виберіть «Зареєстрований користувач» за запитом.\",\n  \"files-share.instructions.macos.time-machine\": \"Як використовувати як місце резервного копіювання для Time Machine\",\n  \"files-share.instructions.macos.time-machine.choose-encryption\": \"Виберіть, чи шифрувати резервні копії, чи ні.\",\n  \"files-share.instructions.macos.time-machine.disk-limit\": \"У полі «Ліміт використання диска» вкажіть максимальний обсяг простору, який ви хочете виділити на Umbrel для резервних копій Time Machine, а потім натисніть «Готово».\",\n  \"files-share.instructions.macos.time-machine.follow-steps\": \"Виконайте наведені вище кроки й відкрийте «Параметри системи» на вашому Mac.\",\n  \"files-share.instructions.macos.time-machine.go-settings\": \"Перейдіть до Time Machine і натисніть «Додати диск резервних копій…».\",\n  \"files-share.instructions.macos.time-machine.select-disk\": \"Виберіть папку та натисніть \\\"Set Up Disk...\\\".\",\n  \"files-share.instructions.umbrelos.backup.follow-onscreen\": \"Дотримуйтесь покрокових інструкцій, щоб налаштувати резервне копіювання.\",\n  \"files-share.instructions.umbrelos.backup.follow-then-go-to\": \"Дотримуйтесь наведених вище кроків, а потім на іншому Umbrel перейдіть у \\\"{{settings}}\\\" > \\\"{{backups}}\\\".\",\n  \"files-share.instructions.umbrelos.backup.select-add\": \"Виберіть опцію \\\"{{addUmbrelOrNas}}\\\".\",\n  \"files-share.instructions.umbrelos.backup.select-connected\": \"Виберіть цей Umbrel у списку підключених пристроїв.\",\n  \"files-share.instructions.umbrelos.backup.title\": \"Як використовувати як місце резервного копіювання для іншого Umbrel\",\n  \"files-share.instructions.umbrelos.cant-find-note\": \"Не можете знайти? Спробуйте обрати \\\"Додати вручну\\\" та використайте наступні облікові дані. Якщо все ще не вдається додати, переконайтеся, що обидва ваші пристрої підключені до тієї самої мережі.\",\n  \"files-share.instructions.umbrelos.enter-password\": \"Введіть <field>{{password}}</field> як пароль.\",\n  \"files-share.instructions.umbrelos.enter-username\": \"Введіть <field>{{username}}</field> як ім'я користувача.\",\n  \"files-share.instructions.umbrelos.open-and-click\": \"На іншому Umbrel відкрийте \\\"Files\\\" і натисніть <plus/> поруч із \\\"<deviceIcon/> {{deviceLabel}}\\\" у бічній панелі.\",\n  \"files-share.instructions.umbrelos.select-device\": \"Виберіть цей пристрій Umbrel зі списку автоматично виявлених пристроїв у вашій мережі.\",\n  \"files-share.instructions.umbrelos.select-sharename\": \"Виберіть \\\"{{sharename}}\\\" та натисніть, щоб додати шар.\",\n  \"files-share.instructions.windows.enter-password\": \"Введіть <field>{{password}}</field> як пароль.\",\n  \"files-share.instructions.windows.enter-url\": \"Введіть <field>{{smbUrl}}</field> і натисніть Enter.\",\n  \"files-share.instructions.windows.enter-username\": \"Введіть <field>{{username}}</field> як ім'я користувача.\",\n  \"files-share.instructions.windows.open-run\": \"Натисніть Windows + R, щоб відкрити вікно «Виконати».\",\n  \"files-share.instructions.windows.remember-credentials\": \"Поставте прапорець «Запам’ятати мої облікові дані» і натисніть OK.\",\n  \"files-share.regular-description\": \"Надати спільний доступ до цієї теки, щоб отримувати до неї доступ з інших пристроїв у вашій мережі\",\n  \"files-share.regular-title\": \"Надати спільний доступ до теки через мережу\",\n  \"files-share.toggle\": \"Надати спільний доступ до «{{name}}» через мережу\",\n  \"files-sidebar.apps\": \"Програми\",\n  \"files-sidebar.external-storage\": \"Зовнішнє сховище\",\n  \"files-sidebar.favorites\": \"Вибране\",\n  \"files-sidebar.home\": \"Головна\",\n  \"files-sidebar.navigation\": \"Навігація файлами\",\n  \"files-sidebar.network\": \"Мережа\",\n  \"files-sidebar.network-pathbar\": \"Мережеві пристрої\",\n  \"files-sidebar.network-sidebar\": \"Пристрої\",\n  \"files-sidebar.recents\": \"Недавні\",\n  \"files-sidebar.shared-folders\": \"Спільні теки\",\n  \"files-sidebar.trash\": \"Кошик\",\n  \"files-sidebar.trash.open\": \"Відкрити\",\n  \"files-sort.created\": \"Додано\",\n  \"files-sort.modified\": \"Змінено\",\n  \"files-sort.name\": \"Назва\",\n  \"files-sort.size\": \"Розмір\",\n  \"files-sort.type\": \"Тип\",\n  \"files-state.uploading\": \"Завантаження...\",\n  \"files-state.waiting\": \"Очікування...\",\n  \"files-type.3gp\": \"Відео 3GP\",\n  \"files-type.3gp2\": \"Відео 3GP2\",\n  \"files-type.7z\": \"Архів 7Z\",\n  \"files-type.aac\": \"Аудіо AAC\",\n  \"files-type.ai\": \"Файл Illustrator\",\n  \"files-type.aiff\": \"Аудіо AIFF\",\n  \"files-type.au\": \"Аудіо AU\",\n  \"files-type.avi\": \"Відео AVI\",\n  \"files-type.avif\": \"Зображення AVIF\",\n  \"files-type.bmp\": \"Зображення BMP\",\n  \"files-type.bzip2\": \"Архів BZIP2\",\n  \"files-type.caf\": \"Аудіо CAF\",\n  \"files-type.compressed\": \"Стиснутий архів\",\n  \"files-type.csv\": \"Файл CSV\",\n  \"files-type.directory\": \"Папка\",\n  \"files-type.dmg\": \"Образ диска\",\n  \"files-type.dv\": \"Відео DV\",\n  \"files-type.epub\": \"Електронна книга EPUB\",\n  \"files-type.excel\": \"Таблиця Excel\",\n  \"files-type.exe\": \"Виконуваний файл Windows\",\n  \"files-type.executable\": \"Виконуваний файл\",\n  \"files-type.external-drive\": \"Диск\",\n  \"files-type.flac\": \"Аудіо FLAC\",\n  \"files-type.flv\": \"Відео FLV\",\n  \"files-type.gif\": \"Зображення GIF\",\n  \"files-type.gzip\": \"Архів GZIP\",\n  \"files-type.heic\": \"Зображення HEIC\",\n  \"files-type.ico\": \"Зображення ICO\",\n  \"files-type.iso\": \"Образ ISO\",\n  \"files-type.jpeg\": \"Зображення JPEG\",\n  \"files-type.keynote\": \"Презентація Keynote\",\n  \"files-type.lzip\": \"Архів LZIP\",\n  \"files-type.lzma\": \"Архів LZMA\",\n  \"files-type.lzop\": \"Архів LZOP\",\n  \"files-type.m3u\": \"Плейліст M3U\",\n  \"files-type.m4a\": \"Аудіо M4A\",\n  \"files-type.m4v\": \"Відео M4V\",\n  \"files-type.midi\": \"Аудіо MIDI\",\n  \"files-type.mka\": \"Аудіо MKA\",\n  \"files-type.mkv\": \"Відео MKV\",\n  \"files-type.mng\": \"Відео MNG\",\n  \"files-type.mobi\": \"Електронна книга MOBI\",\n  \"files-type.mp3\": \"Аудіо MP3\",\n  \"files-type.mp4\": \"Відео MP4\",\n  \"files-type.mp4-audio\": \"Аудіо MP4\",\n  \"files-type.mpeg\": \"Відео MPEG\",\n  \"files-type.mpeg-ts\": \"Транспортний потік MPEG\",\n  \"files-type.network-drive\": \"Мережевий диск\",\n  \"files-type.numbers\": \"Таблиця Numbers\",\n  \"files-type.ogg\": \"Аудіо OGG\",\n  \"files-type.ogv\": \"Відео OGV\",\n  \"files-type.pages\": \"Документ Pages\",\n  \"files-type.pdf\": \"Документ PDF\",\n  \"files-type.png\": \"Зображення PNG\",\n  \"files-type.powerpoint\": \"Презентація PowerPoint\",\n  \"files-type.psd\": \"Документ Photoshop\",\n  \"files-type.quicktime\": \"Відео QuickTime\",\n  \"files-type.rar\": \"Архів RAR\",\n  \"files-type.sgi\": \"Відео SGI\",\n  \"files-type.svg\": \"Зображення SVG\",\n  \"files-type.tar\": \"Архів TAR\",\n  \"files-type.tiff\": \"Зображення TIFF\",\n  \"files-type.ts\": \"Відео TS\",\n  \"files-type.txt\": \"Текстовий файл\",\n  \"files-type.umbrel-backup\": \"Umbrel Backup\",\n  \"files-type.wav\": \"Аудіо WAV\",\n  \"files-type.webm\": \"Відео WebM\",\n  \"files-type.webm-audio\": \"Аудіо WebM\",\n  \"files-type.webp\": \"Зображення WebP\",\n  \"files-type.wma\": \"Аудіо WMA\",\n  \"files-type.wmv\": \"Відео WMV\",\n  \"files-type.word\": \"Документ Word\",\n  \"files-type.xz\": \"Архів XZ\",\n  \"files-type.zip\": \"Архів ZIP\",\n  \"files-upload-island.uploading-count\": \"Завантаження {{count}} елементів\",\n  \"files-view.icons\": \"Значки\",\n  \"files-view.list\": \"Список\",\n  \"files-view.sort-by\": \"Сортувати за\",\n  \"files-view.view-as\": \"Перегляд як\",\n  \"files-widgets.favorites.no-items-text\": \"Додайте папку у Вибране, щоб побачити її тут\",\n  \"files-widgets.recents.no-items-text\": \"Немає нещодавніх файлів\",\n  \"generic-in\": \"в\",\n  \"hide-details\": \"Приховати деталі\",\n  \"install-first.install-app\": \"Встановити {{app}}\",\n  \"install-first.title\": \"{{app}} вимагає ці додатки\",\n  \"install-your-first-app\": \"Встановіть свою першу програму\",\n  \"language\": \"Мова\",\n  \"language-description\": \"Ваша бажана мова umbrelOS\",\n  \"language.select-description\": \"Виберіть бажану мову umbrelOS\",\n  \"live-usage\": \"Живе використання\",\n  \"loading\": \"Завантаження\",\n  \"local-ip\": \"Локальний IP\",\n  \"login-2fa.subtitle\": \"Введіть код 2FA, відображений у вашій програмі-аутентифікаторі\",\n  \"login-2fa.title\": \"Аутентифікація\",\n  \"login-with-umbrel.description\": \"Введіть пароль Umbrel, щоб відкрити {{app}}\",\n  \"login-with-umbrel.title\": \"Увійти з Umbrel\",\n  \"login.password-label\": \"Пароль\",\n  \"login.password.submit\": \"Увійти\",\n  \"login.subtitle\": \"Введіть пароль Umbrel для входу\",\n  \"login.title\": \"З поверненням\",\n  \"logout\": \"Вийти\",\n  \"logout-error-generic\": \"Помилка: вихід не вдався\",\n  \"logout.confirm.submit\": \"Вийти\",\n  \"logout.confirm.title\": \"Ви впевнені, що хочете вийти?\",\n  \"memory\": \"Пам'ять\",\n  \"memory.low\": \"Низький рівень пам'яті\",\n  \"migrate\": \"Мігрувати\",\n  \"migrate.callout\": \"Не вимикайте ваш Umbrel, доки міграція не завершиться\",\n  \"migrate.failed.retry\": \"Спробувати знову\",\n  \"migrate.failed.title\": \"Міграція не вдалася\",\n  \"migrate.success.description\": \"Всі ваші програми, дані програм і деталі облікового запису були перенесені на ваш Umbrel Home.\",\n  \"migrate.success.title\": \"Міграція успішна\",\n  \"migration-assistant\": \"Помічник з міграції\",\n  \"migration-assistant-description\": \"Перенесіть усі ваші додатки та дані з Raspberry Pi на {{deviceName}}\",\n  \"migration-assistant-unsupported-device-description\": \"Наразі Migration Assistant підтримує перенесення всіх даних і додатків з Raspberry Pi з umbrelOS на Umbrel Home або Umbrel Pro. Відкрийте Migration Assistant на своєму Umbrel Home або Umbrel Pro, щоб почати.\",\n  \"migration-assistant.continue-migration.ready.submit\": \"Почати міграцію\",\n  \"migration-assistant.failed\": \"Щось не так...\",\n  \"migration-assistant.failed.retrying-message\": \"Повторна спроба...\",\n  \"migration-assistant.mobile.start-button\": \"Почати міграцію\",\n  \"migration-assistant.prep.body\": \"Підготуйтеся до міграції\",\n  \"migration-assistant.prep.button-continue\": \"Продовжити\",\n  \"migration-assistant.prep.callout\": \"Дані на вашому {{deviceName}}, якщо такі є, будуть безповоротно видалені.\",\n  \"migration-assistant.prep.connect-disk-to-home\": \"Підключіть зовнішній диск до будь-якого USB-порту на вашому {{deviceName}}.\",\n  \"migration-assistant.prep.prep-done-continue-message\": \"Коли будете готові, натисніть '{{button}}' нижче.\",\n  \"migration-assistant.prep.shut-down-rpi\": \"Вимкніть ваш Raspberry Pi Umbrel.\",\n  \"migration-assistant.ready.description\": \"Усі ваші дані та додатки готові до перенесення на ваш {{deviceName}}\",\n  \"migration-assistant.ready.hint-header\": \"Речі, які варто пам'ятати\",\n  \"migration-assistant.ready.hint-keep-pi-off.description\": \"Це допомагає запобігти проблемам з програмами, такими як Lightning Node\",\n  \"migration-assistant.ready.hint-keep-pi-off.title\": \"Тримайте ваш Raspberry Pi вимкненим після оновлення\",\n  \"migration-assistant.ready.hint-use-same-password.description\": \"Не забудьте використовувати пароль Umbrel зі свого Raspberry Pi, щоб увійти на {{deviceName}}.\",\n  \"migration-assistant.ready.hint-use-same-password.title\": \"Використовуйте той самий пароль\",\n  \"migration-assistant.ready.title\": \"Ви готові до міграції!\",\n  \"mini-browser.default-title\": \"Виберіть папку\",\n  \"mini-browser.empty-external\": \"Підключіть зовнішній диск, щоб він з'явився тут.\",\n  \"mini-browser.empty-network\": \"Додайте Umbrel або NAS, щоб вони з'явилися тут.\",\n  \"mini-browser.load-more\": \"Завантажити більше\",\n  \"mini-browser.load-more-in-folder\": \"Завантажити більше у {{name}}\",\n  \"mini-browser.loading-more\": \"Завантаження додаткових…\",\n  \"mini-browser.select\": \"Вибрати\",\n  \"mini-browser.select-folder\": \"Виберіть папку\",\n  \"name\": \"Ім'я\",\n  \"nas\": \"NAS\",\n  \"no-forgot-password-message\": \"Якщо ви забудете свій пароль, ви не зможете увійти в Umbrel. Переконайтеся, що ви надійно зберегли його.\",\n  \"no-results-found\": \"Результатів не знайдено\",\n  \"not-found-404\": \"Код помилки: 404\",\n  \"not-found-404.back\": \"Назад\",\n  \"not-found-404.home\": \"Перейти на головну\",\n  \"notifications.backups-failing-location.description\": \"Автоматичні Backups на {{location}} не виконуються. Перевірте підключення та перегляньте налаштування Backups.\",\n  \"notifications.backups-failing.description\": \"Автоматичне резервне копіювання не працює. Перевірте місце збереження та налаштування резервних копій.\",\n  \"notifications.backups-failing.go-to-backups\": \"Перейти до Backups\",\n  \"notifications.backups-failing.title\": \"За останні 24 години резервні копії не створювалися\",\n  \"notifications.cpu.too-hot\": \"Висока температура ЦП\",\n  \"notifications.memory.low\": \"Низький рівень пам'яті вашого пристрою\",\n  \"notifications.new-version-available\": \"{{update}} доступна для встановлення\",\n  \"notifications.raid.issue.description\": \"Виявлено проблему зі сховищем. Перегляньте Диспетчер сховища для деталей.\",\n  \"notifications.raid.issue.title\": \"Потрібні термінові дії\",\n  \"notifications.ssd.health.description\": \"Один або кілька SSD можуть потребувати уваги. Перегляньте Диспетчер сховища для деталей.\",\n  \"notifications.ssd.health.title\": \"Попередження про стан SSD\",\n  \"notifications.storage.full\": \"Пам'ять вашого пристрою заповнена\",\n  \"notifications.view\": \"Переглянути\",\n  \"ok\": \"OK\",\n  \"onboarding.account-created.by-clicking-button-you-agree\": \"Натискаючи 'Далі', ви погоджуєтеся з <linked>Умовами обслуговування umbrelOS</linked>\",\n  \"onboarding.account-created.youre-all-set-name\": \"Ви готові, {{name}}.\",\n  \"onboarding.contact-support\": \"Підтримка\",\n  \"onboarding.create-account\": \"Створити акаунт\",\n  \"onboarding.create-account.confirm-password.input-label\": \"Підтвердити пароль\",\n  \"onboarding.create-account.failed.name-required\": \"Ім'я обов'язкове\",\n  \"onboarding.create-account.failed.passwords-dont-match\": \"Паролі не співпадають\",\n  \"onboarding.create-account.name.input-placeholder\": \"Ваше ім'я\",\n  \"onboarding.create-account.password.input-label\": \"Пароль\",\n  \"onboarding.create-account.submit\": \"Створити\",\n  \"onboarding.create-account.submitting\": \"Створення\",\n  \"onboarding.create-account.subtitle\": \"Ваша інформація облікового запису зберігається лише на вашому Umbrel. Переконайтеся, що ви надійно зберегли свій пароль, оскільки його не можна скинути.\",\n  \"onboarding.create-instead-long\": \"Створити новий обліковий запис\",\n  \"onboarding.create-instead-short\": \"Новий обліковий запис\",\n  \"onboarding.launch-umbrelos\": \"Запустити umbrelOS\",\n  \"onboarding.raid.available-storage\": \"Доступне місце\",\n  \"onboarding.raid.change-drives-link\": \"Потрібно додати або замінити накопичувачі?\",\n  \"onboarding.raid.configuring.subtitle\": \"Це може зайняти кілька хвилин.\",\n  \"onboarding.raid.configuring.title\": \"Налаштування вашого сховища\",\n  \"onboarding.raid.configuring.warning\": \"Не оновлюйте цю сторінку та не вимикайте Umbrel під час налаштування сховища.\",\n  \"onboarding.raid.continue\": \"Продовжити\",\n  \"onboarding.raid.error.detection-instructions\": \"Вимкніть Umbrel Pro, перевірте, чи SSD правильно встановлені, і спробуйте ще раз.\",\n  \"onboarding.raid.error.no-ssds-detected\": \"SSD не виявлено\",\n  \"onboarding.raid.error.no-ssds-instructions\": \"Вимкніть Umbrel Pro і вставте щонайменше один SSD, щоб продовжити.\",\n  \"onboarding.raid.failsafe\": \"FailSafe\",\n  \"onboarding.raid.failsafe.cant-enable\": \"Поки що не можна увімкнути FailSafe\",\n  \"onboarding.raid.failsafe.enable\": \"Увімкнути FailSafe\",\n  \"onboarding.raid.failsafe.mixed-sizes\": \"FailSafe обмежений найменшим SSD ({{smallest}}). Додатковий простір на більших SSD не можна використати — {{wasted}} залишиться непридатним для використання.\",\n  \"onboarding.raid.failsafe.protection-info-2ssds\": \"{{protection}} використовується для захисту даних. Додайте ще один SSD розміром {{smallest}}, щоб збільшити доступне сховище до {{futureWith3}}, або додайте ще два — до {{futureWith4}}. Ви можете додавати SSD будь-коли.\",\n  \"onboarding.raid.failsafe.protection-info-3ssds\": \"{{protection}} використовується для захисту даних. Додайте ще один SSD розміром {{smallest}}, щоб збільшити доступне сховище до {{futureWith4}}. Ви можете додавати SSD будь-коли.\",\n  \"onboarding.raid.failsafe.single-ssd-info\": \"У вас лише один SSD. Додайте щонайменше ще один {{size}} SSD, щоб увімкнути захист FailSafe для ваших даних. Ви можете додавати SSD будь-коли.\",\n  \"onboarding.raid.failsafe.subtitle\": \"Ваші дані залишаться в безпеці, якщо вийде з ладу будь-який один SSD.\",\n  \"onboarding.raid.failsafe.tip\": \"Використовуйте SSD одного розміру, щоб отримати максимум місця й нуль непридатного простору.\",\n  \"onboarding.raid.failsafe.warning-now-only\": \"Якщо у вас більше одного SSD, FailSafe можна ввімкнути лише під час початкового налаштування. Потім ввімкнути його не вдасться.\",\n  \"onboarding.raid.health-warning\": \"Цей диск повідомляє про проблеми зі станом\",\n  \"onboarding.raid.launching\": \"Запускається...\",\n  \"onboarding.raid.no-ssds-alt\": \"SSD не знайдено\",\n  \"onboarding.raid.recommended\": \"Рекомендовано\",\n  \"onboarding.raid.scanning\": \"Перевірка слотів SSD\",\n  \"onboarding.raid.scanning-alt\": \"Сканування SSD\",\n  \"onboarding.raid.setup-failed.description-no-retry\": \"Будь ласка, вимкніть пристрій і спробуйте ще раз.\",\n  \"onboarding.raid.setup-failed.description-retry\": \"Спробуйте ще раз або вимкніть пристрій, щоб перевірити накопичувачі.\",\n  \"onboarding.raid.setup-failed.title\": \"Не вдалося налаштувати сховище\",\n  \"onboarding.raid.shutdown-dialog.description\": \"Щоб додати або замінити накопичувачі, вимкніть Umbrel Pro. Після цього можна знову ввімкнути і продовжити налаштування.\",\n  \"onboarding.raid.shutdown-dialog.title\": \"Змінити накопичувачі?\",\n  \"onboarding.raid.ssd-in-slot\": \"Один <highlight>{{size}}</highlight> SSD у <highlight>слоті {{slot}}</highlight>\",\n  \"onboarding.raid.ssd-label\": \"SSD {{number}}\",\n  \"onboarding.raid.ssd-tray-alt\": \"Лоток SSD\",\n  \"onboarding.raid.ssds-found\": \"У вашому Umbrel Pro знайдено такі SSD\",\n  \"onboarding.raid.storage\": \"Сховище\",\n  \"onboarding.raid.storage-label\": \"Сховище\",\n  \"onboarding.raid.success.storage-info\": \"Сховище {{available}}\",\n  \"onboarding.raid.success.storage-info-failsafe\": \"Сховище {{available}} · FailSafe {{failsafe}}\",\n  \"onboarding.raid.try-again\": \"Спробувати ще раз\",\n  \"onboarding.raid.wasted\": \"Непридатне\",\n  \"onboarding.restore-long\": \"Відновити мій Umbrel\",\n  \"onboarding.restore-short\": \"Відновити\",\n  \"onboarding.start.continue\": \"Почати\",\n  \"onboarding.start.subtitle\": \"Ваш домашній хмарний сервер готовий до налаштування.\",\n  \"onboarding.start.title\": \"Ласкаво просимо до umbrelOS\",\n  \"open\": \"Відкрити\",\n  \"open-live-usage\": \"Відкрити Живе використання\",\n  \"password\": \"Пароль\",\n  \"preferences\": \"Налаштування\",\n  \"raid-error.description\": \"Ваша система зберігання не змогла запуститися належним чином. Перевірте стан ваших SSD нижче та виконайте кроки з усунення неполадок. Якщо проблема не зникне, деякі пошкоджені SSD може знадобитися замінити.\",\n  \"raid-error.factory-reset-dialog.description\": \"Це видалить усі дані на вашому Umbrel Pro і відновить заводські налаштування. Цю дію неможливо скасувати.\",\n  \"raid-error.factory-reset-dialog.title\": \"Скинути до заводських налаштувань?\",\n  \"raid-error.factory-reset-failed\": \"Не вдалося скинути до заводських налаштувань\",\n  \"raid-error.health-warning\": \"Попередження про стан\",\n  \"raid-error.missing-ssd-multiple\": \"{{count}} SSD не відповідають\",\n  \"raid-error.missing-ssd-one\": \"1 SSD не відповідає\",\n  \"raid-error.shutdown-dialog.description\": \"Вимкніть Umbrel Pro, переконайтеся, що всі SSD правильно встановлені у своїх слотах, потім увімкніть пристрій знову.\",\n  \"raid-error.shutdown-dialog.title\": \"Вимкнути для перевірки дисків?\",\n  \"raid-error.ssd-in-slot\": \"Один <highlight>{{size}}</highlight> SSD у <highlight>слоті {{slot}}</highlight>\",\n  \"raid-error.step-check-connections.button\": \"Вимкнути\",\n  \"raid-error.step-check-connections.description\": \"Вимкніть живлення і перевірте, що всі SSD правильно встановлені у своїх слотах.\",\n  \"raid-error.step-check-connections.title\": \"Перевірте підключення SSD\",\n  \"raid-error.step-factory-reset.button\": \"Скинути до заводських налаштувань\",\n  \"raid-error.step-factory-reset.description\": \"Крайній захід, якщо нічого іншого не допомагає. Це видалить усі дані.\",\n  \"raid-error.step-factory-reset.title\": \"Скидання до заводських налаштувань\",\n  \"raid-error.step-restart.button\": \"Перезавантажити\",\n  \"raid-error.step-restart.description\": \"Швидкий перший крок, який часто допомагає\",\n  \"raid-error.step-restart.title\": \"Спробуйте перезавантажити\",\n  \"raid-error.title\": \"Виявлено проблему зі сховищем\",\n  \"read-less\": \"Читати менше\",\n  \"read-more\": \"Читати більше\",\n  \"reconnect\": \"Підключитися знову\",\n  \"redirect.to-home\": \"Завантаження...\",\n  \"redirect.to-login\": \"Завантаження...\",\n  \"redirect.to-onboarding\": \"Завантаження...\",\n  \"redirect.to-raid-error\": \"Завантаження...\",\n  \"reload\": \"Оновити\",\n  \"remote-tor-access\": \"Віддалений доступ через Tor\",\n  \"reset\": \"Скинути\",\n  \"restart\": \"Перезапустити\",\n  \"restart.confirm.submit\": \"Перезапустити\",\n  \"restart.confirm.title\": \"Ви впевнені, що хочете перезапустити Umbrel?\",\n  \"restart.restarting\": \"Перезапуск\",\n  \"restart.restarting-message\": \"Будь ласка, не оновлюйте цю сторінку і не вимикайте ваш Umbrel, доки він перезапускається.\",\n  \"rewind\": \"Rewind\",\n  \"rewind.files-as-of\": \"Ваші файли станом на\",\n  \"rewind.loading-snapshots\": \"Завантаження знімків...\",\n  \"rewind.now\": \"Зараз\",\n  \"rewind.preflight.description\": \"Знайдіть файли й папки з попередніх резервних копій та відновіть їх у теперішній стан.\",\n  \"rewind.preflight.enable-backups\": \"Увімкніть Backups у Налаштуваннях, щоб почати використовувати Rewind\",\n  \"rewind.restore-complete\": \"Відновлення завершено\",\n  \"rewind.restore-error-description\": \"Спробуйте ще раз.\",\n  \"rewind.restore-failed\": \"Не вдалося відновити\",\n  \"rewind.restore-running-description\": \"Не закривайте й не оновлюйте цю сторінку, поки відновлення не завершиться\",\n  \"rewind.restore-selected\": \"Відновити вибране\",\n  \"rewind.restore-success-description\": \"Ваші файли відновлено\",\n  \"rewind.restoring\": \"Відновлення\",\n  \"rewind.snapshots-count_one\": \"{{count}} резервна копія з\",\n  \"rewind.snapshots-count_other\": \"{{count}} резервних копій з\",\n  \"search\": \"Пошук\",\n  \"settings\": \"Налаштування\",\n  \"settings.app-store-preferences.title\": \"Налаштування App Store\",\n  \"settings.contact-support\": \"Потрібна допомога? <linked>Зв'яжіться з підтримкою.</linked>\",\n  \"settings.file-sharing\": \"Спільний доступ до файлів\",\n  \"settings.file-sharing.add-folder\": \"Додати\",\n  \"settings.file-sharing.add-folder-title\": \"Виберіть папку для спільного доступу\",\n  \"settings.file-sharing.choice-entire-description\": \"Надайте спільний доступ до всіх файлів на вашому Umbrel\",\n  \"settings.file-sharing.choice-entire-title\": \"Усе\",\n  \"settings.file-sharing.choice-heading\": \"Що ви хочете надати у спільний доступ?\",\n  \"settings.file-sharing.choice-specific-description\": \"Оберіть папки для спільного доступу\",\n  \"settings.file-sharing.choice-specific-title\": \"Окремі папки\",\n  \"settings.file-sharing.choice-subtitle\": \"Отримуйте доступ до файлів і папок у стилі Dropbox — як мережеві папки на вашому комп'ютері чи телефоні\",\n  \"settings.file-sharing.configure\": \"Налаштувати\",\n  \"settings.file-sharing.description\": \"Отримуйте доступ до файлів у стилі Dropbox як мережеву папку (SMB) на інших пристроях\",\n  \"settings.file-sharing.home-shared-note\": \"Вся папка \\\"{{homeDirectoryName}}\\\" надана у спільний доступ. Окремим папкам не потрібно окремого надання доступу.\",\n  \"settings.file-sharing.share-entire-home-dir\": \"Надати спільний доступ до всієї папки Home\",\n  \"settings.file-sharing.share-entire-home-dir-description\": \"Отримуйте доступ до всіх файлів і папок у \\\"{{homeDirectoryName}}\\\" з інших пристроїв у вашій мережі\",\n  \"settings.file-sharing.shared-folders\": \"Спільні папки\",\n  \"show-details\": \"Показати деталі\",\n  \"shut-down\": \"Вимкнути\",\n  \"shut-down.complete\": \"Вимкнення завершено\",\n  \"shut-down.complete-text\": \"Тепер ви можете відключити пристрій від живлення.\",\n  \"shut-down.confirm.submit\": \"Вимкнути\",\n  \"shut-down.confirm.title\": \"Ви впевнені, що хочете вимкнути Umbrel?\",\n  \"shut-down.failed\": \"Не вдалося вимкнути: {{message}}\",\n  \"shut-down.shutting-down\": \"Вимкнення\",\n  \"shut-down.shutting-down-message\": \"Будь ласка, не оновлюйте цю сторінку і не вимикайте ваш Umbrel, доки він вимикається.\",\n  \"software-update.callout\": \"Будь ласка, не оновлюйте цю сторінку і не вимикайте ваш Umbrel під час оновлення.\",\n  \"software-update.check\": \"Перевірити наявність оновлень\",\n  \"software-update.checking\": \"Перевірка наявності оновлень...\",\n  \"software-update.current-running\": \"Ви використовуєте\",\n  \"software-update.failed\": \"Не вдалося оновити\",\n  \"software-update.failed-to-check\": \"Не вдалося перевірити наявність оновлень\",\n  \"software-update.failed.retry\": \"Спробувати ще раз\",\n  \"software-update.install-now\": \"Встановити зараз\",\n  \"software-update.new-version\": \"Доступна нова {{name}} для встановлення\",\n  \"software-update.on-latest\": \"У вас найновіша версія umbrelOS\",\n  \"software-update.see-whats-new\": \"Дивіться <linked>що нового</linked>\",\n  \"software-update.title\": \"Оновлення програмного забезпечення\",\n  \"software-update.updating-to\": \"Оновлення до {{name}}\",\n  \"software-update.view\": \"Переглянути\",\n  \"something-left\": \"Залишилося {{left}}\",\n  \"something-went-wrong\": \"⚠ Щось пішло не так\",\n  \"start\": \"Почати\",\n  \"stop\": \"Зупинити\",\n  \"storage\": \"Зберігання\",\n  \"storage-manager\": \"Менеджер сховища\",\n  \"storage-manager.add\": \"Додати\",\n  \"storage-manager.add-to-raid.add-ssd\": \"Додати SSD\",\n  \"storage-manager.add-to-raid.available\": \"Доступно:\",\n  \"storage-manager.add-to-raid.description\": \"Виявлено новий SSD, готовий до додавання.\",\n  \"storage-manager.add-to-raid.enable-failsafe\": \"Увімкнути FailSafe\",\n  \"storage-manager.add-to-raid.failed-add\": \"Не вдалося додати SSD\",\n  \"storage-manager.add-to-raid.failed-enable-failsafe\": \"Не вдалося ввімкнути FailSafe\",\n  \"storage-manager.add-to-raid.failsafe-label\": \"FailSafe:\",\n  \"storage-manager.add-to-raid.info-capacity-added\": \"Ваш новий <highlight>{{size}}</highlight> SSD буде додано до доступного простору.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-available\": \"Ваш новий <highlight>{{size}}</highlight> SSD додасть <highlight>{{available}}</highlight> доступного простору.\",\n  \"storage-manager.add-to-raid.info-capacity-adds-both\": \"Ваш новий <highlight>{{size}}</highlight> SSD додасть <highlight>{{available}}</highlight> доступного простору та <highlight>{{protection}}</highlight> для захисту даних.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only\": \"Ваш новий <highlight>{{size}}</highlight> SSD додасть <highlight>{{protection}}</highlight> для захисту даних.\",\n  \"storage-manager.add-to-raid.info-capacity-protection-only-full\": \"Ваш новий <highlight>{{size}}</highlight> SSD буде використаний повністю для захисту даних.\",\n  \"storage-manager.add-to-raid.info-data-safe\": \"Ваші дані будуть у безпеці, якщо вийде з ладу будь-який один SSD.\",\n  \"storage-manager.add-to-raid.info-no-protection\": \"Якщо SSD відмовить, ви можете втратити свої дані.\",\n  \"storage-manager.add-to-raid.info-total-wasted\": \"<wasted>{{size}}</wasted> загалом непридатно через різні розміри SSD.\",\n  \"storage-manager.add-to-raid.info-wasted\": \"<wasted>{{size}}</wasted> буде непридатним через різні розміри SSD.\",\n  \"storage-manager.add-to-raid.recommended\": \"Рекомендовано\",\n  \"storage-manager.add-to-raid.recommended-inline\": \"(рекомендується)\",\n  \"storage-manager.add-to-raid.restart-active-tasks\": \"Усі активні завдання будуть перервані\",\n  \"storage-manager.add-to-raid.restart-after\": \"Після перезапуску налаштування FailSafe завершиться автоматично і ви зможете продовжити звичайне використання.\",\n  \"storage-manager.add-to-raid.restart-during\": \"Під час перезапуску:\",\n  \"storage-manager.add-to-raid.restart-intro\": \"Під час цього процесу ви можете продовжувати використовувати umbrelOS як зазвичай. Однак на 50% прогресу ваш Umbrel автоматично перезавантажиться.\",\n  \"storage-manager.add-to-raid.restart-required\": \"Потрібне перезавантаження системи\",\n  \"storage-manager.add-to-raid.restart-ui-inaccessible\": \"umbrelOS буде тимчасово недоступний\",\n  \"storage-manager.add-to-raid.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD у <highlight>слоті {{slot}}</highlight>\",\n  \"storage-manager.add-to-raid.title\": \"Додати SSD до сховища\",\n  \"storage-manager.add-to-raid.too-small\": \"SSD занадто малий\",\n  \"storage-manager.add-to-raid.too-small-description\": \"Цей SSD ({{deviceSize}}) менший за найменший SSD, що зараз встановлений ({{minSize}}). FailSafe вимагає, щоб усі SSD були щонайменше такого ж розміру, як найменший використовуваний SSD.\",\n  \"storage-manager.add-to-raid.understand-continue\": \"Зрозуміло, продовжити\",\n  \"storage-manager.add-to-raid.warning-failsafe-now-only\": \"Наявність більше ніж одного SSD означає, що FailSafe можна увімкнути лише зараз. Пізніше ви не зможете його увімкнути.\",\n  \"storage-manager.add-to-raid.wasted-label\": \"Непридатне:\",\n  \"storage-manager.available-storage\": \"Доступне сховище\",\n  \"storage-manager.description\": \"Перегляньте сховище, стан і налаштування ваших SSD\",\n  \"storage-manager.empty\": \"Порожньо\",\n  \"storage-manager.failsafe-transition-failed\": \"Не вдалося ввімкнути FailSafe\",\n  \"storage-manager.for-failsafe\": \"Для FailSafe\",\n  \"storage-manager.health.checksum-errors\": \"Помилки контрольної суми: {{count}}\",\n  \"storage-manager.health.critical\": \"Критично\",\n  \"storage-manager.health.critical-threshold\": \"Критичний поріг\",\n  \"storage-manager.health.current-temperature\": \"Поточна температура\",\n  \"storage-manager.health.estimated-life\": \"Орієнтовний залишок ресурсу\",\n  \"storage-manager.health.general\": \"Загальне\",\n  \"storage-manager.health.health-status\": \"Стан\",\n  \"storage-manager.health.low\": \"Низький\",\n  \"storage-manager.health.model-and-capacity\": \"Модель і розмір\",\n  \"storage-manager.health.overheating\": \"Перегрів\",\n  \"storage-manager.health.raid-failed-advice\": \"Цей SSD має проблему. Вимкніть Umbrel і перевірте підключення SSD. Якщо проблема не зникне, можливо, SSD потрібно замінити.\",\n  \"storage-manager.health.read-errors\": \"Помилки читання: {{count}}\",\n  \"storage-manager.health.serial-number\": \"Серійний номер\",\n  \"storage-manager.health.status-healthy\": \"Добрий стан\",\n  \"storage-manager.health.status-unhealthy\": \"Поганий стан\",\n  \"storage-manager.health.status-unknown\": \"Невідомо\",\n  \"storage-manager.health.temperature\": \"Температура\",\n  \"storage-manager.health.title\": \"Стан SSD\",\n  \"storage-manager.health.warning-life-advice\": \"Розгляньте можливість заміни цього SSD найближчим часом.\",\n  \"storage-manager.health.warning-life-message\": \"Залишилося лише {{percent}}% ресурсу\",\n  \"storage-manager.health.warning-temp-advice\": \"Переконайтеся, що у вашого Umbrel Pro хороший обдув і SSD встановлено правильно.\",\n  \"storage-manager.health.warning-temp-critical\": \"Температура критична ({{temperature}})\",\n  \"storage-manager.health.warning-temp-overheating\": \"Диск перегрівається ({{temperature}})\",\n  \"storage-manager.health.warning-threshold\": \"Поріг попередження\",\n  \"storage-manager.health.warning-unhealthy-advice\": \"Цей SSD може скоро вийти з ладу. Розгляньте його заміну.\",\n  \"storage-manager.health.warning-unhealthy-message\": \"У цьому SSD може бути проблема\",\n  \"storage-manager.health.warnings\": \"Попередження\",\n  \"storage-manager.health.wear\": \"Знос\",\n  \"storage-manager.health.write-errors\": \"Помилки запису: {{count}}\",\n  \"storage-manager.install-ssd.description\": \"Додайте ще SSD, щоб розширити сховище\",\n  \"storage-manager.install-ssd.step-insert\": \"Вставте нові SSD у порожні слоти\",\n  \"storage-manager.install-ssd.step-power-on\": \"Увімкніть ваш {{deviceName}}\",\n  \"storage-manager.install-ssd.step-remove-bottom-cover\": \"Зніміть магнітну нижню кришку\",\n  \"storage-manager.install-ssd.step-replace-bottom-cover\": \"Поверніть нижню кришку на місце\",\n  \"storage-manager.install-ssd.step-return\": \"Поверніться сюди, щоб додати SSD до свого сховища\",\n  \"storage-manager.install-ssd.step-shut-down\": \"Вимкніть ваш {{deviceName}}\",\n  \"storage-manager.install-ssd.title\": \"Додавання SSD\",\n  \"storage-manager.install-tips.image-alt\": \"Інструкція з встановлення SSD\",\n  \"storage-manager.install-tips.instructions\": \"Щоб встановити, відкрутіть фіксуючий гвинт і вставте SSD під кутом у слот. Натисніть SSD униз, поки він не ляже на опору гвинта, після чого закріпіть його фіксуючим гвинтом.\",\n  \"storage-manager.install-tips.toggle\": \"Забули, як вставити SSD?\",\n  \"storage-manager.manage\": \"Керувати\",\n  \"storage-manager.missing-ssd-warning\": \"Здається, один SSD відсутній. Вимкніть Umbrel і перевірте, чи всі SSD підключені. Якщо проблема триває, можливо, SSD потрібно замінити.\",\n  \"storage-manager.mode\": \"Режим\",\n  \"storage-manager.mode.failsafe\": \"FailSafe\",\n  \"storage-manager.mode.failsafe.description\": \"Захищає ваші дані на випадок відмови SSD. Якщо SSD мають різний розмір, додатковий простір на більших буде невикористаним.\",\n  \"storage-manager.mode.failsafe.info-description\": \"FailSafe захищає ваші дані, зберігаючи їх копії на різних SSD. Якщо якийсь SSD відмовить, ваші дані залишаться в безпеці і їх можна буде відновити після встановлення заміни.\",\n  \"storage-manager.mode.failsafe.info-title\": \"Про FailSafe\",\n  \"storage-manager.mode.full-storage\": \"Повне сховище\",\n  \"storage-manager.mode.full-storage.description\": \"Використовує весь доступний простір SSD разом. Якщо SSD відмовить, ви можете втратити дані.\",\n  \"storage-manager.mode.full-storage.info-description\": \"Full Storage об'єднує всі ваші SSD в один великий простір, даючи максимум сховища. Однак якщо якийсь SSD відмовить, усі ваші дані будуть втрачені.\",\n  \"storage-manager.mode.full-storage.info-title\": \"Про Повне сховище\",\n  \"storage-manager.mode.switch-from-failsafe-unavailable\": \"Перехід з FailSafe у режим Full Storage вимагає резервного копіювання даних, скидання пристрою до заводських налаштувань і відновлення з резервної копії.\",\n  \"storage-manager.mode.switch-to-failsafe-unavailable\": \"Коли у режимі Full Storage використовується кілька SSD, ваші дані розподілені по всіх дисках. Перехід у режим FailSafe вимагає резервного копіювання, скидання до заводських налаштувань і відновлення.\",\n  \"storage-manager.mode.why-cant-switch\": \"Чому я не можу переключитися?\",\n  \"storage-manager.operation-in-progress.shutdown-description\": \"Можна безпечно вимкнути пристрій. Операція призупиниться і продовжиться після перезавантаження, але має завершитися, перш ніж Ви зможете вносити інші зміни.\",\n  \"storage-manager.operation-in-progress.shutdown-title\": \"Ваше сховище оновлюється\",\n  \"storage-manager.operation-in-progress.wait-description\": \"Зачекайте, поки поточна операція не завершиться, перш ніж вносити інші зміни.\",\n  \"storage-manager.operation-in-progress.wait-title\": \"Ваше сховище оновлюється\",\n  \"storage-manager.operation.adding-ssd\": \"Додавання SSD...\",\n  \"storage-manager.operation.enabling-failsafe\": \"Увімкнення FailSafe...\",\n  \"storage-manager.operation.expanding\": \"Розширення сховища...\",\n  \"storage-manager.operation.rebuilding\": \"Відновлення даних...\",\n  \"storage-manager.operation.replacing\": \"Заміна накопичувача...\",\n  \"storage-manager.operation.restarting\": \"Перезапуск...\",\n  \"storage-manager.operation.starting\": \"Запуск...\",\n  \"storage-manager.operation.syncing-restarts\": \"Синхронізація даних • Перезавантаження на 50%\",\n  \"storage-manager.raid-status.degraded\": \"У режимі деградації\",\n  \"storage-manager.raid-status.failed\": \"Вийшов з ладу\",\n  \"storage-manager.raid-status.offline\": \"Офлайн\",\n  \"storage-manager.raid-status.online\": \"Онлайн\",\n  \"storage-manager.raid-status.removed\": \"Вилучено\",\n  \"storage-manager.raid-status.unavailable\": \"Недоступно\",\n  \"storage-manager.replace\": \"Замінити\",\n  \"storage-manager.replace-failed.degraded\": \"Захист FailSafe знижено\",\n  \"storage-manager.replace-failed.degraded-description\": \"У сховищі FailSafe відсутній один SSD. Замініть його, щоб відновити повний захист.\",\n  \"storage-manager.replace-failed.description\": \"Використайте цей SSD, щоб відновити захист FailSafe.\",\n  \"storage-manager.replace-failed.error\": \"Не вдалося почати заміну\",\n  \"storage-manager.replace-failed.replace-now\": \"Замінити зараз\",\n  \"storage-manager.replace-failed.ssd-in-slot\": \"<highlight>{{size}}</highlight> SSD у слоті {{slot}}\",\n  \"storage-manager.replace-failed.step-protected\": \"Після завершення ваші дані знову будуть повністю захищені\",\n  \"storage-manager.replace-failed.step-rebuild\": \"Дані будуть відновлені на новому SSD\",\n  \"storage-manager.replace-failed.step-time\": \"Це може зайняти деякий час залежно від обсягу ваших даних\",\n  \"storage-manager.replace-failed.title\": \"Замінити SSD\",\n  \"storage-manager.replace-failed.too-small\": \"SSD замалий\",\n  \"storage-manager.replace-failed.too-small-description\": \"Цей SSD ({{deviceSize}}) має менший обсяг, ніж мінімально необхідний ({{minSize}}) для вашого сховища FailSafe.\",\n  \"storage-manager.replace-failed.what-happens\": \"Що відбудеться далі:\",\n  \"storage-manager.ssd-failing\": \"Виходить з ладу\",\n  \"storage-manager.swap\": \"Заміна\",\n  \"storage-manager.swap.data-erased-description\": \"Режим Full Storage не забезпечує захисту даних. Всі дані на вашому {{deviceName}} будуть видалені під час скидання до заводських налаштувань. Обов'язково зробіть резервну копію перед цим.\",\n  \"storage-manager.swap.data-protected\": \"Ваші дані захищені\",\n  \"storage-manager.swap.data-protected-description\": \"Якщо FailSafe увімкнено, ви можете замінити будь-який один SSD без втрати даних. Резервна копія не потрібна.\",\n  \"storage-manager.swap.data-will-be-erased\": \"Дані будуть видалені\",\n  \"storage-manager.swap.description-failsafe\": \"Замініть диск у вашому сховищі FailSafe.\",\n  \"storage-manager.swap.description-full-storage\": \"Замініть диск у вашій конфігурації Full Storage.\",\n  \"storage-manager.swap.description-no-free-slot\": \"У режимі Full Storage за наявності всіх зайнятих слотів заміна SSD вимагає повного резервного копіювання та відновлення.\",\n  \"storage-manager.swap.description-replace\": \"Міграція ваших даних на новий SSD, а потім видалення старого.\",\n  \"storage-manager.swap.failed-to-start\": \"Не вдалося запустити заміну\",\n  \"storage-manager.swap.no-data-loss\": \"Без втрати даних\",\n  \"storage-manager.swap.no-data-loss-description\": \"Ваші дані будуть скопійовані на новий SSD. Після завершення ви зможете безпечно вилучити старий.\",\n  \"storage-manager.swap.safe-swap-available\": \"Доступна безпечна заміна\",\n  \"storage-manager.swap.safe-swap-description\": \"Оскільки у вас є порожній слот, ви можете спочатку додати новий SSD і перенести дані перед видаленням старого. Резервна копія не потрібна.\",\n  \"storage-manager.swap.select-new-ssd\": \"Виберіть новий SSD для використання:\",\n  \"storage-manager.swap.ssd-in-slot\": \"{{size}} SSD у слоті {{slot}}\",\n  \"storage-manager.swap.step-backup\": \"Зробіть резервну копію даних\",\n  \"storage-manager.swap.step-backup-description\": \"Перейдіть у Налаштування → Backups і створіть резервну копію всіх даних.\",\n  \"storage-manager.swap.step-data-copied\": \"Дані будуть скопійовані зі старого SSD на новий\",\n  \"storage-manager.swap.step-factory-reset\": \"Скидання до заводських налаштувань\",\n  \"storage-manager.swap.step-factory-reset-description\": \"Перейдіть у Налаштування → Додатково → Скидання до заводських налаштувань, щоб стерти ваш {{deviceName}}.\",\n  \"storage-manager.swap.step-insert-new-ssd\": \"Вставте новий SSD у порожній слот\",\n  \"storage-manager.swap.step-may-take-while\": \"Це може зайняти деякий час залежно від обсягу ваших даних\",\n  \"storage-manager.swap.step-power-on\": \"Увімкніть ваш {{deviceName}}\",\n  \"storage-manager.swap.step-remove-bottom-cover\": \"Зніміть магнітну нижню кришку\",\n  \"storage-manager.swap.step-remove-old\": \"Після завершення вимкніть пристрій і витягніть {{ssd}}\",\n  \"storage-manager.swap.step-replace-bottom-cover\": \"Поверніть нижню кришку на місце\",\n  \"storage-manager.swap.step-restore\": \"Відновіть дані\",\n  \"storage-manager.swap.step-restore-description\": \"Перейдіть у Налаштування → Backups і відновіть з резервної копії.\",\n  \"storage-manager.swap.step-return-to-storage-manager\": \"Поверніться сюди в Керування сховищем, щоб підтвердити заміну та додати новий SSD до сховища\",\n  \"storage-manager.swap.step-return-to-swap\": \"Поверніться сюди до Керування сховищем і натисніть «Заміна» ще раз, щоб почати процес заміни\",\n  \"storage-manager.swap.step-setup-new-storage\": \"Налаштуйте нове сховище\",\n  \"storage-manager.swap.step-setup-new-storage-description\": \"Увімкніть ваш {{deviceName}} і завершіть процес налаштування з новим SSD.\",\n  \"storage-manager.swap.step-shut-down\": \"Вимкніть ваш {{deviceName}}\",\n  \"storage-manager.swap.step-shut-down-and-swap\": \"Вимкніть і замініть {{ssd}}\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-other\": \"Вимкніть живлення, відкрийте пристрій, замініть SSD і зберіть назад.\",\n  \"storage-manager.swap.step-shut-down-and-swap-description-pro\": \"Вимкніть живлення, зніміть нижню кришку, замініть SSD і закрийте кришку.\",\n  \"storage-manager.swap.step-swap-ssd\": \"Замініть {{ssd}} на новий того ж розміру\",\n  \"storage-manager.swap.too-small\": \"Занадто малий (потрібно {{size}})\",\n  \"storage-manager.swap.what-happens-next\": \"Що буде далі:\",\n  \"storage-manager.total-capacity-added\": \"Загальна додана ємність\",\n  \"storage-manager.umbrel-pro\": \"Umbrel Pro\",\n  \"storage-manager.used\": \"Використано\",\n  \"storage-manager.wasted\": \"Непридатне для використання\",\n  \"storage-manager.wasted-size\": \"{{size}} непридатне для використання\",\n  \"storage.full\": \"Пам'ять заповнена\",\n  \"storage.low\": \"Низький рівень зберігання\",\n  \"temperature\": \"Температура\",\n  \"temperature.dangerously-hot\": \"Дуже гаряче\",\n  \"temperature.nice\": \"Приємно\",\n  \"temperature.normal\": \"Нормально\",\n  \"temperature.too-hot-suggestion\": \"Розгляньте можливість зміни середовища вашого пристрою.\",\n  \"temperature.warm\": \"Тепло\",\n  \"terminal\": \"Термінал\",\n  \"terminal-description\": \"Виконуйте власні команди в umbrelOS або в межах програми\",\n  \"terminal.app\": \"Програма\",\n  \"terminal.app-description\": \"Виконуйте власні команди в межах певної програми\",\n  \"terminal.umbrelos-description\": \"Виконуйте власні команди в umbrelOS\",\n  \"tor-description\": \"Доступ до вашого Umbrel з будь-якого місця за допомогою браузера Tor\",\n  \"tor-enabled-description\": \"Доступ до вашого Umbrel з будь-якого місця через браузер Tor за наступною URL-адресою:\",\n  \"tor-error\": \"Не вдалося оновити налаштування Tor: {{message}}\",\n  \"tor.disable.description\": \"Це може зайняти кілька хвилин\",\n  \"tor.disable.progress\": \"Вимкнення віддаленого доступу через Tor\",\n  \"tor.enable.description\": \"Це може зайняти кілька хвилин\",\n  \"tor.enable.mobile.switch-label\": \"Увімкнути віддалений доступ Tor\",\n  \"tor.hidden-service\": \"URL прихованої служби Tor\",\n  \"troubleshoot\": \"Вирішення проблем\",\n  \"troubleshoot-description\": \"Вирішити проблеми з umbrelOS або програмою\",\n  \"troubleshoot-no-logs-yet\": \"Ще немає журналів\",\n  \"troubleshoot-pick-title\": \"Вирішення проблем\",\n  \"troubleshoot.app\": \"Програма\",\n  \"troubleshoot.app-description\": \"Переглянути журнали програми, встановленої на вашому Umbrel\",\n  \"troubleshoot.app-download\": \"Завантажити журнали {{app}}\",\n  \"troubleshoot.share-with-umbrel-support\": \"Поділитися з підтримкою Umbrel\",\n  \"troubleshoot.system-download\": \"Завантажити {{label}}\",\n  \"troubleshoot.umbrelos-description\": \"Переглянути журнали umbrelOS\",\n  \"troubleshoot.umbrelos-logs\": \"Журнали umbrelOS\",\n  \"trpc.backend-unavailable\": \"Помилка: не вдалося підключитися до системного API\",\n  \"trpc.checking-backend\": \"Завантаження...\",\n  \"try-again\": \"Спробувати ще раз\",\n  \"umbrel\": \"Umbrel\",\n  \"umbrelos\": \"umbrelOS\",\n  \"unknown\": \"Невідомо\",\n  \"unknown-app\": \"Невідома програма\",\n  \"unknown-error\": \"Невідома помилка\",\n  \"uptime\": \"Час роботи\",\n  \"url\": \"URL\",\n  \"wallpaper\": \"Фон\",\n  \"wallpaper-description\": \"Ваш фон та тема Umbrel\",\n  \"whats-new.continue\": \"Продовжити\",\n  \"whats-new.feature-1.description\": \"Налаштуйте автоматизовані, зашифровані Backups для всього вашого Umbrel на зовнішній USB-накопичувач, на NAS або на інший Umbrel.\",\n  \"whats-new.feature-2.description\": \"Поверніться назад у часі, щоб відновити окремі файли та папки з попередніх Backups.\",\n  \"whats-new.feature-3.description\": \"Або відновіть весь ваш Umbrel разом з усіма додатками, файлами та даними.\",\n  \"whats-new.feature-4.description\": \"Підключіть NAS або інший Umbrel, і отримайте доступ до його сховища через Files.\",\n  \"whats-new.feature-4.title\": \"Мережеві пристрої\",\n  \"whats-new.feature-5.description\": \"Підключайте зовнішні USB-накопичувачі (на Umbrel Home або на будь-якому пристрої Intel чи AMD) та отримуйте до них доступ через Files.\",\n  \"whats-new.feature-5.helper-text\": \"Не підтримується на пристроях Raspberry Pi через можливі проблеми з живленням.\",\n  \"whats-new.feature-5.title\": \"Зовнішнє сховище\",\n  \"whats-new.next\": \"Далі\",\n  \"whats-new.title\": \"Що нового в {{version}}\",\n  \"widget.progress.in-progress\": \"В процесі\",\n  \"widgets.edit.select-up-to-3-widgets\": \"Виберіть до 3 віджетів\",\n  \"widgets.install-an-app-before-using-widgets\": \"Встановіть програму, щоб почати налаштування головного екрану за допомогою віджетів.\",\n  \"wifi\": \"Wi-Fi\",\n  \"wifi-connect-insecure-message\": \"Відкриті мережі можуть бути небезпечними\",\n  \"wifi-connection-failed\": \"Не вдалося підключитися\",\n  \"wifi-dangerous-change-confirmation-description\": \"Зміна мережі Wi-Fi може відключити вас від вашого Umbrel. Для повторного підключення переконайтеся, що і ваш Umbrel, і пристрій, з якого ви отримуєте до нього доступ, знаходяться в одній мережі.\",\n  \"wifi-dangerous-change-confirmation-title\": \"Ви впевнені, що хочете змінити мережу Wi-Fi?\",\n  \"wifi-dangerous-disable-confirmation-description\": \"Вимкнення Wi-Fi може відключити вас від вашого Umbrel. Для повторного підключення підключіть Ethernet-кабель до вашого Umbrel і переконайтеся, що і ваш Umbrel, і пристрій, з якого ви отримуєте до нього доступ, знаходяться в одній мережі.\",\n  \"wifi-dangerous-disable-confirmation-title\": \"Ви впевнені, що хочете вимкнути Wi-Fi?\",\n  \"wifi-description\": \"Підключіть ваш пристрій до Wi-Fi мережі\",\n  \"wifi-description-long\": \"Ваш пристрій залишатиметься підключеним до обраної Wi-Fi мережі, навіть якщо Ethernet-кабель буде відключено, і автоматично підключатиметься до Wi-Fi при запуску.\",\n  \"wifi-no-networks-message\": \"Wi-Fi мережі не знайдено\",\n  \"wifi-searching\": \"Пошук Wi-Fi мереж...\",\n  \"wifi-unsupported-device-description\": \"Wi-Fi не підтримується на цьому пристрої. Це може бути через відсутність або несумісність бездротового адаптера.\",\n  \"wifi-view-networks\": \"Переглянути мережі\"\n}"
  },
  {
    "path": "packages/ui/public/site.webmanifest",
    "content": "{\n\t\"name\": \"\",\n\t\"short_name\": \"\",\n\t\"icons\": [\n\t\t{\n\t\t\t\"src\": \"/favicon/android-chrome-192x192.png\",\n\t\t\t\"sizes\": \"192x192\",\n\t\t\t\"type\": \"image/png\"\n\t\t},\n\t\t{\n\t\t\t\"src\": \"/favicon/android-chrome-512x512.png\",\n\t\t\t\"sizes\": \"512x512\",\n\t\t\t\"type\": \"image/png\"\n\t\t}\n\t],\n\t\"theme_color\": \"#000000\",\n\t\"background_color\": \"#000000\",\n\t\"display\": \"standalone\"\n}\n"
  },
  {
    "path": "packages/ui/src/components/app-icon.tsx",
    "content": "import {HTMLProps, useEffect, useState} from 'react'\n\nimport {cn} from '@/lib/utils'\nimport {APP_ICON_PLACEHOLDER_SRC} from '@/modules/desktop/app-icon'\n\ntype AppIconProps = {src?: string; size?: number; ref?: React.Ref<HTMLImageElement>} & HTMLProps<HTMLImageElement>\n\nexport function AppIcon({src, style, size, className, ref, ...props}: AppIconProps) {\n\t// Keep a local copy of the image `src` so we can gracefully fall back to a\n\t// placeholder if the provided source fails to load. Because `src` can change\n\t// (for example, when navigating between different apps without remounting the\n\t// component), we need to update the local state whenever the prop changes.\n\tconst [imgSrc, setImgSrc] = useState(src || APP_ICON_PLACEHOLDER_SRC)\n\n\t// If the `src` prop updates, refresh `imgSrc` so the new icon is displayed.\n\tuseEffect(() => {\n\t\tsetImgSrc(src || APP_ICON_PLACEHOLDER_SRC)\n\t}, [src])\n\n\t// Not using `FadeImg` because we have a placeholder and `FadeImg` doesn't support placeholder images\n\t// Also not fading any other way because we want color-thief to work by picking up the color\n\treturn (\n\t\t<img\n\t\t\tsrc={imgSrc}\n\t\t\talt=''\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t'aspect-square shrink-0 border-[1px] border-slate-100/10 bg-white/10 bg-cover bg-center',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\tonError={() => setImgSrc(APP_ICON_PLACEHOLDER_SRC)}\n\t\t\tstyle={{\n\t\t\t\t...style,\n\t\t\t\twidth: size,\n\t\t\t\theight: size,\n\t\t\t\tminWidth: size,\n\t\t\t\tminHeight: size,\n\t\t\t}}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/caret-right.tsx",
    "content": "const SvgComponent = ({className}: {className?: string}) => (\n\t<svg xmlns='http://www.w3.org/2000/svg' width={27} height={26} fill='none' className={className}>\n\t\t<g clipPath='url(#a)'>\n\t\t\t<path fill='currentColor' d='M14.75 12.98 9.47 7.7l1.508-1.508 6.789 6.788-6.789 6.788L9.47 18.26l5.28-5.28Z' />\n\t\t</g>\n\t\t<defs>\n\t\t\t<clipPath id='a'>\n\t\t\t\t<path fill='currentColor' d='M.7.18h25.6v25.6H.7z' />\n\t\t\t</clipPath>\n\t\t</defs>\n\t</svg>\n)\nexport default SvgComponent\n"
  },
  {
    "path": "packages/ui/src/components/chevron-down.tsx",
    "content": "/**\n * Most icons have a box around them. This one's bounding box matches the icon.\n */\nexport function ChevronDown() {\n\treturn (\n\t\t<svg width='6' height='5' viewBox='0 0 6 5' fill='none' xmlns='http://www.w3.org/2000/svg'>\n\t\t\t<path\n\t\t\t\td='M5.29688 0.789062L6 1.49219L3 4.49219L0 1.49219L0.703125 0.789062L3 3.08594L5.29688 0.789062Z'\n\t\t\t\tfill='currentColor'\n\t\t\t/>\n\t\t</svg>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/cmdk-providers.tsx",
    "content": "import React from 'react'\n\n// Backups\nimport {BackupsCmdkSearchProvider} from '@/features/backups/cmdk-search-provider'\n// ---------------------------------------------------------------------------\n// Providers\n// ---------------------------------------------------------------------------\n\n// Files\nimport {FilesCmdkSearchProvider} from '@/features/files/cmdk-search-provider'\n\n/**\n * ---------------------------------------------------------------------------\n * Command-K Search Providers\n * ---------------------------------------------------------------------------\n *\n * Each feature that wants to surface its own search results inside the global\n * command-k component should export a small React component that adheres to\n * the `CmdkSearchProvider` signature defined below.\n *\n * The component will be rendered inside the existing `<CommandList>` context so\n * it can directly return `CommandItem` elements.\n *\n * A very small, opinionated interface is intentionally chosen to keep things\n * straightforward: we just pass the current `query` and a helper to `close`\n * the palette once the provider performs its action (navigation, etc.).\n *\n * Providers are collected in the `cmdkSearchProviders` array (see bottom of\n * file). Currently only /features/files uses this, but new features should\n * add their provider to that array – in the future this could be automated via\n * code-generation or dynamic imports, but for now an explicit list keeps the\n * coupling minimal.\n */\n\nexport interface CmdkSearchProviderProps {\n\t// The current search query coming from the command-k input.\n\tquery: string\n\t// Helper to close the command-k. Call it after executing the action\n\tclose: () => void\n}\n\nexport type CmdkSearchProvider = React.FC<CmdkSearchProviderProps>\n\nexport const cmdkSearchProviders: CmdkSearchProvider[] = [FilesCmdkSearchProvider, BackupsCmdkSearchProvider]\n"
  },
  {
    "path": "packages/ui/src/components/cmdk.tsx",
    "content": "import {useCommandState} from 'cmdk'\nimport {ComponentPropsWithoutRef, createContext, SetStateAction, useContext, useEffect, useRef, useState} from 'react'\nimport {ErrorBoundary} from 'react-error-boundary'\nimport {useNavigate} from 'react-router-dom'\nimport {range} from 'remeda'\n\n// Pluggable search providers rendered inside the command palette\n// Currently only /features/files uses this\nimport {cmdkSearchProviders} from '@/components/cmdk-providers'\nimport {CommandDialog, CommandEmpty, CommandInput, CommandItem, CommandList} from '@/components/ui/command'\nimport {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'\nimport {Separator} from '@/components/ui/separator'\nimport {LOADING_DASH} from '@/constants'\nimport {\n\tAPPS_PATH as FILES_APPS_PATH,\n\tRECENTS_PATH as FILES_RECENTS_PATH,\n\tTRASH_PATH as FILES_TRASH_PATH,\n} from '@/features/files/constants'\nimport {useDebugInstallRandomApps} from '@/hooks/use-debug-install-random-apps'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {useLaunchApp} from '@/hooks/use-launch-app'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {cn} from '@/lib/utils'\nimport {systemAppsKeyed, useApps} from '@/providers/apps'\nimport {useAvailableApps} from '@/providers/available-apps'\nimport {AppState, trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nimport {AppIcon} from './app-icon'\nimport {FadeScroller} from './fade-scroller'\nimport {DebugOnlyBare} from './ui/debug-only'\n\nconst CmdkOpenContext = createContext<{\n\topen: boolean\n\tsetOpen: (value: SetStateAction<boolean>) => void\n} | null>(null)\n\nexport function useCmdkOpen() {\n\tconst ctx = useContext(CmdkOpenContext)\n\n\tif (!ctx) throw new Error('useCmdkOpen must be used within a CommandRoot')\n\n\treturn ctx\n}\n\nexport function CmdkProvider({children}: {children: React.ReactNode}) {\n\tconst [open, setOpen] = useState(false)\n\n\t// Register Cmd+K listener once here, not in useCmdkOpen (which is called\n\t// by multiple components and would register duplicate listeners).\n\tuseEffect(() => {\n\t\tconst handler = (e: KeyboardEvent) => {\n\t\t\tif (e.key === 'k' && (e.metaKey || e.ctrlKey)) {\n\t\t\t\te.preventDefault()\n\t\t\t\tsetOpen((open) => !open)\n\t\t\t}\n\t\t}\n\t\tdocument.addEventListener('keydown', handler)\n\t\treturn () => document.removeEventListener('keydown', handler)\n\t}, [])\n\n\treturn <CmdkOpenContext value={{open, setOpen}}>{children}</CmdkOpenContext>\n}\n\nexport function CmdkMenu() {\n\tconst {open, setOpen} = useCmdkOpen()\n\n\treturn (\n\t\t<CommandDialog open={open} onOpenChange={setOpen}>\n\t\t\t<CommandInput placeholder={t('cmdk.input-placeholder')} />\n\t\t\t<Separator />\n\t\t\t<ErrorBoundary FallbackComponent={ErrorBoundaryCardFallback}>\n\t\t\t\t<CmdkContent />\n\t\t\t</ErrorBoundary>\n\t\t</CommandDialog>\n\t)\n}\n\nfunction CmdkContent() {\n\tconst {setOpen} = useCmdkOpen()\n\tconst navigate = useNavigate()\n\tconst {addLinkSearchParams} = useQueryParams()\n\tconst userApps = useApps()\n\tconst scrollRef = useRef<HTMLDivElement>(null)\n\n\t// The current search query from the command input. We pass this down to all\n\t// external search providers so they can surface their own results.\n\tconst searchQuery = useCommandState((state) => state.search)\n\tconst userQ = trpcReact.user.get.useQuery()\n\tconst launchApp = useLaunchApp()\n\tconst debugInstallRandomApps = useDebugInstallRandomApps()\n\t// We only show installed community apps here, effectively limiting available\n\t// apps to those present in the official app store\n\tconst availableApps = useAvailableApps()\n\n\tconst isLoading = userQ.isLoading || availableApps.isLoading || userApps.isLoading\n\n\tif (availableApps.isLoading) return null\n\tif (isLoading) return null\n\tif (userQ.isLoading) return null\n\tif (!userApps.userApps || !userApps.userAppsKeyed) return null\n\n\tconst readyApps = userApps.userApps.filter((app) => app.state === 'ready')\n\tconst unreadyApps = userApps.userApps.filter((app) => app.state !== 'ready')\n\t// Apps not installed yet\n\tconst installableApps = availableApps.apps.filter((app) => !userApps.userAppsKeyed?.[app.id])\n\n\treturn (\n\t\t<CommandList ref={scrollRef}>\n\t\t\t<FrequentApps onLaunchApp={() => setOpen(false)} />\n\t\t\t<CommandEmpty>{t('no-results-found')}</CommandEmpty>\n\t\t\t<CommandItem\n\t\t\t\ticon={systemAppsKeyed['UMBREL_settings'].icon}\n\t\t\t\tonSelect={() => {\n\t\t\t\t\tnavigate({pathname: '/settings', search: addLinkSearchParams({dialog: 'restart'})})\n\t\t\t\t\tsetOpen(false)\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{t('cmdk.restart-umbrel')}\n\t\t\t</CommandItem>\n\t\t\t<CommandItem\n\t\t\t\ticon={systemAppsKeyed['UMBREL_app-store'].icon}\n\t\t\t\tonSelect={() => {\n\t\t\t\t\tnavigate('/app-store?dialog=updates')\n\t\t\t\t\tsetOpen(false)\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{t('cmdk.update-all-apps')}\n\t\t\t</CommandItem>\n\t\t\t<CommandItem\n\t\t\t\ticon={systemAppsKeyed['UMBREL_settings'].icon}\n\t\t\t\tonSelect={() => {\n\t\t\t\t\tnavigate('/settings/wallpaper')\n\t\t\t\t\tsetOpen(false)\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{t('cmdk.change-wallpaper')}\n\t\t\t</CommandItem>\n\t\t\t<CommandItem\n\t\t\t\ticon={systemAppsKeyed['UMBREL_live-usage'].icon}\n\t\t\t\tonSelect={() => {\n\t\t\t\t\tnavigate(systemAppsKeyed['UMBREL_live-usage'].systemAppTo)\n\t\t\t\t\tsetOpen(false)\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{t('cmdk.live-usage')}\n\t\t\t</CommandItem>\n\t\t\t<CommandItem\n\t\t\t\ticon={systemAppsKeyed['UMBREL_widgets'].icon}\n\t\t\t\tonSelect={() => {\n\t\t\t\t\tnavigate('/edit-widgets')\n\t\t\t\t\tsetOpen(false)\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{t('cmdk.widgets')}\n\t\t\t</CommandItem>\n\t\t\t<SearchItem\n\t\t\t\ticon={systemAppsKeyed['UMBREL_home'].icon}\n\t\t\t\tvalue={systemAppsKeyed['UMBREL_home'].name}\n\t\t\t\tonSelect={() => {\n\t\t\t\t\tnavigate(systemAppsKeyed['UMBREL_home'].systemAppTo)\n\t\t\t\t\tsetOpen(false)\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{systemAppsKeyed['UMBREL_home'].name}\n\t\t\t</SearchItem>\n\t\t\t<SearchItem\n\t\t\t\ticon={systemAppsKeyed['UMBREL_app-store'].icon}\n\t\t\t\tvalue={systemAppsKeyed['UMBREL_app-store'].name}\n\t\t\t\tonSelect={() => {\n\t\t\t\t\tnavigate(systemAppsKeyed['UMBREL_app-store'].systemAppTo)\n\t\t\t\t\tsetOpen(false)\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{systemAppsKeyed['UMBREL_app-store'].name}\n\t\t\t</SearchItem>\n\t\t\t<SearchItem\n\t\t\t\ticon={systemAppsKeyed['UMBREL_files'].icon}\n\t\t\t\tvalue={systemAppsKeyed['UMBREL_files'].name}\n\t\t\t\tonSelect={() => {\n\t\t\t\t\t// TODO: THIS IS A HACK\n\t\t\t\t\t// We need a better approach to track the last visited path (possibly scroll position too?)\n\t\t\t\t\t// inside every page. We do this right now for the File app because it's has the most\n\t\t\t\t\t// UX-advantage (eg. user accidentally clicking close while they're in a deeply nested path)\n\t\t\t\t\tconst lastFilesPath = sessionStorage.getItem('lastFilesPath')\n\n\t\t\t\t\tnavigate(lastFilesPath || systemAppsKeyed['UMBREL_files'].systemAppTo)\n\t\t\t\t\tsetOpen(false)\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{systemAppsKeyed['UMBREL_files'].name}\n\t\t\t</SearchItem>\n\t\t\t<SearchItem\n\t\t\t\ticon={systemAppsKeyed['UMBREL_files'].icon}\n\t\t\t\tvalue={t('files-sidebar.recents')}\n\t\t\t\tonSelect={() => {\n\t\t\t\t\tnavigate(`/files${FILES_RECENTS_PATH}`)\n\t\t\t\t\tsetOpen(false)\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{t('files-sidebar.recents')}\n\t\t\t</SearchItem>\n\t\t\t<SearchItem\n\t\t\t\ticon={systemAppsKeyed['UMBREL_files'].icon}\n\t\t\t\tvalue={t('files-sidebar.apps')}\n\t\t\t\tonSelect={() => {\n\t\t\t\t\tnavigate(`/files${FILES_APPS_PATH}`)\n\t\t\t\t\tsetOpen(false)\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{t('files-sidebar.apps')}\n\t\t\t</SearchItem>\n\t\t\t<SearchItem\n\t\t\t\ticon={systemAppsKeyed['UMBREL_files'].icon}\n\t\t\t\tvalue={t('files-sidebar.trash')}\n\t\t\t\tonSelect={() => {\n\t\t\t\t\tnavigate(`/files${FILES_TRASH_PATH}`)\n\t\t\t\t\tsetOpen(false)\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{t('files-sidebar.trash')}\n\t\t\t</SearchItem>\n\t\t\t<SettingsSearchItem\n\t\t\t\tvalue={systemAppsKeyed['UMBREL_settings'].name}\n\t\t\t\tonSelect={() => navigate(systemAppsKeyed['UMBREL_settings'].systemAppTo)}\n\t\t\t/>\n\t\t\t<SettingsSearchItem\n\t\t\t\tvalue={t('logout')}\n\t\t\t\tonSelect={() => navigate({search: addLinkSearchParams({dialog: 'logout'})})}\n\t\t\t/>\n\t\t\t<SettingsSearchItem\n\t\t\t\tvalue={t('cmdk.shutdown-umbrel')}\n\t\t\t\tonSelect={() => navigate({pathname: 'settings', search: addLinkSearchParams({dialog: 'shutdown'})})}\n\t\t\t/>\n\t\t\t{/* ---- */}\n\t\t\t{/* List rows */}\n\t\t\t<SettingsSearchItem value={t('change-name')} onSelect={() => navigate('settings/account/change-name')} />\n\t\t\t<SettingsSearchItem value={t('change-password')} onSelect={() => navigate('settings/account/change-password')} />\n\t\t\t<SettingsSearchItem value={'wifi'} onSelect={() => navigate('/settings/wifi')}>\n\t\t\t\t{t('wifi')}\n\t\t\t</SettingsSearchItem>\n\t\t\t<SettingsSearchItem value={'2fa'} onSelect={() => navigate('/settings/2fa')}>\n\t\t\t\t{t('2fa')}\n\t\t\t</SettingsSearchItem>\n\t\t\t<SettingsSearchItem value={t('remote-tor-access')} onSelect={() => navigate('/settings/advanced/tor')} />\n\t\t\t<SettingsSearchItem value={t('migration-assistant')} onSelect={() => navigate('/settings/migration-assistant')} />\n\t\t\t<SettingsSearchItem value={t('language')} onSelect={() => navigate('/settings/language')} />\n\t\t\t<SettingsSearchItem value={t('troubleshoot')} onSelect={() => navigate('/settings/troubleshoot')} />\n\t\t\t<SettingsSearchItem value={t('terminal')} onSelect={() => navigate('/settings/terminal')} />\n\t\t\t<SettingsSearchItem value={t('device-info')} onSelect={() => navigate('/settings/device-info')} />\n\t\t\t<SettingsSearchItem value={t('software-update.title')} onSelect={() => navigate('/settings/software-update')} />\n\t\t\t<SettingsSearchItem value={t('factory-reset')} onSelect={() => navigate('/factory-reset')} />\n\t\t\t<SettingsSearchItem value={t('advanced-settings')} onSelect={() => navigate('/settings/advanced')} />\n\t\t\t<SettingsSearchItem value={t('beta-program')} onSelect={() => navigate('/settings/advanced/beta-program')} />\n\t\t\t<SettingsSearchItem value={t('external-dns')} onSelect={() => navigate('/settings/advanced/external-dns')} />\n\t\t\t{readyApps.map((app) => (\n\t\t\t\t<SearchItem\n\t\t\t\t\tvalue={app.name}\n\t\t\t\t\ticon={app.icon}\n\t\t\t\t\tkey={app.id}\n\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\tlaunchApp(app.id)\n\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{app.name}\n\t\t\t\t</SearchItem>\n\t\t\t))}\n\t\t\t{unreadyApps.map((app) => (\n\t\t\t\t<SearchItem\n\t\t\t\t\tdisabled\n\t\t\t\t\tvalue={app.name}\n\t\t\t\t\ticon={app.icon}\n\t\t\t\t\tkey={app.id}\n\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\tnavigate(`/app-store/${app.id}`)\n\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<span>\n\t\t\t\t\t\t{app.name} <span className='opacity-50'> – {appStateToString(app.state)}</span>\n\t\t\t\t\t</span>\n\t\t\t\t</SearchItem>\n\t\t\t))}\n\t\t\t{installableApps.map((app) => (\n\t\t\t\t<SearchItem\n\t\t\t\t\tvalue={app.name}\n\t\t\t\t\ticon={app.icon}\n\t\t\t\t\tkey={app.id}\n\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\tnavigate(`/app-store/${app.id}`)\n\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<span>\n\t\t\t\t\t\t{app.name} <span className='opacity-50'>{t('generic-in')} App Store</span>\n\t\t\t\t\t</span>\n\t\t\t\t</SearchItem>\n\t\t\t))}\n\n\t\t\t{/* Pluggable search providers */}\n\t\t\t{cmdkSearchProviders.map((Provider, idx) => (\n\t\t\t\t<Provider key={idx} query={searchQuery} close={() => setOpen(false)} />\n\t\t\t))}\n\t\t\t<DebugOnlyBare>\n\t\t\t\t<SearchItem value='Install a bunch of random apps' onSelect={debugInstallRandomApps}>\n\t\t\t\t\tInstall a bunch of random apps\n\t\t\t\t</SearchItem>\n\t\t\t</DebugOnlyBare>\n\t\t</CommandList>\n\t)\n}\n\nfunction FrequentApps({onLaunchApp}: {onLaunchApp: () => void}) {\n\tconst lastAppsQ = trpcReact.apps.recentlyOpened.useQuery(undefined, {\n\t\tretry: false,\n\t})\n\tconst lastApps = lastAppsQ.data ?? []\n\tconst {userAppsKeyed} = useApps()\n\n\tconst search = useCommandState((state) => state.search)\n\n\t// If there's a search query, don't show frequent apps\n\tif (search) return null\n\tif (!userAppsKeyed) return null\n\tif (!lastApps) return null\n\tif (lastApps.length === 0) return null\n\n\treturn (\n\t\t<div className='mb-3 flex flex-col gap-3 md:mb-5 md:gap-5'>\n\t\t\t<div>\n\t\t\t\t<h3 className='mb-5 ml-2 hidden text-15 leading-tight font-semibold -tracking-2 md:block'>\n\t\t\t\t\t{t('cmdk.frequent-apps')}\n\t\t\t\t</h3>\n\t\t\t\t<FadeScroller direction='x' className='umbrel-hide-scrollbar w-full overflow-x-auto whitespace-nowrap'>\n\t\t\t\t\t{/* Show skeleton by default to prevent layout shift */}\n\t\t\t\t\t{lastAppsQ.isLoading &&\n\t\t\t\t\t\trange(0, 3).map((i) => <FrequentApp key={i} appId={''} icon='' name={LOADING_DASH} />)}\n\t\t\t\t\t{appsByFrequency(lastApps, 6).map((appId) => (\n\t\t\t\t\t\t<FrequentApp\n\t\t\t\t\t\t\tkey={appId}\n\t\t\t\t\t\t\tappId={appId}\n\t\t\t\t\t\t\ticon={userAppsKeyed[appId]?.icon}\n\t\t\t\t\t\t\tname={userAppsKeyed[appId]?.name}\n\t\t\t\t\t\t\tonLaunch={onLaunchApp}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t</FadeScroller>\n\t\t\t</div>\n\n\t\t\t<Separator />\n\t\t</div>\n\t)\n}\n\nfunction appsByFrequency(lastOpenedApps: string[], count: number) {\n\tconst openCounts = new Map<string, number>()\n\n\tlastOpenedApps.map((appId) => {\n\t\tif (!openCounts.has(appId)) {\n\t\t\topenCounts.set(appId, 1)\n\t\t} else {\n\t\t\topenCounts.set(appId, openCounts.get(appId)! + 1)\n\t\t}\n\t})\n\n\tconst sortedAppIds = [...openCounts.entries()]\n\t\t.sort((a, b) => b[1] - a[1])\n\t\t.slice(0, count)\n\t\t.map((a) => a[0])\n\n\treturn sortedAppIds\n}\n\nfunction FrequentApp({\n\tappId,\n\ticon,\n\tname,\n\tonLaunch,\n}: {\n\tappId: string\n\ticon: string\n\tname: string\n\tonLaunch?: () => void\n}) {\n\tconst launchApp = useLaunchApp()\n\tconst isMobile = useIsMobile()\n\treturn (\n\t\t<button\n\t\t\tclassName='inline-flex w-[75px] flex-col items-center gap-2 overflow-hidden rounded-8 border border-transparent p-1.5 outline-hidden transition-all hover:border-white/10 hover:bg-white/4 focus-visible:border-white/10 focus-visible:bg-white/4 active:border-white/20 md:w-[100px] md:p-2'\n\t\t\tonClick={() => {\n\t\t\t\tonLaunch?.()\n\t\t\t\tlaunchApp(appId)\n\t\t\t}}\n\t\t\tonKeyDown={(e) => {\n\t\t\t\tif (e.key === 'Enter') {\n\t\t\t\t\t// Prevent triggering first selected cmdk item\n\t\t\t\t\te.preventDefault()\n\t\t\t\t\tlaunchApp(appId)\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t<AppIcon src={icon} size={isMobile ? 48 : 64} className='rounded-10 lg:rounded-15' />\n\t\t\t<div className='w-full truncate text-[10px] -tracking-2 text-white/75 md:text-13'>{name ?? appId}</div>\n\t\t</button>\n\t)\n}\n\nconst SettingsSearchItem = ({\n\tonSelect,\n\tvalue,\n\tchildren,\n}: {\n\tonSelect: () => void\n\tvalue: string\n\tchildren?: React.ReactNode\n}) => {\n\tconst {setOpen} = useCmdkOpen()\n\treturn (\n\t\t<SearchItem\n\t\t\tvalue={value}\n\t\t\ticon={systemAppsKeyed['UMBREL_settings'].icon}\n\t\t\tonSelect={() => {\n\t\t\t\tonSelect()\n\t\t\t\tsetOpen(false)\n\t\t\t}}\n\t\t>\n\t\t\t{children ?? value}\n\t\t</SearchItem>\n\t)\n}\n\nconst SearchItem = (props: ComponentPropsWithoutRef<typeof CommandItem>) => {\n\tconst search = useCommandState((state) => state.search)\n\tif (!search) return null\n\n\treturn (\n\t\t<CommandItem\n\t\t\t{...props}\n\t\t\tclassName={cn(props.className, props.disabled && 'opacity-50')}\n\t\t\tonSelect={(value) => {\n\t\t\t\tprops.onSelect?.(value)\n\t\t\t}}\n\t\t/>\n\t)\n}\n\nexport function appStateToString(appState: AppState) {\n\treturn {\n\t\t'not-installed': t('app.install'),\n\t\tinstalling: t('app.installing'),\n\t\tready: t('app.open'),\n\t\trunning: t('app.open'),\n\t\tstarting: t('app.restarting'),\n\t\trestarting: t('app.starting'),\n\t\tstopping: t('app.stopping'),\n\t\tupdating: t('app.updating'),\n\t\tuninstalling: t('app.uninstalling'),\n\t\tunknown: t('app.offline'),\n\t\tstopped: t('app.offline'),\n\t\tloading: t('loading'),\n\t}[appState]\n}\n"
  },
  {
    "path": "packages/ui/src/components/darken-layer.tsx",
    "content": "import {cn} from '@/lib/utils'\n\n/**\n * Put a darken layer over the page\n */\nexport function DarkenLayer({className}: {className?: string}) {\n\treturn <div className={cn('fixed inset-0 bg-black/50 contrast-more:bg-black', className)} />\n}\n"
  },
  {
    "path": "packages/ui/src/components/fade-scroller.tsx",
    "content": "import {ComponentPropsWithoutRef, useLayoutEffect, useRef} from 'react'\nimport {mergeRefs} from 'react-merge-refs'\n\nexport type FadeScrollerProps = ComponentPropsWithoutRef<'div'> & {\n\tdirection: 'x' | 'y'\n\tdebug?: boolean\n\tref?: React.Ref<HTMLDivElement>\n}\n\nconst FADE_SCROLLER_CLASS_X = 'umbrel-fade-scroller-x'\nconst FADE_SCROLLER_CLASS_Y = 'umbrel-fade-scroller-y'\n\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nexport function useFadeScroller(direction: 'x' | 'y', debug?: boolean) {\n\tconst ref = useRef<HTMLDivElement>(null)\n\n\t// TODO: consider re-running this effect when window is resized\n\t// NOTE: useLayoutEffect is used to avoid flicker when fading is rendered\n\tuseLayoutEffect(() => {\n\t\t// Horizontal scroll in chrome adds fading via scroll-timeline even when it shouldn't. This happens in the 3-up section of the app store\n\t\t// Animating in the side fades also doesn't work because the positions of the gradient markers would be based on the scroll position\n\t\tconst el = ref!.current\n\t\tif (!el) return\n\n\t\t// Throttle scroll updates to once per frame via rAF to avoid redundant\n\t\t// style recalculations — scroll events can fire 10+ times per frame.\n\t\tlet rafId = 0\n\t\tconst updateFade = () => {\n\t\t\tif (!el) return\n\n\t\t\t// Round to avoid issues with sub-pixel scrolling\n\t\t\t// Using `<` and `>` to capture the edge case where the user scrolls past the end of the content (iOS bouncing)\n\t\t\tconst atStart = direction === 'x' ? el.scrollLeft <= 0 : el.scrollTop <= 0\n\t\t\tconst atEnd =\n\t\t\t\tdirection === 'x'\n\t\t\t\t\t? Math.round(el.scrollLeft) + el.clientWidth >= el.scrollWidth\n\t\t\t\t\t: Math.round(el.scrollTop) + el.clientHeight >= el.scrollHeight\n\n\t\t\tif (atStart && atEnd) {\n\t\t\t\tel.style.setProperty('--distance1', `0px`)\n\t\t\t\tel.style.setProperty('--distance2', `0px`)\n\t\t\t} else if (atStart) {\n\t\t\t\tel.style.setProperty('--distance1', `0px`)\n\t\t\t\tel.style.setProperty('--distance2', `50px`)\n\t\t\t} else if (atEnd) {\n\t\t\t\tel.style.setProperty('--distance1', `50px`)\n\t\t\t\tel.style.setProperty('--distance2', `0px`)\n\t\t\t} else {\n\t\t\t\tel.style.setProperty('--distance1', `50px`)\n\t\t\t\tel.style.setProperty('--distance2', `50px`)\n\t\t\t}\n\t\t}\n\n\t\tconst handleScroll = () => {\n\t\t\tcancelAnimationFrame(rafId)\n\t\t\trafId = requestAnimationFrame(updateFade)\n\t\t}\n\n\t\t// Run on mount by default\n\t\tupdateFade()\n\n\t\tel.addEventListener('scroll', handleScroll, {passive: true})\n\t\treturn () => {\n\t\t\tel.removeEventListener('scroll', handleScroll)\n\t\t\tcancelAnimationFrame(rafId)\n\t\t}\n\t}, [direction])\n\n\tconst scrollerClass =\n\t\tdirection === 'x' ? FADE_SCROLLER_CLASS_X : direction === 'y' ? FADE_SCROLLER_CLASS_Y : undefined\n\n\treturn {scrollerClass, ref}\n}\n\nexport function FadeScroller({direction, debug, className, ref, ...props}: FadeScrollerProps) {\n\tconst {scrollerClass, ref: scrollerRef} = useFadeScroller(direction, debug)\n\n\treturn <div ref={mergeRefs([ref, scrollerRef])} className={scrollerClass + ' ' + className} {...props} />\n}\n"
  },
  {
    "path": "packages/ui/src/components/iframe-checker.tsx",
    "content": "import React from 'react'\n\nexport function IframeChecker({children}: {children: React.ReactNode}) {\n\tconst isIframe = window.self !== window.top\n\n\tif (isIframe) {\n\t\treturn <div className='grid h-screen w-full place-items-center'>umbrelOS cannot be embedded in an iframe.</div>\n\t}\n\treturn <>{children}</>\n}\n"
  },
  {
    "path": "packages/ui/src/components/install-button-connected.tsx",
    "content": "import prettyBytes from 'pretty-bytes'\nimport {useImperativeHandle, useState} from 'react'\nimport semver from 'semver'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {useAppInstall} from '@/hooks/use-app-install'\nimport {useLaunchApp} from '@/hooks/use-launch-app'\nimport {useVersion} from '@/hooks/use-version'\nimport {OSUpdateRequiredDialog} from '@/modules/app-store/os-update-required'\nimport {SelectDependenciesDialog} from '@/modules/app-store/select-dependencies-dialog'\nimport {useApps} from '@/providers/apps'\nimport {useAllAvailableApps} from '@/providers/available-apps'\nimport {installedStates, RegistryApp} from '@/trpc/trpc'\n\nimport {InstallButton} from './install-button'\n\nexport function InstallButtonConnected({app, ref}: {app: RegistryApp; ref?: React.Ref<unknown>}) {\n\tconst appInstall = useAppInstall(app.id)\n\tconst {apps} = useAllAvailableApps()\n\tconst [showDepsDialog, setShowDepsDialog] = useState(false)\n\tconst [showOSUpdateRequiredDialog, setShowOSUpdateRequiredDialog] = useState(false)\n\tconst {userAppsKeyed, isLoading} = useApps()\n\tconst openApp = useLaunchApp()\n\tconst [selections, setSelections] = useState({} as Record<string, string>)\n\tconst os = useVersion()\n\tconst [highlightDependency, setHighlightDependency] = useState<string | undefined>(undefined)\n\n\tuseImperativeHandle(ref, () => ({\n\t\ttriggerInstall(highlightDependency?: string) {\n\t\t\tsetHighlightDependency(highlightDependency)\n\t\t\ttriggerInstall()\n\t\t},\n\t}))\n\n\tif (isLoading || !userAppsKeyed || !apps || os.isLoading) {\n\t\treturn (\n\t\t\t<InstallButton\n\t\t\t\tkey={app.id}\n\t\t\t\tinstallSize={app.installSize ? prettyBytes(app.installSize) : undefined}\n\t\t\t\tprogress={appInstall.progress}\n\t\t\t\tstate='loading'\n\t\t\t/>\n\t\t)\n\t}\n\n\tconst isInstalled = (appId: string) => arrayIncludes(installedStates, userAppsKeyed[appId]?.state)\n\n\tconst selectAlternative = (dependencyId: string, appId: string | undefined) => {\n\t\tif (appId) selections[dependencyId] = appId\n\t\telse delete selections[dependencyId]\n\t\tsetSelections({...selections})\n\t}\n\n\tconst getAppsImplementing = (dependencyId: string) =>\n\t\tapps\n\t\t\t// Filter out community apps that aren't installed\n\t\t\t.filter((registryApp) => {\n\t\t\t\tconst isCommunityApp = registryApp.appStoreId !== 'umbrel-app-store'\n\t\t\t\treturn !isCommunityApp || userAppsKeyed[registryApp.id]\n\t\t\t})\n\t\t\t// Prefer installed app over registry app\n\t\t\t.map((registryApp) => userAppsKeyed[registryApp.id] ?? registryApp)\n\t\t\t.filter((applicableApp) => applicableApp.implements?.includes(dependencyId))\n\t\t\t.map((implementingApp) => implementingApp.id)\n\n\t// Obtain possible alternatives for each dependency. Groups alternatives for\n\t// each dependency into a two dimensional array, where each item references\n\t// both the original dependency and the alterantive app. First item always is\n\t// the original dependency.\n\t// [\n\t//   [{dependencyId, appId: dependencyId}, {dependencyId, appId: implementingId}],\n\t//   [{dependencyId, appId: dependencyId}],\n\t// ]\n\tconst dependencies = (app.dependencies ?? []).map((dependencyId) =>\n\t\t[dependencyId, ...getAppsImplementing(dependencyId)].map((appId) => ({\n\t\t\tdependencyId,\n\t\t\tappId,\n\t\t})),\n\t)\n\n\t// Auto-select the first installed alternative, naturally preferring the original\n\t// app when it is installed as well.\n\tdependencies.forEach((alternatives) => {\n\t\talternatives.forEach(({dependencyId, appId}) => {\n\t\t\tif (!selections[dependencyId] && isInstalled(appId)) {\n\t\t\t\tselectAlternative(dependencyId, appId)\n\t\t\t}\n\t\t})\n\t})\n\n\t// TODO: Also check if app is ready? `&& userAppsKeyed[dep].state === 'ready'`\n\t// Will want to mark apps as in progress so we don't show that an app needs to be installed first\n\tconst areAllAlternativesSelectedAndInstalled = dependencies.every((alternatives) =>\n\t\talternatives.some((app) => selections[app.dependencyId] === app.appId && isInstalled(app.appId)),\n\t)\n\n\tconst compatible = semver.lte(app.manifestVersion, os.version)\n\n\tconst install = () => {\n\t\tif (!compatible) {\n\t\t\tsetShowOSUpdateRequiredDialog(true)\n\t\t\treturn\n\t\t}\n\t\tif (dependencies.length > 0) {\n\t\t\treturn setShowDepsDialog(true)\n\t\t}\n\t\tappInstall.install()\n\t}\n\n\tfunction triggerInstall() {\n\t\tinstall()\n\t}\n\n\tconst verifyInstall = (selectedDeps: Record<string, string>) => {\n\t\t// Currently always the case because AppPermissionsDialog checks\n\t\tif (areAllAlternativesSelectedAndInstalled) {\n\t\t\tappInstall.install(selectedDeps)\n\t\t}\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<InstallButton\n\t\t\t\t// `key` to prevent framer-motion from thinking install buttons from different pages are the same and animating between them\n\t\t\t\tkey={app.id}\n\t\t\t\tinstallSize={app.installSize ? prettyBytes(app.installSize) : undefined}\n\t\t\t\t// progress={userApp?.installProgress}\n\t\t\t\t// state={userApp?.state || 'initial'}\n\t\t\t\tprogress={appInstall.progress}\n\t\t\t\tstate={appInstall.state}\n\t\t\t\tcompatible={compatible}\n\t\t\t\tonInstallClick={install}\n\t\t\t\tonOpenClick={() => openApp(app.id)}\n\t\t\t/>\n\t\t\t<SelectDependenciesDialog\n\t\t\t\tappId={app.id}\n\t\t\t\tdependencies={dependencies}\n\t\t\t\topen={showDepsDialog}\n\t\t\t\tonOpenChange={setShowDepsDialog}\n\t\t\t\tonNext={verifyInstall}\n\t\t\t\thighlightDependency={highlightDependency}\n\t\t\t/>\n\t\t\t<OSUpdateRequiredDialog\n\t\t\t\tapp={app}\n\t\t\t\topen={showOSUpdateRequiredDialog}\n\t\t\t\tonOpenChange={setShowOSUpdateRequiredDialog}\n\t\t\t/>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/install-button.tsx",
    "content": "import {TbLoader} from 'react-icons/tb'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {ProgressButton} from '@/components/progress-button'\nimport {UNKNOWN} from '@/constants'\nimport {cn} from '@/lib/utils'\nimport {AppStateOrLoading} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {assertUnreachable} from '@/utils/misc'\n// import {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nimport {AnimatedNumber} from './ui/animated-number'\n\ntype Props = {\n\tinstallSize?: string\n\tprogress?: number\n\tstate: AppStateOrLoading\n\tcompatible?: boolean\n\tonInstallClick?: () => void\n\tonOpenClick?: () => void\n}\n\nexport function InstallButton({installSize, progress, state, onInstallClick, onOpenClick, ...props}: Props) {\n\treturn (\n\t\t<ProgressButton\n\t\t\tvariant={state === 'updating' ? 'default' : 'primary'}\n\t\t\tsize='lg'\n\t\t\tstate={state}\n\t\t\tprogress={progress}\n\t\t\tonClick={() => {\n\t\t\t\tif (state === 'not-installed') {\n\t\t\t\t\tonInstallClick?.()\n\t\t\t\t} else if (state === 'ready') {\n\t\t\t\t\tonOpenClick?.()\n\t\t\t\t}\n\t\t\t}}\n\t\t\tclassName='hover:bg-brand-lighter max-md:h-[30px] max-md:w-full max-md:text-13'\n\t\t\tstyle={{\n\t\t\t\t['--progress-button-bg' as string]: state === 'updating' ? 'hsl(0 0 30%)' : 'hsl(var(--color-brand))',\n\t\t\t}}\n\t\t\tdisabled={!arrayIncludes(['not-installed', 'ready'], state)}\n\t\t\tinitial={{borderRadius: 9999}}\n\t\t\t{...props}\n\t\t>\n\t\t\t<ButtonContentForState state={state} installSize={installSize} progress={progress} />\n\t\t</ProgressButton>\n\t)\n}\n\nfunction ButtonContentForState({\n\tstate,\n\tinstallSize,\n\tprogress,\n}: {\n\tstate: AppStateOrLoading\n\tinstallSize?: string\n\tprogress?: number\n}) {\n\tswitch (state) {\n\t\tcase 'not-installed':\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t{t('app.install')}{' '}\n\t\t\t\t\t<span className='-tracking-normal whitespace-nowrap uppercase opacity-40'>{installSize}</span>\n\t\t\t\t</>\n\t\t\t)\n\t\tcase 'installing':\n\t\tcase 'updating': {\n\t\t\tconst text = state === 'updating' ? t('app.updating') : t('app.installing')\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t{text} {/*  */}\n\t\t\t\t\t{/* 4ch to fit text \"100%\" */}\n\t\t\t\t\t<span className='inline-block w-[4ch] text-right -tracking-[0.08em] opacity-40'>\n\t\t\t\t\t\t{progress === undefined ? UNKNOWN() : <AnimatedNumber to={progress} />}%\n\t\t\t\t\t</span>\n\t\t\t\t</>\n\t\t\t)\n\t\t}\n\t\tcase 'ready':\n\t\tcase 'running':\n\t\t\treturn t('app.open')\n\t\tcase 'starting':\n\t\t\treturn t('app.restarting') + '...'\n\t\tcase 'restarting':\n\t\t\treturn t('app.starting') + '...'\n\t\tcase 'stopping':\n\t\t\treturn t('app.stopping') + '...'\n\t\tcase 'uninstalling':\n\t\t\treturn t('app.uninstalling') + '...'\n\t\tcase 'unknown':\n\t\tcase 'stopped':\n\t\t\treturn t('app.offline')\n\t\tcase 'loading':\n\t\tcase undefined:\n\t\t\treturn <TbLoader className='white h-3 w-3 animate-spin opacity-50 shadow-xs' />\n\t\t// return t('loading') + '...'\n\t}\n\treturn assertUnreachable(state)\n}\n\nexport const installButtonClass = cn(\n\ttw`whitespace-nowrap disabled:bg-brand/60 disabled:opacity-100 bg-brand hover:bg-brand-lighter`,\n\ttw`max-md:h-[30px] max-md:w-full max-md:text-13`,\n)\n"
  },
  {
    "path": "packages/ui/src/components/markdown.tsx",
    "content": "import MarkdownPrimitive from 'react-markdown'\nimport {useLocation} from 'react-router-dom'\nimport remarkBreaks from 'remark-breaks'\nimport remarkGfm from 'remark-gfm'\n\nimport {cn} from '@/lib/utils'\nimport {tw} from '@/utils/tw'\n\n// IMPORTANT: Want to avoid any risk of tracking pixels, XSS, etc.\n// NEVER ALLOW HTML IN MARKDOWN\n// NEVER ALLOW IMAGES IN MARKDOWN\nexport function Markdown({className, ...props}: React.ComponentProps<typeof MarkdownPrimitive>) {\n\tconst {pathname} = useLocation()\n\tconst isInCommunityAppStore = pathname.startsWith('/community-app-store')\n\n\tif (isInCommunityAppStore) {\n\t\treturn <div className={cn(textClass, 'whitespace-pre-line', className)} {...props} />\n\t}\n\n\treturn (\n\t\t<MarkdownPrimitive\n\t\t\tremarkPlugins={[\n\t\t\t\t[\n\t\t\t\t\tremarkBreaks,\n\t\t\t\t\t{\n\t\t\t\t\t\tsoftbreak: '\\n',\n\t\t\t\t\t\tstrongbreak: '\\n',\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\t[remarkGfm, {singleTilde: false}],\n\t\t\t]}\n\t\t\t// Don't want big headings in user content\n\t\t\tcomponents={{\n\t\t\t\th1: 'h4',\n\t\t\t\th2: 'h4',\n\t\t\t\th3: 'h4',\n\t\t\t\th4: 'h4',\n\t\t\t\th5: 'h4',\n\t\t\t\th6: 'h4',\n\t\t\t\ta: (props) => (\n\t\t\t\t\t<a\n\t\t\t\t\t\tclassName='decoration-white/30 underline-offset-2 outline-hidden transition-opacity hover:opacity-80 focus:opacity-80'\n\t\t\t\t\t\ttarget='_blank'\n\t\t\t\t\t\t{...props}\n\t\t\t\t\t/>\n\t\t\t\t),\n\t\t\t}}\n\t\t\tallowedElements={[\n\t\t\t\t'h1',\n\t\t\t\t'h2',\n\t\t\t\t'h3',\n\t\t\t\t'h4',\n\t\t\t\t'h5',\n\t\t\t\t'h6',\n\t\t\t\t'a',\n\t\t\t\t'p',\n\t\t\t\t'ul',\n\t\t\t\t'ol',\n\t\t\t\t'li',\n\t\t\t\t'em',\n\t\t\t\t'strong',\n\t\t\t\t// 'del',\n\t\t\t\t'code',\n\t\t\t\t'pre',\n\t\t\t\t'br',\n\t\t\t]}\n\t\t\t// `unwrapDisallowed` because **some text** should still render \"some text\" rather than nothing\n\t\t\tunwrapDisallowed\n\t\t\t// `skipHtml` still renders contents\n\t\t\tskipHtml\n\t\t\tclassName={cn(textClass, className)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nconst textClass = tw`prose prose-sm prose-neutral prose-invert overflow-x-hidden`\n"
  },
  {
    "path": "packages/ui/src/components/onboarding-background.tsx",
    "content": "import {useDeviceInfo} from '@/hooks/use-device-info'\nimport {cn} from '@/lib/utils'\nimport {useOnboardingDevice} from '@/routes/onboarding/use-onboarding-device'\n\nconst backgroundClass = 'pointer-events-none fixed inset-0 size-full object-cover object-center'\n\nexport function OnboardingBackground({className}: {className?: string}) {\n\tconst {isLoading} = useDeviceInfo()\n\tconst {showDevice} = useOnboardingDevice()\n\n\t// Show black while loading to prevent wallpaper 18 flash on Pro/Home devices\n\tif (isLoading) {\n\t\treturn <div className={cn(backgroundClass, 'bg-black', className)} />\n\t}\n\n\t// Pro/Home: Video (webm) with poster (jpg) fallback for older browsers\n\t// Uses pre-rendered ping-pong video (forward + reversed) for seamless infinite loop\n\tif (showDevice) {\n\t\treturn (\n\t\t\t<video\n\t\t\t\tautoPlay\n\t\t\t\tloop\n\t\t\t\tmuted\n\t\t\t\tplaysInline\n\t\t\t\tposter='/assets/wallpapers/22.jpg'\n\t\t\t\tsrc='/assets/onboarding/onboarding-bg.webm'\n\t\t\t\tclassName={cn(backgroundClass, className)}\n\t\t\t/>\n\t\t)\n\t}\n\n\t// Other devices: Static wallpaper\n\treturn <img src='/assets/wallpapers/18.jpg' alt='' className={cn(backgroundClass, className)} />\n}\n"
  },
  {
    "path": "packages/ui/src/components/progress-button.tsx",
    "content": "import {VariantProps} from 'class-variance-authority'\nimport {HTMLMotionProps, motion} from 'motion/react'\nimport {CSSProperties, useEffect, useState} from 'react'\nimport {useFirstMountState} from 'react-use'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {buttonVariants} from '@/components/ui/button'\nimport {cn} from '@/lib/utils'\nimport {AppStateOrLoading, progressBarStates} from '@/trpc/trpc'\n\n// Check if CSS available\n// https://developer.mozilla.org/en-US/docs/Web/API/CSS/registerProperty\nif (typeof CSS !== 'undefined' && CSS.registerProperty) {\n\tCSS.registerProperty({\n\t\tname: '--progress-button-progress',\n\t\tsyntax: '<percentage>',\n\t\tinherits: false,\n\t\tinitialValue: '0%',\n\t})\n}\n\ntype Props = {\n\tprogress?: number\n\tstate: AppStateOrLoading\n\tonClick?: () => void\n} & VariantProps<typeof buttonVariants> &\n\tHTMLMotionProps<'button'>\n\nexport function ProgressButton({variant, size, progress, state, children, className, style, ...buttonProps}: Props) {\n\tconst isFirstRender = useFirstMountState()\n\tconst progressing = arrayIncludes(progressBarStates, state)\n\n\t// Stops flicker when progressing done\n\tconst [progressingDone, setProgressingDone] = useState(true)\n\tuseEffect(() => {\n\t\tif (state === 'ready') {\n\t\t\tsetTimeout(() => setProgressingDone(true), 0)\n\t\t} else if (progressing) {\n\t\t\tsetProgressingDone(false)\n\t\t}\n\t}, [state, progressing])\n\n\tconst progressingStyle: CSSProperties = {\n\t\t// Adding transitions so hover and other transitions work\n\t\ttransition: '--progress-button-progress 0.3s',\n\t\t// ['--progress-button-bg' as string]: 'var(--color-brand)',\n\t\t['--progress-button-progress' as string]: `${Math.round(progress ?? 0)}%`,\n\t\tbackgroundImage:\n\t\t\t'linear-gradient(to right, var(--progress-button-bg) var(--progress-button-progress), transparent var(--progress-button-progress))',\n\t\tbackgroundColor: 'color-mix(in srgb, var(--progress-button-bg) 60%, transparent)',\n\t\topacity: 1,\n\t}\n\n\treturn (\n\t\t<motion.button\n\t\t\tdata-progressing={progressing}\n\t\t\tclassName={cn(\n\t\t\t\tbuttonVariants({size, variant}),\n\t\t\t\t'whitespace-nowrap disabled:opacity-60',\n\t\t\t\tstate === 'loading' && '!bg-white/10',\n\t\t\t\t// Disable transition right when installing done for a sec to prevent flicker\n\t\t\t\tstate === 'ready' && !progressingDone && 'transition-none',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\tstyle={{\n\t\t\t\t...(progressing ? progressingStyle : undefined),\n\t\t\t\t...style,\n\t\t\t}}\n\t\t\tlayout\n\t\t\tdisabled={!arrayIncludes(['not-installed', 'ready'], state)}\n\t\t\t{...buttonProps}\n\t\t>\n\t\t\t{/* Child has `layout` too to prevent content from being scaled and stretched with the parent */}\n\t\t\t{/* https://codesandbox.io/p/sandbox/framer-motion-2-scale-correction-z4tgr?file=%2Fsrc%2FApp.js&from-embed= */}\n\t\t\t<motion.div\n\t\t\t\tlayout='position'\n\t\t\t\tkey={state}\n\t\t\t\tinitial={{opacity: 0}}\n\t\t\t\tanimate={{\n\t\t\t\t\topacity: 1,\n\t\t\t\t\ttransition: {opacity: {duration: 0.2, delay: state === 'loading' || isFirstRender ? 0 : 0.2}},\n\t\t\t\t}}\n\t\t\t\t// className='bg-red-500/50'\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</motion.div>\n\t\t</motion.button>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/alert-dialog.tsx",
    "content": "import * as AlertDialogPrimitive from '@radix-ui/react-dialog'\nimport {LucideIcon} from 'lucide-react'\nimport * as React from 'react'\nimport {IconType} from 'react-icons'\nimport {omit} from 'remeda'\n\nimport {Button, buttonVariants} from '@/components/ui/button'\nimport {cn} from '@/lib/utils'\n\nimport {\n\tdialogContentAnimationClass,\n\tdialogContentAnimationSlideClass,\n\tdialogContentClass,\n\tdialogFooterClass,\n\tdialogOverlayClass,\n} from './shared/dialog'\n\n// https://github.com/radix-ui/primitives/issues/1281#issuecomment-1081767007\nconst AlertDialogContext = React.createContext<{\n\topen?: boolean\n\tonOpenChange?: (open: boolean) => void\n}>({\n\topen: false,\n\tonOpenChange: () => {},\n})\n\nfunction useDialogState() {\n\tconst context = React.useContext(AlertDialogContext)\n\tif (!context) {\n\t\tthrow new Error('useDialogState must be used within a AlertDialogProvider')\n\t}\n\treturn context\n}\n\nconst AlertDialog = ({children, ...props}: AlertDialogPrimitive.DialogProps) => {\n\treturn (\n\t\t<AlertDialogContext\n\t\t\tvalue={{\n\t\t\t\topen: props.open,\n\t\t\t\tonOpenChange: props.onOpenChange,\n\t\t\t}}\n\t\t>\n\t\t\t<AlertDialogPrimitive.Root {...props}>{children}</AlertDialogPrimitive.Root>\n\t\t</AlertDialogContext>\n\t)\n}\n\nconst AlertDialogTrigger = AlertDialogPrimitive.Trigger\n\nconst AlertDialogPortal = (props: AlertDialogPrimitive.DialogPortalProps) => <AlertDialogPrimitive.Portal {...props} />\nAlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName\n\nfunction AlertDialogOverlay({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> & {\n\tref?: React.Ref<React.ComponentRef<typeof AlertDialogPrimitive.Overlay>>\n}) {\n\treturn (\n\t\t<AlertDialogPrimitive.Overlay\n\t\t\tclassName={cn(dialogOverlayClass, className)}\n\t\t\t{...omit(props, ['children'])}\n\t\t\tref={ref}\n\t\t/>\n\t)\n}\n\nfunction AlertDialogContent({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> & {\n\tref?: React.Ref<React.ComponentRef<typeof AlertDialogPrimitive.Content>>\n}) {\n\treturn (\n\t\t<AlertDialogPortal>\n\t\t\t<AlertDialogOverlay />\n\t\t\t<AlertDialogPrimitive.Content\n\t\t\t\tref={ref}\n\t\t\t\tclassName={cn(\n\t\t\t\t\tdialogContentClass,\n\t\t\t\t\tdialogContentAnimationClass,\n\t\t\t\t\tdialogContentAnimationSlideClass,\n\t\t\t\t\t'w-full max-w-[calc(100%-40px)] sm:max-w-md',\n\t\t\t\t\tclassName,\n\t\t\t\t)}\n\t\t\t\t{...props}\n\t\t\t/>\n\t\t</AlertDialogPortal>\n\t)\n}\n\nconst AlertDialogHeader = ({\n\tclassName,\n\ticon,\n\tchildren,\n\t...props\n}: React.HTMLAttributes<HTMLDivElement> & {\n\ticon?: IconType | LucideIcon\n}) => {\n\tconst IconComponent = icon\n\treturn (\n\t\t<div className={cn('flex flex-col space-y-2 text-center', className)} {...props}>\n\t\t\t{IconComponent && <IconComponent className='mx-auto h-7 w-7 rounded-full bg-white/10 p-1' />}\n\t\t\t{children}\n\t\t</div>\n\t)\n}\nAlertDialogHeader.displayName = 'AlertDialogHeader'\n\nconst AlertDialogFooter = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (\n\t<div className={cn(dialogFooterClass, 'md:justify-center', className)} {...props} />\n)\nAlertDialogFooter.displayName = 'AlertDialogFooter'\n\nfunction AlertDialogTitle({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> & {\n\tref?: React.Ref<React.ComponentRef<typeof AlertDialogPrimitive.Title>>\n}) {\n\treturn (\n\t\t<AlertDialogPrimitive.Title\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t'text-center text-17 leading-snug font-semibold -tracking-2 break-words whitespace-pre-line',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction AlertDialogDescription({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> & {\n\tref?: React.Ref<React.ComponentRef<typeof AlertDialogPrimitive.Description>>\n}) {\n\treturn (\n\t\t<AlertDialogPrimitive.Description\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t'text-13 leading-tight font-normal -tracking-2 break-words whitespace-pre-line text-white/60',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction AlertDialogAction({\n\tvariant,\n\tchildren,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof Button> & {\n\tref?: React.Ref<React.ComponentRef<typeof Button>>\n}) {\n\treturn (\n\t\t<Button ref={ref} size={'dialog'} variant={variant ?? 'primary'} {...props}>\n\t\t\t{children}\n\t\t</Button>\n\t)\n}\n\nfunction AlertDialogCancel({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof Button> & {\n\tref?: React.Ref<React.ComponentRef<typeof Button>>\n}) {\n\tconst {onOpenChange} = useDialogState()\n\treturn (\n\t\t<Button\n\t\t\tref={ref}\n\t\t\tclassName={cn(buttonVariants({size: 'dialog'}), className)}\n\t\t\tonClick={(e) => {\n\t\t\t\tprops.onClick?.(e)\n\t\t\t\tonOpenChange?.(false)\n\t\t\t}}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nexport {\n\tAlertDialog,\n\tAlertDialogTrigger,\n\tAlertDialogContent,\n\tAlertDialogHeader,\n\tAlertDialogFooter,\n\tAlertDialogTitle,\n\tAlertDialogDescription,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/alert.tsx",
    "content": "import {cva, VariantProps} from 'class-variance-authority'\n\nimport {cn} from '@/lib/utils'\n\nimport {IconTypes} from './icon'\n\nexport function ErrorAlert({\n\ticon,\n\tdescription,\n\tclassName,\n\tselectable = false,\n}: {\n\ticon?: IconTypes\n\tdescription: React.ReactNode\n\tclassName?: string\n\t// Enable text selection for copying error messages. Defaults to false for native OS feel.\n\t// Set to true for actual errors users might need to copy for support/debugging.\n\tselectable?: boolean\n}) {\n\tconst IconComponent = icon\n\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t'flex items-center gap-2 rounded-8 bg-[#3C1C1C] p-2.5 text-13 leading-tight -tracking-2 text-[#FF3434]',\n\t\t\t\tselectable && 'select-text',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t>\n\t\t\t{IconComponent && <IconComponent className='h-5 w-5 shrink-0' />}\n\t\t\t<span className='opacity-90'>{description}</span>\n\t\t</div>\n\t)\n}\n\nconst alertVariants = cva(\n\t'relative rounded-lg py-2 px-3 gap-[5px] text-14 leading-tight -tracking-2 rounded-full truncate flex items-center',\n\t{\n\t\tvariants: {\n\t\t\tvariant: {\n\t\t\t\tdefault: 'bg-white/10',\n\t\t\t\twarning: 'bg-[#4B2D00] text-[#F27400]',\n\t\t\t\tdestructive: 'bg-[#3C1C1C] text-[#F23737]',\n\t\t\t\tsuccess: 'bg-[#142F14] text-[#18CE15]',\n\t\t\t},\n\t\t},\n\t\tdefaultVariants: {\n\t\t\tvariant: 'default',\n\t\t},\n\t},\n)\n\ntype AlertProps = React.HTMLAttributes<HTMLDivElement> &\n\tVariantProps<typeof alertVariants> & {\n\t\ticon?: IconTypes\n\t\tref?: React.Ref<HTMLDivElement>\n\t}\n\nfunction Alert({className, variant, icon, children, ref, ...props}: AlertProps) {\n\tconst Icon = icon\n\n\treturn (\n\t\t<div ref={ref} role='alert' className={cn(alertVariants({variant}), className)} {...props}>\n\t\t\t{Icon && <Icon className='h-4 w-4 shrink-0' />}\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n\nexport {Alert}\n\nexport function WarningAlert({\n\ticon,\n\tdescription,\n\tclassName,\n}: {\n\ticon?: IconTypes\n\tdescription: React.ReactNode\n\tclassName?: string\n}) {\n\tconst IconComponent = icon\n\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t'flex items-center gap-2 rounded-8 bg-[#4B2D00] p-2.5 text-13 leading-tight -tracking-2 text-[#ffb46e]',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t>\n\t\t\t{IconComponent && <IconComponent className='h-5 w-5 shrink-0' />}\n\t\t\t<span className='opacity-90'>{description}</span>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/animated-number.tsx",
    "content": "import {animate} from 'motion/react'\nimport {useEffect, useRef} from 'react'\nimport {usePrevious} from 'react-use'\n\ntype CounterProps = {\n\tto: number\n}\n\nexport function AnimatedNumber({to}: CounterProps) {\n\tconst nodeRef = useRef<HTMLSpanElement>(null)\n\tconst from = usePrevious(to) ?? to\n\n\tuseEffect(() => {\n\t\tconst node = nodeRef.current\n\n\t\tif (!node) {\n\t\t\treturn\n\t\t}\n\n\t\tif (to === Infinity || to === -Infinity || isNaN(to)) {\n\t\t\tnode.textContent = to.toString()\n\t\t\treturn\n\t\t}\n\n\t\tconst controls = animate(from, to, {\n\t\t\tduration: 0.3,\n\t\t\tease: 'easeInOut',\n\t\t\tonUpdate(value) {\n\t\t\t\tnode.textContent = value.toFixed(0)\n\t\t\t},\n\t\t})\n\n\t\treturn () => controls.stop()\n\t}, [from, to])\n\n\treturn <span className='tabular-nums' ref={nodeRef} />\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/arc.tsx",
    "content": "import React from 'react'\n\ninterface ProgressArcProps {\n\t/** Progress from 0 to 1 */\n\tprogress: number\n\tsize: number // Size of the SVG (width and height)\n\tstrokeWidth: number\n}\n\n// Used ChatGPT to generate the basics of the arc, then added `circleFraction` and other things myself\nexport const Arc: React.FC<ProgressArcProps> = ({progress, size, strokeWidth}) => {\n\tconst radius = (size - strokeWidth) / 2\n\tconst circumference = 2 * Math.PI * radius\n\n\t// 1 means it's a circle. 0.5 means it's a half circle\n\tconst circleFraction = 0.7\n\n\tconst outline = circumference - circumference * circleFraction\n\tconst offset = circumference - progress * circumference * circleFraction\n\n\t// SVG Path for a circle, but with the stroke offset to create the gap\n\tconst arcPath = `\n        M ${size / 2}, ${size / 2}\n        m 0, -${radius}\n        a ${radius},${radius} 0 1,1 0,${2 * radius}\n        a ${radius},${radius} 0 1,1 0,-${2 * radius}\n    `\n\n\t// Rotate the arc so the opening/gap is at the bottom\n\tconst arcAngle = (360 * circleFraction) / 2\n\n\tconst arcStyle = {\n\t\t'--full-length': circumference,\n\t\t'--final-offset': offset,\n\t\tanimation: `animate-arc 400ms ease-out forwards`,\n\t}\n\n\treturn (\n\t\t<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>\n\t\t\t<path\n\t\t\t\tclassName='stroke-white/10'\n\t\t\t\td={arcPath}\n\t\t\t\tfill='none'\n\t\t\t\tstrokeWidth={strokeWidth}\n\t\t\t\tstrokeDasharray={circumference}\n\t\t\t\tstrokeDashoffset={outline}\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\ttransform={`rotate(-${arcAngle} ${size / 2} ${size / 2})`}\n\t\t\t/>\n\t\t\t<path\n\t\t\t\tclassName='stroke-white'\n\t\t\t\td={arcPath}\n\t\t\t\tfill='none'\n\t\t\t\tstrokeWidth={strokeWidth}\n\t\t\t\tstrokeDasharray={circumference}\n\t\t\t\t// strokeDashoffset={offset}\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\ttransform={`rotate(-${arcAngle} ${size / 2} ${size / 2})`}\n\t\t\t\tstyle={arcStyle}\n\t\t\t/>\n\t\t</svg>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/badge.tsx",
    "content": "import {cva, type VariantProps} from 'class-variance-authority'\nimport {LucideIcon} from 'lucide-react'\nimport * as React from 'react'\nimport {IconType} from 'react-icons'\n\nimport {cn} from '@/lib/utils'\n\nconst badgeVariants = cva(\n\t'inline-flex items-center rounded-full border px-2 py-1.5 text-12 font-normal transition-colors leading-inter-trimmed',\n\t{\n\t\tvariants: {\n\t\t\tvariant: {\n\t\t\t\tdefault: 'border-white/10 text-white/90 bg-white/10',\n\t\t\t\tprimary: 'border-transparent text-white/90 bg-brand/70',\n\t\t\t\tdestructive: 'border-transparent text-white/90 bg-destructive/30',\n\t\t\t\toutline: 'text-white/90 border-white/10',\n\t\t\t},\n\t\t},\n\t\tdefaultVariants: {\n\t\t\tvariant: 'default',\n\t\t},\n\t},\n)\n\nexport interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {\n\ticon?: IconType | LucideIcon\n}\n\nfunction Badge({className, variant, icon, children, ...props}: BadgeProps) {\n\tconst Icon = icon\n\treturn (\n\t\t<div className={cn(badgeVariants({variant}), className)} {...props}>\n\t\t\t{Icon && <Icon className='mr-0.5 -ml-1' />}\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n\nexport {Badge, badgeVariants}\n"
  },
  {
    "path": "packages/ui/src/components/ui/button-link.tsx",
    "content": "import {type VariantProps} from 'class-variance-authority'\nimport * as React from 'react'\nimport {AnchorHTMLAttributes, ReactNode} from 'react'\nimport {Link, LinkProps} from 'react-router-dom'\n\nimport {buttonVariants} from '@/components/ui/button'\nimport {cn} from '@/lib/utils'\n\ntype CustomProps = VariantProps<typeof buttonVariants>\n\ntype ButtonLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> &\n\tLinkProps & {\n\t\tchildren?: ReactNode\n\t} & CustomProps & {\n\t\tref?: React.Ref<HTMLAnchorElement>\n\t}\n\nfunction ButtonLink({className, variant, text, size, ref, ...props}: ButtonLinkProps) {\n\treturn <Link className={cn(buttonVariants({variant, size, text, className}))} ref={ref} {...props} />\n}\n\nexport {ButtonLink}\n"
  },
  {
    "path": "packages/ui/src/components/ui/button.tsx",
    "content": "import {Slot} from '@radix-ui/react-slot'\nimport {cva, type VariantProps} from 'class-variance-authority'\nimport * as React from 'react'\n\nimport {cn} from '@/lib/utils'\n\nconst buttonVariants = cva(\n\t// `bg-clip-padding` to make button bg (especially in progress button) not be clipped by invisible border\n\t'inline-flex items-center justify-center font-medium transition-[color,background-color,scale,box-shadow,opacity] disabled:pointer-events-none disabled:opacity-50 -tracking-2 leading-inter-trimmed gap-1.5 focus:outline-hidden focus-visible:ring-3 shrink-0 disabled:shadow-none duration-300 umbrel-button bg-clip-padding',\n\t{\n\t\tvariants: {\n\t\t\tvariant: {\n\t\t\t\tdefault:\n\t\t\t\t\t'bg-white/10 active:bg-white/6 hover:bg-white/15 focus-visible:bg-white/10 border-[0.5px] border-white/20 ring-white/20 data-[state=open]:bg-white/10 shadow-button-highlight-soft-hpx focus-visible:border-white/20 focus-visible:border-1 data-[state=open]:border-1 data-[state=open]:border-white/20',\n\t\t\t\tprimary:\n\t\t\t\t\t'bg-brand hover:bg-brand-lighter focus-visible:bg-brand-lighter active:bg-brand ring-brand/40 data-[state=open]:bg-brand-lighter shadow-button-highlight-hpx',\n\t\t\t\tsecondary:\n\t\t\t\t\t'bg-white/90 hover:bg-white focus-visible:bg-white active:bg-white ring-white/40 data-[state=open]:bg-white text-black',\n\t\t\t\tdestructive:\n\t\t\t\t\t'bg-destructive2 hover:bg-destructive2-lighter focus-visible:bg-destructive2-lighter active:bg-destructive2 ring-destructive/40 data-[state=open]:bg-destructive2-lighter shadow-button-highlight-hpx',\n\t\t\t},\n\t\t\tsize: {\n\t\t\t\tsm: 'rounded-full h-[25px] px-[10px] text-12 gap-2',\n\t\t\t\tmd: 'rounded-full h-[30px] min-w-[80px] px-4 text-13',\n\t\t\t\t'md-squared': 'rounded-8 h-[36px] px-[10px] text-13 gap-2',\n\t\t\t\tdefault: 'rounded-full h-[30px] px-2.5 text-12',\n\t\t\t\t'input-short': 'rounded-full h-9 px-4 text-13 font-medium min-w-[80px]',\n\t\t\t\tdialog:\n\t\t\t\t\t'rounded-full h-[42px] md:h-[30px] min-w-[80px] px-4 font-semibold w-full md:w-auto md:font-medium text-13',\n\t\t\t\tlg: 'rounded-full h-[40px] px-[15px] text-15',\n\t\t\t\txl: 'rounded-10 h-[50px] px-[15px] text-13',\n\t\t\t\t'icon-only': 'rounded-full h-[30px] w-[30px]',\n\t\t\t},\n\t\t\ttext: {\n\t\t\t\tdefault: 'text-white',\n\t\t\t\tdestructive: 'text-destructive',\n\t\t\t},\n\t\t},\n\t\tcompoundVariants: [\n\t\t\t{\n\t\t\t\tvariant: 'primary',\n\t\t\t\tsize: 'lg',\n\t\t\t\tclass: 'shadow-button-highlight',\n\t\t\t},\n\t\t],\n\t\tdefaultVariants: {\n\t\t\tvariant: 'default',\n\t\t\tsize: 'default',\n\t\t},\n\t},\n)\n\nexport interface ButtonProps\n\textends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {\n\tasChild?: boolean\n}\n\nfunction Button({\n\tclassName,\n\tvariant,\n\tsize,\n\ttext,\n\tasChild = false,\n\tchildren,\n\tref,\n\t...props\n}: ButtonProps & {ref?: React.Ref<HTMLButtonElement>}) {\n\tconst Comp = asChild ? Slot : 'button'\n\n\t// No children for icon-only buttons\n\tconst children2 = size === 'icon-only' ? null : children\n\n\t// Prevents ordinary buttons in forms from submitting it\n\tconst extraPropsIfButton = Comp === 'button' ? {...props, type: props.type ?? 'button'} : props\n\n\treturn (\n\t\t<Comp className={cn(buttonVariants({variant, size, text, className}))} ref={ref} {...extraPropsIfButton}>\n\t\t\t{children2}\n\t\t</Comp>\n\t)\n}\n\nexport {Button, buttonVariants}\n"
  },
  {
    "path": "packages/ui/src/components/ui/card.tsx",
    "content": "import {HtmlHTMLAttributes} from 'react'\n\nimport {cn} from '@/lib/utils'\nimport {tw} from '@/utils/tw'\n\nexport function Card({\n\tchildren,\n\tclassName,\n\t...props\n}: {children?: React.ReactNode; className?: string} & HtmlHTMLAttributes<HTMLDivElement>) {\n\treturn (\n\t\t<div className={cn(cardClass, className)} {...props}>\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n\nexport const cardClass = tw`rounded-12 bg-white/5 px-3 py-4 max-lg:min-h-[95px] lg:p-6`\n"
  },
  {
    "path": "packages/ui/src/components/ui/carousel.tsx",
    "content": "import useEmblaCarousel, {type UseEmblaCarouselType} from 'embla-carousel-react'\nimport {ArrowLeft, ArrowRight} from 'lucide-react'\nimport * as React from 'react'\n\nimport {Button} from '@/components/ui/button'\nimport {cn} from '@/lib/utils'\n\ntype CarouselApi = UseEmblaCarouselType[1]\ntype UseCarouselParameters = Parameters<typeof useEmblaCarousel>\ntype CarouselOptions = UseCarouselParameters[0]\ntype CarouselPlugin = UseCarouselParameters[1]\n\ntype CarouselProps = {\n\topts?: CarouselOptions\n\tplugins?: CarouselPlugin\n\torientation?: 'horizontal' | 'vertical'\n\tsetApi?: (api: CarouselApi) => void\n}\n\ntype CarouselContextProps = {\n\tcarouselRef: ReturnType<typeof useEmblaCarousel>[0]\n\tapi: ReturnType<typeof useEmblaCarousel>[1]\n\tscrollPrev: () => void\n\tscrollNext: () => void\n\tcanScrollPrev: boolean\n\tcanScrollNext: boolean\n} & CarouselProps\n\nconst CarouselContext = React.createContext<CarouselContextProps | null>(null)\n\nfunction useCarousel() {\n\tconst context = React.useContext(CarouselContext)\n\n\tif (!context) {\n\t\tthrow new Error('useCarousel must be used within a <Carousel />')\n\t}\n\n\treturn context\n}\n\nfunction Carousel({\n\torientation = 'horizontal',\n\topts,\n\tsetApi,\n\tplugins,\n\tclassName,\n\tchildren,\n\tref,\n\t...props\n}: React.HTMLAttributes<HTMLDivElement> & CarouselProps & {ref?: React.Ref<HTMLDivElement>}) {\n\tconst [carouselRef, api] = useEmblaCarousel(\n\t\t{\n\t\t\t...opts,\n\t\t\taxis: orientation === 'horizontal' ? 'x' : 'y',\n\t\t},\n\t\tplugins,\n\t)\n\tconst [canScrollPrev, setCanScrollPrev] = React.useState(false)\n\tconst [canScrollNext, setCanScrollNext] = React.useState(false)\n\n\tconst onSelect = React.useCallback((api: CarouselApi) => {\n\t\tif (!api) {\n\t\t\treturn\n\t\t}\n\n\t\tsetCanScrollPrev(api.canScrollPrev())\n\t\tsetCanScrollNext(api.canScrollNext())\n\t}, [])\n\n\tconst scrollPrev = React.useCallback(() => {\n\t\tapi?.scrollPrev()\n\t}, [api])\n\n\tconst scrollNext = React.useCallback(() => {\n\t\tapi?.scrollNext()\n\t}, [api])\n\n\tconst handleKeyDown = React.useCallback(\n\t\t(event: React.KeyboardEvent<HTMLDivElement>) => {\n\t\t\tif (event.key === 'ArrowLeft') {\n\t\t\t\tevent.preventDefault()\n\t\t\t\tscrollPrev()\n\t\t\t} else if (event.key === 'ArrowRight') {\n\t\t\t\tevent.preventDefault()\n\t\t\t\tscrollNext()\n\t\t\t}\n\t\t},\n\t\t[scrollPrev, scrollNext],\n\t)\n\n\tReact.useEffect(() => {\n\t\tif (!api || !setApi) {\n\t\t\treturn\n\t\t}\n\n\t\tsetApi(api)\n\t}, [api, setApi])\n\n\tReact.useEffect(() => {\n\t\tif (!api) {\n\t\t\treturn\n\t\t}\n\n\t\tonSelect(api)\n\t\tapi.on('reInit', onSelect)\n\t\tapi.on('select', onSelect)\n\n\t\treturn () => {\n\t\t\tapi?.off('select', onSelect)\n\t\t}\n\t}, [api, onSelect])\n\n\treturn (\n\t\t<CarouselContext\n\t\t\tvalue={{\n\t\t\t\tcarouselRef,\n\t\t\t\tapi: api,\n\t\t\t\topts,\n\t\t\t\torientation: orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),\n\t\t\t\tscrollPrev,\n\t\t\t\tscrollNext,\n\t\t\t\tcanScrollPrev,\n\t\t\t\tcanScrollNext,\n\t\t\t}}\n\t\t>\n\t\t\t<div\n\t\t\t\tref={ref}\n\t\t\t\tonKeyDownCapture={handleKeyDown}\n\t\t\t\tclassName={cn('relative', className)}\n\t\t\t\trole='region'\n\t\t\t\taria-roledescription='carousel'\n\t\t\t\t{...props}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</div>\n\t\t</CarouselContext>\n\t)\n}\n\nfunction CarouselContent({\n\tclassName,\n\tref,\n\t...props\n}: React.HTMLAttributes<HTMLDivElement> & {ref?: React.Ref<HTMLDivElement>}) {\n\tconst {carouselRef, orientation} = useCarousel()\n\n\treturn (\n\t\t<div ref={carouselRef} className='overflow-hidden'>\n\t\t\t<div\n\t\t\t\tref={ref}\n\t\t\t\tclassName={cn('flex', orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', className)}\n\t\t\t\t{...props}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n\nfunction CarouselItem({\n\tclassName,\n\tref,\n\t...props\n}: React.HTMLAttributes<HTMLDivElement> & {ref?: React.Ref<HTMLDivElement>}) {\n\tconst {orientation} = useCarousel()\n\n\treturn (\n\t\t<div\n\t\t\tref={ref}\n\t\t\trole='group'\n\t\t\taria-roledescription='slide'\n\t\t\tclassName={cn('min-w-0 shrink-0 grow-0 basis-full', orientation === 'horizontal' ? 'pl-4' : 'pt-4', className)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction CarouselPrevious({\n\tclassName,\n\tref,\n\tvariant = 'default',\n\tsize = 'icon-only',\n\t...props\n}: React.ComponentProps<typeof Button> & {ref?: React.Ref<HTMLButtonElement>}) {\n\tconst {orientation, scrollPrev, canScrollPrev} = useCarousel()\n\n\treturn (\n\t\t<Button\n\t\t\tref={ref}\n\t\t\tvariant={variant}\n\t\t\tsize={size}\n\t\t\tclassName={cn(\n\t\t\t\t'absolute h-8 w-8 rounded-full',\n\t\t\t\torientation === 'horizontal'\n\t\t\t\t\t? 'top-1/2 -left-12 -translate-y-1/2'\n\t\t\t\t\t: '-top-12 left-1/2 -translate-x-1/2 rotate-90',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\tdisabled={!canScrollPrev}\n\t\t\tonClick={scrollPrev}\n\t\t\t{...props}\n\t\t>\n\t\t\t<ArrowLeft className='h-4 w-4' />\n\t\t\t<span className='sr-only'>Previous slide</span>\n\t\t</Button>\n\t)\n}\n\nfunction CarouselNext({\n\tclassName,\n\tref,\n\tvariant = 'default',\n\tsize = 'icon-only',\n\t...props\n}: React.ComponentProps<typeof Button> & {ref?: React.Ref<HTMLButtonElement>}) {\n\tconst {orientation, scrollNext, canScrollNext} = useCarousel()\n\n\treturn (\n\t\t<Button\n\t\t\tref={ref}\n\t\t\tvariant={variant}\n\t\t\tsize={size}\n\t\t\tclassName={cn(\n\t\t\t\t'absolute h-8 w-8 rounded-full',\n\t\t\t\torientation === 'horizontal'\n\t\t\t\t\t? 'top-1/2 -right-12 -translate-y-1/2'\n\t\t\t\t\t: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\tdisabled={!canScrollNext}\n\t\t\tonClick={scrollNext}\n\t\t\t{...props}\n\t\t>\n\t\t\t<ArrowRight className='h-4 w-4' />\n\t\t\t<span className='sr-only'>Next slide</span>\n\t\t</Button>\n\t)\n}\n\nexport {type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext}\n"
  },
  {
    "path": "packages/ui/src/components/ui/checkbox.tsx",
    "content": "import * as CheckboxPrimitive from '@radix-ui/react-checkbox'\nimport * as React from 'react'\nimport {TbCheck, TbMinus} from 'react-icons/tb'\n\nimport {cn} from '@/lib/utils'\nimport {tw} from '@/utils/tw'\n\nfunction Checkbox({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {\n\tref?: React.Ref<React.ComponentRef<typeof CheckboxPrimitive.Root>>\n}) {\n\treturn (\n\t\t<CheckboxPrimitive.Root\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t'group peer h-5 w-5 shrink-0 rounded-5 border border-white/20 bg-white/10 ring-offset-neutral-950 transition-[color,background-color,opacity] focus-visible:ring-2 focus-visible:ring-neutral-300 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-brand data-[state=checked]:text-neutral-50 data-[state=checked]:text-white data-[state=indeterminate]:bg-brand data-[state=indeterminate]:text-neutral-50 data-[state=indeterminate]:text-white',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t>\n\t\t\t<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>\n\t\t\t\t<TbCheck\n\t\t\t\t\tclassName='hidden h-4 w-4 animate-in shadow-xs duration-100 ease-out fade-in zoom-in-150 group-data-[state=checked]:block [&>*]:stroke-[3px]'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tfilter: 'drop-shadow(#00000055 0px 1px 1px)',\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t<TbMinus\n\t\t\t\t\tclassName='hidden h-4 w-4 animate-in shadow-xs duration-100 ease-out fade-in zoom-in-150 group-data-[state=indeterminate]:block [&>*]:stroke-[3px]'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tfilter: 'drop-shadow(#00000055 0px 1px 1px)',\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</CheckboxPrimitive.Indicator>\n\t\t</CheckboxPrimitive.Root>\n\t)\n}\n\nconst checkboxContainerClass = tw`flex items-center space-x-2`\n// Removing `peer-disabled:cursor-not-allowed` because we want to disable the checkbox while it's going to the server without changing the cursor\nconst checkboxLabelClass = tw`text-15 font-medium leading-none peer-disabled:opacity-50`\n\nexport {Checkbox, checkboxContainerClass, checkboxLabelClass}\n"
  },
  {
    "path": "packages/ui/src/components/ui/command.tsx",
    "content": "import * as DialogPrimitive from '@radix-ui/react-dialog'\nimport {DialogProps} from '@radix-ui/react-dialog'\nimport {Command as CommandPrimitive} from 'cmdk'\nimport * as React from 'react'\nimport {RiCloseCircleFill} from 'react-icons/ri'\nimport {mergeRefs} from 'react-merge-refs'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {useFadeScroller} from '@/components/fade-scroller'\nimport {Dialog} from '@/components/ui/dialog'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {cn} from '@/lib/utils'\n\nimport {dialogContentAnimationClass, dialogContentClass, dialogOverlayClass} from './shared/dialog'\n\nfunction Command({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof CommandPrimitive> & {\n\tref?: React.Ref<React.ComponentRef<typeof CommandPrimitive>>\n}) {\n\treturn (\n\t\t<CommandPrimitive ref={ref} className={cn('flex h-full w-full flex-col overflow-hidden', className)} {...props} />\n\t)\n}\n\ntype CommandDialogProps = DialogProps\n\nconst CommandDialog = ({children, ...props}: CommandDialogProps) => {\n\treturn (\n\t\t<Dialog {...props}>\n\t\t\t<BlurOverlay />\n\t\t\t<DialogPrimitive.Content\n\t\t\t\tclassName={cn(\n\t\t\t\t\tdialogContentClass,\n\t\t\t\t\tdialogContentAnimationClass,\n\t\t\t\t\t'data-[state=closed]:slide-out-to-top-0 data-[state=open]:slide-in-from-top-0',\n\t\t\t\t\t'top-4 translate-y-0 overflow-hidden p-3 md:p-[30px] lg:top-[10%]',\n\t\t\t\t\t'w-full max-w-[calc(100%-40px)] sm:max-w-[700px]',\n\t\t\t\t\t'z-[999]',\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t<Command\n\t\t\t\t\tloop\n\t\t\t\t\tclassName='[&_[cmdk-group-heading]]:font-medium[&_[cmdk-group-heading]]:text-neutral-400 flex flex-col gap-3 md:gap-5 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0'\n\t\t\t\t>\n\t\t\t\t\t{children}\n\t\t\t\t</Command>\n\t\t\t</DialogPrimitive.Content>\n\t\t</Dialog>\n\t)\n}\n\nfunction CommandInput({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {\n\tref?: React.Ref<React.ComponentRef<typeof CommandPrimitive.Input>>\n}) {\n\treturn (\n\t\t<div className='flex items-center pr-2' cmdk-input-wrapper=''>\n\t\t\t<CommandPrimitive.Input\n\t\t\t\tref={ref}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'flex w-full rounded-md bg-transparent p-2 text-15 font-medium -tracking-2 outline-hidden placeholder:text-white/25 disabled:cursor-not-allowed disabled:opacity-50',\n\t\t\t\t\tclassName,\n\t\t\t\t)}\n\t\t\t\t{...props}\n\t\t\t/>\n\t\t\t<CommandCloseButton />\n\t\t</div>\n\t)\n}\n\nfunction CommandList({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> & {\n\tref?: React.Ref<React.ComponentRef<typeof CommandPrimitive.List>>\n}) {\n\tconst {scrollerClass, ref: localRef} = useFadeScroller('y')\n\treturn (\n\t\t<CommandPrimitive.List\n\t\t\tref={mergeRefs([localRef, ref])}\n\t\t\tclassName={cn(scrollerClass, 'overflow-x-hidden overflow-y-auto', className)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction CommandEmpty({\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> & {\n\tref?: React.Ref<React.ComponentRef<typeof CommandPrimitive.Empty>>\n}) {\n\treturn <CommandPrimitive.Empty ref={ref} className='py-6 text-center text-sm' {...props} />\n}\n\nfunction CommandGroup({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> & {\n\tref?: React.Ref<React.ComponentRef<typeof CommandPrimitive.Group>>\n}) {\n\treturn <CommandPrimitive.Group ref={ref} className={cn('overflow-hidden text-neutral-50', className)} {...props} />\n}\n\nfunction CommandSeparator({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> & {\n\tref?: React.Ref<React.ComponentRef<typeof CommandPrimitive.Separator>>\n}) {\n\treturn <CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-white', className)} {...props} />\n}\n\n// Accept either a string (image source URL) or a React node for the icon\ntype CommandItemIcon = string | React.ReactNode\n\nfunction CommandItem({\n\tclassName,\n\tref,\n\ticon,\n\tchildren,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> & {\n\ticon?: CommandItemIcon\n\tref?: React.Ref<React.ComponentRef<typeof CommandPrimitive.Item>>\n}) {\n\tconst isMobile = useIsMobile()\n\treturn (\n\t\t<CommandPrimitive.Item\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t'group relative flex cursor-default items-center gap-3 rounded-8 p-2 text-13 font-medium -tracking-2 outline-hidden aria-selected:bg-white/4 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 md:text-15',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t>\n\t\t\t{icon &&\n\t\t\t\t(typeof icon === 'string' ? (\n\t\t\t\t\t<AppIcon src={icon} size={isMobile ? 24 : 36} className='rounded-6 sm:rounded-8' />\n\t\t\t\t) : (\n\t\t\t\t\t// When a custom React node is provided, we still want to constrain its\n\t\t\t\t\t// dimensions so spacing stays consistent across command items.\n\t\t\t\t\t<span\n\t\t\t\t\t\tclassName='flex items-center justify-center'\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\twidth: isMobile ? '24px' : '36px',\n\t\t\t\t\t\t\theight: isMobile ? '24px' : '36px',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{icon}\n\t\t\t\t\t</span>\n\t\t\t\t))}\n\t\t\t{children}\n\t\t\t<CommandShortcut className='mr-1 hidden group-aria-selected:block'>↵</CommandShortcut>\n\t\t</CommandPrimitive.Item>\n\t)\n}\n\nconst CommandShortcut = ({className, ...props}: React.HTMLAttributes<HTMLSpanElement>) => {\n\treturn <span className={cn('ml-auto text-xs tracking-widest text-white/30', className)} {...props} />\n}\n\nexport {\n\tCommand,\n\tCommandDialog,\n\tCommandEmpty,\n\tCommandGroup,\n\tCommandInput,\n\tCommandItem,\n\tCommandList,\n\tCommandSeparator,\n\tCommandShortcut,\n}\n\nfunction BlurOverlay({ref}: {ref?: React.Ref<HTMLDivElement>}) {\n\treturn (\n\t\t<DialogPrimitive.DialogOverlay\n\t\t\tref={ref}\n\t\t\tclassName={cn(dialogOverlayClass, 'z-[999] bg-black/30 backdrop-blur-xl contrast-more:backdrop-blur-none')}\n\t\t/>\n\t)\n}\n\nconst CommandCloseButton = () => (\n\t<DialogPrimitive.Close className='rounded-full opacity-30 ring-white/60 outline-hidden transition-opacity hover:opacity-40 focus-visible:opacity-40 focus-visible:ring-2'>\n\t\t<RiCloseCircleFill className='h-[18px] w-[18px] md:h-5 md:w-5' />\n\t\t<span className='sr-only'>Close</span>\n\t</DialogPrimitive.Close>\n)\n"
  },
  {
    "path": "packages/ui/src/components/ui/context-menu.tsx",
    "content": "import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'\nimport {Check, ChevronRight, Circle} from 'lucide-react'\nimport * as React from 'react'\n\nimport {cn} from '@/lib/utils'\n\nimport {contextMenuClasses} from './shared/menu'\n\nconst ContextMenu = ContextMenuPrimitive.Root\n\nconst ContextMenuTrigger = ContextMenuPrimitive.Trigger\n\nconst ContextMenuGroup = ContextMenuPrimitive.Group\n\nconst ContextMenuPortal = ContextMenuPrimitive.Portal\n\nconst ContextMenuSub = ContextMenuPrimitive.Sub\n\nconst ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup\n\nfunction ContextMenuSubTrigger({\n\tclassName,\n\tinset,\n\tchildren,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {\n\tinset?: boolean\n\tref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.SubTrigger>>\n}) {\n\treturn (\n\t\t<ContextMenuPrimitive.SubTrigger\n\t\t\tref={ref}\n\t\t\tclassName={cn(contextMenuClasses.item.root, inset && 'pl-8', className)}\n\t\t\t{...props}\n\t\t>\n\t\t\t{children}\n\t\t\t<ChevronRight className='ml-auto h-4 w-4' />\n\t\t</ContextMenuPrimitive.SubTrigger>\n\t)\n}\n\nfunction ContextMenuSubContent({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> & {\n\tref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.SubContent>>\n}) {\n\treturn (\n\t\t<ContextMenuPrimitive.SubContent\n\t\t\tref={ref}\n\t\t\tclassName={cn(contextMenuClasses.content, className)}\n\t\t\t{...props}\n\t\t\t// Prevent right-clicks within subcontent from triggering parent context menus\n\t\t\tonContextMenu={(e) => {\n\t\t\t\te.preventDefault() // Prevent default browser context menu\n\t\t\t\te.stopPropagation()\n\t\t\t}}\n\t\t/>\n\t)\n}\n\nfunction ContextMenuContent({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {\n\tref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.Content>>\n}) {\n\treturn (\n\t\t<ContextMenuPrimitive.Portal>\n\t\t\t<ContextMenuPrimitive.Content\n\t\t\t\tref={ref}\n\t\t\t\tclassName={cn(contextMenuClasses.content, className)}\n\t\t\t\t{...props}\n\t\t\t\t// Prevent right-clicks within content from triggering parent context menus\n\t\t\t\tonContextMenu={(e) => {\n\t\t\t\t\te.preventDefault() // Prevent default browser context menu\n\t\t\t\t\te.stopPropagation()\n\t\t\t\t}}\n\t\t\t/>\n\t\t</ContextMenuPrimitive.Portal>\n\t)\n}\n\nfunction ContextMenuItem({\n\tclassName,\n\tinset,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {\n\tinset?: boolean\n\tref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.Item>>\n}) {\n\treturn (\n\t\t<ContextMenuPrimitive.Item\n\t\t\tref={ref}\n\t\t\tclassName={cn(contextMenuClasses.item.root, inset && 'pl-8', className)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction ContextMenuCheckboxItem({\n\tclassName,\n\tchildren,\n\tchecked,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {\n\tref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.CheckboxItem>>\n}) {\n\treturn (\n\t\t<ContextMenuPrimitive.CheckboxItem\n\t\t\tref={ref}\n\t\t\tclassName={cn(contextMenuClasses.checkboxItem.root, className)}\n\t\t\tchecked={checked}\n\t\t\t{...props}\n\t\t>\n\t\t\t<span className={contextMenuClasses.checkboxItem.indicatorWrapper}>\n\t\t\t\t<ContextMenuPrimitive.ItemIndicator>\n\t\t\t\t\t<Check className='h-4 w-4' />\n\t\t\t\t</ContextMenuPrimitive.ItemIndicator>\n\t\t\t</span>\n\t\t\t{children}\n\t\t</ContextMenuPrimitive.CheckboxItem>\n\t)\n}\n\nfunction ContextMenuRadioItem({\n\tclassName,\n\tchildren,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {\n\tref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.RadioItem>>\n}) {\n\treturn (\n\t\t<ContextMenuPrimitive.RadioItem ref={ref} className={cn(contextMenuClasses.radioItem.root, className)} {...props}>\n\t\t\t<span className={contextMenuClasses.radioItem.indicatorWrapper}>\n\t\t\t\t<ContextMenuPrimitive.ItemIndicator>\n\t\t\t\t\t<Circle className='h-4 w-4 fill-current' />\n\t\t\t\t</ContextMenuPrimitive.ItemIndicator>\n\t\t\t</span>\n\t\t\t{children}\n\t\t</ContextMenuPrimitive.RadioItem>\n\t)\n}\n\nfunction ContextMenuLabel({\n\tclassName,\n\tinset,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {\n\tinset?: boolean\n\tref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.Label>>\n}) {\n\treturn (\n\t\t<ContextMenuPrimitive.Label\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t'px-2 py-1.5 text-sm font-semibold text-neutral-950 dark:text-neutral-50',\n\t\t\t\tinset && 'pl-8',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction ContextMenuSeparator({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> & {\n\tref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.Separator>>\n}) {\n\treturn <ContextMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-white/5', className)} {...props} />\n}\n\nconst ContextMenuShortcut = ({className, ...props}: React.HTMLAttributes<HTMLSpanElement>) => {\n\treturn (\n\t\t<span\n\t\t\tclassName={cn('ml-auto text-xs tracking-widest text-neutral-500 dark:text-neutral-400', className)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nexport {\n\tContextMenu,\n\tContextMenuTrigger,\n\tContextMenuContent,\n\tContextMenuItem,\n\tContextMenuCheckboxItem,\n\tContextMenuRadioItem,\n\tContextMenuLabel,\n\tContextMenuSeparator,\n\tContextMenuShortcut,\n\tContextMenuGroup,\n\tContextMenuPortal,\n\tContextMenuSub,\n\tContextMenuSubContent,\n\tContextMenuSubTrigger,\n\tContextMenuRadioGroup,\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/copy-button.tsx",
    "content": "import {useState} from 'react'\nimport {TbCopy} from 'react-icons/tb'\nimport {useCopyToClipboard} from 'react-use'\n\nimport {Tooltip, TooltipContent, TooltipTrigger} from '@/components/ui/tooltip'\nimport {t} from '@/utils/i18n'\nimport {sleep} from '@/utils/misc'\n\nexport function CopyButton({value}: {value: string}) {\n\tconst [, copyToClipboard] = useCopyToClipboard()\n\tconst [showCopied, setShowCopied] = useState(false)\n\n\treturn (\n\t\t<Tooltip open={showCopied}>\n\t\t\t<TooltipTrigger asChild>\n\t\t\t\t<button\n\t\t\t\t\tclassName='rounded-4 opacity-20 transition-opacity ring-inset hover:opacity-40 focus:outline-hidden focus-visible:opacity-60'\n\t\t\t\t\tonClick={async () => {\n\t\t\t\t\t\tcopyToClipboard(value)\n\t\t\t\t\t\tsetShowCopied(true)\n\t\t\t\t\t\tawait sleep(1000)\n\t\t\t\t\t\tsetShowCopied(false)\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<TbCopy className='shrink-0' />\n\t\t\t\t</button>\n\t\t\t</TooltipTrigger>\n\t\t\t{/* TODO: consider putting in portal to avoid inheriting parent's styling */}\n\t\t\t<TooltipContent>{t('clipboard.copied')}</TooltipContent>\n\t\t</Tooltip>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/copyable-field.tsx",
    "content": "import {useRef, useState, type RefObject} from 'react'\nimport {MdContentCopy} from 'react-icons/md'\nimport {useCopyToClipboard} from 'react-use'\nimport {useIsFocused} from 'use-is-focused'\n\nimport {Tooltip, TooltipContent, TooltipTrigger} from '@/components/ui/tooltip'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\nimport {sleep} from '@/utils/misc'\n\nexport function CopyableField({\n\tvalue,\n\tclassName,\n\tisPassword,\n\tnarrow,\n}: {\n\tvalue: string\n\tclassName?: string\n\tisPassword?: boolean\n\tnarrow?: boolean\n}) {\n\tconst ref = useRef<HTMLInputElement>(null)\n\tconst [, copyToClipboard] = useCopyToClipboard()\n\tconst [showCopied, setShowCopied] = useState(false)\n\n\tconst focused = useIsFocused(ref as RefObject<HTMLInputElement>)\n\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t// 'items-stretch' to make sure button is the same height as the field\n\t\t\t\t'flex max-w-full items-stretch rounded-4 border border-dashed border-white/5 bg-white/4 text-14 leading-none text-white/40 outline-hidden focus-visible:border-white/40',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t>\n\t\t\t<input\n\t\t\t\treadOnly\n\t\t\t\tref={ref}\n\t\t\t\tonClick={() => setTimeout(() => ref.current?.select())}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'block min-w-0 flex-1 appearance-none truncate bg-transparent py-1.5 pl-2.5 font-mono outline-hidden',\n\t\t\t\t\tnarrow && 'py-0.5',\n\t\t\t\t)}\n\t\t\t\ttype={isPassword && !focused ? 'password' : 'text'}\n\t\t\t\tvalue={value}\n\t\t\t/>\n\n\t\t\t<Tooltip open={showCopied}>\n\t\t\t\t<TooltipTrigger asChild>\n\t\t\t\t\t<button\n\t\t\t\t\t\tclassName='rounded-4 px-1.5 transition-colors ring-inset hover:text-white/50 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/40'\n\t\t\t\t\t\tonClick={async () => {\n\t\t\t\t\t\t\tcopyToClipboard(value)\n\t\t\t\t\t\t\tsetShowCopied(true)\n\t\t\t\t\t\t\tawait sleep(1000)\n\t\t\t\t\t\t\tsetShowCopied(false)\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<MdContentCopy className='shrink-0' />\n\t\t\t\t\t</button>\n\t\t\t\t</TooltipTrigger>\n\t\t\t\t{/* TODO: consider putting in portal to avoid inheriting parent's styling */}\n\t\t\t\t<TooltipContent>{t('clipboard.copied')}</TooltipContent>\n\t\t\t</Tooltip>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/cover-message.tsx",
    "content": "import {Portal} from '@radix-ui/react-portal'\nimport {useEffect, useState} from 'react'\n\nimport {cn} from '@/lib/utils'\nimport {Wallpaper} from '@/providers/wallpaper'\nimport {tw} from '@/utils/tw'\n\nimport {DarkenLayer} from '../darken-layer'\n\n/** Compiler-safe replacement for react-use's useTimeout */\nfunction useDelayedShow(ms: number) {\n\tconst [show, setShow] = useState(false)\n\tuseEffect(() => {\n\t\tconst id = setTimeout(() => setShow(true), ms)\n\t\treturn () => clearTimeout(id)\n\t}, [ms])\n\treturn show\n}\n\n/** Cover message without  */\nexport function BareCoverMessage({\n\tchildren,\n\tdelayed,\n\tonClick,\n}: {\n\tchildren: React.ReactNode\n\tdelayed?: boolean\n\tonClick?: () => void\n}) {\n\tconst show = useDelayedShow(600)\n\n\treturn (\n\t\t<CoverMessageContent>\n\t\t\t<div className='absolute inset-0 z-50 bg-black' onClick={onClick}>\n\t\t\t\t<div className={coverMessageBodyClass}>{!delayed ? children : show && children}</div>\n\t\t\t</div>\n\t\t</CoverMessageContent>\n\t)\n}\n\n/** Covers entire screen to show a message */\nexport function CoverMessage({\n\tchildren,\n\tbodyClassName,\n\tonClick,\n\tdelayed,\n}: {\n\tchildren: React.ReactNode\n\tbodyClassName?: string\n\tonClick?: () => void\n\tdelayed?: boolean\n}) {\n\tconst show = useDelayedShow(600)\n\n\treturn (\n\t\t<CoverMessageContent>\n\t\t\t{/* <div className='absolute inset-0 z-50'> */}\n\t\t\t<Wallpaper className='z-50' stayBlurred />\n\t\t\t<DarkenLayer className='z-50 animate-in duration-700 fade-in' />\n\t\t\t<div onClick={onClick} className={cn(coverMessageBodyClass, bodyClassName)}>\n\t\t\t\t{!delayed ? children : show && children}\n\t\t\t</div>\n\t\t\t{/* </div> */}\n\t\t</CoverMessageContent>\n\t)\n}\n\n// ---\n\nexport const COVER_MESSAGE_TARGET_ID = 'cover-message-id'\n\nexport function CoverMessageTarget() {\n\treturn <div id={COVER_MESSAGE_TARGET_ID} />\n}\nexport function CoverMessageContent({children}: {children: React.ReactNode}) {\n\t// `?? undefined` to ensure we put portal in default place otherwise\n\treturn <Portal container={document.getElementById(COVER_MESSAGE_TARGET_ID) ?? undefined}>{children}</Portal>\n}\n\nexport function CoverMessageParagraph({children, className}: {children: React.ReactNode; className?: string}) {\n\treturn <p className={cn(tw`max-sm: px-4 text-center text-13 text-white/60`, className)}>{children}</p>\n}\n\nexport const coverMessageBodyClass = tw`fixed inset-0 z-50 flex flex-col items-center justify-center gap-1 duration-700 animate-in fade-in fill-mode-both`\n"
  },
  {
    "path": "packages/ui/src/components/ui/debug-only.tsx",
    "content": "import {IS_DEV} from '@/utils/misc'\n\nexport function DebugOnly({children}: {children: React.ReactNode}) {\n\tif (IS_DEV) {\n\t\treturn (\n\t\t\t<div className='relative border border-dotted border-white/50 p-2'>\n\t\t\t\t{children}\n\t\t\t\t<div className='absolute top-0 left-0 bg-destructive2 px-0.5 text-[8px]'>development only</div>\n\t\t\t</div>\n\t\t)\n\t}\n\treturn null\n}\n\nexport function DebugOnlyBare({children}: {children: React.ReactNode}) {\n\tif (IS_DEV) {\n\t\treturn <>{children}</>\n\t}\n\treturn null\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/dialog-close-button.tsx",
    "content": "import * as DialogPrimitive from '@radix-ui/react-dialog'\nimport {RiCloseCircleFill} from 'react-icons/ri'\n\nimport {cn} from '@/lib/utils'\nimport {dialogHeaderCircleButtonClass} from '@/utils/element-classes'\nimport {t} from '@/utils/i18n'\n\nexport const DialogCloseButton = ({className}: {className?: React.ReactNode}) => (\n\t<DialogPrimitive.Close className={cn(dialogHeaderCircleButtonClass, className)}>\n\t\t<RiCloseCircleFill className='h-5 w-5 lg:h-6 lg:w-6' />\n\t\t<span className='sr-only'>{t('close')}</span>\n\t</DialogPrimitive.Close>\n)\n"
  },
  {
    "path": "packages/ui/src/components/ui/dialog.tsx",
    "content": "import * as DialogPrimitive from '@radix-ui/react-dialog'\nimport * as React from 'react'\n\nimport {DialogCloseButton} from '@/components/ui/dialog-close-button'\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {cn} from '@/lib/utils'\n\nimport {\n\tdialogContentAnimationClass,\n\tdialogContentAnimationSlideClass,\n\tdialogContentClass,\n\tdialogFooterClass,\n\tdialogOverlayClass,\n} from './shared/dialog'\n\nconst Dialog = DialogPrimitive.Root\n\nconst DialogTrigger = DialogPrimitive.Trigger\n\nconst DialogPortal = (props: DialogPrimitive.DialogPortalProps) => <DialogPrimitive.Portal {...props} />\nDialogPortal.displayName = DialogPrimitive.Portal.displayName\n\nfunction DialogOverlay({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {\n\tref?: React.Ref<React.ComponentRef<typeof DialogPrimitive.Overlay>>\n}) {\n\treturn <DialogPrimitive.Overlay ref={ref} className={cn(dialogOverlayClass, className)} {...props} />\n}\n\nfunction DialogContent({\n\tclassName,\n\tchildren,\n\tslide = true,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {slide?: boolean} & {\n\tref?: React.Ref<React.ComponentRef<typeof DialogPrimitive.Content>>\n}) {\n\treturn (\n\t\t<DialogPortal>\n\t\t\t<DialogOverlay />\n\t\t\t<DialogPrimitive.Content\n\t\t\t\tref={ref}\n\t\t\t\tclassName={cn(\n\t\t\t\t\tdialogContentClass,\n\t\t\t\t\tdialogContentAnimationClass,\n\t\t\t\t\tslide && dialogContentAnimationSlideClass,\n\t\t\t\t\t'w-full max-w-[calc(100%-40px)] sm:max-w-[480px]',\n\t\t\t\t\tclassName,\n\t\t\t\t)}\n\t\t\t\t{...props}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t\t{/* <DialogPrimitive.Close className=\"absolute right-4 top-4 rounded-xs opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400\">\n        <X className=\"h-4 w-4\" />\n        <span className=\"sr-only\">Close</span>\n      </DialogPrimitive.Close> */}\n\t\t\t</DialogPrimitive.Content>\n\t\t</DialogPortal>\n\t)\n}\n\nconst DialogScrollableContent = ({\n\tchildren,\n\tshowClose,\n\tonOpenAutoFocus,\n}: {\n\tchildren: React.ReactNode\n\tshowClose?: boolean\n\tonOpenAutoFocus?: (e: Event) => void\n}) => {\n\treturn (\n\t\t<DialogContent className='flex flex-col p-0' onOpenAutoFocus={onOpenAutoFocus}>\n\t\t\t{/* TODO: adjust dialog inset if `showClose` is true so close button isn't too close to scrollbar */}\n\t\t\t<ScrollArea className='flex flex-col' dialogInset>\n\t\t\t\t{children}\n\t\t\t</ScrollArea>\n\t\t\t{showClose && <DialogCloseButton className='absolute top-2 right-2 z-50' />}\n\t\t</DialogContent>\n\t)\n}\n\nconst DialogHeader = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (\n\t<div className={cn('flex flex-col space-y-1.5', className)} {...props} />\n)\nDialogHeader.displayName = 'DialogHeader'\n\nconst DialogFooter = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (\n\t<div className={cn(dialogFooterClass, className)} {...props} />\n)\nDialogFooter.displayName = 'DialogFooter'\n\nfunction DialogTitle({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> & {\n\tref?: React.Ref<React.ComponentRef<typeof DialogPrimitive.Title>>\n}) {\n\treturn (\n\t\t<DialogPrimitive.Title\n\t\t\tref={ref}\n\t\t\tclassName={cn('text-left text-17 leading-snug font-semibold -tracking-2', className)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction DialogDescription({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & {\n\tref?: React.Ref<React.ComponentRef<typeof DialogPrimitive.Description>>\n}) {\n\treturn (\n\t\t<DialogPrimitive.Description\n\t\t\tref={ref}\n\t\t\tclassName={cn('text-left text-13 leading-tight font-normal -tracking-2 text-white/40', className)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nexport {\n\tDialog,\n\tDialogContent,\n\tDialogScrollableContent,\n\tDialogDescription,\n\tDialogFooter,\n\tDialogHeader,\n\tDialogPortal,\n\tDialogTitle,\n\tDialogTrigger,\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/drawer.tsx",
    "content": "import * as React from 'react'\nimport {Drawer as DrawerPrimitive} from 'vaul'\n\nimport {FadeScroller} from '@/components/fade-scroller'\nimport {cn} from '@/lib/utils'\n\nconst Drawer = ({shouldScaleBackground = false, ...props}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (\n\t<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />\n)\n\nconst DrawerTrigger = DrawerPrimitive.Trigger\n\nconst DrawerPortal = DrawerPrimitive.Portal\n\nconst DrawerClose = DrawerPrimitive.Close\n\nfunction DrawerOverlay({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> & {\n\tref?: React.Ref<React.ComponentRef<typeof DrawerPrimitive.Overlay>>\n}) {\n\treturn <DrawerPrimitive.Overlay ref={ref} className={cn('fixed inset-0 z-50 bg-black/50', className)} {...props} />\n}\n\nfunction DrawerContent({\n\tclassName,\n\tref,\n\tchildren,\n\tfullHeight,\n\twithScroll,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & {\n\tfullHeight?: boolean\n\twithScroll?: boolean\n\tref?: React.Ref<React.ComponentRef<typeof DrawerPrimitive.Content>>\n}) {\n\treturn (\n\t\t<DrawerPortal>\n\t\t\t<DrawerOverlay />\n\t\t\t<DrawerPrimitive.Content\n\t\t\t\tref={ref}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col gap-5 rounded-t-20 bg-[#0F0F0F] p-5 outline-hidden',\n\t\t\t\t\tfullHeight && 'top-0',\n\t\t\t\t\tclassName,\n\t\t\t\t)}\n\t\t\t\tstyle={{\n\t\t\t\t\tboxShadow: '0px 2px 2px 0px hsla(0, 0%, 100%, 0.05) inset',\n\t\t\t\t}}\n\t\t\t\t{...props}\n\t\t\t>\n\t\t\t\t{/* -mb-[4px] so height is effectively zero */}\n\t\t\t\t<div className='top-6 mx-auto -mb-[4px] h-[4px] w-[40px] shrink-0 rounded-full bg-white/10' />\n\t\t\t\t{!withScroll && children}\n\t\t\t\t{withScroll && <DrawerScroller>{children}</DrawerScroller>}\n\t\t\t</DrawerPrimitive.Content>\n\t\t</DrawerPortal>\n\t)\n}\n\nconst DrawerHeader = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (\n\t<div className={cn('grid gap-0.5', className)} {...props} />\n)\n\nconst DrawerFooter = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (\n\t<div className={cn('mt-auto flex shrink-0 flex-col gap-2.5', className)} {...props} />\n)\n\nfunction DrawerTitle({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> & {\n\tref?: React.Ref<React.ComponentRef<typeof DrawerPrimitive.Title>>\n}) {\n\treturn <DrawerPrimitive.Title ref={ref} className={cn('text-19 leading-tight font-bold', className)} {...props} />\n}\n\nfunction DrawerDescription({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> & {\n\tref?: React.Ref<React.ComponentRef<typeof DrawerPrimitive.Description>>\n}) {\n\treturn (\n\t\t<DrawerPrimitive.Description\n\t\t\tref={ref}\n\t\t\tclassName={cn('text-12 leading-tight -tracking-2 opacity-50', className)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\n// Put this in the content of a `Drawer` to make it scrollable. You might need to add `flex-1` to the parent.\nfunction DrawerScroller({children}: {children: React.ReactNode}) {\n\treturn (\n\t\t<FadeScroller direction='y' className='flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto'>\n\t\t\t{children}\n\t\t</FadeScroller>\n\t)\n}\n\nexport {\n\tDrawer,\n\tDrawerPortal,\n\tDrawerOverlay,\n\tDrawerTrigger,\n\tDrawerClose,\n\tDrawerContent,\n\tDrawerHeader,\n\tDrawerFooter,\n\tDrawerTitle,\n\tDrawerDescription,\n\tDrawerScroller,\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/dropdown-menu.tsx",
    "content": "import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'\nimport {Check, ChevronRight, Circle} from 'lucide-react'\nimport * as React from 'react'\n\nimport {cn} from '@/lib/utils'\n\nimport {dropdownClasses} from './shared/menu'\n\nconst DropdownMenu = DropdownMenuPrimitive.Root\n\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger\n\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group\n\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal\n\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub\n\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup\n\nfunction DropdownMenuSubTrigger({\n\tclassName,\n\tinset,\n\tchildren,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {\n\tinset?: boolean\n\tref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>>\n}) {\n\treturn (\n\t\t<DropdownMenuPrimitive.SubTrigger\n\t\t\tref={ref}\n\t\t\tclassName={cn(dropdownClasses.item.root, inset && 'pl-8', className)}\n\t\t\t{...props}\n\t\t>\n\t\t\t{children}\n\t\t\t<ChevronRight className='ml-auto h-4 w-4' />\n\t\t</DropdownMenuPrimitive.SubTrigger>\n\t)\n}\n\nfunction DropdownMenuSubContent({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & {\n\tref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>>\n}) {\n\treturn (\n\t\t<DropdownMenuPrimitive.SubContent\n\t\t\tref={ref}\n\t\t\tclassName={cn(dropdownClasses.content, className)}\n\t\t\t{...props}\n\t\t\t// Prevent right-clicks within subcontent from triggering parent context menus\n\t\t\tonContextMenu={(e) => {\n\t\t\t\te.preventDefault() // Prevent default browser context menu\n\t\t\t\te.stopPropagation()\n\t\t\t}}\n\t\t/>\n\t)\n}\n\nfunction DropdownMenuContent({\n\tclassName,\n\tsideOffset = 4,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {\n\tref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.Content>>\n}) {\n\treturn (\n\t\t<DropdownMenuPrimitive.Portal>\n\t\t\t<DropdownMenuPrimitive.Content\n\t\t\t\tref={ref}\n\t\t\t\tsideOffset={sideOffset}\n\t\t\t\tclassName={cn(dropdownClasses.content, className)}\n\t\t\t\t{...props}\n\t\t\t\t// Prevent right-clicks within content from triggering parent context menus\n\t\t\t\tonContextMenu={(e) => {\n\t\t\t\t\te.preventDefault() // Prevent default browser context menu\n\t\t\t\t\te.stopPropagation()\n\t\t\t\t}}\n\t\t\t/>\n\t\t</DropdownMenuPrimitive.Portal>\n\t)\n}\n\nfunction DropdownMenuItem({\n\tclassName,\n\tinset,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {\n\tinset?: boolean\n\tref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.Item>>\n}) {\n\treturn (\n\t\t<DropdownMenuPrimitive.Item\n\t\t\tref={ref}\n\t\t\tclassName={cn(dropdownClasses.item.root, inset && 'pl-8', className)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction DropdownMenuCheckboxItem({\n\tclassName,\n\tchildren,\n\tchecked,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {\n\tref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>>\n}) {\n\treturn (\n\t\t<DropdownMenuPrimitive.CheckboxItem\n\t\t\tref={ref}\n\t\t\tclassName={cn(dropdownClasses.checkboxItem.root, className)}\n\t\t\tchecked={checked}\n\t\t\t{...props}\n\t\t>\n\t\t\t{children}\n\t\t\t<DropdownMenuPrimitive.ItemIndicator className={dropdownClasses.checkboxItem.indicatorWrapper}>\n\t\t\t\t<Check className='h-4 w-4' />\n\t\t\t</DropdownMenuPrimitive.ItemIndicator>\n\t\t</DropdownMenuPrimitive.CheckboxItem>\n\t)\n}\n\nfunction DropdownMenuRadioItem({\n\tclassName,\n\tchildren,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {\n\tref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>>\n}) {\n\treturn (\n\t\t<DropdownMenuPrimitive.RadioItem ref={ref} className={cn(dropdownClasses.radioItem.root, className)} {...props}>\n\t\t\t<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>\n\t\t\t\t<DropdownMenuPrimitive.ItemIndicator>\n\t\t\t\t\t<Circle className='h-2 w-2 fill-current' />\n\t\t\t\t</DropdownMenuPrimitive.ItemIndicator>\n\t\t\t</span>\n\t\t\t{children}\n\t\t</DropdownMenuPrimitive.RadioItem>\n\t)\n}\n\nfunction DropdownMenuLabel({\n\tclassName,\n\tinset,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {\n\tinset?: boolean\n\tref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.Label>>\n}) {\n\treturn (\n\t\t<DropdownMenuPrimitive.Label\n\t\t\tref={ref}\n\t\t\tclassName={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction DropdownMenuSeparator({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {\n\tref?: React.Ref<React.ComponentRef<typeof DropdownMenuPrimitive.Separator>>\n}) {\n\treturn (\n\t\t<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-2.5 my-2.5 h-px bg-white/5', className)} {...props} />\n\t)\n}\n\nconst DropdownMenuShortcut = ({className, ...props}: React.HTMLAttributes<HTMLSpanElement>) => {\n\treturn <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />\n}\n\nexport {\n\tDropdownMenu,\n\tDropdownMenuTrigger,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuCheckboxItem,\n\tDropdownMenuRadioItem,\n\tDropdownMenuLabel,\n\tDropdownMenuSeparator,\n\tDropdownMenuShortcut,\n\tDropdownMenuGroup,\n\tDropdownMenuPortal,\n\tDropdownMenuSub,\n\tDropdownMenuSubContent,\n\tDropdownMenuSubTrigger,\n\tDropdownMenuRadioGroup,\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/error-boundary-card-fallback.tsx",
    "content": "import type {FallbackProps} from 'react-error-boundary'\nimport {useRouteError} from 'react-router-dom'\n\nimport {Button} from '@/components/ui/button'\nimport {Card} from '@/components/ui/card'\nimport {GenericErrorDetails, GenericErrorText} from '@/components/ui/generic-error-text'\nimport {t} from '@/utils/i18n'\n\nfunction useRouteErrorSafe() {\n\ttry {\n\t\treturn useRouteError()\n\t} catch {\n\t\treturn null\n\t}\n}\n\n/**\n * Used for larger areas like the settings page, dialog content, etc.\n */\nexport function ErrorBoundaryCardFallback({error, resetErrorBoundary}: Partial<FallbackProps>) {\n\tconst routeError = useRouteErrorSafe()\n\tconst resolvedError = error ?? routeError\n\n\treturn (\n\t\t// Wrap div to prevent flex parent from sizing this element inappropriately\n\t\t<div className='relative w-full'>\n\t\t\t<Card className='grid w-full animate-in place-items-center fade-in zoom-in-150 md:h-60'>\n\t\t\t\t<div className='flex flex-col items-center gap-2'>\n\t\t\t\t\t<GenericErrorText />\n\t\t\t\t\t{resetErrorBoundary && (\n\t\t\t\t\t\t<Button size='sm' variant='default' onClick={resetErrorBoundary}>\n\t\t\t\t\t\t\t{t('try-again')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</Card>\n\t\t\t{resolvedError != null && <GenericErrorDetails error={resolvedError} />}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/error-boundary-page-fallback.tsx",
    "content": "import {ChevronDown, ChevronUp} from 'lucide-react'\nimport {useState} from 'react'\nimport type {FallbackProps} from 'react-error-boundary'\nimport {useNavigate, useRouteError} from 'react-router-dom'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {Button} from '@/components/ui/button'\nimport {Dock, DockBottomPositioner} from '@/modules/desktop/dock'\nimport {AppsProvider} from '@/providers/apps'\nimport {AvailableAppsProvider} from '@/providers/available-apps'\nimport {Wallpaper} from '@/providers/wallpaper'\nimport {t} from '@/utils/i18n'\nimport {downloadLogs} from '@/utils/logs'\n\nfunction useRouteErrorSafe() {\n\ttry {\n\t\treturn useRouteError()\n\t} catch {\n\t\treturn null\n\t}\n}\n\nfunction getErrorMessage(error: unknown): string {\n\tif (error instanceof Error) return error.message\n\tif (typeof error === 'string') return error\n\treturn String(error)\n}\n\n/**\n * Used for when we can't reasonably replace the component with error text. EX: wallpaper or cmdk\n */\nexport function ErrorBoundaryPageFallback({error}: Partial<FallbackProps> = {}) {\n\tconst navigate = useNavigate()\n\tconst [showDetails, setShowDetails] = useState(false)\n\n\tconst routeError = useRouteErrorSafe()\n\tconst resolvedError = error ?? routeError\n\n\treturn (\n\t\t<>\n\t\t\t<Wallpaper />\n\t\t\t<AppsProvider>\n\t\t\t\t<AvailableAppsProvider>\n\t\t\t\t\t<DockBottomPositioner>\n\t\t\t\t\t\t<Dock />\n\t\t\t\t\t</DockBottomPositioner>\n\t\t\t\t</AvailableAppsProvider>\n\t\t\t</AppsProvider>\n\t\t\t<AlertDialog open>\n\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t\t<AlertDialogTitle>{t('something-went-wrong')}</AlertDialogTitle>\n\t\t\t\t\t\t<AlertDialogDescription></AlertDialogDescription>\n\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t\t<AlertDialogAction onClick={() => navigate('/')}>{t('not-found-404.home')}</AlertDialogAction>\n\t\t\t\t\t\t<Button size='dialog' variant='default' onClick={() => downloadLogs()}>\n\t\t\t\t\t\t\t{t('download-logs')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t\t{resolvedError != null && (\n\t\t\t\t\t\t<div className='-mb-4 flex flex-col items-center'>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\tonClick={() => setShowDetails((prev) => !prev)}\n\t\t\t\t\t\t\t\tclassName='flex items-center gap-0.5 text-11 text-white/30 transition-opacity duration-300 hover:text-white/50'\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{showDetails ? t('hide-details') : t('show-details')}\n\t\t\t\t\t\t\t\t{showDetails ? <ChevronUp className='size-3' /> : <ChevronDown className='size-3' />}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t{showDetails && (\n\t\t\t\t\t\t\t\t<p className='mt-1 max-h-40 w-full overflow-y-auto text-11 break-all text-white/30'>\n\t\t\t\t\t\t\t\t\t{getErrorMessage(resolvedError)}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</AlertDialogContent>\n\t\t\t</AlertDialog>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/fade-in-img.tsx",
    "content": "import {useState} from 'react'\n\nimport {cn} from '@/lib/utils'\n\nexport function FadeInImg({src, alt, className, ...props}: React.ImgHTMLAttributes<HTMLImageElement>) {\n\tconst [loaded, setLoaded] = useState(false)\n\n\treturn (\n\t\t<img\n\t\t\tsrc={src}\n\t\t\talt={alt}\n\t\t\tclassName={cn('transition-opacity duration-500 fill-mode-both', loaded ? 'opacity-100' : 'opacity-0', className)}\n\t\t\tonLoad={() => {\n\t\t\t\tsetLoaded(true)\n\t\t\t}}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/form.tsx",
    "content": "import * as LabelPrimitive from '@radix-ui/react-label'\nimport {Slot} from '@radix-ui/react-slot'\nimport * as React from 'react'\nimport {\n\tController,\n\tFormProvider,\n\tuseFormContext,\n\tuseFormState,\n\ttype ControllerProps,\n\ttype FieldPath,\n\ttype FieldValues,\n} from 'react-hook-form'\n\nimport {Label} from '@/components/ui/label'\nimport {cn} from '@/lib/utils'\n\nconst Form = FormProvider\n\ntype FormFieldContextValue<\n\tTFieldValues extends FieldValues = FieldValues,\n\tTName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n> = {\n\tname: TName\n}\n\nconst FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)\n\nconst FormField = <\n\tTFieldValues extends FieldValues = FieldValues,\n\tTName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n>({\n\t...props\n}: ControllerProps<TFieldValues, TName>) => {\n\treturn (\n\t\t<FormFieldContext value={{name: props.name}}>\n\t\t\t<Controller {...props} />\n\t\t</FormFieldContext>\n\t)\n}\n\nconst useFormField = () => {\n\tconst fieldContext = React.useContext(FormFieldContext)\n\tconst itemContext = React.useContext(FormItemContext)\n\tconst {getFieldState} = useFormContext()\n\tconst formState = useFormState({name: fieldContext.name})\n\tconst fieldState = getFieldState(fieldContext.name, formState)\n\n\tif (!fieldContext) {\n\t\tthrow new Error('useFormField should be used within <FormField>')\n\t}\n\n\tconst {id} = itemContext\n\n\treturn {\n\t\tid,\n\t\tname: fieldContext.name,\n\t\tformItemId: `${id}-form-item`,\n\t\tformDescriptionId: `${id}-form-item-description`,\n\t\tformMessageId: `${id}-form-item-message`,\n\t\t...fieldState,\n\t}\n}\n\ntype FormItemContextValue = {\n\tid: string\n}\n\nconst FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)\n\nfunction FormItem({className, ...props}: React.ComponentProps<'div'>) {\n\tconst id = React.useId()\n\n\treturn (\n\t\t<FormItemContext value={{id}}>\n\t\t\t<div data-slot='form-item' className={cn('grid gap-2', className)} {...props} />\n\t\t</FormItemContext>\n\t)\n}\n\nfunction FormLabel({className, ...props}: React.ComponentProps<typeof LabelPrimitive.Root>) {\n\tconst {error, formItemId} = useFormField()\n\n\treturn (\n\t\t<Label\n\t\t\tdata-slot='form-label'\n\t\t\tdata-error={!!error}\n\t\t\tclassName={cn('data-[error=true]:text-destructive', className)}\n\t\t\thtmlFor={formItemId}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction FormControl({...props}: React.ComponentProps<typeof Slot>) {\n\tconst {error, formItemId, formDescriptionId, formMessageId} = useFormField()\n\n\treturn (\n\t\t<Slot\n\t\t\tdata-slot='form-control'\n\t\t\tid={formItemId}\n\t\t\taria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}\n\t\t\taria-invalid={!!error}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction FormDescription({className, ...props}: React.ComponentProps<'p'>) {\n\tconst {formDescriptionId} = useFormField()\n\n\treturn (\n\t\t<p\n\t\t\tdata-slot='form-description'\n\t\t\tid={formDescriptionId}\n\t\t\tclassName={cn('text-muted-foreground text-sm', className)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction FormMessage({className, ...props}: React.ComponentProps<'p'>) {\n\tconst {error, formMessageId} = useFormField()\n\tconst body = error ? String(error?.message ?? '') : props.children\n\n\tif (!body) {\n\t\treturn null\n\t}\n\n\t// Allow text selection for copying error messages\n\treturn (\n\t\t<p\n\t\t\tdata-slot='form-message'\n\t\t\tid={formMessageId}\n\t\t\tclassName={cn('text-sm text-destructive select-text', className)}\n\t\t\t{...props}\n\t\t>\n\t\t\t{body}\n\t\t</p>\n\t)\n}\n\nexport {useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField}\n"
  },
  {
    "path": "packages/ui/src/components/ui/generic-error-text.tsx",
    "content": "import {ChevronDown, ChevronUp} from 'lucide-react'\nimport {useState} from 'react'\n\nimport {t} from '@/utils/i18n'\n\nfunction getErrorMessage(error: unknown): string {\n\tif (error instanceof Error) return error.message\n\tif (typeof error === 'string') return error\n\treturn String(error)\n}\n\nexport function GenericErrorText({error}: {error?: unknown}) {\n\tconst [showDetails, setShowDetails] = useState(false)\n\n\treturn (\n\t\t<div className='flex flex-col items-center'>\n\t\t\t<div className='font-semibold text-destructive2-lightest'>{t('something-went-wrong')}</div>\n\t\t\t{error != null && (\n\t\t\t\t<div className='flex flex-col items-center'>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\tonClick={() => setShowDetails((prev) => !prev)}\n\t\t\t\t\t\tclassName='mt-1 flex items-center gap-0.5 text-11 text-white/30 transition-opacity duration-300 hover:text-white/50'\n\t\t\t\t\t>\n\t\t\t\t\t\t{showDetails ? t('hide-details') : t('show-details')}\n\t\t\t\t\t\t{showDetails ? <ChevronUp className='size-3' /> : <ChevronDown className='size-3' />}\n\t\t\t\t\t</button>\n\t\t\t\t\t{showDetails && (\n\t\t\t\t\t\t<p className='mt-1 max-h-40 overflow-y-auto text-11 break-all text-white/30'>{getErrorMessage(error)}</p>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\nexport function GenericErrorDetails({error}: {error: unknown}) {\n\tconst [showDetails, setShowDetails] = useState(false)\n\n\treturn (\n\t\t<div className='flex flex-col items-center p-3'>\n\t\t\t<button\n\t\t\t\ttype='button'\n\t\t\t\tonClick={() => setShowDetails((prev) => !prev)}\n\t\t\t\tclassName='flex items-center gap-0.5 text-11 text-white/30 transition-opacity duration-300 hover:text-white/50'\n\t\t\t>\n\t\t\t\t{showDetails ? t('hide-details') : t('show-details')}\n\t\t\t\t{showDetails ? <ChevronUp className='size-3' /> : <ChevronDown className='size-3' />}\n\t\t\t</button>\n\t\t\t{showDetails && (\n\t\t\t\t<p className='mt-1 max-h-40 overflow-y-auto text-11 break-all text-white/30'>{getErrorMessage(error)}</p>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/icon-button-link.tsx",
    "content": "import {type VariantProps} from 'class-variance-authority'\nimport * as React from 'react'\nimport {AnchorHTMLAttributes, ReactNode} from 'react'\nimport {Link, LinkProps} from 'react-router-dom'\n\nimport {buttonVariants} from '@/components/ui/button'\nimport {cn} from '@/lib/utils'\n\nimport {Icon, IconTypes} from './icon'\n\ntype CustomProps = VariantProps<typeof buttonVariants> & {\n\ticon?: IconTypes\n}\n\ntype IconButtonLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> &\n\tLinkProps & {\n\t\tchildren?: ReactNode\n\t} & CustomProps & {\n\t\tref?: React.Ref<HTMLAnchorElement>\n\t}\n\nfunction IconButtonLink({className, variant, text, size, icon, children, ref, ...props}: IconButtonLinkProps) {\n\treturn (\n\t\t<Link className={cn(buttonVariants({variant, size, text, className}))} ref={ref} {...props}>\n\t\t\t{icon && <Icon component={icon} size={size} />}\n\t\t\t{children}\n\t\t</Link>\n\t)\n}\n\nexport {IconButtonLink}\n"
  },
  {
    "path": "packages/ui/src/components/ui/icon-button.tsx",
    "content": "import {type VariantProps} from 'class-variance-authority'\nimport * as React from 'react'\n\nimport {buttonVariants} from '@/components/ui/button'\nimport {cn} from '@/lib/utils'\n\nimport {Icon, IconTypes} from './icon'\n\nexport interface ButtonProps\n\textends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {\n\ticon: IconTypes\n\tref?: React.Ref<HTMLButtonElement>\n}\n\nfunction IconButton({className, variant, text, size, icon, children, ref, ...props}: ButtonProps) {\n\t// No children for icon-only buttons\n\tconst children2 = size === 'icon-only' ? null : children\n\n\treturn (\n\t\t<button className={cn(buttonVariants({variant, size, text, className}))} ref={ref} {...props}>\n\t\t\t<Icon component={icon} size={size} />\n\t\t\t{children2}\n\t\t</button>\n\t)\n}\n\nexport {IconButton}\n"
  },
  {
    "path": "packages/ui/src/components/ui/icon.tsx",
    "content": "import type {VariantProps} from 'class-variance-authority'\nimport type {LucideIcon} from 'lucide-react'\nimport type {IconType} from 'react-icons'\n\nimport {buttonVariants} from '@/components/ui/button'\nimport {cn} from '@/lib/utils'\n\ntype SizeVariant = VariantProps<typeof buttonVariants>['size']\ntype Size = NonNullable<SizeVariant>\n\nexport type IconTypes = IconType | LucideIcon\n\ntype IconProps = {\n\tcomponent: IconTypes\n\tstyle?: React.CSSProperties\n\tsize?: SizeVariant\n} & Omit<React.ComponentPropsWithoutRef<'svg'>, 'size'>\n\nexport const sizeMap = {\n\tsm: '12px',\n\tmd: '14px',\n\tdefault: '14px',\n\t'input-short': '16px',\n\t'md-squared': '16px',\n\tlg: '17px',\n\txl: '13px',\n\t//\n\tdialog: '14px',\n\t'icon-only': '14px',\n} as const satisfies Record<Size, string>\n\nexport function Icon({component, size = 'default', style, className, ...props}: IconProps) {\n\tconst Comp = component\n\n\treturn (\n\t\t<Comp\n\t\t\tclassName={cn('shrink-0 opacity-80', className)}\n\t\t\t{...props}\n\t\t\tstyle={{\n\t\t\t\t...style,\n\t\t\t\twidth: sizeMap[size ?? 'default'],\n\t\t\t\theight: sizeMap[size ?? 'default'],\n\t\t\t}}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/immersive-dialog.tsx",
    "content": "import {Dialog, DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogTrigger} from '@radix-ui/react-dialog'\nimport {motion} from 'motion/react'\nimport {Children, ComponentPropsWithoutRef, ReactNode, useEffect} from 'react'\nimport {RiCloseLine} from 'react-icons/ri'\n\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {\n\tdialogContentAnimationClass,\n\tdialogContentAnimationSlideClass,\n\tdialogContentClass,\n\tdialogOverlayClass,\n} from '@/components/ui/shared/dialog'\nimport {cn} from '@/lib/utils'\nimport {useImmersiveDialogCounter} from '@/providers/immersive-dialog'\nimport {tw} from '@/utils/tw'\n\nimport {IconTypes} from './icon'\nimport {IconButton} from './icon-button'\n\nexport const immersiveDialogTitleClass = tw`text-24 font-bold leading-none -tracking-4 text-white/80`\nexport const immersiveDialogDescriptionClass = tw`text-15 font-normal leading-tight -tracking-2 text-white/40`\n\nexport function ImmersiveDialogSeparator() {\n\treturn <hr className='w-full border-white/10' />\n}\n\n// Wrapper that tracks open state in context so other components (like floating islands) can react.\n// For example, when an immersive dialog is open, the floating islands z-index is raised to show above it.\nexport function ImmersiveDialog({open, children, ...props}: ComponentPropsWithoutRef<typeof Dialog>) {\n\tconst {increment, decrement} = useImmersiveDialogCounter()\n\n\t// Increment counter on open, decrement on close/unmount.\n\t// Counter approach is more robust than boolean for complex dialogs (see provider comments).\n\tuseEffect(() => {\n\t\tif (open) {\n\t\t\tincrement()\n\t\t\treturn () => decrement()\n\t\t}\n\t}, [open, increment, decrement])\n\n\treturn (\n\t\t<Dialog open={open} {...props}>\n\t\t\t{children}\n\t\t</Dialog>\n\t)\n}\nexport const ImmersiveDialogTrigger = DialogTrigger\n\nexport function ImmersiveDialogContent({\n\tchildren,\n\tsize = 'default',\n\tshort = false,\n\tshowScroll = false,\n\tref,\n\t...contentProps\n}: {\n\tchildren: React.ReactNode\n\tsize?: 'default' | 'sm' | 'md' | 'lg' | 'xl'\n\tshort?: boolean\n\tshowScroll?: boolean\n\tref?: React.Ref<HTMLDivElement>\n} & ComponentPropsWithoutRef<typeof DialogContent>) {\n\treturn (\n\t\t<DialogContent\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\tdialogContentClass,\n\t\t\t\tdialogContentAnimationClass,\n\t\t\t\tdialogContentAnimationSlideClass,\n\t\t\t\tshort ? immersiveContentShortClass : immersiveContentTallClass,\n\t\t\t\t// overrides default size\n\t\t\t\tsize === 'sm' && 'max-w-[600px]',\n\t\t\t\tsize === 'md' && 'max-w-[900px]',\n\t\t\t\tsize === 'lg' && 'max-w-[980px]',\n\t\t\t\tsize === 'xl' && 'max-w-[1440px]',\n\t\t\t\t'p-0',\n\t\t\t)}\n\t\t\t{...contentProps}\n\t\t>\n\t\t\t{showScroll ? (\n\t\t\t\t<ScrollArea dialogInset className='h-full'>\n\t\t\t\t\t<div className={immersiveScrollAreaContentsClass}>{children}</div>\n\t\t\t\t</ScrollArea>\n\t\t\t) : (\n\t\t\t\t<div className={immersiveScrollAreaContentsClass}>{children}</div>\n\t\t\t)}\n\t\t\t<ImmersiveDialogClose />\n\t\t</DialogContent>\n\t)\n}\n\nexport function ImmersiveDialogSplitContent({\n\tchildren,\n\tside,\n\tref,\n\t...contentProps\n}: {children: React.ReactNode; side: React.ReactNode; ref?: React.Ref<HTMLDivElement>} & ComponentPropsWithoutRef<\n\ttypeof DialogContent\n>) {\n\treturn (\n\t\t<DialogPortal>\n\t\t\t<ImmersiveDialogOverlay />\n\t\t\t<DialogContent\n\t\t\t\tref={ref}\n\t\t\t\tclassName={cn(\n\t\t\t\t\tdialogContentClass,\n\t\t\t\t\t'bg-transparent shadow-none ring-2 ring-white/3', // remove shadow from `dialogContentClass`\n\t\t\t\t\tdialogContentAnimationClass,\n\t\t\t\t\tdialogContentAnimationSlideClass,\n\t\t\t\t\timmersiveContentTallClass,\n\t\t\t\t\t'flex flex-row justify-between gap-0 p-0',\n\t\t\t\t)}\n\t\t\t\t{...contentProps}\n\t\t\t>\n\t\t\t\t<section className='hidden w-[210px] flex-col items-center justify-center bg-black/40 md:flex md:rounded-l-20'>\n\t\t\t\t\t{side}\n\t\t\t\t</section>\n\t\t\t\t<section className='min-w-0 flex-1 overflow-hidden bg-dialog-content/70 max-md:rounded-20 md:rounded-r-20'>\n\t\t\t\t\t<ScrollArea dialogInset className='h-full'>\n\t\t\t\t\t\t<div className={immersiveScrollAreaContentsClass}>{children}</div>\n\t\t\t\t\t</ScrollArea>\n\t\t\t\t</section>\n\t\t\t\t<ImmersiveDialogClose />\n\t\t\t</DialogContent>\n\t\t</DialogPortal>\n\t)\n}\n\nconst immersiveContentShortClass = tw`w-[calc(100%-40px)] max-w-[800px] max-h-[calc(100dvh-90px)]`\nconst immersiveContentTallClass = tw`top-[calc(50%-30px)] max-h-[800px] w-[calc(100%-40px)] max-w-[800px] h-[calc(100dvh-90px)]`\nconst immersiveScrollAreaContentsClass = tw`flex h-full flex-col gap-6 p-4 md:p-8`\n\nexport function ImmersiveDialogOverlay({ref}: {ref?: React.Ref<HTMLDivElement>}) {\n\treturn (\n\t\t<DialogOverlay\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\tdialogOverlayClass,\n\t\t\t\t'transform-gpu bg-black/30 backdrop-blur-xl will-change-[backdrop-filter] contrast-more:backdrop-blur-none',\n\t\t\t)}\n\t\t/>\n\t)\n}\n\nfunction ImmersiveDialogClose() {\n\treturn (\n\t\t<div className='absolute top-full left-1/2 mt-5 -translate-x-1/2'>\n\t\t\t{/* Note, because this parent has a backdrop, this button won't have a backdrop */}\n\t\t\t<DialogClose asChild>\n\t\t\t\t<IconButton\n\t\t\t\t\ticon={RiCloseLine}\n\t\t\t\t\t// Overriding state colors\n\t\t\t\t\tclassName='h-[36px] w-[36px] border-none bg-dialog-content/70 shadow-immersive-dialog-close hover:border-solid hover:bg-dialog-content focus:border-solid focus:bg-dialog-content active:bg-dialog-content'\n\t\t\t\t/>\n\t\t\t</DialogClose>\n\t\t</div>\n\t)\n}\n\nexport function ImmersiveDialogBody({\n\ttitle,\n\tdescription,\n\tbodyText,\n\tchildren,\n\tfooter,\n}: {\n\ttitle: string\n\tdescription: string\n\tbodyText: React.ReactNode\n\tchildren: React.ReactNode\n\tfooter: React.ReactNode\n}) {\n\treturn (\n\t\t<div className='flex h-full flex-col items-start gap-5'>\n\t\t\t<div className='space-y-2'>\n\t\t\t\t<h1 className={immersiveDialogTitleClass}>{title}</h1>\n\t\t\t\t<p className={immersiveDialogDescriptionClass}>{description}</p>\n\t\t\t</div>\n\t\t\t<ImmersiveDialogSeparator />\n\t\t\t<div className='w-full space-y-2.5'>\n\t\t\t\t<div className={cn('mb-4', bodyTextClass)}>{bodyText}</div>\n\t\t\t\t<AnimateIn>{children}</AnimateIn>\n\t\t\t</div>\n\t\t\t<div className='flex-1' />\n\t\t\t<ImmersiveDialogFooter>{footer}</ImmersiveDialogFooter>\n\t\t</div>\n\t)\n}\n\nconst bodyTextClass = tw`text-15 font-medium leading-none -tracking-4 text-white/90`\n\nfunction AnimateIn({children}: {children: React.ReactNode}) {\n\treturn (\n\t\t<>\n\t\t\t{Children.map(children, (child, i) => (\n\t\t\t\t<motion.div\n\t\t\t\t\t// TODO: don't use index as key\n\t\t\t\t\tkey={i}\n\t\t\t\t\tinitial={{opacity: 0, translateY: 10}}\n\t\t\t\t\tanimate={{opacity: 1, translateY: 0}}\n\t\t\t\t\ttransition={{delay: i * 0.2 + 0.1}}\n\t\t\t\t>\n\t\t\t\t\t{child}\n\t\t\t\t</motion.div>\n\t\t\t))}\n\t\t</>\n\t)\n}\n\nexport function ImmersiveDialogFooter({children, className}: {children: React.ReactNode; className?: string}) {\n\treturn <div className={cn('flex w-full flex-wrap-reverse items-center gap-2', className)}>{children}</div>\n}\n\nexport function ImmersiveDialogIconMessage({\n\ticon,\n\ttitle,\n\tdescription,\n\tclassName,\n\ticonClassName,\n}: {\n\ticon: IconTypes\n\ttitle: ReactNode\n\tdescription?: ReactNode\n\tclassName?: string\n\ticonClassName?: string\n}) {\n\tconst IconComponent = icon\n\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t'inline-flex w-full items-center gap-2 rounded-10 border border-white/4 bg-white/4 p-2 text-left font-normal',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\tstyle={{\n\t\t\t\tboxShadow: '0px 40px 60px 0px rgba(0, 0, 0, 0.10)',\n\t\t\t}}\n\t\t>\n\t\t\t<div className='flex h-8 w-8 shrink-0 items-center justify-center rounded-8 bg-white/4'>\n\t\t\t\t<IconComponent className={cn('h-5 w-5 [&>*]:stroke-1', iconClassName)} />\n\t\t\t</div>\n\t\t\t<div className='space-y-1'>\n\t\t\t\t<div className='text-13 leading-tight font-normal -tracking-2'>{title}</div>\n\t\t\t\t{description && (\n\t\t\t\t\t<div className='text-12 leading-tight font-normal -tracking-2 text-white/50'>{description}</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\nexport function ImmersiveDialogIconMessageKeyValue({\n\ticon,\n\tk,\n\tv,\n\tclassName,\n\ticonClassName,\n}: {\n\ticon: IconTypes\n\tk: ReactNode\n\tv: ReactNode\n\tclassName?: string\n\ticonClassName?: string\n}) {\n\tconst IconComponent = icon\n\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t'inline-flex w-full items-center gap-2 rounded-10 border border-white/4 bg-white/4 px-3 py-2.5 text-left font-normal',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\tstyle={{\n\t\t\t\tboxShadow: '0px 40px 60px 0px rgba(0, 0, 0, 0.10)',\n\t\t\t}}\n\t\t>\n\t\t\t<div className='flex h-8 w-8 shrink-0 items-center justify-center rounded-8 bg-white/4'>\n\t\t\t\t<IconComponent className={cn('h-5 w-5', iconClassName)} />\n\t\t\t</div>\n\t\t\t<div className='flex flex-1 text-14'>\n\t\t\t\t<div className='flex-1 leading-tight font-normal -tracking-2 opacity-60'>{k}</div>\n\t\t\t\t<div className='leading-tight font-medium -tracking-2'>{v}</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/input.tsx",
    "content": "import {cva, VariantProps} from 'class-variance-authority'\nimport {AnimatePresence, motion} from 'motion/react'\nimport * as React from 'react'\nimport {TbAlertCircle, TbEye, TbEyeOff} from 'react-icons/tb'\nimport {usePreviousDistinct} from 'react-use'\n\nimport {cn} from '@/lib/utils'\nimport {tw} from '@/utils/tw'\n\nconst inputVariants = cva(\n\t'flex w-full border-px md:border-hpx border-white/10 bg-white/4 hover:bg-white/6 px-5 py-2 text-15 font-medium -tracking-1 transition-colors duration-300 placeholder:text-white/30 focus-visible:placeholder:text-white/40 text-white/40 focus-visible:text-white focus-visible:bg-white/10 focus-visible:outline-hidden focus-visible:border-white/50 disabled:cursor-not-allowed disabled:opacity-40',\n\t{\n\t\tvariants: {\n\t\t\tvariant: {\n\t\t\t\tdefault: '',\n\t\t\t\tdestructive: 'text-destructive2-lightest border-destructive2-lightest',\n\t\t\t},\n\t\t\t// `input` element already has a `size` attribute so we need a different name\n\t\t\tsizeVariant: {\n\t\t\t\tdefault: 'h-12 rounded-full',\n\t\t\t\tshort: 'h-9 rounded-full',\n\t\t\t\t'short-square': 'h-9 rounded-8 px-2.5 text-14 font-normal',\n\t\t\t},\n\t\t},\n\t\tdefaultVariants: {\n\t\t\tvariant: 'default',\n\t\t\tsizeVariant: 'default',\n\t\t},\n\t},\n)\n\nexport interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>, VariantProps<typeof inputVariants> {\n\tonValueChange?: (value: string) => void\n}\n\nexport function Labeled({children, label}: {children: React.ReactNode; label: string}) {\n\treturn (\n\t\t<label>\n\t\t\t<div className='mb-1.5 px-[5px] text-12 -tracking-2 text-white/50'>{label}</div>\n\t\t\t{children}\n\t\t</label>\n\t)\n}\n\nexport function Input({\n\tclassName,\n\ttype,\n\tvariant,\n\tsizeVariant,\n\tonChange,\n\tonValueChange,\n\tref,\n\t...props\n}: InputProps & {ref?: React.Ref<HTMLInputElement>}) {\n\treturn (\n\t\t<input\n\t\t\ttype={type}\n\t\t\tclassName={cn(inputVariants({variant, sizeVariant}), className)}\n\t\t\tref={ref}\n\t\t\tonChange={(e) => {\n\t\t\t\tonChange?.(e)\n\t\t\t\tonValueChange?.(e.target.value)\n\t\t\t}}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\n// NOTE: If too many props start getting added to this, best to convert to something like this:\n// https://www.radix-ui.com/primitives/docs/components/form\nexport function PasswordInput({\n\tvalue,\n\tlabel,\n\tonValueChange,\n\terror,\n\tautoFocus,\n\tsizeVariant,\n\tinputRef,\n\tclassName,\n}: {\n\tvalue?: string\n\t/** Calling it a label rather than a placeholder */\n\tlabel?: string\n\tonValueChange?: (value: string) => void\n\terror?: string\n\tautoFocus?: boolean\n\tsizeVariant?: VariantProps<typeof inputVariants>['sizeVariant']\n\tinputRef?: React.Ref<HTMLInputElement>\n\tclassName?: string\n}) {\n\tconst [showPassword, setShowPassword] = React.useState(false)\n\treturn (\n\t\t<div className={className}>\n\t\t\t<div className={cn(iconRightClasses.root)}>\n\t\t\t\t<Input\n\t\t\t\t\tref={inputRef}\n\t\t\t\t\tvariant={error ? 'destructive' : undefined}\n\t\t\t\t\tplaceholder={label}\n\t\t\t\t\ttype={showPassword ? 'text' : 'password'}\n\t\t\t\t\tclassName={iconRightClasses.input}\n\t\t\t\t\tsizeVariant={sizeVariant}\n\t\t\t\t\tvalue={value}\n\t\t\t\t\tonChange={(e) => onValueChange?.(e.target.value)}\n\t\t\t\t\tautoFocus={autoFocus}\n\t\t\t\t/>\n\t\t\t\t<button\n\t\t\t\t\t// Prevent tabbing to this button to prevent accidentally showing the password\n\t\t\t\t\ttabIndex={-1}\n\t\t\t\t\ttype='button'\n\t\t\t\t\tclassName={iconRightClasses.button}\n\t\t\t\t\tonClick={() => setShowPassword((prev) => !prev)}\n\t\t\t\t>\n\t\t\t\t\t{showPassword ? <TbEyeOff className={iconRightClasses.icon} /> : <TbEye className={iconRightClasses.icon} />}\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t\t<AnimatedInputError>{error}</AnimatedInputError>\n\t\t</div>\n\t)\n}\n\nexport function AnimatedInputError({children}: {children: React.ReactNode}) {\n\tconst [showShake, setShowShake] = React.useState(false)\n\tconst prev = usePreviousDistinct(children)\n\n\tReact.useEffect(() => {\n\t\tif (prev !== children) {\n\t\t\tsetShowShake(true)\n\t\t}\n\t}, [children, prev])\n\n\treturn (\n\t\t<AnimatePresence>\n\t\t\t{children && (\n\t\t\t\t<motion.div\n\t\t\t\t\tclassName={showShake ? 'mt-1 animate-shake' : 'mt-1'}\n\t\t\t\t\tonAnimationEnd={() => setShowShake(false)}\n\t\t\t\t\tinitial={{\n\t\t\t\t\t\theight: 0,\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t}}\n\t\t\t\t\tanimate={{\n\t\t\t\t\t\theight: 'auto',\n\t\t\t\t\t\topacity: 1,\n\t\t\t\t\t}}\n\t\t\t\t\texit={{\n\t\t\t\t\t\theight: 0,\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<InputError>{children}</InputError>\n\t\t\t\t</motion.div>\n\t\t\t)}\n\t\t</AnimatePresence>\n\t)\n}\n\nexport function InputError({children}: {children: React.ReactNode}) {\n\treturn (\n\t\t// Allow text selection for copying error messages\n\t\t<div className='flex items-center gap-1 p-1 text-13 font-normal -tracking-2 text-destructive2-lightest select-text'>\n\t\t\t<TbAlertCircle className='h-4 w-4 shrink-0' />\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n\n// Grouping like this to because they all depend on each other\n/**\n * Classes for\n */\nconst iconRightClasses = {\n\troot: tw`relative`,\n\tinput: tw`pr-11`,\n\t// Using `text-white opacity-40` instead of `text-white/40` because the latter applies to strokes and displays incorrectly\n\tbutton: tw`absolute inset-y-0 right-0 h-full pl-2 pr-4 text-white opacity-40 outline-hidden hover:opacity-80 transition-opacity`,\n\ticon: tw`h-5 w-5`,\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/label.tsx",
    "content": "import * as LabelPrimitive from '@radix-ui/react-label'\nimport {cva, type VariantProps} from 'class-variance-authority'\nimport * as React from 'react'\n\nimport {cn} from '@/lib/utils'\n\nconst labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70')\n\nfunction Label({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &\n\tVariantProps<typeof labelVariants> & {ref?: React.Ref<React.ComponentRef<typeof LabelPrimitive.Root>>}) {\n\treturn <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />\n}\n\nexport {Label}\n"
  },
  {
    "path": "packages/ui/src/components/ui/list.tsx",
    "content": "import {TbCheck} from 'react-icons/tb'\n\nimport {cn} from '@/lib/utils'\nimport {tw} from '@/utils/tw'\n\nexport function ListRadioItem({\n\tchildren,\n\tchecked,\n\tname,\n\tonSelect,\n\tdisabled = false,\n}: {\n\tchildren: React.ReactNode\n\tchecked: boolean\n\tname?: string\n\tonSelect: () => void\n\tdisabled: boolean\n}) {\n\treturn (\n\t\t<div className={cn(listItemClass, 'relative')}>\n\t\t\t{children}\n\t\t\t{checked && <TbCheck className='h-4 w-4' />}\n\t\t\t<input\n\t\t\t\ttype='radio'\n\t\t\t\tname={name}\n\t\t\t\tchecked={checked}\n\t\t\t\tdisabled={disabled}\n\t\t\t\tonChange={onSelect}\n\t\t\t\t// Red so it's obvious when opacity is not zero and that it takes the whole space\n\t\t\t\t// Not using inset-0 because it's not supported in mobile Safari\n\t\t\t\tclassName='absolute top-0 left-0 block h-full w-full bg-red-500 opacity-0'\n\t\t\t/>\n\t\t</div>\n\t)\n}\n\nexport const listClass = tw`divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6`\nexport const listItemClass = tw`flex items-center gap-3 px-3 h-[50px] text-15 font-medium -tracking-3 justify-between`\n"
  },
  {
    "path": "packages/ui/src/components/ui/loading.tsx",
    "content": "import {TbLoader} from 'react-icons/tb'\n\nimport {t} from '@/utils/i18n'\n\nexport function Loading({children}: {children?: React.ReactNode}) {\n\treturn (\n\t\t<div className='flex items-center gap-1'>\n\t\t\t<Spinner />\n\t\t\t{children ?? t('loading')}\n\t\t</div>\n\t)\n}\n\nexport function Loading2({children}: {children?: React.ReactNode}) {\n\treturn (\n\t\t<div className='flex items-center gap-1'>\n\t\t\t<TbLoader className='white size-4 animate-spin opacity-50 shadow-xs' />\n\t\t\t{children ?? t('loading')}\n\t\t</div>\n\t)\n}\n\nexport function Spinner({size = '4'}: {size?: string}) {\n\treturn (\n\t\t<svg\n\t\t\taria-hidden='true'\n\t\t\trole='status'\n\t\t\tclassName={`relative inline h-${size} w-${size} animate-spin text-white`}\n\t\t\tviewBox='0 0 100 101'\n\t\t\tfill='none'\n\t\t\txmlns='http://www.w3.org/2000/svg'\n\t\t>\n\t\t\t<path\n\t\t\t\tclassName='fill-white/50'\n\t\t\t\td='M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\tclassName='fill-white/80'\n\t\t\t\td='M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z'\n\t\t\t/>\n\t\t</svg>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/notification-badge.tsx",
    "content": "export function NotificationBadge({count}: {count: number}) {\n\treturn (\n\t\t// min-w so it's a circle when count is below 10\n\t\t<div className='absolute -top-1 -right-1 flex h-[17px] min-w-[17px] animate-in items-center justify-center rounded-full bg-red-600/80 px-1 text-[11px] font-bold shadow-md shadow-red-800/50 zoom-in'>\n\t\t\t{count}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/numbered-list.tsx",
    "content": "import {ReactNode} from 'react'\n\nexport const NumberedList = ({children}: {children: ReactNode}) => {\n\treturn <ol className='ml-7 list-none divide-y divide-white/5 text-15'>{children}</ol>\n}\n\nexport const NumberedListItem = ({children}: {children: ReactNode}) => {\n\treturn (\n\t\t<li className='relative py-3 leading-tight before:absolute before:grid before:h-5 before:w-5 before:-translate-x-7 before:place-items-center before:rounded-full before:bg-white/10 before:content-[counter(list-item)]'>\n\t\t\t{children}\n\t\t</li>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/pagination.tsx",
    "content": "import {ChevronLeft, ChevronRight, MoreHorizontal} from 'lucide-react'\nimport * as React from 'react'\n\nimport {ButtonProps, buttonVariants} from '@/components/ui/button'\nimport {cn} from '@/lib/utils'\n\nconst Pagination = ({className, ...props}: React.ComponentProps<'nav'>) => (\n\t<nav\n\t\trole='navigation'\n\t\taria-label='pagination'\n\t\tclassName={cn('mx-auto flex w-full justify-center', className)}\n\t\t{...props}\n\t/>\n)\n\nfunction PaginationContent({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentProps<'ul'> & {ref?: React.Ref<HTMLUListElement>}) {\n\treturn <ul ref={ref} className={cn('flex flex-row items-center gap-1', className)} {...props} />\n}\n\nfunction PaginationItem({className, ref, ...props}: React.ComponentProps<'li'> & {ref?: React.Ref<HTMLLIElement>}) {\n\treturn <li ref={ref} className={cn('flex h-7 w-7 items-center justify-center', className)} {...props} />\n}\n\ntype PaginationLinkProps = {\n\tisActive?: boolean\n} & Pick<ButtonProps, 'size'> &\n\tReact.ComponentProps<'a'>\n\nconst PaginationLink = ({className, isActive, size = 'icon-only', ...props}: PaginationLinkProps) => (\n\t<a\n\t\taria-current={isActive ? 'page' : undefined}\n\t\tclassName={cn(\n\t\t\tbuttonVariants({\n\t\t\t\tvariant: isActive ? 'primary' : 'default',\n\t\t\t\tsize,\n\t\t\t}),\n\t\t\t'rounded-md',\n\t\t\t'h-7 w-7',\n\t\t\tclassName,\n\t\t)}\n\t\t{...props}\n\t/>\n)\n\nconst PaginationPrevious = ({\n\tclassName,\n\tchildren,\n\t...props\n}: React.ComponentProps<typeof PaginationLink> & {children?: React.ReactNode}) => (\n\t<PaginationLink aria-label='Go to previous page' size='default' className={cn('h-7 w-7', className)} {...props}>\n\t\t{children || (\n\t\t\t<>\n\t\t\t\t<ChevronLeft className='h-4 w-4' />\n\t\t\t\t<span>Previous</span>\n\t\t\t</>\n\t\t)}\n\t</PaginationLink>\n)\n\nconst PaginationNext = ({\n\tclassName,\n\tchildren,\n\t...props\n}: React.ComponentProps<typeof PaginationLink> & {children?: React.ReactNode}) => (\n\t<PaginationLink aria-label='Go to next page' size='default' className={cn('gap-1 pr-2.5', className)} {...props}>\n\t\t{children || (\n\t\t\t<>\n\t\t\t\t<span>Next</span>\n\t\t\t\t<ChevronRight className='h-4 w-4' />\n\t\t\t</>\n\t\t)}\n\t</PaginationLink>\n)\n\nconst PaginationEllipsis = ({className, ...props}: React.ComponentProps<'span'>) => (\n\t<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>\n\t\t<MoreHorizontal className='h-4 w-4' />\n\t\t<span className='sr-only'>More pages</span>\n\t</span>\n)\n\nexport {\n\tPagination,\n\tPaginationContent,\n\tPaginationLink,\n\tPaginationItem,\n\tPaginationPrevious,\n\tPaginationNext,\n\tPaginationEllipsis,\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/pin-input.tsx",
    "content": "// From:\n// https://github.com/leonardodino/rci/blob/77273d05278970a112cbd0e643e0d21f659be354/apps/demo/src/Example.tsx\n\nimport {CodeInput, getSegmentCssWidth} from 'rci'\nimport {useRef, useState, type RefObject} from 'react'\nimport {useIsFocused} from 'use-is-focused'\n\nimport {cn} from '@/lib/utils'\nimport {tw} from '@/utils/tw'\n\n// ---\n\nconst dotClass = tw`absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#D9D9D9]/10`\n\n// ---\n\nexport type CodeState = 'input' | 'loading' | 'error' | 'success'\ntype PinInputProps = {\n\tlength: number\n\tautoFocus?: boolean\n\tonCodeCheck: (code: string) => Promise<boolean>\n}\n\nexport const PinInput = ({length, onCodeCheck, autoFocus}: PinInputProps) => {\n\tconst [state, setState] = useState<CodeState>('input')\n\tconst inputRef = useRef<HTMLInputElement>(null)\n\tconst focused = useIsFocused(inputRef as RefObject<HTMLInputElement>)\n\n\tconst padding = '12px'\n\tconst width = getSegmentCssWidth(padding)\n\tconst isError = state === 'error'\n\tconst errorClassName = tw`motion-safe:animate-shake`\n\n\treturn (\n\t\t<CodeInput\n\t\t\tclassName={isError ? errorClassName : ''}\n\t\t\tinputClassName={tw`caret-transparent selection:bg-transparent`}\n\t\t\tautoFocus={autoFocus}\n\t\t\tlength={length}\n\t\t\treadOnly={state !== 'input'}\n\t\t\tdisabled={state === 'loading'}\n\t\t\tinputRef={inputRef as RefObject<HTMLInputElement>}\n\t\t\tpadding={padding}\n\t\t\tspacing={'10px'}\n\t\t\tspellCheck={false}\n\t\t\tinputMode='numeric'\n\t\t\tpattern='[0-9]*'\n\t\t\tautoComplete='one-time-code'\n\t\t\tonChange={({currentTarget: input}) => {\n\t\t\t\t// only accept numbers\n\t\t\t\tinput.value = input.value.replace(/\\D+/g, '')\n\n\t\t\t\t// auto submit on input fill\n\t\t\t\tif (input.value.length === length) {\n\t\t\t\t\tsetState('loading')\n\t\t\t\t\tonCodeCheck(input.value)\n\t\t\t\t\t\t.then((res) => {\n\t\t\t\t\t\t\tsetState('success')\n\t\t\t\t\t\t\tif (!res) {\n\t\t\t\t\t\t\t\tthrow new Error('Invalid code')\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t})\n\t\t\t\t\t\t.catch(() => {\n\t\t\t\t\t\t\tsetState('error')\n\t\t\t\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t\t\t\tsetState('input')\n\t\t\t\t\t\t\t\tinput.value = ''\n\t\t\t\t\t\t\t\tinput.dispatchEvent(new Event('input'))\n\t\t\t\t\t\t\t\tinput.focus()\n\t\t\t\t\t\t\t}, 500)\n\t\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}}\n\t\t\trenderSegment={(segment) => {\n\t\t\t\tconst isCaret = focused && segment.state === 'cursor'\n\t\t\t\tconst isSelection = focused && segment.state === 'selected'\n\t\t\t\tconst isLoading = state === 'loading'\n\t\t\t\tconst isSuccess = state === 'success'\n\t\t\t\tconst isError = state === 'error'\n\t\t\t\tconst isActive = isSuccess || isError || isSelection || isCaret\n\n\t\t\t\tconst baseClassName = tw`flex h-full relative appearance-none rounded-8 border-hpx border-white/20 [--segment-color:#fff]`\n\t\t\t\tconst activeClassName = tw`bg-white/5 data-[state]:border-[var(--segment-color)]`\n\t\t\t\tconst loadingClassName = tw`animate-[pulse-border_1s_ease-in-out_0s_infinite]`\n\n\t\t\t\tconst outerClassName = cn(baseClassName, isActive && activeClassName, isLoading && loadingClassName)\n\n\t\t\t\tconst caretClassName = tw`flex-[0_0_1px] justify-self-center ml-2 my-2 w-0.5 bg-white animate-[blink-caret_1.2s_step-end_infinite]`\n\t\t\t\tconst selectionClassName = tw`flex-1 m-[3px] rounded-5 bg-[var(--segment-color)] opacity-[0.15625]`\n\n\t\t\t\tconst innerClassName = cn(isSelection && selectionClassName, isCaret && caretClassName)\n\n\t\t\t\treturn (\n\t\t\t\t\t<div key={segment.index} data-state={state} className={outerClassName} style={{width}}>\n\t\t\t\t\t\t<div className={innerClassName} />\n\t\t\t\t\t\t<div className={dotClass} />\n\t\t\t\t\t</div>\n\t\t\t\t)\n\t\t\t}}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/popover.tsx",
    "content": "import * as PopoverPrimitive from '@radix-ui/react-popover'\nimport * as React from 'react'\n\nimport {cn} from '@/lib/utils'\n\nimport {contextMenuClasses} from './shared/menu'\n\nconst Popover = PopoverPrimitive.Root\n\nconst PopoverTrigger = PopoverPrimitive.Trigger\n\nconst PopoverAnchor = PopoverPrimitive.Anchor\n\nconst PopoverClose = PopoverPrimitive.Close\n\nfunction PopoverContent({\n\tclassName,\n\tref,\n\talign = 'center',\n\tsideOffset = 4,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {\n\tref?: React.Ref<React.ComponentRef<typeof PopoverPrimitive.Content>>\n}) {\n\treturn (\n\t\t<PopoverPrimitive.Portal>\n\t\t\t<PopoverPrimitive.Content\n\t\t\t\tref={ref}\n\t\t\t\talign={align}\n\t\t\t\tsideOffset={sideOffset}\n\t\t\t\tclassName={cn(contextMenuClasses.content, className)}\n\t\t\t\t{...props}\n\t\t\t\t// Prevent right-clicks within content from triggering parent context menus\n\t\t\t\tonContextMenu={(e) => {\n\t\t\t\t\te.preventDefault() // Prevent default browser context menu\n\t\t\t\t\te.stopPropagation()\n\t\t\t\t}}\n\t\t\t/>\n\t\t</PopoverPrimitive.Portal>\n\t)\n}\n\nexport {Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose}\n"
  },
  {
    "path": "packages/ui/src/components/ui/progress.tsx",
    "content": "import * as ProgressPrimitive from '@radix-ui/react-progress'\nimport {cva, VariantProps} from 'class-variance-authority'\nimport * as React from 'react'\n\nimport {cn} from '@/lib/utils'\n\nconst progressVariants = cva('relative w-full overflow-hidden rounded-full bg-white/10', {\n\tvariants: {\n\t\tsize: {\n\t\t\tdefault: 'h-1.5',\n\t\t\tthicker: 'h-2',\n\t\t},\n\t},\n\tdefaultVariants: {\n\t\tsize: 'default',\n\t},\n})\n\nconst progressIndicatorVariants = cva('h-full w-full flex-1 bg-white transition-all duration-700 rounded-full', {\n\tvariants: {\n\t\tvariant: {\n\t\t\tdefault: 'bg-white',\n\t\t\tprimary: 'bg-brand',\n\t\t},\n\t},\n\tdefaultVariants: {\n\t\tvariant: 'default',\n\t},\n})\n\nfunction Progress({\n\tclassName,\n\tvalue,\n\tvariant,\n\tsize,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> &\n\tVariantProps<typeof progressVariants> &\n\tVariantProps<typeof progressIndicatorVariants> & {\n\t\tref?: React.Ref<React.ComponentRef<typeof ProgressPrimitive.Root>>\n\t}) {\n\treturn (\n\t\t<ProgressPrimitive.Root ref={ref} className={cn(progressVariants({className, size}), className)} {...props}>\n\t\t\t<ProgressPrimitive.Indicator\n\t\t\t\tclassName={cn(progressIndicatorVariants({variant}), className)}\n\t\t\t\tstyle={{transform: `translateX(-${100 - (value || 0)}%)`}}\n\t\t\t/>\n\t\t</ProgressPrimitive.Root>\n\t)\n}\n\nexport {Progress}\n"
  },
  {
    "path": "packages/ui/src/components/ui/radio-group.tsx",
    "content": "import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'\nimport * as React from 'react'\n\nimport {cn} from '@/lib/utils'\n\nfunction RadioGroup({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> & {\n\tref?: React.Ref<React.ComponentRef<typeof RadioGroupPrimitive.Root>>\n}) {\n\treturn <RadioGroupPrimitive.Root className={cn('grid gap-2', className)} {...props} ref={ref} />\n}\n\nfunction RadioGroupItem({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> & {\n\tref?: React.Ref<React.ComponentRef<typeof RadioGroupPrimitive.Item>>\n}) {\n\treturn (\n\t\t<RadioGroupPrimitive.Item\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t'group aspect-square h-5 w-5 rounded-full bg-white/10 opacity-100 shadow-radio-outline transition-all duration-300 focus-visible:ring-2 focus-visible:ring-brand-lighter/50 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-white/0',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t>\n\t\t\t<RadioGroupPrimitive.Indicator className='flex items-center justify-center'>\n\t\t\t\t<RadioIndicator />\n\t\t\t</RadioGroupPrimitive.Indicator>\n\t\t</RadioGroupPrimitive.Item>\n\t)\n}\n\nconst RadioIndicator = () => (\n\t// Inner stroke not allowed in SVG, so using `clipPath`\n\t// https://stackoverflow.com/a/32162431\n\t<svg\n\t\txmlns='http://www.w3.org/2000/svg'\n\t\twidth={20}\n\t\theight={20}\n\t\tfill='none'\n\t\tclassName='block animate-in duration-300 zoom-in-50 fade-in'\n\t>\n\t\t<use\n\t\t\thref='#path'\n\t\t\tclassName='fill-brand stroke-white/20 stroke-2 transition-colors group-focus-visible:fill-brand-lighter'\n\t\t\tclip-path='url(#clip)'\n\t\t/>\n\t\t<defs>\n\t\t\t<path\n\t\t\t\tid='path'\n\t\t\t\tfill-rule='evenodd'\n\t\t\t\tclip-rule='evenodd'\n\t\t\t\td='M10 20C15.5228 20 20 15.5228 20 10C20 4.47715 15.5228 0 10 0C4.47715 0 0 4.47715 0 10C0 15.5228 4.47715 20 10 20ZM10 12C11.1046 12 12 11.1046 12 10C12 8.89543 11.1046 8 10 8C8.89543 8 8 8.89543 8 10C8 11.1046 8.89543 12 10 12Z'\n\t\t\t/>\n\t\t\t<clipPath id='clip'>\n\t\t\t\t<use href='#path' />\n\t\t\t</clipPath>\n\t\t</defs>\n\t</svg>\n)\n\nexport {RadioGroup, RadioGroupItem}\n"
  },
  {
    "path": "packages/ui/src/components/ui/root-error-fallback.tsx",
    "content": "import {ChevronDown, ChevronUp} from 'lucide-react'\nimport {useEffect, useState} from 'react'\n\nimport {Button} from '@/components/ui/button'\nimport {t} from '@/utils/i18n'\nimport {downloadLogs} from '@/utils/logs'\n\nfunction getErrorMessage(error: unknown): string {\n\tif (error instanceof Error) return error.message\n\tif (typeof error === 'string') return error\n\treturn String(error)\n}\n\n// Common error message patterns that indicate a network/connection failure rather than\n// an application bug. These occur when a browser tab has been inactive —\n// browsers throttle or kill idle connections, so background fetches and WebSocket\n// reconnections fail silently. When the user returns to the tab, React tries to render\n// with stale/failed data and throws. We detect these to show a \"Connection lost\"\n// message instead of a scary \"Something went wrong\" nessage.\n// Covers Chrome (\"Failed to fetch\"), Firefox (\"NetworkError\"), Safari (\"Load failed\"),\n// and Node-style errors (\"ECONNREFUSED\").\nconst NETWORK_ERROR_PATTERNS = [\n\t'Failed to fetch',\n\t'NetworkError',\n\t'Load failed',\n\t'net::ERR_',\n\t'fetch',\n\t'network',\n\t'ECONNREFUSED',\n]\n\nfunction isNetworkError(error: unknown): boolean {\n\tconst message = getErrorMessage(error).toLowerCase()\n\treturn NETWORK_ERROR_PATTERNS.some((pattern) => message.toLowerCase().includes(pattern.toLowerCase()))\n}\n\n// Last-resort error fallback rendered when all inner error boundaries fail.\n// This is the outermost catch — it sits above every provider (trpc, wallpaper, router, etc.)\n// so it can't rely on any of them. The UI is self-contained: no providers, no router, just\n// plain React + Tailwind + i18n keys.\n//\n// Two modes:\n// - Network errors (failed to fetch, connection lost): Shows \"Connection lost\" with an\n//   explanation. Most commonly triggered when a browser tab goes idle, the device restarts, or\n//   the network drops. A single \"Reconnect\" button reloads the page.\n// - Application errors (real bugs): Shows \"Something went wrong\" with \"Reload\" and \"Download Logs\".\n//\n// Both modes use the Page Visibility API to auto-reload when the user returns to the tab.\n// This handles the most common scenario: user switches away, connection drops, they come back\nexport function RootErrorFallback({error}: {error: unknown}) {\n\tconst [showDetails, setShowDetails] = useState(false)\n\tconst isNetwork = isNetworkError(error)\n\n\t// Auto-reload when the tab becomes visible again. Uses the Page Visibility API to detect\n\t// when users return to a stale/broken tab. This covers the most common failure mode:\n\t// browser throttles inactive tabs, killing fetch connections, and the user returns to\n\t// find an error screen. Full page reload (not resetErrorBoundary) because providers\n\t// and module state may be corrupted.\n\tuseEffect(() => {\n\t\tconst handleVisibilityChange = () => {\n\t\t\tif (document.visibilityState === 'visible') {\n\t\t\t\tconsole.log('[RootErrorFallback] Tab became visible, reloading...')\n\t\t\t\twindow.location.reload()\n\t\t\t}\n\t\t}\n\t\tdocument.addEventListener('visibilitychange', handleVisibilityChange)\n\t\treturn () => document.removeEventListener('visibilitychange', handleVisibilityChange)\n\t}, [])\n\n\treturn (\n\t\t<div className='fixed inset-0 z-50 flex items-center justify-center bg-black'>\n\t\t\t{/* Self-contained dialog card — mirrors AlertDialogContent styling but without\n\t\t\t    depending on Radix or any providers. Uses the same design tokens (rounded-20,\n\t\t\t    bg-dialog-content, shadow-dialog, backdrop-blur) for visual consistency. */}\n\t\t\t<div className='flex w-full max-w-[calc(100%-40px)] flex-col items-center gap-5 rounded-20 bg-dialog-content/70 p-8 shadow-dialog backdrop-blur-3xl sm:max-w-md'>\n\t\t\t\t<div className='flex flex-col items-center gap-1.5'>\n\t\t\t\t\t<h2 className='text-15 leading-tight font-semibold -tracking-4'>\n\t\t\t\t\t\t{isNetwork ? t('connection-lost') : t('something-went-wrong')}\n\t\t\t\t\t</h2>\n\t\t\t\t\t{isNetwork && <p className='text-center text-13 text-white/50'>{t('connection-lost-description')}</p>}\n\t\t\t\t</div>\n\t\t\t\t<div className='flex w-full flex-col gap-2.5 md:flex-row md:justify-center'>\n\t\t\t\t\t{/* Uses variant='default' (not 'primary') because the brand color CSS variable\n\t\t\t\t\t    is set by the wallpaper provider, which isn't available at this level. */}\n\t\t\t\t\t<Button size='dialog' variant='default' onClick={() => window.location.reload()}>\n\t\t\t\t\t\t{isNetwork ? t('reconnect') : t('reload')}\n\t\t\t\t\t</Button>\n\t\t\t\t\t{!isNetwork && (\n\t\t\t\t\t\t<Button size='dialog' variant='default' onClick={() => downloadLogs()}>\n\t\t\t\t\t\t\t{t('download-logs')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t\t{error != null && (\n\t\t\t\t\t<div className='-mb-4 flex flex-col items-center'>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\tonClick={() => setShowDetails((prev) => !prev)}\n\t\t\t\t\t\t\tclassName='flex items-center gap-0.5 text-11 text-white/30 transition-opacity duration-300 hover:text-white/50'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{showDetails ? t('hide-details') : t('show-details')}\n\t\t\t\t\t\t\t{showDetails ? <ChevronUp className='size-3' /> : <ChevronDown className='size-3' />}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t{showDetails && (\n\t\t\t\t\t\t\t<p className='mt-1 max-h-40 w-full overflow-y-auto text-11 break-all text-white/30'>\n\t\t\t\t\t\t\t\t{getErrorMessage(error)}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/scroll-area.tsx",
    "content": "import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'\nimport * as React from 'react'\nimport {mergeRefs} from 'react-merge-refs'\n\nimport {useFadeScroller} from '@/components/fade-scroller'\nimport {cn} from '@/lib/utils'\n\ntype Props = React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {\n\tdialogInset?: boolean\n\tscrollbarClass?: string\n\torientation?: 'horizontal' | 'vertical'\n\tviewportRef?: React.RefObject<HTMLDivElement | null>\n\tref?: React.Ref<React.ComponentRef<typeof ScrollAreaPrimitive.Root>>\n}\n\nfunction ScrollArea({\n\tclassName,\n\tchildren,\n\tviewportRef,\n\tdialogInset,\n\tscrollbarClass,\n\torientation = 'vertical',\n\tref,\n\t...props\n}: Props) {\n\tconst {scrollerClass, ref: scrollerRef} = useFadeScroller('y')\n\treturn (\n\t\t<ScrollAreaPrimitive.Root\n\t\t\tref={ref}\n\t\t\tclassName={cn('relative overflow-hidden', className)}\n\t\t\tscrollHideDelay={0}\n\t\t\t{...props}\n\t\t>\n\t\t\t<ScrollAreaPrimitive.Viewport\n\t\t\t\tref={mergeRefs([viewportRef, scrollerRef])}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t// Setting `block` to fix issues with radix `ScrollArea` component\n\t\t\t\t\t// https://github.com/radix-ui/primitives/issues/926#issuecomment-1015279283\n\t\t\t\t\t'flex h-full w-full rounded-[inherit] *:!block *:flex-grow',\n\t\t\t\t\torientation === 'vertical' && 'flex-col',\n\t\t\t\t\tscrollerClass,\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</ScrollAreaPrimitive.Viewport>\n\t\t\t<ScrollBar dialogInset={dialogInset} scrollbarClass={scrollbarClass} orientation={orientation} />\n\t\t\t<ScrollAreaPrimitive.Corner />\n\t\t</ScrollAreaPrimitive.Root>\n\t)\n}\n\nfunction ScrollBar({\n\tclassName,\n\tref,\n\tdialogInset,\n\tscrollbarClass,\n\torientation = 'vertical',\n\t...props\n}: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> & {\n\tdialogInset?: boolean\n\tscrollbarClass?: string\n\tref?: React.Ref<React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>>\n}) {\n\treturn (\n\t\t<ScrollAreaPrimitive.ScrollAreaScrollbar\n\t\t\tref={ref}\n\t\t\torientation={orientation}\n\t\t\tclassName={cn(\n\t\t\t\t'group flex touch-none rounded-l transition-colors hover:bg-white/6',\n\t\t\t\torientation === 'vertical' && 'w-2.5 border-l border-l-transparent p-[3px]',\n\t\t\t\torientation === 'vertical' && dialogInset && 'my-5',\n\t\t\t\torientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[3px]',\n\t\t\t\torientation === 'horizontal' && dialogInset && 'mx-5',\n\t\t\t\tscrollbarClass,\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t>\n\t\t\t<ScrollAreaPrimitive.ScrollAreaThumb className='relative flex-1 rounded-full bg-white/10 group-hover:bg-white/50' />\n\t\t</ScrollAreaPrimitive.ScrollAreaScrollbar>\n\t)\n}\n\nexport {ScrollArea, ScrollBar}\n"
  },
  {
    "path": "packages/ui/src/components/ui/segmented-control.tsx",
    "content": "import {motion} from 'motion/react'\nimport {useId} from 'react'\n\nimport {cn} from '@/lib/utils'\n\ntype Tab<T extends string> = {id: T; label: string}\n\n// Based on:\n// https://buildui.com/recipes/animated-tabs\nexport function SegmentedControl<T extends string>({\n\tvalue,\n\tonValueChange,\n\tsize = 'default',\n\tvariant = 'default',\n\ttabs,\n}: {\n\tvalue?: T\n\tonValueChange: (value: T) => void\n\tsize?: 'default' | 'lg' | 'sm'\n\tvariant?: 'default' | 'primary'\n\ttabs: readonly Tab<T>[]\n}) {\n\t// When layout shifts, we don't want the layout animation to play\n\tconst id = useId()\n\n\tconst justTwo = tabs.length === 2\n\n\treturn (\n\t\t<motion.div\n\t\t\t// `layoutRoot` to prevent it from animating when the layout shifts\n\t\t\tlayoutRoot\n\t\t\tclassName={cn(\n\t\t\t\t'flex shrink-0 gap-0 rounded-full border-[0.5px] border-white/6 bg-white/3',\n\t\t\t\tsize === 'sm' && 'h-[24px] p-1 text-[9px]',\n\t\t\t\tsize === 'default' && 'h-[30px] p-1 text-12',\n\t\t\t\tsize === 'lg' && 'h-[40px] p-[5px] text-12',\n\t\t\t)}\n\t\t\tonClick={() => {\n\t\t\t\tif (justTwo && value !== undefined) {\n\t\t\t\t\tif (value === tabs[0].id) {\n\t\t\t\t\t\tonValueChange(tabs[1].id)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tonValueChange(tabs[0].id)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t{tabs.map((tab) => (\n\t\t\t\t<button\n\t\t\t\t\tkey={tab.id}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t'group relative flex-grow rounded-full leading-inter-trimmed outline-hidden transition-[box-shadow,background]',\n\t\t\t\t\t\tvalue === tab.id && variant === 'primary' && 'focus-visible:ring-2 focus-visible:ring-brand/40',\n\t\t\t\t\t\tvalue !== tab.id && 'outline-1 -outline-offset-2 outline-transparent focus-visible:outline-white/10',\n\t\t\t\t\t\tsize === 'sm' && 'px-2',\n\t\t\t\t\t\tsize === 'default' && 'px-2.5',\n\t\t\t\t\t\tsize === 'lg' && 'px-[14px]',\n\t\t\t\t\t)}\n\t\t\t\t\tdisabled={!justTwo ? value === tab.id : undefined}\n\t\t\t\t\tonClick={() => onValueChange(tab.id)}\n\t\t\t\t>\n\t\t\t\t\t{value === tab.id && (\n\t\t\t\t\t\t<motion.span\n\t\t\t\t\t\t\tlayoutId={id}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t'absolute inset-0 z-10 rounded-full',\n\t\t\t\t\t\t\t\tvariant === 'default' && 'bg-white/10',\n\t\t\t\t\t\t\t\tvariant === 'primary' && 'bg-brand',\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\ttransition={{type: 'spring', bounce: 0.2, duration: 0.4}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t<span\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t'relative z-10 transition-opacity duration-200',\n\t\t\t\t\t\t\tsize === 'lg' && 'font-medium',\n\t\t\t\t\t\t\tvalue !== tab.id && 'opacity-50 group-hover:opacity-80',\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{tab.label}\n\t\t\t\t\t</span>\n\t\t\t\t</button>\n\t\t\t))}\n\t\t</motion.div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/separator.tsx",
    "content": "import * as SeparatorPrimitive from '@radix-ui/react-separator'\nimport * as React from 'react'\n\nimport {cn} from '@/lib/utils'\n\nfunction Separator({\n\tclassName,\n\torientation = 'horizontal',\n\tdecorative = true,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> & {\n\tref?: React.Ref<React.ComponentRef<typeof SeparatorPrimitive.Root>>\n}) {\n\treturn (\n\t\t<SeparatorPrimitive.Root\n\t\t\tref={ref}\n\t\t\tdecorative={decorative}\n\t\t\torientation={orientation}\n\t\t\tclassName={cn(\n\t\t\t\t'shrink-0 from-transparent via-white/10 to-transparent',\n\t\t\t\torientation === 'horizontal' ? 'h-[1px] w-full bg-linear-to-r' : 'h-full w-[1px] bg-linear-to-b',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nexport {Separator}\n"
  },
  {
    "path": "packages/ui/src/components/ui/shared/dialog.ts",
    "content": "import {tw} from '@/utils/tw'\n\nexport const dialogOverlayClass = tw`fixed inset-0 z-50 bg-black/60 contrast-more:bg-black/90 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0`\n\nexport const dialogContentClass = tw`fixed left-[50%] top-[50%] z-50 flex flex-col translate-x-[-50%] translate-y-[-50%] gap-5 rounded-20 bg-dialog-content/70 contrast-more:bg-dialog-content p-8 shadow-dialog backdrop-blur-2xl contrast-more:backdrop-blur-none duration-200 outline-hidden max-h-[calc(100%-16px)]`\nexport const dialogContentAnimationClass = tw`data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95`\nexport const dialogContentAnimationSlideClass = tw``\n\nexport const dialogFooterClass = tw`flex flex-col gap-2.5 md:flex-row`\n"
  },
  {
    "path": "packages/ui/src/components/ui/shared/menu.ts",
    "content": "import {cn} from '@/lib/utils'\nimport {tw} from '@/utils/tw'\n\n// Removed `data-[state=closed]:animate-out` here so the context menu moves with\n// the cursor on subsequent right clicks. Appears to be a shadcn/ui bug, as it's\n// also behaving this way at https://ui.shadcn.com/docs/components/context-menu\n// Removed bg-blur in favor of bg with color-mix as bg-blur doesn't work on subcontext menus\nconst menuContentClass = tw`bg-[color-mix(in_hsl,hsl(var(--color-brand))_20%,black_80%)] z-50 min-w-[8rem] p-1 animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 text-white`\n\nconst menuItemClass = tw`relative flex cursor-default items-center px-3 py-2 text-13 font-medium -tracking-3 leading-tight outline-hidden transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-white/5 focus:text-white data-[highlighted]:bg-white/5 data-[highlighted]:text-white`\nconst menuItemDestructiveClass = cn(menuItemClass, tw`text-destructive2-lightest focus:text-destructive2-lightest`)\n\nconst checkboxIndicatorWrapperClass = tw`absolute right-3 flex h-3.5 w-3.5 items-center justify-center`\nconst radioIndicatorWrapperClass = tw`absolute left-2 flex h-3.5 w-3.5 items-center justify-center`\n\nconst contextMenuItemClass = cn(menuItemClass, 'rounded-5')\n\nexport const contextMenuClasses = {\n\tcontent: cn(menuContentClass, 'shadow-context-menu rounded-8'),\n\titem: {\n\t\troot: contextMenuItemClass,\n\t\trootDestructive: menuItemDestructiveClass,\n\t},\n\tcheckboxItem: {\n\t\troot: cn(contextMenuItemClass, 'pr-10'),\n\t\tindicatorWrapper: checkboxIndicatorWrapperClass,\n\t},\n\tradioItem: {\n\t\troot: cn(contextMenuItemClass, 'pl-8'),\n\t\tindicatorWrapper: radioIndicatorWrapperClass,\n\t},\n}\n\nconst dropdownItemClass = cn(menuItemClass, 'rounded-8')\nexport const dropdownClasses = {\n\tcontent: cn(menuContentClass, 'shadow-dropdown rounded-15 p-2.5'),\n\titem: {\n\t\troot: dropdownItemClass,\n\t},\n\tcheckboxItem: {\n\t\troot: cn(dropdownItemClass, 'pr-10'),\n\t\tindicatorWrapper: checkboxIndicatorWrapperClass,\n\t},\n\tradioItem: {\n\t\troot: cn(dropdownItemClass, 'pl-8'),\n\t\tindicatorWrapper: radioIndicatorWrapperClass,\n\t},\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/sheet-scroll-area.tsx",
    "content": "import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'\nimport * as React from 'react'\nimport {mergeRefs} from 'react-merge-refs'\n\nimport {useFadeScroller} from '@/components/fade-scroller'\nimport {cn} from '@/lib/utils'\n\ntype Props = React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {\n\tviewportRef?: React.RefObject<HTMLDivElement | null>\n\tref?: React.Ref<React.ComponentRef<typeof ScrollAreaPrimitive.Root>>\n}\n\nfunction ScrollArea({className, children, viewportRef, ref, ...props}: Props) {\n\tconst {scrollerClass, ref: scrollerRef} = useFadeScroller('y')\n\treturn (\n\t\t<ScrollAreaPrimitive.Root\n\t\t\tref={ref}\n\t\t\tclassName={cn('relative overflow-hidden', className)}\n\t\t\tscrollHideDelay={0}\n\t\t\t{...props}\n\t\t>\n\t\t\t{/*\n\t\t\tTODO: figure out child issue\n\t\t\tWe need to get a ref to it so we can scroll it programmatically\n\t\t\thttps://github.com/radix-ui/primitives/issues/1666\n\t\t */}\n\t\t\t<ScrollAreaPrimitive.Viewport\n\t\t\t\tref={mergeRefs([viewportRef, scrollerRef])}\n\t\t\t\tclassName={cn(scrollerClass, 'h-full w-full rounded-[inherit] [&>div]:!block')}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</ScrollAreaPrimitive.Viewport>\n\t\t\t<ScrollBar />\n\t\t\t<ScrollAreaPrimitive.Corner />\n\t\t</ScrollAreaPrimitive.Root>\n\t)\n}\n\nfunction ScrollBar({\n\tclassName,\n\tref,\n\torientation = 'vertical',\n\t...props\n}: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> & {\n\tref?: React.Ref<React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>>\n}) {\n\treturn (\n\t\t<ScrollAreaPrimitive.ScrollAreaScrollbar\n\t\t\tref={ref}\n\t\t\torientation={orientation}\n\t\t\tclassName={cn(\n\t\t\t\t'group flex touch-none rounded-tl-4 transition-colors hover:bg-white/10',\n\t\t\t\torientation === 'vertical' && 'mt-[38px] h-[calc(100%-38px)] w-[11px] border-l border-l-transparent p-[4px]',\n\t\t\t\torientation === 'horizontal' && 'h-[11px] flex-col border-t border-t-transparent p-[4px]',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t>\n\t\t\t<ScrollAreaPrimitive.ScrollAreaThumb className='relative flex-1 rounded-full bg-white/20 group-hover:bg-white/50' />\n\t\t</ScrollAreaPrimitive.ScrollAreaScrollbar>\n\t)\n}\n\nexport {ScrollArea, ScrollBar}\n"
  },
  {
    "path": "packages/ui/src/components/ui/sheet.tsx",
    "content": "import * as SheetPrimitive from '@radix-ui/react-dialog'\nimport {cva, type VariantProps} from 'class-variance-authority'\nimport * as React from 'react'\n\nimport {cn} from '@/lib/utils'\nimport {useWallpaper} from '@/providers/wallpaper'\n\nconst Sheet = SheetPrimitive.Root\n\nconst SheetTrigger = SheetPrimitive.Trigger\n\nconst SheetPortal = (props: SheetPrimitive.DialogPortalProps) => <SheetPrimitive.Portal {...props} />\nSheetPortal.displayName = SheetPrimitive.Portal.displayName\n\nconst sheetVariants = cva(\n\t'fixed z-30 gap-4 bg-black/70 contrast-more:bg-black overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-100 outline-hidden data-[state=closed]:fade-out data-[state=closed]:ease-in fill-mode-both',\n\t{\n\t\tvariants: {\n\t\t\tside: {\n\t\t\t\ttop: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',\n\t\t\t\tbottom:\n\t\t\t\t\t'inset-x-0 bottom-0 data-[state=closed]:slide-out-to-bottom-1/2 data-[state=open]:slide-in-from-bottom-1/2 rounded-t-20',\n\t\t\t\t'bottom-zoom':\n\t\t\t\t\t'inset-x-0 bottom-0 data-[state=closed]:zoom-out-75 data-[state=open]:zoom-in-90 rounded-t-20 data-[state=open]:duration-200 data-[state=closed]:duration-100',\n\t\t\t\tleft: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',\n\t\t\t\tright:\n\t\t\t\t\t'inset-y-0 right-0 h-full w-3/4  border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',\n\t\t\t},\n\t\t},\n\t\tdefaultVariants: {\n\t\t\tside: 'bottom',\n\t\t},\n\t},\n)\n\ninterface SheetContentProps\n\textends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, VariantProps<typeof sheetVariants> {\n\tbackdrop?: React.ReactNode\n\tcloseButton?: React.ReactNode\n\tref?: React.Ref<React.ComponentRef<typeof SheetPrimitive.Content>>\n}\n\nfunction SheetContent({\n\tside = 'bottom',\n\tclassName,\n\tchildren,\n\tbackdrop,\n\tcloseButton = true,\n\tref,\n\t...props\n}: SheetContentProps) {\n\tconst {wallpaper} = useWallpaper()\n\n\treturn (\n\t\t// <SheetPortal container={document.getElementById(\"container\")}>\n\t\t<>\n\t\t\t{backdrop}\n\t\t\t{/* <SheetOverlay /> */}\n\t\t\t<SheetPrimitive.Content\n\t\t\t\tref={ref}\n\t\t\t\tclassName={cn(sheetVariants({side}), 'transform-gpu will-change-transform', className)}\n\t\t\t\t{...props}\n\t\t\t>\n\t\t\t\t{/* Keep before other elements to prevent auto-focus on other elements. Some element must be focused for accessibility */}\n\t\t\t\t{closeButton}\n\t\t\t\t<div className='absolute inset-0 bg-black contrast-more:hidden'>\n\t\t\t\t\t{/* Fade in sheet background to avoid white flash when sheet opens.\n\t\t\t\t\t    Using filter (not backdrop-filter) so the blur is computed once and cached\n\t\t\t\t\t    by the GPU, rather than re-sampled every frame during animations/scrolling. */}\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName='absolute inset-0 opacity-0'\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tanimation: 'fade-in 700ms ease-out 200ms both',\n\t\t\t\t\t\t\tbackgroundImage: `url(/assets/wallpapers/generated-thumbs/${wallpaper.id}.jpg)`,\n\t\t\t\t\t\t\tbackgroundSize: 'cover',\n\t\t\t\t\t\t\tbackgroundPosition: 'center',\n\t\t\t\t\t\t\ttransform: 'scale(1.2) rotate(180deg)',\n\t\t\t\t\t\t\tfilter: 'blur(48px) brightness(0.3) saturate(1.2)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t{children}\n\t\t\t\t{/* Sheet inner glow highlight */}\n\t\t\t\t<div className='pointer-events-none absolute inset-0 z-50 rounded-t-20 shadow-sheet-shadow' />\n\t\t\t</SheetPrimitive.Content>\n\t\t</>\n\t\t// </SheetPortal>\n\t)\n}\n\nconst SheetHeader = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (\n\t<div className={cn('flex flex-col gap-2', className)} {...props} />\n)\nSheetHeader.displayName = 'SheetHeader'\n\nconst SheetFooter = ({className, ...props}: React.HTMLAttributes<HTMLDivElement>) => (\n\t<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />\n)\nSheetFooter.displayName = 'SheetFooter'\n\nfunction SheetTitle({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> & {\n\tref?: React.Ref<React.ComponentRef<typeof SheetPrimitive.Title>>\n}) {\n\treturn (\n\t\t<SheetPrimitive.Title\n\t\t\tref={ref}\n\t\t\tclassName={cn('text-24 font-bold -tracking-3 text-white/75 md:text-48', className)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction SheetDescription({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> & {\n\tref?: React.Ref<React.ComponentRef<typeof SheetPrimitive.Description>>\n}) {\n\treturn <SheetPrimitive.Description ref={ref} className={cn('text-sm text-neutral-400', className)} {...props} />\n}\n\nexport {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger}\n"
  },
  {
    "path": "packages/ui/src/components/ui/switch.tsx",
    "content": "import * as SwitchPrimitives from '@radix-ui/react-switch'\nimport * as React from 'react'\n\nimport {cn} from '@/lib/utils'\n\nfunction Switch({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> & {\n\tref?: React.Ref<React.ComponentRef<typeof SwitchPrimitives.Root>>\n}) {\n\treturn (\n\t\t<SwitchPrimitives.Root\n\t\t\tclassName={cn(\n\t\t\t\t// Removing `disabled:cursor-not-allowed` so that we can disable switch while it's going to the server without changing the cursor\n\t\t\t\t'peer inline-flex h-[20px] w-[36px] shrink-0 items-center rounded-full border-2 border-transparent transition-[background,color,box-shadow] focus-visible:ring-3 focus-visible:ring-white/6 focus-visible:ring-offset-1 focus-visible:ring-offset-white/20 focus-visible:outline-hidden disabled:opacity-50 data-[state=checked]:bg-brand data-[state=unchecked]:bg-white/10',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t\tref={ref}\n\t\t>\n\t\t\t<SwitchPrimitives.Thumb\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',\n\t\t\t\t)}\n\t\t\t/>\n\t\t</SwitchPrimitives.Root>\n\t)\n}\n\nexport {Switch}\n"
  },
  {
    "path": "packages/ui/src/components/ui/table.tsx",
    "content": "import * as React from 'react'\n\nimport {cn} from '@/lib/utils'\n\nfunction Table({\n\tclassName,\n\tref,\n\t...props\n}: React.HTMLAttributes<HTMLTableElement> & {ref?: React.Ref<HTMLTableElement>}) {\n\treturn (\n\t\t<div className='relative w-full overflow-auto'>\n\t\t\t<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />\n\t\t</div>\n\t)\n}\n\nfunction TableHeader({\n\tclassName,\n\tref,\n\t...props\n}: React.HTMLAttributes<HTMLTableSectionElement> & {ref?: React.Ref<HTMLTableSectionElement>}) {\n\treturn <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />\n}\n\nfunction TableBody({\n\tclassName,\n\tref,\n\t...props\n}: React.HTMLAttributes<HTMLTableSectionElement> & {ref?: React.Ref<HTMLTableSectionElement>}) {\n\treturn <tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />\n}\n\nfunction TableFooter({\n\tclassName,\n\tref,\n\t...props\n}: React.HTMLAttributes<HTMLTableSectionElement> & {ref?: React.Ref<HTMLTableSectionElement>}) {\n\treturn (\n\t\t<tfoot ref={ref} className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)} {...props} />\n\t)\n}\n\nfunction TableRow({\n\tclassName,\n\tref,\n\t...props\n}: React.HTMLAttributes<HTMLTableRowElement> & {ref?: React.Ref<HTMLTableRowElement>}) {\n\treturn (\n\t\t<tr\n\t\t\tref={ref}\n\t\t\tclassName={cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', className)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction TableHead({\n\tclassName,\n\tref,\n\t...props\n}: React.ThHTMLAttributes<HTMLTableCellElement> & {ref?: React.Ref<HTMLTableCellElement>}) {\n\treturn (\n\t\t<th\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t'text-muted-foreground h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction TableCell({\n\tclassName,\n\tref,\n\t...props\n}: React.TdHTMLAttributes<HTMLTableCellElement> & {ref?: React.Ref<HTMLTableCellElement>}) {\n\treturn <td ref={ref} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />\n}\n\nfunction TableCaption({\n\tclassName,\n\tref,\n\t...props\n}: React.HTMLAttributes<HTMLTableCaptionElement> & {ref?: React.Ref<HTMLTableCaptionElement>}) {\n\treturn <caption ref={ref} className={cn('text-muted-foreground mt-4 text-sm', className)} {...props} />\n}\n\nexport {Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption}\n"
  },
  {
    "path": "packages/ui/src/components/ui/tabs.tsx",
    "content": "import * as TabsPrimitive from '@radix-ui/react-tabs'\nimport * as React from 'react'\n\nimport {cn} from '@/lib/utils'\n\nconst Tabs = TabsPrimitive.Root\n\nfunction TabsList({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {\n\tref?: React.Ref<React.ComponentRef<typeof TabsPrimitive.List>>\n}) {\n\treturn (\n\t\t<TabsPrimitive.List\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t'bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction TabsTrigger({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {\n\tref?: React.Ref<React.ComponentRef<typeof TabsPrimitive.Trigger>>\n}) {\n\treturn (\n\t\t<TabsPrimitive.Trigger\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-xs px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-xs',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nfunction TabsContent({\n\tclassName,\n\tref,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> & {\n\tref?: React.Ref<React.ComponentRef<typeof TabsPrimitive.Content>>\n}) {\n\treturn (\n\t\t<TabsPrimitive.Content\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nexport {Tabs, TabsList, TabsTrigger, TabsContent}\n"
  },
  {
    "path": "packages/ui/src/components/ui/toast.tsx",
    "content": "import type {IconType} from 'react-icons'\nimport {TbAlertCircle, TbAlertTriangle, TbCircleCheck, TbInfoCircle} from 'react-icons/tb'\nimport * as SonnerPrimitive from 'sonner'\n\nimport {buttonVariants} from '@/components/ui/button'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {tw} from '@/utils/tw'\n\nexport function Toaster() {\n\tconst isMobile = useIsMobile()\n\treturn (\n\t\t<SonnerPrimitive.Toaster\n\t\t\tcloseButton\n\t\t\tposition='top-right'\n\t\t\t// `undefined` to use the default value\n\t\t\toffset={isMobile ? 12 : undefined}\n\t\t\tclassName='group'\n\t\t\ttoastOptions={{\n\t\t\t\tunstyled: true,\n\t\t\t\tclassNames: {\n\t\t\t\t\tcloseButton: tw`absolute top-0 right-0 p-1 -translate-y-1/3 translate-x-1/3 bg-neutral-600/70 rounded-full hover:scale-105 transition-[scale,opacity] duration-300 hidden sm:block`,\n\t\t\t\t\t// Allow text selection for copying error messages\n\t\t\t\t\ttoast: tw`bg-[#404040]/40 rounded-12 py-4 px-5 backdrop-blur-md flex items-center gap-2 shadow-dialog text-15 text-white -tracking-4 w-full select-text`,\n\t\t\t\t\ttitle: tw`font-medium leading-[18px] select-text`,\n\t\t\t\t\tdescription: tw`opacity-60 leading-[18px]`,\n\t\t\t\t\tactionButton: buttonVariants(),\n\t\t\t\t},\n\t\t\t}}\n\t\t/>\n\t)\n}\n\nconst toastFunction = (...args: Parameters<typeof SonnerPrimitive.toast>) => {\n\treturn SonnerPrimitive.toast(...args)\n}\n\nexport const toast = Object.assign(toastFunction, {\n\t...SonnerPrimitive.toast,\n\tsuccess: (message: string, opts?: SonnerPrimitive.ExternalToast) =>\n\t\tSonnerPrimitive.toast.success(message, {...opts, icon: <ToastIcon component={TbCircleCheck} hexColor='#00AD79' />}),\n\tinfo: (message: string, opts?: SonnerPrimitive.ExternalToast) =>\n\t\tSonnerPrimitive.toast.info(message, {...opts, icon: <ToastIcon component={TbInfoCircle} hexColor='#139EED' />}),\n\twarning: (message: string, opts?: SonnerPrimitive.ExternalToast) =>\n\t\tSonnerPrimitive.toast.warning(message, {\n\t\t\t...opts,\n\t\t\ticon: <ToastIcon component={TbAlertTriangle} hexColor='#D7BF44' />,\n\t\t}),\n\terror: (message: string, opts?: SonnerPrimitive.ExternalToast) =>\n\t\tSonnerPrimitive.toast.error(message, {...opts, icon: <ToastIcon component={TbAlertCircle} hexColor='#F45A5A' />}),\n})\n\nexport function ToastIcon({component, hexColor}: {component: IconType; hexColor: string}) {\n\tconst Comp = component\n\t// 88 in filter adds 50% opacity\n\treturn <Comp className='h-6 w-6' style={{color: hexColor, filter: `drop-shadow(0 0 8px ${hexColor}88)`}} />\n}\n"
  },
  {
    "path": "packages/ui/src/components/ui/tooltip.tsx",
    "content": "import * as TooltipPrimitive from '@radix-ui/react-tooltip'\nimport * as React from 'react'\n\nimport {cn} from '@/lib/utils'\n\nconst TooltipProvider = TooltipPrimitive.Provider\n\nconst Tooltip = TooltipPrimitive.Root\n\nconst TooltipTrigger = TooltipPrimitive.Trigger\n\nfunction TooltipContent({\n\tclassName,\n\tref,\n\tsideOffset = 4,\n\t...props\n}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {\n\tref?: React.Ref<React.ComponentRef<typeof TooltipPrimitive.Content>>\n}) {\n\treturn (\n\t\t<TooltipPrimitive.Content\n\t\t\tref={ref}\n\t\t\tsideOffset={sideOffset}\n\t\t\tclassName={cn(\n\t\t\t\t'z-50 animate-in overflow-hidden rounded-md border border-neutral-200 bg-white px-2 py-1 text-sm text-neutral-950 shadow-md fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n\nexport {Tooltip, TooltipTrigger, TooltipContent, TooltipProvider}\n"
  },
  {
    "path": "packages/ui/src/components/umbrel-logo.tsx",
    "content": "import {SVGProps} from 'react'\n\nfunction UmbrelLogo({style, ref, ...props}: SVGProps<SVGSVGElement> & {ref?: React.Ref<SVGSVGElement>}) {\n\treturn (\n\t\t<svg\n\t\t\txmlns='http://www.w3.org/2000/svg'\n\t\t\twidth={96}\n\t\t\t// height={47} // get rid of height to make it responsive\n\t\t\tviewBox='0 0 96 47'\n\t\t\tfill='none'\n\t\t\t{...props}\n\t\t\tstyle={style}\n\t\t\tref={ref}\n\t\t>\n\t\t\t<path\n\t\t\t\tfill='currentColor'\n\t\t\t\tfillRule='evenodd'\n\t\t\t\td='M47.416 8.723c10.404-.2 18.594 2.599 24.948 8.11 4.615 4.002 8.475 9.622 11.46 17.045-2.275-.56-4.679-.835-7.196-.835-5.324 0-10.102 1.232-14.083 3.912-4.46-2.722-9.258-4.152-14.34-4.152-5.198 0-10.188 1.495-14.923 4.302-4.571-2.875-9.722-4.302-15.341-4.302-2.03 0-3.97.188-5.802.582 2.684-6.827 6.235-12.09 10.546-15.946 6.16-5.512 14.278-8.516 24.731-8.716ZM7.761 45.613a4.35 4.35 0 0 0 .472-.493c1.901-2.205 4.878-3.604 9.708-3.604 4.557 0 8.466 1.266 11.884 3.768l.135.1a5.446 5.446 0 0 0 6.304.143c4.085-2.764 8.043-4.011 11.94-4.011 3.83 0 7.545 1.202 11.228 3.817l.076.055a5.446 5.446 0 0 0 6.727-.307c2.433-2.1 5.762-3.325 10.393-3.325 4.871 0 8.648 1.358 11.63 3.875a4.38 4.38 0 0 0 1.632.907 4.336 4.336 0 0 0 2.968-.168 4.364 4.364 0 0 0 2.592-4.66 4.39 4.39 0 0 0-.109-.51c-3.456-13.388-9.106-23.87-17.269-30.95C69.822 3.095 59.422-.222 47.25.012 35.124.245 24.874 3.79 16.876 10.945 8.948 18.037 3.639 28.312.633 41.3a4.352 4.352 0 0 0 2.533 5.081 4.352 4.352 0 0 0 4.595-.767Z'\n\t\t\t\tclipRule='evenodd'\n\t\t\t/>\n\t\t</svg>\n\t)\n}\nexport default UmbrelLogo\n"
  },
  {
    "path": "packages/ui/src/components/widget-check-icon.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const WidgetCheckIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg xmlns='http://www.w3.org/2000/svg' width={26} height={26} fill='none' {...props}>\n\t\t<path\n\t\t\tfill='currentColor'\n\t\t\td='M12.655.813A12.187 12.187 0 1 0 24.843 13 12.2 12.2 0 0 0 12.655.812Zm5.351 10.038-6.562 6.562a.94.94 0 0 1-1.327 0l-2.813-2.812a.938.938 0 1 1 1.327-1.327l2.15 2.15 5.899-5.9a.938.938 0 1 1 1.326 1.327Z'\n\t\t/>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/constants/index.ts",
    "content": "import {t} from '@/utils/i18n'\n\nexport const UNKNOWN = () => t('unknown')\n// This is an en dash (U+2013)\nexport const LOADING_DASH = '–'\n\nexport const SETTINGS_SYSTEM_CARDS_ID = 'settings-system-cards'\n\nexport const hostEnvironments = ['umbrel-pro', 'umbrel-home', 'raspberry-pi', 'docker-container', 'unknown'] as const\nexport type UmbrelHostEnvironment = (typeof hostEnvironments)[number]\n\nexport const hostEnvironmentMap = {\n\t'umbrel-pro': {\n\t\ticon: '/assets/system-umbrel-pro.webp',\n\t},\n\t'umbrel-home': {\n\t\ticon: '/assets/system-umbrel-home.png',\n\t},\n\t'raspberry-pi': {\n\t\ticon: '/assets/system-pi.svg',\n\t},\n\t'docker-container': {\n\t\ticon: '/assets/system-docker.svg',\n\t},\n\tunknown: {\n\t\ticon: '/assets/system-generic-device.svg',\n\t},\n} satisfies Record<\n\tUmbrelHostEnvironment,\n\t{\n\t\ticon?: string\n\t}\n>\n"
  },
  {
    "path": "packages/ui/src/constants/links.ts",
    "content": "export const links = {\n\tsupport: 'https://umbrel.com/support',\n\tlegal: {\n\t\tprivacy: 'https://umbrel.com/legal/privacy',\n\t\ttos: 'https://umbrel.com/legal/umbrelos/tos',\n\t},\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/cmdk-search-provider.tsx",
    "content": "import {useNavigate} from 'react-router-dom'\n\nimport {CmdkSearchProviderProps} from '@/components/cmdk-providers'\nimport {CommandItem} from '@/components/ui/command'\nimport backupsIcon from '@/features/backups/assets/backups-icon.png'\nimport {useBackups} from '@/features/backups/hooks/use-backups'\nimport {t} from '@/utils/i18n'\n\nexport const BackupsCmdkSearchProvider: React.FC<CmdkSearchProviderProps> = ({close}) => {\n\tconst navigate = useNavigate()\n\tconst {repositories} = useBackups()\n\n\t// Determine if we have existing repositories\n\tconst hasExistingRepositories = (repositories?.length ?? 0) > 0\n\n\t// Navigate to the appropriate route based on whether repositories exist\n\tconst handleSelect = () => {\n\t\tconst route = hasExistingRepositories ? '/settings/backups/configure' : '/settings/backups/setup'\n\t\tnavigate(route, {preventScrollReset: true})\n\t\tclose()\n\t}\n\n\t// Render the appropriate command item\n\treturn (\n\t\t<CommandItem\n\t\t\ticon={<img src={backupsIcon} alt='Backups' className='size-full' />}\n\t\t\tvalue='backup-settings'\n\t\t\tonSelect={handleSelect}\n\t\t>\n\t\t\t<span>\n\t\t\t\t{t('backups')}{' '}\n\t\t\t\t<span className='opacity-50'>\n\t\t\t\t\t{t('generic-in')} {t('settings')}\n\t\t\t\t</span>\n\t\t\t</span>\n\t\t</CommandItem>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/backup-device-icon.tsx",
    "content": "import {getDeviceType} from '@/features/backups/utils/backup-location-helpers'\nimport externalStorageIcon from '@/features/files/assets/external-storage-icon.png'\nimport activeNasIcon from '@/features/files/assets/nas-icon-active.png'\nimport inactiveNasIcon from '@/features/files/assets/nas-icon-inactive.png'\nimport umbrelDeviceActive from '@/features/files/assets/umbrel-device-icon-active.png'\nimport {useNetworkDeviceType} from '@/features/files/hooks/use-network-device-type'\nimport {t} from '@/utils/i18n'\n\nexport function BackupDeviceIcon({\n\tpath,\n\tconnected = true,\n\tclassName = '',\n}: {\n\tpath: string\n\tconnected?: boolean\n\tclassName?: string\n}) {\n\tconst kind = getDeviceType(path)\n\tconst {deviceType} = useNetworkDeviceType(path)\n\n\tif (kind === 'NAS') {\n\t\t// Show Umbrel Home icon if detected as Umbrel device\n\t\tif (deviceType === 'umbrel') {\n\t\t\treturn <img src={umbrelDeviceActive} alt={t('umbrel')} className={className} draggable={false} />\n\t\t}\n\t\t// Otherwise show generic NAS icon (active/inactive)\n\t\treturn (\n\t\t\t<img src={connected ? activeNasIcon : inactiveNasIcon} alt={t('nas')} className={className} draggable={false} />\n\t\t)\n\t}\n\n\treturn <img src={externalStorageIcon} alt={t('external-drive')} className={className} draggable={false} />\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/backup-location-dropdown.tsx",
    "content": "import {ChevronDown} from 'lucide-react'\n\nimport {Button} from '@/components/ui/button'\nimport {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'\nimport {t} from '@/utils/i18n'\n\ntype RestoreLocationDropdownProps = {\n\tonSelect: (root: string) => void\n}\n\nexport function RestoreLocationDropdown({onSelect}: RestoreLocationDropdownProps) {\n\treturn (\n\t\t<DropdownMenu>\n\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t<Button\n\t\t\t\t\ttype='button'\n\t\t\t\t\tsize='sm'\n\t\t\t\t\tclassName='absolute top-1/2 right-5 inline-flex -translate-y-1/2 items-center gap-1'\n\t\t\t\t>\n\t\t\t\t\t{t('backups-restore.choose')}\n\t\t\t\t\t<ChevronDown className='size-3' />\n\t\t\t\t</Button>\n\t\t\t</DropdownMenuTrigger>\n\t\t\t<DropdownMenuContent align='end' className='min-w-[320px]'>\n\t\t\t\t<DropdownMenuItem className='block' onSelect={() => onSelect('/Network')}>\n\t\t\t\t\t<div className='flex w-full flex-col items-start'>\n\t\t\t\t\t\t<div className='text-sm font-medium'>{t('backups-restore.browse-nas-title')}</div>\n\t\t\t\t\t\t<div className='text-xs opacity-60'>{t('backups-restore.browse-nas-subtitle')}</div>\n\t\t\t\t\t</div>\n\t\t\t\t</DropdownMenuItem>\n\t\t\t\t<DropdownMenuItem className='block' onSelect={() => onSelect('/External')}>\n\t\t\t\t\t<div className='flex w-full flex-col items-start'>\n\t\t\t\t\t\t<div className='text-sm font-medium'>{t('backups-restore.browse-external-title')}</div>\n\t\t\t\t\t\t<div className='text-xs opacity-60'>{t('backups-restore.browse-external-subtitle')}</div>\n\t\t\t\t\t</div>\n\t\t\t\t</DropdownMenuItem>\n\t\t\t\t<DropdownMenuItem disabled className='block cursor-not-allowed opacity-60'>\n\t\t\t\t\t<div className='flex w-full flex-col items-start'>\n\t\t\t\t\t\t<div className='text-sm font-medium'>{t('backups-restore.browse-cloud-title')}</div>\n\t\t\t\t\t\t<div className='text-xs opacity-60'>{t('backups-restore.browse-cloud-subtitle')}</div>\n\t\t\t\t\t</div>\n\t\t\t\t</DropdownMenuItem>\n\t\t\t</DropdownMenuContent>\n\t\t</DropdownMenu>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/backups-exclusions.tsx",
    "content": "// This is the \"Exclude from Backups\" section of the Backups Configure Wizard\n// It renders and allows the selection/deselection of files, folders, and apps to be excluded from backups\n\nimport {ChevronDown, MinusCircle, PlusCircle} from 'lucide-react'\nimport {matchSorter} from 'match-sorter'\nimport {useEffect, useMemo, useRef, useState} from 'react'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {Button} from '@/components/ui/button'\nimport {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'\nimport {Input} from '@/components/ui/input'\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {useAppsAutoExcludedPaths} from '@/features/backups/hooks/use-apps-auto-excluded-paths'\nimport {useAppsBackupIgnoredSummary} from '@/features/backups/hooks/use-apps-backup-ignore'\nimport {useBackupIgnoredPaths} from '@/features/backups/hooks/use-backup-ignored-paths'\nimport {formatAppPathForDisplay} from '@/features/backups/utils/filepath-helpers'\nimport {MiniBrowser} from '@/features/files/components/mini-browser'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {useListDirectory} from '@/features/files/hooks/use-list-directory'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {useApps} from '@/providers/apps'\nimport {t} from '@/utils/i18n'\n\n// MAIN COMPONENT\nexport function BackupsExclusions({showTitle = false}: {showTitle?: boolean}) {\n\tconst {filteredIgnoredPaths, addIgnoredPath, removeIgnoredPath} = useBackupIgnoredPaths()\n\n\tconst [isAddFolderOpen, setAddFolderOpen] = useState(false)\n\n\t// Apps\n\tconst {userApps = [], isLoading: isLoadingApps} = useApps()\n\tconst {\n\t\tisIgnoredByAppId,\n\t\texcludedAppsCount,\n\t\tignore,\n\t\tunignore,\n\t\tisLoading: isIgnoredLoading,\n\t} = useAppsBackupIgnoredSummary()\n\tconst {pathsByAppId, autoExcludedAppsCount, isLoading: isAutoExcludedLoading} = useAppsAutoExcludedPaths()\n\n\tconst [appPickerOpen, setAppPickerOpen] = useState(false)\n\tconst [appQuery, setAppQuery] = useState('')\n\n\tconst appQueryInputRef = useRef<HTMLInputElement>(null)\n\n\tuseEffect(() => {\n\t\tif (!appPickerOpen) return\n\t\tsetTimeout(() => {\n\t\t\tappQueryInputRef.current?.focus()\n\t\t\tappQueryInputRef.current?.select()\n\t\t}, 0)\n\t}, [appPickerOpen])\n\n\treturn (\n\t\t<div className='space-y-3'>\n\t\t\t{showTitle && <span className='text-13 font-medium text-white/90'>{t('backups.exclude-from-backups')}</span>}\n\n\t\t\t{/* Folders */}\n\t\t\t<div className='space-y-2'>\n\t\t\t\t<div className='flex items-center justify-between'>\n\t\t\t\t\t<div className='text-13 text-white/60'>{t('backups-exclusions.files-and-folders')}</div>\n\t\t\t\t\t<Button size='sm' onClick={() => setAddFolderOpen(true)}>\n\t\t\t\t\t\t{t('backups-exclusions.add')}\n\t\t\t\t\t\t<PlusCircle className='h-3 w-3' />\n\t\t\t\t\t</Button>\n\t\t\t\t</div>\n\t\t\t\t<div className='divide-y divide-white/6 rounded-12 bg-white/5'>\n\t\t\t\t\t{filteredIgnoredPaths.length === 0 ? (\n\t\t\t\t\t\t<div className='p-4 text-sm text-white/50'>{t('backups-exclusions.no-excluded-files-or-folders')}</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t{filteredIgnoredPaths.map((p: string) => (\n\t\t\t\t\t\t\t\t<FilePathRow\n\t\t\t\t\t\t\t\t\tkey={p}\n\t\t\t\t\t\t\t\t\tpath={p}\n\t\t\t\t\t\t\t\t\trightSlot={\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\trole='button'\n\t\t\t\t\t\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => removeIgnoredPath(p)}\n\t\t\t\t\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\tif (e.key === 'Enter' || e.key === ' ') {\n\t\t\t\t\t\t\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\t\t\t\t\t\t\tremoveIgnoredPath(p)\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\taria-label={t('backups-exclusions.stop-excluding')}\n\t\t\t\t\t\t\t\t\t\t\tclassName='inline-flex h-6 w-6 items-center justify-center text-[#F45A5A] hover:text-[#F45A5A]/90'\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<MinusCircle className='h-4 w-4' />\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* Apps */}\n\t\t\t<div className='space-y-2'>\n\t\t\t\t<div className='flex items-center justify-between'>\n\t\t\t\t\t<div className='text-13 text-white/60'>{t('backups-exclusions.excluded-apps')}</div>\n\t\t\t\t\t<DropdownMenu\n\t\t\t\t\t\topen={appPickerOpen}\n\t\t\t\t\t\tonOpenChange={(o) => {\n\t\t\t\t\t\t\tsetAppPickerOpen(o)\n\t\t\t\t\t\t\tif (!o) setAppQuery('')\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t\t\t\t<Button size='sm' className='inline-flex items-center gap-1'>\n\t\t\t\t\t\t\t\t{t('backups-exclusions.add')}\n\t\t\t\t\t\t\t\t<PlusCircle className='h-3 w-3' />\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t\t\t<DropdownMenuContent align='end' className='flex max-h-72 min-w-64 flex-col gap-3'>\n\t\t\t\t\t\t\t{isLoadingApps && <div className='p-2 text-sm text-white/50'>{t('loading')}</div>}\n\t\t\t\t\t\t\t{!isLoadingApps && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\t\tvalue={appQuery}\n\t\t\t\t\t\t\t\t\t\tclassName='shrink-0'\n\t\t\t\t\t\t\t\t\t\tonChange={(e) => setAppQuery(e.target.value)}\n\t\t\t\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\t\t\t\tif (e.key === 'Escape') setAppPickerOpen(false)\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tsizeVariant={'short-square'}\n\t\t\t\t\t\t\t\t\t\tplaceholder={t('app-picker.search')}\n\t\t\t\t\t\t\t\t\t\tref={appQueryInputRef}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t{(() => {\n\t\t\t\t\t\t\t\t\t\tconst rawApps = userApps || []\n\t\t\t\t\t\t\t\t\t\tconst results = matchSorter(rawApps, appQuery, {\n\t\t\t\t\t\t\t\t\t\t\tkeys: ['name', 'id'],\n\t\t\t\t\t\t\t\t\t\t\tthreshold: matchSorter.rankings.WORD_STARTS_WITH,\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t\tif (results.length === 0) {\n\t\t\t\t\t\t\t\t\t\t\treturn <div className='px-2 text-14 text-white/50'>{t('no-results-found')}</div>\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t<ScrollArea className='relative -mx-2.5 flex h-full flex-col px-2.5'>\n\t\t\t\t\t\t\t\t\t\t\t\t{results.map((app) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<DropdownMenuItem\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={app.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tignore(app.id)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetAppPickerOpen(false)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='flex items-center gap-2'\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<AppIcon size={20} src={app.icon} className='rounded-4' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='truncate'>{app.name || app.id}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t</ScrollArea>\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t})()}\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</DropdownMenuContent>\n\t\t\t\t\t</DropdownMenu>\n\t\t\t\t</div>\n\t\t\t\t{/* Existing app exclusions list */}\n\t\t\t\t<div className='divide-y divide-white/6 rounded-12 bg-white/5'>\n\t\t\t\t\t{(isLoadingApps || isIgnoredLoading || isAutoExcludedLoading) && (\n\t\t\t\t\t\t<div className='p-3 text-sm text-white/50'>{t('loading')}</div>\n\t\t\t\t\t)}\n\t\t\t\t\t{!isLoadingApps &&\n\t\t\t\t\t!isIgnoredLoading &&\n\t\t\t\t\t!isAutoExcludedLoading &&\n\t\t\t\t\texcludedAppsCount === 0 &&\n\t\t\t\t\tautoExcludedAppsCount === 0 ? (\n\t\t\t\t\t\t<div className='p-4 text-sm text-white/50'>{t('backups-exclusions.no-excluded-apps')}</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t!isLoadingApps &&\n\t\t\t\t\t\t(userApps || []).map((app) => (\n\t\t\t\t\t\t\t<AppRow\n\t\t\t\t\t\t\t\tkey={app.id}\n\t\t\t\t\t\t\t\tapp={app}\n\t\t\t\t\t\t\t\tonUnignore={(appId) => unignore(appId)}\n\t\t\t\t\t\t\t\tonIgnore={(appId) => ignore(appId)}\n\t\t\t\t\t\t\t\tpaths={pathsByAppId.get(app.id) || []}\n\t\t\t\t\t\t\t\tisIgnored={!!isIgnoredByAppId.get(app.id)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* MiniBrowser for adding excluded files and folders */}\n\t\t\t<MiniBrowser\n\t\t\t\topen={isAddFolderOpen}\n\t\t\t\tonOpenChange={setAddFolderOpen}\n\t\t\t\trootPath={'/Home'}\n\t\t\t\tonOpenPath={'/Home'}\n\t\t\t\tpreselectOnOpen={false}\n\t\t\t\ttitle={t('backups-exclusions.select-item-to-exclude')}\n\t\t\t\t// we allow selecting both files and folders\n\t\t\t\tselectionMode='files-and-folders'\n\t\t\t\tonSelect={(p) => {\n\t\t\t\t\taddIgnoredPath(p)\n\t\t\t\t\tsetAddFolderOpen(false)\n\t\t\t\t}}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n\n// SUB-COMPONENTS\n\nfunction useFileItemForPath(path: string) {\n\tconst name = useMemo(() => path.split('/').filter(Boolean).pop() || '', [path])\n\tconst parent = useMemo(() => {\n\t\tconst parts = path.split('/').filter(Boolean)\n\t\treturn '/' + parts.slice(0, -1).join('/')\n\t}, [path])\n\tconst {listing} = useListDirectory(parent || '/')\n\tconst found = (listing?.items || []).find((f: any) => f?.name === name) as FileSystemItem | undefined\n\treturn found\n}\n\nfunction FilePathRow({path, rightSlot}: {path: string; rightSlot?: React.ReactNode}) {\n\tconst found = useFileItemForPath(path)\n\tconst name = useMemo(() => path.split('/').filter(Boolean).pop() || path, [path])\n\tconst item: FileSystemItem = found || {\n\t\tpath,\n\t\tname,\n\t\ttype: '',\n\t\tmodified: 0,\n\t\tsize: 0,\n\t\tthumbnail: undefined,\n\t\toperations: [],\n\t}\n\tconst displayPath = path.startsWith('/Home/') ? path.slice('/Home/'.length) : path\n\treturn (\n\t\t<div className='flex items-center justify-between p-3 text-sm'>\n\t\t\t<div className='flex min-w-0 flex-1 items-center gap-2'>\n\t\t\t\t<FileItemIcon item={item} className='size-6' />\n\t\t\t\t<span dir='ltr' className='w-0 flex-1 truncate text-left text-13' title={path}>\n\t\t\t\t\t{displayPath}\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t\t{rightSlot ? <div className='shrink-0'>{rightSlot}</div> : null}\n\t\t</div>\n\t)\n}\n\nfunction AppRow({\n\tapp,\n\tonUnignore,\n\tonIgnore,\n\tpaths,\n\tisIgnored,\n}: {\n\tapp: {id: string; name?: string; icon?: string}\n\tonUnignore: (appId: string) => void\n\tonIgnore: (appId: string) => void\n\tpaths: string[]\n\tisIgnored: boolean\n}) {\n\tconst [open, setOpen] = useState(false)\n\tconst hasDefaultIgnores = (paths || []).length > 0\n\n\tif (!isIgnored && !hasDefaultIgnores) return null\n\n\treturn (\n\t\t<div className='p-3 text-sm'>\n\t\t\t<div className='flex items-center justify-between gap-2'>\n\t\t\t\t<div className='flex min-w-0 items-center gap-2'>\n\t\t\t\t\t<AppIcon size={26} src={app.icon} className='rounded-md' />\n\t\t\t\t\t<span className='truncate text-13' title={app.name || app.id}>\n\t\t\t\t\t\t{app.name || app.id}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t{!isIgnored && hasDefaultIgnores && (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => setOpen((v) => !v)}\n\t\t\t\t\t\t\tclassName='flex items-center gap-1 rounded-md bg-white/10 px-2 py-0.5 text-12 text-white/70 hover:text-white'\n\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('backups-exclusions.auto-excluded')} ({paths.length})\n\t\t\t\t\t\t\t<ChevronDown className={`size-3 transition-transform ${open ? 'rotate-180' : ''}`} />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t\t{isIgnored && (\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\trole='button'\n\t\t\t\t\t\t\ttabIndex={0}\n\t\t\t\t\t\t\tonClick={() => onUnignore(app.id)}\n\t\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\t\tif (e.key === 'Enter' || e.key === ' ') {\n\t\t\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\t\t\tonUnignore(app.id)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\taria-label={t('backups-exclusions.stop-excluding')}\n\t\t\t\t\t\t\tclassName='inline-flex size-6 items-center justify-center text-[#F45A5A] hover:text-[#F45A5A]/90'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<MinusCircle className='size-4' />\n\t\t\t\t\t\t</span>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t{!isIgnored && open && hasDefaultIgnores && (\n\t\t\t\t<div className='mt-2 space-y-1 rounded-md bg-white/5 p-2'>\n\t\t\t\t\t<div className='text-12 text-white/60'>{t('backups-exclusions.app-paths-explanation')}</div>\n\t\t\t\t\t<div className='text-12 text-white/60'>{t('backups-exclusions.app-paths-cannot-be-modified')}</div>\n\t\t\t\t\t{paths.map((p: string) => (\n\t\t\t\t\t\t<FilePathRow key={p} path={formatAppPathForDisplay(p)} />\n\t\t\t\t\t))}\n\t\t\t\t\t<div className='pt-1'>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tvariant='destructive'\n\t\t\t\t\t\t\tsize='sm'\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tonIgnore(app.id)\n\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('backups-exclusions.exclude-entire-app')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/configure-wizard.tsx",
    "content": "import {ArrowLeft, ChevronDown, ChevronRight, Loader2} from 'lucide-react'\nimport {AnimatePresence, motion} from 'motion/react'\nimport * as React from 'react'\nimport {useNavigate} from 'react-router-dom'\n\nimport {FadeScroller} from '@/components/fade-scroller'\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {Button} from '@/components/ui/button'\nimport {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'\nimport {ImmersiveDialogSeparator} from '@/components/ui/immersive-dialog'\nimport {BackupDeviceIcon} from '@/features/backups/components/backup-device-icon'\nimport {BackupsExclusions} from '@/features/backups/components/backups-exclusions'\nimport {\n\tuseBackupProgress,\n\tuseBackups,\n\tuseRepositoryBackups,\n\tuseRepositorySize,\n\tuseTriggerBackupForRepo,\n} from '@/features/backups/hooks/use-backups'\nimport {isRepoConnected} from '@/features/backups/utils/backup-location-helpers'\nimport {getDisplayRepositoryPath} from '@/features/backups/utils/filepath-helpers'\nimport {sortBackupsByTimeDesc} from '@/features/backups/utils/sort'\nimport {EXTERNAL_STORAGE_PATH, NETWORK_STORAGE_PATH} from '@/features/files/constants'\nimport {useExternalStorage} from '@/features/files/hooks/use-external-storage'\nimport {useNetworkStorage} from '@/features/files/hooks/use-network-storage'\nimport {formatFilesystemDate} from '@/features/files/utils/format-filesystem-date'\nimport {formatFilesystemSize} from '@/features/files/utils/format-filesystem-size'\nimport {useIsSmallMobile} from '@/hooks/use-is-mobile'\nimport {useLanguage} from '@/hooks/use-language'\nimport {t} from '@/utils/i18n'\n\n// MAIN COMPONENT\n\nexport function BackupsConfigureWizard() {\n\tconst navigate = useNavigate()\n\tconst {repositories, forgetRepository, isForgettingRepository} = useBackups()\n\tconst {doesHostHaveMountedShares} = useNetworkStorage()\n\tconst {disks} = useExternalStorage()\n\n\tconst [viewRepoId, setViewRepoId] = React.useState<string | null>(null)\n\tconst viewRepo = (repositories || []).find((r) => r.id === viewRepoId) || null\n\tconst isViewConnected = React.useMemo(\n\t\t() => (viewRepo ? isRepoConnected(viewRepo.path, doesHostHaveMountedShares, disks) : false),\n\t\t[viewRepo, doesHostHaveMountedShares, disks],\n\t)\n\tconst repoSizeQ = useRepositorySize(viewRepoId || undefined, {enabled: isViewConnected})\n\n\t// Fetch backups for the selected repository\n\tconst {data: backupsUnsorted, isLoading: isLoadingBackups} = useRepositoryBackups(viewRepoId || undefined, {\n\t\tenabled: isViewConnected && !!viewRepoId,\n\t\tstaleTime: 15_000,\n\t})\n\n\t// Sort backups from latest to oldest\n\tconst backups = React.useMemo(\n\t\t() => (backupsUnsorted ? sortBackupsByTimeDesc(backupsUnsorted as any[]) : undefined),\n\t\t[backupsUnsorted],\n\t)\n\n\t// Backup progress for disabling buttons and showing inline progress\n\tconst backupProgressQ = useBackupProgress(1000)\n\tconst backupProgressByRepo = React.useMemo(() => {\n\t\tconst map = new Map<string, number>()\n\t\tfor (const p of backupProgressQ.data || []) map.set(p.repositoryId, p.percent)\n\t\treturn map\n\t}, [backupProgressQ.data])\n\n\tconst goToSetupNas = React.useCallback(\n\t\t() => navigate(`/settings/backups/setup?backups-setup-tab=${NETWORK_STORAGE_PATH.slice(1).toLowerCase()}`),\n\t\t[navigate],\n\t)\n\tconst goToSetupExternal = React.useCallback(\n\t\t() => navigate(`/settings/backups/setup?backups-setup-tab=${EXTERNAL_STORAGE_PATH.slice(1).toLowerCase()}`),\n\t\t[navigate],\n\t)\n\tconst goToSetupUmbrelPrivateCloud = React.useCallback(\n\t\t() => navigate(`/settings/backups/setup?backups-setup-tab=umbrel-private-cloud`),\n\t\t[navigate],\n\t)\n\n\treturn (\n\t\t<div className='flex h-full flex-col gap-4'>\n\t\t\t<div>\n\t\t\t\t<h2 className='text-24 font-medium text-white'>{t('backups')}</h2>\n\t\t\t</div>\n\t\t\t<ImmersiveDialogSeparator />\n\n\t\t\t{!viewRepo ? (\n\t\t\t\t<>\n\t\t\t\t\t<span className='mb-4 text-13 text-white/60'>{t('backups.schedule-description')}</span>\n\t\t\t\t\t<LocationsSection\n\t\t\t\t\t\trepositories={repositories || []}\n\t\t\t\t\t\tdoesHostHaveMountedShares={doesHostHaveMountedShares}\n\t\t\t\t\t\tdisks={disks}\n\t\t\t\t\t\tbackupProgressByRepo={backupProgressByRepo}\n\t\t\t\t\t\tonViewRepo={setViewRepoId}\n\t\t\t\t\t\tonAddNas={goToSetupNas}\n\t\t\t\t\t\tonAddExternal={goToSetupExternal}\n\t\t\t\t\t\tonAddUmbrelPrivateCloud={goToSetupUmbrelPrivateCloud}\n\t\t\t\t\t/>\n\n\t\t\t\t\t<div className='h-2' />\n\t\t\t\t\t{/* Global Backups Exclusions */}\n\t\t\t\t\t<BackupsExclusions showTitle />\n\t\t\t\t</>\n\t\t\t) : (\n\t\t\t\t<RepositoryDetails\n\t\t\t\t\trepo={viewRepo as any}\n\t\t\t\t\tisConnected={isViewConnected}\n\t\t\t\t\tsizeUsed={repoSizeQ.data?.used}\n\t\t\t\t\tsizeAvailable={repoSizeQ.data?.available}\n\t\t\t\t\tinProgressPercent={viewRepoId ? backupProgressByRepo.get(viewRepoId) : undefined}\n\t\t\t\t\tbackups={backups}\n\t\t\t\t\tisLoadingBackups={isLoadingBackups}\n\t\t\t\t\tonBack={() => setViewRepoId(null)}\n\t\t\t\t\tonForget={() => viewRepoId && forgetRepository(viewRepoId)}\n\t\t\t\t\tisForgettingRepository={isForgettingRepository}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\n// SUB COMPONENTS\n\n// Green/red dot to indicate device connectivity\nfunction ConnectivityDot({connected}: {connected: boolean}) {\n\tconst solidCentre = connected ? '#299E16' : '#DF1F1F'\n\tconst lighterRadius = connected ? '#299E163D' : '#DF1F1F3D'\n\treturn (\n\t\t<div className='grid size-3 place-items-center rounded-full' style={{backgroundColor: lighterRadius}}>\n\t\t\t<div className='size-1.5 rounded-full' style={{backgroundColor: solidCentre}} />\n\t\t</div>\n\t)\n}\n\n// Indicator for in-progress backups\nfunction CircularProgress({percent, className}: {percent: number; className?: string}) {\n\tconst p = Math.max(0, Math.min(100, Math.floor(percent || 0)))\n\tconst radius = 7\n\tconst circumference = 2 * Math.PI * radius\n\tconst dashoffset = circumference - (p / 100) * circumference\n\treturn (\n\t\t<svg viewBox='0 0 16 16' className={className} role='img' aria-label={`${p}%`}>\n\t\t\t<circle cx='8' cy='8' r={radius} stroke='currentColor' strokeWidth='2' fill='none' opacity='0.2' />\n\t\t\t<circle\n\t\t\t\tcx='8'\n\t\t\t\tcy='8'\n\t\t\t\tr={radius}\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='2'\n\t\t\t\tfill='none'\n\t\t\t\tstrokeDasharray={circumference}\n\t\t\t\tstrokeDashoffset={dashoffset}\n\t\t\t\ttransform='rotate(-90 8 8)'\n\t\t\t/>\n\t\t</svg>\n\t)\n}\n\nfunction InlineBackupProgress({percent}: {percent: number}) {\n\treturn (\n\t\t<div className='flex items-center gap-2 text-sm'>\n\t\t\t<CircularProgress percent={percent} className='size-4' />\n\t\t\t<div>{t('backups-configure.in-progress')}</div>\n\t\t\t<div className='tabular-nums'>{Math.floor(percent)}%</div>\n\t\t</div>\n\t)\n}\n\nfunction BackupNowButton({repoId, hidden}: {repoId: string; hidden: boolean}) {\n\tconst {triggerBackup, isPending} = useTriggerBackupForRepo(repoId)\n\n\tif (hidden) return null\n\n\treturn (\n\t\t<Button size='sm' variant='default' disabled={isPending} className='shrink-0' onClick={triggerBackup}>\n\t\t\t<span className={isPending ? 'opacity-0' : 'opacity-100'}>{t('backups-configure.back-up-now')}</span>\n\t\t\t{isPending && <Loader2 className='absolute h-4 w-4 animate-spin' />}\n\t\t</Button>\n\t)\n}\n\n// Section for showing all backup repositories\nfunction LocationsSection({\n\trepositories,\n\tdoesHostHaveMountedShares,\n\tdisks,\n\tbackupProgressByRepo,\n\tonViewRepo,\n\tonAddNas,\n\tonAddExternal,\n\tonAddUmbrelPrivateCloud,\n}: {\n\trepositories: Array<{id: string; path: string; lastBackup?: any}>\n\tdoesHostHaveMountedShares: (rootPath: string) => boolean\n\tdisks: any[] | undefined\n\tbackupProgressByRepo: Map<string, number>\n\tonViewRepo: (id: string) => void\n\tonAddNas: () => void\n\tonAddExternal: () => void\n\tonAddUmbrelPrivateCloud: () => void\n}) {\n\tconst isSmallMobile = useIsSmallMobile()\n\tconst [lang] = useLanguage()\n\treturn (\n\t\t<>\n\t\t\t<div className='space-y-2'>\n\t\t\t\t<div className='flex items-center justify-between'>\n\t\t\t\t\t<span className='text-13 font-medium text-white/90'>{t('backups-configure.locations')}</span>\n\t\t\t\t\t{/* Dropdown menu to add a NAS or External drive */}\n\t\t\t\t\t<DropdownMenu>\n\t\t\t\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t\t\t\t<Button size='sm' className='inline-flex items-center gap-1'>\n\t\t\t\t\t\t\t\t{t('backups-configure.add-backup-location')}\n\t\t\t\t\t\t\t\t<ChevronDown className='h-3 w-3' />\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t\t\t<DropdownMenuContent align='end' className='min-w-[280px]'>\n\t\t\t\t\t\t\t<DropdownMenuItem onSelect={onAddNas}>\n\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t<div className='text-14 font-medium'>{t('backups-setup-umbrel-or-nas')}</div>\n\t\t\t\t\t\t\t\t\t<div className='text-12 text-white/40'>{t('backups-setup-nas-or-umbrel-description')}</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t<DropdownMenuItem onSelect={onAddExternal}>\n\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t<div className='text-14 font-medium'>{t('external-drive')}</div>\n\t\t\t\t\t\t\t\t\t<div className='text-12 text-white/40'>{t('backups-setup-external-description')}</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t<DropdownMenuItem onSelect={onAddUmbrelPrivateCloud}>\n\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t<div className='text-14 font-medium'>{t('backups-setup-umbrel-private-cloud')}</div>\n\t\t\t\t\t\t\t\t\t<div className='text-12 text-white/40'>{t('backups-setup-umbrel-private-cloud-description')}</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t</DropdownMenuContent>\n\t\t\t\t\t</DropdownMenu>\n\t\t\t\t</div>\n\t\t\t\t{/* List of backup repositories */}\n\t\t\t\t<div className='divide-y divide-white/6 rounded-12 bg-white/5'>\n\t\t\t\t\t{repositories.length === 0 ? (\n\t\t\t\t\t\t<div className='p-5 text-sm text-white/50'>{t('backups-configure.no-backup-locations')}</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\trepositories.map((repo) => {\n\t\t\t\t\t\t\tconst deviceName = repo.path.split('/').filter(Boolean)[1] || repo.path\n\t\t\t\t\t\t\tconst isConnected = isRepoConnected(repo.path, doesHostHaveMountedShares, disks)\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<div className='flex flex-col gap-0 p-3' key={repo.id}>\n\t\t\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t\t\t<ConnectivityDot connected={isConnected} />\n\t\t\t\t\t\t\t\t\t\t<BackupDeviceIcon path={repo.path} connected={isConnected} className='size-8 opacity-90' />\n\t\t\t\t\t\t\t\t\t\t<div className='min-w-0 flex-1 truncate'>\n\t\t\t\t\t\t\t\t\t\t\t<span className='block text-sm font-medium'>{deviceName}</span>\n\t\t\t\t\t\t\t\t\t\t\t{isConnected && repo.lastBackup ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<span className='block text-11 text-white/40'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{backupProgressByRepo.has(repo.id)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? t('backups-configure.backing-up-now')\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: `${t('backups-configure.last-backup')}: ${formatFilesystemDate(Number(repo.lastBackup), lang)}`}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t\t\t\t{!isSmallMobile && backupProgressByRepo.has(repo.id) ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<InlineBackupProgress percent={backupProgressByRepo.get(repo.id) || 0} />\n\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t{!isSmallMobile && (\n\t\t\t\t\t\t\t\t\t\t\t\t<BackupNowButton repoId={repo.id} hidden={backupProgressByRepo.has(repo.id)} />\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t<Button size='sm' variant='default' className='shrink-0' onClick={() => onViewRepo(repo.id)}>\n\t\t\t\t\t\t\t\t\t\t\t\t{t('backups-configure.view')}\n\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t})\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</>\n\t)\n}\n\n// View details for a single backup repository when user clicks \"View\"\nfunction RepositoryDetails({\n\trepo,\n\tisConnected,\n\tsizeUsed,\n\tsizeAvailable,\n\tinProgressPercent,\n\tbackups,\n\tisLoadingBackups,\n\tonBack,\n\tonForget,\n\tisForgettingRepository,\n}: {\n\trepo: {id: string; path: string; lastBackup?: any}\n\tisConnected: boolean\n\tsizeUsed?: number\n\tsizeAvailable?: number\n\tinProgressPercent?: number\n\tbackups?: Array<{id: string; time: number; size: number}>\n\tisLoadingBackups: boolean\n\tonBack: () => void\n\tonForget: () => void\n\tisForgettingRepository: boolean\n}) {\n\tconst [lang] = useLanguage()\n\tconst [confirmRemoveOpen, setConfirmRemoveOpen] = React.useState(false)\n\tconst [showAllBackups, setShowAllBackups] = React.useState(false)\n\n\tconst deviceName = repo.path.split('/').filter(Boolean)[1] || repo.path\n\treturn (\n\t\t<div className='space-y-4'>\n\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t<span\n\t\t\t\t\trole='button'\n\t\t\t\t\ttabIndex={0}\n\t\t\t\t\tonClick={onBack}\n\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\tif (e.key === 'Enter' || e.key === ' ') {\n\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\tonBack()\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\taria-label={t('back')}\n\t\t\t\t\tclassName='inline-flex h-6 w-6 items-center justify-center text-white'\n\t\t\t\t>\n\t\t\t\t\t<ArrowLeft className='h-4 w-4' />\n\t\t\t\t</span>\n\t\t\t\t<BackupDeviceIcon path={repo.path} className='h-5 w-5 opacity-90' />\n\t\t\t\t<span className='text-15 font-medium'>{deviceName}</span>\n\t\t\t</div>\n\t\t\t<div className='divide-y divide-white/6 rounded-12 bg-white/5'>\n\t\t\t\t{/* Connection row */}\n\t\t\t\t<div className='flex items-center justify-between p-3 text-sm'>\n\t\t\t\t\t<div className='text-white/60'>{t('backups-configure.connection')}</div>\n\t\t\t\t\t<div className='flex items-center justify-end gap-2'>\n\t\t\t\t\t\t<ConnectivityDot connected={isConnected} />\n\t\t\t\t\t\t<div className='text-right'>\n\t\t\t\t\t\t\t{isConnected ? t('backups-configure.connected') : t('backups-configure.not-connected')}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className='flex items-center justify-between gap-3 p-3 text-sm'>\n\t\t\t\t\t<div className='shrink-0 text-white/60'>{t('backups-configure.path')}</div>\n\t\t\t\t\t<FadeScroller\n\t\t\t\t\t\tdirection='x'\n\t\t\t\t\t\tclassName='umbrel-hide-scrollbar min-w-0 overflow-x-auto text-right whitespace-nowrap'\n\t\t\t\t\t>\n\t\t\t\t\t\t{getDisplayRepositoryPath(repo.path)}\n\t\t\t\t\t</FadeScroller>\n\t\t\t\t</div>\n\t\t\t\t<div className='flex items-center justify-between p-3 text-sm'>\n\t\t\t\t\t<div className='text-white/60'>{t('backups-configure.last-backup')}</div>\n\t\t\t\t\t<div className='text-right'>\n\t\t\t\t\t\t{repo.lastBackup\n\t\t\t\t\t\t\t? formatFilesystemDate(Number(repo.lastBackup), lang)\n\t\t\t\t\t\t\t: t('backups-restore.no-backups-yet')}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className='flex items-center justify-between p-3 text-sm'>\n\t\t\t\t\t<div className='text-white/60'>{t('backups-configure.status')}</div>\n\t\t\t\t\t<div className='flex items-center justify-end gap-2 text-right'>\n\t\t\t\t\t\t{typeof inProgressPercent === 'number' ? (\n\t\t\t\t\t\t\t<InlineBackupProgress percent={inProgressPercent} />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<div>{t('backups-configure.awaiting-next-backup')}</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className='flex items-center justify-between p-3 text-sm'>\n\t\t\t\t\t<div className='text-white/60'>{t('backups-configure.used')}</div>\n\t\t\t\t\t<div className='flex items-center justify-end text-right'>\n\t\t\t\t\t\t{isConnected ? (\n\t\t\t\t\t\t\tsizeUsed !== undefined ? (\n\t\t\t\t\t\t\t\tformatFilesystemSize(sizeUsed)\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<Loader2 className='size-4 animate-spin text-white/60' aria-label={t('loading')} />\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t'—'\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className='flex items-center justify-between p-3 text-sm'>\n\t\t\t\t\t<div className='text-white/60'>{t('backups-configure.available')}</div>\n\t\t\t\t\t<div className='flex items-center justify-end text-right'>\n\t\t\t\t\t\t{isConnected ? (\n\t\t\t\t\t\t\tsizeAvailable !== undefined ? (\n\t\t\t\t\t\t\t\tformatFilesystemSize(sizeAvailable)\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<Loader2 className='size-4 animate-spin text-white/60' aria-label={t('loading')} />\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t'—'\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div\n\t\t\t\t\tclassName={`flex items-center justify-between p-3 text-sm transition-colors ${\n\t\t\t\t\t\tisLoadingBackups || (backups?.length || 0) === 0\n\t\t\t\t\t\t\t? ''\n\t\t\t\t\t\t\t: `hover:bg-white/5 ${!showAllBackups ? 'hover:rounded-b-12' : ''}`\n\t\t\t\t\t}`}\n\t\t\t\t\tonClick={\n\t\t\t\t\t\tisLoadingBackups || (backups?.length || 0) === 0 ? undefined : () => setShowAllBackups(!showAllBackups)\n\t\t\t\t\t}\n\t\t\t\t>\n\t\t\t\t\t<div className='text-white/60'>{t('backups-configure.total-backups')}</div>\n\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t<div className='text-right'>\n\t\t\t\t\t\t\t{isLoadingBackups ? (\n\t\t\t\t\t\t\t\t<Loader2 className='size-4 animate-spin text-white/60' aria-label={t('loading')} />\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\tbackups?.length || 0\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{!isLoadingBackups && (backups?.length || 0) > 0 && (\n\t\t\t\t\t\t\t<ChevronRight\n\t\t\t\t\t\t\t\tclassName={`size-4 transition-transform duration-200 ${showAllBackups ? 'rotate-90' : ''}`}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<AnimatePresence initial={false}>\n\t\t\t\t\t{showAllBackups && (\n\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\tinitial={{height: 0, opacity: 0}}\n\t\t\t\t\t\t\tanimate={{height: 'auto', opacity: 1}}\n\t\t\t\t\t\t\texit={{height: 0, opacity: 0}}\n\t\t\t\t\t\t\ttransition={{duration: 0.3, ease: [0.4, 0.0, 0.2, 1]}}\n\t\t\t\t\t\t\tclassName='overflow-hidden border-t border-white/6'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<BackupsList backups={backups} isLoading={isLoadingBackups} />\n\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t)}\n\t\t\t\t</AnimatePresence>\n\t\t\t</div>\n\n\t\t\t<div className='flex justify-end gap-2'>\n\t\t\t\t<BackupNowButton repoId={repo.id} hidden={!isConnected || typeof inProgressPercent === 'number'} />\n\t\t\t\t<Button\n\t\t\t\t\tsize='sm'\n\t\t\t\t\tvariant='destructive'\n\t\t\t\t\tdisabled={isForgettingRepository}\n\t\t\t\t\tonClick={() => setConfirmRemoveOpen(true)}\n\t\t\t\t>\n\t\t\t\t\t<span className={isForgettingRepository ? 'opacity-0' : 'opacity-100'}>\n\t\t\t\t\t\t{t('backups-configure.remove-backup-location')}\n\t\t\t\t\t</span>\n\t\t\t\t\t{isForgettingRepository && <Loader2 className='absolute h-4 w-4 animate-spin' />}\n\t\t\t\t</Button>\n\t\t\t</div>\n\n\t\t\t{/* Confirm remove dialog */}\n\t\t\t<AlertDialog open={confirmRemoveOpen} onOpenChange={setConfirmRemoveOpen}>\n\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t\t<AlertDialogTitle>{t('backups-configure.remove-backup-location-confirmation')}</AlertDialogTitle>\n\t\t\t\t\t\t<AlertDialogDescription>\n\t\t\t\t\t\t\t{t('backups-configure.remove-backup-location-confirmation-description', {device: deviceName})}\n\t\t\t\t\t\t</AlertDialogDescription>\n\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t\t<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t\t\t<AlertDialogAction\n\t\t\t\t\t\t\tvariant='destructive'\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tonForget()\n\t\t\t\t\t\t\t\tsetConfirmRemoveOpen(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('backups-configure.remove-backup-location')}\n\t\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t</AlertDialogContent>\n\t\t\t</AlertDialog>\n\t\t</div>\n\t)\n}\n\n// Scrollable list of backups\nfunction BackupsList({\n\tbackups,\n\tisLoading,\n}: {\n\tbackups?: Array<{id: string; time: number; size: number}>\n\tisLoading: boolean\n}) {\n\tconst [lang] = useLanguage()\n\n\tif (isLoading) {\n\t\treturn (\n\t\t\t<div className='flex items-center justify-center p-4'>\n\t\t\t\t<Loader2 className='size-4 animate-spin text-white/60' aria-label={t('loading')} />\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (!backups || backups.length === 0) {\n\t\treturn <div className='p-3 text-center text-sm text-white/40'>{t('backups-restore.no-backups-found')}</div>\n\t}\n\n\t// Show max 5 backups with scroll\n\tconst shouldScroll = backups.length > 5\n\n\treturn (\n\t\t<div className={shouldScroll ? 'max-h-[200px] overflow-y-auto' : ''}>\n\t\t\t<div className='divide-y divide-white/6'>\n\t\t\t\t{backups.map((backup, index) => {\n\t\t\t\t\tconst id = backup.id ?? ''\n\t\t\t\t\tconst when = backup.time\n\t\t\t\t\tconst date = when ? new Date(when) : null\n\t\t\t\t\tconst dateLabel = date ? formatFilesystemDate(when, lang) : t('backups-restore.unknown-date')\n\t\t\t\t\tconst size = backup.size\n\t\t\t\t\tconst sizeTxt = typeof size === 'number' ? formatFilesystemSize(size) : ''\n\n\t\t\t\t\tconst isLatest = index === 0\n\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<div key={id || Math.random()} className='flex items-center justify-between p-3 text-sm'>\n\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t<div className='text-white/60'>{dateLabel}</div>\n\t\t\t\t\t\t\t\t{isLatest && (\n\t\t\t\t\t\t\t\t\t<span className='rounded-full bg-green-500/20 px-2 text-[8px] font-medium tracking-wider text-green-500 uppercase'>\n\t\t\t\t\t\t\t\t\t\t{t('backups-restore.latest')}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className='text-right text-white/90'>{sizeTxt || '—'}</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)\n\t\t\t\t})}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/floating-island/expanded.tsx",
    "content": "import {motion} from 'motion/react'\n\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {BackupDeviceIcon} from '@/features/backups/components/backup-device-icon'\nimport {t} from '@/utils/i18n'\n\ntype Progress = {name: string; percent: number; path?: string}\n\nexport function ExpandedContent({progresses}: {progresses: Progress[]}) {\n\t// Single backup - show circular progress\n\tif (progresses.length === 1) {\n\t\tconst progress = progresses[0]\n\t\tconst radius = 40\n\t\tconst circumference = 2 * Math.PI * radius\n\t\tconst strokeDashoffset = circumference - (progress.percent / 100) * circumference\n\n\t\treturn (\n\t\t\t<div className='flex size-full items-center justify-between overflow-hidden px-8 py-6'>\n\t\t\t\t{/* Left side */}\n\t\t\t\t<div className='flex flex-col gap-1'>\n\t\t\t\t\t<div className='truncate text-sm tracking-tight text-white/90'>\n\t\t\t\t\t\t{t('backups-floating-island.backing-up-to')}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className='truncate text-xs font-normal text-white/50'>{progress.name}</div>\n\t\t\t\t\t<div className='mt-2 flex items-baseline gap-1'>\n\t\t\t\t\t\t<div className='text-5xl font-light tracking-tight text-white'>{progress.percent.toFixed(0)}</div>\n\t\t\t\t\t\t<div className='font-medium text-white/40'>%</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t{/* Right side */}\n\t\t\t\t<motion.div\n\t\t\t\t\tclassName='relative flex items-center justify-center'\n\t\t\t\t\tinitial={{scale: 0.6, opacity: 0, rotate: -10}}\n\t\t\t\t\tanimate={{scale: 1, opacity: 1, rotate: 0}}\n\t\t\t\t\texit={{scale: 0.6, opacity: 0, rotate: 10}}\n\t\t\t\t\ttransition={{\n\t\t\t\t\t\ttype: 'spring',\n\t\t\t\t\t\tstiffness: 300,\n\t\t\t\t\t\tdamping: 20,\n\t\t\t\t\t\tdelay: 0.05,\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{/* Subtle background glow */}\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tclassName='absolute inset-0 rounded-full bg-linear-to-br from-brand/30 to-transparent'\n\t\t\t\t\t\tinitial={{scale: 0.8, opacity: 0}}\n\t\t\t\t\t\tanimate={{scale: 1, opacity: 1}}\n\t\t\t\t\t\texit={{scale: 0.8, opacity: 0}}\n\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\ttype: 'spring',\n\t\t\t\t\t\t\tstiffness: 400,\n\t\t\t\t\t\t\tdamping: 25,\n\t\t\t\t\t\t\tdelay: 0.1,\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{/* Main progress ring */}\n\t\t\t\t\t<svg className='relative size-28 -rotate-90' viewBox='0 0 112 112'>\n\t\t\t\t\t\t<defs>\n\t\t\t\t\t\t\t<linearGradient id='progressGradient' x1='0%' y1='0%' x2='100%' y2='100%'>\n\t\t\t\t\t\t\t\t<stop offset='0%' stopColor='hsl(var(--color-brand))' />\n\t\t\t\t\t\t\t\t<stop offset='100%' stopColor='hsl(var(--color-brand-lightest))' />\n\t\t\t\t\t\t\t</linearGradient>\n\t\t\t\t\t\t\t<filter id='glow'>\n\t\t\t\t\t\t\t\t<feGaussianBlur stdDeviation='2' result='coloredBlur' />\n\t\t\t\t\t\t\t\t<feMerge>\n\t\t\t\t\t\t\t\t\t<feMergeNode in='coloredBlur' />\n\t\t\t\t\t\t\t\t\t<feMergeNode in='SourceGraphic' />\n\t\t\t\t\t\t\t\t</feMerge>\n\t\t\t\t\t\t\t</filter>\n\t\t\t\t\t\t</defs>\n\t\t\t\t\t\t{/* Background circle */}\n\t\t\t\t\t\t<circle\n\t\t\t\t\t\t\tcx='56'\n\t\t\t\t\t\t\tcy='56'\n\t\t\t\t\t\t\tr={radius}\n\t\t\t\t\t\t\tstroke='currentColor'\n\t\t\t\t\t\t\tstrokeWidth='3'\n\t\t\t\t\t\t\tfill='none'\n\t\t\t\t\t\t\tclassName='text-white/10'\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{/* Progress circle with gradient */}\n\t\t\t\t\t\t<circle\n\t\t\t\t\t\t\tcx='56'\n\t\t\t\t\t\t\tcy='56'\n\t\t\t\t\t\t\tr={radius}\n\t\t\t\t\t\t\tstroke='url(#progressGradient)'\n\t\t\t\t\t\t\tstrokeWidth='3'\n\t\t\t\t\t\t\tfill='none'\n\t\t\t\t\t\t\tstrokeDasharray={circumference}\n\t\t\t\t\t\t\tstrokeDashoffset={strokeDashoffset}\n\t\t\t\t\t\t\tclassName='transition-all duration-700 ease-out'\n\t\t\t\t\t\t\tstrokeLinecap='round'\n\t\t\t\t\t\t\tfilter='url(#glow)'\n\t\t\t\t\t\t/>\n\t\t\t\t\t</svg>\n\n\t\t\t\t\t{/* Icon container */}\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tclassName='absolute inset-0 flex items-center justify-center'\n\t\t\t\t\t\tinitial={{scale: 0.7, opacity: 0}}\n\t\t\t\t\t\tanimate={{scale: 1, opacity: 1}}\n\t\t\t\t\t\texit={{scale: 0.7, opacity: 0}}\n\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\ttype: 'spring',\n\t\t\t\t\t\t\tstiffness: 350,\n\t\t\t\t\t\t\tdamping: 22,\n\t\t\t\t\t\t\tdelay: 0.2,\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\tclassName='relative rounded-full border border-white/10 bg-white/5 p-3'\n\t\t\t\t\t\t\tinitial={{scale: 0.8, opacity: 0}}\n\t\t\t\t\t\t\tanimate={{scale: 1, opacity: 1}}\n\t\t\t\t\t\t\texit={{scale: 0.8, opacity: 0}}\n\t\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\t\ttype: 'spring',\n\t\t\t\t\t\t\t\tstiffness: 400,\n\t\t\t\t\t\t\t\tdamping: 20,\n\t\t\t\t\t\t\t\tdelay: 0.25,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{progress.path ? (\n\t\t\t\t\t\t\t\t<BackupDeviceIcon path={progress.path} className='size-12 p-1' />\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<div className='size-12' />\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t</motion.div>\n\t\t\t\t</motion.div>\n\t\t\t</div>\n\t\t)\n\t}\n\n\t// Multiple backups - show list view\n\treturn (\n\t\t<div className='flex h-full w-full flex-col overflow-hidden py-5'>\n\t\t\t<div className='mb-4 flex items-center justify-between px-5'>\n\t\t\t\t<span className='text-xs text-white/60'>{t('backups-floating-island.backing-up-to')}</span>\n\t\t\t</div>\n\n\t\t\t<ScrollArea className='flex-1 px-5 pb-1'>\n\t\t\t\t<div className='space-y-3'>\n\t\t\t\t\t{progresses.map((p) => (\n\t\t\t\t\t\t<div key={p.name} className='flex items-center gap-3'>\n\t\t\t\t\t\t\t{p.path ? (\n\t\t\t\t\t\t\t\t<BackupDeviceIcon path={p.path} className='size-7 shrink-0' />\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<div className='size-6 shrink-0' />\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t\t\t\t\t<div className='flex items-center justify-between text-xs text-white/70'>\n\t\t\t\t\t\t\t\t\t<span className='truncate'>{p.name}</span>\n\t\t\t\t\t\t\t\t\t<span className='shrink-0 text-white/60'>{p.percent.toFixed(0)}%</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className='relative mt-1 h-1 overflow-hidden rounded-full bg-white/20'>\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName='absolute top-0 left-0 h-full rounded-full bg-brand transition-all duration-300'\n\t\t\t\t\t\t\t\t\t\tstyle={{width: `${p.percent}%`}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t</ScrollArea>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/floating-island/index.tsx",
    "content": "import {useBackupProgress} from '@/features/backups/hooks/use-backups'\nimport {getDeviceNameFromPath} from '@/features/backups/utils/backup-location-helpers'\nimport {Island, IslandExpanded, IslandMinimized} from '@/modules/floating-island/bare-island'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nimport {ExpandedContent} from './expanded'\nimport {MinimizedContent} from './minimized'\n\nexport function BackupsIsland() {\n\t// Poll backup progress; island visibility is controlled by container\n\tconst progressQ = useBackupProgress(1000)\n\tconst reposQ = trpcReact.backups.getRepositories.useQuery()\n\n\tconst progresses = progressQ.data ?? []\n\tconst repoMap = new Map((reposQ.data || []).map((r) => [r.id, r]))\n\t// TODO: Figure out why sometimes we cannot get the repo path and remove the path/null check\n\tconst withNames: Array<{name: string; percent: number; path?: string}> = progresses\n\t\t.map((p: any) => {\n\t\t\tconst repoPath = repoMap.get(p.repositoryId)?.path\n\t\t\tif (!repoPath) return null\n\n\t\t\treturn {\n\t\t\t\tpercent: p.percent ?? 0,\n\t\t\t\tname: getDeviceNameFromPath(repoPath) || t('backups.backup-location'),\n\t\t\t\tpath: repoPath,\n\t\t\t}\n\t\t})\n\t\t.filter((item): item is NonNullable<typeof item> => item !== null)\n\n\tconst count = withNames.length\n\tconst totalPercent = count > 0 ? Math.round(withNames.reduce((s, p) => s + (p.percent ?? 0), 0) / count) : 0\n\n\treturn (\n\t\t<Island id='backups-island' nonDismissable>\n\t\t\t<IslandMinimized>\n\t\t\t\t<MinimizedContent count={count} progress={totalPercent} />\n\t\t\t</IslandMinimized>\n\t\t\t<IslandExpanded>\n\t\t\t\t<ExpandedContent progresses={withNames as any} />\n\t\t\t</IslandExpanded>\n\t\t</Island>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/floating-island/minimized.tsx",
    "content": "import {TbHistory} from 'react-icons/tb'\n\nimport {CircularProgress} from '@/features/files/components/shared/circular-progress'\nimport {t} from '@/utils/i18n'\n\nexport function MinimizedContent({progress}: {count: number; progress: number}) {\n\treturn (\n\t\t<div className='flex size-full items-center gap-2 px-2'>\n\t\t\t<CircularProgress progress={progress}>\n\t\t\t\t{/* simple dot */}\n\t\t\t\t<TbHistory size={12} />\n\t\t\t</CircularProgress>\n\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t<span className='block truncate text-center text-xs text-white/90'>\n\t\t\t\t\t{t('backups-floating-island.backing-up')}\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t\t{/* Reserve right-side space to match other islands' layout (invisible) */}\n\t\t\t<div className='flex shrink-0 items-center gap-2'>\n\t\t\t\t<span className='text-xs text-white/60'>{progress}%</span>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/modals/already-configured-modal.tsx",
    "content": "// Modal presented when the chosen backup folder already contains an Umbrel backup\n// that is currently in use on this Umbrel. Provides a quick action to manage it.\nimport {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'\nimport {BackupDeviceIcon} from '@/features/backups/components/backup-device-icon'\nimport {t} from '@/utils/i18n'\n\nexport function AlreadyConfiguredModal({\n\topen,\n\tfolderPath,\n\tonClose,\n\tonManage,\n}: {\n\topen: boolean\n\tfolderPath: string | undefined\n\tonClose: () => void\n\tonManage: () => void\n}) {\n\treturn (\n\t\t<Dialog open={open} onOpenChange={(v) => (!v ? onClose() : null)}>\n\t\t\t<DialogContent className='flex flex-col items-center text-center'>\n\t\t\t\t<DialogHeader className='items-center text-center'>\n\t\t\t\t\t<BackupDeviceIcon path={folderPath || ''} className='mb-2 size-10 opacity-80' />\n\t\t\t\t\t<DialogTitle>{t('backups.modals.already-in-use.title')}</DialogTitle>\n\t\t\t\t\t<DialogDescription>{t('backups.modals.already-in-use.description')}</DialogDescription>\n\t\t\t\t</DialogHeader>\n\t\t\t\t<DialogFooter className='justify-center gap-2 pt-2'>\n\t\t\t\t\t<Button variant='primary' size='dialog' onClick={onManage}>\n\t\t\t\t\t\t{t('backups.modals.already-in-use.manage')}\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button variant='default' size='dialog' onClick={onClose}>\n\t\t\t\t\t\t{t('close')}\n\t\t\t\t\t</Button>\n\t\t\t\t</DialogFooter>\n\t\t\t</DialogContent>\n\t\t</Dialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/modals/connect-existing-modal.tsx",
    "content": "// Modal shown when a backup repository is detected at the selected location but\n// is not yet connected to this Umbrel. Prompts for the encryption password and\n// provides Connect/Cancel actions.\nimport {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'\nimport {FormLabel} from '@/components/ui/form'\nimport {PasswordInput} from '@/components/ui/input'\nimport backupsIcon from '@/features/backups/assets/backups-icon.png'\nimport {t} from '@/utils/i18n'\n\nexport function ConnectExistingModal({\n\topen,\n\tpassword,\n\tonPasswordChange,\n\tonClose,\n\tonConnect,\n\tisConnecting,\n}: {\n\topen: boolean\n\tfolderPath: string | undefined\n\tpassword: string\n\tonPasswordChange: (v: string) => void\n\tonClose: () => void\n\tonConnect: () => void\n\tisConnecting?: boolean\n}) {\n\treturn (\n\t\t<Dialog open={open} onOpenChange={(v) => (!v ? onClose() : null)}>\n\t\t\t<DialogContent className='flex flex-col items-center text-center'>\n\t\t\t\t<DialogHeader className='items-center text-center'>\n\t\t\t\t\t<img\n\t\t\t\t\t\tsrc={backupsIcon}\n\t\t\t\t\t\talt={t('files-type.umbrel-backup')}\n\t\t\t\t\t\tclassName='mb-2 size-10 opacity-80'\n\t\t\t\t\t\tdraggable={false}\n\t\t\t\t\t/>\n\t\t\t\t\t<DialogTitle>{t('backups.modals.connect-existing.title')}</DialogTitle>\n\t\t\t\t\t<DialogDescription>{t('backups.modals.connect-existing.description')}</DialogDescription>\n\t\t\t\t</DialogHeader>\n\t\t\t\t<div className='w-full space-y-2 text-left'>\n\t\t\t\t\t<FormLabel className='text-13 opacity-60'>{t('backups-restore.encryption-password')}</FormLabel>\n\t\t\t\t\t<PasswordInput value={password} onValueChange={onPasswordChange} />\n\t\t\t\t</div>\n\t\t\t\t<DialogFooter className='justify-center gap-2 pt-2'>\n\t\t\t\t\t<Button variant='primary' size='dialog' disabled={!password || isConnecting} onClick={onConnect}>\n\t\t\t\t\t\t{t('connect')}\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button variant='default' size='dialog' onClick={onClose} disabled={isConnecting}>\n\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t</Button>\n\t\t\t\t</DialogFooter>\n\t\t\t</DialogContent>\n\t\t</Dialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/restore-location-dropdown.tsx",
    "content": "// We reuse this dropdown for both the:\n// - Restore wizard accessed via settings\n// - Restore flow during onboarding\n\nimport {ChevronDown} from 'lucide-react'\nimport {useState} from 'react'\nimport {TbAlertTriangleFilled} from 'react-icons/tb'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {Button} from '@/components/ui/button'\nimport {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'\nimport externalStorageIcon from '@/features/files/assets/external-storage-icon.png'\nimport {t} from '@/utils/i18n'\n\ntype RestoreLocationDropdownProps = {\n\tonSelect: (root: string) => void\n\tisExternalStorageSupported?: boolean\n}\n\nexport function RestoreLocationDropdown({onSelect, isExternalStorageSupported = true}: RestoreLocationDropdownProps) {\n\tconst [showUnsupportedDialog, setShowUnsupportedDialog] = useState(false)\n\n\tconst handleExternalClick = () => {\n\t\tif (isExternalStorageSupported) {\n\t\t\tonSelect('/External')\n\t\t} else {\n\t\t\tsetShowUnsupportedDialog(true)\n\t\t}\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<DropdownMenu>\n\t\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t\t<Button\n\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\tsize='sm'\n\t\t\t\t\t\tclassName='absolute top-1/2 right-5 inline-flex -translate-y-1/2 items-center gap-1'\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('backups-restore.choose')}\n\t\t\t\t\t\t<ChevronDown className='size-3' />\n\t\t\t\t\t</Button>\n\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t<DropdownMenuContent align='end' className='min-w-[320px]'>\n\t\t\t\t\t<DropdownMenuItem className='block' onSelect={() => onSelect('/Network')}>\n\t\t\t\t\t\t<div className='flex w-full flex-col items-start'>\n\t\t\t\t\t\t\t<div className='text-sm font-medium'>{t('backups-restore.browse-nas-title')}</div>\n\t\t\t\t\t\t\t<div className='text-xs opacity-60'>{t('backups-restore.browse-nas-subtitle')}</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t<DropdownMenuItem className='block' onSelect={handleExternalClick}>\n\t\t\t\t\t\t<div className='flex w-full flex-col items-start'>\n\t\t\t\t\t\t\t<div className='text-sm font-medium'>{t('backups-restore.browse-external-title')}</div>\n\t\t\t\t\t\t\t<div className='text-xs opacity-60'>{t('backups-restore.browse-external-subtitle')}</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t<DropdownMenuItem disabled className='block cursor-not-allowed opacity-60'>\n\t\t\t\t\t\t<div className='flex w-full flex-col items-start'>\n\t\t\t\t\t\t\t<div className='text-sm font-medium'>{t('backups-restore.browse-cloud-title')}</div>\n\t\t\t\t\t\t\t<div className='text-xs opacity-60'>{t('backups-restore.browse-cloud-subtitle')}</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t</DropdownMenuContent>\n\t\t\t</DropdownMenu>\n\n\t\t\t{/* External storage unsupported dialog */}\n\t\t\t<AlertDialog open={showUnsupportedDialog} onOpenChange={setShowUnsupportedDialog}>\n\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t\t<AlertDialogTitle>{t('files-external-storage.unsupported.title')}</AlertDialogTitle>\n\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t<div className='mt-2 flex justify-center'>\n\t\t\t\t\t\t<div className='relative'>\n\t\t\t\t\t\t\t<img src={externalStorageIcon} alt={t('external-drive')} className='size-16' draggable={false} />\n\t\t\t\t\t\t\t<div className='absolute -top-2 -right-2'>\n\t\t\t\t\t\t\t\t<TbAlertTriangleFilled className='size-8 text-yellow-400' />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<AlertDialogDescription className='text-center'>\n\t\t\t\t\t\t{t('files-external-storage.unsupported.description-general')}\n\t\t\t\t\t</AlertDialogDescription>\n\t\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t\t<AlertDialogAction onClick={() => setShowUnsupportedDialog(false)}>{t('ok')}</AlertDialogAction>\n\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t</AlertDialogContent>\n\t\t\t</AlertDialog>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/restore-wizard.tsx",
    "content": "import {zodResolver} from '@hookform/resolvers/zod'\nimport {formatDistanceToNow} from 'date-fns'\nimport {AlertOctagon, ArrowLeft, Loader2, Plus, Server} from 'lucide-react'\nimport {useMemo, useState} from 'react'\nimport {FormProvider, useForm, type Resolver, type SubmitHandler} from 'react-hook-form'\nimport {Trans} from 'react-i18next/TransWithoutContext'\nimport {TbCalendarTime, TbDatabase} from 'react-icons/tb'\nimport {Link} from 'react-router-dom'\nimport {z} from 'zod'\n\nimport {ErrorAlert} from '@/components/ui/alert'\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {Button} from '@/components/ui/button'\nimport {ImmersiveDialogSeparator} from '@/components/ui/immersive-dialog'\nimport {Input, PasswordInput} from '@/components/ui/input'\nimport {BackupDeviceIcon} from '@/features/backups/components/backup-device-icon'\nimport {RestoreLocationDropdown} from '@/features/backups/components/restore-location-dropdown'\nimport {ReviewCard} from '@/features/backups/components/review-card'\nimport {EmptyTile as EmptyCard, LoadingTile as LoadingCard} from '@/features/backups/components/tiles'\nimport {\n\tBackup,\n\tBackupRepository,\n\tuseBackups,\n\tuseConnectToRepository as useBackupsConnect,\n\tuseRestoreBackup as useBackupsRestore,\n\tuseRepositoryBackups,\n} from '@/features/backups/hooks/use-backups'\nimport {isRepoConnected} from '@/features/backups/utils/backup-location-helpers'\nimport {\n\tBACKUP_FILE_NAME,\n\tgetDisplayRepositoryPath,\n\tgetRepositoryDisplayName,\n\tgetRepositoryPathFromBackupFile,\n\tgetRepositoryRelativePath,\n} from '@/features/backups/utils/filepath-helpers'\nimport {sortBackupsByTimeDesc} from '@/features/backups/utils/sort'\nimport AddNetworkShareDialog from '@/features/files/components/dialogs/add-network-share-dialog'\nimport {MiniBrowser} from '@/features/files/components/mini-browser'\nimport {useExternalStorage} from '@/features/files/hooks/use-external-storage'\nimport {useNetworkStorage} from '@/features/files/hooks/use-network-storage'\nimport {formatFilesystemDate} from '@/features/files/utils/format-filesystem-date'\nimport {formatFilesystemSize} from '@/features/files/utils/format-filesystem-size'\nimport {useLanguage} from '@/hooks/use-language'\nimport {trpcReact} from '@/trpc/trpc'\nimport {languageCodeToDateLocale} from '@/utils/date-time'\nimport {t} from '@/utils/i18n'\n\n// ---------------------------------------------\n// Types & Schema\n// ---------------------------------------------\n\nconst restoreExistingSchema = z.object({\n\trepositoryId: z.string().min(1, {message: t('backups-restore.please-select-repository')}),\n\tbackupId: z.string().min(1, {message: t('backups-restore.please-select-backup')}),\n})\n\n// Wizard form values (backupId is optional until selected)\ntype RestoreWizardValues = {repositoryId: string; backupId?: string}\n\n// Relaxed schema while stepping (backupId optional until step 2)\nconst wizardExistingSchema: z.ZodType<RestoreWizardValues> = restoreExistingSchema.partial({\n\tbackupId: true,\n})\n\n// ---------------------------------------------\n// Step enum + labels\n// ---------------------------------------------\n\nenum Step {\n\tRepository = 0,\n\tBackups = 1,\n\tReview = 2,\n}\n\nconst headerMetaForStep = (s: Step) => {\n\tswitch (s) {\n\t\tcase Step.Repository:\n\t\t\treturn {\n\t\t\t\ttitle: t('backups-restore.choose-backup-location'),\n\t\t\t\tsubtitle: t('backups-restore.restore-from-nas-or-external'),\n\t\t\t}\n\t\tcase Step.Backups:\n\t\t\treturn {title: t('backups-restore.select-backup'), subtitle: t('backups-restore.select-backup-description')}\n\t\tcase Step.Review:\n\t\t\treturn {title: t('backups.review'), subtitle: t('backups.review-description')}\n\t\tdefault:\n\t\t\treturn {title: '', subtitle: ''}\n\t}\n}\n\n// ---------------------------------------------\n// Main Component\n// ---------------------------------------------\n\nexport function BackupsRestoreWizard() {\n\tconst [step, setStep] = useState<Step>(Step.Repository)\n\tconst [repoMode, setRepoMode] = useState<'known' | 'manual'>('known')\n\tconst [manualPath, setManualPath] = useState('')\n\tconst [manualPassword, setManualPassword] = useState('')\n\tconst [confirmOpen, setConfirmOpen] = useState(false)\n\tconst [confirmPassword, setConfirmPassword] = useState('')\n\tconst [confirmError, setConfirmError] = useState('')\n\tconst [isStartingRestore, setIsStartingRestore] = useState(false)\n\n\tconst form = useForm<RestoreWizardValues>({\n\t\tresolver: zodResolver(wizardExistingSchema as any) as unknown as Resolver<RestoreWizardValues>,\n\t\tdefaultValues: {\n\t\t\trepositoryId: '',\n\t\t\tbackupId: '',\n\t\t},\n\t\tmode: 'onChange',\n\t})\n\n\t// Data: repositories\n\tconst {repositories, isLoadingRepositories: isLoadingRepos} = useBackups()\n\n\t// Data: backups for selected repository\n\tconst repositoryId = form.watch('repositoryId')\n\tconst {data: backupsUnsorted, isLoading: isLoadingBackups} = useRepositoryBackups(repositoryId, {\n\t\tenabled: !!repositoryId,\n\t\tstaleTime: 15_000,\n\t})\n\n\t// Sort backups from latest to oldest\n\tconst backups = useMemo(\n\t\t() => (backupsUnsorted ? sortBackupsByTimeDesc(backupsUnsorted as Backup[]) : undefined),\n\t\t[backupsUnsorted],\n\t)\n\n\t// Watches for gating\n\tconst backupId = form.watch('backupId')\n\n\tconst canNext =\n\t\tstep === Step.Repository\n\t\t\t? repoMode === 'known'\n\t\t\t\t? !!repositoryId\n\t\t\t\t: manualPath.trim().length > 0 && manualPassword.trim().length > 0 && manualPath.endsWith(BACKUP_FILE_NAME)\n\t\t\t: step === Step.Backups\n\t\t\t\t? !!backupId\n\t\t\t\t: true\n\n\t// Start restore mutation\n\tconst {restoreBackup} = useBackupsRestore()\n\tconst {connectToRepository, isPending: isConnecting} = useBackupsConnect()\n\tconst verifyPasswordMutation = trpcReact.user.login.useMutation()\n\n\t// Step-scoped validation before next\n\tconst next = async () => {\n\t\tconst fieldsByStep: Record<Step, Array<keyof RestoreWizardValues | string>> = {\n\t\t\t[Step.Repository]: ['repositoryId'],\n\t\t\t[Step.Backups]: ['backupId'],\n\t\t\t[Step.Review]: [],\n\t\t}\n\t\tconst fields = fieldsByStep[step] ?? []\n\t\tif (step === Step.Repository && repoMode === 'manual') {\n\t\t\t// Attempt to connect to a manually specified repository\n\t\t\ttry {\n\t\t\t\t// Route enforces auth when a user exists; otherwise allowed during recovery\n\t\t\t\t// Extract parent directory from backup file path\n\t\t\t\tconst repositoryPath = getRepositoryPathFromBackupFile(manualPath)\n\t\t\t\tconst id = await connectToRepository({path: repositoryPath, password: manualPassword})\n\t\t\t\tform.setValue('repositoryId', id, {shouldValidate: true})\n\t\t\t\tsetStep(Step.Backups)\n\t\t\t\treturn\n\t\t\t} catch {\n\t\t\t\t// Error toasts are handled in the hook; remain on this step\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tconst ok = await form.trigger(fields as any, {shouldFocus: true})\n\t\tif (!ok) return\n\t\tsetStep((s) => Math.min(s + 1, Step.Review))\n\t}\n\n\tconst back = () => setStep((s) => Math.max(s - 1, Step.Repository))\n\n\t// Reset dependent fields when repository changes\n\tconst handleSelectRepository = (repoId: string) => {\n\t\tform.reset(\n\t\t\t{\n\t\t\t\trepositoryId: repoId,\n\t\t\t\tbackupId: '',\n\t\t\t},\n\t\t\t{keepDirty: false, keepTouched: false, keepErrors: false},\n\t\t)\n\t}\n\n\t// Selected entities\n\tconst selectedRepo = useMemo(() => repositories?.find((r) => r.id === repositoryId), [repositories, repositoryId])\n\tconst selectedBackup = useMemo(() => backups?.find((b) => b.id === backupId), [backups, backupId])\n\n\t// Final submit: start restore\n\tconst onSubmit: SubmitHandler<RestoreWizardValues> = async (values) => {\n\t\tconst {backupId} = values\n\t\tif (!backupId) return\n\t\ttry {\n\t\t\tawait restoreBackup(backupId)\n\t\t} catch {\n\t\t\t// Error toasts are handled in the hook; remain on this step\n\t\t}\n\t}\n\n\tconst handleConfirmRestore = async () => {\n\t\ttry {\n\t\t\tsetConfirmError('')\n\t\t\tif (!confirmPassword.trim()) return\n\n\t\t\tsetIsStartingRestore(true)\n\t\t\t// Verify password without altering auth state\n\t\t\tawait verifyPasswordMutation.mutateAsync({password: confirmPassword})\n\t\t\tawait onSubmit({repositoryId: '', backupId: backupId || ''})\n\t\t\t// Only close dialog on successful restore\n\t\t\tsetConfirmOpen(false)\n\t\t\tsetConfirmPassword('')\n\t\t} catch (error: any) {\n\t\t\tsetConfirmError(error?.message || t('backups-restore.invalid-password'))\n\t\t} finally {\n\t\t\tsetIsStartingRestore(false)\n\t\t}\n\t}\n\n\treturn (\n\t\t<FormProvider {...form}>\n\t\t\t<div className='flex h-full flex-col'>\n\t\t\t\t{/* Header */}\n\t\t\t\t<div className='mb-4'>\n\t\t\t\t\t{(() => {\n\t\t\t\t\t\tconst h = headerMetaForStep(step)\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<div className='text-24 font-medium text-white'>{h.title}</div>\n\t\t\t\t\t\t\t\t{h.subtitle ? <div className='text-15 text-white/60'>{h.subtitle}</div> : null}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)\n\t\t\t\t\t})()}\n\t\t\t\t</div>\n\t\t\t\t<div className='pb-4'>\n\t\t\t\t\t<ImmersiveDialogSeparator />\n\t\t\t\t</div>\n\n\t\t\t\t{/* Body */}\n\t\t\t\t<div className='min-h-0 flex-1 overflow-hidden'>\n\t\t\t\t\t{step === Step.Repository && (\n\t\t\t\t\t\t<RepositoryStep\n\t\t\t\t\t\t\trepositories={repositories}\n\t\t\t\t\t\t\tisLoading={isLoadingRepos}\n\t\t\t\t\t\t\tselectedId={repositoryId}\n\t\t\t\t\t\t\tonSelect={handleSelectRepository}\n\t\t\t\t\t\t\tmode={repoMode}\n\t\t\t\t\t\t\tonModeChange={setRepoMode}\n\t\t\t\t\t\t\tmanualPath={manualPath}\n\t\t\t\t\t\t\tonManualPathChange={setManualPath}\n\t\t\t\t\t\t\tmanualPassword={manualPassword}\n\t\t\t\t\t\t\tonManualPasswordChange={setManualPassword}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{step === Step.Backups && (\n\t\t\t\t\t\t<BackupsStep\n\t\t\t\t\t\t\tbackups={backups as any[] | undefined}\n\t\t\t\t\t\t\tisLoading={isLoadingBackups}\n\t\t\t\t\t\t\tselectedId={backupId}\n\t\t\t\t\t\t\tonSelect={(id) => form.setValue('backupId', id, {shouldValidate: true})}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{step === Step.Review && <ReviewStep repository={selectedRepo} backup={selectedBackup} />}\n\t\t\t\t</div>\n\n\t\t\t\t{/* Footer */}\n\t\t\t\t<div className='mt-6 flex items-center gap-2 pt-4 max-md:flex-col-reverse'>\n\t\t\t\t\t{step !== Step.Repository ? (\n\t\t\t\t\t\t<Button size='dialog' onClick={back} className='min-w-0 max-md:w-full'>\n\t\t\t\t\t\t\t{t('back')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t) : null}\n\t\t\t\t\t{step !== Step.Review ? (\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\t\tonClick={next}\n\t\t\t\t\t\t\tdisabled={!canNext || isConnecting}\n\t\t\t\t\t\t\tclassName='min-w-0 max-md:w-full'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className={isConnecting ? 'opacity-0' : 'opacity-100'}>{t('continue')}</span>\n\t\t\t\t\t\t\t{isConnecting && <Loader2 className='absolute h-4 w-4 animate-spin' />}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tvariant='destructive'\n\t\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\t\tonClick={() => setConfirmOpen(true)}\n\t\t\t\t\t\t\tclassName='min-w-0 max-md:w-full'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('backups-restore.restore-umbrel')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t{/* Confirm restore dialog */}\n\t\t\t\t<AlertDialog open={confirmOpen} onOpenChange={(o) => setConfirmOpen(o)}>\n\t\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t\t\t<AlertDialogTitle className='text-left'>{t('backups-restore.final-confirmation')}</AlertDialogTitle>\n\t\t\t\t\t\t\t<AlertDialogDescription className='text-left'>\n\t\t\t\t\t\t\t\t{t('backups-restore.final-confirmation-description')}\n\t\t\t\t\t\t\t</AlertDialogDescription>\n\t\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t\t<div className='mt-2'>\n\t\t\t\t\t\t\t<span className='mb-2 block text-left text-13 opacity-60'>\n\t\t\t\t\t\t\t\t{t('backups-restore.enter-password-to-confirm')}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<PasswordInput\n\t\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\t\tvalue={confirmPassword}\n\t\t\t\t\t\t\t\tonValueChange={(v) => {\n\t\t\t\t\t\t\t\t\tsetConfirmPassword(v)\n\t\t\t\t\t\t\t\t\tsetConfirmError('')\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\terror={confirmError}\n\t\t\t\t\t\t\t\tsizeVariant='short'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<AlertDialogFooter className='flex justify-start md:justify-start'>\n\t\t\t\t\t\t\t<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t\t\t\t<AlertDialogAction\n\t\t\t\t\t\t\t\tvariant='destructive'\n\t\t\t\t\t\t\t\tdisabled={!confirmPassword.trim() || isStartingRestore}\n\t\t\t\t\t\t\t\tonClick={handleConfirmRestore}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span className={isStartingRestore ? 'opacity-0' : 'opacity-100'}>\n\t\t\t\t\t\t\t\t\t{t('backups-restore.restore-umbrel')}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t{isStartingRestore && <Loader2 className='absolute h-4 w-4 animate-spin' />}\n\t\t\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t\t</AlertDialogContent>\n\t\t\t\t</AlertDialog>\n\t\t\t</div>\n\t\t</FormProvider>\n\t)\n}\n\n// ---------------------------------------------\n// Step 1 — Repositories\n// ---------------------------------------------\n\nfunction RepositoryStep({\n\trepositories,\n\tisLoading,\n\tselectedId,\n\tonSelect,\n\tmode,\n\tonModeChange,\n\tmanualPath,\n\tonManualPathChange,\n\tmanualPassword,\n\tonManualPasswordChange,\n}: {\n\trepositories?: BackupRepository[]\n\tisLoading: boolean\n\tselectedId?: string\n\tonSelect: (id: string) => void\n\tmode: 'known' | 'manual'\n\tonModeChange: (m: 'known' | 'manual') => void\n\tmanualPath: string\n\tonManualPathChange: (v: string) => void\n\tmanualPassword: string\n\tonManualPasswordChange: (v: string) => void\n}) {\n\tconst {doesHostHaveMountedShares} = useNetworkStorage()\n\tconst {disks, isExternalStorageSupported} = useExternalStorage()\n\tconst isConnected = (path: string) => isRepoConnected(path, doesHostHaveMountedShares, disks as any)\n\tconst renderIcon = (path: string) => (\n\t\t<>\n\t\t\t<BackupDeviceIcon path={path} connected={isConnected(path)} className='h-8 w-8 opacity-90' />\n\t\t</>\n\t)\n\n\tconst repoName = (path: string) => getRepositoryDisplayName(path) || t('unknown')\n\n\tconst repoPathDisplay = (path: string) => getRepositoryRelativePath(path)\n\n\t// Simple folder browser state for manual mode\n\tconst [isBrowserOpen, setBrowserOpen] = useState(false)\n\tconst [browserRoot, setBrowserRoot] = useState<string | undefined>(undefined)\n\tconst [isAddNasOpen, setAddNasOpen] = useState(false)\n\n\tconst [lang] = useLanguage()\n\n\treturn (\n\t\t<div className='space-y-4'>\n\t\t\t<div className='min-h-0 space-y-3'>\n\t\t\t\t{mode === 'known' ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{isLoading ? (\n\t\t\t\t\t\t\t<LoadingCard />\n\t\t\t\t\t\t) : repositories && repositories.length > 0 ? (\n\t\t\t\t\t\t\t<div className='max-h-[min(60vh,500px)] overflow-y-auto pr-1'>\n\t\t\t\t\t\t\t\t<div className='space-y-3'>\n\t\t\t\t\t\t\t\t\t{repositories.map((repo) => {\n\t\t\t\t\t\t\t\t\t\tconst selected = repo.id === selectedId\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t// We do not allow continuing with a disconnected device. If not connected,the row is visually disabled and non-interactive.\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tkey={repo.id}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={[\n\t\t\t\t\t\t\t\t\t\t\t\t\t'flex w-full min-w-0 items-center gap-3 rounded-xl border p-4 transition-colors',\n\t\t\t\t\t\t\t\t\t\t\t\t\tselected\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'border-brand bg-brand/15'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: isConnected(repo.path)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'border-white/10 bg-white/5 hover:bg-white/10'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'cursor-not-allowed border-white/10 bg-white/5 opacity-60',\n\t\t\t\t\t\t\t\t\t\t\t\t].join(' ')}\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\tif (!isConnected(repo.path)) return\n\t\t\t\t\t\t\t\t\t\t\t\t\tonSelect(repo.id)\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\taria-disabled={!isConnected(repo.path)}\n\t\t\t\t\t\t\t\t\t\t\t\ttabIndex={isConnected(repo.path) ? 0 : -1}\n\t\t\t\t\t\t\t\t\t\t\t\ttitle={!isConnected(repo.path) ? t('backups-configure.not-connected') : undefined}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{/* Connection dot like Configure */}\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='grid size-3 place-items-center rounded-full'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{backgroundColor: isConnected(repo.path) ? '#299E163D' : '#DF1F1F3D'}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='size-1.5 rounded-full'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{backgroundColor: isConnected(repo.path) ? '#299E16' : '#DF1F1F'}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{renderIcon(repo.path)}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className='text-sm break-words whitespace-normal' title={repo.path}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='font-medium'>{repoName(repo.path)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span> · {repoPathDisplay(repo.path)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className='text-[12px] opacity-60'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{repo.lastBackup\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? t('backups-restore.last-backup', {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdate: formatFilesystemDate(Number(repo.lastBackup), lang),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: t('backups-restore.no-backups-yet')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t{/* Manual add callout row (always shown in known mode) */}\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName='flex w-full min-w-0 items-center gap-3 rounded-xl border border-dashed border-white/10 bg-white/5 p-4 transition-colors hover:border-brand hover:bg-brand/15'\n\t\t\t\t\t\t\tonClick={() => onModeChange('manual')}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<div className='flex h-10 w-10 shrink-0 items-center justify-center'>\n\t\t\t\t\t\t\t\t<div className='flex size-10 items-center justify-center rounded-full bg-white/10'>\n\t\t\t\t\t\t\t\t\t<div className='flex size-7 items-center justify-center rounded-full bg-white/20'>\n\t\t\t\t\t\t\t\t\t\t<Plus className='size-4' />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t\t\t\t\t<div className='text-sm font-medium'>{t('backups-restore.restore-from-unlisted')}</div>\n\t\t\t\t\t\t\t\t<div className='text-[12px] opacity-60'>{t('backups-restore.browse-nas-or-external')}</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<div className='space-y-4'>\n\t\t\t\t\t\t{/* Title row with back arrow */}\n\t\t\t\t\t\t<div className='mb-1 flex items-center gap-2'>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\tclassName='inline-flex items-center justify-center text-white/70 hover:text-white'\n\t\t\t\t\t\t\t\tonClick={() => onModeChange('known')}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<ArrowLeft className='h-4 w-4' />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<div className='text-sm font-medium'>{t('backups-restore.connect-to-backup-location')}</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<div className='mb-2 text-sm font-medium'>{t('backups-restore.backup-location')}</div>\n\t\t\t\t\t\t\t<div className='relative'>\n\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\ttype='text'\n\t\t\t\t\t\t\t\t\tvalue={manualPath}\n\t\t\t\t\t\t\t\t\treadOnly\n\t\t\t\t\t\t\t\t\tclassName='pr-28'\n\t\t\t\t\t\t\t\t\ttitle={manualPath || ''}\n\t\t\t\t\t\t\t\t\taria-disabled={!manualPath}\n\t\t\t\t\t\t\t\t\ttabIndex={manualPath ? 0 : -1}\n\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t// We don't allow opening the browser by clicking the input if it is blank (user must click the dropdown to choose nas/external)\n\t\t\t\t\t\t\t\t\t\tif (!manualPath) return\n\t\t\t\t\t\t\t\t\t\t// Default to Network if no path is set, otherwise determine from current path\n\t\t\t\t\t\t\t\t\t\tconst root = manualPath?.startsWith('/Network') ? '/Network' : '/External'\n\t\t\t\t\t\t\t\t\t\tsetBrowserRoot(root)\n\t\t\t\t\t\t\t\t\t\tsetBrowserOpen(true)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<RestoreLocationDropdown\n\t\t\t\t\t\t\t\t\tonSelect={(root) => {\n\t\t\t\t\t\t\t\t\t\tsetBrowserRoot(root)\n\t\t\t\t\t\t\t\t\t\tsetBrowserOpen(true)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tisExternalStorageSupported={isExternalStorageSupported}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t<div className='mb-2 text-sm font-medium'>{t('backups-restore.encryption-password')}</div>\n\t\t\t\t\t\t\t<PasswordInput value={manualPassword} onValueChange={onManualPasswordChange} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<MiniBrowser\n\t\t\t\t\t\t\topen={isBrowserOpen}\n\t\t\t\t\t\t\tonOpenChange={setBrowserOpen}\n\t\t\t\t\t\t\trootPath={browserRoot || '/'}\n\t\t\t\t\t\t\tonOpenPath={manualPath || browserRoot || '/'}\n\t\t\t\t\t\t\tpreselectOnOpen={true}\n\t\t\t\t\t\t\tselectionMode='folders'\n\t\t\t\t\t\t\ttitle={t('backups-restore.select-backup-file')}\n\t\t\t\t\t\t\tsubtitle={\n\t\t\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\t\t\ti18nKey='backups-restore.select-backup-file-only'\n\t\t\t\t\t\t\t\t\tvalues={{backupFileName: BACKUP_FILE_NAME}}\n\t\t\t\t\t\t\t\t\tcomponents={{\n\t\t\t\t\t\t\t\t\t\tbold: <span className='text-brand-lightest' />,\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t// only allow selecting the backup file\n\t\t\t\t\t\t\tselectableFilter={(entry) => entry.name === BACKUP_FILE_NAME}\n\t\t\t\t\t\t\tonSelect={(p) => {\n\t\t\t\t\t\t\t\tonManualPathChange(p)\n\t\t\t\t\t\t\t\tsetBrowserOpen(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tactions={\n\t\t\t\t\t\t\t\tbrowserRoot === '/Network' ? (\n\t\t\t\t\t\t\t\t\t<Button size='sm' variant='default' onClick={() => setAddNasOpen(true)}>\n\t\t\t\t\t\t\t\t\t\t{t('backups.add-umbrel-or-nas')}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t) : null\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<AddNetworkShareDialog\n\t\t\t\t\t\t\topen={isAddNasOpen}\n\t\t\t\t\t\t\tonOpenChange={(v) => setAddNasOpen(v)}\n\t\t\t\t\t\t\tsuppressNavigateOnAdd\n\t\t\t\t\t\t\tonAdded={() => {\n\t\t\t\t\t\t\t\tsetBrowserRoot('/Network')\n\t\t\t\t\t\t\t\tsetBrowserOpen(true)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\n// ---------------------------------------------\n// Step 2 — Backups in selected repository\n// ---------------------------------------------\n\nfunction BackupsStep({\n\tbackups,\n\tisLoading,\n\tselectedId,\n\tonSelect,\n}: {\n\tbackups?: Backup[]\n\tisLoading: boolean\n\tselectedId?: string\n\tonSelect: (id: string) => void\n}) {\n\tconst [lang] = useLanguage()\n\treturn (\n\t\t<div className='space-y-4'>\n\t\t\t<div className='space-y-2'>\n\t\t\t\t{isLoading ? (\n\t\t\t\t\t<LoadingCard />\n\t\t\t\t) : !backups || backups.length === 0 ? (\n\t\t\t\t\t<EmptyCard text={t('backups-restore.no-backups-found')} />\n\t\t\t\t) : (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName='max-h-[45vh] overflow-hidden overflow-y-auto rounded-2xl bg-linear-to-b from-white/[0.03] to-transparent pt-1 pb-8 pl-1 md:max-h-[min(60vh,560px)]'\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tmaskImage: 'linear-gradient(to bottom, red 50px calc(100% - 80px), transparent)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className='space-y-1'>\n\t\t\t\t\t\t\t{backups.map((b, i) => {\n\t\t\t\t\t\t\t\tconst id = b.id ?? ''\n\t\t\t\t\t\t\t\tconst when = b.time\n\t\t\t\t\t\t\t\tconst date = when ? new Date(when) : null\n\t\t\t\t\t\t\t\tconst dateLabel = date ? formatFilesystemDate(when, lang) : t('backups-restore.unknown-date')\n\t\t\t\t\t\t\t\tconst timeAgo = date\n\t\t\t\t\t\t\t\t\t? formatDistanceToNow(date, {\n\t\t\t\t\t\t\t\t\t\t\taddSuffix: true,\n\t\t\t\t\t\t\t\t\t\t\tlocale: languageCodeToDateLocale[lang] ?? languageCodeToDateLocale.en,\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t: ''\n\t\t\t\t\t\t\t\tconst size = b.size\n\t\t\t\t\t\t\t\tconst sizeTxt = typeof size === 'number' ? formatFilesystemSize(size) : ''\n\n\t\t\t\t\t\t\t\tconst selected = id === selectedId\n\t\t\t\t\t\t\t\tconst isFirst = i === 0\n\t\t\t\t\t\t\t\tconst isLast = i === backups.length - 1\n\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tkey={id || Math.random()}\n\t\t\t\t\t\t\t\t\t\tclassName={[\n\t\t\t\t\t\t\t\t\t\t\t'group relative flex w-full items-center gap-4 rounded-12 px-3 py-2 md:px-4 md:py-3.5',\n\t\t\t\t\t\t\t\t\t\t\tselected\n\t\t\t\t\t\t\t\t\t\t\t\t? 'bg-linear-to-r from-brand/20 to-brand/10 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.1)]'\n\t\t\t\t\t\t\t\t\t\t\t\t: 'hover:bg-white/[0.06]',\n\t\t\t\t\t\t\t\t\t\t].join(' ')}\n\t\t\t\t\t\t\t\t\t\tonClick={() => id && onSelect(id)}\n\t\t\t\t\t\t\t\t\t\ttitle={id}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{/* Selection indicator */}\n\t\t\t\t\t\t\t\t\t\t<div className='relative flex size-10 shrink-0 items-center justify-center'>\n\t\t\t\t\t\t\t\t\t\t\t{/* Outer ring */}\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={[\n\t\t\t\t\t\t\t\t\t\t\t\t\t'absolute inset-0 rounded-full',\n\t\t\t\t\t\t\t\t\t\t\t\t\tselected\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'bg-linear-to-br from-brand to-brand/60 shadow-[0_0_20px_rgba(var(--color-brand-rgb),0.3)]'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'bg-white/10 group-hover:bg-white/15',\n\t\t\t\t\t\t\t\t\t\t\t\t].join(' ')}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t\t\t\t\t{/* Inner dot */}\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={[\n\t\t\t\t\t\t\t\t\t\t\t\t\t'relative size-4 rounded-full',\n\t\t\t\t\t\t\t\t\t\t\t\t\tselected\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'bg-white shadow-[0_0_10px_rgba(255,255,255,0.5)]'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'bg-white/20 group-hover:bg-white/30',\n\t\t\t\t\t\t\t\t\t\t\t\t].join(' ')}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t\t\t\t\t{/* Connecting line */}\n\t\t\t\t\t\t\t\t\t\t\t{!isLast && (\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='absolute top-full left-1/2 h-[calc(100%+0.25rem)] w-px -translate-x-1/2 bg-linear-to-b from-white/10 to-transparent' />\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t\t{/* Content */}\n\t\t\t\t\t\t\t\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t\t\t\t\t\t\t\t<div className='flex items-baseline gap-2'>\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={[\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t'truncate font-medium transition-colors duration-200 max-md:text-sm',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tselected ? 'text-white' : 'text-white/90',\n\t\t\t\t\t\t\t\t\t\t\t\t\t].join(' ')}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{dateLabel}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t{timeAgo && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={[\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t'shrink-0 text-xs transition-colors duration-200 max-md:hidden',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tselected ? 'text-white/70' : 'text-white/50',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t].join(' ')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{timeAgo}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t{sizeTxt && (\n\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={[\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t'mt-0.5 text-xs transition-colors duration-200',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tselected ? 'text-white/60' : 'text-white/40',\n\t\t\t\t\t\t\t\t\t\t\t\t\t].join(' ')}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{sizeTxt}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t\t{/* Latest badge */}\n\t\t\t\t\t\t\t\t\t\t{isFirst && (\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={[\n\t\t\t\t\t\t\t\t\t\t\t\t\t'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wider uppercase transition-all duration-200',\n\t\t\t\t\t\t\t\t\t\t\t\t\tselected ? 'bg-white/20 text-white' : 'bg-white/10 text-white/60',\n\t\t\t\t\t\t\t\t\t\t\t\t].join(' ')}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{t('backups-restore.latest')}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\n// ---------------------------------------------\n// Step 3 — Review\n// ---------------------------------------------\n\nfunction ReviewStep({repository, backup}: {repository?: BackupRepository; backup?: Backup}) {\n\tconst [lang] = useLanguage()\n\tconst when = backup?.time\n\tconst label = when ? formatFilesystemDate(when, lang) : t('backups-restore.unknown-date')\n\tconst size = backup?.size\n\tconst sizeTxt = typeof size === 'number' ? formatFilesystemSize(size) : t('unknown')\n\n\tconst repoDisplayPath = useMemo(() => {\n\t\tconst p = repository?.path || ''\n\t\tif (!p) return t('backups-restore.unknown-repository')\n\t\tconst display = getDisplayRepositoryPath(p)\n\t\treturn display || t('backups-restore.unknown-repository')\n\t}, [repository?.path])\n\n\treturn (\n\t\t<div className='space-y-4'>\n\t\t\t{/* Cards */}\n\t\t\t<div className='space-y-3'>\n\t\t\t\t<ReviewCard icon={<Server className='h-5 w-5 opacity-80' />} label={t('backups-restore.backup-location')}>\n\t\t\t\t\t<div className='truncate text-sm' title={repoDisplayPath}>\n\t\t\t\t\t\t{repoDisplayPath}\n\t\t\t\t\t</div>\n\t\t\t\t</ReviewCard>\n\t\t\t\t<ReviewCard icon={<TbCalendarTime className='h-5 w-5 opacity-80' />} label={t('backups-restore.backup-date')}>\n\t\t\t\t\t<div className='truncate text-sm' title={label}>\n\t\t\t\t\t\t{label}\n\t\t\t\t\t</div>\n\t\t\t\t</ReviewCard>\n\t\t\t\t<ReviewCard icon={<TbDatabase className='h-5 w-5 opacity-80' />} label={t('backups-restore.total-size')}>\n\t\t\t\t\t<div className='truncate text-sm'>{sizeTxt}</div>\n\t\t\t\t</ReviewCard>\n\t\t\t</div>\n\n\t\t\t{/* Warning */}\n\t\t\t{/* Use Trans component to embed Rewind link within translated text using numeric placeholders */}\n\t\t\t<ErrorAlert\n\t\t\t\ticon={AlertOctagon}\n\t\t\t\tdescription={\n\t\t\t\t\t<Trans\n\t\t\t\t\t\ti18nKey='backups-restore.restore-warning'\n\t\t\t\t\t\tcomponents={[<Link to='/files?rewind=open' className='underline' key='rewind' />]}\n\t\t\t\t\t/>\n\t\t\t\t}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/review-card.tsx",
    "content": "import * as React from 'react'\n\nexport function ReviewCard({\n\ticon,\n\tlabel,\n\tchildren,\n}: {\n\ticon: React.ReactNode\n\tlabel: string\n\tchildren?: React.ReactNode\n}) {\n\treturn (\n\t\t<div className='flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-4'>\n\t\t\t<div className='flex size-10 shrink-0 items-center justify-center rounded-[8px] bg-white/10'>{icon}</div>\n\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t<div className='text-[13px] text-white/60'>{label}</div>\n\t\t\t\t<div className='min-w-0 text-sm break-words'>{children}</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/setup-wizard.tsx",
    "content": "import {zodResolver} from '@hookform/resolvers/zod'\nimport {t} from 'i18next'\nimport {ChevronDown, Copy, Eye, EyeOff, HardDrive, Loader2, LockKeyhole} from 'lucide-react'\nimport * as React from 'react'\nimport {useEffect, useMemo, useState} from 'react'\nimport {FormProvider, useForm, useFormContext, type Resolver, type SubmitHandler} from 'react-hook-form'\nimport {Trans} from 'react-i18next/TransWithoutContext'\nimport {FaRegSave} from 'react-icons/fa'\nimport {TbAlertTriangleFilled, TbExternalLink, TbPassword, TbShoppingBag} from 'react-icons/tb'\nimport {useNavigate} from 'react-router-dom'\nimport {useCopyToClipboard} from 'react-use'\nimport {z} from 'zod'\n\nimport {ErrorAlert, WarningAlert} from '@/components/ui/alert'\nimport {Button} from '@/components/ui/button'\nimport {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'\nimport {Form, FormControl, FormField, FormItem, FormLabel, FormMessage} from '@/components/ui/form'\nimport {ImmersiveDialogSeparator} from '@/components/ui/immersive-dialog'\nimport {Input, PasswordInput} from '@/components/ui/input'\nimport umbrelPrivateCloudIcon from '@/features/backups/assets/umbrel-private-cloud-icon.png'\nimport {BackupDeviceIcon} from '@/features/backups/components/backup-device-icon'\nimport {BackupsExclusions} from '@/features/backups/components/backups-exclusions'\nimport {AlreadyConfiguredModal} from '@/features/backups/components/modals/already-configured-modal'\nimport {ConnectExistingModal} from '@/features/backups/components/modals/connect-existing-modal'\nimport {ReviewCard} from '@/features/backups/components/review-card'\nimport {TabSwitcher} from '@/features/backups/components/tab-switcher'\nimport {LoadingTile as LoadingCard} from '@/features/backups/components/tiles'\nimport {useAppsBackupIgnoredSummary} from '@/features/backups/hooks/use-apps-backup-ignore'\nimport {useBackupIgnoredPaths} from '@/features/backups/hooks/use-backup-ignored-paths'\nimport {useBackups, type BackupDestination} from '@/features/backups/hooks/use-backups'\nimport {useExistingBackupDetection} from '@/features/backups/hooks/use-existing-backup-detection'\nimport {BACKUP_FILE_NAME, getLastPathSegment, getRelativePathFromRoot} from '@/features/backups/utils/filepath-helpers'\nimport externalStorageIcon from '@/features/files/assets/external-storage-icon.png'\nimport {AddManuallyCard, ServerCard} from '@/features/files/components/cards/server-cards'\nimport AddNetworkShareDialog from '@/features/files/components/dialogs/add-network-share-dialog'\nimport {MiniBrowser} from '@/features/files/components/mini-browser'\nimport {useExternalStorage} from '@/features/files/hooks/use-external-storage'\nimport {useNetworkDeviceType} from '@/features/files/hooks/use-network-device-type'\nimport {useNetworkStorage} from '@/features/files/hooks/use-network-storage'\nimport {formatFilesystemSize} from '@/features/files/utils/format-filesystem-size'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {useConfirmation} from '@/providers/confirmation'\n\n// ---------------------------------------------\n// Types & Schema\n// ---------------------------------------------\n\nconst encryptionSchema = z\n\t.object({\n\t\tpassword: z.string().min(8, {message: t('backups.password-minimum-length')}),\n\t\tconfirm: z.string(),\n\t})\n\t.refine((d) => d.password === d.confirm, {\n\t\tmessage: t('backups.passwords-do-not-match'),\n\t\tpath: ['confirm'],\n\t})\n\nconst destinationSchema = z.discriminatedUnion('type', [\n\tz.object({\n\t\ttype: z.literal('nas'),\n\t\thost: z.string().min(1),\n\t\trootPath: z.string().min(1),\n\t}),\n\tz.object({\n\t\ttype: z.literal('external'),\n\t\tmountpoint: z.string().min(1),\n\t}),\n]) satisfies z.ZodType<BackupDestination>\n\nconst formSchema = z.object({\n\tdestination: destinationSchema,\n\tfolder: z.string().min(1, {message: t('backups.please-choose-folder')}),\n\tencryption: encryptionSchema,\n})\n\ntype FormValues = z.infer<typeof formSchema>\n\n// Relaxed schema used during the wizard (destination required, others can be filled later)\nconst wizardStepSchema = z.object({\n\tdestination: destinationSchema,\n\tfolder: z.string().optional(),\n\tencryption: encryptionSchema.partial(),\n})\n\n// ---------------------------------------------\n// Wizard Steps\n// ---------------------------------------------\n\nenum Step {\n\tDestination = 0,\n\tFolder = 1,\n\tExclusions = 2,\n\tEncryption = 3,\n\tReview = 4,\n}\n\n// Header meta per step (title and optional subtitle)\nconst headerMetaForStep = (s: Step) => {\n\tswitch (s) {\n\t\tcase Step.Destination:\n\t\t\treturn {\n\t\t\t\ttitle: t('backups.select-backup-location'),\n\t\t\t\tsubtitle: t('backups.schedule-description'),\n\t\t\t}\n\t\tcase Step.Folder:\n\t\t\treturn {title: t('backups.select-backup-location'), subtitle: t('backups.select-backup-folder-description')}\n\t\tcase Step.Exclusions:\n\t\t\treturn {title: t('backups.exclude-from-backups'), subtitle: t('backups.exclude-from-backups-description')}\n\t\tcase Step.Encryption:\n\t\t\treturn {title: t('backups.set-encryption-password'), subtitle: t('backups.set-encryption-password-description')}\n\t\tcase Step.Review:\n\t\t\treturn {title: t('backups.review'), subtitle: t('backups.review-description')}\n\t\tdefault:\n\t\t\treturn {title: '', subtitle: ''}\n\t}\n}\n\n// ---------------------------------------------\n// MAIN COMPONENT\n// ---------------------------------------------\n\nexport function BackupsSetupWizard() {\n\tconst [step, setStep] = useState<Step>(Step.Destination)\n\tconst navigate = useNavigate()\n\tconst confirm = useConfirmation()\n\n\tconst form = useForm<FormValues>({\n\t\tresolver: zodResolver(wizardStepSchema as any) as Resolver<FormValues>,\n\t\tdefaultValues: {\n\t\t\tdestination: undefined as any,\n\t\t\tfolder: '',\n\t\t\tencryption: {password: '', confirm: ''},\n\t\t},\n\t\tmode: 'onChange',\n\t})\n\n\tconst {setupBackup, isSettingUpBackup, repositories, connectExistingRepository, isConnectingExisting} = useBackups()\n\tconst {disks} = useExternalStorage()\n\tconst showExclusionsStep = (repositories?.length ?? 0) === 0\n\n\t// Watches so the parent re-renders when these fields change\n\tconst destination = form.watch('destination')\n\tconst folder = form.watch('folder')\n\tconst enc = form.watch('encryption')\n\n\t// modals when connecting existing/configured repositories\n\tconst [alreadyConfiguredOpen, setAlreadyConfiguredOpen] = useState(false)\n\tconst [connectExistingOpen, setConnectExistingOpen] = useState(false)\n\tconst [connectPassword, setConnectPassword] = useState('')\n\n\t// Detect if the selected folder contains an Umbrel backup and whether it's already configured\n\tconst {status: repoStatus} = useExistingBackupDetection(folder, repositories)\n\n\tconst canNext =\n\t\tstep === Step.Destination\n\t\t\t? !!destination\n\t\t\t: step === Step.Folder\n\t\t\t\t? !!folder\n\t\t\t\t: step === Step.Encryption\n\t\t\t\t\t? (enc?.password?.length ?? 0) >= 8 && enc?.password === enc?.confirm\n\t\t\t\t\t: true\n\n\t// Validate per-step before advancing\n\tconst next = async () => {\n\t\tconst fieldsByStep: Record<Step, Array<keyof FormValues | string>> = {\n\t\t\t[Step.Destination]: ['destination'],\n\t\t\t[Step.Folder]: ['folder'],\n\t\t\t[Step.Exclusions]: [],\n\t\t\t[Step.Encryption]: ['encryption.password', 'encryption.confirm'],\n\t\t\t[Step.Review]: [],\n\t\t}\n\t\tconst fields = fieldsByStep[step] ?? []\n\t\tconst ok = await form.trigger(fields as any, {shouldFocus: true})\n\t\tif (!ok) return\n\n\t\t// Intercept Folder step for existing repositories UX\n\t\tif (step === Step.Folder) {\n\t\t\tif (repoStatus === 'already-configured') {\n\t\t\t\tsetAlreadyConfiguredOpen(true)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (repoStatus === 'exists-not-configured') {\n\t\t\t\tsetConnectExistingOpen(true)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// Before advancing from Encryption, show a confirmation alert\n\t\tif (step === Step.Encryption) {\n\t\t\ttry {\n\t\t\t\tconst res = await confirm({\n\t\t\t\t\ttitle: t('backups.store-encryption-password-safely'),\n\t\t\t\t\tmessage: t('backups.encryption-password-warning'),\n\t\t\t\t\tactions: [\n\t\t\t\t\t\t{label: t('backups.i-understand'), value: 'confirm', variant: 'primary'},\n\t\t\t\t\t\t{label: t('cancel'), value: 'cancel', variant: 'default'},\n\t\t\t\t\t],\n\t\t\t\t})\n\t\t\t\tif (res.actionValue !== 'confirm') return\n\t\t\t} catch {\n\t\t\t\t// dialog dismissed or cancelled\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tsetStep((s) => {\n\t\t\tlet target = Math.min(s + 1, Step.Review)\n\t\t\tif (!showExclusionsStep && s === Step.Folder) target = Step.Encryption\n\t\t\treturn target\n\t\t})\n\t}\n\n\tconst back = () =>\n\t\tsetStep((s) => {\n\t\t\tlet target = Math.max(s - 1, Step.Destination)\n\t\t\tif (!showExclusionsStep && s === Step.Encryption) target = Step.Folder\n\t\t\treturn target\n\t\t})\n\n\t// When destination changes, reset dependent fields (folder/encryption/frequency) using reset\n\tconst handleDestinationChange = (dest: BackupDestination) => {\n\t\tform.reset(\n\t\t\t{\n\t\t\t\tdestination: dest,\n\t\t\t\tfolder: '',\n\t\t\t\tencryption: {password: '', confirm: ''},\n\t\t\t},\n\t\t\t{\n\t\t\t\tkeepDirty: false,\n\t\t\t\tkeepTouched: false,\n\t\t\t\tkeepErrors: false,\n\t\t\t},\n\t\t)\n\t}\n\n\t// Full submit (strict validate)\n\tconst onSubmit: SubmitHandler<FormValues> = async (values) => {\n\t\tconst parsed = formSchema.safeParse(values)\n\t\tif (!parsed.success) return\n\n\t\ttry {\n\t\t\tif (repoStatus === 'exists-not-configured') {\n\t\t\t\tawait connectExistingRepository({path: parsed.data.folder, password: parsed.data.encryption.password})\n\t\t\t} else {\n\t\t\t\tawait setupBackup({\n\t\t\t\t\tdestination: parsed.data.destination,\n\t\t\t\t\tfolder: parsed.data.folder,\n\t\t\t\t\tencryptionPassword: parsed.data.encryption.password,\n\t\t\t\t})\n\t\t\t}\n\t\t\t// On success, close the dialog by navigating to Configure\n\t\t\tnavigate('/settings/backups/configure', {preventScrollReset: true})\n\t\t} catch {\n\t\t\t// Error toasts are handled in the hook; remain on this step\n\t\t}\n\t}\n\n\tconst folderRootPath = React.useMemo(() => {\n\t\tif (!destination) return undefined\n\t\treturn destination.type === 'nas' ? destination.rootPath : destination.mountpoint\n\t}, [destination])\n\n\t// Clear sensitive encryption fields on unmount (defense-in-depth)\n\tReact.useEffect(() => {\n\t\treturn () => {\n\t\t\tform.reset({\n\t\t\t\t...form.getValues(),\n\t\t\t\tencryption: {password: '', confirm: ''},\n\t\t\t})\n\t\t}\n\t}, [])\n\n\treturn (\n\t\t<FormProvider {...form}>\n\t\t\t<div className='flex h-full flex-col'>\n\t\t\t\t{/* Header */}\n\t\t\t\t<div className='mb-4'>\n\t\t\t\t\t{(() => {\n\t\t\t\t\t\tconst h = headerMetaForStep(step)\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<h2 className='text-24 font-medium text-white'>{h.title}</h2>\n\t\t\t\t\t\t\t\t{h.subtitle ? <span className='text-13 text-white/60'>{h.subtitle}</span> : null}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)\n\t\t\t\t\t})()}\n\t\t\t\t</div>\n\t\t\t\t<div className='pb-4'>\n\t\t\t\t\t<ImmersiveDialogSeparator />\n\t\t\t\t</div>\n\n\t\t\t\t{/* Body */}\n\t\t\t\t<div className='min-h-0 flex-1 overflow-y-auto'>\n\t\t\t\t\t{step === Step.Destination && <DestinationStep onChangeDestination={handleDestinationChange} onNext={next} />}\n\t\t\t\t\t{step === Step.Folder && folderRootPath && (\n\t\t\t\t\t\t<FolderPickerStep\n\t\t\t\t\t\t\trootPath={folderRootPath}\n\t\t\t\t\t\t\tdisabledPaths={destination?.type === 'nas' ? [folderRootPath] : []}\n\t\t\t\t\t\t\tvalue={folder}\n\t\t\t\t\t\t\tonChange={(val) => form.setValue('folder', val, {shouldValidate: true})}\n\t\t\t\t\t\t\tselectedName={\n\t\t\t\t\t\t\t\tdestination?.type === 'nas'\n\t\t\t\t\t\t\t\t\t? destination.host\n\t\t\t\t\t\t\t\t\t: destination?.type === 'external'\n\t\t\t\t\t\t\t\t\t\t? disks\n\t\t\t\t\t\t\t\t\t\t\t\t?.flatMap((disk) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\tdisk.partitions.map((p) => ({\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tmountpoint: p.mountpoints?.[0],\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tlabel: p.label || disk.name || t('external-drive'),\n\t\t\t\t\t\t\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\t.find((p) => p.mountpoint === destination.mountpoint)?.label\n\t\t\t\t\t\t\t\t\t\t: t('external-drive')\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t\t{step === Step.Exclusions && showExclusionsStep && <BackupsExclusions />}\n\t\t\t\t\t{step === Step.Encryption && <EncryptionStep />}\n\t\t\t\t\t{step === Step.Review && <ReviewStep values={form.getValues()} />}\n\t\t\t\t</div>\n\n\t\t\t\t{/* Footer */}\n\t\t\t\t<div className='mt-6 flex items-center gap-2 pt-4 max-md:flex-col-reverse'>\n\t\t\t\t\t{step !== Step.Destination ? (\n\t\t\t\t\t\t<Button size='dialog' onClick={back} className='min-w-0 max-md:w-full'>\n\t\t\t\t\t\t\t{t('back')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t) : null}\n\t\t\t\t\t{step !== Step.Review ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\t\t\tonClick={next}\n\t\t\t\t\t\t\t\tdisabled={!canNext}\n\t\t\t\t\t\t\t\tclassName='min-w-0 max-md:w-full'\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t('continue')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\t\tdisabled={isSettingUpBackup}\n\t\t\t\t\t\t\tonClick={form.handleSubmit(onSubmit)}\n\t\t\t\t\t\t\tclassName='min-w-0 max-md:w-full'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className={isSettingUpBackup ? 'opacity-0' : 'opacity-100'}>{t('backups-setup-confirm')}</span>\n\t\t\t\t\t\t\t{isSettingUpBackup && <Loader2 className='absolute h-4 w-4 animate-spin' />}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t{/* Modal: shown when the chosen folder already has a backup configured on this Umbrel */}\n\t\t\t\t<AlreadyConfiguredModal\n\t\t\t\t\topen={alreadyConfiguredOpen}\n\t\t\t\t\tfolderPath={folder}\n\t\t\t\t\tonClose={() => setAlreadyConfiguredOpen(false)}\n\t\t\t\t\tonManage={() => {\n\t\t\t\t\t\tsetAlreadyConfiguredOpen(false)\n\t\t\t\t\t\tnavigate('/settings/backups/configure', {preventScrollReset: true})\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t{/* Modal: shown when the chosen folder contains a backup that is not yet connected here */}\n\t\t\t\t<ConnectExistingModal\n\t\t\t\t\topen={connectExistingOpen}\n\t\t\t\t\tfolderPath={folder}\n\t\t\t\t\tpassword={connectPassword}\n\t\t\t\t\tonPasswordChange={setConnectPassword}\n\t\t\t\t\tonClose={() => setConnectExistingOpen(false)}\n\t\t\t\t\tonConnect={async () => {\n\t\t\t\t\t\t// Remove the backup file name from the folder path to get the repo path\n\t\t\t\t\t\t// in case the user selected the backup file itself\n\t\t\t\t\t\tawait connectExistingRepository({\n\t\t\t\t\t\t\tpath: folder!.endsWith(`/${BACKUP_FILE_NAME}`)\n\t\t\t\t\t\t\t\t? folder!.slice(0, -(BACKUP_FILE_NAME.length + 1))\n\t\t\t\t\t\t\t\t: folder!,\n\t\t\t\t\t\t\tpassword: connectPassword,\n\t\t\t\t\t\t})\n\t\t\t\t\t\tsetConnectExistingOpen(false)\n\t\t\t\t\t\tnavigate('/settings/backups/configure', {preventScrollReset: true})\n\t\t\t\t\t}}\n\t\t\t\t\tisConnecting={isConnectingExisting}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</FormProvider>\n\t)\n}\n\n// ---------------------------------------------\n// Step 0 — Destination (NAS or External Drive)\n// ---------------------------------------------\n\nfunction DestinationStep({\n\tonChangeDestination,\n\tonNext,\n}: {\n\tonChangeDestination: (dest: BackupDestination) => void\n\tonNext: () => void\n}) {\n\tconst form = useFormContext<FormValues>()\n\tconst {params, addLinkSearchParams} = useQueryParams()\n\tconst navigate = useNavigate()\n\tconst initialTabParam = params.get('backups-setup-tab')\n\tconst isMobile = useIsMobile()\n\n\tconst [tab, setTab] = useState<'nas' | 'external' | 'umbrel-private-cloud'>(\n\t\tinitialTabParam === 'external'\n\t\t\t? 'external'\n\t\t\t: initialTabParam === 'umbrel-private-cloud'\n\t\t\t\t? 'umbrel-private-cloud'\n\t\t\t\t: 'nas',\n\t)\n\tconst [isAddNasOpen, setAddNasOpen] = useState(false)\n\n\t// Prefer the selected destination type to drive the tab (so Back returns to the right tab)\n\tconst dest = form.watch('destination') as BackupDestination | undefined\n\tuseEffect(() => {\n\t\tif (dest?.type === 'nas' || dest?.type === 'external') {\n\t\t\tsetTab(dest.type)\n\t\t}\n\t}, [dest?.type])\n\n\t// NAS sources: show hosts that have at least one mounted share\n\tconst {shares, isLoadingShares, refetchShares} = useNetworkStorage({suppressNavigateOnAdd: true})\n\tconst hosts = useMemo(() => {\n\t\tif (!shares) return []\n\t\tconst mounted = shares.filter((s) => s.isMounted)\n\t\treturn Array.from(new Set(mounted.map((s) => s.host)))\n\t}, [shares])\n\n\t// External drives (partitions)\n\tconst {disks, isLoadingExternalStorage, isExternalStorageSupported} = useExternalStorage()\n\n\tconst currentDest = form.watch('destination')\n\n\tconst switchTab = (tab: 'nas' | 'external' | 'umbrel-private-cloud') => {\n\t\tsetTab(tab)\n\t\tconst search = addLinkSearchParams({'backups-setup-tab': tab})\n\t\t// Update URL without navigating\n\t\twindow.history.replaceState(null, '', search)\n\t}\n\n\treturn (\n\t\t<div className='space-y-4'>\n\t\t\t{isMobile ? (\n\t\t\t\t<div className='flex items-center justify-between pb-4'>\n\t\t\t\t\t<span className='text-13'>{t('backups.backup-location')}</span>\n\t\t\t\t\t<DropdownMenu>\n\t\t\t\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t\t\t\t<Button variant='default' className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t{tab === 'nas'\n\t\t\t\t\t\t\t\t\t\t? t('backups-setup-umbrel-or-nas')\n\t\t\t\t\t\t\t\t\t\t: tab === 'external'\n\t\t\t\t\t\t\t\t\t\t\t? t('external-drive')\n\t\t\t\t\t\t\t\t\t\t\t: t('backups-setup-umbrel-private-cloud')}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<ChevronDown className='h-3 w-3' />\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t\t\t<DropdownMenuContent align='end' className='min-w-[280px]'>\n\t\t\t\t\t\t\t<DropdownMenuItem onSelect={() => switchTab('nas')}>\n\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t<div className='text-14 font-medium'>{t('backups-setup-umbrel-or-nas')}</div>\n\t\t\t\t\t\t\t\t\t<div className='text-12 text-white/40'>{t('backups-setup-nas-or-umbrel-description')}</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t<DropdownMenuItem onSelect={() => switchTab('external')}>\n\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t<div className='text-14 font-medium'>{t('external-drive')}</div>\n\t\t\t\t\t\t\t\t\t<div className='text-12 text-white/40'>{t('backups-setup-external-description')}</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t<DropdownMenuItem onSelect={() => switchTab('umbrel-private-cloud')}>\n\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t<div className='text-14 font-medium'>{t('backups-setup-umbrel-private-cloud')}</div>\n\t\t\t\t\t\t\t\t\t<div className='text-12 text-white/40'>{t('backups-setup-umbrel-private-cloud-description')}</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t</DropdownMenuContent>\n\t\t\t\t\t</DropdownMenu>\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<TabSwitcher\n\t\t\t\t\toptions={[\n\t\t\t\t\t\t{id: 'nas', label: t('backups-setup-umbrel-or-nas')},\n\t\t\t\t\t\t{id: 'external', label: t('external-drive')},\n\t\t\t\t\t\t{id: 'umbrel-private-cloud', label: t('backups-setup-umbrel-private-cloud')},\n\t\t\t\t\t]}\n\t\t\t\t\tvalue={tab}\n\t\t\t\t\tonChange={(v) => {\n\t\t\t\t\t\tswitchTab(v as 'nas' | 'external' | 'umbrel-private-cloud')\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t)}\n\n\t\t\t{tab === 'nas' ? (\n\t\t\t\t<div className='grid grid-cols-[repeat(auto-fill,125px)] gap-3'>\n\t\t\t\t\t{isLoadingShares ? (\n\t\t\t\t\t\t<LoadingCard />\n\t\t\t\t\t) : hosts.length === 0 ? (\n\t\t\t\t\t\t<AddManuallyCard onClick={() => setAddNasOpen(true)} label={t('backups.add-umbrel-or-nas')} />\n\t\t\t\t\t) : (\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\t<AddManuallyCard\n\t\t\t\t\t\t\t\tkey='add-umbrel-or-nas'\n\t\t\t\t\t\t\t\tonClick={() => setAddNasOpen(true)}\n\t\t\t\t\t\t\t\tlabel={t('backups.add-umbrel-or-nas')}\n\t\t\t\t\t\t\t/>,\n\t\t\t\t\t\t\t...hosts.map((host) => {\n\t\t\t\t\t\t\t\tconst selected =\n\t\t\t\t\t\t\t\t\tcurrentDest?.type === 'nas' &&\n\t\t\t\t\t\t\t\t\tcurrentDest.host === host &&\n\t\t\t\t\t\t\t\t\tcurrentDest.rootPath === `/Network/${host}`\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<ServerCard\n\t\t\t\t\t\t\t\t\t\tkey={host}\n\t\t\t\t\t\t\t\t\t\tselected={!!selected}\n\t\t\t\t\t\t\t\t\t\tonClick={() => onChangeDestination({type: 'nas', host, rootPath: `/Network/${host}`})}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<BackupDeviceIcon path={`/Network/${host}`} connected className='mb-2 size-12' />\n\t\t\t\t\t\t\t\t\t\t<span className='w-full truncate text-center text-[12px]' title={host}>\n\t\t\t\t\t\t\t\t\t\t\t{host}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</ServerCard>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t]\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t) : tab === 'external' ? (\n\t\t\t\t!isExternalStorageSupported ? (\n\t\t\t\t\t// External storage not supported on Raspberry Pi\n\t\t\t\t\t<div className='flex flex-col items-center justify-center gap-4 rounded-20 border border-white/10 bg-black/30 px-6 py-8'>\n\t\t\t\t\t\t<div className='relative'>\n\t\t\t\t\t\t\t<img src={externalStorageIcon} alt={t('external-drive')} className='size-16' draggable={false} />\n\t\t\t\t\t\t\t<div className='absolute -top-2 -right-2'>\n\t\t\t\t\t\t\t\t<TbAlertTriangleFilled className='size-8 text-yellow-400' />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className='flex flex-col items-center gap-1 text-center'>\n\t\t\t\t\t\t\t<span className='text-15 font-medium text-white'>{t('files-external-storage.unsupported.title')}</span>\n\t\t\t\t\t\t\t<span className='max-w-sm text-13 text-white/60'>\n\t\t\t\t\t\t\t\t{t('files-external-storage.unsupported.description-general')}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t) : (\n\t\t\t\t\t<div className='grid grid-cols-[repeat(auto-fill,125px)] gap-3'>\n\t\t\t\t\t\t{isLoadingExternalStorage ? (\n\t\t\t\t\t\t\t<div className='col-span-full flex items-center justify-start gap-2 py-2 text-sm text-white/60'>\n\t\t\t\t\t\t\t\t<Loader2 className='size-4 animate-spin will-change-transform' />\n\t\t\t\t\t\t\t\t<span>{t('backups.scanning-for-external-drives')}</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : !disks || disks.length === 0 ? (\n\t\t\t\t\t\t\t<div className='col-span-full flex items-center justify-start py-2'>\n\t\t\t\t\t\t\t\t<span className='text-sm text-white/40'>{t('backups.no-external-drives-detected')}</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{/* Normal external drives that don't need formatting */}\n\t\t\t\t\t\t\t\t{disks\n\t\t\t\t\t\t\t\t\t.filter((disk) => disk.isMounted && !disk.isFormatting)\n\t\t\t\t\t\t\t\t\t.flatMap((disk) =>\n\t\t\t\t\t\t\t\t\t\tdisk.partitions.flatMap((p) => {\n\t\t\t\t\t\t\t\t\t\t\tconst firstMount = p.mountpoints?.[0]\n\t\t\t\t\t\t\t\t\t\t\tif (!firstMount) return []\n\t\t\t\t\t\t\t\t\t\t\tconst label = p.label || disk.name || t('unknown')\n\t\t\t\t\t\t\t\t\t\t\tconst selected = currentDest?.type === 'external' && currentDest.mountpoint === firstMount\n\t\t\t\t\t\t\t\t\t\t\treturn [\n\t\t\t\t\t\t\t\t\t\t\t\t<ServerCard\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={`${disk.id}-${p.id}-${firstMount}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\tselected={!!selected}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => onChangeDestination({type: 'external', mountpoint: firstMount})}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className='mb-2 flex h-12 w-12 items-center justify-center'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<BackupDeviceIcon path={firstMount} connected className='size-11' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className='w-full truncate text-center text-[12px]'>{label}</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className='w-full truncate text-center text-[11px] text-white/40'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{formatFilesystemSize(p.size)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t</ServerCard>,\n\t\t\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{/* External drives that need formatting */}\n\t\t\t\t\t\t\t\t{disks\n\t\t\t\t\t\t\t\t\t.filter((disk) => !disk.isMounted || disk.isFormatting)\n\t\t\t\t\t\t\t\t\t.map((disk) => {\n\t\t\t\t\t\t\t\t\t\tconst label = disk.name || t('unknown')\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t<ServerCard\n\t\t\t\t\t\t\t\t\t\t\t\tkey={`${disk.id}-requires-format`}\n\t\t\t\t\t\t\t\t\t\t\t\tselected={false}\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\tif (disk.isFormatting) return\n\t\t\t\t\t\t\t\t\t\t\t\t\tnavigate(`/files/Home?dialog=files-format-drive&deviceId=${disk.id}`)\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='mb-2 flex h-12 w-12 items-center justify-center'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<BackupDeviceIcon path='' connected={false} className='size-11' />\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='w-full truncate text-center text-[12px]'>{label}</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='w-full truncate text-center text-[11px] text-white/40'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{disk.isFormatting ? t('files-format.formatting') : t('files-format.title-requires-format')}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</ServerCard>\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t)\n\t\t\t) : tab === 'umbrel-private-cloud' ? (\n\t\t\t\t<div className='flex flex-col items-center justify-center gap-7 rounded-20 border border-white/10 bg-black/30 px-3 pt-8 pb-10'>\n\t\t\t\t\t<div className='flex flex-col items-center justify-center gap-1 text-center'>\n\t\t\t\t\t\t<h2 className='mb-0 text-2xl text-white'>{t('backups-setup-umbrel-private-cloud')}</h2>\n\t\t\t\t\t\t<span className='mt-0 text-sm text-white/80'>{t('backups-setup-umbrel-private-cloud-subtitle')}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<img\n\t\t\t\t\t\tsrc={umbrelPrivateCloudIcon}\n\t\t\t\t\t\talt={t('backups-setup-umbrel-private-cloud')}\n\t\t\t\t\t\tclassName='w-24'\n\t\t\t\t\t\tdraggable={false}\n\t\t\t\t\t/>\n\t\t\t\t\t<div className='flex flex-col items-center justify-center gap-2'>\n\t\t\t\t\t\t<p className='max-w-md text-center text-sm text-white/80'>\n\t\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\t\ti18nKey='backups-setup-umbrel-private-cloud-cta'\n\t\t\t\t\t\t\t\tcomponents={{\n\t\t\t\t\t\t\t\t\tbold: <span className='font-bold text-white' />,\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<Button asChild className='mt-4 px-4' variant='primary'>\n\t\t\t\t\t\t\t<a href='https://link.umbrel.com/private-cloud' target='_blank' rel='noopener noreferrer'>\n\t\t\t\t\t\t\t\t<TbExternalLink className='size-4' />\n\t\t\t\t\t\t\t\t{t('backups-setup-umbrel-private-cloud-cta-link')}\n\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t) : null}\n\n\t\t\t<AddNetworkShareDialog\n\t\t\t\topen={isAddNasOpen}\n\t\t\t\tonOpenChange={(v) => setAddNasOpen(v)}\n\t\t\t\tsuppressNavigateOnAdd\n\t\t\t\tonAdded={(host) => {\n\t\t\t\t\t// Keep shares fresh so the NAS list stays up to date in the UI\n\t\t\t\t\trefetchShares()\n\t\t\t\t\t// If we know which host was added, select it as the destination and advance\n\t\t\t\t\tif (host) {\n\t\t\t\t\t\tonChangeDestination({type: 'nas', host, rootPath: `/Network/${host}`})\n\t\t\t\t\t\tonNext()\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n\n// ---------------------------------------------\n// Step 1 — Folder Picker (read-only input + mini browser)\n// ---------------------------------------------\n\nfunction FolderPickerStep({\n\trootPath,\n\tvalue,\n\tonChange,\n\tselectedName,\n\tdisabledPaths = [],\n}: {\n\trootPath: string\n\tvalue?: string\n\tonChange: (v: string) => void\n\tselectedName?: string\n\tdisabledPaths?: string[]\n}) {\n\tconst [isBrowserOpen, setBrowserOpen] = useState(false)\n\n\t// Show nothing until a subfolder is chosen\n\tconst displayValue = value || ''\n\tconst shownValue = React.useMemo(() => {\n\t\tif (!displayValue) return ''\n\t\treturn getRelativePathFromRoot(displayValue, rootPath)\n\t}, [displayValue, rootPath])\n\n\treturn (\n\t\t<div className='space-y-4'>\n\t\t\t<div>\n\t\t\t\t<div className='mb-4 text-sm font-medium'>\n\t\t\t\t\t{/* Use Trans component to allow HTML interpolation for brand styling while maintaining proper i18n sentence context */}\n\t\t\t\t\t<Trans\n\t\t\t\t\t\ti18nKey='backups.choose-folder-within-device'\n\t\t\t\t\t\tvalues={{device: selectedName || ''}}\n\t\t\t\t\t\tcomponents={{\n\t\t\t\t\t\t\tbold: <span className='font-bold text-brand-lightest' />,\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t{/* Input with inline \"Browse\" button */}\n\t\t\t\t<div className='relative'>\n\t\t\t\t\t<Input\n\t\t\t\t\t\ttype='text'\n\t\t\t\t\t\tvalue={shownValue}\n\t\t\t\t\t\treadOnly\n\t\t\t\t\t\tclassName='pr-28 text-white/90'\n\t\t\t\t\t\ttitle={shownValue}\n\t\t\t\t\t\tonClick={() => setBrowserOpen(true)}\n\t\t\t\t\t/>\n\t\t\t\t\t<Button\n\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\tsize='sm'\n\t\t\t\t\t\tclassName='absolute top-1/2 right-5 -translate-y-1/2'\n\t\t\t\t\t\tonClick={() => setBrowserOpen(true)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('backups.browse')}\n\t\t\t\t\t</Button>\n\t\t\t\t</div>\n\n\t\t\t\t<div className='mt-4'>\n\t\t\t\t\t<WarningAlert\n\t\t\t\t\t\ticon={HardDrive}\n\t\t\t\t\t\tdescription={t('backups.storage-capacity-warning', {device: selectedName || ''})}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* Mini folder browser */}\n\t\t\t<MiniBrowser\n\t\t\t\topen={isBrowserOpen}\n\t\t\t\tonOpenChange={setBrowserOpen}\n\t\t\t\trootPath={rootPath}\n\t\t\t\tdisabledPaths={disabledPaths}\n\t\t\t\tonOpenPath={value || rootPath}\n\t\t\t\tpreselectOnOpen={true}\n\t\t\t\tselectionMode='folders'\n\t\t\t\ttitle={t('backups.select-backup-folder')}\n\t\t\t\tselectButtonLabel={t('mini-browser.select-folder')}\n\t\t\t\tonSelect={(p) => {\n\t\t\t\t\tonChange(p)\n\t\t\t\t\tsetBrowserOpen(false)\n\t\t\t\t}}\n\t\t\t\tallowNewFolderCreation={true}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n\n// ---------------------------------------------\n// Step 3 — Encryption (index 3)\n// ---------------------------------------------\n\nfunction EncryptionStep() {\n\tconst form = useFormContext<FormValues>()\n\n\treturn (\n\t\t<div className='space-y-4'>\n\t\t\t<Form {...form}>\n\t\t\t\t<div className='grid grid-cols-1 gap-3'>\n\t\t\t\t\t<FormField\n\t\t\t\t\t\tcontrol={form.control}\n\t\t\t\t\t\tname='encryption.password'\n\t\t\t\t\t\trender={({field}) => (\n\t\t\t\t\t\t\t<FormItem>\n\t\t\t\t\t\t\t\t<FormLabel className='text-13 opacity-60'>{t('password')}</FormLabel>\n\t\t\t\t\t\t\t\t<FormControl>\n\t\t\t\t\t\t\t\t\t<PasswordInput value={field.value} onValueChange={field.onChange} />\n\t\t\t\t\t\t\t\t</FormControl>\n\t\t\t\t\t\t\t\t<div className='relative'>\n\t\t\t\t\t\t\t\t\t<FormMessage className='absolute -top-1 left-0 text-xs' />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</FormItem>\n\t\t\t\t\t\t)}\n\t\t\t\t\t/>\n\n\t\t\t\t\t<FormField\n\t\t\t\t\t\tcontrol={form.control}\n\t\t\t\t\t\tname='encryption.confirm'\n\t\t\t\t\t\trender={({field}) => (\n\t\t\t\t\t\t\t<FormItem>\n\t\t\t\t\t\t\t\t<FormLabel className='text-13 opacity-60'>{t('backups.confirm-password')}</FormLabel>\n\t\t\t\t\t\t\t\t<FormControl>\n\t\t\t\t\t\t\t\t\t<PasswordInput value={field.value} onValueChange={field.onChange} />\n\t\t\t\t\t\t\t\t</FormControl>\n\t\t\t\t\t\t\t\t<div className='relative'>\n\t\t\t\t\t\t\t\t\t<FormMessage className='absolute -top-1 left-0 text-xs' />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</FormItem>\n\t\t\t\t\t\t)}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</Form>\n\n\t\t\t<ErrorAlert icon={LockKeyhole} description={t('backups.password-safety-warning')} />\n\t\t</div>\n\t)\n}\n\n// ---------------------------------------------\n// Step 4 — Review (index 4)\n// ---------------------------------------------\n\nfunction ReviewStep({values}: {values: FormValues}) {\n\tlet pathOnly = values.folder\n\tif (values.destination.type === 'nas') {\n\t\tconst hostRoot = `/Network/${values.destination.host}`\n\t\tif (pathOnly.startsWith(hostRoot)) {\n\t\t\tpathOnly = pathOnly.slice(hostRoot.length) || '/'\n\t\t\tif (!pathOnly.startsWith('/')) pathOnly = `/${pathOnly}`\n\t\t}\n\t} else {\n\t\tconst mountRoot = values.destination.mountpoint\n\t\tif (mountRoot && pathOnly.startsWith(mountRoot)) {\n\t\t\tpathOnly = pathOnly.slice(mountRoot.length) || '/'\n\t\t\tif (!pathOnly.startsWith('/')) pathOnly = `/${pathOnly}`\n\t\t}\n\t}\n\n\tlet locationCombined: string\n\tconst {deviceType} = useNetworkDeviceType(values.destination.type === 'nas' ? values.destination.rootPath : '')\n\tif (values.destination.type === 'nas') {\n\t\tlocationCombined = `${deviceType === 'umbrel' ? t('umbrel') : t('nas')} · ${values.destination.host} · ${pathOnly}`\n\t} else {\n\t\tlocationCombined = `${t('external-drive')} · ${getLastPathSegment(values.destination.mountpoint)} · ${pathOnly}`\n\t}\n\n\tconst [showPw, setShowPw] = useState(false)\n\tconst plainPw = values.encryption.password\n\tconst masked = plainPw ? '•'.repeat(Math.max(8, plainPw.length)) : ''\n\tconst [, copyToClipboard] = useCopyToClipboard()\n\n\tconst {filteredIgnoredPaths} = useBackupIgnoredPaths()\n\tconst {excludedAppsCount} = useAppsBackupIgnoredSummary()\n\n\treturn (\n\t\t<div className='space-y-3'>\n\t\t\t<ReviewCard icon={<FaRegSave className='h-5 w-5 opacity-80' />} label={t('backups.location')}>\n\t\t\t\t<div className='text-sm break-words' title={locationCombined}>\n\t\t\t\t\t{locationCombined}\n\t\t\t\t</div>\n\t\t\t</ReviewCard>\n\n\t\t\t<ReviewCard icon={<TbShoppingBag className='h-5 w-5 opacity-80' />} label={t('backups.apps-and-data')}>\n\t\t\t\t<div className='text-sm'>\n\t\t\t\t\t{filteredIgnoredPaths.length > 0 || excludedAppsCount > 0\n\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\tfilteredIgnoredPaths.length > 0\n\t\t\t\t\t\t\t\t\t? t('{{count}} {{fileFolderText}} excluded', {\n\t\t\t\t\t\t\t\t\t\t\tcount: filteredIgnoredPaths.length,\n\t\t\t\t\t\t\t\t\t\t\tfileFolderText: filteredIgnoredPaths.length === 1 ? t('file/folder') : t('files/folders'),\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t: null,\n\t\t\t\t\t\t\t\texcludedAppsCount > 0\n\t\t\t\t\t\t\t\t\t? t('{{count}} {{appText}} excluded', {\n\t\t\t\t\t\t\t\t\t\t\tcount: excludedAppsCount,\n\t\t\t\t\t\t\t\t\t\t\tappText: excludedAppsCount === 1 ? t('app') : t('apps'),\n\t\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t\t: null,\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t.filter(Boolean)\n\t\t\t\t\t\t\t\t.join(' · ')\n\t\t\t\t\t\t: t('backups.all-apps-and-data-will-be-backed-up')}\n\t\t\t\t</div>\n\t\t\t</ReviewCard>\n\n\t\t\t<ReviewCard icon={<TbPassword className='h-5 w-5 opacity-80' />} label={t('backups.encryption')}>\n\t\t\t\t<div className='flex items-center gap-2 text-sm'>\n\t\t\t\t\t{plainPw ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<span className='opacity-90'>{t('backups.password-is-set')}</span>\n\t\t\t\t\t\t\t{/* Constrained, selectable password display */}\n\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\treadOnly\n\t\t\t\t\t\t\t\tvalue={showPw ? plainPw : masked}\n\t\t\t\t\t\t\t\tsize={Math.min((showPw ? plainPw : masked).length, 32)}\n\t\t\t\t\t\t\t\ttype={showPw ? 'text' : 'text'}\n\t\t\t\t\t\t\t\tclassName='flex h-6 w-auto max-w-[120px] items-center overflow-hidden rounded-[3px] border border-[#ffffff0a] bg-white/10 px-1 font-mono text-12 leading-none text-ellipsis whitespace-nowrap outline-hidden'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName='group inline-flex h-6 w-6 items-center justify-center'\n\t\t\t\t\t\t\t\tonClick={() => setShowPw((s) => !s)}\n\t\t\t\t\t\t\t\ttitle={showPw ? t('backups.hide') : t('backups.show')}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{showPw ? (\n\t\t\t\t\t\t\t\t\t<EyeOff className='h-4 w-4 opacity-80 transition-colors group-hover:opacity-100' />\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t<Eye className='h-4 w-4 opacity-80 transition-colors group-hover:opacity-100' />\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName='group inline-flex h-6 w-6 items-center justify-center'\n\t\t\t\t\t\t\t\tonClick={() => copyToClipboard(plainPw)}\n\t\t\t\t\t\t\t\ttitle={t('backups.copy')}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<Copy className='h-4 w-4 opacity-80 transition-colors group-hover:opacity-100' />\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<span className='opacity-60'>{t('backups.no-password-set')}</span>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</ReviewCard>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/tab-switcher.tsx",
    "content": "import {motion} from 'motion/react'\nimport * as React from 'react'\n\nexport function TabSwitcher({\n\toptions,\n\tvalue,\n\tonChange,\n}: {\n\toptions: Array<{id: string; label: string}>\n\tvalue: string\n\tonChange: (v: string) => void\n}) {\n\treturn (\n\t\t<div className='relative w-full'>\n\t\t\t<div className='flex w-full rounded-full border-[0.5px] border-white/5 bg-white/5 p-1'>\n\t\t\t\t{options.map((opt) => {\n\t\t\t\t\tconst selected = value === opt.id\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={opt.id}\n\t\t\t\t\t\t\tclassName={[\n\t\t\t\t\t\t\t\t'relative flex-1 rounded-full px-3 py-1 text-12 focus:ring-0 focus:outline-hidden',\n\t\t\t\t\t\t\t\tselected ? 'text-white' : 'text-white/60',\n\t\t\t\t\t\t\t].join(' ')}\n\t\t\t\t\t\t\tonClick={() => onChange(opt.id)}\n\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{selected && (\n\t\t\t\t\t\t\t\t<motion.span\n\t\t\t\t\t\t\t\t\tlayoutId='wizard-tabs-pill'\n\t\t\t\t\t\t\t\t\tclassName='absolute inset-0 -z-10 rounded-full bg-white/10'\n\t\t\t\t\t\t\t\t\ttransition={{type: 'tween', ease: 'easeInOut', duration: 0.2}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{opt.label}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)\n\t\t\t\t})}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/components/tiles.tsx",
    "content": "import {Loader2} from 'lucide-react'\nimport * as React from 'react'\n\nimport {RadioGroupItem} from '@/components/ui/radio-group'\n\nexport function SelectableTile({\n\tchildren,\n\tselected,\n\tonClick,\n}: {\n\tchildren: React.ReactNode\n\tselected?: boolean\n\tonClick?: () => void\n}) {\n\treturn (\n\t\t<div\n\t\t\tclassName={[\n\t\t\t\t'flex h-[120px] flex-col items-center justify-center rounded-xl p-4 text-center',\n\t\t\t\tselected ? 'border border-brand bg-brand/15' : 'border border-white/10 bg-white/5 hover:bg-white/10',\n\t\t\t].join(' ')}\n\t\t\tonClick={onClick}\n\t\t>\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n\nexport function ClickableTile({children, onClick}: {children: React.ReactNode; onClick?: () => void}) {\n\treturn (\n\t\t<div\n\t\t\tclassName='flex h-[120px] flex-col items-center justify-center rounded-xl border border-white/10 bg-white/5 p-4 text-center hover:bg-white/10'\n\t\t\tonClick={onClick}\n\t\t>\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n\nexport function LoadingTile() {\n\treturn (\n\t\t<div className='flex h-[120px] items-center justify-center rounded-xl border border-white/10 bg-white/5'>\n\t\t\t<Loader2 className='h-6 w-6 animate-spin opacity-60' />\n\t\t</div>\n\t)\n}\n\nexport function EmptyTile({text}: {text: string}) {\n\treturn (\n\t\t<div className='flex h-[120px] items-center justify-center rounded-xl border border-white/10 bg-white/5 text-sm opacity-60'>\n\t\t\t{text}\n\t\t</div>\n\t)\n}\n\nexport function RadioTile({\n\tvalue,\n\tselected,\n\ttitle,\n\tchildren,\n\tonSelect,\n}: {\n\tvalue: string\n\tselected: boolean\n\ttitle: string\n\tchildren?: React.ReactNode\n\tonSelect?: () => void\n}) {\n\treturn (\n\t\t<div\n\t\t\trole='button'\n\t\t\ttabIndex={0}\n\t\t\tonClick={onSelect}\n\t\t\tonKeyDown={(e) => {\n\t\t\t\tif (e.key === 'Enter' || e.key === ' ') {\n\t\t\t\t\te.preventDefault()\n\t\t\t\t\tonSelect?.()\n\t\t\t\t}\n\t\t\t}}\n\t\t\tclassName={[\n\t\t\t\t'flex items-center gap-3 rounded-xl border p-4',\n\t\t\t\tselected ? 'border-brand bg-brand/15' : 'border-white/10 bg-white/5 hover:bg-white/10',\n\t\t\t].join(' ')}\n\t\t>\n\t\t\t<RadioGroupItem id={`radio-${value}`} value={value} />\n\t\t\t<div className='flex-1'>\n\t\t\t\t<div className='text-sm font-medium'>{title}</div>\n\t\t\t\t<div className='text-[12px] opacity-60'>{children}</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/hooks/use-apps-auto-excluded-paths.ts",
    "content": "import {useMemo} from 'react'\n\nimport {useApps} from '@/providers/apps'\nimport {trpcReact} from '@/trpc/trpc'\n\n// Hook to aggregate auto-excluded paths per app (from app manifests backupIgnore).\nexport function useAppsAutoExcludedPaths() {\n\tconst {userApps = []} = useApps()\n\n\tconst pathsQs = trpcReact.useQueries((t) =>\n\t\t(userApps || []).map((app) => t.apps.getBackupIgnoredPaths({appId: app.id})),\n\t)\n\n\tconst isLoading = pathsQs.some((q) => q.isLoading)\n\n\tconst pathsByAppId = useMemo(() => {\n\t\tconst map = new Map<string, string[]>()\n\t\tuserApps.forEach((app, idx) => {\n\t\t\tmap.set(app.id, (pathsQs[idx]?.data as string[] | undefined) || [])\n\t\t})\n\t\treturn map\n\t}, [userApps, pathsQs])\n\n\tconst autoExcludedAppsCount = useMemo(\n\t\t() => pathsQs.reduce((sum, q) => sum + (((q.data?.length as number | undefined) ?? 0) > 0 ? 1 : 0), 0),\n\t\t[pathsQs],\n\t)\n\n\treturn {pathsByAppId, autoExcludedAppsCount, isLoading}\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/hooks/use-apps-backup-ignore.ts",
    "content": "import {useMemo} from 'react'\n\nimport {useApps} from '@/providers/apps'\nimport {trpcReact} from '@/trpc/trpc'\n\n// Hook for aggregating ignored status for all user apps and providing helpers to toggle ignore.\n// The backupIgnore exclusions that apps define themselves are handled elsewhere\nexport function useAppsBackupIgnoredSummary() {\n\tconst {userApps = []} = useApps()\n\tconst utils = trpcReact.useUtils()\n\n\tconst ignoredQs = trpcReact.useQueries((t) => (userApps || []).map((app) => t.apps.isBackupIgnored({appId: app.id})))\n\n\tconst isLoading = ignoredQs.some((q) => q.isLoading)\n\n\tconst isIgnoredByAppId = useMemo(() => {\n\t\tconst map = new Map<string, boolean>()\n\t\tuserApps.forEach((app, idx) => {\n\t\t\tmap.set(app.id, !!ignoredQs[idx]?.data)\n\t\t})\n\t\treturn map\n\t}, [userApps, ignoredQs])\n\n\tconst excludedAppsCount = useMemo(() => ignoredQs.reduce((sum, q) => sum + (q.data ? 1 : 0), 0), [ignoredQs])\n\n\tconst toggleMut = trpcReact.apps.backupIgnore.useMutation({\n\t\tonSuccess: async (_data, variables) => {\n\t\t\tif (variables?.appId) {\n\t\t\t\tawait utils.apps.isBackupIgnored.invalidate({appId: variables.appId})\n\t\t\t}\n\t\t},\n\t})\n\n\tconst ignore = (appId: string) => toggleMut.mutateAsync({appId, value: true})\n\tconst unignore = (appId: string) => toggleMut.mutateAsync({appId, value: false})\n\n\treturn {isIgnoredByAppId, excludedAppsCount, isLoading, ignore, unignore}\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/hooks/use-backup-ignored-paths.ts",
    "content": "import {useMemo} from 'react'\n\nimport {trpcReact} from '@/trpc/trpc'\n\n// Hook for managing backup ignored paths specifically for files and folders under /Home\n// Provides the ignored paths list and helpers to add/remove paths while keeping queries fresh.\n// Ignored paths for apps are handled elsewhere\nexport function useBackupIgnoredPaths(options?: {excludeSystemPaths?: boolean}) {\n\tconst excludeSystemPaths = options?.excludeSystemPaths ?? true\n\tconst utils = trpcReact.useUtils()\n\n\tconst ignoredPathsQ = trpcReact.backups.getIgnoredPaths.useQuery()\n\n\tconst ignoredPaths = useMemo(() => ignoredPathsQ.data || [], [ignoredPathsQ.data])\n\tconst filteredIgnoredPaths = useMemo(() => {\n\t\tif (!excludeSystemPaths) return ignoredPaths\n\t\treturn ignoredPaths.filter((p) => p !== '/External' && p !== '/Network')\n\t}, [ignoredPaths, excludeSystemPaths])\n\n\tconst addIgnoredPathMut = trpcReact.backups.addIgnoredPath.useMutation({\n\t\tonSuccess: async () => {\n\t\t\tawait utils.backups.getIgnoredPaths.invalidate()\n\t\t},\n\t})\n\n\tconst removeIgnoredPathMut = trpcReact.backups.removeIgnoredPath.useMutation({\n\t\tonSuccess: async () => {\n\t\t\tawait utils.backups.getIgnoredPaths.invalidate()\n\t\t},\n\t})\n\n\tconst addIgnoredPath = (path: string) => addIgnoredPathMut.mutateAsync({path})\n\tconst removeIgnoredPath = (path: string) => removeIgnoredPathMut.mutateAsync({path})\n\n\treturn {\n\t\tignoredPaths,\n\t\tfilteredIgnoredPaths,\n\t\tisLoadingIgnoredPaths: ignoredPathsQ.isLoading,\n\t\trefetchIgnoredPaths: ignoredPathsQ.refetch,\n\t\taddIgnoredPath,\n\t\tremoveIgnoredPath,\n\t}\n}\n\nexport default useBackupIgnoredPaths\n"
  },
  {
    "path": "packages/ui/src/features/backups/hooks/use-backups.ts",
    "content": "import {keepPreviousData} from '@tanstack/react-query'\nimport {useEffect, useRef, useState} from 'react'\n\nimport {toast} from '@/components/ui/toast'\nimport {trpcReact, type RouterOutput} from '@/trpc/trpc'\n\nimport {getUserFriendlyErrorMessage} from '../utils/error-messages'\n\nexport type BackupDestination =\n\t| {type: 'nas'; host: string; rootPath: string} // e.g. /Network/<host>\n\t| {type: 'external'; mountpoint: string} // partition mountpoint\n\nexport type SetupBackupInput = {\n\tdestination: BackupDestination\n\tfolder: string\n\tencryptionPassword: string\n}\n\nexport type BackupRepository = RouterOutput['backups']['getRepositories'][number]\n\nexport type Backup = RouterOutput['backups']['listBackups'][number]\n\nexport function useBackups(options?: {repositoriesEnabled?: boolean}) {\n\tconst utils = trpcReact.useUtils()\n\n\tconst {\n\t\tdata: repositories,\n\t\tisLoading: isLoadingRepositories,\n\t\trefetch: refetchRepositories,\n\t} = trpcReact.backups.getRepositories.useQuery(undefined, {\n\t\tplaceholderData: keepPreviousData,\n\t\tstaleTime: 15_000,\n\t\tenabled: options?.repositoriesEnabled ?? true,\n\t})\n\n\t// Individual mutations\n\tconst createRepoMutation = trpcReact.backups.createRepository.useMutation()\n\tconst backupMutation = trpcReact.backups.backup.useMutation()\n\tconst forgetRepoMutation = trpcReact.backups.forgetRepository.useMutation()\n\tconst connectToRepoMutation = trpcReact.backups.connectToExistingRepository.useMutation()\n\n\t// Pending state so the wizards have access to it to show loading indicators throughout the flow\n\tconst [isSettingUpBackup, setIsSettingUpBackup] = useState(false)\n\tconst [isForgettingRepository, setIsForgettingRepository] = useState(false)\n\n\t// Create a repository at the selected folder and immediately start a backup.\n\tconst setupBackup = async (input: SetupBackupInput) => {\n\t\tconst path = input.folder\n\t\tconst password = input.encryptionPassword?.trim() ?? ''\n\n\t\tsetIsSettingUpBackup(true)\n\t\ttry {\n\t\t\t// Create repository\n\t\t\tconst repositoryId = await createRepoMutation.mutateAsync({path, password})\n\n\t\t\t// Start first backup (don't wait for completion so we can close the wizard and progress will be shown elsewhere)\n\t\t\tbackupNow(repositoryId).catch(() => {})\n\n\t\t\t// Keep queries fresh\n\t\t\tawait utils.backups.getRepositories.invalidate()\n\n\t\t\treturn {repositoryId, path}\n\t\t} catch (error: any) {\n\t\t\tconst userFriendlyMessage = getUserFriendlyErrorMessage(error)\n\t\t\ttoast.error(userFriendlyMessage)\n\t\t\tthrow error\n\t\t} finally {\n\t\t\tsetIsSettingUpBackup(false)\n\t\t}\n\t}\n\n\t// Connect to an existing repository at the selected folder and immediately start a backup.\n\tconst connectExistingRepository = async (input: {path: string; password: string}) => {\n\t\tconst path = input.path\n\t\tconst password = input.password?.trim() ?? ''\n\n\t\ttry {\n\t\t\tconst repositoryId = await connectToRepoMutation.mutateAsync({path, password})\n\n\t\t\t// Start first backup (don't wait)\n\t\t\tbackupNow(repositoryId).catch(() => {})\n\n\t\t\t// invalidate repositories to keep data fresh across views\n\t\t\tawait utils.backups.getRepositories.invalidate()\n\t\t\treturn {repositoryId, path}\n\t\t} catch (error: any) {\n\t\t\tconst userFriendlyMessage = getUserFriendlyErrorMessage(error)\n\t\t\ttoast.error(userFriendlyMessage)\n\t\t\tthrow error\n\t\t}\n\t}\n\n\t// Triggers a backup for a repository.\n\t// UI components should use `useTriggerBackupForRepo` so that loading state is isolated.\n\tconst backupNow = async (repositoryId: string) => {\n\t\ttry {\n\t\t\tawait backupMutation.mutateAsync({repositoryId})\n\t\t\t// No success toast since we show progress indicators throughout the UI (floating island, wizards, etc.)\n\t\t} catch (error: any) {\n\t\t\tconst userFriendlyMessage = getUserFriendlyErrorMessage(error)\n\t\t\ttoast.error(userFriendlyMessage)\n\t\t\tthrow error\n\t\t}\n\t}\n\n\t// Forget a repository and refresh the repositories list.\n\tconst forgetRepository = async (repositoryId: string) => {\n\t\tsetIsForgettingRepository(true)\n\t\ttry {\n\t\t\tawait forgetRepoMutation.mutateAsync({repositoryId})\n\t\t\tawait utils.backups.getRepositories.invalidate()\n\t\t\t// No success toast since we show progress indicators throughout the UI (floating island, wizards, etc.)\n\t\t} catch (error: any) {\n\t\t\tconst userFriendlyMessage = getUserFriendlyErrorMessage(error)\n\t\t\ttoast.error(userFriendlyMessage)\n\t\t\tthrow error\n\t\t} finally {\n\t\t\tsetIsForgettingRepository(false)\n\t\t}\n\t}\n\n\treturn {\n\t\t// setup flow\n\t\tsetupBackup,\n\t\tisSettingUpBackup,\n\t\tconnectExistingRepository,\n\t\tisConnectingExisting: connectToRepoMutation.isPending,\n\n\t\t// repos\n\t\trepositories,\n\t\tisLoadingRepositories,\n\t\trefetchRepositories,\n\n\t\t// repository management\n\t\tforgetRepository,\n\t\tisForgettingRepository,\n\t}\n}\n\n// Convenience wrappers for queries\n\nexport function useBackupProgress(refetchIntervalMs = 1000) {\n\tconst utils = trpcReact.useUtils()\n\tconst previousProgressRef = useRef<Array<{repositoryId: string; percent: number}>>([])\n\n\tconst query = trpcReact.backups.backupProgress.useQuery(undefined, {\n\t\trefetchInterval: refetchIntervalMs,\n\t})\n\n\t// Detect when backups complete and invalidate relevant queries\n\tuseEffect(() => {\n\t\tconst currentProgress = query.data ?? []\n\t\tconst previousProgress = previousProgressRef.current\n\n\t\t// Check if any backup completed (disappeared from progress array)\n\t\tconst hasCompletedBackups = previousProgress.length > 0 && currentProgress.length < previousProgress.length\n\n\t\tif (hasCompletedBackups) {\n\t\t\t// Invalidate queries that should be refreshed after backup completion\n\t\t\tutils.backups.getRepositories.invalidate()\n\t\t\tutils.backups.listBackups.invalidate()\n\t\t\tutils.backups.getRepositorySize.invalidate()\n\t\t}\n\n\t\t// Update the ref for next comparison\n\t\tpreviousProgressRef.current = currentProgress\n\t}, [query.data, utils])\n\n\t// Cap progress percentages at 100% as sometimes the backend\n\t// reports progress percentages greater than 100%.\n\t// TODO: remove this once the backend is fixed.\n\tconst cappedData = query.data?.map((progress) => ({\n\t\t...progress,\n\t\tpercent: Math.min(progress.percent, 100),\n\t}))\n\n\treturn {\n\t\t...query,\n\t\tdata: cappedData,\n\t}\n}\n\nexport function useRestoreStatus(refetchIntervalMs = 500) {\n\treturn trpcReact.backups.restoreStatus.useQuery(undefined, {\n\t\trefetchInterval: refetchIntervalMs,\n\t})\n}\n\nexport function useRepositorySize(repositoryId: string | undefined, options?: {enabled?: boolean; staleTime?: number}) {\n\treturn trpcReact.backups.getRepositorySize.useQuery(\n\t\t{repositoryId: repositoryId || ''},\n\t\t{\n\t\t\tenabled: Boolean(repositoryId) && (options?.enabled ?? true),\n\t\t\tstaleTime: options?.staleTime ?? 15_000,\n\t\t},\n\t)\n}\n\nexport function useRepositoryBackups(\n\trepositoryId: string | undefined,\n\toptions?: {enabled?: boolean; staleTime?: number},\n) {\n\treturn trpcReact.backups.listBackups.useQuery(\n\t\t{repositoryId: repositoryId || ''},\n\t\t{\n\t\t\tenabled: Boolean(repositoryId) && (options?.enabled ?? true),\n\t\t\tplaceholderData: keepPreviousData,\n\t\t\tstaleTime: options?.staleTime ?? 15_000,\n\t\t},\n\t)\n}\n\nexport function useRestoreBackup() {\n\tconst mutation = trpcReact.backups.restoreBackup.useMutation()\n\n\tconst restoreBackup = async (backupId: string) => {\n\t\ttry {\n\t\t\treturn await mutation.mutateAsync({backupId})\n\t\t} catch (error: any) {\n\t\t\tconst userFriendlyMessage = getUserFriendlyErrorMessage(error)\n\t\t\ttoast.error(userFriendlyMessage)\n\t\t\tthrow error\n\t\t}\n\t}\n\n\treturn {\n\t\t...mutation,\n\t\trestoreBackup,\n\t}\n}\n\nexport function useConnectToRepository() {\n\tconst mutation = trpcReact.backups.connectToExistingRepository.useMutation()\n\tconst [isConnecting, setIsConnecting] = useState(false)\n\n\tconst connectToRepository = async (input: {path: string; password: string}) => {\n\t\tsetIsConnecting(true)\n\t\ttry {\n\t\t\treturn await mutation.mutateAsync(input)\n\t\t} catch (error: any) {\n\t\t\tconst userFriendlyMessage = getUserFriendlyErrorMessage(error)\n\t\t\ttoast.error(userFriendlyMessage)\n\t\t\tthrow error\n\t\t} finally {\n\t\t\tsetIsConnecting(false)\n\t\t}\n\t}\n\n\treturn {\n\t\t...mutation,\n\t\tconnectToRepository,\n\t\tisPending: isConnecting,\n\t}\n}\n\nexport function useMountBackup() {\n\tconst mutation = trpcReact.backups.mountBackup.useMutation()\n\n\tconst mountBackup = async (backupId: string) => {\n\t\ttry {\n\t\t\treturn await mutation.mutateAsync({backupId})\n\t\t} catch (error: any) {\n\t\t\tconst userFriendlyMessage = getUserFriendlyErrorMessage(error)\n\t\t\ttoast.error(userFriendlyMessage)\n\t\t\tthrow error\n\t\t}\n\t}\n\n\treturn {\n\t\t...mutation,\n\t\tmountBackup,\n\t}\n}\n\nexport function useUnmountBackup() {\n\tconst mutation = trpcReact.backups.unmountBackup.useMutation()\n\n\tconst unmountBackup = async (directoryName: string) => {\n\t\ttry {\n\t\t\tawait mutation.mutateAsync({directoryName})\n\t\t} catch (error: any) {\n\t\t\t// Silent failure for unmount operations\n\t\t\t// Don't show error toast - unmount is cleanup, not user-facing action\n\t\t\tconsole.warn('Unmount failed:', error.message)\n\t\t}\n\t}\n\n\treturn {\n\t\t...mutation,\n\t\tunmountBackup,\n\t}\n}\n\n// Triggers a backup for a single repository.\n// Use in UI components to isolate loading state per component (e.g., a button)\nexport function useTriggerBackupForRepo(repositoryId: string) {\n\tconst utils = trpcReact.useUtils()\n\tconst mutation = trpcReact.backups.backup.useMutation({\n\t\tonSuccess: () => {\n\t\t\t// Refresh repositories after triggering a backup\n\t\t\tutils.backups.getRepositories.invalidate()\n\t\t},\n\t\tonError: (error: any) => {\n\t\t\tconst userFriendlyMessage = getUserFriendlyErrorMessage(error)\n\t\t\ttoast.error(userFriendlyMessage)\n\t\t},\n\t})\n\n\tconst triggerBackup = () => mutation.mutate({repositoryId})\n\n\treturn {\n\t\ttriggerBackup,\n\t\tisPending: mutation.isPending,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/hooks/use-existing-backup-detection.ts",
    "content": "import {useEffect, useState} from 'react'\n\nimport type {BackupRepository} from '@/features/backups/hooks/use-backups'\nimport {BACKUP_FILE_NAME} from '@/features/backups/utils/filepath-helpers'\nimport {trpcReact} from '@/trpc/trpc'\n\nexport type ExistingRepoStatus = 'none' | 'exists-not-configured' | 'already-configured'\n\nexport function useExistingBackupDetection(folder: string | undefined, repositories: BackupRepository[] | undefined) {\n\tconst utils = trpcReact.useUtils()\n\tconst [status, setStatus] = useState<ExistingRepoStatus>('none')\n\tconst [repositoryPath, setRepositoryPath] = useState<string | undefined>(undefined)\n\n\t// Detect whether the selected folder contains an Umbrel backup repository and\n\t// whether that repository is already configured on this Umbrel.\n\tuseEffect(() => {\n\t\tlet cancelled = false\n\t\tasync function detect() {\n\t\t\t// If no folder is selected, reset state and exit early\n\t\t\tif (!folder) {\n\t\t\t\tif (!cancelled) {\n\t\t\t\t\tsetStatus('none')\n\t\t\t\t\tsetRepositoryPath(undefined)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttry {\n\t\t\t\t// Build the expected repository path once\n\t\t\t\t// TODO: In the future we could consider replacing this list-based existence check with a dedicated files.stat/files.exists endpoint\n\t\t\t\tconst normalizedFolder = folder.replace(/\\/+$/, '')\n\t\t\t\tconst repoPath = `${normalizedFolder}/${BACKUP_FILE_NAME}`\n\t\t\t\tif (!cancelled) setRepositoryPath(repoPath)\n\n\t\t\t\t// 1) First, check if this repository is already configured locally\n\t\t\t\t// We check for both the repo path (eg. /External/USB-DISK/Data/Umbrel Backup.backup) and the folder path (eg. /External/USB-DISK/Data)\n\t\t\t\tconst isAlreadyConfigured = (repositories || []).some((r) => r.path === repoPath || r.path === folder)\n\t\t\t\tif (isAlreadyConfigured) {\n\t\t\t\t\tif (!cancelled) setStatus('already-configured')\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// 2) Otherwise, check if the folder itself is the backup file, if so, attempt to list the constructed repo path.\n\t\t\t\t// If it succeeds, the repo folder exists (even if empty).\n\t\t\t\tif (folder.endsWith(BACKUP_FILE_NAME)) {\n\t\t\t\t\tawait utils.files.list.fetch({path: folder, limit: 1, sortBy: 'name', sortOrder: 'ascending'})\n\t\t\t\t\tif (!cancelled) setStatus('exists-not-configured')\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// 3) Otherwise, attempt to list the constructed repo path.\n\t\t\t\tawait utils.files.list.fetch({path: repoPath, limit: 1, sortBy: 'name', sortOrder: 'ascending'})\n\t\t\t\tif (!cancelled) setStatus('exists-not-configured')\n\t\t\t\treturn\n\t\t\t} catch {\n\t\t\t\t// On any error (e.g., permission or transient listing error), fall back\n\t\t\t\t// to a neutral state rather than blocking the flow\n\t\t\t\tif (!cancelled) {\n\t\t\t\t\tsetStatus('none')\n\t\t\t\t\tsetRepositoryPath(undefined)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tvoid detect()\n\t\treturn () => {\n\t\t\t// Prevent state updates if the component unmounts while the async work is in flight\n\t\t\tcancelled = true\n\t\t}\n\t}, [folder, repositories, utils.files.list])\n\n\treturn {status, repositoryPath}\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/index.tsx",
    "content": "import {Route, Routes, useNavigate} from 'react-router-dom'\n\nimport {FadeInImg} from '@/components/ui/fade-in-img'\nimport {ImmersiveDialog, ImmersiveDialogSplitContent} from '@/components/ui/immersive-dialog'\nimport backupsIcon from '@/features/backups/assets/backups-icon.png'\nimport {BackupsConfigureWizard} from '@/features/backups/components/configure-wizard'\nimport {BackupsRestoreWizard} from '@/features/backups/components/restore-wizard'\nimport {BackupsSetupWizard} from '@/features/backups/components/setup-wizard'\nimport {useBackups} from '@/features/backups/hooks/use-backups'\nimport {EnsureLoggedIn} from '@/modules/auth/ensure-logged-in'\nimport {t} from '@/utils/i18n'\n\nfunction SplitDialog({\n\tchildren,\n\tonClosePath = '/settings',\n\t// this is the translation key for the title on the left side of the dialog\n\tsideTitleKey = 'backups',\n}: {\n\tchildren: React.ReactNode\n\tonClosePath?: string\n\tsideTitleKey?: string\n}) {\n\tconst navigate = useNavigate()\n\treturn (\n\t\t<ImmersiveDialog\n\t\t\topen={true}\n\t\t\tonOpenChange={(isOpen) => {\n\t\t\t\tif (!isOpen) {\n\t\t\t\t\tnavigate(onClosePath, {preventScrollReset: true})\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t{/* We prevent the dialog from closing when clicking outside of it */}\n\t\t\t{/* These are all long, multi-step wizards, so we don't want users to close them accidentally */}\n\t\t\t<ImmersiveDialogSplitContent\n\t\t\t\tside={<SplitLeftContent titleKey={sideTitleKey} />}\n\t\t\t\tonInteractOutside={(e) => e.preventDefault()}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</ImmersiveDialogSplitContent>\n\t\t</ImmersiveDialog>\n\t)\n}\n\nfunction SplitLeftContent({titleKey = 'backup'}: {titleKey?: string}) {\n\treturn (\n\t\t<div className='flex flex-col items-center'>\n\t\t\t<FadeInImg src={backupsIcon} width={67} height={67} alt='' />\n\t\t\t<div className='mt-2.5 px-2 text-center text-15 font-medium'>{t(titleKey)}</div>\n\t\t\t<div className='text-13 opacity-40'>{t('umbrel')}</div>\n\t\t</div>\n\t)\n}\n\nexport default function BackupsRestoreDialog() {\n\tconst {repositories} = useBackups()\n\tconst hasRepositories = (repositories?.length ?? 0) > 0\n\treturn (\n\t\t<>\n\t\t\t<Routes>\n\t\t\t\t{/* Setup Wizard */}\n\t\t\t\t<Route\n\t\t\t\t\tpath='setup'\n\t\t\t\t\telement={\n\t\t\t\t\t\t<EnsureLoggedIn>\n\t\t\t\t\t\t\t{/* Conditionally navigate on close: to settings if no repos, otherwise to configure */}\n\t\t\t\t\t\t\t<SplitDialog onClosePath={hasRepositories ? '/settings/backups/configure' : '/settings'}>\n\t\t\t\t\t\t\t\t<BackupsSetupWizard />\n\t\t\t\t\t\t\t</SplitDialog>\n\t\t\t\t\t\t</EnsureLoggedIn>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t{/* Configure Wizard */}\n\t\t\t\t<Route\n\t\t\t\t\tpath='configure'\n\t\t\t\t\telement={\n\t\t\t\t\t\t<EnsureLoggedIn>\n\t\t\t\t\t\t\t<SplitDialog>\n\t\t\t\t\t\t\t\t<BackupsConfigureWizard />\n\t\t\t\t\t\t\t</SplitDialog>\n\t\t\t\t\t\t</EnsureLoggedIn>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t{/* Restore Wizard */}\n\t\t\t\t<Route\n\t\t\t\t\tpath='restore'\n\t\t\t\t\telement={\n\t\t\t\t\t\t<EnsureLoggedIn>\n\t\t\t\t\t\t\t<SplitDialog sideTitleKey='backups-restore'>\n\t\t\t\t\t\t\t\t<BackupsRestoreWizard />\n\t\t\t\t\t\t\t</SplitDialog>\n\t\t\t\t\t\t</EnsureLoggedIn>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t</Routes>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/utils/backup-location-helpers.ts",
    "content": "import {EXTERNAL_STORAGE_PATH, NETWORK_STORAGE_PATH} from '@/features/files/constants'\nimport {t} from '@/utils/i18n'\n\nexport type DeviceKind = 'NAS' | 'DRIVE'\n\nexport function getDeviceType(path: string): DeviceKind {\n\treturn path.startsWith(NETWORK_STORAGE_PATH) ? 'NAS' : 'DRIVE'\n}\n\n/**\n * Extracts a human-readable device name from a backup repository path.\n * Examples:\n * - \"/Network/nas.local/Backups\" -> \"nas.local\"\n * - \"/External/MyDrive/Backups\" -> \"MyDrive\"\n * - \"/Unknown/path\" -> fallback to translated backup location\n */\nexport function getDeviceNameFromPath(path: string): string {\n\tconst parts = path.split('/').filter(Boolean)\n\tif (path.startsWith('/Network/')) return parts[1] || t('nas')\n\tif (path.startsWith('/External/')) return parts[1] || t('external-drive')\n\treturn parts[0] || t('backups.backup-location')\n}\n\n/**\n * Determines whether a repository path is currently connected/available.\n * - NAS: uses doesHostHaveMountedShares('/Network/<host>')\n * - External: presence of any mountpoint under /External/<device>\n */\nexport function isRepoConnected(\n\tpath: string,\n\tdoesHostHaveMountedShares: (rootPath: string) => boolean,\n\tdisks: Array<{partitions?: Array<{mountpoints?: string[]}>}> | undefined,\n): boolean {\n\tif (path.startsWith(NETWORK_STORAGE_PATH)) {\n\t\tconst host = path.split('/')[2]\n\t\treturn !!host && doesHostHaveMountedShares(`${NETWORK_STORAGE_PATH}/${host}`)\n\t}\n\t// Otherwise treat as external drive\n\tconst device = path.split('/')[2]\n\tif (!device) return false\n\tconst prefix = `${EXTERNAL_STORAGE_PATH}/${device}`\n\treturn (disks || []).some((disk) =>\n\t\t(disk.partitions || []).some((part) =>\n\t\t\t(part.mountpoints || []).some((m) => typeof m === 'string' && m.startsWith(prefix)),\n\t\t),\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/utils/error-messages.ts",
    "content": "import {t} from '@/utils/i18n'\n\n// Helper function to convert backend error messages to user-friendly messages\nexport function getUserFriendlyErrorMessage(error: any): string {\n\tconst message = error?.message ?? t('unknown-error')\n\n\t// ========================================\n\t// Handle specific bracketed error codes\n\t// ========================================\n\t// Backup/restore already running\n\tif (message.includes('[in-progress]')) {\n\t\treturn t('backups-error.in-progress')\n\t}\n\t// Insufficient disk space for operation\n\tif (message.includes('[not-enough-space]')) {\n\t\treturn t('backups-error.not-enough-space')\n\t}\n\t// Repository or backup not found\n\tif (message.includes('[not-found]')) {\n\t\treturn t('backups-error.not-found')\n\t}\n\n\t// ========================================\n\t// Handle Kopia errors\n\t// ========================================\n\t// Wrong encryption password\n\tif (message.includes('invalid repository password')) {\n\t\treturn t('backups-error.invalid-password')\n\t}\n\t// Network/filesystem timeout\n\tif (message.includes('Mount timeout')) {\n\t\treturn t('backups-error.mount-timeout')\n\t}\n\t// Mount process failed\n\tif (message.includes('Mount exited with code')) {\n\t\treturn t('backups-error.mount-failed')\n\t}\n\n\t// ========================================\n\t// Defense-in-depth errors (prevented by frontend validation)\n\t// ========================================\n\t// NOTE: The following errors are prevented by frontend validation but kept for defense-in-depth:\n\t// - Repository already exists (prevented by useExistingBackupDetection)\n\t// - Repository not found (prevented by only showing existing repositories)\n\t// - Backup not found (prevented by only showing existing backups)\n\t// - Invalid path (prevented by file browser restrictions)\n\t// - Path to exclude must be in /Home (prevented by MiniBrowser rootPath restriction)\n\n\t// From Kopia repository creation\n\tif (message.includes('Repository already exists')) {\n\t\treturn t('backups-error.repository-exists')\n\t}\n\t// From Kopia repository access\n\tif (message.includes('Repository') && message.includes('not found')) {\n\t\treturn t('backups-error.repository-not-found')\n\t}\n\t// From Kopia backup operations\n\tif (message.includes('Backup') && message.includes('not found')) {\n\t\treturn t('backups-error.backup-not-found')\n\t}\n\t// From file system operations\n\tif (message.includes('Invalid path')) {\n\t\treturn t('backups-error.invalid-path')\n\t}\n\t// From backup exclusion validation\n\tif (message.includes('Path to exclude must be in /Home')) {\n\t\treturn t('backups-error.invalid-exclusion-path')\n\t}\n\n\t// For any other errors, return generic message with actual details\n\treturn t('backups-error.generic', {details: message})\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/utils/filepath-helpers.ts",
    "content": "import {APPS_PATH, EXTERNAL_STORAGE_PATH, NETWORK_STORAGE_PATH} from '@/features/files/constants'\n\n// File name used by Umbrel backups within a repository directory\nexport const BACKUP_FILE_NAME = 'Umbrel Backup.backup'\n\n// Returns a display path starting from the device name up to the parent directory\n// containing the Umbrel backup file, always ending with a trailing slash.\n// Examples:\n//  - /Network/samba.orb.local/data/My Backups/Umbrel Backup.backup -> samba.orb.local/data/My Backups/\n//  - /External/USB-DISK/Umbrel Backup.backup -> USB-DISK/\n//  - /Network/samba.orb.local/data/My Backups -> samba.orb.local/data/My Backups/\nexport function getDisplayRepositoryPath(path: string): string {\n\tconst segments = path.split('/').filter(Boolean)\n\n\t// For /Network/<device>/... or /External/<device>/..., the device starts at index 1\n\tlet startIndex = 0\n\tif (path.startsWith(NETWORK_STORAGE_PATH) || path.startsWith(EXTERNAL_STORAGE_PATH)) {\n\t\tstartIndex = 1\n\t}\n\n\t// Cut off at the backup file if present\n\tconst backupIdx = segments.findIndex((s) => s === BACKUP_FILE_NAME)\n\tconst endIndexExclusive = backupIdx !== -1 ? backupIdx : segments.length\n\n\tconst parts = segments.slice(startIndex, endIndexExclusive)\n\tif (parts.length === 0) return ''\n\treturn parts.join('/') + '/'\n}\n\n// Convert '/app-data/<appId>/path...' to '/Apps/<appId>/path...'\nexport function formatAppPathForDisplay(path: string) {\n\t// Replace anything up to and including '/app-data/' with the UI's Apps prefix.\n\t// If '/app-data/' is not present, this is a no-op and returns the original path. But this should never happen.\n\treturn path.replace(/^.*\\/app-data\\//, `${APPS_PATH}/`)\n}\n\n// Return the final segment of a path, trimming a trailing slash if present.\nexport function getLastPathSegment(p?: string) {\n\tif (!p) return ''\n\tconst trimmed = p.endsWith('/') ? p.slice(0, -1) : p\n\tconst idx = trimmed.lastIndexOf('/')\n\treturn idx >= 0 ? trimmed.slice(idx + 1) : trimmed\n}\n\n// Return a path relative to a given root, always starting with '/'.\n// If path does not start with root, the original path is returned.\nexport function getRelativePathFromRoot(path: string, root: string): string {\n\tif (!path || !root) return path\n\tif (path.startsWith(root)) {\n\t\tlet p = path.slice(root.length) || '/'\n\t\tif (!p.startsWith('/')) p = '/' + p\n\t\treturn p\n\t}\n\treturn path\n}\n\n// Extract the device/host name from a repository path, e.g.\n//  - /Network/<host>/... -> <host>\n//  - /External/<device>/... -> <device>\n// Returns empty string if not applicable.\nexport function getRepositoryDisplayName(path: string): string {\n\tconst segments = path.split('/').filter(Boolean)\n\tif (path.startsWith(NETWORK_STORAGE_PATH) || path.startsWith(EXTERNAL_STORAGE_PATH)) {\n\t\treturn segments[1] || ''\n\t}\n\treturn ''\n}\n\n// Returns the path within the device (excluding the device name) and without the backup file name.\n// Example:\n//  - /Network/host/data/Umbrel Backup.backup -> /\n//  - /Network/host/data/My Backups/Umbrel Backup.backup -> /data/My Backups\n//  - /External/USB-DISK/Umbrel Backup.backup -> /\nexport function getRepositoryRelativePath(path: string): string {\n\tconst segments = path.split('/').filter(Boolean)\n\n\t// Skip root and device segments when present\n\tlet startIndex = 0\n\tif (path.startsWith(NETWORK_STORAGE_PATH) || path.startsWith(EXTERNAL_STORAGE_PATH)) {\n\t\tstartIndex = 2 // skip 'Network' or 'External' and the device name\n\t}\n\n\t// Cut off the backup file if present at the end\n\tconst backupIdx = segments.findIndex((s) => s === BACKUP_FILE_NAME)\n\tconst endIndexExclusive = backupIdx !== -1 ? backupIdx : segments.length\n\n\tlet inner = segments.slice(startIndex, endIndexExclusive).join('/')\n\tif (!inner) return '/'\n\tif (!inner.startsWith('/')) inner = '/' + inner\n\treturn inner\n}\n\n// Extracts the repository path (parent directory) from a backup file path.\n// e.g., /Network/host/data/Umbrel Backup.backup -> /Network/host/data\nexport function getRepositoryPathFromBackupFile(backupFilePath: string): string {\n\tconst path = backupFilePath.trim()\n\treturn path.endsWith(BACKUP_FILE_NAME) ? path.slice(0, -BACKUP_FILE_NAME.length).replace(/\\/$/, '') || '/' : path\n}\n"
  },
  {
    "path": "packages/ui/src/features/backups/utils/sort.ts",
    "content": "import type {Backup} from '@/features/backups/hooks/use-backups'\n\n// Sort backups from latest to oldest\nexport function sortBackupsByTimeDesc(backups: Backup[] | undefined | null): Backup[] {\n\tif (!Array.isArray(backups)) return []\n\treturn [...backups].sort((a, b) => {\n\t\tconst timeA = a.time ? new Date(a.time).getTime() : 0\n\t\tconst timeB = b.time ? new Date(b.time).getTime() : 0\n\t\treturn timeB - timeA\n\t}) as Backup[]\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/add-folder-icon.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const AddFolderIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='17' height='16' viewBox='0 0 17 16' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g clipPath='url(#clip0_1_71)'>\n\t\t\t<path\n\t\t\t\td='M8.49994 12.6665H3.83327C3.47965 12.6665 3.14051 12.526 2.89046 12.276C2.64041 12.0259 2.49994 11.6868 2.49994 11.3332V3.99984C2.49994 3.64622 2.64041 3.30708 2.89046 3.05703C3.14051 2.80698 3.47965 2.6665 3.83327 2.6665H6.49994L8.49994 4.6665H13.1666C13.5202 4.6665 13.8594 4.80698 14.1094 5.05703C14.3595 5.30708 14.4999 5.64622 14.4999 5.99984V8.33317'\n\t\t\t\tstroke='white'\n\t\t\t\tstrokeWidth='1.5'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M11.1666 12.6665H15.1666'\n\t\t\t\tstroke='white'\n\t\t\t\tstrokeWidth='1.5'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M13.1666 10.6665V14.6665'\n\t\t\t\tstroke='white'\n\t\t\t\tstrokeWidth='1.5'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<clipPath id='clip0_1_71'>\n\t\t\t\t<rect width='16' height='16' fill='white' transform='translate(0.5)' />\n\t\t\t</clipPath>\n\t\t</defs>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/apps-icon.tsx",
    "content": "import {SVGProps, useId} from 'react'\n\nexport const AppsIcon = (props: SVGProps<SVGSVGElement>) => {\n\tconst id = useId()\n\treturn (\n\t\t<svg width={15} height={18} viewBox='0 0 15 18' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t\t<g filter={`url(#filter-${id})`}>\n\t\t\t\t<path\n\t\t\t\t\td='M7.70623 8.79459C7.57331 8.87543 7.50684 8.91585 7.43305 8.9167C7.35927 8.91754 7.2919 8.87864 7.15716 8.80085L1.51977 5.54611C1.46272 5.51317 1.60535 5.59552 1.60249 5.59377C1.47862 5.51807 1.47585 5.21481 1.59833 5.13686C1.60116 5.13506 1.3865 5.26426 1.47237 5.21258L7.39013 1.61321C7.47462 1.56181 7.57136 1.53403 7.67025 1.53278C7.76915 1.53154 7.86656 1.55686 7.95232 1.60612L13.6712 4.90789C13.7797 4.97061 13.7509 5.11836 13.6438 5.18351L7.70623 8.79459Z'\n\t\t\t\t\tfill='hsl(var(--color-brand))'\n\t\t\t\t/>\n\t\t\t\t<path\n\t\t\t\t\td='M7.70623 8.79459C7.57331 8.87543 7.50684 8.91585 7.43305 8.9167C7.35927 8.91754 7.2919 8.87864 7.15716 8.80085L1.51977 5.54611C1.46272 5.51317 1.60535 5.59552 1.60249 5.59377C1.47862 5.51807 1.47585 5.21481 1.59833 5.13686C1.60116 5.13506 1.3865 5.26426 1.47237 5.21258L7.39013 1.61321C7.47462 1.56181 7.57136 1.53403 7.67025 1.53278C7.76915 1.53154 7.86656 1.55686 7.95232 1.60612L13.6712 4.90789C13.7797 4.97061 13.7509 5.11836 13.6438 5.18351L7.70623 8.79459Z'\n\t\t\t\t\tfill={`url(#gradient1-${id})`}\n\t\t\t\t/>\n\t\t\t\t<path\n\t\t\t\t\td='M7.8946 16.8549L13.8118 13.2552C13.9706 13.1595 14.1026 13.025 14.1953 12.8643C14.2881 12.7037 14.3386 12.5222 14.342 12.3367L14.4997 5.41177C14.5042 5.21402 14.4556 5.01869 14.3589 4.84616C14.2622 4.67363 14.1209 4.53024 13.9498 4.43095L8.23097 1.12918C8.05944 1.03067 7.86462 0.980016 7.66684 0.98251C7.46906 0.985004 7.27558 1.04055 7.1066 1.14335L1.18823 4.74239C1.02938 4.83813 0.897402 4.97261 0.804663 5.13324C0.711923 5.29387 0.661449 5.4754 0.657962 5.66084L0.500291 12.5858C0.495753 12.7836 0.544387 12.9789 0.641117 13.1514C0.737847 13.324 0.879125 13.4673 1.0502 13.5666L6.76844 16.8681C6.94008 16.9671 7.13518 17.0181 7.33332 17.0158C7.53145 17.0135 7.72532 16.9579 7.8946 16.8549ZM13.2464 12.0178C13.2432 12.162 13.2415 12.2341 13.2071 12.2938C13.1726 12.3534 13.111 12.3909 12.9877 12.4658L8.67542 15.0869C8.30447 15.3124 8.11899 15.4251 7.98083 15.3453C7.84268 15.2656 7.84757 15.0486 7.85736 14.6146L7.97112 9.56948C7.97437 9.42527 7.97599 9.35317 8.01045 9.2935C8.0449 9.23383 8.10653 9.19637 8.22979 9.12145L12.5421 6.50037C12.9131 6.2749 13.0985 6.16216 13.2367 6.24193C13.3749 6.32169 13.37 6.53869 13.3602 6.97268L13.2464 12.0178ZM12.0228 4.59403C12.3737 4.7966 12.5491 4.89788 12.5508 5.05128C12.5526 5.20467 12.3795 5.30994 12.0334 5.52047L7.70266 8.15468C7.56973 8.23553 7.50326 8.27596 7.42947 8.2768C7.35567 8.27765 7.2883 8.23875 7.15356 8.16095L3.08669 5.81295C2.73595 5.61045 2.56057 5.50919 2.5588 5.35582C2.55702 5.20245 2.73 5.09717 3.07597 4.88659L7.40527 2.25156C7.53822 2.17064 7.60469 2.13018 7.67851 2.12932C7.75232 2.12847 7.81971 2.16738 7.9545 2.2452L12.0228 4.59403ZM1.72706 7.22397C1.73628 6.81891 1.7409 6.61638 1.87462 6.54119C2.00835 6.466 2.18379 6.56729 2.53467 6.76988L6.59892 9.11637C6.73361 9.19413 6.80095 9.23301 6.83712 9.29731C6.87328 9.36161 6.87153 9.43935 6.86804 9.59484L6.75405 14.6636C6.74494 15.0688 6.74038 15.2714 6.60664 15.3466C6.4729 15.4218 6.29741 15.3205 5.94643 15.1178L1.88074 12.7705C1.74601 12.6927 1.67864 12.6538 1.64248 12.5895C1.60631 12.5252 1.60808 12.4474 1.61162 12.2919L1.72706 7.22397Z'\n\t\t\t\t\tfill='hsl(var(--color-brand))'\n\t\t\t\t/>\n\t\t\t\t<path\n\t\t\t\t\td='M7.8946 16.8549L13.8118 13.2552C13.9706 13.1595 14.1026 13.025 14.1953 12.8643C14.2881 12.7037 14.3386 12.5222 14.342 12.3367L14.4997 5.41177C14.5042 5.21402 14.4556 5.01869 14.3589 4.84616C14.2622 4.67363 14.1209 4.53024 13.9498 4.43095L8.23097 1.12918C8.05944 1.03067 7.86462 0.980016 7.66684 0.98251C7.46906 0.985004 7.27558 1.04055 7.1066 1.14335L1.18823 4.74239C1.02938 4.83813 0.897402 4.97261 0.804663 5.13324C0.711923 5.29387 0.661449 5.4754 0.657962 5.66084L0.500291 12.5858C0.495753 12.7836 0.544387 12.9789 0.641117 13.1514C0.737847 13.324 0.879125 13.4673 1.0502 13.5666L6.76844 16.8681C6.94008 16.9671 7.13518 17.0181 7.33332 17.0158C7.53145 17.0135 7.72532 16.9579 7.8946 16.8549ZM13.2464 12.0178C13.2432 12.162 13.2415 12.2341 13.2071 12.2938C13.1726 12.3534 13.111 12.3909 12.9877 12.4658L8.67542 15.0869C8.30447 15.3124 8.11899 15.4251 7.98083 15.3453C7.84268 15.2656 7.84757 15.0486 7.85736 14.6146L7.97112 9.56948C7.97437 9.42527 7.97599 9.35317 8.01045 9.2935C8.0449 9.23383 8.10653 9.19637 8.22979 9.12145L12.5421 6.50037C12.9131 6.2749 13.0985 6.16216 13.2367 6.24193C13.3749 6.32169 13.37 6.53869 13.3602 6.97268L13.2464 12.0178ZM12.0228 4.59403C12.3737 4.7966 12.5491 4.89788 12.5508 5.05128C12.5526 5.20467 12.3795 5.30994 12.0334 5.52047L7.70266 8.15468C7.56973 8.23553 7.50326 8.27596 7.42947 8.2768C7.35567 8.27765 7.2883 8.23875 7.15356 8.16095L3.08669 5.81295C2.73595 5.61045 2.56057 5.50919 2.5588 5.35582C2.55702 5.20245 2.73 5.09717 3.07597 4.88659L7.40527 2.25156C7.53822 2.17064 7.60469 2.13018 7.67851 2.12932C7.75232 2.12847 7.81971 2.16738 7.9545 2.2452L12.0228 4.59403ZM1.72706 7.22397C1.73628 6.81891 1.7409 6.61638 1.87462 6.54119C2.00835 6.466 2.18379 6.56729 2.53467 6.76988L6.59892 9.11637C6.73361 9.19413 6.80095 9.23301 6.83712 9.29731C6.87328 9.36161 6.87153 9.43935 6.86804 9.59484L6.75405 14.6636C6.74494 15.0688 6.74038 15.2714 6.60664 15.3466C6.4729 15.4218 6.29741 15.3205 5.94643 15.1178L1.88074 12.7705C1.74601 12.6927 1.67864 12.6538 1.64248 12.5895C1.60631 12.5252 1.60808 12.4474 1.61162 12.2919L1.72706 7.22397Z'\n\t\t\t\t\tfill={`url(#gradient2-${id})`}\n\t\t\t\t/>\n\t\t\t</g>\n\t\t\t<defs>\n\t\t\t\t<filter\n\t\t\t\t\tid={`filter-${id}`}\n\t\t\t\t\tx={0.223691}\n\t\t\t\t\ty={0.706113}\n\t\t\t\t\twidth={14.4145}\n\t\t\t\t\theight={16.4477}\n\t\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\t>\n\t\t\t\t\t<feFlood floodOpacity={0} result='BackgroundImageFix' />\n\t\t\t\t\t<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t/>\n\t\t\t\t\t<feOffset dx={0.276309} dy={0.276309} />\n\t\t\t\t\t<feGaussianBlur stdDeviation={0.0690772} />\n\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2={-1} k3={1} />\n\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0' />\n\t\t\t\t\t<feBlend mode='normal' in2='shape' result='effect1_innerShadow_1136_3196' />\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t/>\n\t\t\t\t\t<feOffset dx={-0.276309} dy={-0.276309} />\n\t\t\t\t\t<feGaussianBlur stdDeviation={0.138154} />\n\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2={-1} k3={1} />\n\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0' />\n\t\t\t\t\t<feBlend mode='normal' in2='effect1_innerShadow_1136_3196' result='effect2_innerShadow_1136_3196' />\n\t\t\t\t</filter>\n\t\t\t\t<linearGradient\n\t\t\t\t\tid={`gradient1-${id}`}\n\t\t\t\t\tx1={7.5}\n\t\t\t\t\ty1={0.982422}\n\t\t\t\t\tx2={7.5}\n\t\t\t\t\ty2={17.0158}\n\t\t\t\t\tgradientUnits='userSpaceOnUse'\n\t\t\t\t>\n\t\t\t\t\t<stop offset={0.315} stopOpacity={0} />\n\t\t\t\t\t<stop offset={0.965} stopOpacity={0.48} />\n\t\t\t\t</linearGradient>\n\t\t\t\t<linearGradient\n\t\t\t\t\tid={`gradient2-${id}`}\n\t\t\t\t\tx1={7.5}\n\t\t\t\t\ty1={0.982422}\n\t\t\t\t\tx2={7.5}\n\t\t\t\t\ty2={17.0158}\n\t\t\t\t\tgradientUnits='userSpaceOnUse'\n\t\t\t\t>\n\t\t\t\t\t<stop offset={0.315} stopOpacity={0} />\n\t\t\t\t\t<stop offset={0.965} stopOpacity={0.48} />\n\t\t\t\t</linearGradient>\n\t\t\t</defs>\n\t\t</svg>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/caret-right.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const CaretRightIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='3' height='6' viewBox='0 0 3 6' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<path d='M3 3L0 6L0 0L3 3Z' fill='currentColor' />\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/chevron-left.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const ChevronLeftIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='21' height='20' viewBox='0 0 21 20' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g clipPath='url(#clip0_681_3294)'>\n\t\t\t<path\n\t\t\t\td='M13 5L8 10L13 15'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.5625'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<clipPath id='clip0_681_3294'>\n\t\t\t\t<rect width='20' height='20' fill='currentColor' transform='translate(0.5)' />\n\t\t\t</clipPath>\n\t\t</defs>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/chevron-right.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const ChevronRightIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='21' height='20' viewBox='0 0 21 20' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g clipPath='url(#clip0_681_3296)'>\n\t\t\t<path\n\t\t\t\td='M8 5L13 10L8 15'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.5625'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<clipPath id='clip0_681_3296'>\n\t\t\t\t<rect width='20' height='20' fill='currentColor' transform='translate(0.5)' />\n\t\t\t</clipPath>\n\t\t</defs>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/copy-icon.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const CopyIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='17' height='16' viewBox='0 0 17 16' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g clipPath='url(#clip0_257_2751)'>\n\t\t\t<path\n\t\t\t\td='M12.5002 5.33325H7.16683C6.43045 5.33325 5.8335 5.93021 5.8335 6.66659V11.9999C5.8335 12.7363 6.43045 13.3333 7.16683 13.3333H12.5002C13.2365 13.3333 13.8335 12.7363 13.8335 11.9999V6.66659C13.8335 5.93021 13.2365 5.33325 12.5002 5.33325Z'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M11.1665 5.33341V4.00008C11.1665 3.64646 11.026 3.30732 10.776 3.05727C10.5259 2.80722 10.1868 2.66675 9.83317 2.66675H4.49984C4.14622 2.66675 3.80708 2.80722 3.55703 3.05727C3.30698 3.30732 3.1665 3.64646 3.1665 4.00008V9.33341C3.1665 9.68704 3.30698 10.0262 3.55703 10.2762C3.80708 10.5263 4.14622 10.6667 4.49984 10.6667H5.83317'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<clipPath id='clip0_257_2751'>\n\t\t\t\t<rect width='16' height='16' fill='currentColor' transform='translate(0.5)' />\n\t\t\t</clipPath>\n\t\t</defs>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/cursor-text-icon.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const CursorTextIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='17' height='16' viewBox='0 0 17 16' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g clipPath='url(#clip0_257_2745)'>\n\t\t\t<path\n\t\t\t\td='M7.1665 8H9.83317'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M6.5 2.66675C7.03043 2.66675 7.53914 2.87746 7.91421 3.25253C8.28929 3.62761 8.5 4.13632 8.5 4.66675V11.3334C8.5 11.8638 8.28929 12.3726 7.91421 12.7476C7.53914 13.1227 7.03043 13.3334 6.5 13.3334'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M10.5 2.66675C9.96957 2.66675 9.46086 2.87746 9.08579 3.25253C8.71071 3.62761 8.5 4.13632 8.5 4.66675V11.3334C8.5 11.8638 8.71071 12.3726 9.08579 12.7476C9.46086 13.1227 9.96957 13.3334 10.5 13.3334'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<clipPath id='clip0_257_2745'>\n\t\t\t\t<rect width='16' height='16' fill='currentColor' transform='translate(0.5)' />\n\t\t\t</clipPath>\n\t\t</defs>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/empty-folder-icon.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const EmptyFolderIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg xmlns='http://www.w3.org/2000/svg' width={53} height={47} fill='none' {...props}>\n\t\t<g clipPath='url(#empty-folder_svg__a)' opacity={0.5}>\n\t\t\t<g filter='url(#empty-folder_svg__b)' opacity={0.6}>\n\t\t\t\t<path\n\t\t\t\t\tfill='#fff'\n\t\t\t\t\td='M15.437 6.45a2.59 2.59 0 0 1 2.253-3.036L38.037.867c1.498-.188 2.919.867 3.173 2.356l1.242 7.279-25.773 3.226z'\n\t\t\t\t/>\n\t\t\t</g>\n\t\t\t<g filter='url(#empty-folder_svg__c)'>\n\t\t\t\t<path\n\t\t\t\t\tfill='#fff'\n\t\t\t\t\td='M10.325 9.346a2.58 2.58 0 0 1 2.271-3.013l34.2-3.97c1.5-.175 2.915.892 3.161 2.382l.826 5.009-39.632 4.601z'\n\t\t\t\t/>\n\t\t\t</g>\n\t\t\t<g filter='url(#empty-folder_svg__d)'>\n\t\t\t\t<path\n\t\t\t\t\tfill='#757575'\n\t\t\t\t\td='M.13 30.063V14.37c0-5.49 0-8.234 1.23-10.254A8.44 8.44 0 0 1 4.183 1.29C6.204.063 8.95.063 14.438.063c1.429 0 2.143 0 2.803.198.383.115.75.278 1.09.486.589.358 1.066.89 2.02 1.953l.444.493c1.375 1.532 2.063 2.298 2.975 2.705.911.406 1.94.406 4 .406h13.61c5.304 0 7.956 0 9.603 1.648 1.648 1.647 1.648 4.299 1.648 9.602v12.509c0 7.954 0 11.932-2.471 14.403s-6.45 2.471-14.404 2.471h-18.75c-7.955 0-11.933 0-14.404-2.47C.131 41.994.131 38.016.131 30.062'\n\t\t\t\t/>\n\t\t\t\t<path\n\t\t\t\t\tfill='url(#empty-folder_svg__e)'\n\t\t\t\t\td='M.13 30.063V14.37c0-5.49 0-8.234 1.23-10.254A8.44 8.44 0 0 1 4.183 1.29C6.204.063 8.95.063 14.438.063c1.429 0 2.143 0 2.803.198.383.115.75.278 1.09.486.589.358 1.066.89 2.02 1.953l.444.493c1.375 1.532 2.063 2.298 2.975 2.705.911.406 1.94.406 4 .406h13.61c5.304 0 7.956 0 9.603 1.648 1.648 1.647 1.648 4.299 1.648 9.602v12.509c0 7.954 0 11.932-2.471 14.403s-6.45 2.471-14.404 2.471h-18.75c-7.955 0-11.933 0-14.404-2.47C.131 41.994.131 38.016.131 30.062'\n\t\t\t\t/>\n\t\t\t</g>\n\t\t</g>\n\t\t<defs>\n\t\t\t<filter\n\t\t\t\tid='empty-folder_svg__b'\n\t\t\t\twidth={30.153}\n\t\t\t\theight={15.253}\n\t\t\t\tx={13.939}\n\t\t\t\ty={-0.795}\n\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t>\n\t\t\t\t<feFlood floodOpacity={0} result='BackgroundImageFix' />\n\t\t\t\t<feColorMatrix in='SourceAlpha' result='hardAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' />\n\t\t\t\t<feOffset dx={-0.729} />\n\t\t\t\t<feGaussianBlur stdDeviation={0.365} />\n\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t<feColorMatrix values='0 0 0 0 0.0745098 0 0 0 0 0.0745098 0 0 0 0 0.0823529 0 0 0 0.16 0' />\n\t\t\t\t<feBlend in2='BackgroundImageFix' result='effect1_dropShadow_1032_6073' />\n\t\t\t\t<feBlend in='SourceGraphic' in2='effect1_dropShadow_1032_6073' result='shape' />\n\t\t\t\t<feColorMatrix in='SourceAlpha' result='hardAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' />\n\t\t\t\t<feOffset dx={2.461} dy={-2.461} />\n\t\t\t\t<feGaussianBlur stdDeviation={0.82} />\n\t\t\t\t<feComposite in2='hardAlpha' k2={-1} k3={1} operator='arithmetic' />\n\t\t\t\t<feColorMatrix values='0 0 0 0 0.159774 0 0 0 0 0.20031 0 0 0 0 0.413127 0 0 0 0.26 0' />\n\t\t\t\t<feBlend in2='shape' result='effect2_innerShadow_1032_6073' />\n\t\t\t</filter>\n\t\t\t<filter\n\t\t\t\tid='empty-folder_svg__c'\n\t\t\t\twidth={43.594}\n\t\t\t\theight={14.382}\n\t\t\t\tx={8.83}\n\t\t\t\ty={0.703}\n\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t>\n\t\t\t\t<feFlood floodOpacity={0} result='BackgroundImageFix' />\n\t\t\t\t<feColorMatrix in='SourceAlpha' result='hardAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' />\n\t\t\t\t<feOffset dx={-0.729} />\n\t\t\t\t<feGaussianBlur stdDeviation={0.365} />\n\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t<feColorMatrix values='0 0 0 0 0.0745098 0 0 0 0 0.0745098 0 0 0 0 0.0823529 0 0 0 0.16 0' />\n\t\t\t\t<feBlend in2='BackgroundImageFix' result='effect1_dropShadow_1032_6073' />\n\t\t\t\t<feBlend in='SourceGraphic' in2='effect1_dropShadow_1032_6073' result='shape' />\n\t\t\t\t<feColorMatrix in='SourceAlpha' result='hardAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' />\n\t\t\t\t<feOffset dx={2.461} dy={-2.461} />\n\t\t\t\t<feGaussianBlur stdDeviation={0.82} />\n\t\t\t\t<feComposite in2='hardAlpha' k2={-1} k3={1} operator='arithmetic' />\n\t\t\t\t<feColorMatrix values='0 0 0 0 0.159774 0 0 0 0 0.20031 0 0 0 0 0.413127 0 0 0 0.26 0' />\n\t\t\t\t<feBlend in2='shape' result='effect2_innerShadow_1032_6073' />\n\t\t\t</filter>\n\t\t\t<filter\n\t\t\t\tid='empty-folder_svg__d'\n\t\t\t\twidth={53.906}\n\t\t\t\theight={48.281}\n\t\t\t\tx={-0.807}\n\t\t\t\ty={-0.875}\n\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t>\n\t\t\t\t<feFlood floodOpacity={0} result='BackgroundImageFix' />\n\t\t\t\t<feBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape' />\n\t\t\t\t<feColorMatrix in='SourceAlpha' result='hardAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' />\n\t\t\t\t<feOffset dx={0.938} dy={0.938} />\n\t\t\t\t<feGaussianBlur stdDeviation={0.234} />\n\t\t\t\t<feComposite in2='hardAlpha' k2={-1} k3={1} operator='arithmetic' />\n\t\t\t\t<feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0' />\n\t\t\t\t<feBlend in2='shape' result='effect1_innerShadow_1032_6073' />\n\t\t\t\t<feColorMatrix in='SourceAlpha' result='hardAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' />\n\t\t\t\t<feOffset dx={-0.938} dy={-0.938} />\n\t\t\t\t<feGaussianBlur stdDeviation={0.469} />\n\t\t\t\t<feComposite in2='hardAlpha' k2={-1} k3={1} operator='arithmetic' />\n\t\t\t\t<feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0' />\n\t\t\t\t<feBlend in2='effect1_innerShadow_1032_6073' result='effect2_innerShadow_1032_6073' />\n\t\t\t</filter>\n\t\t\t<linearGradient\n\t\t\t\tid='empty-folder_svg__e'\n\t\t\t\tx1={26.381}\n\t\t\t\tx2={26.381}\n\t\t\t\ty1={0.063}\n\t\t\t\ty2={46.938}\n\t\t\t\tgradientUnits='userSpaceOnUse'\n\t\t\t>\n\t\t\t\t<stop offset={0.315} stopOpacity={0} />\n\t\t\t\t<stop offset={0.965} stopOpacity={0.48} />\n\t\t\t</linearGradient>\n\t\t\t<clipPath id='empty-folder_svg__a'>\n\t\t\t\t<rect width={52.5} height={46.875} x={0.25} y={0.063} fill='#fff' rx={9.375} />\n\t\t\t</clipPath>\n\t\t</defs>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/file-items-thumbnails/index.tsx",
    "content": "import React from 'react'\n\n// We convert the SVG sources to the WebP format at build\n// time using `vite-imagetools`. This ensures the thumbnails are lightweight\n// rasters, and don't affect performance when browsing large directories.\n\nimport aiWebp from './ai.svg?w=120&format=webp&imagetools'\nimport audioWebp from './audio.svg?w=120&format=webp&imagetools'\nimport csvWebp from './csv.svg?w=120&format=webp&imagetools'\nimport dmgWebp from './dmg.svg?w=120&format=webp&imagetools'\nimport docxWebp from './docx.svg?w=120&format=webp&imagetools'\nimport ebookWebp from './ebook.svg?w=120&format=webp&imagetools'\nimport exeWebp from './exe.svg?w=120&format=webp&imagetools'\nimport imageWebp from './image.svg?w=120&format=webp&imagetools'\nimport isoWebp from './iso.svg?w=120&format=webp&imagetools'\nimport pdfWebp from './pdf.svg?w=120&format=webp&imagetools'\nimport pptWebp from './ppt.svg?w=120&format=webp&imagetools'\nimport psdWebp from './psd.svg?w=120&format=webp&imagetools'\nimport txtWebp from './txt.svg?w=120&format=webp&imagetools'\nimport unknownWebp from './unknown.svg?w=120&format=webp&imagetools'\nimport videoWebp from './video.svg?w=120&format=webp&imagetools'\nimport zipWebp from './zip.svg?w=120&format=webp&imagetools'\n\ntype ThumbnailProps = React.ImgHTMLAttributes<HTMLImageElement>\n\nexport const AiThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={aiWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const AudioThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={audioWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const CsvThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={csvWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const DmgThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={dmgWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const DocxThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={docxWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const EbookThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={ebookWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const ExeThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={exeWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const ImageThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={imageWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const IsoThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={isoWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const PdfThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={pdfWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const PptThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={pptWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const PsdThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={psdWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const TxtThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={txtWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const UnknownThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={unknownWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const VideoThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={videoWebp} {...props} style={{objectFit: 'contain'}} />\n)\nexport const ZipThumbnail: React.FC<ThumbnailProps> = (props) => (\n\t<img src={zipWebp} {...props} style={{objectFit: 'contain'}} />\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/flame-icon.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const FlameIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='11' height='14' viewBox='0 0 11 14' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<path\n\t\t\tfillRule='evenodd'\n\t\t\tclipRule='evenodd'\n\t\t\td='M5.59229 14.0098C8.15522 14.0098 10.2329 11.9951 10.2329 9.50976C10.2329 6.69727 8.68604 4.63477 8.68604 4.63477C8.68604 4.63477 8.68604 5.94727 7.52579 6.32227C7.52579 6.32227 7.7191 5.38477 7.7191 4.44727C7.7191 2.25914 6.17166 0.603141 5.20547 0.134766C5.20547 1.70574 4.17219 3.18563 3.12415 4.68666C2.04572 6.23121 0.95166 7.79815 0.95166 9.50976C0.95166 11.7252 2.60268 13.5667 4.77669 13.9405C4.99601 13.9859 5.23547 14.0104 5.49598 14.0104C5.51673 14.0104 5.53743 14.0101 5.55808 14.0096C5.56947 14.0097 5.58087 14.0098 5.59229 14.0098ZM5.55808 14.0096C7.12485 13.9736 8.38479 12.5768 8.38479 10.8589C8.38479 9.14098 5.9981 6.21092 5.51529 6.13536C5.55635 6.33205 5.57829 6.53567 5.57829 6.74455C5.57829 7.98794 4.84834 8.60434 4.13761 9.20451C3.54733 9.70297 2.97031 10.1902 2.83573 11.0162C2.64841 12.1673 3.17674 13.6095 4.77669 13.9405C5.03062 13.9841 5.29168 14.0078 5.55808 14.0096Z'\n\t\t\tfill='white'\n\t\t/>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/grid-layout-icon.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const GridLayoutIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='17' height='16' viewBox='0 0 17 16' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g clipPath='url(#clip0_1_58)'>\n\t\t\t<path\n\t\t\t\td='M6.50002 2.66666H3.83335C3.46516 2.66666 3.16669 2.96513 3.16669 3.33332V5.99999C3.16669 6.36818 3.46516 6.66666 3.83335 6.66666H6.50002C6.86821 6.66666 7.16669 6.36818 7.16669 5.99999V3.33332C7.16669 2.96513 6.86821 2.66666 6.50002 2.66666Z'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M13.1666 2.66666H10.5C10.1318 2.66666 9.83331 2.96513 9.83331 3.33332V5.99999C9.83331 6.36818 10.1318 6.66666 10.5 6.66666H13.1666C13.5348 6.66666 13.8333 6.36818 13.8333 5.99999V3.33332C13.8333 2.96513 13.5348 2.66666 13.1666 2.66666Z'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M6.50002 9.33334H3.83335C3.46516 9.33334 3.16669 9.63182 3.16669 10V12.6667C3.16669 13.0349 3.46516 13.3333 3.83335 13.3333H6.50002C6.86821 13.3333 7.16669 13.0349 7.16669 12.6667V10C7.16669 9.63182 6.86821 9.33334 6.50002 9.33334Z'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M13.1666 9.33334H10.5C10.1318 9.33334 9.83331 9.63182 9.83331 10V12.6667C9.83331 13.0349 10.1318 13.3333 10.5 13.3333H13.1666C13.5348 13.3333 13.8333 13.0349 13.8333 12.6667V10C13.8333 9.63182 13.5348 9.33334 13.1666 9.33334Z'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<clipPath id='clip0_1_58'>\n\t\t\t\t<rect width='16' height='16' fill='white' transform='translate(0.5)' />\n\t\t\t</clipPath>\n\t\t</defs>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/home-icon.tsx",
    "content": "import {SVGProps, useId} from 'react'\n\nexport const HomeIcon = (props: SVGProps<SVGSVGElement>) => {\n\tconst id = useId()\n\treturn (\n\t\t<svg width={18} height={16} viewBox='0 0 18 16' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t\t<g filter={`url(#filter-${id})`}>\n\t\t\t\t<path\n\t\t\t\t\tfillRule='evenodd'\n\t\t\t\t\tclipRule='evenodd'\n\t\t\t\t\td='M2.78928 8.60098C2.78928 8.37912 2.52503 8.26366 2.36224 8.41439V8.41439C1.7922 8.94216 0.902249 8.90792 0.37449 8.33791C-0.153268 7.7679 -0.119009 6.87798 0.45103 6.3502L4.98636 2.15091C7.25128 0.0538645 10.7489 0.0538645 13.0137 2.15091L17.5491 6.3502C18.1191 6.87798 18.1533 7.7679 17.6255 8.33791C17.0978 8.90792 16.2078 8.94216 15.6378 8.41439V8.41439C15.4599 8.24971 15.1712 8.37585 15.1712 8.61825V12.6029C15.1712 14.1593 13.9095 15.4211 12.353 15.4211H5.60751C4.05104 15.4211 2.78928 14.1593 2.78928 12.6029V8.60098ZM11.7197 10.3156C11.7197 11.8174 10.5022 13.0349 9.00036 13.0349C7.49855 13.0349 6.28113 11.8174 6.28113 10.3156C6.28113 8.8138 7.49855 7.59635 9.00036 7.59635C10.5022 7.59635 11.7197 8.8138 11.7197 10.3156Z'\n\t\t\t\t\tfill='hsl(var(--color-brand))'\n\t\t\t\t/>\n\t\t\t\t<path\n\t\t\t\t\tfillRule='evenodd'\n\t\t\t\t\tclipRule='evenodd'\n\t\t\t\t\td='M2.78928 8.60098C2.78928 8.37912 2.52503 8.26366 2.36224 8.41439V8.41439C1.7922 8.94216 0.902249 8.90792 0.37449 8.33791C-0.153268 7.7679 -0.119009 6.87798 0.45103 6.3502L4.98636 2.15091C7.25128 0.0538645 10.7489 0.0538645 13.0137 2.15091L17.5491 6.3502C18.1191 6.87798 18.1533 7.7679 17.6255 8.33791C17.0978 8.90792 16.2078 8.94216 15.6378 8.41439V8.41439C15.4599 8.24971 15.1712 8.37585 15.1712 8.61825V12.6029C15.1712 14.1593 13.9095 15.4211 12.353 15.4211H5.60751C4.05104 15.4211 2.78928 14.1593 2.78928 12.6029V8.60098ZM11.7197 10.3156C11.7197 11.8174 10.5022 13.0349 9.00036 13.0349C7.49855 13.0349 6.28113 11.8174 6.28113 10.3156C6.28113 8.8138 7.49855 7.59635 9.00036 7.59635C10.5022 7.59635 11.7197 8.8138 11.7197 10.3156Z'\n\t\t\t\t\tfill={`url(#gradient-${id})`}\n\t\t\t\t/>\n\t\t\t</g>\n\t\t\t<defs>\n\t\t\t\t<filter\n\t\t\t\t\tid={`filter-${id}`}\n\t\t\t\t\tx='-0.349953'\n\t\t\t\t\ty='0.228134'\n\t\t\t\t\twidth='18.525'\n\t\t\t\t\theight='15.3687'\n\t\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\t>\n\t\t\t\t\t<feFlood floodOpacity={0} result='BackgroundImageFix' />\n\t\t\t\t\t<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t/>\n\t\t\t\t\t<feOffset dx='0.349991' dy='0.349991' />\n\t\t\t\t\t<feGaussianBlur stdDeviation='0.0874978' />\n\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0' />\n\t\t\t\t\t<feBlend mode='normal' in2='shape' result='effect1_innerShadow_1076_3217' />\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t/>\n\t\t\t\t\t<feOffset dx='-0.349991' dy='-0.349991' />\n\t\t\t\t\t<feGaussianBlur stdDeviation='0.174996' />\n\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0' />\n\t\t\t\t\t<feBlend mode='normal' in2='effect1_innerShadow_1076_3217' result='effect2_innerShadow_1076_3217' />\n\t\t\t\t</filter>\n\t\t\t\t<linearGradient\n\t\t\t\t\tid={`gradient-${id}`}\n\t\t\t\t\tx1='9.00004'\n\t\t\t\t\ty1='0.578125'\n\t\t\t\t\tx2='9.00004'\n\t\t\t\t\ty2='15.4211'\n\t\t\t\t\tgradientUnits='userSpaceOnUse'\n\t\t\t\t>\n\t\t\t\t\t<stop offset='0.315' stopOpacity={0} />\n\t\t\t\t\t<stop offset='0.965' stopOpacity={0.48} />\n\t\t\t\t</linearGradient>\n\t\t\t</defs>\n\t\t</svg>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/list-layout-icon.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const ListLayoutIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='17' height='16' viewBox='0 0 17 16' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g clipPath='url(#clip0_1_56)'>\n\t\t\t<path\n\t\t\t\td='M9.16669 3.33334H14.5'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path d='M9.16669 6H12.5' stroke='currentColor' strokeWidth='1.25' strokeLinecap='round' strokeLinejoin='round' />\n\t\t\t<path\n\t\t\t\td='M9.16669 10H14.5'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M9.16669 12.6667H12.5'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M5.83333 2.66666H3.16667C2.79848 2.66666 2.5 2.96513 2.5 3.33332V5.99999C2.5 6.36818 2.79848 6.66666 3.16667 6.66666H5.83333C6.20152 6.66666 6.5 6.36818 6.5 5.99999V3.33332C6.5 2.96513 6.20152 2.66666 5.83333 2.66666Z'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M5.83333 9.33334H3.16667C2.79848 9.33334 2.5 9.63182 2.5 10V12.6667C2.5 13.0349 2.79848 13.3333 3.16667 13.3333H5.83333C6.20152 13.3333 6.5 13.0349 6.5 12.6667V10C6.5 9.63182 6.20152 9.33334 5.83333 9.33334Z'\n\t\t\t\tstroke='currentColor'\n\t\t\t\tstrokeWidth='1.25'\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<clipPath id='clip0_1_56'>\n\t\t\t\t<rect width='16' height='16' fill='white' transform='translate(0.5)' />\n\t\t\t</clipPath>\n\t\t</defs>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/recents-icon.tsx",
    "content": "import {SVGProps, useId} from 'react'\n\nexport const RecentsIcon = (props: SVGProps<SVGSVGElement>) => {\n\tconst id = useId()\n\treturn (\n\t\t<svg width={15} height={16} viewBox='0 0 15 16' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t\t<g filter={`url(#filter-${id})`}>\n\t\t\t\t<path\n\t\t\t\t\td='M11.2501 1.50493C12.3814 2.15808 13.3224 3.09535 13.98 4.22394C14.6377 5.35253 14.9892 6.63332 14.9998 7.93952C15.0103 9.24571 14.6795 10.532 14.0402 11.6711C13.4008 12.8102 12.4751 13.7625 11.3546 14.4338C10.2341 15.1052 8.95764 15.4722 7.65167 15.4987C6.3457 15.5251 5.05547 15.21 3.90871 14.5845C2.76195 13.9591 1.79843 13.045 1.11351 11.9327C0.428602 10.8204 0.0460575 9.54856 0.00375013 8.24301L0 8.00001L0.00375013 7.75701C0.045753 6.46174 0.422666 5.19945 1.09774 4.09321C1.77282 2.98697 2.72302 2.07453 3.85572 1.44484C4.98841 0.815158 6.26494 0.489717 7.56085 0.500248C8.85676 0.510779 10.1278 0.856922 11.2501 1.50493ZM7.50009 3.49996C7.31639 3.49998 7.13908 3.56742 7.00181 3.68949C6.86453 3.81157 6.77683 3.97977 6.75533 4.16221L6.75008 4.24997V8.00001L6.75683 8.09826C6.77393 8.22839 6.82488 8.35175 6.90459 8.45602L6.96984 8.53102L9.21986 10.781L9.29036 10.8425C9.4219 10.9446 9.58364 11 9.75012 11C9.9166 11 10.0783 10.9446 10.2099 10.8425L10.2804 10.7803L10.3426 10.7098C10.4447 10.5783 10.5001 10.4165 10.5001 10.25C10.5001 10.0836 10.4447 9.92181 10.3426 9.79028L10.2804 9.71978L8.38389 7.8226C8.31788 7.75656 8.28487 7.72354 8.26749 7.68156C8.2501 7.63958 8.2501 7.59289 8.2501 7.49952V4.24997L8.24485 4.16221C8.22336 3.97977 8.13566 3.81157 7.99838 3.68949C7.8611 3.56742 7.68379 3.49998 7.50009 3.49996Z'\n\t\t\t\t\tfill='hsl(var(--color-brand))'\n\t\t\t\t/>\n\t\t\t\t<path\n\t\t\t\t\td='M11.2501 1.50493C12.3814 2.15808 13.3224 3.09535 13.98 4.22394C14.6377 5.35253 14.9892 6.63332 14.9998 7.93952C15.0103 9.24571 14.6795 10.532 14.0402 11.6711C13.4008 12.8102 12.4751 13.7625 11.3546 14.4338C10.2341 15.1052 8.95764 15.4722 7.65167 15.4987C6.3457 15.5251 5.05547 15.21 3.90871 14.5845C2.76195 13.9591 1.79843 13.045 1.11351 11.9327C0.428602 10.8204 0.0460575 9.54856 0.00375013 8.24301L0 8.00001L0.00375013 7.75701C0.045753 6.46174 0.422666 5.19945 1.09774 4.09321C1.77282 2.98697 2.72302 2.07453 3.85572 1.44484C4.98841 0.815158 6.26494 0.489717 7.56085 0.500248C8.85676 0.510779 10.1278 0.856922 11.2501 1.50493ZM7.50009 3.49996C7.31639 3.49998 7.13908 3.56742 7.00181 3.68949C6.86453 3.81157 6.77683 3.97977 6.75533 4.16221L6.75008 4.24997V8.00001L6.75683 8.09826C6.77393 8.22839 6.82488 8.35175 6.90459 8.45602L6.96984 8.53102L9.21986 10.781L9.29036 10.8425C9.4219 10.9446 9.58364 11 9.75012 11C9.9166 11 10.0783 10.9446 10.2099 10.8425L10.2804 10.7803L10.3426 10.7098C10.4447 10.5783 10.5001 10.4165 10.5001 10.25C10.5001 10.0836 10.4447 9.92181 10.3426 9.79028L10.2804 9.71978L8.38389 7.8226C8.31788 7.75656 8.28487 7.72354 8.26749 7.68156C8.2501 7.63958 8.2501 7.59289 8.2501 7.49952V4.24997L8.24485 4.16221C8.22336 3.97977 8.13566 3.81157 7.99838 3.68949C7.8611 3.56742 7.68379 3.49998 7.50009 3.49996Z'\n\t\t\t\t\tfill={`url(#gradient-${id})`}\n\t\t\t\t/>\n\t\t\t</g>\n\t\t\t<defs>\n\t\t\t\t<filter\n\t\t\t\t\tid={`filter-${id}`}\n\t\t\t\t\tx={-0.276309}\n\t\t\t\t\ty={0.223691}\n\t\t\t\t\twidth={15.4145}\n\t\t\t\t\theight={15.4145}\n\t\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\t>\n\t\t\t\t\t<feFlood floodOpacity={0} result='BackgroundImageFix' />\n\t\t\t\t\t<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t/>\n\t\t\t\t\t<feOffset dx={0.276309} dy={0.276309} />\n\t\t\t\t\t<feGaussianBlur stdDeviation={0.0690772} />\n\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2={-1} k3={1} />\n\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0' />\n\t\t\t\t\t<feBlend mode='normal' in2='shape' result='effect1_innerShadow_1032_6361' />\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t/>\n\t\t\t\t\t<feOffset dx={-0.276309} dy={-0.276309} />\n\t\t\t\t\t<feGaussianBlur stdDeviation={0.138154} />\n\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2={-1} k3={1} />\n\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0' />\n\t\t\t\t\t<feBlend mode='normal' in2='effect1_innerShadow_1032_6361' result='effect2_innerShadow_1032_6361' />\n\t\t\t\t</filter>\n\t\t\t\t<linearGradient id={`gradient-${id}`} x1={7.5} y1={0.5} x2={7.5} y2={15.5002} gradientUnits='userSpaceOnUse'>\n\t\t\t\t\t<stop offset={0.315} stopOpacity={0} />\n\t\t\t\t\t<stop offset={0.965} stopOpacity={0.48} />\n\t\t\t\t</linearGradient>\n\t\t\t</defs>\n\t\t</svg>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/rewind-icon.tsx",
    "content": "import {SVGProps, useId} from 'react'\n\nexport const RewindIcon = (props: SVGProps<SVGSVGElement>) => {\n\tconst id = useId()\n\treturn (\n\t\t<svg xmlns='http://www.w3.org/2000/svg' width='109' height='82' fill='none' viewBox='0 0 109 82' {...props}>\n\t\t\t<g filter={`url(#filter0_ii_${id})`}>\n\t\t\t\t<path\n\t\t\t\t\tfill='hsl(var(--color-brand))'\n\t\t\t\t\td='M37.19 24.831c.146-12.097.218-18.146 4.093-21.66C45.157-.344 51.184.173 63.239 1.206l22.31 1.913c10.421.893 15.631 1.34 18.791 4.782s3.16 8.671 3.16 19.13v27.412c0 10.47 0 15.705-3.165 19.149-3.164 3.443-8.38 3.884-18.814 4.765l-22.685 1.917c-12.308 1.04-18.462 1.56-22.351-2.058-3.89-3.618-3.816-9.793-3.669-22.144z'\n\t\t\t\t></path>\n\t\t\t\t<path\n\t\t\t\t\tfill={`url(#paint0_linear_${id})`}\n\t\t\t\t\tfillOpacity='0.75'\n\t\t\t\t\td='M37.19 24.831c.146-12.097.218-18.146 4.093-21.66C45.157-.344 51.184.173 63.239 1.206l22.31 1.913c10.421.893 15.631 1.34 18.791 4.782s3.16 8.671 3.16 19.13v27.412c0 10.47 0 15.705-3.165 19.149-3.164 3.443-8.38 3.884-18.814 4.765l-22.685 1.917c-12.308 1.04-18.462 1.56-22.351-2.058-3.89-3.618-3.816-9.793-3.669-22.144z'\n\t\t\t\t></path>\n\t\t\t</g>\n\t\t\t<path\n\t\t\t\tfill='#fff'\n\t\t\t\td='m79.31 26.79.001.001.136-.01h.28l.137.01.142.019.124.02.255.065.16.055.312.142.213.132.192.148.197.191.128.157.128.195.04.073.064.136.076.22.024.107.023.122.01.116.005.12v24.373c0 1.73-2.337 2.634-3.846 1.59l-.2-.154L64.695 42.43c-.408-.35-.653-.815-.69-1.309a1.84 1.84 0 0 1 .493-1.372l.197-.191 13.218-12.187.222-.168.183-.11.227-.11.086-.034.158-.055.256-.065.126-.02z'\n\t\t\t></path>\n\t\t\t<g filter={`url(#filter1_ii_${id})`}>\n\t\t\t\t<path\n\t\t\t\t\tfill='hsl(var(--color-brand))'\n\t\t\t\t\td='M17 25c0-3.72 0-5.58.409-7.106a12 12 0 0 1 8.485-8.485C27.42 9 29.28 9 33 9v63c-3.72 0-5.58 0-7.106-.409a12 12 0 0 1-8.485-8.485C17 61.58 17 59.72 17 56z'\n\t\t\t\t></path>\n\t\t\t\t<path\n\t\t\t\t\tfill={`url(#paint1_linear_${id})`}\n\t\t\t\t\tfillOpacity='0.75'\n\t\t\t\t\td='M17 25c0-3.72 0-5.58.409-7.106a12 12 0 0 1 8.485-8.485C27.42 9 29.28 9 33 9v63c-3.72 0-5.58 0-7.106-.409a12 12 0 0 1-8.485-8.485C17 61.58 17 59.72 17 56z'\n\t\t\t\t></path>\n\t\t\t</g>\n\t\t\t<g filter={`url(#filter2_ii_${id})`}>\n\t\t\t\t<path fill='hsl(var(--color-brand))' d='M1 31c0-6.627 5.373-12 12-12v44C6.373 63 1 57.627 1 51z'></path>\n\t\t\t\t<path\n\t\t\t\t\tfill={`url(#paint2_linear_${id})`}\n\t\t\t\t\tfillOpacity='0.75'\n\t\t\t\t\td='M1 31c0-6.627 5.373-12 12-12v44C6.373 63 1 57.627 1 51z'\n\t\t\t\t></path>\n\t\t\t</g>\n\t\t\t<defs>\n\t\t\t\t<linearGradient id={`paint0_linear_${id}`} x1='72' x2='72' y1='-1' y2='82.5' gradientUnits='userSpaceOnUse'>\n\t\t\t\t\t<stop offset='0.315' stopOpacity='0'></stop>\n\t\t\t\t\t<stop offset='0.965' stopOpacity='0.48'></stop>\n\t\t\t\t</linearGradient>\n\t\t\t\t<linearGradient id={`paint1_linear_${id}`} x1='25' x2='25' y1='9' y2='72' gradientUnits='userSpaceOnUse'>\n\t\t\t\t\t<stop offset='0.315' stopOpacity='0'></stop>\n\t\t\t\t\t<stop offset='0.965' stopOpacity='0.48'></stop>\n\t\t\t\t</linearGradient>\n\t\t\t\t<linearGradient id={`paint2_linear_${id}`} x1='7' x2='7' y1='19' y2='63' gradientUnits='userSpaceOnUse'>\n\t\t\t\t\t<stop offset='0.315' stopOpacity='0'></stop>\n\t\t\t\t\t<stop offset='0.965' stopOpacity='0.48'></stop>\n\t\t\t\t</linearGradient>\n\t\t\t\t<filter\n\t\t\t\t\tid={`filter0_ii_${id}`}\n\t\t\t\t\twidth='79.733'\n\t\t\t\t\theight='89.606'\n\t\t\t\t\tx='30.767'\n\t\t\t\t\ty='-5.563'\n\t\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\t>\n\t\t\t\t\t<feFlood floodOpacity='0' result='BackgroundImageFix'></feFlood>\n\t\t\t\t\t<feBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'></feBlend>\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t></feColorMatrix>\n\t\t\t\t\t<feOffset dx='6' dy='6'></feOffset>\n\t\t\t\t\t<feGaussianBlur stdDeviation='1.5'></feGaussianBlur>\n\t\t\t\t\t<feComposite in2='hardAlpha' k2='-1' k3='1' operator='arithmetic'></feComposite>\n\t\t\t\t\t<feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0'></feColorMatrix>\n\t\t\t\t\t<feBlend in2='shape' result='effect1_innerShadow_1498_22827'></feBlend>\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t></feColorMatrix>\n\t\t\t\t\t<feOffset dx='-6' dy='-6'></feOffset>\n\t\t\t\t\t<feGaussianBlur stdDeviation='3'></feGaussianBlur>\n\t\t\t\t\t<feComposite in2='hardAlpha' k2='-1' k3='1' operator='arithmetic'></feComposite>\n\t\t\t\t\t<feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0'></feColorMatrix>\n\t\t\t\t\t<feBlend in2='effect1_innerShadow_1498_22827' result='effect2_innerShadow_1498_22827'></feBlend>\n\t\t\t\t</filter>\n\t\t\t\t<filter\n\t\t\t\t\tid={`filter1_ii_${id}`}\n\t\t\t\t\twidth='25'\n\t\t\t\t\theight='72'\n\t\t\t\t\tx='11'\n\t\t\t\t\ty='3'\n\t\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\t>\n\t\t\t\t\t<feFlood floodOpacity='0' result='BackgroundImageFix'></feFlood>\n\t\t\t\t\t<feBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'></feBlend>\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t></feColorMatrix>\n\t\t\t\t\t<feOffset dx='6' dy='6'></feOffset>\n\t\t\t\t\t<feGaussianBlur stdDeviation='1.5'></feGaussianBlur>\n\t\t\t\t\t<feComposite in2='hardAlpha' k2='-1' k3='1' operator='arithmetic'></feComposite>\n\t\t\t\t\t<feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0'></feColorMatrix>\n\t\t\t\t\t<feBlend in2='shape' result='effect1_innerShadow_1498_22827'></feBlend>\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t></feColorMatrix>\n\t\t\t\t\t<feOffset dx='-6' dy='-6'></feOffset>\n\t\t\t\t\t<feGaussianBlur stdDeviation='3'></feGaussianBlur>\n\t\t\t\t\t<feComposite in2='hardAlpha' k2='-1' k3='1' operator='arithmetic'></feComposite>\n\t\t\t\t\t<feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0'></feColorMatrix>\n\t\t\t\t\t<feBlend in2='effect1_innerShadow_1498_22827' result='effect2_innerShadow_1498_22827'></feBlend>\n\t\t\t\t</filter>\n\t\t\t\t<filter\n\t\t\t\t\tid={`filter2_ii_${id}`}\n\t\t\t\t\twidth='21'\n\t\t\t\t\theight='53'\n\t\t\t\t\tx='-5'\n\t\t\t\t\ty='13'\n\t\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\t>\n\t\t\t\t\t<feFlood floodOpacity='0' result='BackgroundImageFix'></feFlood>\n\t\t\t\t\t<feBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape'></feBlend>\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t></feColorMatrix>\n\t\t\t\t\t<feOffset dx='6' dy='6'></feOffset>\n\t\t\t\t\t<feGaussianBlur stdDeviation='1.5'></feGaussianBlur>\n\t\t\t\t\t<feComposite in2='hardAlpha' k2='-1' k3='1' operator='arithmetic'></feComposite>\n\t\t\t\t\t<feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0'></feColorMatrix>\n\t\t\t\t\t<feBlend in2='shape' result='effect1_innerShadow_1498_22827'></feBlend>\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t></feColorMatrix>\n\t\t\t\t\t<feOffset dx='-6' dy='-6'></feOffset>\n\t\t\t\t\t<feGaussianBlur stdDeviation='3'></feGaussianBlur>\n\t\t\t\t\t<feComposite in2='hardAlpha' k2='-1' k3='1' operator='arithmetic'></feComposite>\n\t\t\t\t\t<feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0'></feColorMatrix>\n\t\t\t\t\t<feBlend in2='effect1_innerShadow_1498_22827' result='effect2_innerShadow_1498_22827'></feBlend>\n\t\t\t\t</filter>\n\t\t\t</defs>\n\t\t</svg>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/search-icon.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const SearchIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='17' height='17' viewBox='0 0 17 17' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g opacity='0.5' clipPath='url(#clip0_237_4894)'>\n\t\t\t<path\n\t\t\t\td='M12.1126 11.4107L14.968 14.2653L14.0246 15.2087L11.17 12.3533C10.1078 13.2048 8.78663 13.668 7.42529 13.666C4.11329 13.666 1.42529 10.978 1.42529 7.66602C1.42529 4.35402 4.11329 1.66602 7.42529 1.66602C10.7373 1.66602 13.4253 4.35402 13.4253 7.66602C13.4272 9.02735 12.9641 10.3485 12.1126 11.4107ZM10.7753 10.916C11.6214 10.0459 12.0939 8.87964 12.092 7.66602C12.092 5.08735 10.0033 2.99935 7.42529 2.99935C4.84663 2.99935 2.75863 5.08735 2.75863 7.66602C2.75863 10.244 4.84663 12.3327 7.42529 12.3327C8.63891 12.3346 9.80522 11.8621 10.6753 11.016L10.7753 10.916V10.916Z'\n\t\t\t\tfill='white'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<clipPath id='clip0_237_4894'>\n\t\t\t\t<rect width='16' height='16' fill='white' transform='translate(0.092041 0.333008)' />\n\t\t\t</clipPath>\n\t\t</defs>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/shared-folder-badge.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const SharedFolderBadge = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width={19} height={18} viewBox='0 0 19 18' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g clipPath='url(#clip0_1076_4406)'>\n\t\t\t<path d='M9.62488 13.5H9.63043' stroke='white' strokeWidth={1.5} strokeLinecap='round' strokeLinejoin='round' />\n\t\t\t<path\n\t\t\t\td='M7.50378 11.3784C8.06637 10.8159 8.82929 10.5 9.62478 10.5C10.4203 10.5 11.1832 10.8159 11.7458 11.3784'\n\t\t\t\tstroke='white'\n\t\t\t\tstrokeWidth={1.5}\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M5.38202 9.25747C5.93917 8.70028 6.60062 8.2583 7.3286 7.95675C8.05657 7.6552 8.83681 7.5 9.62477 7.5C10.4127 7.5 11.193 7.6552 11.9209 7.95675C12.6489 8.2583 13.3104 8.70028 13.8675 9.25747'\n\t\t\t\tstroke='white'\n\t\t\t\tstrokeWidth={1.5}\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M3.26093 7.13644C6.77542 3.62119 12.4739 3.62119 16.0109 7.13644'\n\t\t\t\tstroke='white'\n\t\t\t\tstrokeWidth={1.5}\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<clipPath id='clip0_1076_4406'>\n\t\t\t\t<rect width={18} height={18} fill='white' transform='translate(0.625)' />\n\t\t\t</clipPath>\n\t\t</defs>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/assets/trash-icon.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const TrashIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width={17} height={17} viewBox='0 0 17 17' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g clipPath='url(#clip0_237_5453)'>\n\t\t\t<path\n\t\t\t\td='M2.75867 4.99805H13.4253'\n\t\t\t\tstroke='white'\n\t\t\t\tstrokeWidth={1.33333}\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M6.75867 7.66602V11.666'\n\t\t\t\tstroke='white'\n\t\t\t\tstrokeWidth={1.33333}\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M9.42529 7.66602V11.666'\n\t\t\t\tstroke='white'\n\t\t\t\tstrokeWidth={1.33333}\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M3.42529 4.99805L4.09196 12.998C4.09196 13.3517 4.23244 13.6908 4.48248 13.9409C4.73253 14.1909 5.07167 14.3314 5.42529 14.3314H10.7586C11.1122 14.3314 11.4514 14.1909 11.7014 13.9409C11.9515 13.6908 12.092 13.3517 12.092 12.998L12.7586 4.99805'\n\t\t\t\tstroke='white'\n\t\t\t\tstrokeWidth={1.33333}\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td='M6.09204 4.9987V2.9987C6.09204 2.82189 6.16228 2.65232 6.2873 2.52729C6.41233 2.40227 6.5819 2.33203 6.75871 2.33203H9.42537C9.60219 2.33203 9.77175 2.40227 9.89678 2.52729C10.0218 2.65232 10.092 2.82189 10.092 2.9987V4.9987'\n\t\t\t\tstroke='white'\n\t\t\t\tstrokeWidth={1.33333}\n\t\t\t\tstrokeLinecap='round'\n\t\t\t\tstrokeLinejoin='round'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<clipPath id='clip0_237_5453'>\n\t\t\t\t<rect width={16} height={16} fill='white' transform='translate(0.092041 0.332031)' />\n\t\t\t</clipPath>\n\t\t</defs>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/cmdk-search-provider.tsx",
    "content": "import {CmdkSearchProviderProps} from '@/components/cmdk-providers'\nimport {CommandItem} from '@/components/ui/command'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useSearchFiles} from '@/features/files/hooks/use-search-files'\nimport {formatItemName} from '@/features/files/utils/format-filesystem-name'\nimport {t} from '@/utils/i18n'\n\n// how many max results we want to show in the command-k\nconst MAX_RESULTS = 10\n\nexport const FilesCmdkSearchProvider: React.FC<CmdkSearchProviderProps> = ({query, close}) => {\n\tconst {navigateToItem} = useNavigate()\n\tconst trimmedQuery = query.trim()\n\n\tconst {results, isLoading} = useSearchFiles({query: trimmedQuery, maxResults: MAX_RESULTS})\n\n\t// return early if there is no query\n\tif (trimmedQuery.length === 0) return null\n\n\tif (isLoading || results.length === 0) return null\n\n\treturn results.map((item) => (\n\t\t<CommandItem\n\t\t\tkey={item.path}\n\t\t\ticon={<FileItemIcon item={item} className='h-full w-full' />}\n\t\t\tvalue={item.path}\n\t\t\tonSelect={() => {\n\t\t\t\tnavigateToItem(item)\n\t\t\t\tclose()\n\t\t\t}}\n\t\t>\n\t\t\t<span>\n\t\t\t\t{formatItemName({name: item.name, maxLength: 40})} <span className='opacity-50'>{t('generic-in')} Files</span>\n\t\t\t</span>\n\t\t</CommandItem>\n\t))\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/cards/server-cards.tsx",
    "content": "import {Plus} from 'lucide-react'\n\nexport function AddManuallyCard({onClick, label}: {onClick?: () => void; label: string}) {\n\treturn (\n\t\t<div\n\t\t\tclassName='mx-auto flex h-[110px] w-[125px] flex-col items-center justify-center rounded-xl border border-dashed border-white/10 bg-white/5 p-2 transition-colors hover:border-brand hover:bg-brand/15'\n\t\t\tonClick={onClick}\n\t\t>\n\t\t\t<div className='mb-2 flex size-12 items-center justify-center'>\n\t\t\t\t<div className='flex size-12 items-center justify-center rounded-full bg-white/10'>\n\t\t\t\t\t<div className='flex size-8 items-center justify-center rounded-full bg-white/20'>\n\t\t\t\t\t\t<Plus className='size-4' />\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<span className='w-full text-center text-12 text-white/60' title={label}>\n\t\t\t\t{label}\n\t\t\t</span>\n\t\t</div>\n\t)\n}\n\nexport function ServerCard({\n\tchildren,\n\tselected = false,\n\tonClick,\n}: {\n\tchildren: React.ReactNode\n\tselected?: boolean\n\tonClick?: () => void\n}) {\n\treturn (\n\t\t<div\n\t\t\tclassName={`mx-auto flex h-[110px] w-[125px] flex-col items-center justify-center rounded-xl p-2 transition-colors ${\n\t\t\t\tselected ? 'border border-brand bg-brand/15' : 'border border-white/10 bg-white/5 hover:bg-white/10'\n\t\t\t}`}\n\t\t\tonClick={onClick}\n\t\t>\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/add-network-share-dialog/index.tsx",
    "content": "import {zodResolver} from '@hookform/resolvers/zod'\nimport {Check, ChevronDown, ChevronUp, Loader, Loader2, RotateCcw} from 'lucide-react'\nimport {AnimatePresence, motion} from 'motion/react'\nimport {startTransition, useEffect, useState} from 'react'\nimport {useForm, useFormContext} from 'react-hook-form'\nimport {z} from 'zod'\n\nimport {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'\nimport {\n\tDrawer,\n\tDrawerContent,\n\tDrawerDescription,\n\tDrawerHeader,\n\tDrawerScroller,\n\tDrawerTitle,\n} from '@/components/ui/drawer'\nimport {Form, FormControl, FormField, FormItem, FormLabel, FormMessage} from '@/components/ui/form'\nimport {Input, PasswordInput} from '@/components/ui/input'\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {BackupDeviceIcon} from '@/features/backups/components/backup-device-icon'\nimport {AddManuallyCard, ServerCard} from '@/features/files/components/cards/server-cards'\nimport {FolderIcon} from '@/features/files/components/shared/file-item-icon/folder-icon'\nimport {useNetworkStorage} from '@/features/files/hooks/use-network-storage'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {cn} from '@/lib/utils'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\n// Validation schemas\n// Step‑agnostic schema where `share` is optional so early steps validate.\n// TODO: update zod messages\nconst stepSchema = z.object({\n\thost: z.string().min(1, {message: t('files-add-network-share.host-required')}),\n\tshare: z.string().optional(),\n\tusername: z.string().min(1, {message: t('files-add-network-share.username-required')}),\n\tpassword: z.string().min(1, {message: t('files-add-network-share.password-required')}),\n})\n\n// Final submission schema where `share` must be present.\nconst submitSchema = stepSchema.extend({\n\tshare: z.string().min(1, {message: t('files-add-network-share.share-required')}),\n})\n\n// Form steps\nenum Step {\n\tDiscover = 0,\n\tCredentials = 1,\n\tSelectShare = 2,\n}\n\n// Manual mode steps\nenum ManualStep {\n\tCredentials = 0,\n\tSelectShare = 1,\n}\n\n/* ------------------------------------------------------------------ */\n/* MAIN COMPONENT                                                     */\n/* ------------------------------------------------------------------ */\nexport default function AddNetworkShareDialog(props?: {\n\t// These optional props allow us to control this dialog from other flows (e.g., backup/setup wizards).\n\t// When a share is successfully added, we extract the host from the returned mountPath and\n\t// invoke onAdded(host). Callers can then use that host to immediately select the device and\n\t// proceed without requiring an extra click.\n\topen?: boolean\n\tonOpenChange?: (open: boolean) => void\n\tsuppressNavigateOnAdd?: boolean\n\tonAdded?: (host?: string) => void\n}) {\n\tconst internalDialog = useDialogOpenProps('files-add-network-share')\n\tconst dialogProps = {\n\t\topen: props?.open ?? internalDialog.open,\n\t\tonOpenChange: props?.onOpenChange ?? internalDialog.onOpenChange,\n\t}\n\tconst isMobile = useIsMobile()\n\n\t// wizard vs manual entry\n\tconst [mode, setMode] = useState<'wizard' | 'manual'>('wizard')\n\tconst [step, setStep] = useState<Step>(Step.Discover)\n\tconst [manualStep, setManualStep] = useState<ManualStep>(ManualStep.Credentials)\n\t// Track selected host in wizard discover step separately from form values typed in manual mode\n\tconst [selectedHostWizard, setSelectedHostWizard] = useState('')\n\n\tconst form = useForm({\n\t\tresolver: zodResolver(stepSchema),\n\t\tdefaultValues: {host: '', share: '', username: '', password: ''},\n\t\tmode: 'onChange',\n\t})\n\tconst {host, share, username, password} = form.watch()\n\n\t// main network storage hook\n\tconst {\n\t\tdiscoverServers,\n\t\tdiscoveredServers: servers,\n\t\tisDiscoveringServers: isLoadingServers,\n\t\taddShare,\n\t\tisAddingShare,\n\t\tdiscoverSharesOnServer,\n\t} = useNetworkStorage({suppressNavigateOnAdd: props?.suppressNavigateOnAdd})\n\n\t// Share discovery (imperative) so we let the hook show error toast\n\t// and we just handle steps here (e.g. go back to credentials if discovery fails)\n\tconst [shares, setShares] = useState<string[]>([])\n\tconst [isLoadingShares, setIsLoadingShares] = useState(false)\n\n\t// fetch shares whenever we enter SelectShare (wizard or manual)\n\tuseEffect(() => {\n\t\tlet abort = false\n\t\tconst fetchShares = async () => {\n\t\t\tconst isSelectShareStep =\n\t\t\t\t(mode === 'wizard' && step === Step.SelectShare) || (mode === 'manual' && manualStep === ManualStep.SelectShare)\n\t\t\tif (!isSelectShareStep || !host || !username || !password) return\n\t\t\tsetIsLoadingShares(true)\n\t\t\ttry {\n\t\t\t\tconst s = await discoverSharesOnServer(host, username, password)\n\t\t\t\tif (!abort)\n\t\t\t\t\t// Render the resulting shares as a non‑urgent update to keep the spinner animation smooth\n\t\t\t\t\tstartTransition(() => {\n\t\t\t\t\t\tsetShares(s)\n\t\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\tif (!abort) {\n\t\t\t\t\tif (mode === 'wizard') {\n\t\t\t\t\t\tsetStep(Step.Credentials) // toast already handled inside hook\n\t\t\t\t\t} else {\n\t\t\t\t\t\tsetManualStep(ManualStep.Credentials)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\tif (!abort) setIsLoadingShares(false)\n\t\t\t}\n\t\t}\n\t\tfetchShares()\n\t\treturn () => {\n\t\t\tabort = true\n\t\t}\n\t}, [step, manualStep, mode, host, username, password])\n\n\t// form handlers\n\tconst resetAll = () => {\n\t\tsetMode('wizard')\n\t\tsetStep(Step.Discover)\n\t\tsetManualStep(ManualStep.Credentials)\n\t\tsetSelectedHostWizard('')\n\t\tform.reset()\n\t\tdiscoverServers()\n\t}\n\n\tuseEffect(() => {\n\t\tif (dialogProps.open) resetAll()\n\t}, [dialogProps.open])\n\n\tconst next = () => {\n\t\tform.clearErrors()\n\t\tsetStep((s) => Math.min(s + 1, Step.SelectShare))\n\t}\n\n\tconst back = () => {\n\t\tform.clearErrors()\n\t\tsetStep((s) => Math.max(s - 1, Step.Discover))\n\t}\n\n\tconst manualNext = () => {\n\t\tform.clearErrors()\n\t\tsetManualStep((s) => Math.min(s + 1, ManualStep.SelectShare))\n\t}\n\n\tconst manualBack = () => {\n\t\tform.clearErrors()\n\t\tsetManualStep((s) => Math.max(s - 1, ManualStep.Credentials))\n\t}\n\n\tconst handleSubmit = async () => {\n\t\t// Validate with the final schema before submitting\n\t\tconst parsed = submitSchema.safeParse(form.getValues())\n\t\tif (!parsed.success) return\n\n\t\ttry {\n\t\t\tconst mountPath = await addShare(parsed.data)\n\t\t\t// Extract host from the returned mountPath (e.g., \"/Network/<host>/<share>\")\n\t\t\tconst host = mountPath.split('/')[2]\n\t\t\t// Notify parent flows so they can auto-select this NAS and advance\n\t\t\tprops?.onAdded?.(host)\n\t\t\tdialogProps.onOpenChange(false)\n\t\t} catch {\n\t\t\t// the network storage hook handles toast\n\t\t}\n\t}\n\n\t// footer buttons\n\tlet footer: React.ReactNode = null\n\n\tif (mode === 'wizard') {\n\t\tswitch (step) {\n\t\t\t// Discover step\n\t\t\tcase Step.Discover:\n\t\t\t\tfooter = (\n\t\t\t\t\t<DialogFooter className={`${isMobile ? 'flex flex-col items-stretch' : 'flex items-center'} gap-2 pt-4`}>\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\t\tdisabled={!selectedHostWizard}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tform.setValue('host', selectedHostWizard)\n\t\t\t\t\t\t\t\tnext()\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('files-add-network-share.continue')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</DialogFooter>\n\t\t\t\t)\n\t\t\t\tbreak\n\n\t\t\t// Credentials step\n\t\t\tcase Step.Credentials:\n\t\t\t\tfooter = (\n\t\t\t\t\t<DialogFooter className='gap-2 pt-4'>\n\t\t\t\t\t\t<Button size='dialog' onClick={back}>\n\t\t\t\t\t\t\t{t('files-add-network-share.back')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button variant='primary' size='dialog' disabled={!(host && username && password)} onClick={next}>\n\t\t\t\t\t\t\t{t('files-add-network-share.continue')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</DialogFooter>\n\t\t\t\t)\n\t\t\t\tbreak\n\n\t\t\t// Select share step\n\t\t\tcase Step.SelectShare:\n\t\t\t\tfooter = (\n\t\t\t\t\t<DialogFooter className='gap-2 pt-4'>\n\t\t\t\t\t\t<Button size='dialog' onClick={back}>\n\t\t\t\t\t\t\t{t('files-add-network-share.back')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button variant='primary' size='dialog' disabled={!share || isAddingShare} onClick={handleSubmit}>\n\t\t\t\t\t\t\t{isAddingShare ? <Loader2 className='h-4 w-4 animate-spin' /> : t('files-add-network-share.add-share')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</DialogFooter>\n\t\t\t\t)\n\t\t\t\tbreak\n\t\t}\n\t} else {\n\t\t// Manual mode footer\n\t\tswitch (manualStep) {\n\t\t\tcase ManualStep.Credentials:\n\t\t\t\tfooter = (\n\t\t\t\t\t<DialogFooter className={`gap-2 pt-4 ${isMobile ? 'flex-col-reverse' : ''}`}>\n\t\t\t\t\t\t<Button size='dialog' onClick={() => setMode('wizard')}>\n\t\t\t\t\t\t\t{t('files-add-network-share.back')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button variant='primary' size='dialog' disabled={!(host && username && password)} onClick={manualNext}>\n\t\t\t\t\t\t\t{t('files-add-network-share.continue')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</DialogFooter>\n\t\t\t\t)\n\t\t\t\tbreak\n\t\t\tcase ManualStep.SelectShare:\n\t\t\t\tfooter = (\n\t\t\t\t\t<DialogFooter className={`gap-2 pt-4 ${isMobile ? 'flex-col-reverse' : ''}`}>\n\t\t\t\t\t\t<Button size='dialog' onClick={manualBack}>\n\t\t\t\t\t\t\t{t('files-add-network-share.back')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button variant='primary' size='dialog' disabled={!share || isAddingShare} onClick={handleSubmit}>\n\t\t\t\t\t\t\t{isAddingShare ? <Loader2 className='h-4 w-4 animate-spin' /> : t('files-add-network-share.add-share')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</DialogFooter>\n\t\t\t\t)\n\t\t\t\tbreak\n\t\t}\n\t}\n\n\tconst header = (\n\t\t<>\n\t\t\t{isMobile ? (\n\t\t\t\t<DrawerHeader>\n\t\t\t\t\t<DrawerTitle>{t('files-add-network-share.title')}</DrawerTitle>\n\t\t\t\t\t<DrawerDescription>{t('files-add-network-share.description')}</DrawerDescription>\n\t\t\t\t</DrawerHeader>\n\t\t\t) : (\n\t\t\t\t<DialogHeader>\n\t\t\t\t\t<DialogTitle>{t('files-add-network-share.title')}</DialogTitle>\n\t\t\t\t\t{/* TODO: consider adding a note to the description that mentions backups and a link to the relevant Settings element */}\n\t\t\t\t\t<DialogDescription>{t('files-add-network-share.description')}</DialogDescription>\n\t\t\t\t</DialogHeader>\n\t\t\t)}\n\t\t</>\n\t)\n\n\tconst body = (\n\t\t<div className='flex-1 overflow-x-hidden overflow-y-auto'>\n\t\t\t<AnimatePresence mode='wait'>\n\t\t\t\t{mode === 'wizard' ? (\n\t\t\t\t\t<Form {...form}>\n\t\t\t\t\t\t{/* Discover */}\n\t\t\t\t\t\t{step === Step.Discover && (\n\t\t\t\t\t\t\t<DiscoverStep\n\t\t\t\t\t\t\t\tservers={servers}\n\t\t\t\t\t\t\t\tisLoading={isLoadingServers}\n\t\t\t\t\t\t\t\tselectedHost={selectedHostWizard}\n\t\t\t\t\t\t\t\tonSelectServer={setSelectedHostWizard}\n\t\t\t\t\t\t\t\tonRetry={discoverServers}\n\t\t\t\t\t\t\t\tonManual={() => {\n\t\t\t\t\t\t\t\t\tsetMode('manual')\n\t\t\t\t\t\t\t\t\tform.clearErrors()\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{/* Credentials */}\n\t\t\t\t\t\t{step === Step.Credentials && (\n\t\t\t\t\t\t\t<motion.div key='creds' initial={{opacity: 0}} animate={{opacity: 1}} transition={{duration: 0.2}}>\n\t\t\t\t\t\t\t\t<CredentialsStep />\n\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{/* Select share */}\n\t\t\t\t\t\t{step === Step.SelectShare && (\n\t\t\t\t\t\t\t<motion.div key='shares' initial={{opacity: 0}} animate={{opacity: 1}} transition={{duration: 0.2}}>\n\t\t\t\t\t\t\t\t<SelectShareStep\n\t\t\t\t\t\t\t\t\tshares={shares}\n\t\t\t\t\t\t\t\t\tisLoading={isLoadingShares}\n\t\t\t\t\t\t\t\t\tselectedShare={share}\n\t\t\t\t\t\t\t\t\tonSelect={(s) => form.setValue('share', s)}\n\t\t\t\t\t\t\t\t\tdisabled={isAddingShare}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Form>\n\t\t\t\t) : (\n\t\t\t\t\t/* Manual mode */\n\t\t\t\t\t<Form {...form}>\n\t\t\t\t\t\t{manualStep === ManualStep.Credentials && (\n\t\t\t\t\t\t\t<motion.div key='manual-creds' initial={{opacity: 0}} animate={{opacity: 1}} transition={{duration: 0.2}}>\n\t\t\t\t\t\t\t\t<div className='space-y-4 py-2'>\n\t\t\t\t\t\t\t\t\t<p className='text-sm font-medium'>{t('files-add-network-share.enter-details-manually')}</p>\n\n\t\t\t\t\t\t\t\t\t<div className='space-y-4'>\n\t\t\t\t\t\t\t\t\t\t{(['host', 'username', 'password'] as const).map((field) => {\n\t\t\t\t\t\t\t\t\t\t\tconst placeholders = {\n\t\t\t\t\t\t\t\t\t\t\t\thost: '192.168.1.100',\n\t\t\t\t\t\t\t\t\t\t\t\tusername: t('files-add-network-share.username-placeholder'),\n\t\t\t\t\t\t\t\t\t\t\t\tpassword: '',\n\t\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\t\tconst labels = {\n\t\t\t\t\t\t\t\t\t\t\t\thost: t('files-add-network-share.host-label'),\n\t\t\t\t\t\t\t\t\t\t\t\tusername: t('files-add-network-share.username-label'),\n\t\t\t\t\t\t\t\t\t\t\t\tpassword: t('files-add-network-share.password-label'),\n\t\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t<FormField\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={field}\n\t\t\t\t\t\t\t\t\t\t\t\t\tcontrol={form.control}\n\t\t\t\t\t\t\t\t\t\t\t\t\tname={field}\n\t\t\t\t\t\t\t\t\t\t\t\t\trender={({field: f}) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<FormItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<FormLabel className='text-13 text-white/60'>{labels[field]}</FormLabel>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<FormControl>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{field === 'password' ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<PasswordInput value={f.value} onValueChange={f.onChange} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<Input type='text' placeholder={placeholders[field]} {...f} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</FormControl>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div className='relative'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<FormMessage className='absolute -top-1 left-0 text-xs' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</FormItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{manualStep === ManualStep.SelectShare && (\n\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\tkey='manual-shares'\n\t\t\t\t\t\t\t\tinitial={{opacity: 0}}\n\t\t\t\t\t\t\t\tanimate={{opacity: 1}}\n\t\t\t\t\t\t\t\ttransition={{duration: 0.2}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<SelectShareStep\n\t\t\t\t\t\t\t\t\tshares={shares}\n\t\t\t\t\t\t\t\t\tisLoading={isLoadingShares}\n\t\t\t\t\t\t\t\t\tselectedShare={share}\n\t\t\t\t\t\t\t\t\tonSelect={(s) => form.setValue('share', s)}\n\t\t\t\t\t\t\t\t\tdisabled={isAddingShare}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Form>\n\t\t\t\t)}\n\t\t\t</AnimatePresence>\n\t\t</div>\n\t)\n\n\tif (isMobile) {\n\t\treturn (\n\t\t\t<Drawer open={dialogProps.open} onOpenChange={dialogProps.onOpenChange}>\n\t\t\t\t<DrawerContent fullHeight>\n\t\t\t\t\t{header}\n\t\t\t\t\t<DrawerScroller>{body}</DrawerScroller>\n\t\t\t\t\t{footer}\n\t\t\t\t</DrawerContent>\n\t\t\t</Drawer>\n\t\t)\n\t}\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogContent className='flex min-h-[480px] flex-col'>\n\t\t\t\t{header}\n\t\t\t\t{body}\n\t\t\t\t{footer}\n\t\t\t</DialogContent>\n\t\t</Dialog>\n\t)\n}\n\n/* ------------------------------------------------------------------ */\n/* Step components (without footer)                                   */\n/* ------------------------------------------------------------------ */\nfunction DiscoverStep({\n\tservers,\n\tisLoading,\n\tselectedHost,\n\tonSelectServer,\n\tonManual,\n\tonRetry,\n}: {\n\tservers?: string[]\n\tisLoading: boolean\n\tselectedHost: string\n\tonSelectServer: (h: string) => void\n\tonManual: () => void\n\tonRetry: () => void\n}) {\n\treturn (\n\t\t<div className='grid grid-cols-[repeat(auto-fill,minmax(125px,1fr))] gap-4 py-2'>\n\t\t\t<AddManuallyCard onClick={onManual} label={t('files-add-network-share.add-manually')} />\n\n\t\t\t{isLoading ? (\n\t\t\t\t<ServerCard>\n\t\t\t\t\t<div className='mb-2 flex size-12 items-center justify-center'>\n\t\t\t\t\t\t<Loader className='size-8 animate-spin text-white/60' />\n\t\t\t\t\t</div>\n\t\t\t\t\t<span className='text-[12px] text-white/60'>{t('files-add-network-share.discovering')}</span>\n\t\t\t\t</ServerCard>\n\t\t\t) : (servers?.length ?? 0) === 0 ? (\n\t\t\t\t<ServerCard onClick={isLoading ? undefined : onRetry}>\n\t\t\t\t\t<div className='mb-2 flex size-12 items-center justify-center'>\n\t\t\t\t\t\t<div className='flex size-12 items-center justify-center rounded-full bg-white/10'>\n\t\t\t\t\t\t\t<div className='flex size-8 items-center justify-center rounded-full bg-white/20'>\n\t\t\t\t\t\t\t\t{isLoading ? (\n\t\t\t\t\t\t\t\t\t<Loader2 className='size-4 animate-spin text-white/60' />\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t<RotateCcw className='size-4' />\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<span\n\t\t\t\t\t\tclassName='w-full truncate text-center text-[12px] text-white/60'\n\t\t\t\t\t\ttitle={t('files-add-network-share.retry-discovery')}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('files-add-network-share.retry-discovery')}\n\t\t\t\t\t</span>\n\t\t\t\t</ServerCard>\n\t\t\t) : (\n\t\t\t\tservers!.map((h, index) => (\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tkey={h}\n\t\t\t\t\t\tinitial={{opacity: 0}}\n\t\t\t\t\t\tanimate={{opacity: 1}}\n\t\t\t\t\t\ttransition={{duration: 0.3, delay: index * 0.1}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<ServerCard selected={selectedHost === h} onClick={() => onSelectServer(h)}>\n\t\t\t\t\t\t\t<BackupDeviceIcon path={`/Network/${h}`} className='mb-2 size-12' />\n\t\t\t\t\t\t\t<span className='w-full truncate text-center text-[12px]' title={h}>\n\t\t\t\t\t\t\t\t{h}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</ServerCard>\n\t\t\t\t\t</motion.div>\n\t\t\t\t))\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\nfunction CredentialsStep() {\n\tconst form = useFormContext()\n\treturn (\n\t\t<div className='space-y-4 py-2'>\n\t\t\t<p className='text-sm font-medium'>\n\t\t\t\t{t('Add credentials for')} <span className='text-brand'>{form.watch('host')}</span>\n\t\t\t</p>\n\n\t\t\t{(['username', 'password'] as const).map((field) => {\n\t\t\t\tconst labels = {\n\t\t\t\t\tusername: t('files-add-network-share.username-label'),\n\t\t\t\t\tpassword: t('files-add-network-share.password-label'),\n\t\t\t\t}\n\n\t\t\t\treturn (\n\t\t\t\t\t<FormField\n\t\t\t\t\t\tkey={field}\n\t\t\t\t\t\tcontrol={form.control}\n\t\t\t\t\t\tname={field}\n\t\t\t\t\t\trender={({field: f}) => (\n\t\t\t\t\t\t\t<FormItem>\n\t\t\t\t\t\t\t\t<FormLabel className='text-13 text-white/60'>{labels[field]}</FormLabel>\n\t\t\t\t\t\t\t\t<FormControl>\n\t\t\t\t\t\t\t\t\t{field === 'password' ? (\n\t\t\t\t\t\t\t\t\t\t<PasswordInput value={f.value} onValueChange={f.onChange} />\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t<Input type='text' {...f} />\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</FormControl>\n\t\t\t\t\t\t\t\t<div className='relative'>\n\t\t\t\t\t\t\t\t\t<FormMessage className='absolute -top-1 left-0 text-xs' />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</FormItem>\n\t\t\t\t\t\t)}\n\t\t\t\t\t/>\n\t\t\t\t)\n\t\t\t})}\n\t\t</div>\n\t)\n}\n\nfunction SelectShareStep({\n\tshares,\n\tisLoading,\n\tselectedShare,\n\tonSelect,\n\tdisabled = false,\n}: {\n\tshares: string[]\n\tisLoading: boolean\n\tselectedShare?: string\n\tonSelect: (s: string) => void\n\tdisabled?: boolean\n}) {\n\tconst [manualValue, setManualValue] = useState('')\n\tconst [showManualEntry, setShowManualEntry] = useState(false)\n\n\tuseEffect(() => {\n\t\tif (selectedShare && !shares.includes(selectedShare)) {\n\t\t\tsetManualValue(selectedShare)\n\t\t\tsetShowManualEntry(true)\n\t\t}\n\t}, [selectedShare, shares])\n\n\t// Auto-select if there's only 1 share\n\tuseEffect(() => {\n\t\tif (shares.length === 1 && !selectedShare && !isLoading) {\n\t\t\tonSelect(shares[0])\n\t\t}\n\t}, [shares, selectedShare, isLoading, onSelect])\n\n\treturn (\n\t\t<div className='space-y-4 py-2'>\n\t\t\t<p className='text-sm font-medium'>{t('files-add-network-share.select-share')}</p>\n\n\t\t\t{isLoading ? (\n\t\t\t\t<div className='flex flex-col items-center justify-center space-y-3 py-8'>\n\t\t\t\t\t<Loader2 className='h-8 w-8 animate-spin text-white/60 will-change-transform' />\n\t\t\t\t\t<p className='text-sm text-white/40'>{t('files-add-network-share.retrieving-shares')}</p>\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<div className='space-y-3'>\n\t\t\t\t\t{/* Discovered shares list */}\n\t\t\t\t\t{shares.length > 0 ? (\n\t\t\t\t\t\t<ScrollArea className={cn('rounded-12 bg-white/6', shares.length > 4 && 'h-[200px]')}>\n\t\t\t\t\t\t\t<div className='divide-y divide-white/6'>\n\t\t\t\t\t\t\t\t{shares.map((s) => (\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tkey={s}\n\t\t\t\t\t\t\t\t\t\tonClick={disabled ? undefined : () => onSelect(s)}\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t'flex h-[50px] items-center gap-2 px-3 text-15 font-medium -tracking-3 transition-colors',\n\t\t\t\t\t\t\t\t\t\t\tselectedShare === s ? 'text-white' : 'hover:bg-white/5',\n\t\t\t\t\t\t\t\t\t\t\tdisabled && 'cursor-not-allowed opacity-50',\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<FolderIcon className='size-5 shrink-0 opacity-80' />\n\t\t\t\t\t\t\t\t\t\t<span className='flex-1 truncate'>{s}</span>\n\t\t\t\t\t\t\t\t\t\t{selectedShare === s && (\n\t\t\t\t\t\t\t\t\t\t\t<div className='flex size-5 shrink-0 items-center justify-center rounded-full bg-brand'>\n\t\t\t\t\t\t\t\t\t\t\t\t<Check className='size-3 text-white' />\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</ScrollArea>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<div className='flex flex-col items-center justify-center space-y-3 py-8'>\n\t\t\t\t\t\t\t<p className='text-sm text-white/40'>{t('files-add-network-share.no-shares-found')}</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* Manual entry expandable section */}\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={() => setShowManualEntry(!showManualEntry)}\n\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\tclassName='flex w-full items-center justify-between text-xs font-medium text-brand-lightest transition-opacity duration-300 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50'\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('files-add-network-share.not-seeing-share')}\n\t\t\t\t\t\t{showManualEntry ? <ChevronUp className='h-4 w-4' /> : <ChevronDown className='h-4 w-4' />}\n\t\t\t\t\t</button>\n\n\t\t\t\t\t<AnimatePresence>\n\t\t\t\t\t\t{showManualEntry && (\n\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\tinitial={{height: 0, opacity: 0}}\n\t\t\t\t\t\t\t\tanimate={{height: 'auto', opacity: 1}}\n\t\t\t\t\t\t\t\texit={{height: 0, opacity: 0}}\n\t\t\t\t\t\t\t\ttransition={{duration: 0.3}}\n\t\t\t\t\t\t\t\tclassName='overflow-hidden'\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className='space-y-4 py-2'>\n\t\t\t\t\t\t\t\t\t<FormField\n\t\t\t\t\t\t\t\t\t\tcontrol={undefined}\n\t\t\t\t\t\t\t\t\t\tname='manual-share'\n\t\t\t\t\t\t\t\t\t\trender={() => (\n\t\t\t\t\t\t\t\t\t\t\t<FormItem>\n\t\t\t\t\t\t\t\t\t\t\t\t<FormLabel className='text-13 text-white/60'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t('files-add-network-share.manual-share-help')}\n\t\t\t\t\t\t\t\t\t\t\t\t</FormLabel>\n\t\t\t\t\t\t\t\t\t\t\t\t<FormControl>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype='text'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tplaceholder={t('files-add-network-share.share-placeholder')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={manualValue}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonChange={(e) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tconst v = e.target.value\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetManualValue(v)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonSelect(v.trim())\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t</FormControl>\n\t\t\t\t\t\t\t\t\t\t\t</FormItem>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</AnimatePresence>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/external-storage-unsupported-dialog/index.tsx",
    "content": "import {TbAlertTriangleFilled} from 'react-icons/tb'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport externalStorageIcon from '@/features/files/assets/external-storage-icon.png'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nexport default function ExternalStorageUnsupportedDialog() {\n\tconst dialogProps = useDialogOpenProps('files-external-storage-unsupported')\n\n\treturn (\n\t\t<AlertDialog {...dialogProps}>\n\t\t\t<AlertDialogContent>\n\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t<AlertDialogTitle>{t('files-external-storage.unsupported.title')}</AlertDialogTitle>\n\t\t\t\t</AlertDialogHeader>\n\t\t\t\t<div className='mt-2 flex justify-center'>\n\t\t\t\t\t<div className='relative'>\n\t\t\t\t\t\t<img src={externalStorageIcon} alt={t('external-drive')} className='size-16' draggable={false} />\n\t\t\t\t\t\t<div className='absolute -top-2 -right-2'>\n\t\t\t\t\t\t\t<TbAlertTriangleFilled className='h-8 w-8 text-yellow-400' />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<AlertDialogDescription className='text-center'>\n\t\t\t\t\t{t('files-external-storage.unsupported.description')}\n\t\t\t\t</AlertDialogDescription>\n\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t<AlertDialogAction onClick={() => dialogProps.onOpenChange(false)}>{t('ok')}</AlertDialogAction>\n\t\t\t\t</AlertDialogFooter>\n\t\t\t</AlertDialogContent>\n\t\t</AlertDialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/format-drive-dialog/index.tsx",
    "content": "import {AlertOctagon} from 'lucide-react'\nimport {useEffect, useState} from 'react'\nimport {RiErrorWarningFill} from 'react-icons/ri'\n\nimport {ErrorAlert} from '@/components/ui/alert'\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {Input} from '@/components/ui/input'\nimport {Label} from '@/components/ui/label'\nimport externalStorageIcon from '@/features/files/assets/external-storage-icon.png'\nimport {useExternalStorage} from '@/features/files/hooks/use-external-storage'\nimport {cn} from '@/lib/utils'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nfunction FilesystemCard({\n\tid,\n\tname,\n\tdescription,\n\tselected,\n\tonClick,\n\tdisabled,\n}: {\n\tid: string\n\tname: string\n\tdescription: string\n\tselected: boolean\n\tonClick: () => void\n\tdisabled?: boolean\n}) {\n\treturn (\n\t\t<button\n\t\t\ttype='button'\n\t\t\tid={id}\n\t\t\tonClick={onClick}\n\t\t\tdisabled={disabled}\n\t\t\ttabIndex={-1}\n\t\t\tclassName={cn(\n\t\t\t\t'flex min-h-[80px] flex-col items-start justify-between rounded-xl border p-3 text-left transition-colors duration-100 sm:min-h-[120px] sm:p-4',\n\t\t\t\tselected ? 'border-brand bg-brand/15' : 'border-white/10 bg-white/5 hover:bg-white/10',\n\t\t\t\tdisabled && 'cursor-not-allowed opacity-50',\n\t\t\t)}\n\t\t>\n\t\t\t<div className='flex items-baseline gap-1'>\n\t\t\t\t<span className='text-16 font-medium text-white/90'>{name}</span>\n\t\t\t\t<span className='text-12 text-white/50 lowercase'>{t('files-format.filesystem')}</span>\n\t\t\t</div>\n\t\t\t<span className='text-12 text-white/70'>{description}</span>\n\t\t</button>\n\t)\n}\n\nexport default function FormatDriveDialog() {\n\tconst dialogProps = useDialogOpenProps('files-format-drive')\n\tconst {disks, formatExternalStorageDevice, isFormatting} = useExternalStorage()\n\tconst [filesystem, setFilesystem] = useState<'ext4' | 'exfat'>('ext4')\n\tconst [label, setLabel] = useState('')\n\n\tconst resetForm = () => {\n\t\tsetFilesystem('ext4')\n\t\tsetLabel('')\n\t}\n\n\t// Reset form when dialog closes\n\tuseEffect(() => {\n\t\tif (!dialogProps.open) {\n\t\t\tresetForm()\n\t\t}\n\t}, [dialogProps.open])\n\n\t// Find the drive that needs formatting from query params\n\t// The dialog is opened via ?dialog=files-format-drive&deviceId=sdc\n\tconst urlParams = new URLSearchParams(window.location.search)\n\tconst deviceId = urlParams.get('deviceId')\n\tconst drive = disks?.find((d) => d.id === deviceId)\n\n\tif (!drive || drive.isFormatting) return null\n\n\tconst requiresFormat = !drive.isMounted\n\n\tconst MAX_LABEL_LENGTH = 11\n\n\tconst handleFormat = async () => {\n\t\tawait formatExternalStorageDevice({\n\t\t\tdeviceId: drive.id,\n\t\t\tfilesystem,\n\t\t\tlabel: label || drive.name.slice(0, MAX_LABEL_LENGTH),\n\t\t})\n\t\tdialogProps.onOpenChange(false)\n\t\tresetForm()\n\t}\n\n\treturn (\n\t\t<AlertDialog {...dialogProps}>\n\t\t\t<AlertDialogContent className='max-sm:px-4'>\n\t\t\t\t<AlertDialogHeader className='max-sm:py-0'>\n\t\t\t\t\t<div className='flex flex-row items-center gap-5 sm:flex-col sm:items-start sm:gap-4'>\n\t\t\t\t\t\t<div className='relative shrink-0'>\n\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\tsrc={externalStorageIcon}\n\t\t\t\t\t\t\t\talt={t('external-drive')}\n\t\t\t\t\t\t\t\tclassName='size-14 opacity-90'\n\t\t\t\t\t\t\t\tdraggable={false}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t{requiresFormat && (\n\t\t\t\t\t\t\t\t<div className='absolute -top-2 -right-2 flex size-7 items-center justify-center rounded-full bg-[#FF9500]'>\n\t\t\t\t\t\t\t\t\t<RiErrorWarningFill className='size-6 text-black' />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className='flex min-w-0 flex-1 flex-col gap-0.5 sm:gap-2'>\n\t\t\t\t\t\t\t<AlertDialogTitle className='text-left'>\n\t\t\t\t\t\t\t\t{requiresFormat ? t('files-format.title-requires-format') : t('files-format.title')}\n\t\t\t\t\t\t\t</AlertDialogTitle>\n\t\t\t\t\t\t\t<span className='text-left text-sm text-white/70'>\n\t\t\t\t\t\t\t\t{requiresFormat\n\t\t\t\t\t\t\t\t\t? t('files-format.description-unreadable', {driveName: drive.name})\n\t\t\t\t\t\t\t\t\t: t('files-format.description', {driveName: drive.name})}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</AlertDialogHeader>\n\t\t\t\t<AlertDialogDescription className='flex flex-col gap-5 text-left'>\n\t\t\t\t\t{/* Filesystem selection */}\n\t\t\t\t\t<div className='flex flex-col gap-2'>\n\t\t\t\t\t\t<Label className='text-left text-13 text-white'>{t('files-format.filesystem-label')}</Label>\n\t\t\t\t\t\t<div className='grid gap-4 max-sm:grid-cols-1 sm:grid-cols-2'>\n\t\t\t\t\t\t\t<FilesystemCard\n\t\t\t\t\t\t\t\tid='ext4'\n\t\t\t\t\t\t\t\tname='ext4'\n\t\t\t\t\t\t\t\tdescription={t('files-format.ext4-description')}\n\t\t\t\t\t\t\t\tselected={filesystem === 'ext4'}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetFilesystem('ext4')\n\t\t\t\t\t\t\t\t\tif (label.length > MAX_LABEL_LENGTH) {\n\t\t\t\t\t\t\t\t\t\tsetLabel(label.slice(0, MAX_LABEL_LENGTH))\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tdisabled={isFormatting}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<FilesystemCard\n\t\t\t\t\t\t\t\tid='exfat'\n\t\t\t\t\t\t\t\tname='exFAT'\n\t\t\t\t\t\t\t\tdescription={t('files-format.exfat-description')}\n\t\t\t\t\t\t\t\tselected={filesystem === 'exfat'}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetFilesystem('exfat')\n\t\t\t\t\t\t\t\t\tif (label.length > MAX_LABEL_LENGTH) {\n\t\t\t\t\t\t\t\t\t\tsetLabel(label.slice(0, MAX_LABEL_LENGTH))\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tdisabled={isFormatting}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Drive label input */}\n\t\t\t\t\t<div className='flex flex-col gap-2'>\n\t\t\t\t\t\t<Label htmlFor='label' className='text-left text-13 text-white'>\n\t\t\t\t\t\t\t{t('files-format.drive-label')}\n\t\t\t\t\t\t</Label>\n\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\tid='label'\n\t\t\t\t\t\t\tvalue={label}\n\t\t\t\t\t\t\tonChange={(e) => {\n\t\t\t\t\t\t\t\tconst newValue = e.target.value\n\t\t\t\t\t\t\t\tif (newValue.length <= MAX_LABEL_LENGTH) {\n\t\t\t\t\t\t\t\t\tsetLabel(newValue)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tplaceholder={drive.name.slice(0, MAX_LABEL_LENGTH)}\n\t\t\t\t\t\t\tclassName='w-full bg-white/5'\n\t\t\t\t\t\t\tdisabled={isFormatting}\n\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\tmaxLength={MAX_LABEL_LENGTH}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t\t<ErrorAlert\n\t\t\t\t\t\ticon={AlertOctagon}\n\t\t\t\t\t\tdescription={t('files-format.description', {driveName: drive.name})}\n\t\t\t\t\t\tclassName='text-left sm:my-1'\n\t\t\t\t\t/>\n\t\t\t\t</AlertDialogDescription>\n\n\t\t\t\t<AlertDialogFooter className='md:justify-start'>\n\t\t\t\t\t<AlertDialogAction variant='destructive' className='px-6' onClick={handleFormat} disabled={isFormatting}>\n\t\t\t\t\t\t{isFormatting ? t('files-format.formatting') : t('files-format.confirm')}\n\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t<AlertDialogCancel disabled={isFormatting}>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t</AlertDialogFooter>\n\t\t\t</AlertDialogContent>\n\t\t</AlertDialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/permanently-delete-confirmation-dialog/index.tsx",
    "content": "import {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {FlameIcon} from '@/features/files/assets/flame-icon'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {useFilesOperations} from '@/features/files/hooks/use-files-operations'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport {formatItemName} from '@/features/files/utils/format-filesystem-name'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nexport default function PermanentlyDeleteConfirmationDialog() {\n\tconst dialogProps = useDialogOpenProps('files-permanently-delete-confirmation')\n\tconst {deleteSelectedItems} = useFilesOperations()\n\tconst selectedItems = useFilesStore((s) => s.selectedItems)\n\tconst needsScroll = selectedItems.length > 3\n\n\tif (selectedItems.length === 0) return null\n\n\tconst ItemsList = () => (\n\t\t<div className='flex flex-col'>\n\t\t\t{selectedItems.map((item, index) => (\n\t\t\t\t<div\n\t\t\t\t\tkey={`${item.path}-permanently-delete-confirmation`}\n\t\t\t\t\tclassName={`flex items-center gap-2 rounded-lg p-3 ${needsScroll && index % 2 === 0 ? 'bg-white/3' : ''}`}\n\t\t\t\t>\n\t\t\t\t\t<FileItemIcon item={item} className='h-8 w-8' />\n\t\t\t\t\t<span className='truncate text-12 text-white'>{formatItemName({name: item.name})}</span>\n\t\t\t\t</div>\n\t\t\t))}\n\t\t</div>\n\t)\n\n\treturn (\n\t\t<AlertDialog {...dialogProps}>\n\t\t\t<AlertDialogContent>\n\t\t\t\t<AlertDialogHeader icon={FlameIcon}>\n\t\t\t\t\t<AlertDialogTitle>\n\t\t\t\t\t\t{selectedItems.length === 1\n\t\t\t\t\t\t\t? t('files-permanently-delete.title-single')\n\t\t\t\t\t\t\t: t('files-permanently-delete.title-multiple', {count: selectedItems.length})}\n\t\t\t\t\t</AlertDialogTitle>\n\t\t\t\t\t<AlertDialogDescription className='flex flex-col gap-3'>\n\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t{selectedItems.length === 1\n\t\t\t\t\t\t\t\t? t('files-permanently-delete.description-single', {fileName: selectedItems[0].name})\n\t\t\t\t\t\t\t\t: t('files-permanently-delete.description-multiple', {count: selectedItems.length})}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{needsScroll ? (\n\t\t\t\t\t\t\t<div className='h-[200px] overflow-hidden rounded-xl bg-black/20'>\n\t\t\t\t\t\t\t\t<ScrollArea className='h-full'>\n\t\t\t\t\t\t\t\t\t<div className='p-4'>\n\t\t\t\t\t\t\t\t\t\t<ItemsList />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</ScrollArea>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<div className='rounded-xl bg-black/20'>\n\t\t\t\t\t\t\t\t<ItemsList />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</AlertDialogDescription>\n\t\t\t\t</AlertDialogHeader>\n\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t<AlertDialogAction\n\t\t\t\t\t\tvariant='destructive'\n\t\t\t\t\t\tclassName='px-6'\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tdeleteSelectedItems()\n\t\t\t\t\t\t\tdialogProps.onOpenChange(false)\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('files-permanently-delete.confirm')}\n\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t</AlertDialogFooter>\n\t\t\t</AlertDialogContent>\n\t\t</AlertDialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/share-info-dialog/index.tsx",
    "content": "import {AnimatePresence, motion} from 'motion/react'\nimport {useState} from 'react'\nimport {useSearchParams} from 'react-router-dom'\n\nimport {FadeScroller} from '@/components/fade-scroller'\nimport {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'\nimport {\n\tDrawer,\n\tDrawerContent,\n\tDrawerDescription,\n\tDrawerHeader,\n\tDrawerScroller,\n\tDrawerTitle,\n} from '@/components/ui/drawer'\nimport {PlatformInstructions} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions'\nimport {\n\tPlatform,\n\tplatforms,\n\tPlatformSelector,\n} from '@/features/files/components/dialogs/share-info-dialog/platform-selector'\nimport {ShareToggle} from '@/features/files/components/dialogs/share-info-dialog/share-toggle'\nimport {HOME_PATH} from '@/features/files/constants'\nimport {useHomeDirectoryName} from '@/features/files/hooks/use-home-directory-name'\nimport {useShares} from '@/features/files/hooks/use-shares'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nexport default function ShareInfoDialog() {\n\tconst isMobile = useIsMobile()\n\tconst homeDirectoryName = useHomeDirectoryName()\n\tconst [searchParams] = useSearchParams()\n\tconst name = searchParams.get('files-share-info-name') || ''\n\tconst path = searchParams.get('files-share-info-path') || ''\n\tconst dialogProps = useDialogOpenProps('files-share-info')\n\n\tconst {\n\t\tshares,\n\t\tsharePassword,\n\t\taddShare,\n\t\tremoveShare,\n\t\tisPathShared,\n\t\tisAddingShare,\n\t\tisRemovingShare,\n\t\tisLoadingSharesPassword,\n\t} = useShares()\n\n\tconst [selectedPlatform, setSelectedPlatform] = useState<Platform>(platforms[0])\n\n\tconst isShared = isPathShared(path) ?? false\n\tconst isSharingHome = path === HOME_PATH\n\tconst sharename = shares?.find((s) => s.path === path)?.sharename\n\n\tconst handleShareToggle = (checked: boolean) => {\n\t\tif (checked) {\n\t\t\taddShare({path})\n\t\t} else {\n\t\t\tremoveShare({path})\n\t\t}\n\t}\n\n\tconst title = isSharingHome ? t('files-share.home-title', {homeDirectoryName}) : t('files-share.regular-title')\n\tconst description = isSharingHome\n\t\t? t('files-share.home-description', {homeDirectoryName})\n\t\t: t('files-share.regular-description')\n\n\tconst smbUrl =\n\t\tselectedPlatform.id === 'windows' ? `\\\\\\\\${window.location.hostname}` : `smb://${window.location.hostname}/`\n\tconst username = 'umbrel'\n\tconst password = isLoadingSharesPassword ? '...' : sharePassword || ''\n\n\tconst content = (\n\t\t<div className='space-y-6'>\n\t\t\t<div className='flex flex-col gap-4'>\n\t\t\t\t<ShareToggle\n\t\t\t\t\tname={name}\n\t\t\t\t\tisShared={isShared}\n\t\t\t\t\tisLoading={isAddingShare || isRemovingShare}\n\t\t\t\t\tonToggle={handleShareToggle}\n\t\t\t\t/>\n\t\t\t\t{isShared && (\n\t\t\t\t\t<AnimatePresence>\n\t\t\t\t\t\t{isShared && (\n\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\tinitial={{height: 0, opacity: 0}}\n\t\t\t\t\t\t\t\tanimate={{height: 'auto', opacity: 1}}\n\t\t\t\t\t\t\t\texit={{height: 0, opacity: 0}}\n\t\t\t\t\t\t\t\ttransition={{duration: 0.3}}\n\t\t\t\t\t\t\t\tclassName='overflow-hidden'\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName='my-4 h-[1px] w-full'\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tbackground:\n\t\t\t\t\t\t\t\t\t\t\t'radial-gradient(50% 50% at 50% 50%, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 100%)',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<div className='flex flex-col gap-4'>\n\t\t\t\t\t\t\t\t\t<PlatformSelector selectedPlatform={selectedPlatform} onPlatformChange={setSelectedPlatform} />\n\t\t\t\t\t\t\t\t\t<PlatformInstructions\n\t\t\t\t\t\t\t\t\t\tplatform={selectedPlatform}\n\t\t\t\t\t\t\t\t\t\tsmbUrl={smbUrl}\n\t\t\t\t\t\t\t\t\t\tusername={username}\n\t\t\t\t\t\t\t\t\t\tpassword={password}\n\t\t\t\t\t\t\t\t\t\tname={name}\n\t\t\t\t\t\t\t\t\t\tsharename={sharename}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</AnimatePresence>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n\n\tif (isMobile) {\n\t\treturn (\n\t\t\t<Drawer {...dialogProps}>\n\t\t\t\t<DrawerContent fullHeight>\n\t\t\t\t\t<DrawerHeader>\n\t\t\t\t\t\t<DrawerTitle>{title}</DrawerTitle>\n\t\t\t\t\t\t<DrawerDescription>{description}</DrawerDescription>\n\t\t\t\t\t</DrawerHeader>\n\t\t\t\t\t<DrawerScroller>{content}</DrawerScroller>\n\t\t\t\t</DrawerContent>\n\t\t\t</Drawer>\n\t\t)\n\t}\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogContent>\n\t\t\t\t<DialogHeader>\n\t\t\t\t\t<DialogTitle>{title}</DialogTitle>\n\t\t\t\t\t<DialogDescription>{description}</DialogDescription>\n\t\t\t\t</DialogHeader>\n\t\t\t\t<FadeScroller direction='y' className='umbrel-hide-scrollbar flex-1 overflow-y-auto'>\n\t\t\t\t\t{content}\n\t\t\t\t</FadeScroller>\n\t\t\t\t<DialogFooter>\n\t\t\t\t\t<Button variant='default' onClick={dialogProps.onOpenChange.bind(null, false)}>\n\t\t\t\t\t\t<span>{t('done')}</span>\n\t\t\t\t\t</Button>\n\t\t\t\t</DialogFooter>\n\t\t\t</DialogContent>\n\t\t</Dialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/share-info-dialog/platform-instructions/index.tsx",
    "content": "import {IOSInstructions} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions/ios-instructions'\nimport {MacOSInstructions} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions/macos-instructions'\nimport {UmbrelOSInstructions} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions/umbrelos-instructions'\nimport {WindowsInstructions} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions/windows-instructions'\nimport {Platform} from '@/features/files/components/dialogs/share-info-dialog/platform-selector'\n\ninterface PlatformInstructionsProps {\n\tplatform: Platform\n\tsmbUrl: string\n\tusername: string\n\tpassword: string\n\tname: string\n\tsharename?: string\n}\n\nexport function PlatformInstructions({\n\tplatform,\n\tsmbUrl,\n\tusername,\n\tpassword,\n\tname,\n\tsharename,\n}: PlatformInstructionsProps) {\n\tif (platform.id === 'macos') {\n\t\treturn <MacOSInstructions smbUrl={smbUrl} username={username} password={password} name={name} />\n\t}\n\n\tif (platform.id === 'windows') {\n\t\treturn <WindowsInstructions smbUrl={smbUrl} username={username} password={password} />\n\t}\n\n\tif (platform.id === 'ios') {\n\t\treturn <IOSInstructions smbUrl={smbUrl} username={username} password={password} />\n\t}\n\n\tif (platform.id === 'umbrelos') {\n\t\treturn <UmbrelOSInstructions username={username} password={password} sharename={sharename} />\n\t}\n\n\treturn null\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/share-info-dialog/platform-instructions/inline-copyable-field.tsx",
    "content": "import {useState} from 'react'\nimport {MdContentCopy} from 'react-icons/md'\nimport {useCopyToClipboard} from 'react-use'\n\nimport {Tooltip, TooltipContent, TooltipTrigger} from '@/components/ui/tooltip'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\nimport {sleep} from '@/utils/misc'\n\nexport function InlineCopyableField({value, className}: {value: string; className?: string}) {\n\tconst [, copyToClipboard] = useCopyToClipboard()\n\tconst [showCopied, setShowCopied] = useState(false)\n\n\treturn (\n\t\t<span\n\t\t\tclassName={cn(\n\t\t\t\t'inline-flex items-center gap-1 rounded-sm border border-dashed border-white/5 bg-white/10 px-1.5',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t>\n\t\t\t<span className='truncate'>{value}&nbsp;</span>\n\n\t\t\t<Tooltip open={showCopied}>\n\t\t\t\t<TooltipTrigger asChild>\n\t\t\t\t\t<button\n\t\t\t\t\t\tclassName='inline-flex items-center opacity-40 transition-opacity hover:opacity-100 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/40'\n\t\t\t\t\t\tonClick={async () => {\n\t\t\t\t\t\t\tcopyToClipboard(value)\n\t\t\t\t\t\t\tsetShowCopied(true)\n\t\t\t\t\t\t\tawait sleep(1000)\n\t\t\t\t\t\t\tsetShowCopied(false)\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<MdContentCopy className='shrink-0' />\n\t\t\t\t\t</button>\n\t\t\t\t</TooltipTrigger>\n\t\t\t\t<TooltipContent>{t('clipboard.copied')}</TooltipContent>\n\t\t\t</Tooltip>\n\t\t</span>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/share-info-dialog/platform-instructions/instruction.tsx",
    "content": "import {type ReactNode} from 'react'\n\nexport function InstructionContainer({children}: {children: ReactNode}) {\n\treturn <div className='divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>{children}</div>\n}\nexport function InstructionItem({children}: {children: ReactNode}) {\n\treturn (\n\t\t<div className='flex items-center justify-between gap-3 p-3 text-12 font-medium -tracking-3'>\n\t\t\t<span>{children}</span>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/share-info-dialog/platform-instructions/ios-instructions.tsx",
    "content": "import {Trans} from 'react-i18next/TransWithoutContext'\n\nimport {InlineCopyableField} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions/inline-copyable-field'\nimport {\n\tInstructionContainer,\n\tInstructionItem,\n} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions/instruction'\nimport {t} from '@/utils/i18n'\n\ninterface IOSInstructionsProps {\n\tsmbUrl: string\n\tusername: string\n\tpassword: string\n}\n\nexport function IOSInstructions({smbUrl, username, password}: IOSInstructionsProps) {\n\treturn (\n\t\t<InstructionContainer>\n\t\t\t<InstructionItem>{t('files-share.instructions.ios.install-files')}</InstructionItem>\n\t\t\t<InstructionItem>{t('files-share.instructions.ios.tap-dots')}</InstructionItem>\n\t\t\t<InstructionItem>\n\t\t\t\t<Trans\n\t\t\t\t\ti18nKey='files-share.instructions.ios.enter-server'\n\t\t\t\t\tvalues={{smbUrl}}\n\t\t\t\t\tcomponents={{\n\t\t\t\t\t\tfield: <InlineCopyableField value={smbUrl} />,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</InstructionItem>\n\t\t\t<InstructionItem>\n\t\t\t\t<Trans\n\t\t\t\t\ti18nKey='files-share.instructions.ios.enter-username'\n\t\t\t\t\tvalues={{username}}\n\t\t\t\t\tcomponents={{\n\t\t\t\t\t\tfield: <InlineCopyableField value={username} />,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</InstructionItem>\n\t\t\t<InstructionItem>\n\t\t\t\t<Trans\n\t\t\t\t\ti18nKey='files-share.instructions.ios.enter-password'\n\t\t\t\t\tvalues={{password}}\n\t\t\t\t\tcomponents={{\n\t\t\t\t\t\tfield: <InlineCopyableField value={password} />,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</InstructionItem>\n\t\t\t<InstructionItem>{t('files-share.instructions.ios.tap-connect')}</InstructionItem>\n\t\t</InstructionContainer>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/share-info-dialog/platform-instructions/macos-instructions.tsx",
    "content": "import {ChevronDown, ChevronUp} from 'lucide-react'\nimport {AnimatePresence, motion} from 'motion/react'\nimport {useState} from 'react'\nimport {Trans} from 'react-i18next/TransWithoutContext'\n\nimport {InlineCopyableField} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions/inline-copyable-field'\nimport {\n\tInstructionContainer,\n\tInstructionItem,\n} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions/instruction'\nimport {t} from '@/utils/i18n'\n\ninterface MacOSInstructionsProps {\n\tsmbUrl: string\n\tusername: string\n\tpassword: string\n\tname: string\n}\n\nexport function MacOSInstructions({smbUrl, username, password, name}: MacOSInstructionsProps) {\n\tconst [showTimeMachine, setShowTimeMachine] = useState(false)\n\n\treturn (\n\t\t<div className='space-y-4'>\n\t\t\t<InstructionContainer>\n\t\t\t\t<InstructionItem>{t('files-share.instructions.macos.open-finder')}</InstructionItem>\n\t\t\t\t<InstructionItem>\n\t\t\t\t\t<Trans\n\t\t\t\t\t\ti18nKey='files-share.instructions.macos.enter-url'\n\t\t\t\t\t\tvalues={{smbUrl}}\n\t\t\t\t\t\tcomponents={{\n\t\t\t\t\t\t\tfield: <InlineCopyableField value={smbUrl} />,\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</InstructionItem>\n\t\t\t\t<InstructionItem>{t('files-share.instructions.macos.select-registered')}</InstructionItem>\n\t\t\t\t<InstructionItem>\n\t\t\t\t\t<Trans\n\t\t\t\t\t\ti18nKey='files-share.instructions.macos.enter-username'\n\t\t\t\t\t\tvalues={{username}}\n\t\t\t\t\t\tcomponents={{\n\t\t\t\t\t\t\tfield: <InlineCopyableField value={username} />,\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</InstructionItem>\n\t\t\t\t<InstructionItem>\n\t\t\t\t\t<Trans\n\t\t\t\t\t\ti18nKey='files-share.instructions.macos.enter-password'\n\t\t\t\t\t\tvalues={{password}}\n\t\t\t\t\t\tcomponents={{\n\t\t\t\t\t\t\tfield: <InlineCopyableField value={password} />,\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</InstructionItem>\n\t\t\t\t<InstructionItem>{t('files-share.instructions.macos.click-connect')}</InstructionItem>\n\t\t\t</InstructionContainer>\n\n\t\t\t<button\n\t\t\t\tonClick={() => setShowTimeMachine(!showTimeMachine)}\n\t\t\t\tclassName='flex w-full items-center justify-between text-xs font-medium text-brand-lightest transition-opacity duration-300 hover:opacity-80'\n\t\t\t>\n\t\t\t\t{t('files-share.instructions.macos.time-machine')}\n\t\t\t\t{showTimeMachine ? <ChevronUp className='h-4 w-4' /> : <ChevronDown className='h-4 w-4' />}\n\t\t\t</button>\n\n\t\t\t<AnimatePresence>\n\t\t\t\t{showTimeMachine && (\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tinitial={{height: 0, opacity: 0}}\n\t\t\t\t\t\tanimate={{height: 'auto', opacity: 1}}\n\t\t\t\t\t\texit={{height: 0, opacity: 0}}\n\t\t\t\t\t\ttransition={{duration: 0.3}}\n\t\t\t\t\t\tclassName='overflow-hidden'\n\t\t\t\t\t>\n\t\t\t\t\t\t<InstructionContainer>\n\t\t\t\t\t\t\t<InstructionItem>{t('files-share.instructions.macos.time-machine.follow-steps')}</InstructionItem>\n\t\t\t\t\t\t\t<InstructionItem>{t('files-share.instructions.macos.time-machine.go-settings')}</InstructionItem>\n\t\t\t\t\t\t\t<InstructionItem>{t('files-share.instructions.macos.time-machine.select-disk', {name})}</InstructionItem>\n\t\t\t\t\t\t\t<InstructionItem>{t('files-share.instructions.macos.time-machine.choose-encryption')}</InstructionItem>\n\t\t\t\t\t\t\t<InstructionItem>{t('files-share.instructions.macos.time-machine.disk-limit')}</InstructionItem>\n\t\t\t\t\t\t</InstructionContainer>\n\t\t\t\t\t</motion.div>\n\t\t\t\t)}\n\t\t\t</AnimatePresence>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/share-info-dialog/platform-instructions/umbrelos-instructions.tsx",
    "content": "import {ChevronDown, ChevronUp} from 'lucide-react'\nimport {AnimatePresence, motion} from 'motion/react'\nimport {useState} from 'react'\nimport {Trans} from 'react-i18next/TransWithoutContext'\nimport {FaPlus} from 'react-icons/fa6'\n\nimport networkIcon from '@/features/files/assets/network-icon.png'\nimport {InlineCopyableField} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions/inline-copyable-field'\nimport {\n\tInstructionContainer,\n\tInstructionItem,\n} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions/instruction'\nimport {t} from '@/utils/i18n'\n\ninterface Props {\n\tusername: string\n\tpassword: string\n\tsharename?: string\n}\n\nexport function UmbrelOSInstructions({username, password, sharename}: Props) {\n\tconst [showBackup, setShowBackup] = useState(false)\n\treturn (\n\t\t<div className='space-y-4'>\n\t\t\t<InstructionContainer>\n\t\t\t\t<InstructionItem>\n\t\t\t\t\t<Trans\n\t\t\t\t\t\ti18nKey='files-share.instructions.umbrelos.open-and-click'\n\t\t\t\t\t\tcomponents={{\n\t\t\t\t\t\t\tplus: <FaPlus className='inline-block size-3 align-middle' />,\n\t\t\t\t\t\t\tdeviceIcon: <img src={networkIcon} alt='' className='inline-block h-4 w-auto align-middle' />,\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tvalues={{deviceLabel: t('files-sidebar.network-sidebar')}}\n\t\t\t\t\t/>\n\t\t\t\t</InstructionItem>\n\t\t\t\t<InstructionItem>\n\t\t\t\t\t<Trans i18nKey='files-share.instructions.umbrelos.select-device' />\n\t\t\t\t\t{sharename ? (\n\t\t\t\t\t\t<div className='mt-1 text-[11px] text-white/60'>\n\t\t\t\t\t\t\t<Trans i18nKey='files-share.instructions.umbrelos.cant-find-note' />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : null}\n\t\t\t\t</InstructionItem>\n\t\t\t\t<InstructionItem>\n\t\t\t\t\t<Trans\n\t\t\t\t\t\ti18nKey='files-share.instructions.umbrelos.enter-username'\n\t\t\t\t\t\tcomponents={{field: <InlineCopyableField value={username} />}}\n\t\t\t\t\t/>\n\t\t\t\t</InstructionItem>\n\t\t\t\t<InstructionItem>\n\t\t\t\t\t<Trans\n\t\t\t\t\t\ti18nKey='files-share.instructions.umbrelos.enter-password'\n\t\t\t\t\t\tcomponents={{field: <InlineCopyableField value={password} />}}\n\t\t\t\t\t/>\n\t\t\t\t</InstructionItem>\n\t\t\t\t{sharename ? (\n\t\t\t\t\t<InstructionItem>\n\t\t\t\t\t\t<Trans i18nKey='files-share.instructions.umbrelos.select-sharename' values={{sharename}} />\n\t\t\t\t\t</InstructionItem>\n\t\t\t\t) : null}\n\t\t\t</InstructionContainer>\n\n\t\t\t<button\n\t\t\t\tonClick={() => setShowBackup(!showBackup)}\n\t\t\t\tclassName='flex w-full items-center justify-between text-xs font-medium text-brand-lightest transition-opacity duration-300 hover:opacity-80'\n\t\t\t>\n\t\t\t\t<Trans i18nKey='files-share.instructions.umbrelos.backup.title' />\n\t\t\t\t{showBackup ? <ChevronUp className='h-4 w-4' /> : <ChevronDown className='h-4 w-4' />}\n\t\t\t</button>\n\n\t\t\t<AnimatePresence>\n\t\t\t\t{showBackup && (\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tinitial={{height: 0, opacity: 0}}\n\t\t\t\t\t\tanimate={{height: 'auto', opacity: 1}}\n\t\t\t\t\t\texit={{height: 0, opacity: 0}}\n\t\t\t\t\t\ttransition={{duration: 0.3}}\n\t\t\t\t\t\tclassName='overflow-hidden'\n\t\t\t\t\t>\n\t\t\t\t\t\t<InstructionContainer>\n\t\t\t\t\t\t\t<InstructionItem>\n\t\t\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\t\t\ti18nKey='files-share.instructions.umbrelos.backup.follow-then-go-to'\n\t\t\t\t\t\t\t\t\tvalues={{settings: t('settings'), backups: t('backups')}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</InstructionItem>\n\t\t\t\t\t\t\t<InstructionItem>\n\t\t\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\t\t\ti18nKey='files-share.instructions.umbrelos.backup.select-add'\n\t\t\t\t\t\t\t\t\tvalues={{addUmbrelOrNas: t('backups-add-umbrel-or-nas', {defaultValue: 'Add Umbrel or NAS'})}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</InstructionItem>\n\t\t\t\t\t\t\t<InstructionItem>\n\t\t\t\t\t\t\t\t<Trans i18nKey='files-share.instructions.umbrelos.backup.select-connected' />\n\t\t\t\t\t\t\t</InstructionItem>\n\t\t\t\t\t\t\t<InstructionItem>\n\t\t\t\t\t\t\t\t<Trans i18nKey='files-share.instructions.umbrelos.backup.follow-onscreen' />\n\t\t\t\t\t\t\t</InstructionItem>\n\t\t\t\t\t\t</InstructionContainer>\n\t\t\t\t\t</motion.div>\n\t\t\t\t)}\n\t\t\t</AnimatePresence>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/share-info-dialog/platform-instructions/windows-instructions.tsx",
    "content": "import {Trans} from 'react-i18next/TransWithoutContext'\n\nimport {InlineCopyableField} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions/inline-copyable-field'\nimport {\n\tInstructionContainer,\n\tInstructionItem,\n} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions/instruction'\nimport {t} from '@/utils/i18n'\n\ninterface WindowsInstructionsProps {\n\tsmbUrl: string\n\tusername: string\n\tpassword: string\n}\n\nexport function WindowsInstructions({smbUrl, username, password}: WindowsInstructionsProps) {\n\treturn (\n\t\t<InstructionContainer>\n\t\t\t<InstructionItem>{t('files-share.instructions.windows.open-run')}</InstructionItem>\n\t\t\t<InstructionItem>\n\t\t\t\t<Trans\n\t\t\t\t\ti18nKey='files-share.instructions.windows.enter-url'\n\t\t\t\t\tvalues={{smbUrl}}\n\t\t\t\t\tcomponents={{\n\t\t\t\t\t\tfield: <InlineCopyableField value={smbUrl} />,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</InstructionItem>\n\t\t\t<InstructionItem>\n\t\t\t\t<Trans\n\t\t\t\t\ti18nKey='files-share.instructions.windows.enter-username'\n\t\t\t\t\tvalues={{username}}\n\t\t\t\t\tcomponents={{\n\t\t\t\t\t\tfield: <InlineCopyableField value={username} />,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</InstructionItem>\n\t\t\t<InstructionItem>\n\t\t\t\t<Trans\n\t\t\t\t\ti18nKey='files-share.instructions.windows.enter-password'\n\t\t\t\t\tvalues={{password}}\n\t\t\t\t\tcomponents={{\n\t\t\t\t\t\tfield: <InlineCopyableField value={password} />,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</InstructionItem>\n\t\t\t<InstructionItem>{t('files-share.instructions.windows.remember-credentials')}</InstructionItem>\n\t\t</InstructionContainer>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/share-info-dialog/platform-selector.tsx",
    "content": "import {ChevronDown} from 'lucide-react'\n\nimport {Button} from '@/components/ui/button'\nimport {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'\nimport iOsIcon from '@/features/files/assets/sharing-info-platforms/ios.png'\nimport macOsIcon from '@/features/files/assets/sharing-info-platforms/macos.png'\nimport windowsIcon from '@/features/files/assets/sharing-info-platforms/windows.png'\nimport umbrelDeviceIconActive from '@/features/files/assets/umbrel-device-icon-active.png'\nimport {t} from '@/utils/i18n'\n\nexport type Platform = {\n\tid: 'macos' | 'ios' | 'windows' | 'umbrelos'\n\tname: string\n\ticon: string\n}\n\nexport const platforms: Platform[] = [\n\t{id: 'macos', name: 'macOS', icon: macOsIcon},\n\t{id: 'windows', name: 'Windows', icon: windowsIcon},\n\t{id: 'ios', name: 'iOS', icon: iOsIcon},\n\t{id: 'umbrelos', name: 'Another Umbrel', icon: umbrelDeviceIconActive},\n]\n\ninterface PlatformSelectorProps {\n\tselectedPlatform: Platform\n\tonPlatformChange: (platform: Platform) => void\n}\n\nexport function PlatformSelector({selectedPlatform, onPlatformChange}: PlatformSelectorProps) {\n\treturn (\n\t\t<div className='flex items-center justify-between'>\n\t\t\t<span className='text-14'>{t('files-share.instructions.how-to-access')}</span>\n\t\t\t<DropdownMenu>\n\t\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t\t<Button variant='default' className='flex items-center gap-2'>\n\t\t\t\t\t\t<img src={selectedPlatform.icon} alt={selectedPlatform.name} className='h-5 w-5' />\n\t\t\t\t\t\t<span>{selectedPlatform.name}</span>\n\t\t\t\t\t\t<ChevronDown className='h-3 w-3' />\n\t\t\t\t\t</Button>\n\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t<DropdownMenuContent className='w-[200px]'>\n\t\t\t\t\t{platforms.map((platform) => (\n\t\t\t\t\t\t<DropdownMenuItem\n\t\t\t\t\t\t\tkey={platform.id}\n\t\t\t\t\t\t\tclassName='flex items-center gap-2'\n\t\t\t\t\t\t\tonClick={() => onPlatformChange(platform)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<img src={platform.icon} alt={platform.name} className='h-5 w-5' />\n\t\t\t\t\t\t\t<span>{platform.name}</span>\n\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t))}\n\t\t\t\t</DropdownMenuContent>\n\t\t\t</DropdownMenu>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/dialogs/share-info-dialog/share-toggle.tsx",
    "content": "import {Switch} from '@/components/ui/switch'\nimport {t} from '@/utils/i18n'\n\ninterface ShareToggleProps {\n\tname: string\n\tisShared: boolean\n\tisLoading: boolean\n\tonToggle: (checked: boolean) => void\n}\n\nexport function ShareToggle({name, isShared, isLoading, onToggle}: ShareToggleProps) {\n\treturn (\n\t\t<div className='divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>\n\t\t\t<div className='flex items-center justify-between gap-3 p-3 text-12 font-medium -tracking-3'>\n\t\t\t\t<span className='text-14'>{t('files-share.toggle', {name})}</span>\n\t\t\t\t<Switch checked={isShared} onCheckedChange={onToggle} disabled={isLoading} />\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/embedded/index.tsx",
    "content": "// This EmbeddedFiles component is a wrapper we use to embed the Files UI inside other features (e.g., Rewind feature).\n// It gets its own navigation state and capabilities via FilesCapabilitiesProvider (instead of the router).\n// We currently use it in Rewind feature with read-only mode and path aliasing.\n\nimport {useState} from 'react'\nimport {HiMenuAlt2} from 'react-icons/hi'\n\nimport {FileViewer} from '@/features/files/components/file-viewer'\nimport {ActionsBar} from '@/features/files/components/listing/actions-bar'\nimport {ActionsBarProvider} from '@/features/files/components/listing/actions-bar/actions-bar-context'\nimport {DirectoryListing} from '@/features/files/components/listing/directory-listing'\nimport {Sidebar} from '@/features/files/components/sidebar'\nimport {MobileSidebarWrapper} from '@/features/files/components/sidebar/mobile-sidebar-wrapper'\nimport {HOME_PATH} from '@/features/files/constants'\nimport {FilesCapabilitiesProvider} from '@/features/files/providers/files-capabilities-context'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\n\nexport function EmbeddedFiles({\n\tmode = 'read-only',\n\tinitialPath = HOME_PATH,\n\tonNavigate,\n\tclassName = '',\n\tpathAliases,\n\tcurrentPath: controlledPath,\n\texplorerScale = 1,\n}: {\n\tmode?: 'full' | 'read-only'\n\tinitialPath?: string\n\tonNavigate?: (path: string) => void\n\tclassName?: string\n\tpathAliases?: Record<string, string>\n\tcurrentPath?: string\n\texplorerScale?: number\n}) {\n\tconst [path, setPath] = useState(initialPath)\n\tconst isMobile = useIsMobile()\n\tconst [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false)\n\n\t// Always update local path state on navigation; notify external listener if provided.\n\tconst handleNavigate = (nextPath: string) => {\n\t\tif (controlledPath === undefined) setPath(nextPath)\n\t\tonNavigate?.(nextPath)\n\t}\n\n\treturn (\n\t\t<FilesCapabilitiesProvider\n\t\t\tvalue={{\n\t\t\t\tmode,\n\t\t\t\tcurrentPath: controlledPath ?? path,\n\t\t\t\tonNavigate: handleNavigate,\n\t\t\t\t// Forward optional aliasing so nested consumers (like use-navigate)\n\t\t\t\t// can transparently remap logical roots to alternate physical roots.\n\t\t\t\tpathAliases,\n\t\t\t\thiddenSidebarItems:\n\t\t\t\t\tmode === 'read-only' ? {network: true, external: true, trash: true, rewind: true} : undefined,\n\t\t\t}}\n\t\t>\n\t\t\t<div className={['grid grid-cols-1 lg:grid-cols-[188px_1fr]', className].join(' ')}>\n\t\t\t\t{/* We still render viewer so you can see past files easily (read-only safe) */}\n\t\t\t\t<FileViewer />\n\t\t\t\t{isMobile ? (\n\t\t\t\t\t<MobileSidebarWrapper isOpen={isMobileSidebarOpen} onClose={() => setIsMobileSidebarOpen(false)}>\n\t\t\t\t\t\t<Sidebar className='h-[calc(100svh-140px)]' />\n\t\t\t\t\t</MobileSidebarWrapper>\n\t\t\t\t) : (\n\t\t\t\t\t<Sidebar className='h-[calc(100vh-300px)]' />\n\t\t\t\t)}\n\t\t\t\t<div className='flex flex-col gap-3 lg:gap-6'>\n\t\t\t\t\t<ActionsBarProvider>\n\t\t\t\t\t\t{/* Mobile-only sidebar toggle */}\n\t\t\t\t\t\t{isMobile ? (\n\t\t\t\t\t\t\t<div className='flex items-center gap-3 px-2'>\n\t\t\t\t\t\t\t\t<HiMenuAlt2\n\t\t\t\t\t\t\t\t\trole='button'\n\t\t\t\t\t\t\t\t\tclassName='h-5 w-5 text-white/90'\n\t\t\t\t\t\t\t\t\tonClick={() => setIsMobileSidebarOpen(true)}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t<ActionsBar />\n\t\t\t\t\t\t{/* The marquee overlay assumes unscaled coordinates, so pass the applied explorer scale\n\t\t\t\t\t\t * (used by SnapshotCarousel) down so selection stays aligned. */}\n\t\t\t\t\t\t<DirectoryListing marqueeScale={explorerScale} />\n\t\t\t\t\t</ActionsBarProvider>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</FilesCapabilitiesProvider>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/file-viewer/audio-viewer/index.tsx",
    "content": "import {useEffect} from 'react'\n\nimport {FileSystemItem} from '@/features/files/types'\nimport {useGlobalFiles} from '@/providers/global-files'\n\ninterface AudioViewerProps {\n\titem: FileSystemItem\n}\n\nexport const AudioViewer: React.FC<AudioViewerProps> = ({item}) => {\n\tconst {setAudio} = useGlobalFiles()\n\n\t// Set the audio file in the global files provider\n\t// so it can auto-render the audio player island\n\t// we don't need to clean up because the island has a close button\n\t// and should be persisted across route changes, different file previews, etc\n\tuseEffect(() => {\n\t\tsetAudio({\n\t\t\tpath: item.path,\n\t\t\tname: item.name,\n\t\t})\n\t}, [item, setAudio])\n\n\treturn null\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/file-viewer/downloader/index.tsx",
    "content": "import {useEffect} from 'react'\nimport {RiFile2Fill} from 'react-icons/ri'\n\nimport {useFilesOperations} from '@/features/files/hooks/use-files-operations'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport {useConfirmation} from '@/providers/confirmation'\nimport {t} from '@/utils/i18n'\n\nexport default function DownloadDialog() {\n\tconst viewerItem = useFilesStore((s) => s.viewerItem)\n\tconst setViewerItem = useFilesStore((s) => s.setViewerItem)\n\tconst {downloadSelectedItems} = useFilesOperations()\n\tconst confirm = useConfirmation()\n\n\tuseEffect(() => {\n\t\tconst showConfirmation = async () => {\n\t\t\tif (!viewerItem) return\n\n\t\t\ttry {\n\t\t\t\tawait confirm({\n\t\t\t\t\ttitle: t('files-download.title', {name: viewerItem.name}),\n\t\t\t\t\tmessage: t('files-download.description'),\n\t\t\t\t\tactions: [\n\t\t\t\t\t\t{label: t('files-download.confirm'), value: 'confirm', variant: 'primary'},\n\t\t\t\t\t\t{label: t('cancel'), value: 'cancel', variant: 'default'},\n\t\t\t\t\t],\n\t\t\t\t\ticon: RiFile2Fill,\n\t\t\t\t})\n\t\t\t\tdownloadSelectedItems()\n\t\t\t} catch {\n\t\t\t\t// User cancelled or dismissed\n\t\t\t} finally {\n\t\t\t\tsetViewerItem(null)\n\t\t\t}\n\t\t}\n\n\t\tshowConfirmation()\n\t}, [viewerItem, confirm, downloadSelectedItems, setViewerItem])\n\n\treturn null\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/file-viewer/image-viewer/index.tsx",
    "content": "import {ViewerWrapper} from '@/features/files/components/file-viewer/viewer-wrapper'\nimport {FileSystemItem} from '@/features/files/types'\n\ninterface ImageViewerProps {\n\titem: FileSystemItem\n}\n\nexport default function ImageViewer({item}: ImageViewerProps) {\n\tconst previewUrl = `/api/files/view?path=${encodeURIComponent(item.path)}`\n\n\treturn (\n\t\t<ViewerWrapper>\n\t\t\t<img\n\t\t\t\tsrc={previewUrl}\n\t\t\t\talt={item.name}\n\t\t\t\tclassName='absolute top-1/2 left-1/2 max-w-[calc(100vw-40px)] -translate-x-1/2 -translate-y-1/2 object-contain md:max-h-[80%] md:max-w-[90%] md:rounded-lg'\n\t\t\t/>\n\t\t</ViewerWrapper>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/file-viewer/index.tsx",
    "content": "import {Suspense, useEffect} from 'react'\n\nimport DownloadDialog from '@/features/files/components/file-viewer/downloader'\nimport {FILE_TYPE_MAP} from '@/features/files/constants'\nimport {useIsTouchDevice} from '@/features/files/hooks/use-is-touch-device'\nimport {useListDirectory} from '@/features/files/hooks/use-list-directory'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\n\nexport const FileViewer: React.FC = () => {\n\tconst viewerItem = useFilesStore((s) => s.viewerItem)\n\tconst setViewerItem = useFilesStore((s) => s.setViewerItem)\n\tconst setSelectedItems = useFilesStore((s) => s.setSelectedItems)\n\n\tconst {currentPath} = useNavigate()\n\tconst {listing} = useListDirectory(currentPath)\n\tconst isTouchDevice = useIsTouchDevice()\n\n\t// Helper to get previewable items\n\tconst getPreviewableItems = () => {\n\t\tif (!listing) return []\n\t\treturn listing.items.filter((file) => {\n\t\t\tif (typeof file.type !== 'string') return false\n\t\t\tconst isSupported =\n\t\t\t\tfile.type.startsWith('image/') || file.type.startsWith('video/') || file.type === 'application/pdf'\n\t\t\tif (!isSupported) return false\n\t\t\tconst entry = FILE_TYPE_MAP[file.type as keyof typeof FILE_TYPE_MAP]\n\t\t\treturn Boolean(entry && entry.viewer)\n\t\t})\n\t}\n\n\t// Arrow key navigation\n\tuseEffect(() => {\n\t\tif (isTouchDevice) return\n\t\tconst handleKeys = (e: KeyboardEvent) => {\n\t\t\tconst isPrev = e.key === 'ArrowLeft' || e.key === 'ArrowUp'\n\t\t\tconst isNext = e.key === 'ArrowRight' || e.key === 'ArrowDown'\n\t\t\tif (!isPrev && !isNext) return\n\t\t\tif (!viewerItem) return\n\t\t\t// Don't intercept arrow keys when viewing video — let the video player handle seek\n\t\t\tif (viewerItem.type?.startsWith('video/')) return\n\n\t\t\tconst previewable = getPreviewableItems()\n\t\t\tif (previewable.length === 0) return\n\t\t\tconst previewItemIndex = previewable.findIndex((f) => f.path === viewerItem.path)\n\t\t\tif (previewItemIndex === -1) return\n\t\t\tlet nextItemIndex = previewItemIndex\n\t\t\tif (isPrev) {\n\t\t\t\tnextItemIndex = previewItemIndex > 0 ? previewItemIndex - 1 : previewable.length - 1\n\t\t\t} else if (isNext) {\n\t\t\t\tnextItemIndex = previewItemIndex < previewable.length - 1 ? previewItemIndex + 1 : 0\n\t\t\t}\n\t\t\te.preventDefault()\n\t\t\tif (nextItemIndex !== previewItemIndex) {\n\t\t\t\tconst nextItem = previewable[nextItemIndex]\n\t\t\t\tsetViewerItem(nextItem)\n\t\t\t\tsetSelectedItems([nextItem])\n\t\t\t}\n\t\t}\n\t\twindow.addEventListener('keydown', handleKeys)\n\t\treturn () => window.removeEventListener('keydown', handleKeys)\n\t}, [viewerItem, listing, isTouchDevice])\n\n\tif (!viewerItem || !viewerItem.type) return null\n\n\t// if there's no matching file type in the map, return download dialog\n\tif (!FILE_TYPE_MAP[viewerItem.type as keyof typeof FILE_TYPE_MAP]) return <DownloadDialog />\n\n\tconst Viewer = FILE_TYPE_MAP[viewerItem.type as keyof typeof FILE_TYPE_MAP].viewer\n\n\t// if there's no viewer for the file type, return download dialog\n\tif (!Viewer) return <DownloadDialog />\n\n\t// render the viewer\n\treturn (\n\t\t<Suspense>\n\t\t\t<Viewer item={viewerItem} />\n\t\t</Suspense>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/file-viewer/pdf-viewer/index.tsx",
    "content": "import {useEffect, useState} from 'react'\n\nimport {ViewerWrapper} from '@/features/files/components/file-viewer/viewer-wrapper'\nimport {FileSystemItem} from '@/features/files/types'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\n\ninterface PdfViewerProps {\n\titem: FileSystemItem\n}\n\nexport default function PdfViewer({item}: PdfViewerProps) {\n\tconst [dimensions, setDimensions] = useState({width: 0, height: 0})\n\tconst encodedPath = encodeURIComponent(item.path)\n\tconst downloadUrl = `/api/files/download?path=${encodedPath}`\n\tconst previewUrl = `/api/files/view?path=${encodedPath}`\n\tconst isMobile = useIsMobile()\n\n\tuseEffect(() => {\n\t\tif (isMobile) {\n\t\t\t// redirect to download page in a new tab\n\t\t\twindow.open(downloadUrl, '_blank')\n\t\t\treturn\n\t\t}\n\n\t\tconst updateDimensions = () => {\n\t\t\tconst width = window.innerWidth - 300\n\t\t\tconst height = window.innerHeight - 200\n\n\t\t\tif (width > 1024) {\n\t\t\t\tsetDimensions({width: 1024, height: 800})\n\t\t\t} else {\n\t\t\t\tsetDimensions({width, height})\n\t\t\t}\n\t\t}\n\t\tupdateDimensions()\n\t\twindow.addEventListener('resize', updateDimensions)\n\n\t\treturn () => window.removeEventListener('resize', updateDimensions)\n\t}, [])\n\n\treturn (\n\t\t<ViewerWrapper>\n\t\t\t<iframe\n\t\t\t\tsrc={previewUrl}\n\t\t\t\theight='100%'\n\t\t\t\twidth='100%'\n\t\t\t\tstyle={{\n\t\t\t\t\twidth: `${dimensions.width}px`,\n\t\t\t\t\theight: `${dimensions.height}px`,\n\t\t\t\t}}\n\t\t\t\tclassName='mx-auto block rounded-lg border-none'\n\t\t\t\ttitle={item.name}\n\t\t\t/>\n\t\t</ViewerWrapper>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/file-viewer/video-viewer/index.tsx",
    "content": "// TODO: Investigate pre-existing issue where large video files fail to play in Safari.\nimport {useEffect, useRef} from 'react'\nimport {Video} from 'react-video-kit'\n\nimport {ViewerWrapper} from '@/features/files/components/file-viewer/viewer-wrapper'\nimport {FileSystemItem} from '@/features/files/types'\n\ninterface VideoViewerProps {\n\titem: FileSystemItem\n}\n\nexport default function VideoViewer({item}: VideoViewerProps) {\n\tconst previewUrl = `/api/files/view?path=${encodeURIComponent(item.path)}`\n\tconst containerRef = useRef<HTMLDivElement>(null)\n\n\t// Ensure video is fully stopped on unmount to prevent lingering audio\n\tuseEffect(() => {\n\t\treturn () => {\n\t\t\tconst video = containerRef.current?.querySelector('video')\n\t\t\tif (video) {\n\t\t\t\tvideo.pause()\n\t\t\t\tvideo.removeAttribute('src')\n\t\t\t\tvideo.load()\n\t\t\t}\n\t\t}\n\t}, [])\n\n\treturn (\n\t\t<ViewerWrapper dontCloseOnSpacebar>\n\t\t\t<div ref={containerRef} className='bg-black'>\n\t\t\t\t<Video.Root src={previewUrl} title={item.name} autoPlay hotkeys={{scope: 'global', enabled: true}}>\n\t\t\t\t\t<Video.Media />\n\t\t\t\t\t<Video.Backdrop />\n\t\t\t\t\t<Video.Header>\n\t\t\t\t\t\t<div className='rv-w-full rv-flex'>\n\t\t\t\t\t\t\t<Video.FullscreenToggle />\n\t\t\t\t\t\t\t<Video.PipToggle />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className='rv-w-full rv-flex rv-justify-end rv-items-center rv-h-fit'>\n\t\t\t\t\t\t\t<Video.Volume.Button />\n\t\t\t\t\t\t\t<Video.Volume.Slider />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Video.Header>\n\t\t\t\t\t<Video.Center>\n\t\t\t\t\t\t<Video.SeekBack seconds={10} />\n\t\t\t\t\t\t<Video.PlayPause />\n\t\t\t\t\t\t<Video.SeekForward seconds={10} />\n\t\t\t\t\t\t<Video.Loading />\n\t\t\t\t\t</Video.Center>\n\t\t\t\t\t<Video.Footer>\n\t\t\t\t\t\t<Video.Title />\n\t\t\t\t\t\t<Video.Timeline />\n\t\t\t\t\t\t<div className='rv-flex rv-justify-between rv-w-full'>\n\t\t\t\t\t\t\t<Video.Time.Current />\n\t\t\t\t\t\t\t<Video.Time.Remaining negative />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</Video.Footer>\n\t\t\t\t</Video.Root>\n\t\t\t</div>\n\t\t</ViewerWrapper>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/file-viewer/viewer-wrapper.tsx",
    "content": "import {useEffect, useRef} from 'react'\n\nimport {useFilesStore} from '@/features/files/store/use-files-store'\n\ninterface ViewerWrapperProps {\n\tchildren: React.ReactNode\n\tdontCloseOnSpacebar?: boolean // used for video viewer, spacebar is used to play/pause\n}\n\nexport const ViewerWrapper: React.FC<ViewerWrapperProps> = ({children, dontCloseOnSpacebar}) => {\n\tconst setViewerItem = useFilesStore((s) => s.setViewerItem)\n\n\tconst wrapperRef = useRef<HTMLDivElement>(null)\n\n\tconst handleClose = () => {\n\t\tsetViewerItem(null)\n\t}\n\n\t// Handle click outside and escape key\n\tuseEffect(() => {\n\t\t// TODO: ignore clicks inside floatingislands\n\t\tconst handleClickOutside = (e: MouseEvent) => {\n\t\t\tconst isClickInViewer = wrapperRef.current?.contains(e.target as Node)\n\n\t\t\tif (!isClickInViewer) {\n\t\t\t\thandleClose()\n\t\t\t}\n\t\t}\n\n\t\tconst handleEscape = (e: KeyboardEvent) => {\n\t\t\tif (e.key === 'Escape' || (e.key === ' ' && !dontCloseOnSpacebar)) {\n\t\t\t\thandleClose()\n\t\t\t}\n\t\t}\n\n\t\tdocument.addEventListener('mousedown', handleClickOutside)\n\t\twindow.addEventListener('keydown', handleEscape)\n\n\t\treturn () => {\n\t\t\tdocument.removeEventListener('mousedown', handleClickOutside)\n\t\t\twindow.removeEventListener('keydown', handleEscape)\n\t\t}\n\t}, [])\n\n\treturn (\n\t\t<div className='absolute top-0 left-1/2 z-10 flex h-full w-full -translate-x-1/2 items-center justify-center bg-black/80'>\n\t\t\t<div ref={wrapperRef} className='p-2 md:px-10'>\n\t\t\t\t{children}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/files-dnd-wrapper/files-dnd-overlay.tsx",
    "content": "import {DragOverlay} from '@dnd-kit/core'\nimport {snapCenterToCursor} from '@dnd-kit/modifiers'\n\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport {cn} from '@/lib/utils'\n\nexport function FilesDndOverlay() {\n\tconst draggedItems = useFilesStore((s) => s.draggedItems)\n\n\tconst firstItem = draggedItems[0]\n\tconst totalItemsBeingDragged = draggedItems.length\n\tconst previewItems = draggedItems.slice(1, 4)\n\n\treturn (\n\t\t<DragOverlay dropAnimation={null} modifiers={[snapCenterToCursor]}>\n\t\t\t{\n\t\t\t\t<div className='relative z-[20390930293920] flex w-[160px] flex-col items-center gap-0'>\n\t\t\t\t\t{totalItemsBeingDragged > 1 && (\n\t\t\t\t\t\t<div className='absolute top-[-10px] right-[-10px] flex h-6 w-6 items-center justify-center rounded-full border border-white/25 bg-brand shadow-xs'>\n\t\t\t\t\t\t\t<span className='text-xs font-medium text-white'>{totalItemsBeingDragged}</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t\t<div className='flex w-full items-center gap-1.5 rounded-lg border border-brand/90 bg-brand/20 p-1.5'>\n\t\t\t\t\t\t<FileItemIcon item={firstItem} className='h-6 w-6 flex-shrink-0' />\n\t\t\t\t\t\t<span className='overflow-hidden text-12 text-ellipsis whitespace-nowrap text-white'>\n\t\t\t\t\t\t\t{firstItem?.name}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{previewItems.map((item, index) => (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tkey={`${item.path}-drag-preview`}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t'rounded-b-lg border-r border-b border-l border-brand bg-brand/20',\n\t\t\t\t\t\t\t\tindex === 0 && 'h-[0.5rem] w-[90%] opacity-75',\n\t\t\t\t\t\t\t\tindex === 1 && 'h-[0.4rem] w-[80%] opacity-50',\n\t\t\t\t\t\t\t\tindex === 2 && 'h-[0.3rem] w-[70%] opacity-25',\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t></div>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t}\n\t\t</DragOverlay>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/files-dnd-wrapper/index.tsx",
    "content": "import {CollisionDetection, DndContext, PointerSensor, rectIntersection, useSensor, useSensors} from '@dnd-kit/core'\nimport {createPortal} from 'react-dom'\n\nimport {FilesDndOverlay} from '@/features/files/components/files-dnd-wrapper/files-dnd-overlay'\nimport {useDragAndDrop} from '@/features/files/hooks/use-drag-and-drop'\nimport {useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\n\n// From: https://github.com/clauderic/dnd-kit/pull/334#issuecomment-1965708784\nconst fixCursorSnapOffset: CollisionDetection = (args) => {\n\t// Bail out if keyboard activated\n\tif (!args.pointerCoordinates) {\n\t\treturn rectIntersection(args)\n\t}\n\tconst {x, y} = args.pointerCoordinates\n\tconst {width, height} = args.collisionRect\n\tconst updated = {\n\t\t...args,\n\t\t// The collision rectangle is broken when using snapCenterToCursor. Reset\n\t\t// the collision rectangle based on pointer location and overlay size.\n\t\tcollisionRect: {\n\t\t\twidth,\n\t\t\theight,\n\t\t\tbottom: y + height / 2,\n\t\t\tleft: x - width / 2,\n\t\t\tright: x + width / 2,\n\t\t\ttop: y - height / 2,\n\t\t},\n\t}\n\treturn rectIntersection(updated)\n}\n\nexport function FilesDndWrapper({children}: {children: React.ReactNode}) {\n\tconst isReadOnly = useIsFilesReadOnly()\n\t// By adding a 8px distance, we disable the drag and drop registeration\n\t// on single/double clicks.\n\tconst sensors = useSensors(\n\t\tuseSensor(PointerSensor, {\n\t\t\tactivationConstraint: {\n\t\t\t\tdistance: 8,\n\t\t\t},\n\t\t}),\n\t)\n\n\tconst {handleDragStart, handleDragEnd} = useDragAndDrop()\n\n\treturn isReadOnly ? (\n\t\t<>{children}</>\n\t) : (\n\t\t<DndContext\n\t\t\tsensors={sensors}\n\t\t\tonDragStart={handleDragStart}\n\t\t\tonDragEnd={handleDragEnd}\n\t\t\tcollisionDetection={fixCursorSnapOffset}\n\t\t>\n\t\t\t{children}\n\n\t\t\t{/* Move the drag overlay to body so the Files app sheet doesn't cover it when dragging */}\n\t\t\t{createPortal(<FilesDndOverlay />, document.body)}\n\t\t</DndContext>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/floating-islands/audio-island/equalizer.tsx",
    "content": "import {motion} from 'motion/react'\nimport React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'\n\nconst Bar = React.memo(({height}: {height: number}) => (\n\t<motion.div\n\t\tclassName='col-span-1 mx-auto my-auto h-6 w-[2px] rounded-full bg-brand'\n\t\tanimate={{height: Math.min(24, Math.max(2, height * 40))}}\n\t\ttransition={{duration: 0.05}}\n\t/>\n))\nBar.displayName = 'Bar'\n\n// Pre-calculate frequency ranges for better performance\nconst RANGES = [\n\t[0, 200], // Deep bass (reduced range, focused on kick)\n\t[250, 500], // Low-mids (vocals, drums)\n\t[500, 2000], // Mids (vocals, guitars)\n\t[2000, 4000], // Upper-mids (vocals, cymbals)\n\t[4000, 6000], // Presence (hi-hats, effects)\n\t[6000, 16000], // Air/Brilliance (cymbals, effects)\n] as const\n\n// Memoized frequency weight calculation\nconst getFrequencyWeights = (binSize: number, frequencyBinCount: number) => {\n\tconst weights = RANGES.map(([start, end]) => {\n\t\tconst startBin = Math.floor(start / binSize)\n\t\tconst endBin = Math.min(Math.floor(end / binSize), frequencyBinCount - 1)\n\t\tconst binWeights = new Float32Array(endBin - startBin + 1)\n\n\t\tfor (let i = 0; i < binWeights.length; i++) {\n\t\t\tbinWeights[i] = Math.log2(2 + i / binWeights.length)\n\t\t}\n\n\t\treturn {startBin, endBin, weights: binWeights}\n\t})\n\n\treturn weights\n}\n\ninterface MusicEqualizerProps {\n\tisPlaying: boolean\n\tanalyserNode?: AnalyserNode\n}\n\nconst MusicEqualizerComponent = ({isPlaying, analyserNode}: MusicEqualizerProps) => {\n\tconst [levels, setLevels] = useState<number[]>(Array(6).fill(0))\n\tconst frameRef = useRef<number | undefined>(undefined)\n\tconst prevLevels = useRef<number[]>(Array(6).fill(0))\n\tconst dataRef = useRef<Uint8Array | undefined>(undefined)\n\n\t// Memoize frequency weights calculation\n\tconst frequencyWeights = useMemo(() => {\n\t\tif (!analyserNode) return null\n\t\tconst binSize = analyserNode.context.sampleRate / (2 * analyserNode.frequencyBinCount)\n\t\treturn getFrequencyWeights(binSize, analyserNode.frequencyBinCount)\n\t}, [analyserNode])\n\n\t// Memoize the level calculation function\n\tconst calculateLevels = useCallback((data: Uint8Array, weights: ReturnType<typeof getFrequencyWeights>) => {\n\t\treturn weights.map(({startBin, endBin, weights: binWeights}, index) => {\n\t\t\tlet sum = 0\n\t\t\tlet count = 0\n\n\t\t\t// Ensure we don't exceed the data array bounds\n\t\t\tconst binCount = Math.min(endBin - startBin + 1, data.length - startBin)\n\n\t\t\t// Use a more efficient loop without creating intermediate variables\n\t\t\tfor (let i = 0; i < binCount; i++) {\n\t\t\t\tconst value = data[startBin + i] / 255\n\t\t\t\tconst weight = binWeights[i]\n\n\t\t\t\t// Apply frequency-specific scaling\n\t\t\t\tconst scaledValue =\n\t\t\t\t\tindex <= 1 ? Math.pow(value, index === 0 ? 3.5 : 2.0) * (index === 0 ? 0.7 : 1.0) : Math.pow(value, 1.5)\n\n\t\t\t\tsum += scaledValue * weight\n\t\t\t\tcount += weight\n\t\t\t}\n\n\t\t\tconst instantLevel = Math.pow(sum / count, 1.2)\n\t\t\tconst smoothingFactor = index === 0 ? 0.5 : index === 1 ? 0.7 : 0.8\n\n\t\t\t// Use the previous level for smoother transitions\n\t\t\tconst prevLevel = prevLevels.current[index] || 0\n\t\t\tconst smoothedLevel = instantLevel * smoothingFactor + prevLevel * (1 - smoothingFactor)\n\t\t\tprevLevels.current[index] = smoothedLevel\n\n\t\t\treturn smoothedLevel\n\t\t})\n\t}, [])\n\n\tuseEffect(() => {\n\t\tif (!analyserNode || !isPlaying || !frequencyWeights) {\n\t\t\tif (frameRef.current) {\n\t\t\t\tcancelAnimationFrame(frameRef.current)\n\t\t\t\tframeRef.current = undefined\n\t\t\t}\n\t\t\t// Only reset levels if we're stopping playback\n\t\t\tif (!isPlaying) {\n\t\t\t\tsetLevels(Array(6).fill(0))\n\t\t\t\tprevLevels.current = Array(6).fill(0)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\t// Create data array only once\n\t\tif (!dataRef.current) {\n\t\t\tdataRef.current = new Uint8Array(analyserNode.frequencyBinCount)\n\t\t}\n\n\t\tlet lastFrameTime = performance.now()\n\t\tconst minFrameInterval = 1000 / 30 // Cap at 30 FPS\n\n\t\tconst update = () => {\n\t\t\tconst currentTime = performance.now()\n\t\t\tconst deltaTime = currentTime - lastFrameTime\n\n\t\t\t// Throttle updates to maintain consistent frame rate\n\t\t\tif (deltaTime >= minFrameInterval) {\n\t\t\t\tanalyserNode.getByteFrequencyData(dataRef.current! as Uint8Array<ArrayBuffer>)\n\t\t\t\tconst newLevels = calculateLevels(dataRef.current!, frequencyWeights)\n\t\t\t\tsetLevels(newLevels)\n\t\t\t\tlastFrameTime = currentTime\n\t\t\t}\n\n\t\t\tframeRef.current = requestAnimationFrame(update)\n\t\t}\n\n\t\tupdate()\n\n\t\treturn () => {\n\t\t\tif (frameRef.current) {\n\t\t\t\tcancelAnimationFrame(frameRef.current)\n\t\t\t}\n\t\t}\n\t}, [analyserNode, isPlaying, frequencyWeights, calculateLevels])\n\n\treturn (\n\t\t<div className='grid h-full grid-cols-6 justify-center gap-[1px] bg-transparent'>\n\t\t\t{levels.map((height, i) => (\n\t\t\t\t<Bar key={i} height={height} />\n\t\t\t))}\n\t\t</div>\n\t)\n}\n\nMusicEqualizerComponent.displayName = 'MusicEqualizer'\n\n// Memoize the entire component to prevent unnecessary re-renders\nexport const MusicEqualizer = React.memo(MusicEqualizerComponent)\n"
  },
  {
    "path": "packages/ui/src/features/files/components/floating-islands/audio-island/expanded.tsx",
    "content": "import React from 'react'\nimport {RiPauseFill, RiPlayFill} from 'react-icons/ri'\n\nimport {MusicEqualizer} from '@/features/files/components/floating-islands/audio-island/equalizer'\nimport {t} from '@/utils/i18n'\n\ninterface ExpandedContentProps {\n\tfileName: string\n\tisPlaying: boolean\n\tcurrentTime: number\n\tduration: number\n\tonTogglePlay: (e?: React.MouseEvent) => void\n\tonProgressChange: (e: React.ChangeEvent<HTMLInputElement>) => void\n\tanalyserNode?: AnalyserNode\n}\n\nconst formatTime = (time: number) => {\n\tconst minutes = Math.floor(time / 60)\n\tconst seconds = Math.floor(time % 60)\n\treturn `${minutes}:${seconds.toString().padStart(2, '0')}`\n}\n\nexport const ExpandedContent: React.FC<ExpandedContentProps> = ({\n\tfileName,\n\tisPlaying,\n\tcurrentTime,\n\tduration,\n\tonTogglePlay,\n\tonProgressChange,\n\tanalyserNode,\n}) => {\n\tconst progressPercentage = duration ? (currentTime / duration) * 100 : 0\n\n\treturn (\n\t\t<>\n\t\t\t<div className='flex justify-between py-6 pt-10'>\n\t\t\t\t<div className='my-auto ml-6 flex-1 overflow-hidden text-left'>\n\t\t\t\t\t<p className='mb-0 truncate text-sm text-white/60'>{t('files-audio-island.now-playing')}</p>\n\t\t\t\t\t<span className='text-md my-0 block truncate text-white/90'>{fileName}</span>\n\t\t\t\t</div>\n\t\t\t\t<div className='relative mt-2 mr-6'>\n\t\t\t\t\t<MusicEqualizer isPlaying={isPlaying} analyserNode={analyserNode} />\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className='mb-2 grid grid-cols-8 items-center gap-2 px-6'>\n\t\t\t\t<div className='text-left'>\n\t\t\t\t\t<p className='text-sm text-white/60'>{formatTime(currentTime)}</p>\n\t\t\t\t</div>\n\n\t\t\t\t<div className='col-span-6'>\n\t\t\t\t\t<div className='relative h-2 w-full overflow-hidden rounded-full bg-white/10'>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName='absolute top-0 left-0 h-full rounded-full bg-brand'\n\t\t\t\t\t\t\tstyle={{width: `${progressPercentage}%`}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<input\n\t\t\t\t\t\t\ttype='range'\n\t\t\t\t\t\t\tmin={0}\n\t\t\t\t\t\t\tmax={duration || 0}\n\t\t\t\t\t\t\tvalue={currentTime}\n\t\t\t\t\t\t\tonChange={onProgressChange}\n\t\t\t\t\t\t\tclassName='absolute top-0 left-0 h-full w-full opacity-0'\n\t\t\t\t\t\t\tstyle={{margin: 0}}\n\t\t\t\t\t\t\taria-label={t('files-audio-island.now-playing')}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div className='text-right'>\n\t\t\t\t\t<p className='text-sm text-white/60'>{formatTime(duration)}</p>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className='my-1 flex items-center justify-center'>\n\t\t\t\t<button\n\t\t\t\t\tonClick={onTogglePlay}\n\t\t\t\t\tclassName='flex items-center justify-center'\n\t\t\t\t\taria-label={isPlaying ? t('files-audio-island.pause') : t('files-audio-island.play')}\n\t\t\t\t>\n\t\t\t\t\t{isPlaying ? <RiPauseFill className='h-5 w-5 text-white' /> : <RiPlayFill className='h-5 w-5 text-white' />}\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/floating-islands/audio-island/index.tsx",
    "content": "import React, {useCallback, useEffect, useRef, useState} from 'react'\n\nimport {ExpandedContent} from '@/features/files/components/floating-islands/audio-island/expanded'\nimport {MinimizedContent} from '@/features/files/components/floating-islands/audio-island/minimized'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport {Island, IslandExpanded, IslandMinimized} from '@/modules/floating-island/bare-island'\nimport {useGlobalFiles} from '@/providers/global-files'\n\ninterface PlayerState {\n\tisPlaying: boolean\n\tcurrentTime: number\n\tduration: number\n}\n\n// The actual island component that will be registered\nexport function AudioIsland() {\n\tconst setViewerItem = useFilesStore((s) => s.setViewerItem)\n\n\tconst {audio, setAudio} = useGlobalFiles()\n\n\tconst {path, name} = audio\n\tconst [playerState, setPlayerState] = useState<PlayerState>({\n\t\tisPlaying: false,\n\t\tcurrentTime: 0,\n\t\tduration: 0,\n\t})\n\n\tconst audioRef = useRef<HTMLAudioElement>(null)\n\tconst audioContextRef = useRef<AudioContext | undefined>(undefined)\n\tconst analyserNodeRef = useRef<AnalyserNode | undefined>(undefined)\n\tconst sourceNodeRef = useRef<MediaElementAudioSourceNode | undefined>(undefined)\n\tconst isInitializedRef = useRef(false)\n\n\tconst downloadUrl = `/api/files/download?path=${encodeURIComponent(path || '')}`\n\tconst fileName = name ? name.split('.').slice(0, -1).join('.') : ''\n\n\t// Memoized state update to reduce re-renders\n\tconst updatePlayerState = useCallback((updates: Partial<PlayerState>) => {\n\t\tsetPlayerState((prev: PlayerState) => ({...prev, ...updates}))\n\t}, [])\n\n\t// Initialize Web Audio API with proper cleanup\n\tconst initAudioContext = useCallback(async () => {\n\t\tif (isInitializedRef.current) return\n\t\tconst audio = audioRef.current\n\t\tif (!audio) return\n\n\t\ttry {\n\t\t\tif (audioContextRef.current?.state !== 'closed') {\n\t\t\t\tawait audioContextRef.current?.close()\n\t\t\t}\n\n\t\t\taudioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)()\n\t\t\tconst context = audioContextRef.current\n\n\t\t\tanalyserNodeRef.current = context.createAnalyser()\n\t\t\tanalyserNodeRef.current.fftSize = 2048\n\n\t\t\tsourceNodeRef.current = context.createMediaElementSource(audio)\n\t\t\tsourceNodeRef.current.connect(analyserNodeRef.current)\n\t\t\tanalyserNodeRef.current.connect(context.destination)\n\n\t\t\tisInitializedRef.current = true\n\t\t} catch (error) {\n\t\t\tconsole.error('Failed to initialize audio context:', error)\n\t\t}\n\t}, [])\n\n\t// Auto-play setup with error handling\n\tuseEffect(() => {\n\t\tconst audio = audioRef.current\n\t\tif (!audio) return\n\n\t\tconst playAudio = async () => {\n\t\t\ttry {\n\t\t\t\tawait audio.play()\n\t\t\t\tupdatePlayerState({isPlaying: true})\n\t\t\t} catch (error) {\n\t\t\t\tconsole.warn('Auto-play failed:', error)\n\t\t\t\tupdatePlayerState({isPlaying: false})\n\t\t\t}\n\t\t}\n\n\t\tconst handleLoadedData = () => {\n\t\t\tplayAudio()\n\t\t}\n\n\t\taudio.addEventListener('loadeddata', handleLoadedData)\n\t\treturn () => audio.removeEventListener('loadeddata', handleLoadedData)\n\t}, [updatePlayerState])\n\n\t// Initialize Web Audio API\n\tuseEffect(() => {\n\t\tconst audio = audioRef.current\n\t\tif (!audio) return\n\n\t\tconst handleCanPlay = () => {\n\t\t\tif (audioContextRef.current?.state === 'suspended') {\n\t\t\t\taudioContextRef.current.resume()\n\t\t\t}\n\t\t\tinitAudioContext()\n\t\t}\n\n\t\taudio.addEventListener('canplay', handleCanPlay)\n\n\t\treturn () => {\n\t\t\taudio.removeEventListener('canplay', handleCanPlay)\n\t\t\tconst cleanup = async () => {\n\t\t\t\tif (sourceNodeRef.current) {\n\t\t\t\t\tsourceNodeRef.current.disconnect()\n\t\t\t\t}\n\t\t\t\tif (analyserNodeRef.current) {\n\t\t\t\t\tanalyserNodeRef.current.disconnect()\n\t\t\t\t}\n\t\t\t\tif (audioContextRef.current?.state !== 'closed') {\n\t\t\t\t\tawait audioContextRef.current?.close()\n\t\t\t\t}\n\t\t\t\tisInitializedRef.current = false\n\t\t\t}\n\t\t\tcleanup()\n\t\t}\n\t}, [initAudioContext])\n\n\tconst handleTogglePlay = useCallback(\n\t\t(e?: React.MouseEvent) => {\n\t\t\te?.stopPropagation()\n\t\t\tconst audio = audioRef.current\n\t\t\tif (!audio) return\n\n\t\t\tif (audio.paused) {\n\t\t\t\taudio\n\t\t\t\t\t.play()\n\t\t\t\t\t.then(() => updatePlayerState({isPlaying: true}))\n\t\t\t\t\t.catch((error) => {\n\t\t\t\t\t\tconsole.error('Failed to play audio:', error)\n\t\t\t\t\t\tupdatePlayerState({isPlaying: false})\n\t\t\t\t\t})\n\t\t\t} else {\n\t\t\t\taudio.pause()\n\t\t\t\tupdatePlayerState({isPlaying: false})\n\t\t\t}\n\t\t},\n\t\t[updatePlayerState],\n\t)\n\n\tconst handleProgress = useCallback(\n\t\t(e: React.ChangeEvent<HTMLInputElement>) => {\n\t\t\tconst audio = audioRef.current\n\t\t\tif (!audio) return\n\n\t\t\tconst time = parseFloat(e.target.value)\n\t\t\taudio.currentTime = time\n\t\t\tupdatePlayerState({currentTime: time})\n\t\t},\n\t\t[updatePlayerState],\n\t)\n\n\t// Handle audio time updates and events with debounced updates\n\tuseEffect(() => {\n\t\tconst audio = audioRef.current\n\t\tif (!audio) return\n\n\t\tlet timeUpdateTimeout: number\n\n\t\tconst handleTimeUpdate = () => {\n\t\t\twindow.clearTimeout(timeUpdateTimeout)\n\t\t\ttimeUpdateTimeout = window.setTimeout(() => {\n\t\t\t\tupdatePlayerState({\n\t\t\t\t\tcurrentTime: audio.currentTime,\n\t\t\t\t\tduration: audio.duration,\n\t\t\t\t})\n\t\t\t}, 100)\n\t\t}\n\n\t\tconst handleEnded = () => {\n\t\t\tupdatePlayerState({isPlaying: false})\n\t\t\taudio.currentTime = 0\n\t\t}\n\n\t\tconst handlePause = () => {\n\t\t\tupdatePlayerState({isPlaying: false})\n\t\t}\n\n\t\tconst handlePlay = () => {\n\t\t\tupdatePlayerState({isPlaying: true})\n\t\t}\n\n\t\taudio.addEventListener('timeupdate', handleTimeUpdate)\n\t\taudio.addEventListener('loadedmetadata', handleTimeUpdate)\n\t\taudio.addEventListener('ended', handleEnded)\n\t\taudio.addEventListener('pause', handlePause)\n\t\taudio.addEventListener('play', handlePlay)\n\n\t\treturn () => {\n\t\t\twindow.clearTimeout(timeUpdateTimeout)\n\t\t\taudio.removeEventListener('timeupdate', handleTimeUpdate)\n\t\t\taudio.removeEventListener('loadedmetadata', handleTimeUpdate)\n\t\t\taudio.removeEventListener('ended', handleEnded)\n\t\t\taudio.removeEventListener('pause', handlePause)\n\t\t\taudio.removeEventListener('play', handlePlay)\n\t\t}\n\t}, [updatePlayerState])\n\n\tconst onClose = () => {\n\t\tsetViewerItem(null)\n\t\tsetAudio({\n\t\t\tpath: null,\n\t\t\tname: null,\n\t\t})\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<div className='invisible absolute z-[-1]'>\n\t\t\t\t<audio ref={audioRef} src={downloadUrl} preload='auto' />\n\t\t\t</div>\n\t\t\t<Island id='audio-island' onClose={onClose}>\n\t\t\t\t<IslandMinimized>\n\t\t\t\t\t<MinimizedContent\n\t\t\t\t\t\tfileName={fileName}\n\t\t\t\t\t\tisPlaying={playerState.isPlaying}\n\t\t\t\t\t\tcurrentTime={playerState.currentTime}\n\t\t\t\t\t\tduration={playerState.duration}\n\t\t\t\t\t\tanalyserNode={analyserNodeRef.current}\n\t\t\t\t\t/>\n\t\t\t\t</IslandMinimized>\n\t\t\t\t<IslandExpanded>\n\t\t\t\t\t<ExpandedContent\n\t\t\t\t\t\tfileName={fileName}\n\t\t\t\t\t\tisPlaying={playerState.isPlaying}\n\t\t\t\t\t\tcurrentTime={playerState.currentTime}\n\t\t\t\t\t\tduration={playerState.duration}\n\t\t\t\t\t\tonTogglePlay={handleTogglePlay}\n\t\t\t\t\t\tonProgressChange={handleProgress}\n\t\t\t\t\t\tanalyserNode={analyserNodeRef.current}\n\t\t\t\t\t/>\n\t\t\t\t</IslandExpanded>\n\t\t\t</Island>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/floating-islands/audio-island/minimized.tsx",
    "content": "import React from 'react'\nimport {RiPauseFill, RiPlayFill} from 'react-icons/ri'\n\nimport {MusicEqualizer} from '@/features/files/components/floating-islands/audio-island/equalizer'\nimport {CircularProgress} from '@/features/files/components/shared/circular-progress'\n\ninterface MinimizedContentProps {\n\tfileName: string\n\tisPlaying: boolean\n\tcurrentTime: number\n\tduration: number\n\tanalyserNode?: AnalyserNode\n}\n\nexport const MinimizedContent: React.FC<MinimizedContentProps> = ({\n\tfileName,\n\tisPlaying,\n\tcurrentTime,\n\tduration,\n\tanalyserNode,\n}) => {\n\tconst progress = duration ? (currentTime / duration) * 100 : 0\n\n\treturn (\n\t\t<div className='flex h-full w-full items-center gap-2 px-2'>\n\t\t\t<CircularProgress progress={progress} size={20}>\n\t\t\t\t{isPlaying ? <RiPauseFill className='h-3 w-3' /> : <RiPlayFill className='ml-0.5 h-3 w-3' />}\n\t\t\t</CircularProgress>\n\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t<span className='block truncate text-xs text-white/90'>{fileName}</span>\n\t\t\t</div>\n\t\t\t<MusicEqualizer isPlaying={isPlaying} analyserNode={analyserNode} />\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/floating-islands/formatting-island/expanded.tsx",
    "content": "import {motion} from 'motion/react'\n\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport externalStorageIcon from '@/features/files/assets/external-storage-icon.png'\nimport {formatFilesystemSize} from '@/features/files/utils/format-filesystem-size'\nimport {t} from '@/utils/i18n'\n\ntype FormattingDevice = {\n\tid: string\n\tname: string\n\tsize: number\n}\n\nexport function ExpandedContent({devices}: {devices: FormattingDevice[]}) {\n\t// Single device - show circular spinning progress\n\tif (devices.length === 1) {\n\t\tconst device = devices[0]\n\n\t\treturn (\n\t\t\t<div className='flex size-full items-center justify-between overflow-hidden px-8 py-6'>\n\t\t\t\t{/* Left side */}\n\t\t\t\t<div className='flex min-w-0 flex-1 flex-col gap-2 pr-2'>\n\t\t\t\t\t<div className='truncate text-sm tracking-tight text-white/60'>{t('files-formatting-island.formatting')}</div>\n\t\t\t\t\t<div className='truncate text-3xl font-light tracking-tight text-white'>{`${device.name.slice(0, 9)}${device.name.length > 9 ? '...' : ''}`}</div>\n\t\t\t\t\t<div className='truncate text-sm tracking-tight text-white/60'>{formatFilesystemSize(device.size)}</div>\n\t\t\t\t</div>\n\n\t\t\t\t{/* Right side - Spinning progress indicator */}\n\t\t\t\t<motion.div\n\t\t\t\t\tclassName='relative flex items-center justify-center'\n\t\t\t\t\tinitial={{scale: 0.6, opacity: 0, rotate: -10}}\n\t\t\t\t\tanimate={{scale: 1, opacity: 1, rotate: 0}}\n\t\t\t\t\texit={{scale: 0.6, opacity: 0, rotate: 10}}\n\t\t\t\t\ttransition={{\n\t\t\t\t\t\ttype: 'spring',\n\t\t\t\t\t\tstiffness: 300,\n\t\t\t\t\t\tdamping: 20,\n\t\t\t\t\t\tdelay: 0.05,\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{/* Subtle background glow */}\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tclassName='absolute inset-0 rounded-full bg-linear-to-br from-brand/30 to-transparent'\n\t\t\t\t\t\tinitial={{scale: 0.8, opacity: 0}}\n\t\t\t\t\t\tanimate={{scale: 1, opacity: 1}}\n\t\t\t\t\t\texit={{scale: 0.8, opacity: 0}}\n\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\ttype: 'spring',\n\t\t\t\t\t\t\tstiffness: 400,\n\t\t\t\t\t\t\tdamping: 25,\n\t\t\t\t\t\t\tdelay: 0.1,\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{/* Spinning progress ring */}\n\t\t\t\t\t<motion.svg\n\t\t\t\t\t\tclassName='relative size-28'\n\t\t\t\t\t\tviewBox='0 0 112 112'\n\t\t\t\t\t\tanimate={{rotate: 360}}\n\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\trepeat: Infinity,\n\t\t\t\t\t\t\tduration: 1.0,\n\t\t\t\t\t\t\tease: 'linear',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<defs>\n\t\t\t\t\t\t\t<linearGradient id='formattingGradient' x1='0%' y1='0%' x2='100%' y2='100%'>\n\t\t\t\t\t\t\t\t<stop offset='0%' stopColor='hsl(var(--color-brand))' />\n\t\t\t\t\t\t\t\t<stop offset='100%' stopColor='hsl(var(--color-brand-lightest))' />\n\t\t\t\t\t\t\t</linearGradient>\n\t\t\t\t\t\t</defs>\n\t\t\t\t\t\t{/* Background circle */}\n\t\t\t\t\t\t<circle\n\t\t\t\t\t\t\tcx='56'\n\t\t\t\t\t\t\tcy='56'\n\t\t\t\t\t\t\tr='40'\n\t\t\t\t\t\t\tstroke='currentColor'\n\t\t\t\t\t\t\tstrokeWidth='3'\n\t\t\t\t\t\t\tfill='none'\n\t\t\t\t\t\t\tclassName='text-white/10'\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{/* Partial progress arc - clean spinner */}\n\t\t\t\t\t\t<circle\n\t\t\t\t\t\t\tcx='56'\n\t\t\t\t\t\t\tcy='56'\n\t\t\t\t\t\t\tr='40'\n\t\t\t\t\t\t\tstroke='url(#formattingGradient)'\n\t\t\t\t\t\t\tstrokeWidth='3'\n\t\t\t\t\t\t\tfill='none'\n\t\t\t\t\t\t\tstrokeDasharray='125.6 125.6'\n\t\t\t\t\t\t\tstrokeLinecap='round'\n\t\t\t\t\t\t/>\n\t\t\t\t\t</motion.svg>\n\n\t\t\t\t\t{/* Icon container */}\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tclassName='absolute inset-0 flex items-center justify-center'\n\t\t\t\t\t\tinitial={{scale: 0.7, opacity: 0}}\n\t\t\t\t\t\tanimate={{scale: 1, opacity: 1}}\n\t\t\t\t\t\texit={{scale: 0.7, opacity: 0}}\n\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\ttype: 'spring',\n\t\t\t\t\t\t\tstiffness: 350,\n\t\t\t\t\t\t\tdamping: 22,\n\t\t\t\t\t\t\tdelay: 0.2,\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\tclassName='relative rounded-full border border-white/10 bg-white/5 p-3'\n\t\t\t\t\t\t\tinitial={{scale: 0.8, opacity: 0}}\n\t\t\t\t\t\t\tanimate={{scale: 1, opacity: 1}}\n\t\t\t\t\t\t\texit={{scale: 0.8, opacity: 0}}\n\t\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\t\ttype: 'spring',\n\t\t\t\t\t\t\t\tstiffness: 400,\n\t\t\t\t\t\t\t\tdamping: 20,\n\t\t\t\t\t\t\t\tdelay: 0.25,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<img src={externalStorageIcon} alt='External Storage' className='size-11' draggable={false} />\n\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t</motion.div>\n\t\t\t\t</motion.div>\n\t\t\t</div>\n\t\t)\n\t}\n\n\t// Multiple devices - show list view with sliding progress bars\n\treturn (\n\t\t<div className='flex h-full w-full flex-col overflow-hidden py-5'>\n\t\t\t<div className='mb-4 flex items-center justify-between px-5'>\n\t\t\t\t<span className='text-xs text-white/60'>\n\t\t\t\t\t{devices.length > 1\n\t\t\t\t\t\t? t('files-formatting-island.formatting-drives', {count: devices.length})\n\t\t\t\t\t\t: t('files-formatting-island.formatting')}\n\t\t\t\t</span>\n\t\t\t</div>\n\n\t\t\t<ScrollArea className='flex-1 px-5 pb-1'>\n\t\t\t\t<div className='space-y-3'>\n\t\t\t\t\t{devices.map((device) => (\n\t\t\t\t\t\t<div key={device.id} className='flex items-center gap-3'>\n\t\t\t\t\t\t\t<img src={externalStorageIcon} alt='External Storage' className='size-7 shrink-0' draggable={false} />\n\t\t\t\t\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t\t\t\t\t<div className='flex items-center justify-between text-xs text-white/70'>\n\t\t\t\t\t\t\t\t\t<span className='truncate'>{device.name}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{/* Indeterminate progress bar with sliding animation */}\n\t\t\t\t\t\t\t\t<div className='relative mt-1 h-1 overflow-hidden rounded-full bg-white/20'>\n\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\tclassName='absolute top-0 left-0 h-full w-1/3 rounded-full bg-brand'\n\t\t\t\t\t\t\t\t\t\tanimate={{\n\t\t\t\t\t\t\t\t\t\t\tx: ['-100%', '300%'],\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\t\t\t\t\trepeat: Infinity,\n\t\t\t\t\t\t\t\t\t\t\tduration: 1.5,\n\t\t\t\t\t\t\t\t\t\t\tease: 'easeInOut',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t</ScrollArea>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/floating-islands/formatting-island/index.tsx",
    "content": "import {useExternalStorage} from '@/features/files/hooks/use-external-storage'\nimport {Island, IslandExpanded, IslandMinimized} from '@/modules/floating-island/bare-island'\n\nimport {ExpandedContent} from './expanded'\nimport {MinimizedContent} from './minimized'\n\ntype FormattingDevice = {\n\tid: string\n\tname: string\n\tsize: number\n}\n\nexport function FormattingIsland() {\n\t// Get external storage devices from hook\n\tconst {disks} = useExternalStorage()\n\n\t// Filter devices that are currently being formatted\n\tconst formattingDevices: FormattingDevice[] =\n\t\tdisks\n\t\t\t?.filter((disk) => disk.isFormatting)\n\t\t\t.map((disk) => ({\n\t\t\t\tid: disk.id,\n\t\t\t\tname: disk.name,\n\t\t\t\tsize: disk.size,\n\t\t\t})) ?? []\n\n\tconst count = formattingDevices.length\n\n\treturn (\n\t\t<Island id='formatting-island' nonDismissable>\n\t\t\t<IslandMinimized>\n\t\t\t\t<MinimizedContent count={count} />\n\t\t\t</IslandMinimized>\n\t\t\t<IslandExpanded>\n\t\t\t\t<ExpandedContent devices={formattingDevices} />\n\t\t\t</IslandExpanded>\n\t\t</Island>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/floating-islands/formatting-island/minimized.tsx",
    "content": "import {motion} from 'motion/react'\n\nimport {t} from '@/utils/i18n'\n\nexport function MinimizedContent({count}: {count: number}) {\n\treturn (\n\t\t<div className='flex size-full items-center gap-2 px-2'>\n\t\t\t<div className='relative inline-flex size-4 items-center justify-center'>\n\t\t\t\t{/* Small circular progress bar */}\n\t\t\t\t<motion.svg\n\t\t\t\t\tclassName='size-4'\n\t\t\t\t\tviewBox='0 0 16 16'\n\t\t\t\t\tanimate={{rotate: 360}}\n\t\t\t\t\ttransition={{\n\t\t\t\t\t\trepeat: Infinity,\n\t\t\t\t\t\tduration: 1.0,\n\t\t\t\t\t\tease: 'linear',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<defs>\n\t\t\t\t\t\t<linearGradient id='minimizedFormattingGradient' x1='0%' y1='0%' x2='100%' y2='100%'>\n\t\t\t\t\t\t\t<stop offset='0%' stopColor='hsl(var(--color-brand))' />\n\t\t\t\t\t\t\t<stop offset='100%' stopColor='hsl(var(--color-brand-lightest))' />\n\t\t\t\t\t\t</linearGradient>\n\t\t\t\t\t</defs>\n\t\t\t\t\t{/* Background circle */}\n\t\t\t\t\t<circle cx='8' cy='8' r='6' stroke='currentColor' strokeWidth='1.5' fill='none' className='text-white/10' />\n\t\t\t\t\t{/* Progress arc */}\n\t\t\t\t\t<circle\n\t\t\t\t\t\tcx='8'\n\t\t\t\t\t\tcy='8'\n\t\t\t\t\t\tr='6'\n\t\t\t\t\t\tstroke='url(#minimizedFormattingGradient)'\n\t\t\t\t\t\tstrokeWidth='1.5'\n\t\t\t\t\t\tfill='none'\n\t\t\t\t\t\tstrokeDasharray='18.84 18.84'\n\t\t\t\t\t\tstrokeLinecap='round'\n\t\t\t\t\t/>\n\t\t\t\t</motion.svg>\n\t\t\t</div>\n\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t<span className='block truncate text-center text-xs text-white/90'>\n\t\t\t\t\t{t('files-formatting-island.formatting')}\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t\t{/* Reserve right-side space to match other islands' layout */}\n\t\t\t<div className='flex shrink-0 items-center gap-2'>\n\t\t\t\t<span className='text-xs text-white/60'>{count}</span>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/floating-islands/operations-island/expanded.tsx",
    "content": "import {ScrollArea} from '@/components/ui/scroll-area'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {formatItemName} from '@/features/files/utils/format-filesystem-name'\nimport {formatFilesystemSize} from '@/features/files/utils/format-filesystem-size'\nimport {useGlobalFiles} from '@/providers/global-files'\nimport {t} from '@/utils/i18n'\nimport {formatNumberI18n} from '@/utils/number'\nimport {secondsToEta} from '@/utils/seconds-to-eta'\n\nexport function ExpandedContent({progress, count, speed}: {progress: number; count: number; speed: number}) {\n\tconst {operations} = useGlobalFiles()\n\n\t// Sort operations so that items with higher progress appear first\n\tconst operationsSorted = [...operations].sort((a, b) => {\n\t\t// Treat missing values as 0\n\t\treturn (b.percent ?? 0) - (a.percent ?? 0)\n\t})\n\n\treturn (\n\t\t<div className='flex h-full w-full flex-col overflow-hidden py-5'>\n\t\t\t<div className='mb-4 flex items-center justify-between px-5'>\n\t\t\t\t<span className='text-xs text-white/60'>\n\t\t\t\t\t{t('files-listing.item-count', {formattedCount: formatNumberI18n({n: count, showDecimals: false}), count})}{' '}\n\t\t\t\t\t&bull; {progress}%\n\t\t\t\t</span>\n\t\t\t\t<span className='text-xs text-white/60'>{formatFilesystemSize(speed)}/s</span>\n\t\t\t</div>\n\n\t\t\t<ScrollArea className='flex-1 px-5 pb-2'>\n\t\t\t\t<div className='space-y-3'>\n\t\t\t\t\t{operationsSorted.map((operation) => {\n\t\t\t\t\t\tconst parts = operation.destinationPath.split('/')\n\t\t\t\t\t\tconst destinationFolderName = parts.length >= 2 ? parts[parts.length - 2] : parts[0]\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={`${operation.file.path}-${operation.destinationPath}-${operation.type}`}\n\t\t\t\t\t\t\t\tclassName='flex items-center gap-2'\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className='flex-shrink-0'>\n\t\t\t\t\t\t\t\t\t<FileItemIcon item={operation.file} className='h-7 w-7' />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t\t\t\t\t\t<div className='mb-1 flex items-center justify-between gap-2'>\n\t\t\t\t\t\t\t\t\t\t{operation.type === 'copy' && (\n\t\t\t\t\t\t\t\t\t\t\t<span className='block max-w-[16rem] text-xs whitespace-nowrap text-white/90'>\n\t\t\t\t\t\t\t\t\t\t\t\t{operation.file.path.startsWith('/Backups/') ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='text-white/60'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t('files-operations-island.restoring', {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tfrom: formatItemName({name: operation.file.name, maxLength: 12}),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tto: formatItemName({name: destinationFolderName, maxLength: 12}),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='text-white/60'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t('files-operations-island.copying', {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tfrom: formatItemName({name: operation.file.name, maxLength: 12}),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tto: formatItemName({name: destinationFolderName, maxLength: 12}),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{operation.type === 'move' && (\n\t\t\t\t\t\t\t\t\t\t\t<span className='block max-w-[16rem] text-xs whitespace-nowrap text-white/90'>\n\t\t\t\t\t\t\t\t\t\t\t\t{t('files-operations-island.moving', {\n\t\t\t\t\t\t\t\t\t\t\t\t\tfrom: formatItemName({name: operation.file.name, maxLength: 12}),\n\t\t\t\t\t\t\t\t\t\t\t\t\tto: formatItemName({name: destinationFolderName, maxLength: 12}),\n\t\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t<span className='flex-shrink-0 text-right text-xs text-white/60'>\n\t\t\t\t\t\t\t\t\t\t\t{secondsToEta(operation.secondsRemaining)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className='relative h-1 overflow-hidden rounded-full bg-white/20'>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName='transition-w absolute top-0 left-0 h-full rounded-full bg-brand duration-300'\n\t\t\t\t\t\t\t\t\t\t\tstyle={{width: `${operation.percent}%`}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</div>\n\t\t\t</ScrollArea>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/floating-islands/operations-island/index.tsx",
    "content": "import {ExpandedContent} from '@/features/files/components/floating-islands/operations-island/expanded'\nimport {MinimizedContent} from '@/features/files/components/floating-islands/operations-island/minimized'\nimport {Island, IslandExpanded, IslandMinimized} from '@/modules/floating-island/bare-island'\nimport {useGlobalFiles} from '@/providers/global-files'\nimport {secondsToEta} from '@/utils/seconds-to-eta'\n\nexport function OperationsIsland() {\n\tconst {operations} = useGlobalFiles()\n\n\tlet totalPercent = 0\n\tlet maxSecondsRemaining = 0\n\tlet totalSpeed = 0\n\n\tfor (const operation of operations) {\n\t\tif (operation.secondsRemaining) {\n\t\t\t// For the ETA, we use the maximum secondsRemaining among operations (i.e. the worst-case)\n\t\t\tmaxSecondsRemaining = Math.max(maxSecondsRemaining, operation.secondsRemaining)\n\t\t}\n\t\tif (operation.percent) {\n\t\t\ttotalPercent += operation.percent\n\t\t}\n\t\tif (operation.bytesPerSecond) {\n\t\t\ttotalSpeed += operation.bytesPerSecond\n\t\t}\n\t}\n\n\tconst totalProgress = operations.length > 0 ? Math.round(totalPercent / operations.length) : 100\n\tconst eta = secondsToEta(maxSecondsRemaining)\n\n\tlet operationType: 'copy' | 'move' | 'mixed' = 'mixed'\n\n\tconst hasCopy = operations.some((op) => op.type === 'copy')\n\tconst hasMove = operations.some((op) => op.type === 'move')\n\n\tif (hasCopy && hasMove) {\n\t\toperationType = 'mixed'\n\t} else if (hasCopy) {\n\t\toperationType = 'copy'\n\t} else if (hasMove) {\n\t\toperationType = 'move'\n\t}\n\n\treturn (\n\t\t<Island id='operations-island' nonDismissable>\n\t\t\t<IslandMinimized>\n\t\t\t\t<MinimizedContent progress={totalProgress} count={operations.length} eta={eta} type={operationType} />\n\t\t\t</IslandMinimized>\n\t\t\t<IslandExpanded>\n\t\t\t\t<ExpandedContent progress={totalProgress} count={operations.length} speed={totalSpeed} />\n\t\t\t</IslandExpanded>\n\t\t</Island>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/floating-islands/operations-island/minimized.tsx",
    "content": "import {RiFileCopyFill, RiFileTransferFill, RiTimeLine} from 'react-icons/ri'\n\nimport {CircularProgress} from '@/features/files/components/shared/circular-progress'\nimport {t} from '@/utils/i18n'\nimport {formatNumberI18n} from '@/utils/number'\n\nexport function MinimizedContent({\n\tprogress,\n\tcount,\n\teta,\n\ttype,\n}: {\n\tprogress: number\n\tcount: number\n\teta: string\n\ttype: 'copy' | 'move' | 'mixed'\n}) {\n\treturn (\n\t\t<div className='flex h-full w-full items-center gap-2 px-2'>\n\t\t\t<CircularProgress progress={progress}>\n\t\t\t\t{type === 'copy' && <RiFileCopyFill className='h-3 w-3 text-white/60' />}\n\t\t\t\t{type === 'move' && <RiFileTransferFill className='h-3 w-3 text-white/60' />}\n\t\t\t\t{type === 'mixed' && <RiTimeLine className='h-3 w-3 text-white/60' />}\n\t\t\t</CircularProgress>\n\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t<span className='block truncate text-center text-xs text-white/90'>\n\t\t\t\t\t{t('files-listing.item-count', {formattedCount: formatNumberI18n({n: count, showDecimals: false}), count})}\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t\t<div className='flex flex-shrink-0 items-center gap-2'>\n\t\t\t\t<span className='text-xs text-white/60'>{eta}</span>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/floating-islands/uploading-island/expanded.tsx",
    "content": "import {RiCloseLine} from 'react-icons/ri'\n\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {formatFilesystemSize} from '@/features/files/utils/format-filesystem-size'\nimport {useGlobalFiles} from '@/providers/global-files'\nimport {t} from '@/utils/i18n'\n\nexport function ExpandedContent() {\n\tconst {uploadingItems, uploadStats, cancelUpload} = useGlobalFiles()\n\n\treturn (\n\t\t<div className='flex h-full w-full flex-col overflow-hidden py-5'>\n\t\t\t<div className='mb-4 flex items-center justify-between px-5'>\n\t\t\t\t<span className='text-sm text-white/60'>\n\t\t\t\t\t{t('files-upload-island.uploading-count', {count: uploadingItems.length})}\n\t\t\t\t</span>\n\t\t\t\t<span className='text-xs text-white/60'>\n\t\t\t\t\t{formatFilesystemSize(uploadStats.totalUploaded)} / {formatFilesystemSize(uploadStats.totalSize)}\n\t\t\t\t</span>\n\t\t\t</div>\n\n\t\t\t<ScrollArea className='flex-1 px-5 pb-2'>\n\t\t\t\t<div className='space-y-3'>\n\t\t\t\t\t{uploadingItems.map((item) => (\n\t\t\t\t\t\t<div key={item.tempId} className='flex items-center gap-2'>\n\t\t\t\t\t\t\t<div className='flex-shrink-0'>\n\t\t\t\t\t\t\t\t<FileItemIcon item={item} className='h-7 w-7' />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t\t\t\t\t<div className='mb-1 flex items-center justify-between gap-2'>\n\t\t\t\t\t\t\t\t\t<span className='block max-w-36 truncate text-xs text-white/90'>{item.name}</span>\n\t\t\t\t\t\t\t\t\t<span className='flex-shrink-0 text-right text-xs text-white/60'>\n\t\t\t\t\t\t\t\t\t\t{formatFilesystemSize(item.speed || 0)}/s - {formatFilesystemSize(item.size ?? 0)}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className='relative h-1 overflow-hidden rounded-full bg-white/20'>\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName='absolute top-0 left-0 h-full rounded-full bg-brand transition-all duration-300'\n\t\t\t\t\t\t\t\t\t\tstyle={{width: `${item.progress}%`}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tclassName='flex-shrink-0 rounded-full bg-white/10 p-1 transition-colors hover:bg-white/20'\n\t\t\t\t\t\t\t\tonClick={() => cancelUpload(item.tempId ?? '')}\n\t\t\t\t\t\t\t\taria-label={t('files-action.cancel-upload')}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<RiCloseLine className='h-3 w-3 text-white' />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t</ScrollArea>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/floating-islands/uploading-island/index.tsx",
    "content": "import {ExpandedContent} from '@/features/files/components/floating-islands/uploading-island/expanded'\nimport {MinimizedContent} from '@/features/files/components/floating-islands/uploading-island/minimized'\nimport {Island, IslandExpanded, IslandMinimized} from '@/modules/floating-island/bare-island'\n\nexport function UploadingIsland() {\n\treturn (\n\t\t<Island id='uploading-island' nonDismissable>\n\t\t\t<IslandMinimized>\n\t\t\t\t<MinimizedContent />\n\t\t\t</IslandMinimized>\n\t\t\t<IslandExpanded>\n\t\t\t\t<ExpandedContent />\n\t\t\t</IslandExpanded>\n\t\t</Island>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/floating-islands/uploading-island/minimized.tsx",
    "content": "import {RiArrowUpLine} from 'react-icons/ri'\n\nimport {CircularProgress} from '@/features/files/components/shared/circular-progress'\nimport {useGlobalFiles} from '@/providers/global-files'\n\nexport function MinimizedContent() {\n\tconst {uploadingItems, uploadStats} = useGlobalFiles()\n\n\treturn (\n\t\t<div className='flex h-full w-full items-center gap-2 px-2'>\n\t\t\t<CircularProgress progress={uploadStats.totalProgress}>\n\t\t\t\t<RiArrowUpLine className='h-3 w-3 text-white/60' />\n\t\t\t</CircularProgress>\n\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t<span className='block truncate text-center text-xs text-white/90'>\n\t\t\t\t\t{uploadingItems.length} item{uploadingItems.length > 1 ? 's' : ''}\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t\t<div className='flex flex-shrink-0 items-center gap-2'>\n\t\t\t\t<span className='text-xs text-white/60'>{uploadStats.eta}</span>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/actions-bar/actions-bar-context.tsx",
    "content": "import React, {createContext, useContext, useState} from 'react'\n\n// Configuration options that influence the behaviour and rendering of the\n// global <ActionsBar /> component. A listing component (e.g. DirectoryListing,\n// TrashListing, etc.) updates the configuration every time it mounts or when\n// the relevant values change (e.g. when listing state transitions from\n// loading to error or back).\nexport interface ActionsBarConfig {\n\t// Whether to hide the path\n\thidePath?: boolean\n\n\t// Optional label to display in place of the path bar (e.g. during search)\n\tpathLabel?: string\n\n\t// Whether to hide the search input\n\thideSearch?: boolean\n\n\t// Additional buttons displayed on desktop resolutions (≥ md breakpoint)\n\t// eg. \"New Folder\", \"Upload\", \"Empty Trash\", etc.\n\tdesktopActions?: React.ReactNode\n\n\t// Additional dropdown items displayed on mobile resolutions (< md breakpoint)\n\tmobileActions?: React.ReactNode\n}\n\ninterface ActionsBarContextValue {\n\t// Current configuration\n\tconfig: ActionsBarConfig\n\t// Update the configuration\n\tsetConfig: (config: ActionsBarConfig) => void\n}\n\nconst ActionsBarContext = createContext<ActionsBarContextValue | undefined>(undefined)\n\nexport function ActionsBarProvider({children}: {children: React.ReactNode}) {\n\t// We intentionally keep the initial config minimal.  Each listing sets the\n\t// configuration on mount.\n\tconst [config, setConfig] = useState<ActionsBarConfig>({})\n\n\treturn <ActionsBarContext value={{config, setConfig}}>{children}</ActionsBarContext>\n}\n\n// Convenience hook used by <ActionsBar /> to access the current config.\nexport function useActionsBarConfig() {\n\tconst ctx = useContext(ActionsBarContext)\n\tif (!ctx) {\n\t\tthrow new Error('useActionsBarConfig must be used within an <ActionsBarProvider />')\n\t}\n\treturn ctx.config\n}\n\n// Hook for listings to update the configuration.\nexport function useSetActionsBarConfig() {\n\tconst ctx = useContext(ActionsBarContext)\n\tif (!ctx) {\n\t\tthrow new Error('useSetActionsBarConfig must be used within an <ActionsBarProvider />')\n\t}\n\treturn ctx.setConfig\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/actions-bar/index.tsx",
    "content": "import {Separator} from '@/components/ui/separator'\nimport {useActionsBarConfig} from '@/features/files/components/listing/actions-bar/actions-bar-context'\nimport {MobileActions} from '@/features/files/components/listing/actions-bar/mobile-actions'\nimport {NavigationControls} from '@/features/files/components/listing/actions-bar/navigation-controls'\nimport {PathBar} from '@/features/files/components/listing/actions-bar/path-bar'\nimport {SearchInput} from '@/features/files/components/listing/actions-bar/search-input'\nimport {SortDropdown} from '@/features/files/components/listing/actions-bar/sort-dropdown'\nimport {ViewToggle} from '@/features/files/components/listing/actions-bar/view-toggle'\nimport {useIsFilesEmbedded, useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\nimport {cn} from '@/lib/utils'\n\n// Actions/navigation bar displayed above every files listing.  Its\n// contents are driven by the configuration exposed via the\n// <ActionsBarProvider /> (see actions-bar-context.tsx).\nexport function ActionsBar() {\n\tconst {hidePath, pathLabel, hideSearch, desktopActions, mobileActions} = useActionsBarConfig()\n\tconst isReadOnly = useIsFilesReadOnly()\n\tconst isEmbedded = useIsFilesEmbedded()\n\tconst showSearchUi = !hideSearch && !isReadOnly\n\tconst showViewToggleUi = isEmbedded || !isReadOnly\n\tconst showSortUi = isEmbedded || !isReadOnly\n\n\treturn (\n\t\t<nav className={cn('flex h-8 w-full min-w-0 gap-3', !isEmbedded && 'lg:-mt-14')} aria-label='File browser actions'>\n\t\t\t{/* Left side: Navigation and Path */}\n\t\t\t<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>\n\t\t\t\t<NavigationControls />\n\t\t\t\t{hidePath ? null : pathLabel ? <span className='truncate text-xs opacity-70'>{pathLabel}</span> : <PathBar />}\n\t\t\t</div>\n\n\t\t\t{/* Right side: View Controls and Actions */}\n\t\t\t<div className='ml-auto flex items-center'>\n\t\t\t\t{/* Desktop view - show toggle for view and separate buttons for each action */}\n\t\t\t\t<div className='hidden items-center gap-2 md:flex'>\n\t\t\t\t\t{desktopActions && !isReadOnly ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t{desktopActions}\n\t\t\t\t\t\t\t<Separator orientation='vertical' className='h-6' />\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : null}\n\t\t\t\t\t{/* Search */}\n\t\t\t\t\t{showSearchUi ? <SearchInput /> : null}\n\t\t\t\t\t{showViewToggleUi ? <ViewToggle /> : null}\n\t\t\t\t\t{showSortUi ? <SortDropdown /> : null}\n\t\t\t\t</div>\n\n\t\t\t\t{/* Mobile view - show menu with all actions */}\n\t\t\t\t<div className='md:hidden'>\n\t\t\t\t\t<MobileActions DropdownItems={!isReadOnly ? mobileActions : null} />\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</nav>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/actions-bar/mobile-actions.tsx",
    "content": "import {RiArrowDropDownLine, RiArrowDropUpLine} from 'react-icons/ri'\nimport {TbDots} from 'react-icons/tb'\n\nimport {Button} from '@/components/ui/button'\nimport {\n\tDropdownMenu,\n\tDropdownMenuCheckboxItem,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuPortal,\n\tDropdownMenuSeparator,\n\tDropdownMenuSub,\n\tDropdownMenuSubContent,\n\tDropdownMenuSubTrigger,\n\tDropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport {useActionsBarConfig} from '@/features/files/components/listing/actions-bar/actions-bar-context'\nimport {SearchInput} from '@/features/files/components/listing/actions-bar/search-input'\nimport {SORT_BY_OPTIONS} from '@/features/files/constants'\nimport {usePreferences} from '@/features/files/hooks/use-preferences'\nimport {useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\nexport function MobileActions({DropdownItems = null}: {DropdownItems?: React.ReactNode}) {\n\tconst {preferences, setView, setSortBy} = usePreferences()\n\tconst isSelectingOnMobile = useFilesStore((state) => state.isSelectingOnMobile)\n\tconst setIsSelectingOnMobile = useFilesStore((state) => state.setIsSelectingOnMobile)\n\tconst {hideSearch} = useActionsBarConfig()\n\tconst isReadOnly = useIsFilesReadOnly()\n\n\treturn (\n\t\t<div className='flex items-center gap-2'>\n\t\t\t{/* Search (hide in read-only or when explicitly hidden) */}\n\t\t\t{!hideSearch && !isReadOnly ? <SearchInput /> : null}\n\n\t\t\t{/* Select toggle button */}\n\t\t\t<Button\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'h-[1.9rem] rounded-full px-3 text-13',\n\t\t\t\t\t'focus:ring-0 focus:ring-offset-0 focus-visible:ring-0',\n\t\t\t\t\t'focus:outline-hidden focus-visible:outline-hidden',\n\t\t\t\t)}\n\t\t\t\tvariant={isSelectingOnMobile ? 'secondary' : 'default'}\n\t\t\t\tsize='default'\n\t\t\t\taria-label={t('files-action.select')}\n\t\t\t\tonClick={() => setIsSelectingOnMobile(!isSelectingOnMobile)}\n\t\t\t>\n\t\t\t\t{isSelectingOnMobile ? t('done') : t('files-action.select')}\n\t\t\t</Button>\n\n\t\t\t<DropdownMenu>\n\t\t\t\t<DropdownMenuTrigger className='focus:ring-0 focus:ring-offset-0 focus:outline-hidden focus-visible:ring-0 focus-visible:outline-hidden'>\n\t\t\t\t\t<TbDots className='h-5 w-5' />\n\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t<DropdownMenuContent className='w-44' align='start'>\n\t\t\t\t\t{DropdownItems ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t{DropdownItems}\n\t\t\t\t\t\t\t<DropdownMenuSeparator />\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : null}\n\t\t\t\t\t<DropdownMenuSub>\n\t\t\t\t\t\t<DropdownMenuSubTrigger>{t('files-view.view-as')}</DropdownMenuSubTrigger>\n\t\t\t\t\t\t<DropdownMenuPortal>\n\t\t\t\t\t\t\t<DropdownMenuSubContent>\n\t\t\t\t\t\t\t\t<DropdownMenuCheckboxItem\n\t\t\t\t\t\t\t\t\tchecked={preferences?.view === 'icons'}\n\t\t\t\t\t\t\t\t\tonCheckedChange={() => setView('icons')}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{t('files-view.icons')}\n\t\t\t\t\t\t\t\t</DropdownMenuCheckboxItem>\n\t\t\t\t\t\t\t\t<DropdownMenuCheckboxItem\n\t\t\t\t\t\t\t\t\tchecked={preferences?.view === 'list'}\n\t\t\t\t\t\t\t\t\tonCheckedChange={() => setView('list')}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{t('files-view.list')}\n\t\t\t\t\t\t\t\t</DropdownMenuCheckboxItem>\n\t\t\t\t\t\t\t</DropdownMenuSubContent>\n\t\t\t\t\t\t</DropdownMenuPortal>\n\t\t\t\t\t</DropdownMenuSub>\n\t\t\t\t\t<DropdownMenuSub>\n\t\t\t\t\t\t<DropdownMenuSubTrigger>{t('files-view.sort-by')}</DropdownMenuSubTrigger>\n\t\t\t\t\t\t<DropdownMenuPortal>\n\t\t\t\t\t\t\t<DropdownMenuSubContent>\n\t\t\t\t\t\t\t\t{SORT_BY_OPTIONS.map((option) => (\n\t\t\t\t\t\t\t\t\t<DropdownMenuItem\n\t\t\t\t\t\t\t\t\t\tkey={option.sortBy}\n\t\t\t\t\t\t\t\t\t\tonClick={() => setSortBy(option.sortBy)}\n\t\t\t\t\t\t\t\t\t\tclassName='flex items-center justify-between'\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{t(option.labelTKey)}\n\t\t\t\t\t\t\t\t\t\t{option.sortBy === preferences?.sortBy && (\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t{preferences.sortOrder === 'ascending' ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<RiArrowDropUpLine className='h-5 w-5' />\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<RiArrowDropDownLine className='h-5 w-5' />\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</DropdownMenuSubContent>\n\t\t\t\t\t\t</DropdownMenuPortal>\n\t\t\t\t\t</DropdownMenuSub>\n\t\t\t\t</DropdownMenuContent>\n\t\t\t</DropdownMenu>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/actions-bar/navigation-controls.tsx",
    "content": "import {motion} from 'motion/react'\nimport {useEffect, useState} from 'react'\nimport {useLocation, useNavigate} from 'react-router-dom'\n\nimport {ChevronLeftIcon} from '@/features/files/assets/chevron-left'\nimport {ChevronRightIcon} from '@/features/files/assets/chevron-right'\nimport {BASE_ROUTE_PATH, SEARCH_PATH} from '@/features/files/constants'\nimport {useNavigate as useFilesNavigate} from '@/features/files/hooks/use-navigate'\nimport {useIsFilesEmbedded} from '@/features/files/providers/files-capabilities-context'\nimport {cn} from '@/lib/utils'\n\n/**\n * File browser navigation controls that track visited folder paths.\n * Maintains clean paths without query parameters to prevent\n * dialog re-renders during back/forward navigation.\n */\nexport function NavigationControls() {\n\tconst location = useLocation()\n\tconst navigate = useNavigate()\n\tconst {uiPath, navigateToDirectory} = useFilesNavigate()\n\tconst isEmbedded = useIsFilesEmbedded()\n\n\t// Track visited paths and current position\n\tconst [navigation, setNavigation] = useState({\n\t\tpaths: [isEmbedded ? uiPath : location.pathname],\n\t\tcurrentPathIndex: 0,\n\t})\n\n\t// Add new path when location or embedded currentPath changes\n\t// and store the latest path in session storage for the Dock to restore\n\tuseEffect(() => {\n\t\t// We only persist the path for the standalone Files feature (skip in embedded/Rewind)\n\t\tif (!isEmbedded) {\n\t\t\tconst isSearchPage = location.pathname === `${BASE_ROUTE_PATH}${SEARCH_PATH}`\n\t\t\tconst newPath = isSearchPage ? `${location.pathname}${location.search}` : location.pathname\n\n\t\t\tsetNavigation((current) => {\n\t\t\t\tconst lastPath = current.paths[current.currentPathIndex]\n\n\t\t\t\t// If the new path is the same as the last path, do nothing\n\t\t\t\tif (newPath === lastPath) {\n\t\t\t\t\treturn current\n\t\t\t\t}\n\n\t\t\t\t// If the new path is a search page and the last path was also a search page,\n\t\t\t\t// update the last path with the new path so we don't store the path for every\n\t\t\t\t// search query character (e.g., \"?q=t\" => \"?q=te\" => \"?q=tes\" => \"?q=test\")\n\t\t\t\tif (isSearchPage && lastPath.startsWith(`${BASE_ROUTE_PATH}${SEARCH_PATH}`)) {\n\t\t\t\t\tconst updatedPaths = [...current.paths.slice(0, current.currentPathIndex), newPath]\n\t\t\t\t\treturn {\n\t\t\t\t\t\tpaths: updatedPaths,\n\t\t\t\t\t\tcurrentPathIndex: current.currentPathIndex,\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Normal navigation push, truncate any forward history\n\t\t\t\tconst updatedPaths = [...current.paths.slice(0, current.currentPathIndex + 1), newPath]\n\t\t\t\treturn {\n\t\t\t\t\tpaths: updatedPaths,\n\t\t\t\t\tcurrentPathIndex: updatedPaths.length - 1,\n\t\t\t\t}\n\t\t\t})\n\t\t} else {\n\t\t\tconst newPath = uiPath\n\t\t\tsetNavigation((current) => {\n\t\t\t\tconst lastPath = current.paths[current.currentPathIndex]\n\t\t\t\tif (newPath === lastPath) return current\n\t\t\t\tconst updatedPaths = [...current.paths.slice(0, current.currentPathIndex + 1), newPath]\n\t\t\t\treturn {\n\t\t\t\t\tpaths: updatedPaths,\n\t\t\t\t\tcurrentPathIndex: updatedPaths.length - 1,\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t}, [isEmbedded, location.pathname, location.search, uiPath])\n\n\t// Navigation handlers\n\tconst handleBack = () => {\n\t\tif (!canGoBack) return\n\t\tconst prevIndex = navigation.currentPathIndex - 1\n\t\tsetNavigation((curr) => ({...curr, currentPathIndex: prevIndex}))\n\t\tif (isEmbedded) {\n\t\t\tnavigateToDirectory(navigation.paths[prevIndex])\n\t\t} else {\n\t\t\tnavigate(navigation.paths[prevIndex])\n\t\t}\n\t}\n\n\tconst handleForward = () => {\n\t\tif (!canGoForward) return\n\t\tconst nextIndex = navigation.currentPathIndex + 1\n\t\tsetNavigation((curr) => ({...curr, currentPathIndex: nextIndex}))\n\t\tif (isEmbedded) {\n\t\t\tnavigateToDirectory(navigation.paths[nextIndex])\n\t\t} else {\n\t\t\tnavigate(navigation.paths[nextIndex])\n\t\t}\n\t}\n\n\t// can go back if there is a previous path\n\tconst canGoBack = Boolean(navigation.paths[navigation.currentPathIndex - 1])\n\t// can go forward if there is a next path\n\tconst canGoForward = Boolean(navigation.paths[navigation.currentPathIndex + 1])\n\n\treturn (\n\t\t<div className='flex items-center gap-2'>\n\t\t\t<motion.button\n\t\t\t\tonClick={handleBack}\n\t\t\t\tdisabled={!canGoBack}\n\t\t\t\tclassName={cn('p-0 hover:bg-transparent focus:ring-0 focus-visible:ring-0', {\n\t\t\t\t\t'opacity-50': !canGoBack,\n\t\t\t\t})}\n\t\t\t\twhileTap={{scale: 0.85}}\n\t\t\t\taria-label='Go back'\n\t\t\t>\n\t\t\t\t<ChevronLeftIcon className='h-5 w-5' />\n\t\t\t</motion.button>\n\t\t\t<motion.button\n\t\t\t\tonClick={handleForward}\n\t\t\t\tdisabled={!canGoForward}\n\t\t\t\tclassName={cn('p-0 hover:bg-transparent focus:ring-0 focus-visible:ring-0', {\n\t\t\t\t\t'opacity-50': !canGoForward,\n\t\t\t\t})}\n\t\t\t\twhileTap={{scale: 0.85}}\n\t\t\t\taria-label='Go forward'\n\t\t\t>\n\t\t\t\t<ChevronRightIcon className='h-5 w-5' />\n\t\t\t</motion.button>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/actions-bar/path-bar/index.tsx",
    "content": "import {Pencil} from 'lucide-react'\nimport {useState} from 'react'\n\nimport {ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger} from '@/components/ui/context-menu'\nimport {PathBarDesktop} from '@/features/files/components/listing/actions-bar/path-bar/path-bar-desktop'\nimport {PathBarMobile} from '@/features/files/components/listing/actions-bar/path-bar/path-bar-mobile'\nimport {PathInput} from '@/features/files/components/listing/actions-bar/path-bar/path-input'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {t} from '@/utils/i18n'\n\nexport function PathBar() {\n\tconst {uiPath} = useNavigate()\n\tconst [isEditing, setIsEditing] = useState(false)\n\tconst isMobile = useIsMobile()\n\n\tconst handleEdit = () => setIsEditing(true)\n\n\treturn (\n\t\t<ContextMenu>\n\t\t\t<ContextMenuTrigger className='w-0 flex-1'>\n\t\t\t\t<PathBarContent\n\t\t\t\t\tisEditing={isEditing}\n\t\t\t\t\tisMobile={isMobile}\n\t\t\t\t\tcurrentPath={uiPath}\n\t\t\t\t\tonInputClose={() => setIsEditing(false)}\n\t\t\t\t/>\n\t\t\t</ContextMenuTrigger>\n\t\t\t<ContextMenuContent>\n\t\t\t\t<ContextMenuItem onSelect={handleEdit}>\n\t\t\t\t\t<Pencil className='mr-2 h-3 w-3' />\n\t\t\t\t\t{t('files-action.go-to-path')}\n\t\t\t\t</ContextMenuItem>\n\t\t\t</ContextMenuContent>\n\t\t</ContextMenu>\n\t)\n}\n\nconst PathBarContent = ({\n\tisEditing,\n\tisMobile,\n\tcurrentPath,\n\tonInputClose,\n}: {\n\tisEditing: boolean\n\tisMobile: boolean\n\tcurrentPath: string\n\tonInputClose: () => void\n}) => {\n\tif (isEditing) {\n\t\treturn <PathInput path={currentPath} onClose={onInputClose} />\n\t}\n\n\tif (isMobile) {\n\t\treturn <PathBarMobile path={currentPath} />\n\t}\n\n\treturn <PathBarDesktop path={currentPath} />\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/actions-bar/path-bar/path-bar-desktop.tsx",
    "content": "import {useCallback, useLayoutEffect, useMemo, useRef} from 'react'\n\nimport {FadeScroller} from '@/components/fade-scroller'\nimport {CaretRightIcon} from '@/features/files/assets/caret-right'\nimport {Droppable} from '@/features/files/components/shared/drag-and-drop'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {\n\tAPPS_PATH,\n\tEXTERNAL_STORAGE_PATH,\n\tHOME_PATH,\n\tNETWORK_STORAGE_PATH,\n\tRECENTS_PATH,\n\tTRASH_PATH,\n} from '@/features/files/constants'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {formatItemName} from '@/features/files/utils/format-filesystem-name'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\ntype PathSegment = {\n\tid: number\n\tpath: string\n\tsegment: string\n\ttype: 'home' | 'trash' | 'recents' | 'apps' | 'folder' | 'external-storage' | 'network-root' | 'network-share'\n}\n\nexport function PathBarDesktop({path}: {path: string}) {\n\t// Ref for the list element that handles width calculations and overflow behavior for path segments\n\tconst breadcrumbsRef = useRef<HTMLUListElement | null>(null)\n\n\t// Ref for the scrollable container that handles horizontal scrolling and fade effect\n\tconst fadeScrollerRef = useRef<HTMLDivElement | null>(null)\n\n\tconst {navigateToDirectory, isBrowsingExternalStorage, isBrowsingNetworkStorage, uiPath} = useNavigate()\n\n\tconst segments = useMemo(() => {\n\t\t// Display path: derive from UI path to hide backups/snapshot segments\n\t\tconst displayPath = uiPath\n\n\t\t// Determine root type and path from UI path\n\t\tconst isUiTrash = displayPath.startsWith(TRASH_PATH)\n\t\tconst isUiRecents = displayPath.startsWith(RECENTS_PATH)\n\t\tconst isUiApps = displayPath.startsWith(APPS_PATH)\n\t\tconst isUiNetwork = displayPath.startsWith(NETWORK_STORAGE_PATH)\n\t\tconst isUiExternal = displayPath.startsWith(EXTERNAL_STORAGE_PATH)\n\n\t\tconst displaySegments = displayPath.split('/').filter(Boolean)\n\n\t\tconst rootInfo = isUiTrash\n\t\t\t? {segment: t('files-sidebar.trash'), type: 'trash' as const, path: TRASH_PATH}\n\t\t\t: isUiRecents\n\t\t\t\t? {segment: t('files-sidebar.recents'), type: 'recents' as const, path: RECENTS_PATH}\n\t\t\t\t: isUiApps\n\t\t\t\t\t? {segment: t('files-sidebar.apps'), type: 'apps' as const, path: APPS_PATH}\n\t\t\t\t\t: isUiExternal\n\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\tsegment: displaySegments[1] || t('files-sidebar.external-storage'),\n\t\t\t\t\t\t\t\ttype: 'external-storage' as const,\n\t\t\t\t\t\t\t\tpath: `${EXTERNAL_STORAGE_PATH}/${displaySegments[1] || ''}`,\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t: isUiNetwork\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\tsegment: displayPath === NETWORK_STORAGE_PATH ? t('files-sidebar.network-pathbar') : '',\n\t\t\t\t\t\t\t\t\ttype: 'network-root' as const,\n\t\t\t\t\t\t\t\t\tpath: NETWORK_STORAGE_PATH,\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: {segment: t('files-sidebar.home'), type: 'home' as const, path: HOME_PATH}\n\n\t\t// Start with the root segment\n\t\tconst items: PathSegment[] = [\n\t\t\t{\n\t\t\t\tid: 0,\n\t\t\t\t...rootInfo,\n\t\t\t},\n\t\t]\n\n\t\t// Add nested folder segments\n\t\tconst nestedDisplayPaths = isBrowsingExternalStorage\n\t\t\t? displayPath.split('/').slice(3).filter(Boolean) // Skip external-storage and disk name\n\t\t\t: displayPath.replace(rootInfo.path, '').split('/').filter(Boolean)\n\n\t\tnestedDisplayPaths.forEach((segment, i) => {\n\t\t\tconst segmentUiPath = [rootInfo.path, ...nestedDisplayPaths.slice(0, i + 1)].join('/')\n\n\t\t\t// Determine the type for the segment\n\t\t\tlet segmentType: PathSegment['type'] = 'folder'\n\n\t\t\t// First level network share gets network-share type for NAS icon\n\t\t\tif (isBrowsingNetworkStorage && i === 0) {\n\t\t\t\tsegmentType = 'network-share'\n\t\t\t}\n\n\t\t\titems.push({\n\t\t\t\tid: i + 1,\n\t\t\t\ttype: segmentType,\n\t\t\t\tsegment,\n\t\t\t\tpath: segmentUiPath,\n\t\t\t})\n\t\t})\n\n\t\treturn items\n\t}, [uiPath, isBrowsingExternalStorage, isBrowsingNetworkStorage])\n\n\tconst deriveIsOverflow = useCallback(() => {\n\t\tif (!breadcrumbsRef.current) return\n\n\t\tconst children = Array.from(breadcrumbsRef.current.children).filter(\n\t\t\t(i): i is HTMLElement => i instanceof HTMLElement,\n\t\t)\n\n\t\t// Reset children inline styles\n\t\tchildren.forEach((child) => {\n\t\t\tchild.style.removeProperty('--natural-width')\n\t\t\tchild.style.removeProperty('--item-width')\n\t\t\tchild.classList.remove('has-overflow')\n\t\t})\n\n\t\tlet availableWidth = breadcrumbsRef.current.clientWidth\n\n\t\t// Subtract space for the static elements\n\t\tchildren\n\t\t\t.filter((child) => child.dataset.static)\n\t\t\t.forEach((child) => {\n\t\t\t\tavailableWidth -= child.getBoundingClientRect().width\n\t\t\t})\n\n\t\tlet remainingSpace = availableWidth\n\t\tlet totalUsedWidth = 0\n\n\t\tchildren\n\t\t\t.filter((child) => !child.dataset.static)\n\t\t\t.forEach((child, i, filteredChildren) => {\n\t\t\t\tconst naturalWidth = child.clientWidth\n\t\t\t\tconst collapsibleCount = filteredChildren.length\n\n\t\t\t\t// Calculate proportional width for the current child\n\t\t\t\tconst proportionalWidth = remainingSpace / (collapsibleCount - i)\n\n\t\t\t\t// Determine the final width for the child\n\t\t\t\tconst width = naturalWidth > proportionalWidth ? proportionalWidth : naturalWidth\n\n\t\t\t\t// Update total used width and remaining space\n\t\t\t\ttotalUsedWidth += width\n\t\t\t\tremainingSpace = availableWidth - totalUsedWidth\n\n\t\t\t\tif (naturalWidth > proportionalWidth) {\n\t\t\t\t\tchild.classList.add('has-overflow')\n\t\t\t\t}\n\n\t\t\t\tchild.style.setProperty('--natural-width', `${Math.round(naturalWidth)}px`)\n\t\t\t\tchild.style.setProperty('--item-width', `${Math.round(width)}px`)\n\t\t\t})\n\t}, [])\n\n\tuseLayoutEffect(() => {\n\t\tif (!breadcrumbsRef.current) return\n\n\t\tconst resizeObserver = new ResizeObserver(deriveIsOverflow)\n\t\tresizeObserver.observe(breadcrumbsRef.current)\n\n\t\tderiveIsOverflow()\n\n\t\t// Auto-scroll to the right after widths are calculated\n\t\trequestAnimationFrame(() => {\n\t\t\tif (fadeScrollerRef.current) {\n\t\t\t\tfadeScrollerRef.current.scrollLeft = fadeScrollerRef.current.scrollWidth\n\t\t\t}\n\t\t})\n\n\t\treturn () => {\n\t\t\tresizeObserver.disconnect()\n\t\t}\n\t}, [deriveIsOverflow, path])\n\n\treturn (\n\t\t<FadeScroller direction='x' className='umbrel-hide-scrollbar overflow-x-auto' ref={fadeScrollerRef}>\n\t\t\t<ul className='flex h-8 items-center border border-transparent py-1 whitespace-nowrap' ref={breadcrumbsRef}>\n\t\t\t\t{segments.map((segment, i) => {\n\t\t\t\t\t/* First and last two segments are static, they always be fully visible */\n\t\t\t\t\tconst isStatic = i === 0 || i > segments.length - 3 ? true : undefined\n\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<PathSegment\n\t\t\t\t\t\t\tkey={segment.id}\n\t\t\t\t\t\t\ttype={segment.type}\n\t\t\t\t\t\t\tsegment={segment.segment}\n\t\t\t\t\t\t\thasArrow={i < segments.length - 1}\n\t\t\t\t\t\t\tonClick={() => navigateToDirectory(segment.path)}\n\t\t\t\t\t\t\tpath={segment.path}\n\t\t\t\t\t\t\tisStatic={isStatic}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)\n\t\t\t\t})}\n\t\t\t</ul>\n\t\t</FadeScroller>\n\t)\n}\n\ntype PathSegmentProps = Omit<PathSegment, 'id'> & {\n\thasArrow: boolean\n\tonClick: () => void\n\tisStatic?: boolean\n}\n\nconst PathSegment = ({segment, hasArrow, onClick, isStatic, path, type}: PathSegmentProps) => (\n\t<li className='inline-flex' data-static={isStatic}>\n\t\t<Droppable\n\t\t\tas='button'\n\t\t\tid={`path-segment-${path}`}\n\t\t\tpath={path}\n\t\t\tonClick={onClick}\n\t\t\tclassName='group inline-flex w-[--item-width] min-w-[42px] items-center gap-1 rounded-sm p-1 transition-[width] duration-300 ease-in-out hover:w-[--natural-width]'\n\t\t>\n\t\t\t<FileItemIcon\n\t\t\t\titem={{\n\t\t\t\t\tpath,\n\t\t\t\t\ttype:\n\t\t\t\t\t\ttype === 'external-storage'\n\t\t\t\t\t\t\t? 'external-storage'\n\t\t\t\t\t\t\t: type === 'network-root'\n\t\t\t\t\t\t\t\t? 'network-root'\n\t\t\t\t\t\t\t\t: type === 'network-share'\n\t\t\t\t\t\t\t\t\t? 'network-share'\n\t\t\t\t\t\t\t\t\t: 'directory',\n\t\t\t\t\tname: segment,\n\t\t\t\t\toperations: [],\n\t\t\t\t\tsize: 0,\n\t\t\t\t\tmodified: 0,\n\t\t\t\t}}\n\t\t\t\tclassName='h-4 w-4 opacity-70 transition-opacity group-hover:opacity-100'\n\t\t\t/>\n\t\t\t<span\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'group-hover:[mask-image:none] [.has-overflow_&]:[mask-image:linear-gradient(to_left,transparent_0%,black_40px)]',\n\t\t\t\t\t'overflow-hidden text-xs opacity-70 transition-opacity group-hover:opacity-100',\n\t\t\t\t\tsegment && 'ml-1',\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{segment && formatItemName({name: segment})}\n\t\t\t</span>\n\t\t\t{hasArrow && <CaretRightIcon className='mt-[1px] ml-1 shrink-0 text-white/50' />}\n\t\t</Droppable>\n\t</li>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/actions-bar/path-bar/path-bar-mobile.tsx",
    "content": "import {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {HOME_PATH} from '@/features/files/constants'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useIsFilesEmbedded} from '@/features/files/providers/files-capabilities-context'\nimport {formatItemName} from '@/features/files/utils/format-filesystem-name'\nimport {t} from '@/utils/i18n'\n\ninterface PathBarMobileProps {\n\tpath: string\n}\n\nexport function PathBarMobile({path}: PathBarMobileProps) {\n\tconst isEmbedded = useIsFilesEmbedded()\n\tconst {\n\t\tisInHome,\n\t\tisBrowsingRecents,\n\t\tisBrowsingTrash,\n\t\tisBrowsingExternalStorage,\n\t\tisViewingNetworkDevices,\n\t\tisBrowsingNetworkStorage,\n\t\tisBrowsingBackups,\n\t\tuiPath,\n\t} = useNavigate()\n\n\t// Use UI path for display so backups/snapshot segments are hidden\n\tconst displayPath = uiPath\n\tconst segments = displayPath.replace(HOME_PATH, '').split('/').filter(Boolean)\n\tconst externalStorageDiskName = isBrowsingExternalStorage ? segments[1] : null\n\tconst networkHostName = isBrowsingNetworkStorage && !isViewingNetworkDevices ? segments[1] : null\n\n\treturn (\n\t\t<div className='flex items-center gap-1.5'>\n\t\t\t<FileItemIcon\n\t\t\t\titem={{\n\t\t\t\t\tpath: isBrowsingNetworkStorage\n\t\t\t\t\t\t? (() => {\n\t\t\t\t\t\t\t\t// So for eg. if path is /Network/samba.orb.local/Documents, we want to return /Network/samba.orb.local\n\t\t\t\t\t\t\t\t//  otherwise the inactive NAS icon will be rendered\n\t\t\t\t\t\t\t\tconst parts = path.split('/')\n\t\t\t\t\t\t\t\t// ['', 'Network', 'samba.orb.local', ...]\n\t\t\t\t\t\t\t\tif (parts.length >= 3) {\n\t\t\t\t\t\t\t\t\treturn `/${parts[1]}/${parts[2]}`\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\treturn path\n\t\t\t\t\t\t\t})()\n\t\t\t\t\t\t: path,\n\t\t\t\t\ttype: isBrowsingExternalStorage\n\t\t\t\t\t\t? 'external-storage'\n\t\t\t\t\t\t: isViewingNetworkDevices\n\t\t\t\t\t\t\t? 'network-root'\n\t\t\t\t\t\t\t: isBrowsingNetworkStorage\n\t\t\t\t\t\t\t\t? 'network-share'\n\t\t\t\t\t\t\t\t: 'directory',\n\t\t\t\t\tname: isEmbedded\n\t\t\t\t\t\t? segments[segments.length - 1] || t('files-sidebar.home')\n\t\t\t\t\t\t: isBrowsingBackups\n\t\t\t\t\t\t\t? t('backups')\n\t\t\t\t\t\t\t: segments[segments.length - 1] || t('files-sidebar.home'),\n\t\t\t\t\toperations: [],\n\t\t\t\t\tsize: 0,\n\t\t\t\t\tmodified: 0,\n\t\t\t\t}}\n\t\t\t\tclassName='h-5 w-5'\n\t\t\t/>\n\t\t\t<span className='text-13'>\n\t\t\t\t{isBrowsingTrash ? t('files-sidebar.trash') : ''}\n\t\t\t\t{isBrowsingRecents ? t('files-sidebar.recents') : ''}\n\t\t\t\t{isInHome ? t('files-sidebar.home') : ''}\n\t\t\t\t{isEmbedded ? '' : isBrowsingBackups ? t('backups') : ''}\n\t\t\t\t{isBrowsingExternalStorage ? externalStorageDiskName || t('files-sidebar.external-storage') : ''}\n\t\t\t\t{isViewingNetworkDevices ? t('files-sidebar.network-pathbar') : ''}\n\t\t\t\t{isBrowsingNetworkStorage && !isViewingNetworkDevices ? networkHostName : ''}\n\t\t\t\t{!isBrowsingTrash && !isBrowsingRecents && !isInHome && !isBrowsingExternalStorage && !isBrowsingNetworkStorage\n\t\t\t\t\t? `${formatItemName({name: segments[segments.length - 1] || t('files-sidebar.home')})}`\n\t\t\t\t\t: ''}\n\t\t\t</span>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/actions-bar/path-bar/path-input.tsx",
    "content": "import {useLayoutEffect, useRef, useState} from 'react'\n\nimport {Input} from '@/components/ui/input'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\ninterface PathInputProps {\n\tpath: string\n\tonClose: () => void\n}\n\nexport function PathInput({path, onClose}: PathInputProps) {\n\tconst inputRef = useRef<HTMLInputElement>(null)\n\tconst {navigateToDirectory} = useNavigate()\n\tconst [inputValue, setInputValue] = useState(path)\n\n\tuseLayoutEffect(() => {\n\t\t// We use a 50ms delay to focus the path input after context menu animations\n\t\t// Without this, the input would not focus and immediately close on smaller screen widths\n\t\tconst timeoutId = setTimeout(() => {\n\t\t\tinputRef.current?.focus()\n\t\t}, 50)\n\t\treturn () => clearTimeout(timeoutId)\n\t}, [])\n\n\tconst handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n\t\tif (e.key === 'Enter') {\n\t\t\te.preventDefault()\n\t\t\tnavigateToDirectory(inputValue)\n\t\t\tonClose()\n\t\t}\n\t\tif (e.key === 'Escape') {\n\t\t\tonClose()\n\t\t}\n\t}\n\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t'border-[0.5px] bg-white/6',\n\t\t\t\t'flex h-8 items-center rounded-full border-[hsl(var(--color-brand))] p-3 py-1',\n\t\t\t)}\n\t\t\trole='group'\n\t\t\taria-label={t('files-path.input-group')}\n\t\t>\n\t\t\t<div className='flex flex-1 items-center'>\n\t\t\t\t<Input\n\t\t\t\t\tref={inputRef}\n\t\t\t\t\tvalue={inputValue}\n\t\t\t\t\tonChange={(e) => setInputValue(e.target.value)}\n\t\t\t\t\tonKeyDown={handleKeyDown}\n\t\t\t\t\tonBlur={onClose}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t'h-8 bg-transparent text-xs text-white',\n\t\t\t\t\t\t'border-none p-0 [outline:none]',\n\t\t\t\t\t\t'focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0',\n\t\t\t\t\t\t'[&:active]:bg-transparent [&:focus]:bg-transparent [&:hover]:bg-transparent',\n\t\t\t\t\t\t'w-full',\n\t\t\t\t\t)}\n\t\t\t\t\tspellCheck={false}\n\t\t\t\t\taria-label={t('files-path.input-label')}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/actions-bar/search-input.tsx",
    "content": "import {useEffect, useRef, useState} from 'react'\nimport {useLocation, useNavigate as useRouterNavigate, useSearchParams} from 'react-router-dom'\n\nimport {Input} from '@/components/ui/input'\nimport {SearchIcon} from '@/features/files/assets/search-icon'\nimport {BASE_ROUTE_PATH, SEARCH_PATH} from '@/features/files/constants'\nimport {useIsTouchDevice} from '@/features/files/hooks/use-is-touch-device'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\n// Search input with keyboard shortcuts:\n// - \"/\" focuses the search input (keydown + preventDefault to avoid typing \"/\")\n// - Escape exits search entirely: clears the query, blurs the input, and\n//   navigates back to the previous directory. This works because query changes\n//   on the search page use replace:true, so only the initial entry into search\n//   pushes a history entry — a single navigate(-1) always returns to the\n//   pre-search directory.\n// - Manually deleting all text does NOT auto-navigate away. This is intentional\n//   so users can backspace and retype without being yanked out of search.\n//   They can use Escape or the nav arrows to leave.\nexport function SearchInput() {\n\tconst navigate = useRouterNavigate()\n\tconst location = useLocation()\n\tconst inputRef = useRef<HTMLInputElement>(null)\n\n\tconst [searchParams] = useSearchParams()\n\n\tconst [query, setQuery] = useState('')\n\n\tconst isTouchDevice = useIsTouchDevice()\n\n\t// \"/\" shortcut to focus the search input\n\tuseEffect(() => {\n\t\tconst handleKeyDown = (e: KeyboardEvent) => {\n\t\t\tif (e.key !== '/') return\n\t\t\tconst target = e.target as HTMLElement\n\t\t\tif (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable)\n\t\t\t\treturn\n\t\t\te.preventDefault()\n\t\t\tinputRef.current?.focus()\n\t\t}\n\t\twindow.addEventListener('keydown', handleKeyDown)\n\t\treturn () => window.removeEventListener('keydown', handleKeyDown)\n\t}, [])\n\n\t// sync local state with the URL when navigating into the search route via\n\t// back/forward browser buttons or a browser refresh, or programmatic\n\t// navigation from anywhere\n\tuseEffect(() => {\n\t\tif (location.pathname.endsWith(SEARCH_PATH)) {\n\t\t\t// when on the search route we want the input to reflect the query param\n\t\t\tsetQuery(searchParams.get('q') ?? '')\n\t\t\t// focus the input on non-touch devices\n\t\t\tif (!isTouchDevice) {\n\t\t\t\tinputRef.current?.focus()\n\t\t\t}\n\t\t} else {\n\t\t\t// when not on the search route we want to clear the input\n\t\t\tsetQuery('')\n\t\t}\n\t}, [location.pathname, searchParams])\n\n\t// helper to push/replace the appropriate route for a given query\n\tconst gotoSearch = (query: string, {replace}: {replace: boolean}) => {\n\t\tconst encodedQuery = encodeURIComponent(query.trim())\n\t\tnavigate(`${BASE_ROUTE_PATH}${SEARCH_PATH}?q=${encodedQuery}`, {replace})\n\t}\n\n\tconst onQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n\t\tconst next = e.target.value\n\t\tsetQuery(next)\n\n\t\tconst trimmed = next.trim()\n\n\t\t// avoid navigating for empty queries – we'll stay on the current\n\t\t// directory (or the existing search page showing previous results).\n\t\tif (trimmed === '') return\n\t\tconst currentlyOnSearchPage = location.pathname.endsWith(SEARCH_PATH)\n\t\tgotoSearch(trimmed, {replace: currentlyOnSearchPage})\n\t}\n\n\treturn (\n\t\t<div className='relative rounded-full focus-within:border-1 focus-within:border-neutral-600 focus-within:border-white/20 md:border-[0.5px] md:border-neutral-800 md:border-white/6 md:bg-white/5 md:shadow-button-highlight-soft-hpx md:ring-white/6 md:focus-within:bg-white/10'>\n\t\t\t<Input\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'h-7 w-0 !border-none !bg-transparent px-4 text-xs !ring-0 !outline-hidden transition-all duration-300 focus:w-[calc(100vw-11rem)] focus:pl-8 md:w-28 md:pr-0 md:pl-8 md:focus:w-36',\n\t\t\t\t\t{\n\t\t\t\t\t\t'w-[calc(100vw-11rem)] pl-8 md:w-36': query.length > 0,\n\t\t\t\t\t},\n\t\t\t\t)}\n\t\t\t\tref={inputRef}\n\t\t\t\tplaceholder={t('files-search.placeholder')}\n\t\t\t\tvalue={query}\n\t\t\t\tonChange={onQueryChange}\n\t\t\t\t// Escape exits search: clear query, blur input, navigate back to previous directory\n\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\tif (e.key === 'Escape') {\n\t\t\t\t\t\tsetQuery('')\n\t\t\t\t\t\tinputRef.current?.blur()\n\t\t\t\t\t\tif (location.pathname.endsWith(SEARCH_PATH)) {\n\t\t\t\t\t\t\tnavigate(-1)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t/>\n\t\t\t<SearchIcon\n\t\t\t\tclassName='absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 transform cursor-text text-neutral-500'\n\t\t\t\tonClick={() => inputRef.current?.focus()}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/actions-bar/sort-dropdown.tsx",
    "content": "import {ArrowUpDown} from 'lucide-react'\nimport {RiArrowDropDownLine, RiArrowDropUpLine} from 'react-icons/ri'\n\nimport {Button} from '@/components/ui/button'\nimport {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'\nimport {SORT_BY_OPTIONS} from '@/features/files/constants'\nimport {usePreferences} from '@/features/files/hooks/use-preferences'\nimport {t} from '@/utils/i18n'\n\nexport function SortDropdown() {\n\tconst {preferences, setSortBy} = usePreferences()\n\n\treturn (\n\t\t<DropdownMenu>\n\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t<Button variant='default' size='default'>\n\t\t\t\t\t<ArrowUpDown className='h-3 w-3' />\n\t\t\t\t</Button>\n\t\t\t</DropdownMenuTrigger>\n\t\t\t<DropdownMenuContent align='end' className='w-24'>\n\t\t\t\t<span className='block px-2 pb-2 text-13 text-white/40'>Sort by</span>\n\t\t\t\t{SORT_BY_OPTIONS.map((option) => (\n\t\t\t\t\t<DropdownMenuItem\n\t\t\t\t\t\tkey={option.sortBy}\n\t\t\t\t\t\tonClick={() => setSortBy(option.sortBy)}\n\t\t\t\t\t\tclassName='flex items-center justify-between'\n\t\t\t\t\t>\n\t\t\t\t\t\t{t(option.labelTKey)}\n\t\t\t\t\t\t{option.sortBy === preferences?.sortBy && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{preferences.sortOrder === 'ascending' ? (\n\t\t\t\t\t\t\t\t\t<RiArrowDropUpLine className='h-5 w-5' />\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t<RiArrowDropDownLine className='h-5 w-5' />\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t))}\n\t\t\t</DropdownMenuContent>\n\t\t</DropdownMenu>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/actions-bar/view-toggle.tsx",
    "content": "import {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs'\nimport {GridLayoutIcon} from '@/features/files/assets/grid-layout-icon'\nimport {ListLayoutIcon} from '@/features/files/assets/list-layout-icon'\nimport {usePreferences} from '@/features/files/hooks/use-preferences'\nimport {ViewPreferences} from '@/features/files/types'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\nexport function ViewToggle() {\n\tconst {preferences, setView, isLoading, isError} = usePreferences()\n\n\tconst viewModes: ViewPreferences['view'][] = ['icons', 'list']\n\n\tconst {view} = preferences ?? {view: 'list'}\n\n\treturn (\n\t\t// Hide the view toggle while loading preferences or if there's an error\n\t\t<Tabs\n\t\t\tvalue={view}\n\t\t\tonValueChange={(value) => setView(value as ViewPreferences['view'])}\n\t\t\tclassName={cn(isLoading || (isError && 'opacity-0'))}\n\t\t>\n\t\t\t<TabsList className='h-7 rounded-full border-[0.5px] border-white/6 bg-white/6 px-0.5 py-0 shadow-button-highlight-soft-hpx ring-white/6 hover:bg-white/10 data-[state=open]:bg-white/10'>\n\t\t\t\t{viewModes.map((mode) => (\n\t\t\t\t\t<TabsTrigger\n\t\t\t\t\t\tkey={mode}\n\t\t\t\t\t\tvalue={mode}\n\t\t\t\t\t\tclassName={cn('h-6 rounded-full', view === mode && 'bg-brand')}\n\t\t\t\t\t\taria-label={t(`files-view.${mode}`)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{mode === 'icons' ? (\n\t\t\t\t\t\t\t<GridLayoutIcon className={cn('h-4 w-4', view === mode ? 'text-white' : 'text-white/50')} />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<ListLayoutIcon className={cn('h-4 w-4', view === mode ? 'text-white' : 'text-white/50')} />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</TabsTrigger>\n\t\t\t\t))}\n\t\t\t</TabsList>\n\t\t</Tabs>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/apps-listing/index.tsx",
    "content": "import {useEffect} from 'react'\n\nimport {Listing} from '@/features/files/components/listing'\nimport {useSetActionsBarConfig} from '@/features/files/components/listing/actions-bar/actions-bar-context'\nimport {useListDirectory} from '@/features/files/hooks/use-list-directory'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\n\nexport function AppsListing() {\n\tconst {currentPath} = useNavigate()\n\tconst setActionsBarConfig = useSetActionsBarConfig()\n\tconst {listing, isLoading, error, fetchMoreItems} = useListDirectory(currentPath)\n\n\tuseEffect(() => {\n\t\tsetActionsBarConfig({\n\t\t\thidePath: !!error,\n\t\t\thideSearch: true,\n\t\t})\n\t}, [error])\n\n\treturn (\n\t\t<Listing\n\t\t\titems={listing?.items ?? []}\n\t\t\tselectableItems={listing?.items ?? []}\n\t\t\tisLoading={isLoading}\n\t\t\terror={error}\n\t\t\thasMore={listing?.hasMore ?? false}\n\t\t\tonLoadMore={fetchMoreItems}\n\t\t\tenableFileDrop={false}\n\t\t\ttotalItems={listing?.totalFiles}\n\t\t\ttruncatedAt={listing?.truncatedAt}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/directory-listing/empty-state.tsx",
    "content": "import {Upload} from 'lucide-react'\nimport {useRef} from 'react'\n\nimport {IconButton} from '@/components/ui/icon-button'\nimport {AddFolderIcon} from '@/features/files/assets/add-folder-icon'\nimport {EmptyFolderIcon} from '@/features/files/assets/empty-folder-icon'\nimport nasIconInactive from '@/features/files/assets/nas-icon-inactive.png'\nimport {UploadInput} from '@/features/files/components/shared/upload-input'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useNetworkStorage} from '@/features/files/hooks/use-network-storage'\nimport {useNewFolder} from '@/features/files/hooks/use-new-folder'\nimport {useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\nimport {t} from '@/utils/i18n'\n\nexport function EmptyStateDirectory() {\n\tconst {currentPath, isViewingNetworkShares} = useNavigate()\n\tconst {doesHostHaveMountedShares} = useNetworkStorage()\n\tconst {startNewFolder} = useNewFolder()\n\tconst isReadOnly = useIsFilesReadOnly()\n\tconst uploadInputRef = useRef<HTMLInputElement | null>(null)\n\n\tconst handleUploadClick = () => {\n\t\tuploadInputRef.current?.click()\n\t}\n\n\tconst isOfflineNetworkHost = isViewingNetworkShares && !doesHostHaveMountedShares?.(currentPath)\n\n\treturn (\n\t\t<div className='flex h-full flex-col items-center justify-center gap-3 p-4 pt-0 text-center'>\n\t\t\t<div className='flex flex-col items-center gap-3'>\n\t\t\t\t{isOfflineNetworkHost ? (\n\t\t\t\t\t<img src={nasIconInactive} alt='Network offline' className='h-12 w-12' />\n\t\t\t\t) : (\n\t\t\t\t\t<EmptyFolderIcon />\n\t\t\t\t)}\n\t\t\t\t<div className='text-12 text-white/40'>\n\t\t\t\t\t{isOfflineNetworkHost ? t('files-empty.network-host-offline') : t('files-empty.directory')}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t{/* in read-only mode, we don't render the upload and new folder buttons */}\n\t\t\t{!isViewingNetworkShares && !isReadOnly && (\n\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t<IconButton icon={Upload} variant='primary' onClick={handleUploadClick}>\n\t\t\t\t\t\t{t('files-action.upload')}\n\t\t\t\t\t</IconButton>\n\t\t\t\t\t<IconButton icon={AddFolderIcon} onClick={startNewFolder}>\n\t\t\t\t\t\t{t('files-folder')}\n\t\t\t\t\t</IconButton>\n\t\t\t\t\t<UploadInput ref={uploadInputRef} />\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\nexport function EmptyStateNetwork() {\n\treturn (\n\t\t<div className='flex h-full items-center justify-center p-4 pt-0 text-center'>\n\t\t\t<div className='text-12 text-white/40'>{t('files-empty.network')}</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/directory-listing/index.tsx",
    "content": "import {Upload} from 'lucide-react'\nimport {useEffect, useLayoutEffect, useRef} from 'react'\nimport {RiClipboardLine} from 'react-icons/ri'\nimport {TbWorldPlus} from 'react-icons/tb'\nimport {useNavigate as useRouterNavigate} from 'react-router-dom'\n\nimport {ContextMenuItem, ContextMenuShortcut} from '@/components/ui/context-menu'\nimport {DropdownMenuItem} from '@/components/ui/dropdown-menu'\nimport {IconButton} from '@/components/ui/icon-button'\nimport {AddFolderIcon} from '@/features/files/assets/add-folder-icon'\nimport {Listing} from '@/features/files/components/listing'\nimport {useSetActionsBarConfig} from '@/features/files/components/listing/actions-bar/actions-bar-context'\nimport {EmptyStateDirectory, EmptyStateNetwork} from '@/features/files/components/listing/directory-listing/empty-state'\nimport {UploadInput} from '@/features/files/components/shared/upload-input'\nimport {useFilesOperations} from '@/features/files/hooks/use-files-operations'\nimport {useListDirectory} from '@/features/files/hooks/use-list-directory'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useNewFolder} from '@/features/files/hooks/use-new-folder'\nimport {useIsFilesEmbedded} from '@/features/files/providers/files-capabilities-context'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport type {FilesStore} from '@/features/files/store/use-files-store'\nimport {useLinkToDialog} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\n// `marqueeScale` is threaded through so embedded contexts (like Rewind) can tell marquee selection\n// about the CSS transform that shrinks the Files UI.\nexport function DirectoryListing({marqueeScale = 1}: {marqueeScale?: number} = {}) {\n\tconst {\n\t\tcurrentPath,\n\t\tuiPath,\n\t\tisBrowsingApps,\n\t\tisBrowsingExternalStorage,\n\t\tisViewingExternalDrives,\n\t\tisViewingNetworkDevices,\n\t\tisViewingNetworkShares,\n\t\tisBrowsingNetworkStorage,\n\t\tnavigateToDirectory,\n\t} = useNavigate()\n\tconst isEmbedded = useIsFilesEmbedded()\n\tconst setActionsBarConfig = useSetActionsBarConfig()\n\tconst {listing, isLoading, error, fetchMoreItems} = useListDirectory(currentPath)\n\tconst routerNavigate = useRouterNavigate()\n\tconst linkToDialog = useLinkToDialog()\n\n\t// Grab the potential \"new folder\" item from store\n\tconst newFolder = useFilesStore((state: FilesStore) => state.newFolder)\n\n\t// Merge new folder (if any) at the top of the list\n\tconst items = newFolder ? [newFolder, ...(listing?.items || [])] : listing?.items || []\n\n\t// For \"Paste\" command\n\tconst {pasteItemsFromClipboard} = useFilesOperations()\n\tconst hasItemsInClipboard = useFilesStore((state: FilesStore) => state.hasItemsInClipboard)\n\n\t// For \"New Folder\"\n\tconst {startNewFolder} = useNewFolder()\n\n\t// For \"Upload\"\n\tconst uploadInputRef = useRef<HTMLInputElement | null>(null)\n\tconst handleUploadClick = () => {\n\t\tuploadInputRef.current?.click()\n\t}\n\n\t// Additional items for the directory context menu\n\t// Disable write actions (New Folder, Upload, Paste) for read-only directories\n\tconst additionalContextMenuItems =\n\t\tisViewingExternalDrives || isViewingNetworkDevices || isViewingNetworkShares ? null : (\n\t\t\t<>\n\t\t\t\t<ContextMenuItem onClick={startNewFolder}>{t('files-action.new-folder')}</ContextMenuItem>\n\t\t\t\t<ContextMenuItem onClick={handleUploadClick}>{t('files-action.upload')}</ContextMenuItem>\n\t\t\t\t<ContextMenuItem\n\t\t\t\t\tonClick={() => pasteItemsFromClipboard({toDirectory: currentPath})}\n\t\t\t\t\tdisabled={!hasItemsInClipboard()}\n\t\t\t\t>\n\t\t\t\t\t{t('files-action.paste')}\n\t\t\t\t\t<ContextMenuShortcut>⌘V</ContextMenuShortcut>\n\t\t\t\t</ContextMenuItem>\n\t\t\t</>\n\t\t)\n\n\t// Filter out items that are currently uploading to prevent them from being selected via marquee selection or keyboard shortcuts\n\tconst selectableItems = (listing?.items ?? []).filter((item) => !item.isUploading)\n\n\t// Hide the path bar and disable actions if there's an error or loading state\n\tconst hidePathAndDisableActions = Boolean(isLoading || error)\n\n\t// In embedded contexts (e.g., Rewind), if the current directory doesn't exist in a snapshot,\n\t// we automatically fall back to the nearest existing parent.\n\t// We use useLayoutEffect to navigate before paint to avoid a visible flicker of the error screen (\"No such file or folder\").\n\tuseLayoutEffect(() => {\n\t\tif (!isEmbedded || !error) return\n\t\t// climb the logical UI path to the nearest existing parent\n\t\tconst logicalBase = uiPath.startsWith('/Apps') ? '/Apps' : '/Home'\n\t\tconst lastSlash = uiPath.lastIndexOf('/')\n\t\tconst parentUi = lastSlash > 0 ? uiPath.slice(0, lastSlash) : logicalBase\n\t\tif (parentUi && parentUi !== uiPath) navigateToDirectory(parentUi)\n\t}, [isEmbedded, error, uiPath, navigateToDirectory])\n\n\t// Desktop actions\n\t// - At /Network (devices view): show \"Add share\" action\n\t// - Elsewhere (non-readonly): show New Folder and Upload\n\tlet DesktopActions: React.ReactNode = null\n\tif (isViewingNetworkDevices) {\n\t\tDesktopActions = (\n\t\t\t<IconButton\n\t\t\t\ticon={TbWorldPlus}\n\t\t\t\tonClick={() => routerNavigate(linkToDialog('files-add-network-share'))}\n\t\t\t\tdisabled={hidePathAndDisableActions}\n\t\t\t>\n\t\t\t\t{t('files-action.add-network-device')}\n\t\t\t</IconButton>\n\t\t)\n\t} else if (!(isViewingExternalDrives || isViewingNetworkShares)) {\n\t\tDesktopActions = (\n\t\t\t<>\n\t\t\t\t<IconButton icon={AddFolderIcon} onClick={startNewFolder} disabled={hidePathAndDisableActions}>\n\t\t\t\t\t{t('files-folder')}\n\t\t\t\t</IconButton>\n\t\t\t\t<IconButton icon={Upload} onClick={handleUploadClick} disabled={hidePathAndDisableActions}>\n\t\t\t\t\t{t('files-action.upload')}\n\t\t\t\t</IconButton>\n\t\t\t</>\n\t\t)\n\t}\n\n\t// Mobile actions\n\tlet MobileDropdownActions: React.ReactNode = null\n\tif (isViewingNetworkDevices) {\n\t\tMobileDropdownActions = (\n\t\t\t<DropdownMenuItem onClick={() => routerNavigate(linkToDialog('files-add-network-share'))}>\n\t\t\t\t<TbWorldPlus className='mr-2 h-4 w-4' />\n\t\t\t\t{t('files-action.add-network-device')}\n\t\t\t</DropdownMenuItem>\n\t\t)\n\t} else if (!(isViewingExternalDrives || isViewingNetworkShares)) {\n\t\tMobileDropdownActions = (\n\t\t\t<>\n\t\t\t\t<DropdownMenuItem onClick={startNewFolder} disabled={hidePathAndDisableActions}>\n\t\t\t\t\t<AddFolderIcon className='mr-2 h-4 w-4 opacity-50' />\n\t\t\t\t\t{t('files-action.new-folder')}\n\t\t\t\t</DropdownMenuItem>\n\t\t\t\t<DropdownMenuItem onClick={handleUploadClick} disabled={hidePathAndDisableActions}>\n\t\t\t\t\t<Upload className='mr-2 h-4 w-4 opacity-50' />\n\t\t\t\t\t{t('files-action.upload')}\n\t\t\t\t</DropdownMenuItem>\n\t\t\t\t<DropdownMenuItem\n\t\t\t\t\tonClick={() => pasteItemsFromClipboard({toDirectory: currentPath})}\n\t\t\t\t\tdisabled={hidePathAndDisableActions || !hasItemsInClipboard()}\n\t\t\t\t>\n\t\t\t\t\t<RiClipboardLine className='mr-2 h-4 w-4 opacity-50' />\n\t\t\t\t\t{t('files-action.paste')}\n\t\t\t\t</DropdownMenuItem>\n\t\t\t</>\n\t\t)\n\t}\n\n\tuseEffect(() => {\n\t\tsetActionsBarConfig({\n\t\t\tdesktopActions: DesktopActions,\n\t\t\tmobileActions: MobileDropdownActions,\n\t\t\thidePath: hidePathAndDisableActions,\n\t\t\thideSearch: isBrowsingApps || isBrowsingExternalStorage || isBrowsingNetworkStorage, // hide search if browsing apps, external storage, or network\n\t\t})\n\t}, [\n\t\thidePathAndDisableActions,\n\t\tisBrowsingApps,\n\t\tisBrowsingExternalStorage,\n\t\tisViewingExternalDrives,\n\t\tisViewingNetworkDevices,\n\t\tisViewingNetworkShares,\n\t\tisBrowsingNetworkStorage,\n\t])\n\n\treturn (\n\t\t<>\n\t\t\t<UploadInput ref={uploadInputRef} />\n\t\t\t<Listing\n\t\t\t\titems={items}\n\t\t\t\ttotalItems={listing?.totalFiles}\n\t\t\t\ttruncatedAt={listing?.truncatedAt}\n\t\t\t\tselectableItems={selectableItems}\n\t\t\t\tisLoading={isLoading}\n\t\t\t\terror={error}\n\t\t\t\thasMore={listing?.hasMore ?? false}\n\t\t\t\tonLoadMore={fetchMoreItems}\n\t\t\t\tadditionalContextMenuItems={additionalContextMenuItems}\n\t\t\t\tenableFileDrop={!isViewingExternalDrives && !isViewingNetworkDevices && !isViewingNetworkShares}\n\t\t\t\tCustomEmptyView={isViewingNetworkDevices ? EmptyStateNetwork : EmptyStateDirectory}\n\t\t\t\tmarqueeScale={marqueeScale}\n\t\t\t/>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/file-item/circular-progress.tsx",
    "content": "import {motion} from 'motion/react'\n\nimport {cn} from '@/lib/utils'\n\ninterface CircularProgressProps {\n\tprogress?: number // Accept 1 to 100 (e.g. 50 = 50%)\n\tclassName?: string\n\tprogressColor?: string\n\ttrackColor?: string\n}\n\nexport const CircularProgress = ({\n\tprogress = 50,\n\tclassName = 'bg-gray-200',\n\tprogressColor = '#FFFFFF',\n\ttrackColor = 'transparent',\n}: CircularProgressProps) => {\n\t// Clamp the progress between 0 and 1\n\tconst clampedProgress = Math.max(0, Math.min(progress / 100, 1))\n\n\t// Convert progress to degrees for the conic-gradient\n\tconst degrees = clampedProgress * 360\n\n\t// Using a conic-gradient for the progress circle\n\tconst gradient = `conic-gradient(${progressColor} ${degrees}deg, ${trackColor} ${degrees}deg)`\n\n\treturn (\n\t\t<div className='absolute top-0 left-0 flex h-full w-full justify-center'>\n\t\t\t<motion.div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'mt-6 rounded-full',\n\t\t\t\t\t'h-[30px] w-[30px]',\n\t\t\t\t\t'shadow-[inset_0_0_0_2px_rgba(255,255,255,0.4)]',\n\t\t\t\t\tclassName,\n\t\t\t\t)}\n\t\t\t\tstyle={{\n\t\t\t\t\tbackground: gradient,\n\t\t\t\t}}\n\t\t\t\tanimate={{\n\t\t\t\t\tbackground: gradient,\n\t\t\t\t}}\n\t\t\t\ttransition={{\n\t\t\t\t\tbackground: {\n\t\t\t\t\t\tduration: 0.3,\n\t\t\t\t\t\tease: 'easeOut',\n\t\t\t\t\t},\n\t\t\t\t}}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/file-item/editable-name.tsx",
    "content": "import {useEffect, useRef, useState} from 'react'\nimport {AiOutlineFileExclamation} from 'react-icons/ai'\n\nimport {Button} from '@/components/ui/button'\nimport {Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle} from '@/components/ui/drawer'\nimport {Input, Labeled} from '@/components/ui/input'\nimport {useFilesOperations} from '@/features/files/hooks/use-files-operations'\nimport {useIsTouchDevice} from '@/features/files/hooks/use-is-touch-device'\nimport {useNewFolder} from '@/features/files/hooks/use-new-folder'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {splitFileName} from '@/features/files/utils/format-filesystem-name'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {cn} from '@/lib/utils'\nimport {useConfirmation} from '@/providers/confirmation'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {t} from '@/utils/i18n'\n\ninterface EditableNameProps {\n\titem: FileSystemItem\n\tview: 'icons' | 'list'\n\tonFinish: () => void\n}\n\nexport const EditableName = ({item, view, onFinish}: EditableNameProps) => {\n\tconst {name: initialName, path} = item\n\tconst inputRef = useRef<HTMLInputElement>(null)\n\tconst [name, setName] = useState(initialName)\n\tconst isTouchDevice = useIsTouchDevice()\n\tconst isMobile = useIsMobile()\n\tconst dialogProps = useSettingsDialogProps()\n\n\tconst {renameItem} = useFilesOperations()\n\tconst {cancelNewFolder, createFolder} = useNewFolder()\n\tconst confirm = useConfirmation()\n\n\tconst isCreatingNewFolder = 'isNew' in item && item.isNew\n\tconst isFolder = item.type === 'directory'\n\n\t// Wording for the mobile drawer for naming/renaming a folder or file\n\tconst drawerTitle = isCreatingNewFolder\n\t\t? t('files-name-drawer.new-folder')\n\t\t: isFolder\n\t\t\t? t('files-name-drawer.rename-folder')\n\t\t\t: t('files-name-drawer.rename-file')\n\tconst drawerDescription = isCreatingNewFolder\n\t\t? t('files-name-drawer.new-folder-description')\n\t\t: isFolder\n\t\t\t? t('files-name-drawer.rename-folder-description')\n\t\t\t: t('files-name-drawer.rename-file-description')\n\tconst inputLabel = isCreatingNewFolder\n\t\t? t('files-name-drawer.new-folder-input')\n\t\t: isFolder\n\t\t\t? t('files-name-drawer.rename-folder-input')\n\t\t\t: t('files-name-drawer.rename-file-input')\n\n\t// Focus the input after the component mounts\n\tuseEffect(() => {\n\t\tconst timer = setTimeout(() => {\n\t\t\tif (inputRef.current) {\n\t\t\t\tinputRef.current.focus()\n\t\t\t\t// if creating a new folder, select all text\n\t\t\t\tif (isCreatingNewFolder) {\n\t\t\t\t\treturn inputRef.current.select()\n\t\t\t\t}\n\n\t\t\t\t// if renaming an item, select its name minus the extension (only for files, not directories)\n\t\t\t\tif (item.type !== 'directory') {\n\t\t\t\t\tconst {name} = splitFileName(item.name)\n\t\t\t\t\treturn inputRef.current.setSelectionRange(0, name.length)\n\t\t\t\t}\n\n\t\t\t\t// select the entire name for directories\n\t\t\t\treturn inputRef.current.select()\n\t\t\t}\n\t\t}, 100)\n\t\treturn () => clearTimeout(timer)\n\t}, [])\n\n\tconst handleSubmit = async (submittedName: string) => {\n\t\tconst trimmedName = submittedName.trim()\n\t\tlet performRename = true\n\t\tif (isCreatingNewFolder) {\n\t\t\t// Calculate parent path and the full path for the new folder\n\t\t\tconst parentPath = path.split('/').slice(0, -1).join('/')\n\t\t\tconst fullPath = `${parentPath}/${trimmedName}`\n\t\t\tcreateFolder.mutate({path: fullPath})\n\t\t} else {\n\t\t\t// check if the user is changing the extension of a file\n\t\t\tif (item.type !== 'directory') {\n\t\t\t\tconst {extension: currentNameExtension} = splitFileName(initialName)\n\t\t\t\tconst {extension: toNameExtension} = splitFileName(trimmedName)\n\n\t\t\t\t// if the extension is changing, show the extension change confirmation dialog\n\t\t\t\t// and let it handle the renaming\n\t\t\t\tif (currentNameExtension !== toNameExtension) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait confirm({\n\t\t\t\t\t\t\ttitle: toNameExtension\n\t\t\t\t\t\t\t\t? t('files-extension-change.title-add', {extension: toNameExtension})\n\t\t\t\t\t\t\t\t: t('files-extension-change.title-remove'),\n\t\t\t\t\t\t\tmessage: toNameExtension\n\t\t\t\t\t\t\t\t? t('files-extension-change.description-add', {\n\t\t\t\t\t\t\t\t\t\tfileName: initialName,\n\t\t\t\t\t\t\t\t\t\textension: toNameExtension,\n\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t: t('files-extension-change.description-remove', {fileName: initialName}),\n\t\t\t\t\t\t\tactions: [\n\t\t\t\t\t\t\t\t{label: t('files-extension-change.confirm'), value: 'confirm', variant: 'destructive'},\n\t\t\t\t\t\t\t\t{label: t('cancel'), value: 'cancel', variant: 'default'},\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\ticon: AiOutlineFileExclamation,\n\t\t\t\t\t\t})\n\t\t\t\t\t\t// Confirmation passed, proceed with rename (handled below)\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// User cancelled confirmation\n\t\t\t\t\t\tperformRename = false\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (performRename) {\n\t\t\t\trenameItem({item, newName: trimmedName})\n\t\t\t}\n\t\t}\n\t\tonFinish()\n\t}\n\n\tconst handleKeyDown = (e: React.KeyboardEvent) => {\n\t\t// submit the name on Enter\n\t\tif (e.key === 'Enter') {\n\t\t\te.preventDefault()\n\t\t\te.stopPropagation()\n\t\t\treturn handleSubmit(name)\n\t\t}\n\n\t\t// cancel new folder and rename on Escape\n\t\tif (e.key === 'Escape') {\n\t\t\te.preventDefault()\n\t\t\te.stopPropagation()\n\n\t\t\t// cancel new folder\n\t\t\tif ('isNew' in item && item.isNew) {\n\t\t\t\treturn cancelNewFolder()\n\t\t\t}\n\n\t\t\t// cancel rename\n\t\t\treturn handleSubmit(initialName)\n\t\t}\n\t}\n\n\tconst preventClickPropagation = (e: React.MouseEvent) => {\n\t\t// stop propagation of the click event so it doesn't trigger an action (eg. extract archive, open, etc.)\n\t\te.stopPropagation()\n\t}\n\n\tconst handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {\n\t\te.preventDefault()\n\t\thandleSubmit(name)\n\t}\n\n\tconst handleClose = () => {\n\t\tif (isCreatingNewFolder) {\n\t\t\tcancelNewFolder()\n\t\t} else {\n\t\t\tonFinish()\n\t\t}\n\t}\n\n\tconst inputStyles = cn(\n\t\t'bg-transparent outline-hidden ring-1 ring-transparent',\n\t\t'p-0 m-0 box-border leading-[16px] tracking-[-0.04em]',\n\t\t// Show ring outline only on touch devices so that it is obvious that the input is focused\n\t\tisTouchDevice && 'focus:ring-[hsl(var(--color-brand))]',\n\t\t// icons view specific styles\n\t\tview === 'icons' && 'mt-1 line-clamp-2 w-full text-12 leading-tight text-center',\n\t\t// list view specific styles\n\t\tview === 'list' && 'text-12 w-full min-w-0 truncate',\n\t)\n\n\tif (isMobile) {\n\t\treturn (\n\t\t\t<Drawer\n\t\t\t\t{...dialogProps}\n\t\t\t\t// We set non-modal for this Drawer only on touch devices to prevent an issue where tapping the overlay to close the drawer causes a residual click event to\n\t\t\t\t// propagate to the underlying FileItem (where this drawer component is rendered) and tiggers unwanted actions like navigation or reopening the drawer.\n\t\t\t\t// We should investigate further in the future and potentially fix this somewhere higher in the layout tree.\n\t\t\t\tmodal={!isTouchDevice}\n\t\t\t\t// ensure state is reset properly when the drawer is closed\n\t\t\t\tonOpenChange={(isOpen) => {\n\t\t\t\t\tif (!isOpen) {\n\t\t\t\t\t\thandleClose()\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{/* prevent clicks inside the drawer from triggering actions */}\n\t\t\t\t<DrawerContent onClick={preventClickPropagation} onDoubleClick={preventClickPropagation}>\n\t\t\t\t\t<DrawerHeader>\n\t\t\t\t\t\t<DrawerTitle>{drawerTitle}</DrawerTitle>\n\t\t\t\t\t\t<DrawerDescription>{drawerDescription}</DrawerDescription>\n\t\t\t\t\t</DrawerHeader>\n\t\t\t\t\t<form onSubmit={handleFormSubmit} onClick={preventClickPropagation} className='flex flex-1 flex-col'>\n\t\t\t\t\t\t<fieldset className='flex flex-1 flex-col gap-5'>\n\t\t\t\t\t\t\t<Labeled label={inputLabel}>\n\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\tvalue={name}\n\t\t\t\t\t\t\t\t\tonClick={preventClickPropagation}\n\t\t\t\t\t\t\t\t\tonDoubleClick={preventClickPropagation}\n\t\t\t\t\t\t\t\t\tonValueChange={setName}\n\t\t\t\t\t\t\t\t\tonKeyDown={handleKeyDown}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</Labeled>\n\t\t\t\t\t\t\t<div className='flex-1' />\n\t\t\t\t\t\t\t<DrawerFooter>\n\t\t\t\t\t\t\t\t<Button type='button' size='dialog' onClick={handleClose}>\n\t\t\t\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<Button type='submit' size='dialog' variant='primary'>\n\t\t\t\t\t\t\t\t\t{t('ok')}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</DrawerFooter>\n\t\t\t\t\t\t</fieldset>\n\t\t\t\t\t</form>\n\t\t\t\t</DrawerContent>\n\t\t\t</Drawer>\n\t\t)\n\t}\n\n\treturn (\n\t\t<input\n\t\t\tref={inputRef}\n\t\t\ttype='text'\n\t\t\tvalue={name}\n\t\t\tonChange={(e) => setName(e.target.value)}\n\t\t\tonKeyDown={handleKeyDown}\n\t\t\tonClick={preventClickPropagation}\n\t\t\tonDoubleClick={preventClickPropagation}\n\t\t\tonBlur={() => handleSubmit(name)}\n\t\t\tclassName={inputStyles}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/file-item/icons-view-file-item.tsx",
    "content": "import {useState} from 'react'\n\nimport {CircularProgress} from '@/features/files/components/listing/file-item/circular-progress'\nimport {EditableName} from '@/features/files/components/listing/file-item/editable-name'\nimport {TruncatedFilename} from '@/features/files/components/listing/file-item/truncated-filename'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {useIsTouchDevice} from '@/features/files/hooks/use-is-touch-device'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {formatFilesystemSize} from '@/features/files/utils/format-filesystem-size'\nimport {isDirectoryANetworkDevice} from '@/features/files/utils/is-directory-a-network-device-or-share'\nimport {isDirectoryAnExternalDrivePartition} from '@/features/files/utils/is-directory-an-external-drive-partition'\nimport {isDirectoryAnUmbrelBackup} from '@/features/files/utils/is-directory-an-umbrel-backup'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\ninterface IconsViewFileItemProps {\n\titem: FileSystemItem\n\tisEditingName: boolean\n\tonEditingNameComplete: () => void\n\tfadedContent?: boolean\n}\n\nexport const IconsViewFileItem = ({\n\titem,\n\tisEditingName,\n\tonEditingNameComplete,\n\tfadedContent,\n}: IconsViewFileItemProps) => {\n\tconst isUploading = 'isUploading' in item && item.isUploading\n\tconst uploadingProgress = isUploading && 'progress' in item ? item.progress : 0\n\tconst isTouchDevice = useIsTouchDevice()\n\n\tconst [isHovered, setIsHovered] = useState(false)\n\n\treturn (\n\t\t<div\n\t\t\t// w-28 is 112px and corresponds to the fixed width of the icons view item\n\t\t\tclassName='relative flex h-full w-28 flex-col items-center gap-1 overflow-hidden p-2 text-center break-all text-ellipsis'\n\t\t\tonMouseEnter={() => setIsHovered(true)}\n\t\t\tonMouseLeave={() => setIsHovered(false)}\n\t\t>\n\t\t\t{/* Do not use animated icon for touch devices where hover doesn't make sense */}\n\t\t\t{/* We pass in isActive so that the trigger for hovering can be on a parent div */}\n\t\t\t{/* TODO: set isHovered to true when the item's context menu is open */}\n\t\t\t<div className='flex justify-center'>\n\t\t\t\t<FileItemIcon item={item} className='h-14 w-14' useAnimatedIcon={!isTouchDevice} isHovered={isHovered} />\n\t\t\t</div>\n\t\t\t<div className={cn('relative w-full flex-col items-center', fadedContent && 'opacity-50')}>\n\t\t\t\t{isEditingName ? (\n\t\t\t\t\t<EditableName item={item} view='icons' onFinish={onEditingNameComplete} />\n\t\t\t\t) : (\n\t\t\t\t\t<TruncatedFilename\n\t\t\t\t\t\tfilename={item.name}\n\t\t\t\t\t\tview='icons'\n\t\t\t\t\t\tclassName='mt-1 line-clamp-2 w-full text-center text-12 leading-tight'\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t\t<span className='w-full text-center text-12 text-white/40'>\n\t\t\t\t\t{isUploading\n\t\t\t\t\t\t? uploadingProgress === 0\n\t\t\t\t\t\t\t? t('files-state.waiting')\n\t\t\t\t\t\t\t: `${uploadingProgress}%`\n\t\t\t\t\t\t: item.type === 'directory'\n\t\t\t\t\t\t\t? isDirectoryAnExternalDrivePartition(item.path)\n\t\t\t\t\t\t\t\t? t('files-type.external-drive')\n\t\t\t\t\t\t\t\t: isDirectoryANetworkDevice(item.path)\n\t\t\t\t\t\t\t\t\t? t('files-type.network-drive')\n\t\t\t\t\t\t\t\t\t: isDirectoryAnUmbrelBackup(item.name)\n\t\t\t\t\t\t\t\t\t\t? t('files-type.umbrel-backup')\n\t\t\t\t\t\t\t\t\t\t: t('files-type.directory')\n\t\t\t\t\t\t\t: formatFilesystemSize(item.size)}\n\t\t\t\t</span>\n\t\t\t</div>\n\n\t\t\t{!!isUploading && (\n\t\t\t\t<div className='absolute inset-0 rounded-lg bg-black/35'>\n\t\t\t\t\t<CircularProgress progress={uploadingProgress} />\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/file-item/index.tsx",
    "content": "import {useCallback, useEffect, useRef, useState} from 'react'\n\nimport {IconsViewFileItem} from '@/features/files/components/listing/file-item/icons-view-file-item'\nimport {ListViewFileItem} from '@/features/files/components/listing/file-item/list-view-file-item'\nimport {Draggable, Droppable} from '@/features/files/components/shared/drag-and-drop'\nimport {useItemClick} from '@/features/files/hooks/use-item-click'\nimport {useNetworkStorage} from '@/features/files/hooks/use-network-storage'\nimport {usePreferences} from '@/features/files/hooks/use-preferences'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {isDirectoryANetworkDevice} from '@/features/files/utils/is-directory-a-network-device-or-share'\nimport {isDirectoryAnUmbrelBackup} from '@/features/files/utils/is-directory-an-umbrel-backup'\nimport {cn} from '@/lib/utils'\n\ninterface FileItemProps {\n\titem: FileSystemItem\n\titems: FileSystemItem[]\n}\n\n// Helper function to detect touch or pen events\nfunction whenTouchOrPen<E>(handler: React.PointerEventHandler<E>): React.PointerEventHandler<E> {\n\treturn (event) => (event.pointerType !== 'mouse' ? handler(event) : undefined)\n}\n\nexport const FileItem = ({item, items}: FileItemProps) => {\n\tconst {handleClick, handleDoubleClick} = useItemClick()\n\tconst isItemSelected = useFilesStore((state) => state.isItemSelected)\n\tconst selectedItems = useFilesStore((state) => state.selectedItems)\n\tconst setSelectedItems = useFilesStore((state) => state.setSelectedItems)\n\tconst clipboardItems = useFilesStore((state) => state.clipboardItems)\n\tconst clipboardMode = useFilesStore((state) => state.clipboardMode)\n\n\tconst [isEditingName, setIsEditingName] = useState(false)\n\n\tconst renamingItemPath = useFilesStore((state) => state.renamingItemPath)\n\tconst setRenamingItemPath = useFilesStore((state) => state.setRenamingItemPath)\n\tconst isUploading = 'isUploading' in item && item.isUploading\n\tconst isSelected = isItemSelected(item)\n\tconst {preferences} = usePreferences()\n\tconst view = preferences?.view\n\tconst setIsSelectingOnMobile = useFilesStore((state) => state.setIsSelectingOnMobile)\n\n\tconst {doesHostHaveMountedShares} = useNetworkStorage()\n\n\t// If the item is a network device that isn't actually mounted then we disable and fade the text content but not the icon.\n\tconst isNetworkHost = isDirectoryANetworkDevice(item.path)\n\tconst isItemInteractive = isNetworkHost ? doesHostHaveMountedShares(item.path) : true\n\n\t// Long press detection to select the item on mobile\n\t// since onContextMenu isn't triggered on mobile\n\tconst longPressTimerRef = useRef(0)\n\tconst clearLongPress = useCallback(() => {\n\t\twindow.clearTimeout(longPressTimerRef.current)\n\t}, [])\n\n\tconst handleOpenContextMenu = useCallback(() => {\n\t\tsetIsSelectingOnMobile(true)\n\t\t// Select the item if it's not already selected\n\t\tif (!isItemSelected(item)) {\n\t\t\tsetSelectedItems([item])\n\t\t} else {\n\t\t\t// Update the selected items with fresh item data from the listing.\n\t\t\t// This ensures operations are up to date (e.g., after folder creation where\n\t\t\t// the item was initially selected with empty operations).\n\t\t\tsetSelectedItems(selectedItems.map((selected) => (selected.path === item.path ? item : selected)))\n\t\t}\n\t}, [setIsSelectingOnMobile, setSelectedItems, isItemSelected, item, selectedItems])\n\n\t// Cleanup timer on unmount\n\tuseEffect(() => {\n\t\treturn () => clearLongPress()\n\t}, [clearLongPress])\n\n\t// Calculate the selection position (first, middle, last, or standalone)\n\tlet selectionPosition = ''\n\tif (isSelected && view === 'list') {\n\t\t// Get the indices of all selected items\n\t\tconst selectedPaths = selectedItems.map((i) => i.path)\n\t\tconst sortedItemIndices = items\n\t\t\t.map((i, index) => (selectedPaths.includes(i.path) ? index : -1))\n\t\t\t.filter((index) => index !== -1)\n\t\t\t.sort((a, b) => a - b)\n\n\t\t// Find the current item's index\n\t\tconst currentIndex = items.findIndex((i) => i.path === item.path)\n\n\t\t// Split the sorted indices into groups of contiguous indices\n\t\tconst groups: number[][] = []\n\t\tlet currentGroup: number[] = []\n\n\t\tsortedItemIndices.forEach((index, i) => {\n\t\t\tif (i === 0 || index !== sortedItemIndices[i - 1] + 1) {\n\t\t\t\t// Start a new group if this is the first item or there's a gap\n\t\t\t\tif (currentGroup.length > 0) {\n\t\t\t\t\tgroups.push(currentGroup)\n\t\t\t\t}\n\t\t\t\tcurrentGroup = [index]\n\t\t\t} else {\n\t\t\t\t// Continue the current group for contiguous indices\n\t\t\t\tcurrentGroup.push(index)\n\t\t\t}\n\t\t})\n\n\t\t// Add the last group\n\t\tif (currentGroup.length > 0) {\n\t\t\tgroups.push(currentGroup)\n\t\t}\n\n\t\t// Find which group contains the current item\n\t\tconst groupIndex = groups.findIndex((group) => group.includes(currentIndex))\n\n\t\tif (groupIndex !== -1) {\n\t\t\tconst group = groups[groupIndex]\n\n\t\t\t// Determine position within the group\n\t\t\tif (group.length === 1) {\n\t\t\t\t// Only item in the group\n\t\t\t\tselectionPosition = 'standalone'\n\t\t\t} else if (group[0] === currentIndex) {\n\t\t\t\t// First item in the group\n\t\t\t\tselectionPosition = 'first'\n\t\t\t} else if (group[group.length - 1] === currentIndex) {\n\t\t\t\t// Last item in the group\n\t\t\t\tselectionPosition = 'last'\n\t\t\t} else {\n\t\t\t\t// Middle item in the group\n\t\t\t\tselectionPosition = 'middle'\n\t\t\t}\n\t\t}\n\t}\n\n\t// Trigger inline rename when the global state is set for this item.\n\tuseEffect(() => {\n\t\tif (renamingItemPath === item.path) {\n\t\t\tsetIsEditingName(true)\n\t\t}\n\t}, [renamingItemPath, item.path])\n\n\tconst handlNameEditingComplete = () => {\n\t\tsetIsEditingName(false)\n\t\tsetRenamingItemPath(null)\n\t}\n\n\tconst isDotfile = (filename: string) => filename.startsWith('.')\n\n\tconst isItemCut = clipboardMode === 'cut' && clipboardItems.find((i) => i.path === item.path)\n\n\tconst isEditingFileName = isEditingName || !!('isNew' in item && item.isNew)\n\n\t// Handle rename on Enter\n\tuseEffect(() => {\n\t\t// do some checks to avoid attaching multiple listeners\n\n\t\t// ensure that the item is selected\n\t\tif (!isSelected) return\n\n\t\t// ensure that this is the only selected item\n\t\tif (selectedItems.length !== 1) return\n\n\t\t// check if the rename operation is allowed for this item\n\t\tif (!item.operations?.includes('rename')) return\n\n\t\t// helper function to check if the event target is an input\n\t\tfunction isInInput(event: KeyboardEvent) {\n\t\t\tconst target = event.target as HTMLElement | null\n\t\t\tif (!target) return false\n\t\t\treturn target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable\n\t\t}\n\n\t\tfunction handleKeyDown(event: KeyboardEvent) {\n\t\t\tif (event.key !== 'Enter') {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// don't allow renaming Umbrel Backup directory\n\t\t\tif (isDirectoryAnUmbrelBackup(item.name)) return\n\n\t\t\t// don't trigger the rename if the user Entered in the input\n\t\t\tif (isInInput(event)) return\n\n\t\t\tevent.preventDefault()\n\n\t\t\tsetIsEditingName(true)\n\t\t}\n\n\t\t// attach the listener\n\t\twindow.addEventListener('keydown', handleKeyDown)\n\n\t\t// remove the listener on cleanup\n\t\treturn () => {\n\t\t\twindow.removeEventListener('keydown', handleKeyDown)\n\t\t}\n\t}, [isSelected, selectedItems.length])\n\n\treturn (\n\t\t<div\n\t\t\tdata-selected={isItemSelected(item) ? 'true' : 'false'}\n\t\t\tdata-selection-position={selectionPosition}\n\t\t\tclassName={cn(\n\t\t\t\t`files-${view}-view-file-item`, // .files-list-view-file-item styles are applied via CSS using combinator classes\n\t\t\t\t'rounded-lg transition-colors duration-100',\n\t\t\t\tisSelected && 'bg-brand/10 shadow-[0_0_0_1px_hsl(var(--color-brand))]', // selected item styles for list view are overwritten by CSS\n\t\t\t\t!isSelected && !isUploading && 'md:hover:!border-white/6 md:hover:!bg-white/5', // don't show hover state for selected items or uploading items\n\t\t\t)}\n\t\t\tdata-marquee-selection-item-path={!isUploading ? item.path : ''} // don't enable marquee selection for uploading items\n\t\t>\n\t\t\t<Droppable\n\t\t\t\tid={`${view}-view-file-item-${item.path}`}\n\t\t\t\tpath={item.path}\n\t\t\t\tdisabled={!!isUploading || item.type !== 'directory' || !isItemInteractive}\n\t\t\t\tclassName='rounded-lg'\n\t\t\t>\n\t\t\t\t<Draggable\n\t\t\t\t\tid={`${view}-view-file-item-${item.path}`}\n\t\t\t\t\titem={item}\n\t\t\t\t\tdisabled={!!isUploading || !isItemInteractive}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tonClick={(e) => handleClick(e, item, items)}\n\t\t\t\t\t\tonDoubleClick={() => handleDoubleClick(item)}\n\t\t\t\t\t\tonContextMenu={() => {\n\t\t\t\t\t\t\thandleOpenContextMenu()\n\t\t\t\t\t\t}}\n\t\t\t\t\t\t// Add pointer events for long press detection on mobile\n\t\t\t\t\t\tonPointerDown={whenTouchOrPen(() => {\n\t\t\t\t\t\t\t// Clear any previous timer\n\t\t\t\t\t\t\tclearLongPress()\n\t\t\t\t\t\t\t// Start a new timer\n\t\t\t\t\t\t\tlongPressTimerRef.current = window.setTimeout(() => handleOpenContextMenu(), 700)\n\t\t\t\t\t\t})}\n\t\t\t\t\t\tonPointerMove={whenTouchOrPen(clearLongPress)}\n\t\t\t\t\t\tonPointerCancel={whenTouchOrPen(clearLongPress)}\n\t\t\t\t\t\tonPointerUp={whenTouchOrPen(clearLongPress)}\n\t\t\t\t\t\t// Prevent native iOS context menu/callout\n\t\t\t\t\t\tstyle={{WebkitTouchCallout: 'none'}}\n\t\t\t\t\t\tclassName={cn(isItemCut && 'opacity-50')}\n\t\t\t\t\t\trole='button'\n\t\t\t\t\t>\n\t\t\t\t\t\t{/* If the item is a dotfile, we decrease the brightness and opacity for the icon and text for a faded look */}\n\t\t\t\t\t\t<div className={cn(isDotfile(item.name) && 'opacity-50 brightness-75')}>\n\t\t\t\t\t\t\t{view === 'icons' ? (\n\t\t\t\t\t\t\t\t<IconsViewFileItem\n\t\t\t\t\t\t\t\t\titem={item}\n\t\t\t\t\t\t\t\t\tisEditingName={isEditingFileName}\n\t\t\t\t\t\t\t\t\tonEditingNameComplete={handlNameEditingComplete}\n\t\t\t\t\t\t\t\t\tfadedContent={!isItemInteractive}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t{view === 'list' ? (\n\t\t\t\t\t\t\t\t<ListViewFileItem\n\t\t\t\t\t\t\t\t\titem={item}\n\t\t\t\t\t\t\t\t\tisEditingName={isEditingFileName}\n\t\t\t\t\t\t\t\t\tonEditingNameComplete={handlNameEditingComplete}\n\t\t\t\t\t\t\t\t\tfadedContent={!isItemInteractive}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</Draggable>\n\t\t\t</Droppable>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/file-item/list-view-file-item.css",
    "content": "@reference \"../../../../../index.css\";\n\n.files-list-view-file-item {\n\t@apply rounded-lg;\n}\n\n/* Selected item */\n.files-list-view-file-item[data-selected='true'] {\n\t@apply bg-brand/10 shadow-[0_0_0_1px_hsl(var(--color-brand))];\n}\n\n/* First selected item in a sequence */\n/* Has top left radius, top right radius, top border, and side borders */\n.files-list-view-file-item[data-selection-position='first'] {\n\t@apply rounded-t-lg rounded-b-none shadow-[inset_0_1px_0_hsl(var(--color-brand)),inset_1px_0_0_hsl(var(--color-brand)),inset_-1px_0_0_hsl(var(--color-brand))];\n}\n\n/* Middle items in a sequence */\n/* Only side borders */\n.files-list-view-file-item[data-selection-position='middle'] {\n\t@apply rounded-none shadow-[inset_1px_0_0_hsl(var(--color-brand)),inset_-1px_0_0_hsl(var(--color-brand))];\n}\n\n/* Last selected item in a sequence */\n/* Has bottom left radius, bottom right radius, bottom border, and side borders */\n.files-list-view-file-item[data-selection-position='last'] {\n\t@apply rounded-t-none rounded-b-lg shadow-[inset_1px_0_0_hsl(var(--color-brand)),inset_-1px_0_0_hsl(var(--color-brand)),inset_0_-1px_0_hsl(var(--color-brand))];\n}\n\n/* Standalone selected item (including first item in list) */\n/* Has top left radius, top right radius, top border, and side borders */\n.files-list-view-file-item[data-selection-position='standalone'] {\n\t@apply rounded-lg shadow-[inset_1px_0_0_hsl(var(--color-brand)),inset_-1px_0_0_hsl(var(--color-brand)),inset_0_1px_0_hsl(var(--color-brand)),inset_0_-1px_0_hsl(var(--color-brand))];\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/file-item/list-view-file-item.tsx",
    "content": "import '@/features/files/components/listing/file-item/list-view-file-item.css'\n\nimport {Progress} from '@/components/ui/progress'\nimport {EditableName} from '@/features/files/components/listing/file-item/editable-name'\nimport {TruncatedFilename} from '@/features/files/components/listing/file-item/truncated-filename'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {FILE_TYPE_MAP} from '@/features/files/constants'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {formatFilesystemDate} from '@/features/files/utils/format-filesystem-date'\nimport {formatFilesystemSize} from '@/features/files/utils/format-filesystem-size'\nimport {isDirectoryANetworkDevice} from '@/features/files/utils/is-directory-a-network-device-or-share'\nimport {isDirectoryAnExternalDrivePartition} from '@/features/files/utils/is-directory-an-external-drive-partition'\nimport {isDirectoryAnUmbrelBackup} from '@/features/files/utils/is-directory-an-umbrel-backup'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {useLanguage} from '@/hooks/use-language'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\ninterface ListViewFileItemProps {\n\titem: FileSystemItem\n\tisEditingName: boolean\n\tonEditingNameComplete: () => void\n\tfadedContent?: boolean\n}\n\nexport function ListViewFileItem({item, isEditingName, onEditingNameComplete, fadedContent}: ListViewFileItemProps) {\n\tconst isUploading = 'isUploading' in item && item.isUploading\n\tconst uploadingProgress = isUploading && 'progress' in item ? item.progress : 0\n\n\tconst isMobile = useIsMobile()\n\tconst [languageCode] = useLanguage()\n\n\t// Get the file type name from the translation key\n\tconst fileType = item.type ? FILE_TYPE_MAP[item.type as keyof typeof FILE_TYPE_MAP]?.nameTKey : ''\n\tconst translatedFileType = fileType ? t(fileType) : item.type\n\n\t// Mobile view\n\tif (isMobile) {\n\t\treturn (\n\t\t\t<div className={cn('flex items-center gap-2 rounded-lg px-3 py-2', isUploading && 'opacity-70')}>\n\t\t\t\t<div className='flex-shrink-0'>\n\t\t\t\t\t<FileItemIcon item={item} className='h-7 w-7' />\n\t\t\t\t</div>\n\t\t\t\t<div className={cn('flex flex-1 items-center justify-between overflow-hidden', fadedContent && 'opacity-50')}>\n\t\t\t\t\t<div className='flex min-w-0 flex-1 flex-col overflow-hidden'>\n\t\t\t\t\t\t{isEditingName ? (\n\t\t\t\t\t\t\t<EditableName item={item} view='list' onFinish={onEditingNameComplete} />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<TruncatedFilename\n\t\t\t\t\t\t\t\tfilename={item.name}\n\t\t\t\t\t\t\t\tview='list'\n\t\t\t\t\t\t\t\tclassName='min-w-0 overflow-hidden pr-2 text-12 text-ellipsis whitespace-nowrap'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<span className='min-w-0 overflow-hidden text-11 text-ellipsis whitespace-nowrap text-white/40'>\n\t\t\t\t\t\t\t{isUploading\n\t\t\t\t\t\t\t\t? uploadingProgress === 0\n\t\t\t\t\t\t\t\t\t? t('files-state.waiting')\n\t\t\t\t\t\t\t\t\t: `${t('files-state.uploading')} ${uploadingProgress}%`\n\t\t\t\t\t\t\t\t: formatFilesystemDate(item.modified, languageCode)}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<span className='shrink-0 pl-2 text-right text-11 whitespace-nowrap text-white/40'>\n\t\t\t\t\t\t{item.type === 'directory'\n\t\t\t\t\t\t\t? isDirectoryAnExternalDrivePartition(item.path)\n\t\t\t\t\t\t\t\t? t('files-type.external-drive')\n\t\t\t\t\t\t\t\t: isDirectoryANetworkDevice(item.path)\n\t\t\t\t\t\t\t\t\t? t('files-type.network-drive')\n\t\t\t\t\t\t\t\t\t: isDirectoryAnUmbrelBackup(item.name)\n\t\t\t\t\t\t\t\t\t\t? t('files-type.umbrel-backup')\n\t\t\t\t\t\t\t\t\t\t: t('files-type.directory')\n\t\t\t\t\t\t\t: formatFilesystemSize(item.size ?? null)}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t)\n\t}\n\n\t// Desktop view\n\tconst tableStyles = 'text-12 p-2.5 whitespace-nowrap overflow-hidden text-ellipsis'\n\n\treturn (\n\t\t<div className={cn('flex items-center', isUploading && 'opacity-70')}>\n\t\t\t<div className={`flex-[5] ${tableStyles}`}>\n\t\t\t\t<div className='flex items-center gap-1.5'>\n\t\t\t\t\t<div className='flex-shrink-0'>\n\t\t\t\t\t\t<FileItemIcon item={item} className='h-5 w-5' />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className={cn(fadedContent && 'opacity-50')}>\n\t\t\t\t\t\t{isEditingName ? (\n\t\t\t\t\t\t\t<EditableName item={item} view='list' onFinish={onEditingNameComplete} />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<TruncatedFilename filename={item.name} view='list' className='min-w-0 text-12' />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t<div className={cn(`flex-[2] ${tableStyles} text-white/60`, fadedContent && 'opacity-50')}>\n\t\t\t\t{isUploading ? <Progress value={uploadingProgress} /> : formatFilesystemDate(item.modified, languageCode)}\n\t\t\t</div>\n\n\t\t\t<div className={cn(`flex-1 ${tableStyles} text-white/60`, fadedContent && 'opacity-50')}>\n\t\t\t\t{isUploading\n\t\t\t\t\t? `${formatFilesystemSize(\n\t\t\t\t\t\t\t((item.size ?? 0) * (uploadingProgress ?? 0)) / 100,\n\t\t\t\t\t\t)} / ${formatFilesystemSize(item.size ?? null)}`\n\t\t\t\t\t: formatFilesystemSize(item.size ?? null)}\n\t\t\t</div>\n\n\t\t\t{/* TODO: Add this back in when we have a file system index in umbreld. The name header was previously flex-[3] */}\n\t\t\t{/* <div className={`flex-[2] lg:hidden xl:flex ${tableStyles} text-white/60`}>\n\t\t\t\t{isUploading ? `${formatFilesystemSize(item.speed ?? 0)}/s` : formatFilesystemDate(item.created, languageCode)}\n\t\t\t</div> */}\n\n\t\t\t<div className={cn(`flex-[2] ${tableStyles} text-white/60`, fadedContent && 'opacity-50')}>\n\t\t\t\t{isUploading\n\t\t\t\t\t? uploadingProgress !== 0\n\t\t\t\t\t\t? t('files-state.uploading')\n\t\t\t\t\t\t: t('files-state.waiting')\n\t\t\t\t\t: item.type === 'directory' && isDirectoryAnExternalDrivePartition(item.path)\n\t\t\t\t\t\t? t('files-type.external-drive')\n\t\t\t\t\t\t: isDirectoryANetworkDevice(item.path)\n\t\t\t\t\t\t\t? t('files-type.network-drive')\n\t\t\t\t\t\t\t: isDirectoryAnUmbrelBackup(item.name)\n\t\t\t\t\t\t\t\t? t('files-type.umbrel-backup')\n\t\t\t\t\t\t\t\t: translatedFileType}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/file-item/truncated-filename.tsx",
    "content": "import {formatItemName, splitFileName} from '@/features/files/utils/format-filesystem-name'\nimport {cn} from '@/lib/utils'\n\ninterface TruncatedFilenameProps {\n\tfilename: string\n\tclassName?: string\n\tview?: 'list' | 'icons'\n}\n\nexport function TruncatedFilename({filename, className, view = 'list'}: TruncatedFilenameProps) {\n\t// In icons view, we know the parent's height/width, so we don't need to use dynamic truncation\n\tif (view === 'icons') {\n\t\treturn (\n\t\t\t<span className={cn('block w-full text-center', className)} title={filename}>\n\t\t\t\t{formatItemName({name: filename, maxLength: 30})}\n\t\t\t</span>\n\t\t)\n\t}\n\n\t// Keep last 16 characters always visible in suffix\n\tconst SUFFIX_LENGTH = 16\n\n\t// Split the same number of characters whether or not there is a file extension\n\tconst {name: fileName, extension} = splitFileName(filename)\n\tconst nameSuffixLength = extension ? Math.max(0, SUFFIX_LENGTH - extension.length) : SUFFIX_LENGTH\n\tconst prefixText = fileName.slice(0, fileName.length - nameSuffixLength)\n\tconst suffixText = fileName.slice(fileName.length - nameSuffixLength) + (extension || '')\n\n\treturn (\n\t\t<span className={cn('flex', className)} title={filename}>\n\t\t\t<span className='min-w-0 overflow-hidden text-ellipsis whitespace-nowrap'>{prefixText}</span>\n\t\t\t<span className='flex-shrink-0 whitespace-nowrap'>{suffixText}</span>\n\t\t</span>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/index.tsx",
    "content": "import {FolderX} from 'lucide-react'\nimport {ComponentType, useRef} from 'react'\nimport {TbLoader} from 'react-icons/tb'\n\nimport {Card} from '@/components/ui/card'\nimport {ListingAndFileItemContextMenu} from '@/features/files/components/listing/listing-and-file-item-context-menu'\nimport {ListingBody} from '@/features/files/components/listing/listing-body'\nimport {MarqueeSelection} from '@/features/files/components/listing/marquee-selection'\nimport {Droppable} from '@/features/files/components/shared/drag-and-drop'\nimport {FileUploadDropZone} from '@/features/files/components/shared/file-upload-drop-zone'\nimport {useFilesKeyboardShortcuts} from '@/features/files/hooks/use-files-keyboard-shortcuts'\nimport {useIsTouchDevice} from '@/features/files/hooks/use-is-touch-device'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {usePreferences} from '@/features/files/hooks/use-preferences'\nimport {useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {getFilesErrorMessage} from '@/features/files/utils/error-messages'\nimport {t} from '@/utils/i18n'\nimport {formatNumberI18n} from '@/utils/number'\n\nexport interface ListingProps {\n\titems: FileSystemItem[] // array of items to display\n\ttotalItems?: number // total number of items in the listing\n\ttruncatedAt?: number // if the listing is truncated at this number\n\tselectableItems?: FileSystemItem[] // array of items that are selectable, eg. for keyboard shortcuts we want to ignore uploading items\n\tisLoading: boolean // if the items are still loading\n\terror?: unknown // if there is an error loading the items\n\thasMore: boolean // if there are more items to load\n\tonLoadMore: () => Promise<boolean> // callback to load more items (removed startIndex)\n\tCustomEmptyView?: ComponentType // custom empty placeholder component\n\tadditionalContextMenuItems?: React.ReactNode // additional items for the context menu\n\tenableFileDrop?: boolean // if file upload drop zone is enabled\n\tmarqueeScale?: number // scale factor applied to marquee math so the overlay stays aligned inside scaled embeds (see Rewind)\n}\n\nfunction ListingContent({\n\titems,\n\ttotalItems,\n\ttruncatedAt,\n\thasMore,\n\tonLoadMore,\n\tscrollAreaRef,\n\tisLoading,\n\terror,\n\tisEmpty,\n\tCustomEmptyView,\n}: {\n\titems: FileSystemItem[]\n\ttotalItems?: number\n\ttruncatedAt?: number\n\thasMore: boolean\n\tonLoadMore: () => Promise<boolean>\n\tscrollAreaRef: React.RefObject<HTMLDivElement | null>\n\tisLoading: boolean\n\terror: unknown\n\tisEmpty: boolean\n\tCustomEmptyView?: ComponentType\n}) {\n\tconst selectedItems = useFilesStore((s) => s.selectedItems)\n\treturn (\n\t\t<Card className='h-[calc(100svh-214px)] !p-0 !pt-4 lg:h-[calc(100vh-300px)]'>\n\t\t\t{(() => {\n\t\t\t\tif (isLoading) return <LoadingView />\n\t\t\t\tif (error) return <ErrorView error={error} />\n\t\t\t\tif (isEmpty) return CustomEmptyView ? <CustomEmptyView /> : <EmptyView />\n\n\t\t\t\treturn (\n\t\t\t\t\t<ListingBody\n\t\t\t\t\t\tscrollAreaRef={scrollAreaRef}\n\t\t\t\t\t\titems={items}\n\t\t\t\t\t\thasMore={hasMore}\n\t\t\t\t\t\tisLoading={isLoading}\n\t\t\t\t\t\tonLoadMore={onLoadMore}\n\t\t\t\t\t/>\n\t\t\t\t)\n\t\t\t})()}\n\n\t\t\t{/* Display total item count (or truncated count) when no items are selected */}\n\t\t\t{totalItems && !selectedItems.length ? (\n\t\t\t\t<span className='absolute right-4 bottom-2 text-12 font-semibold text-white/60'>\n\t\t\t\t\t{truncatedAt\n\t\t\t\t\t\t? t('files-listing.item-count-truncated', {\n\t\t\t\t\t\t\t\tformattedCount: formatNumberI18n({n: truncatedAt, showDecimals: false}),\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t: t('files-listing.item-count', {\n\t\t\t\t\t\t\t\tcount: totalItems,\n\t\t\t\t\t\t\t\tformattedCount: formatNumberI18n({n: totalItems, showDecimals: false}),\n\t\t\t\t\t\t\t})}\n\t\t\t\t</span>\n\t\t\t) : null}\n\n\t\t\t{/* Display selected count vs total (or truncated count) when items are selected */}\n\t\t\t{selectedItems.length > 0 && (\n\t\t\t\t<span className='absolute right-4 bottom-2 text-12 font-semibold text-white/60'>\n\t\t\t\t\t{truncatedAt\n\t\t\t\t\t\t? t('files-listing.selected-count-truncated', {\n\t\t\t\t\t\t\t\tselectedCount: selectedItems.length,\n\t\t\t\t\t\t\t\ttotalCount: truncatedAt,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t: t('files-listing.selected-count', {\n\t\t\t\t\t\t\t\tselectedCount: selectedItems.length,\n\t\t\t\t\t\t\t\ttotalCount: totalItems,\n\t\t\t\t\t\t\t})}\n\t\t\t\t</span>\n\t\t\t)}\n\t\t</Card>\n\t)\n}\n\nexport function Listing({\n\titems,\n\ttotalItems = 0,\n\ttruncatedAt,\n\tselectableItems = [],\n\tisLoading,\n\terror,\n\thasMore = false,\n\tonLoadMore = async () => false,\n\tCustomEmptyView,\n\tadditionalContextMenuItems,\n\tenableFileDrop = true,\n\tmarqueeScale = 1,\n}: ListingProps) {\n\tconst isTouchDevice = useIsTouchDevice()\n\tconst scrollAreaRef = useRef<HTMLDivElement>(null)\n\tconst {currentPath} = useNavigate()\n\tconst isReadOnly = useIsFilesReadOnly()\n\tconst {preferences} = usePreferences()\n\n\tuseFilesKeyboardShortcuts({items: selectableItems, scrollAreaRef, view: preferences?.view ?? 'list'})\n\n\tconst isEmpty = !isLoading && items.length === 0\n\n\tconst content = (\n\t\t// Wrap in a flex column to ensure the context menu works\n\t\t<div className='flex flex-col'>\n\t\t\t<ListingContent\n\t\t\t\titems={items}\n\t\t\t\ttotalItems={totalItems}\n\t\t\t\ttruncatedAt={truncatedAt}\n\t\t\t\thasMore={hasMore}\n\t\t\t\tonLoadMore={onLoadMore}\n\t\t\t\tscrollAreaRef={scrollAreaRef}\n\t\t\t\tisLoading={isLoading}\n\t\t\t\terror={error}\n\t\t\t\tisEmpty={isEmpty}\n\t\t\t\tCustomEmptyView={CustomEmptyView}\n\t\t\t/>\n\t\t</div>\n\t)\n\n\t// if read-only, return the content without the context menu\n\tconst contentWithContextMenu = !isReadOnly ? (\n\t\t<ListingAndFileItemContextMenu menuItems={additionalContextMenuItems}>{content}</ListingAndFileItemContextMenu>\n\t) : (\n\t\tcontent\n\t)\n\n\t// For touch devices, disable marquee selection + file upload drop zone and droppable\n\tif (isTouchDevice) {\n\t\treturn contentWithContextMenu\n\t}\n\n\t// For desktop, wrap in marquee selection, enable file upload drop zone and droppable\n\treturn (\n\t\t<MarqueeSelection scrollAreaRef={scrollAreaRef} items={selectableItems} scale={marqueeScale}>\n\t\t\t{/* if read-only, return the content without the file upload drop zone */}\n\t\t\t{enableFileDrop && !isReadOnly ? (\n\t\t\t\t<FileUploadDropZone>\n\t\t\t\t\t<Droppable\n\t\t\t\t\t\tid={`files-listing-${currentPath}`}\n\t\t\t\t\t\tpath={currentPath}\n\t\t\t\t\t\tclassName='relative flex h-full flex-col outline-hidden'\n\t\t\t\t\t\tdropOverClassName='bg-transparent'\n\t\t\t\t\t>\n\t\t\t\t\t\t{contentWithContextMenu}\n\t\t\t\t\t</Droppable>\n\t\t\t\t</FileUploadDropZone>\n\t\t\t) : (\n\t\t\t\tcontentWithContextMenu\n\t\t\t)}\n\t\t</MarqueeSelection>\n\t)\n}\n\nfunction ErrorView({error}: {error: unknown}) {\n\tconst message = error instanceof Error ? error.message : t('files-listing.error')\n\n\tconst isNotFound =\n\t\tmessage.startsWith('ENOENT') ||\n\t\tmessage.startsWith('Cannot map') ||\n\t\tmessage.includes('[does-not-exist]') ||\n\t\tmessage.includes('[source-not-exists]') ||\n\t\tmessage.includes('[invalid-path]') ||\n\t\tmessage.startsWith('EIO')\n\n\treturn (\n\t\t<div className='flex h-full items-center justify-center p-4 text-center'>\n\t\t\t{isNotFound ? (\n\t\t\t\t<div className='flex flex-col items-center gap-2'>\n\t\t\t\t\t<FolderX className='h-6 w-6 opacity-50' />\n\t\t\t\t\t<span className='text-12 text-white/40'>{t('files-listing.no-such-file')}</span>\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<span className='text-12 text-white/40'>{getFilesErrorMessage(message)}</span>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\nfunction LoadingView() {\n\treturn (\n\t\t<div className='flex h-full items-center justify-center p-4'>\n\t\t\t<TbLoader className='white h-6 w-6 animate-spin opacity-50 shadow-xs' aria-label={t('files-listing.loading')} />\n\t\t</div>\n\t)\n}\n\nfunction EmptyView() {\n\treturn (\n\t\t<div className='flex h-full items-center justify-center p-4 text-center'>\n\t\t\t<div className='text-12 text-white/40'>{t('files-listing.empty')}</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/listing-and-file-item-context-menu.tsx",
    "content": "import {RiArrowDropDownLine, RiArrowDropUpLine} from 'react-icons/ri'\nimport {useNavigate} from 'react-router-dom'\n\nimport {\n\tContextMenu,\n\tContextMenuCheckboxItem,\n\tContextMenuContent,\n\tContextMenuItem,\n\tContextMenuSeparator,\n\tContextMenuShortcut,\n\tContextMenuSub,\n\tContextMenuSubContent,\n\tContextMenuSubTrigger,\n\tContextMenuTrigger,\n} from '@/components/ui/context-menu'\nimport {contextMenuClasses} from '@/components/ui/shared/menu'\nimport {SORT_BY_OPTIONS, SUPPORTED_ARCHIVE_EXTRACT_EXTENSIONS} from '@/features/files/constants'\nimport {useFavorites} from '@/features/files/hooks/use-favorites'\nimport {useFilesOperations} from '@/features/files/hooks/use-files-operations'\nimport {useIsTouchDevice} from '@/features/files/hooks/use-is-touch-device'\nimport {useItemClick} from '@/features/files/hooks/use-item-click'\nimport {useNavigate as useFilesNavigate} from '@/features/files/hooks/use-navigate'\nimport {useNetworkStorage} from '@/features/files/hooks/use-network-storage'\nimport {usePreferences} from '@/features/files/hooks/use-preferences'\nimport {useRewindAction} from '@/features/files/hooks/use-rewind-action'\nimport {useShares} from '@/features/files/hooks/use-shares'\nimport {useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport {\n\tisDirectoryANetworkDevice,\n\tisDirectoryANetworkShare,\n} from '@/features/files/utils/is-directory-a-network-device-or-share'\nimport {isDirectoryAnUmbrelBackup} from '@/features/files/utils/is-directory-an-umbrel-backup'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {useLinkToDialog} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\ninterface ListingAndFileItemContextMenuProps {\n\tchildren: React.ReactNode\n\tmenuItems?: React.ReactNode\n}\n\nexport function ListingAndFileItemContextMenu({children, menuItems}: ListingAndFileItemContextMenuProps) {\n\tconst isReadOnly = useIsFilesReadOnly()\n\tconst {preferences, setView, setSortBy} = usePreferences()\n\n\t// Files related state\n\tconst selectedItems = useFilesStore((state) => state.selectedItems)\n\tconst hasItemsInClipboard = useFilesStore((state) => state.hasItemsInClipboard)\n\tconst isItemInClipboard = useFilesStore((state) => state.isItemInClipboard)\n\n\t// Rewind action, including logic for when it can be shown and how to navigate\n\tconst {canShowRewind, onClick: onRewind} = useRewindAction(selectedItems)\n\n\t// Global rename helper\n\tconst setRenamingItemPath = useFilesStore((state) => state.setRenamingItemPath)\n\n\tconst navigate = useNavigate()\n\tconst {addLinkSearchParams} = useQueryParams()\n\n\t// Helpers\n\tconst {\n\t\trestoreSelectedItems,\n\t\ttrashSelectedItems,\n\t\tdownloadSelectedItems,\n\t\tarchiveSelectedItems,\n\t\tpasteItemsFromClipboard,\n\t\textractSelectedItems,\n\t} = useFilesOperations()\n\n\tconst {handleDoubleClick} = useItemClick()\n\n\tconst linkToDialog = useLinkToDialog()\n\n\tconst {\n\t\tisBrowsingTrash,\n\t\tisBrowsingRecents,\n\t\tisBrowsingSearch,\n\t\tisViewingExternalDrives,\n\t\tisViewingNetworkDevices,\n\t\tisViewingNetworkShares,\n\t\tnavigateToDirectory,\n\t} = useFilesNavigate()\n\n\tconst {isPathShared, isAddingShare, isRemovingShare} = useShares()\n\tconst {isPathFavorite, addFavorite, removeFavorite, isAddingFavorite, isRemovingFavorite} = useFavorites()\n\tconst {removeHostOrShare, isRemovingShare: isRemovingNetworkShare, doesHostHaveMountedShares} = useNetworkStorage()\n\tconst isTouchDevice = useIsTouchDevice()\n\n\t// If read-only, just render children without wrapping menu\n\tif (isReadOnly) return <>{children}</>\n\n\tconst hasSelectedItems = selectedItems.length > 0\n\n\t// Determine if the context menu should behave as a file menu or a listing menu.\n\tconst isFileMenu = hasSelectedItems\n\n\t// Build menu items depending on mode\n\tlet contextMenuContent: React.ReactNode = null\n\n\tif (isFileMenu) {\n\t\t// We'll base the computation on the first selected item. Some actions will\n\t\t// be disabled depending on the capabilities of all selected items.\n\t\tconst item = selectedItems[0]\n\n\t\tif (isBrowsingTrash) {\n\t\t\t// if the item is in the trash\n\t\t\tcontextMenuContent = (\n\t\t\t\t<>\n\t\t\t\t\t<ContextMenuItem onClick={restoreSelectedItems}>{t('files-action.restore')}</ContextMenuItem>\n\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\tonClick={() => navigate(linkToDialog('files-permanently-delete-confirmation'))}\n\t\t\t\t\t\tclassName={contextMenuClasses.item.rootDestructive}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('files-action.delete')}\n\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t</>\n\t\t\t)\n\t\t} else if (isViewingExternalDrives) {\n\t\t\t// if the item is actually a drive in /External\n\t\t\tcontextMenuContent = null\n\t\t} else if ('isUploading' in item && item.isUploading) {\n\t\t\t// if the item is uploading\n\t\t\tcontextMenuContent = null\n\t\t} else {\n\t\t\t// if the item is not in the trash or recents\n\t\t\tconst hasOneSelectedItem = selectedItems.length === 1\n\n\t\t\t// allow/disallow actions based on backend operations\n\t\t\tconst isUnmountedNetworkHost = isDirectoryANetworkDevice(item.path) && !doesHostHaveMountedShares(item.path)\n\t\t\tconst canOpen = hasOneSelectedItem && !isUnmountedNetworkHost && !isDirectoryAnUmbrelBackup(item.name)\n\t\t\tconst canRename =\n\t\t\t\thasOneSelectedItem && item.operations.includes('rename') && !isDirectoryAnUmbrelBackup(item.name)\n\t\t\tconst canDownload = !isUnmountedNetworkHost // disable for unmounted network hosts\n\t\t\tconst canCut = selectedItems.every((itm) => itm.operations.includes('move'))\n\t\t\tconst canCopy = selectedItems.every((itm) => itm.operations.includes('copy')) && !isUnmountedNetworkHost\n\t\t\tconst canPaste =\n\t\t\t\thasItemsInClipboard() && hasOneSelectedItem && !isItemInClipboard(item) && item.type === 'directory'\n\t\t\tconst canTrash = item.operations.includes('trash')\n\t\t\tconst canPermanentlyDelete = item.operations.includes('delete')\n\t\t\tconst canExtract = selectedItems.every(\n\t\t\t\t(itm) =>\n\t\t\t\t\titm.operations.includes('unarchive') &&\n\t\t\t\t\tSUPPORTED_ARCHIVE_EXTRACT_EXTENSIONS.some((ext) => itm.name.toLowerCase().endsWith(ext)),\n\t\t\t)\n\n\t\t\tconst canShare =\n\t\t\t\thasOneSelectedItem &&\n\t\t\t\t!isPathShared(item.path) &&\n\t\t\t\t!isAddingShare &&\n\t\t\t\titem.operations.includes('share') &&\n\t\t\t\t!isDirectoryAnUmbrelBackup(item.name)\n\t\t\tconst canRemoveShare = hasOneSelectedItem && isPathShared(item.path) && !isRemovingShare\n\t\t\tconst canFavorite =\n\t\t\t\thasOneSelectedItem &&\n\t\t\t\t!isPathFavorite(item.path) &&\n\t\t\t\t!isAddingFavorite &&\n\t\t\t\titem.operations.includes('favorite') &&\n\t\t\t\t!isDirectoryAnUmbrelBackup(item.name)\n\t\t\tconst canRemoveFavorite = hasOneSelectedItem && isPathFavorite(item.path) && !isRemovingFavorite\n\t\t\tconst canArchive = !(isViewingNetworkDevices || isViewingNetworkShares || isDirectoryAnUmbrelBackup(item.name))\n\n\t\t\t// Network eject logic\n\t\t\tconst isNetworkHost = isDirectoryANetworkDevice(item.path) // /Network/hostname\n\t\t\tconst isNetworkShare = isDirectoryANetworkShare(item.path) // /Network/hostname/share\n\t\t\tconst canEjectNetwork = (isNetworkHost || isNetworkShare) && !isRemovingNetworkShare\n\n\t\t\tconst openShareInfoDialog = () => {\n\t\t\t\tnavigate({\n\t\t\t\t\tsearch: addLinkSearchParams({\n\t\t\t\t\t\tdialog: 'files-share-info',\n\t\t\t\t\t\t'files-share-info-name': item.name,\n\t\t\t\t\t\t'files-share-info-path': item.path,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tcontextMenuContent = (\n\t\t\t\t<>\n\t\t\t\t\t{/* if browsing recents or search, show the \"show in enclosing folder\" option */}\n\t\t\t\t\t{(isBrowsingRecents || isBrowsingSearch) && (\n\t\t\t\t\t\t<ContextMenuItem onClick={() => navigateToDirectory(item.path.slice(0, -item.name.length))}>\n\t\t\t\t\t\t\t{t('files-action.show-in-folder')}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t)}\n\t\t\t\t\t{!isTouchDevice && (\n\t\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\t\tdisabled={!canOpen}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\thandleDoubleClick(item)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('files-action.open')}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t)}\n\t\t\t\t\t<ContextMenuItem disabled={!canRename} onClick={() => setRenamingItemPath(item.path)}>\n\t\t\t\t\t\t{t('files-action.rename')}\n\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t<ContextMenuItem disabled={!canDownload} onClick={downloadSelectedItems}>\n\t\t\t\t\t\t{selectedItems.length > 1\n\t\t\t\t\t\t\t? t('files-action.download-items', {count: selectedItems.length})\n\t\t\t\t\t\t\t: t('files-action.download')}\n\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t<ContextMenuSeparator />\n\t\t\t\t\t<ContextMenuItem disabled={!canCut} onClick={() => useFilesStore.getState().cutItemsToClipboard()}>\n\t\t\t\t\t\t{t('files-action.cut')}\n\t\t\t\t\t\t<ContextMenuShortcut>⌘X</ContextMenuShortcut>\n\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t<ContextMenuItem disabled={!canCopy} onClick={() => useFilesStore.getState().copyItemsToClipboard()}>\n\t\t\t\t\t\t{t('files-action.copy')}\n\t\t\t\t\t\t<ContextMenuShortcut>⌘C</ContextMenuShortcut>\n\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t<ContextMenuItem disabled={!canPaste} onClick={() => pasteItemsFromClipboard({toDirectory: item.path})}>\n\t\t\t\t\t\t{t('files-action.paste')}\n\t\t\t\t\t\t<ContextMenuShortcut>⌘V</ContextMenuShortcut>\n\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t<ContextMenuItem disabled={!canShowRewind} onClick={onRewind}>\n\t\t\t\t\t\t{t('rewind')}\n\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t{canTrash || canPermanentlyDelete ? <ContextMenuSeparator /> : null}\n\t\t\t\t\t{canTrash && (\n\t\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\t\tonClick={trashSelectedItems}\n\t\t\t\t\t\t\tclassName={contextMenuClasses.item.rootDestructive}\n\t\t\t\t\t\t\tdisabled={!canTrash}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('files-action.trash')}\n\t\t\t\t\t\t\t<ContextMenuShortcut>⌘⌫</ContextMenuShortcut>\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t)}\n\t\t\t\t\t{canPermanentlyDelete && (\n\t\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\t\tonClick={() => navigate(linkToDialog('files-permanently-delete-confirmation'))}\n\t\t\t\t\t\t\tclassName={contextMenuClasses.item.rootDestructive}\n\t\t\t\t\t\t\tdisabled={!canPermanentlyDelete}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('files-action.delete')}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t)}\n\t\t\t\t\t<ContextMenuSeparator />\n\t\t\t\t\t<ContextMenuItem disabled={!canArchive} onClick={archiveSelectedItems}>\n\t\t\t\t\t\t{t('files-action.compress')}\n\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t<ContextMenuItem disabled={!canExtract} onClick={extractSelectedItems}>\n\t\t\t\t\t\t{t('files-action.uncompress')}\n\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t<ContextMenuSeparator />\n\t\t\t\t\t{isPathShared(item.path) ? (\n\t\t\t\t\t\t<ContextMenuItem disabled={!canRemoveShare} onClick={openShareInfoDialog}>\n\t\t\t\t\t\t\t{t('files-action.sharing')}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<ContextMenuItem disabled={!canShare} onClick={openShareInfoDialog}>\n\t\t\t\t\t\t\t{t('files-action.share')}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t)}\n\t\t\t\t\t{isPathFavorite(item.path) ? (\n\t\t\t\t\t\t<ContextMenuItem disabled={!canRemoveFavorite} onClick={() => removeFavorite({path: item.path})}>\n\t\t\t\t\t\t\t{t('files-action.remove-favorite')}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<ContextMenuItem disabled={!canFavorite} onClick={() => addFavorite({path: item.path})}>\n\t\t\t\t\t\t\t{t('files-action.add-favorite')}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t)}\n\t\t\t\t\t{canEjectNetwork && (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<ContextMenuSeparator />\n\t\t\t\t\t\t\t<ContextMenuItem disabled={!canEjectNetwork} onClick={() => removeHostOrShare(item.path)}>\n\t\t\t\t\t\t\t\t{isNetworkHost ? t('files-action.remove-network-host') : t('files-action.remove-network-share')}\n\t\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t</>\n\t\t\t)\n\t\t}\n\t} else {\n\t\t// Listing menu (no items selected)\n\t\tcontextMenuContent = (\n\t\t\t<>\n\t\t\t\t{menuItems ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{menuItems}\n\t\t\t\t\t\t<ContextMenuSeparator />\n\t\t\t\t\t</>\n\t\t\t\t) : null}\n\t\t\t\t<ContextMenuSub>\n\t\t\t\t\t<ContextMenuSubTrigger>{t('files-view.view-as')}</ContextMenuSubTrigger>\n\t\t\t\t\t<ContextMenuSubContent className='w-28'>\n\t\t\t\t\t\t<ContextMenuCheckboxItem checked={preferences?.view === 'list'} onCheckedChange={() => setView('list')}>\n\t\t\t\t\t\t\t{t('files-view.list')}\n\t\t\t\t\t\t</ContextMenuCheckboxItem>\n\t\t\t\t\t\t<ContextMenuCheckboxItem checked={preferences?.view === 'icons'} onCheckedChange={() => setView('icons')}>\n\t\t\t\t\t\t\t{t('files-view.icons')}\n\t\t\t\t\t\t</ContextMenuCheckboxItem>\n\t\t\t\t\t</ContextMenuSubContent>\n\t\t\t\t</ContextMenuSub>\n\t\t\t\t<ContextMenuSub>\n\t\t\t\t\t<ContextMenuSubTrigger>{t('files-view.sort-by')}</ContextMenuSubTrigger>\n\t\t\t\t\t<ContextMenuSubContent className='w-24'>\n\t\t\t\t\t\t{SORT_BY_OPTIONS.map((option) => (\n\t\t\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\t\t\tkey={option.sortBy}\n\t\t\t\t\t\t\t\tonClick={() => setSortBy(option.sortBy)}\n\t\t\t\t\t\t\t\tclassName='flex items-center justify-between'\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t(option.labelTKey)}\n\t\t\t\t\t\t\t\t{option.sortBy === preferences?.sortBy && (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t{preferences.sortOrder === 'ascending' ? (\n\t\t\t\t\t\t\t\t\t\t\t<RiArrowDropUpLine className='h-5 w-5' />\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<RiArrowDropDownLine className='h-5 w-5' />\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</ContextMenuSubContent>\n\t\t\t\t</ContextMenuSub>\n\t\t\t</>\n\t\t)\n\t}\n\n\tif (!contextMenuContent) return children\n\n\treturn (\n\t\t<ContextMenu modal={false}>\n\t\t\t<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>\n\t\t\t<ContextMenuContent className='w-48'>{contextMenuContent}</ContextMenuContent>\n\t\t</ContextMenu>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/listing-body.tsx",
    "content": "import {RiArrowDropDownLine, RiArrowDropUpLine} from 'react-icons/ri'\n\nimport {Table, TableCell, TableHeader, TableRow} from '@/components/ui/table'\nimport {VirtualizedList} from '@/features/files/components/listing/virtualized-list'\nimport {SORT_BY_OPTIONS} from '@/features/files/constants'\nimport {usePreferences} from '@/features/files/hooks/use-preferences'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\ninterface ListingBodyProps {\n\tchildren?: React.ReactNode\n\tscrollAreaRef: React.RefObject<HTMLDivElement | null> // used by marquee selection for scrolling\n\titems: FileSystemItem[]\n\thasMore: boolean\n\tisLoading: boolean\n\tonLoadMore: (startIndex: number) => Promise<boolean>\n}\n\nexport const ListingBody = ({scrollAreaRef, items, hasMore, isLoading, onLoadMore}: ListingBodyProps) => {\n\tconst {preferences, setSortBy} = usePreferences()\n\n\t// Icons view\n\tif (preferences?.view === 'icons') {\n\t\treturn (\n\t\t\t<VirtualizedList\n\t\t\t\tscrollAreaRef={scrollAreaRef}\n\t\t\t\titems={items}\n\t\t\t\thasMore={hasMore}\n\t\t\t\tisLoading={isLoading}\n\t\t\t\tonLoadMore={onLoadMore}\n\t\t\t\tview='icons'\n\t\t\t/>\n\t\t)\n\t}\n\n\t// List view\n\tif (preferences?.view === 'list') {\n\t\treturn (\n\t\t\t<div className='flex h-full flex-col overflow-hidden'>\n\t\t\t\t{/* Desktop table header - hidden on mobile */}\n\t\t\t\t<div className='hidden flex-none lg:mx-6 lg:block'>\n\t\t\t\t\t<Table>\n\t\t\t\t\t\t<TableHeader>\n\t\t\t\t\t\t\t<TableRow className='cursor-default border-none'>\n\t\t\t\t\t\t\t\t<TableCell colSpan={5} className='py-0 pr-0 pl-0'>\n\t\t\t\t\t\t\t\t\t<div className='flex'>\n\t\t\t\t\t\t\t\t\t\t{SORT_BY_OPTIONS.map((option) => (\n\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\tkey={option.sortBy}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t'flex items-center justify-between overflow-hidden p-2.5 text-12 text-ellipsis whitespace-nowrap text-white/70',\n\t\t\t\t\t\t\t\t\t\t\t\t\toption.sortBy === 'name' && 'flex-[5]',\n\t\t\t\t\t\t\t\t\t\t\t\t\toption.sortBy === 'modified' && 'flex-[2]',\n\t\t\t\t\t\t\t\t\t\t\t\t\toption.sortBy === 'size' && 'flex-[1]',\n\t\t\t\t\t\t\t\t\t\t\t\t\t// TODO: Add this back in when we have a file system index in umbreld. The name column was previously flex-[3]\n\t\t\t\t\t\t\t\t\t\t\t\t\t// option.sortBy === 'created' && 'flex-[2] lg:hidden xl:flex',\n\t\t\t\t\t\t\t\t\t\t\t\t\toption.sortBy === 'type' && 'flex-[2]',\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => setSortBy(option.sortBy)}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{t(option.labelTKey)}\n\t\t\t\t\t\t\t\t\t\t\t\t{option.sortBy === preferences.sortBy && preferences.sortOrder === 'ascending' && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<RiArrowDropUpLine className='h-5 w-5' />\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t{option.sortBy === preferences.sortBy && preferences.sortOrder === 'descending' && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<RiArrowDropDownLine className='h-5 w-5' />\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</TableCell>\n\t\t\t\t\t\t\t</TableRow>\n\t\t\t\t\t\t</TableHeader>\n\t\t\t\t\t</Table>\n\t\t\t\t</div>\n\n\t\t\t\t<div className='flex-1 overflow-hidden'>\n\t\t\t\t\t<VirtualizedList\n\t\t\t\t\t\tscrollAreaRef={scrollAreaRef}\n\t\t\t\t\t\titems={items}\n\t\t\t\t\t\thasMore={hasMore}\n\t\t\t\t\t\tisLoading={isLoading}\n\t\t\t\t\t\tonLoadMore={onLoadMore}\n\t\t\t\t\t\tview='list'\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t)\n\t}\n\n\treturn null\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/marquee-selection.tsx",
    "content": "import React, {CSSProperties, PointerEvent, useCallback, useEffect, useRef, useState} from 'react'\n\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport type {FileSystemItem} from '@/features/files/types'\n\n// DOMVector class inspired by https://www.joshuawootonn.com/react-drag-to-select\nclass DOMVector {\n\tconstructor(\n\t\treadonly x: number,\n\t\treadonly y: number,\n\t\treadonly magnitudeX: number,\n\t\treadonly magnitudeY: number,\n\t) {}\n\n\tgetDiagonalLength(): number {\n\t\treturn Math.sqrt(Math.pow(this.magnitudeX, 2) + Math.pow(this.magnitudeY, 2))\n\t}\n\n\ttoDOMRect(): DOMRect {\n\t\treturn new DOMRect(\n\t\t\tMath.min(this.x, this.x + this.magnitudeX),\n\t\t\tMath.min(this.y, this.y + this.magnitudeY),\n\t\t\tMath.abs(this.magnitudeX),\n\t\t\tMath.abs(this.magnitudeY),\n\t\t)\n\t}\n\n\ttoTerminalPoint(): DOMPoint {\n\t\treturn new DOMPoint(this.x + this.magnitudeX, this.y + this.magnitudeY)\n\t}\n\n\tadd(vector: DOMVector): DOMVector {\n\t\treturn new DOMVector(\n\t\t\tthis.x + vector.x,\n\t\t\tthis.y + vector.y,\n\t\t\tthis.magnitudeX + vector.magnitudeX,\n\t\t\tthis.magnitudeY + vector.magnitudeY,\n\t\t)\n\t}\n\n\tclamp(rect: DOMRect): DOMVector {\n\t\treturn new DOMVector(\n\t\t\tthis.x,\n\t\t\tthis.y,\n\t\t\tMath.min(rect.width - this.x, this.magnitudeX),\n\t\t\tMath.min(rect.height - this.y, this.magnitudeY),\n\t\t)\n\t}\n}\n\nfunction rectsIntersect(rect1: DOMRect, rect2: DOMRect): boolean {\n\tif (rect1.right < rect2.left || rect2.right < rect1.left) return false\n\tif (rect1.bottom < rect2.top || rect2.bottom < rect1.top) return false\n\treturn true\n}\n\ninterface MarqueeSelectionProps {\n\titems: FileSystemItem[]\n\tscrollAreaRef: React.RefObject<HTMLDivElement | null>\n\tchildren: React.ReactNode\n\t// Optional scale factor to compensate when the listing is rendered inside a CSS transform (e.g. Rewind embeds scale the Files UI).\n\tscale?: number\n}\n\n/**\n * Marquee selection component\n *\n *  - Displays a bounding box on click-drag.\n *  - Detects intersections of elements with data-marquee-selection-item-path.\n *  - Selects items when the marquee box intersects with them.\n *  - Scrolls `scrollAreaRef` (top/bottom) when dragging near edges.\n *  - Maintains selection for items that scroll out of view during a drag operation.\n */\nexport const MarqueeSelection: React.FC<MarqueeSelectionProps> = ({\n\titems,\n\tscrollAreaRef,\n\tchildren,\n\tscale: scaleProp = 1,\n}) => {\n\t// Treat zero/undefined as \"no scale\" while still supporting explicitly passing 1.\n\tconst effectiveScale = scaleProp || 1\n\tconst containerRef = useRef<HTMLDivElement | null>(null)\n\n\tconst [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false)\n\tconst [startX, setStartX] = useState(0)\n\tconst [startY, setStartY] = useState(0)\n\tconst [, forceRender] = useState(0)\n\n\t// Track if we're auto-scrolling\n\tconst isAutoScrolling = useRef(false)\n\n\tconst setSelectedItems = useFilesStore((s) => s.setSelectedItems)\n\tconst selectedItems = useFilesStore((s) => s.selectedItems)\n\n\t// Track initial scroll position\n\tconst initialScrollOffset = useRef<{x: number; y: number}>({x: 0, y: 0})\n\t// Track current scroll offset for visual positioning\n\tconst currentScrollOffset = useRef<{x: number; y: number}>({x: 0, y: 0})\n\n\t// Track modifier keys for selection merging\n\tconst [isModifierPressed, setIsModifierPressed] = useState(false)\n\tconst initialSelection = useRef<FileSystemItem[]>([])\n\n\t// For tracking items during marquee selection - this helps with virtualized lists\n\t// where items may scroll out of view but we want to keep them selected\n\tconst selectedPaths = useRef<Set<string>>(new Set())\n\tconst unselectedPaths = useRef<Set<string>>(new Set())\n\n\t// We will use a ref to store the requestAnimationFrame ID\n\t// for scrolling so that it can be cleaned up when dragging stops.\n\tconst animationFrameIdRef = useRef<number | null>(null)\n\n\t// Add refs to track drag and scroll vectors\n\tconst dragVectorRef = useRef<DOMVector | null>(null)\n\tconst scrollVectorRef = useRef<DOMVector | null>(null)\n\n\t// Add refs to track scroll speeds\n\tconst scrollSpeedsRef = useRef<{x: number; y: number}>({x: 0, y: 0})\n\n\t// ----------------------------------------\n\t// Helpers\n\t// ----------------------------------------\n\n\t/**\n\t * Calculate the visual position of the marquee box when scrolling occurs\n\t * This function handles the visual representation that's shown to the user\n\t * and ensures it stays within the viewport.\n\t * Using the DOMVector approach for more robust rectangle handling.\n\t */\n\tconst getVisualMarqueeBox = useCallback(() => {\n\t\tif (!dragVectorRef.current || !scrollVectorRef.current) {\n\t\t\treturn {left: 0, top: 0, width: 0, height: 0}\n\t\t}\n\n\t\t// For the visual display, we need to show the selection rectangle in the viewport\n\t\t// So we need to apply vector addition and then subtract the current scroll position\n\n\t\t// Get the selection rectangle in content-relative coordinates.\n\t\tconst selectionRect = dragVectorRef.current.add(scrollVectorRef.current).toDOMRect()\n\n\t\t// Now convert to viewport coordinates by subtracting current scroll position\n\t\tlet visualLeft = selectionRect.x - currentScrollOffset.current.x\n\t\tlet visualTop = selectionRect.y - currentScrollOffset.current.y\n\t\tlet visualWidth = selectionRect.width\n\t\tlet visualHeight = selectionRect.height\n\n\t\t// Get the scroll container bounds to constrain the visual marquee\n\t\tif (scrollAreaRef.current && containerRef.current) {\n\t\t\tconst scrollRect = scrollAreaRef.current.getBoundingClientRect()\n\t\t\tconst containerRect = containerRef.current.getBoundingClientRect()\n\n\t\t\t// Calculate bounds in container-relative coordinates. Because the content itself may be scaled by\n\t\t\t// a CSS transform, we divide by `effectiveScale` to translate from physical pixels back into the\n\t\t\t// logical coordinate space we operate in.\n\t\t\tconst containerLeft = (scrollRect.left - containerRect.left) / effectiveScale\n\t\t\tconst containerRight = (scrollRect.right - containerRect.left) / effectiveScale\n\t\t\tconst containerTop = (scrollRect.top - containerRect.top) / effectiveScale\n\t\t\tconst containerBottom = (scrollRect.bottom - containerRect.top) / effectiveScale\n\n\t\t\t// Constrain the visual marquee to the scroll container's visible area\n\t\t\t// Ensure left doesn't go outside the visible area\n\t\t\tif (visualLeft < containerLeft) {\n\t\t\t\t// Adjust width to account for clipping at the left\n\t\t\t\tvisualWidth = visualWidth - (containerLeft - visualLeft)\n\t\t\t\tvisualLeft = containerLeft\n\t\t\t}\n\n\t\t\t// Ensure right doesn't go outside the visible area\n\t\t\tconst visualRight = visualLeft + visualWidth\n\t\t\tif (visualRight > containerRight) {\n\t\t\t\tvisualWidth = containerRight - visualLeft\n\t\t\t}\n\n\t\t\t// Ensure top doesn't go above the viewport\n\t\t\tif (visualTop < containerTop) {\n\t\t\t\t// Adjust height to account for clipping at the top\n\t\t\t\tvisualHeight = visualHeight - (containerTop - visualTop)\n\t\t\t\tvisualTop = containerTop\n\t\t\t}\n\n\t\t\t// Ensure bottom doesn't go below the viewport\n\t\t\tconst visualBottom = visualTop + visualHeight\n\t\t\tif (visualBottom > containerBottom) {\n\t\t\t\tvisualHeight = containerBottom - visualTop\n\t\t\t}\n\n\t\t\t// Don't allow negative dimensions (can happen during fast scrolling)\n\t\t\tvisualWidth = Math.max(0, visualWidth)\n\t\t\tvisualHeight = Math.max(0, visualHeight)\n\t\t}\n\n\t\treturn {\n\t\t\tleft: visualLeft,\n\t\t\ttop: visualTop,\n\t\t\twidth: visualWidth,\n\t\t\theight: visualHeight,\n\t\t}\n\t}, [scrollAreaRef, containerRef, dragVectorRef, scrollVectorRef, currentScrollOffset, effectiveScale])\n\n\t/**\n\t * Perform live selection detection by intersecting\n\t * the marquee box with each item that has the `data-marquee-selection-item-path`.\n\t * Maintains selection state for virtualized items that may scroll out of view.\n\t * Uses the DOMVector approach for better rectangle handling.\n\t */\n\tconst detectIntersections = useCallback(() => {\n\t\tif (!containerRef.current || !isMarqueeSelecting || !scrollAreaRef.current) return\n\t\tif (!dragVectorRef.current || !scrollVectorRef.current) return\n\n\t\t// Get latest scroll position for accurate calculations\n\t\tif (scrollAreaRef.current) {\n\t\t\tcurrentScrollOffset.current = {\n\t\t\t\tx: scrollAreaRef.current.scrollLeft,\n\t\t\t\ty: scrollAreaRef.current.scrollTop,\n\t\t\t}\n\n\t\t\t// Update scroll vector with latest scroll position\n\t\t\tscrollVectorRef.current = new DOMVector(\n\t\t\t\tinitialScrollOffset.current.x,\n\t\t\t\tinitialScrollOffset.current.y,\n\t\t\t\tcurrentScrollOffset.current.x - initialScrollOffset.current.x,\n\t\t\t\tcurrentScrollOffset.current.y - initialScrollOffset.current.y,\n\t\t\t)\n\t\t}\n\n\t\t// Get the container's bounding rectangle for coordinate conversion\n\t\tconst containerRect = containerRef.current.getBoundingClientRect()\n\n\t\t// Get the combined selection rectangle (drag + scroll)\n\t\t// This gives us content-relative coordinates\n\t\tconst combinedRect = dragVectorRef.current.add(scrollVectorRef.current).toDOMRect()\n\n\t\t// Select items that intersect with the combined rectangle\n\t\tconst selectableElements = containerRef.current.querySelectorAll<HTMLElement>('[data-marquee-selection-item-path]')\n\n\t\t// Get currently visible items\n\t\tconst visiblePaths: Set<string> = new Set()\n\t\tconst currentlyIntersectedPaths: Set<string> = new Set()\n\n\t\t// First determine which items are currently visible and which intersect with the marquee\n\t\tselectableElements.forEach((el) => {\n\t\t\tconst path = el.getAttribute('data-marquee-selection-item-path')\n\t\t\tif (!path) return\n\n\t\t\tvisiblePaths.add(path)\n\n\t\t\tconst itemRect = el.getBoundingClientRect()\n\n\t\t\t// Convert to content-relative coordinates (like in the working example). Similar to the visual\n\t\t\t// calculations above we divide by `effectiveScale` so that hit-testing happens in the unscaled space.\n\t\t\tconst translatedItemRect = new DOMRect(\n\t\t\t\t(itemRect.x - containerRect.x) / effectiveScale + scrollAreaRef.current!.scrollLeft,\n\t\t\t\t(itemRect.y - containerRect.y) / effectiveScale + scrollAreaRef.current!.scrollTop,\n\t\t\t\titemRect.width / effectiveScale,\n\t\t\t\titemRect.height / effectiveScale,\n\t\t\t)\n\n\t\t\tif (rectsIntersect(combinedRect, translatedItemRect)) {\n\t\t\t\tcurrentlyIntersectedPaths.add(path)\n\t\t\t}\n\t\t})\n\n\t\t// Update our tracking sets\n\t\t// For each currently intersected item:\n\t\tcurrentlyIntersectedPaths.forEach((path) => {\n\t\t\t// Add to selected set\n\t\t\tselectedPaths.current.add(path)\n\t\t\t// Remove from unselected set (in case it was previously unselected)\n\t\t\tunselectedPaths.current.delete(path)\n\t\t})\n\n\t\t// For each visible item that's NOT intersected:\n\t\tvisiblePaths.forEach((path) => {\n\t\t\tif (!currentlyIntersectedPaths.has(path)) {\n\t\t\t\t// Add to unselected set\n\t\t\t\tunselectedPaths.current.add(path)\n\t\t\t\t// Remove from selected set\n\t\t\t\tselectedPaths.current.delete(path)\n\t\t\t}\n\t\t})\n\n\t\t// Now determine the final selection by filtering the items array\n\t\tconst finalSelection = items.filter((item) => {\n\t\t\t// If the path is in our selected set and not in our unselected set, include it\n\t\t\treturn selectedPaths.current.has(item.path) && !unselectedPaths.current.has(item.path)\n\t\t})\n\n\t\t// If modifier key is pressed, merge with initial selection\n\t\tif (isModifierPressed) {\n\t\t\tconst mergedSelection = [...initialSelection.current]\n\t\t\tconst mergedSelectionPaths = new Set(mergedSelection.map((item) => item.path))\n\n\t\t\t// Add newly selected items that aren't in initial selection\n\t\t\tfinalSelection.forEach((item) => {\n\t\t\t\tif (!mergedSelectionPaths.has(item.path)) {\n\t\t\t\t\tmergedSelection.push(item)\n\t\t\t\t}\n\t\t\t})\n\n\t\t\tsetSelectedItems(mergedSelection)\n\t\t} else {\n\t\t\tsetSelectedItems(finalSelection.length > 0 ? finalSelection : [])\n\t\t}\n\t}, [items, isMarqueeSelecting, setSelectedItems, isModifierPressed, scrollAreaRef, effectiveScale])\n\n\t/**\n\t * Scrolls the container if the pointer is near edges.\n\t * Called on pointer move while dragging.\n\t * Supports both vertical and horizontal scrolling using requestAnimationFrame for smooth animation.\n\t */\n\tconst handleScrollOnDrag = useCallback(\n\t\t(clientX: number, clientY: number) => {\n\t\t\tif (!scrollAreaRef.current || !dragVectorRef.current) return\n\n\t\t\t// Constants for scroll behavior\n\t\t\tconst SCROLL_THRESHOLD = 20 // Distance from edge to trigger scrolling\n\t\t\tconst MIN_SCROLL_SPEED = 5 // Minimum scroll speed\n\t\t\tconst MAX_SCROLL_SPEED = 40 // Maximum scroll speed\n\t\t\tconst ACCELERATION_FACTOR = 0.3 // Acceleration factor\n\n\t\t\tconst scrollRect = scrollAreaRef.current.getBoundingClientRect()\n\n\t\t\t// Calculate how far outside the boundaries the cursor is\n\t\t\tconst distanceOutsideTop = Math.max(0, scrollRect.top - clientY)\n\t\t\tconst distanceOutsideBottom = Math.max(0, clientY - scrollRect.bottom)\n\t\t\tconst distanceOutsideLeft = Math.max(0, scrollRect.left - clientX)\n\t\t\tconst distanceOutsideRight = Math.max(0, clientX - scrollRect.right)\n\n\t\t\t// Calculate scroll speeds for all directions\n\t\t\tlet scrollSpeedY = 0\n\t\t\tlet scrollSpeedX = 0\n\t\t\tlet shouldScroll = false\n\n\t\t\t// Vertical scrolling (top/bottom)\n\t\t\t// If cursor is near or above the top edge\n\t\t\tif (distanceOutsideTop > 0 || clientY - scrollRect.top < SCROLL_THRESHOLD) {\n\t\t\t\tshouldScroll = true\n\t\t\t\tisAutoScrolling.current = true\n\n\t\t\t\t// Calculate speed based on distance\n\t\t\t\tif (distanceOutsideTop > 0) {\n\t\t\t\t\t// If outside, use distance for acceleration\n\t\t\t\t\tscrollSpeedY = -Math.min(MAX_SCROLL_SPEED, MIN_SCROLL_SPEED + distanceOutsideTop * ACCELERATION_FACTOR)\n\t\t\t\t} else {\n\t\t\t\t\t// If inside but near edge, use fixed speed\n\t\t\t\t\tconst proximity = SCROLL_THRESHOLD - (clientY - scrollRect.top)\n\t\t\t\t\tscrollSpeedY = -MIN_SCROLL_SPEED - (proximity / SCROLL_THRESHOLD) * (MAX_SCROLL_SPEED - MIN_SCROLL_SPEED)\n\t\t\t\t}\n\t\t\t}\n\t\t\t// If cursor is near or below the bottom edge\n\t\t\telse if (distanceOutsideBottom > 0 || scrollRect.bottom - clientY < SCROLL_THRESHOLD) {\n\t\t\t\tshouldScroll = true\n\t\t\t\tisAutoScrolling.current = true\n\n\t\t\t\t// Calculate speed based on distance\n\t\t\t\tif (distanceOutsideBottom > 0) {\n\t\t\t\t\t// If outside, use distance for acceleration\n\t\t\t\t\tscrollSpeedY = Math.min(MAX_SCROLL_SPEED, MIN_SCROLL_SPEED + distanceOutsideBottom * ACCELERATION_FACTOR)\n\t\t\t\t} else {\n\t\t\t\t\t// If inside but near edge, use fixed speed\n\t\t\t\t\tconst proximity = SCROLL_THRESHOLD - (scrollRect.bottom - clientY)\n\t\t\t\t\tscrollSpeedY = MIN_SCROLL_SPEED + (proximity / SCROLL_THRESHOLD) * (MAX_SCROLL_SPEED - MIN_SCROLL_SPEED)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Horizontal scrolling (left/right)\n\t\t\t// If cursor is near or left of the left edge\n\t\t\tif (distanceOutsideLeft > 0 || clientX - scrollRect.left < SCROLL_THRESHOLD) {\n\t\t\t\tshouldScroll = true\n\t\t\t\tisAutoScrolling.current = true\n\n\t\t\t\t// Calculate speed based on distance\n\t\t\t\tif (distanceOutsideLeft > 0) {\n\t\t\t\t\t// If outside, use distance for acceleration\n\t\t\t\t\tscrollSpeedX = -Math.min(MAX_SCROLL_SPEED, MIN_SCROLL_SPEED + distanceOutsideLeft * ACCELERATION_FACTOR)\n\t\t\t\t} else {\n\t\t\t\t\t// If inside but near edge, use fixed speed\n\t\t\t\t\tconst proximity = SCROLL_THRESHOLD - (clientX - scrollRect.left)\n\t\t\t\t\tscrollSpeedX = -MIN_SCROLL_SPEED - (proximity / SCROLL_THRESHOLD) * (MAX_SCROLL_SPEED - MIN_SCROLL_SPEED)\n\t\t\t\t}\n\t\t\t}\n\t\t\t// If cursor is near or right of the right edge\n\t\t\telse if (distanceOutsideRight > 0 || scrollRect.right - clientX < SCROLL_THRESHOLD) {\n\t\t\t\tshouldScroll = true\n\t\t\t\tisAutoScrolling.current = true\n\n\t\t\t\t// Calculate speed based on distance\n\t\t\t\tif (distanceOutsideRight > 0) {\n\t\t\t\t\t// If outside, use distance for acceleration\n\t\t\t\t\tscrollSpeedX = Math.min(MAX_SCROLL_SPEED, MIN_SCROLL_SPEED + distanceOutsideRight * ACCELERATION_FACTOR)\n\t\t\t\t} else {\n\t\t\t\t\t// If inside but near edge, use fixed speed\n\t\t\t\t\tconst proximity = SCROLL_THRESHOLD - (scrollRect.right - clientX)\n\t\t\t\t\tscrollSpeedX = MIN_SCROLL_SPEED + (proximity / SCROLL_THRESHOLD) * (MAX_SCROLL_SPEED - MIN_SCROLL_SPEED)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Update the scroll speeds ref so the animation frame can use latest values\n\t\t\tscrollSpeedsRef.current = {x: scrollSpeedX, y: scrollSpeedY}\n\n\t\t\t// Start or stop the animation based on whether we should be scrolling\n\t\t\tif (shouldScroll) {\n\t\t\t\t// Only set up the animation if it's not already running\n\t\t\t\tif (animationFrameIdRef.current === null) {\n\t\t\t\t\tconst scrollAndAnimate = () => {\n\t\t\t\t\t\tif (scrollAreaRef.current) {\n\t\t\t\t\t\t\t// Use the ref values for smooth scrolling\n\t\t\t\t\t\t\tscrollAreaRef.current.scrollBy({\n\t\t\t\t\t\t\t\ttop: scrollSpeedsRef.current.y,\n\t\t\t\t\t\t\t\tleft: scrollSpeedsRef.current.x,\n\t\t\t\t\t\t\t})\n\n\t\t\t\t\t\t\t// Update current scroll position\n\t\t\t\t\t\t\tcurrentScrollOffset.current = {\n\t\t\t\t\t\t\t\tx: scrollAreaRef.current.scrollLeft,\n\t\t\t\t\t\t\t\ty: scrollAreaRef.current.scrollTop,\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t// Update scroll vector with new scroll position\n\t\t\t\t\t\t\tif (scrollVectorRef.current) {\n\t\t\t\t\t\t\t\tscrollVectorRef.current = new DOMVector(\n\t\t\t\t\t\t\t\t\tinitialScrollOffset.current.x,\n\t\t\t\t\t\t\t\t\tinitialScrollOffset.current.y,\n\t\t\t\t\t\t\t\t\tcurrentScrollOffset.current.x - initialScrollOffset.current.x,\n\t\t\t\t\t\t\t\t\tcurrentScrollOffset.current.y - initialScrollOffset.current.y,\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t// Force detection of intersections every time we auto-scroll\n\t\t\t\t\t\t\t// This is critical for upward scrolling to work properly\n\t\t\t\t\t\t\tdetectIntersections()\n\n\t\t\t\t\t\t\t// Continue the animation loop only if still selecting and should scroll\n\t\t\t\t\t\t\tif (isMarqueeSelecting && shouldScroll) {\n\t\t\t\t\t\t\t\tanimationFrameIdRef.current = requestAnimationFrame(scrollAndAnimate)\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tanimationFrameIdRef.current = null\n\t\t\t\t\t\t\t\tisAutoScrolling.current = false\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tanimationFrameIdRef.current = null\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Start the animation loop\n\t\t\t\t\tanimationFrameIdRef.current = requestAnimationFrame(scrollAndAnimate)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Only cancel the animation if we're not scrolling anymore\n\t\t\t\tif (animationFrameIdRef.current !== null) {\n\t\t\t\t\tcancelAnimationFrame(animationFrameIdRef.current)\n\t\t\t\t\tanimationFrameIdRef.current = null\n\t\t\t\t\tisAutoScrolling.current = false\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t[scrollAreaRef, detectIntersections, isMarqueeSelecting],\n\t)\n\n\t// ----------------------------------------\n\t// Animation and Selection Effects\n\t// ----------------------------------------\n\n\t// Set up animation frame for selection updates when dragging but not auto-scrolling\n\tuseEffect(() => {\n\t\tif (!isMarqueeSelecting) return\n\n\t\t// If we're already auto-scrolling with animation frame, we don't need another one\n\t\t// because detectIntersections is called in the scroll animation loop\n\t\tif (isAutoScrolling.current) return\n\n\t\t// Recursive function to update selection using rAF\n\t\tconst updateSelection = () => {\n\t\t\t// Run detection\n\t\t\tdetectIntersections()\n\n\t\t\t// Schedule next frame only if still selecting and not auto-scrolling\n\t\t\tif (isMarqueeSelecting && !isAutoScrolling.current) {\n\t\t\t\tselectionAnimationRef.current = requestAnimationFrame(updateSelection)\n\t\t\t}\n\t\t}\n\n\t\t// Start the animation loop for selection updates\n\t\tconst selectionAnimationRef = {current: requestAnimationFrame(updateSelection)}\n\n\t\t// Clean up on unmount or when isMarqueeSelecting changes\n\t\treturn () => {\n\t\t\tcancelAnimationFrame(selectionAnimationRef.current)\n\t\t}\n\t}, [isMarqueeSelecting, detectIntersections, isAutoScrolling])\n\n\t// Update current scroll position when scrolling happens outside of pointer events\n\tuseEffect(() => {\n\t\tif (!isMarqueeSelecting || !scrollAreaRef.current) return\n\n\t\tconst scrollElement = scrollAreaRef.current\n\t\tconst handleScroll = () => {\n\t\t\tif (scrollElement) {\n\t\t\t\t// Update current scroll offset when scroll events happen\n\t\t\t\tcurrentScrollOffset.current = {\n\t\t\t\t\tx: scrollElement.scrollLeft,\n\t\t\t\t\ty: scrollElement.scrollTop,\n\t\t\t\t}\n\n\t\t\t\t// Update scroll vector with latest scroll position\n\t\t\t\tif (scrollVectorRef.current) {\n\t\t\t\t\tscrollVectorRef.current = new DOMVector(\n\t\t\t\t\t\tinitialScrollOffset.current.x,\n\t\t\t\t\t\tinitialScrollOffset.current.y,\n\t\t\t\t\t\tcurrentScrollOffset.current.x - initialScrollOffset.current.x,\n\t\t\t\t\t\tcurrentScrollOffset.current.y - initialScrollOffset.current.y,\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Add scroll event listener to capture all scrolling\n\t\tscrollElement.addEventListener('scroll', handleScroll)\n\n\t\treturn () => {\n\t\t\tscrollElement.removeEventListener('scroll', handleScroll)\n\t\t}\n\t}, [isMarqueeSelecting, scrollAreaRef])\n\n\t// Cleanup any remaining animation frames on unmount\n\tuseEffect(() => {\n\t\treturn () => {\n\t\t\tif (animationFrameIdRef.current !== null) {\n\t\t\t\tcancelAnimationFrame(animationFrameIdRef.current)\n\t\t\t}\n\t\t}\n\t}, [])\n\n\t// ----------------------------------------\n\t// Event Handlers\n\t// ----------------------------------------\n\n\tconst onPointerDown = (e: PointerEvent<HTMLDivElement>) => {\n\t\t// Only handle primary button (left-click)\n\t\tif (e.button !== 0) return\n\n\t\t// If the click is on an interactive element, don't start marquee selection\n\t\t// [vaul-overlay] is the modal overlay for the shadcn Drawer component that we use for the editable name modal (gets clicked when blurring the Drawer)\n\t\tconst target = e.target as HTMLElement\n\t\tif (target.closest('button, a, input, [role=\"button\"], [role=\"link\"], [role=\"menuitem\"], [vaul-overlay]')) {\n\t\t\treturn\n\t\t}\n\n\t\t// If there's an active input (eg. new folder name input), blur it\n\t\tconst activeElement = document.activeElement as HTMLElement | null\n\t\tif (activeElement && activeElement.tagName === 'INPUT') {\n\t\t\tactiveElement.blur()\n\t\t}\n\n\t\t// We do not allow selection in scrollbar areas\n\t\tif (scrollAreaRef.current) {\n\t\t\t// check if the element has a vertical scrollbar\n\t\t\tconst hasVerticalScrollbar = scrollAreaRef.current.scrollHeight > scrollAreaRef.current.clientHeight\n\t\t\tif (hasVerticalScrollbar) {\n\t\t\t\tconst rect = scrollAreaRef.current.getBoundingClientRect()\n\t\t\t\t// 11px is the width of the scrollbar (see main index.css)\n\t\t\t\tif (e.clientX > rect.right - 11 && e.clientX <= rect.right) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Make this element capture pointer events\n\t\ttarget.setPointerCapture(e.pointerId)\n\n\t\tconst containerRect = containerRef.current?.getBoundingClientRect()\n\t\tif (!containerRect) return\n\n\t\t// Store initial scroll position\n\t\tif (scrollAreaRef.current) {\n\t\t\tinitialScrollOffset.current = {\n\t\t\t\tx: scrollAreaRef.current.scrollLeft,\n\t\t\t\ty: scrollAreaRef.current.scrollTop,\n\t\t\t}\n\t\t\t// Initialize current scroll position to initial\n\t\t\tcurrentScrollOffset.current = {\n\t\t\t\tx: scrollAreaRef.current.scrollLeft,\n\t\t\t\ty: scrollAreaRef.current.scrollTop,\n\t\t\t}\n\t\t}\n\n\t\t// Check if modifier key is pressed\n\t\tconst hasModifier = e.shiftKey || e.metaKey || e.ctrlKey\n\t\tsetIsModifierPressed(hasModifier)\n\n\t\t// Store initial selection if using modifier\n\t\tif (hasModifier && selectedItems) {\n\t\t\tinitialSelection.current = selectedItems\n\t\t} else {\n\t\t\tinitialSelection.current = []\n\t\t\tsetSelectedItems([])\n\t\t}\n\n\t\t// Reset our tracking sets\n\t\tselectedPaths.current = new Set()\n\t\tunselectedPaths.current = new Set()\n\n\t\t// Reset auto-scrolling flag\n\t\tisAutoScrolling.current = false\n\n\t\t// Convert to container-local coordinates. We divide by `effectiveScale` to undo any transforms\n\t\t// applied to the rendered content so pointer math stays aligned.\n\t\tconst x = (e.clientX - containerRect.left) / effectiveScale\n\t\tconst y = (e.clientY - containerRect.top) / effectiveScale\n\n\t\t// Initialize drag vector with position and zero magnitude\n\t\tdragVectorRef.current = new DOMVector(x, y, 0, 0)\n\n\t\t// Initialize scroll vector with scroll position and zero magnitude\n\t\tscrollVectorRef.current = new DOMVector(\n\t\t\tscrollAreaRef.current?.scrollLeft || 0,\n\t\t\tscrollAreaRef.current?.scrollTop || 0,\n\t\t\t0,\n\t\t\t0,\n\t\t)\n\n\t\tsetIsMarqueeSelecting(true)\n\t\tsetStartX(x)\n\t\tsetStartY(y)\n\t\tforceRender((tick) => tick + 1)\n\t}\n\n\tconst onPointerMove = (e: PointerEvent<HTMLDivElement>) => {\n\t\tif (!isMarqueeSelecting || !scrollAreaRef.current) return\n\n\t\te.preventDefault()\n\n\t\tconst containerRect = containerRef.current?.getBoundingClientRect()\n\t\tif (!containerRect) return\n\n\t\t// Update current scroll position\n\t\tcurrentScrollOffset.current = {\n\t\t\tx: scrollAreaRef.current.scrollLeft,\n\t\t\ty: scrollAreaRef.current.scrollTop,\n\t\t}\n\n\t\t// Calculate vectors using the approach from the working example\n\t\t// This is critical for upward scrolling to work properly\n\n\t\t// Create drag vector (from start point to current mouse position)\n\t\tconst pointerX = (e.clientX - containerRect.left) / effectiveScale\n\t\tconst pointerY = (e.clientY - containerRect.top) / effectiveScale\n\n\t\tdragVectorRef.current = new DOMVector(startX, startY, pointerX - startX, pointerY - startY)\n\n\t\t// Create scroll vector (from initial scroll position)\n\t\tscrollVectorRef.current = new DOMVector(\n\t\t\tinitialScrollOffset.current.x,\n\t\t\tinitialScrollOffset.current.y,\n\t\t\tcurrentScrollOffset.current.x - initialScrollOffset.current.x,\n\t\t\tcurrentScrollOffset.current.y - initialScrollOffset.current.y,\n\t\t)\n\n\t\t// Update current pointer position to trigger re-render\n\t\tforceRender((tick) => tick + 1)\n\n\t\tif (!isAutoScrolling.current) {\n\t\t\tdetectIntersections()\n\t\t}\n\n\t\t// Auto-scroll if near container edges\n\t\thandleScrollOnDrag(e.clientX, e.clientY)\n\t}\n\n\tconst onPointerUp = (e: PointerEvent<HTMLDivElement>) => {\n\t\tif (!isMarqueeSelecting) return\n\n\t\te.preventDefault()\n\t\t// Release pointer capture\n\t\t;(e.target as HTMLElement).releasePointerCapture(e.pointerId)\n\n\t\tsetIsMarqueeSelecting(false)\n\n\t\t// Clear our tracking sets when we're done\n\t\tselectedPaths.current = new Set()\n\t\tunselectedPaths.current = new Set()\n\n\t\t// Reset auto-scrolling flag\n\t\tisAutoScrolling.current = false\n\n\t\t// Reset vector references\n\t\tdragVectorRef.current = null\n\t\tscrollVectorRef.current = null\n\n\t\t// Reset scroll speeds\n\t\tscrollSpeedsRef.current = {x: 0, y: 0}\n\n\t\t// Clear any scroll intervals\n\t\tif (animationFrameIdRef.current !== null) {\n\t\t\tcancelAnimationFrame(animationFrameIdRef.current)\n\t\t\tanimationFrameIdRef.current = null\n\t\t}\n\t}\n\n\t// ----------------------------------------\n\t// Render\n\t// ----------------------------------------\n\tconst visualMarqueeBox = getVisualMarqueeBox()\n\tconst marqueeStyle: CSSProperties = {\n\t\tdisplay: isMarqueeSelecting ? 'block' : 'none',\n\t\tleft: visualMarqueeBox.left,\n\t\ttop: visualMarqueeBox.top,\n\t\twidth: visualMarqueeBox.width,\n\t\theight: visualMarqueeBox.height,\n\t}\n\n\treturn (\n\t\t<div\n\t\t\tref={containerRef}\n\t\t\tclassName='relative size-full'\n\t\t\tonPointerDown={onPointerDown}\n\t\t\tonPointerMove={onPointerMove}\n\t\t\tonPointerUp={onPointerUp}\n\t\t>\n\t\t\t{/* The Marquee Selection Box */}\n\t\t\t<div\n\t\t\t\tclassName='pointer-events-none absolute z-50 overflow-hidden border border-slate-400 bg-slate-400/15'\n\t\t\t\tstyle={marqueeStyle}\n\t\t\t/>\n\n\t\t\t{/* The wrapped content */}\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/recents-listing/index.tsx",
    "content": "import {useEffect} from 'react'\n\nimport {Listing} from '@/features/files/components/listing'\nimport {useSetActionsBarConfig} from '@/features/files/components/listing/actions-bar/actions-bar-context'\nimport {useListRecents} from '@/features/files/hooks/use-list-recents'\n\nexport function RecentsListing() {\n\tconst {listing, isLoading, error} = useListRecents()\n\tconst items = listing || []\n\tconst setActionsBarConfig = useSetActionsBarConfig()\n\n\tuseEffect(() => {\n\t\tsetActionsBarConfig({\n\t\t\thidePath: !!error,\n\t\t})\n\t}, [error])\n\n\treturn (\n\t\t<Listing\n\t\t\titems={items}\n\t\t\ttotalItems={items.length} // Since there's no pagination for recents, as it's capped at 50\n\t\t\tselectableItems={items}\n\t\t\tisLoading={isLoading}\n\t\t\terror={error}\n\t\t\thasMore={false} // we only track 50 max recents, which is less than the initial batch size\n\t\t\tonLoadMore={async () => false} // no-op since we don't need to load more\n\t\t\tenableFileDrop={false}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/search-listing/index.tsx",
    "content": "// The search query is read directly from the URL (the `q` query parameter). We\n// intentionally avoid any internal component state so that\n// - The browser's address bar always reflects the current search.\n// - The browser back-button naturally returns the user to the results after\n//   they navigate into a file or folder.\n\nimport {useEffect} from 'react'\nimport {useSearchParams} from 'react-router-dom'\n\nimport {Listing} from '@/features/files/components/listing'\nimport {useSetActionsBarConfig} from '@/features/files/components/listing/actions-bar/actions-bar-context'\nimport {useSearchFiles} from '@/features/files/hooks/use-search-files'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function SearchListing() {\n\tconst clearSelectedItems = useFilesStore((state) => state.clearSelectedItems)\n\n\tconst setActionsBarConfig = useSetActionsBarConfig()\n\tconst userName = trpcReact.user.get.useQuery().data?.name\n\n\t// read the current search term from the URL\n\tconst [params] = useSearchParams()\n\tconst queryParam = params.get('q') ?? ''\n\n\tuseEffect(() => {\n\t\t// clear any selected items that the user may have selected from the\n\t\t// previous search results\n\t\tclearSelectedItems()\n\t}, [queryParam])\n\n\tuseEffect(() => {\n\t\tsetActionsBarConfig({\n\t\t\tpathLabel: t('files-search.searching-label', {name: userName ?? 'Umbrel'}),\n\t\t\thideSearch: false,\n\t\t})\n\t}, [userName])\n\n\t// query the backend – the hook internally short-circuits when provided an\n\t// empty string, so clearing the search box stops the requests\n\tconst {results, isLoading, isError, error} = useSearchFiles({query: queryParam})\n\n\t// search results are currently returned in a single batch so we keep\n\t// pagination disabled\n\treturn (\n\t\t<Listing\n\t\t\titems={results}\n\t\t\ttotalItems={results.length}\n\t\t\tselectableItems={results}\n\t\t\tisLoading={isLoading}\n\t\t\terror={isError ? error : undefined}\n\t\t\thasMore={false}\n\t\t\tonLoadMore={async () => false}\n\t\t\tCustomEmptyView={() => <EmptySearchView query={queryParam} />}\n\t\t\tenableFileDrop={false} // disable dropping files\n\t\t/>\n\t)\n}\n\nfunction EmptySearchView({query}: {query: string}) {\n\treturn (\n\t\t<div className='flex h-full items-center justify-center text-xs text-neutral-500'>\n\t\t\t{query === '' ? t('files-search.default') : t('files-search.no-results', {query})}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/trash-listing/index.tsx",
    "content": "import {useEffect} from 'react'\n\nimport {ContextMenuItem} from '@/components/ui/context-menu'\nimport {DropdownMenuItem} from '@/components/ui/dropdown-menu'\nimport {IconButton} from '@/components/ui/icon-button'\nimport {FlameIcon} from '@/features/files/assets/flame-icon'\nimport {Listing} from '@/features/files/components/listing'\nimport {useSetActionsBarConfig} from '@/features/files/components/listing/actions-bar/actions-bar-context'\nimport {useFilesOperations} from '@/features/files/hooks/use-files-operations'\nimport {useListDirectory} from '@/features/files/hooks/use-list-directory'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useConfirmation} from '@/providers/confirmation'\nimport {t} from '@/utils/i18n'\n\nexport function TrashListing() {\n\tconst {currentPath} = useNavigate()\n\tconst {listing, isLoading, error, fetchMoreItems} = useListDirectory(currentPath)\n\tconst {emptyTrash} = useFilesOperations()\n\tconst confirm = useConfirmation()\n\tconst setActionsBarConfig = useSetActionsBarConfig()\n\n\tconst items = listing?.items || []\n\tconst isTrashEmpty = items.length === 0\n\n\tconst handleEmptyTrash = async () => {\n\t\tif (isTrashEmpty) return\n\t\ttry {\n\t\t\tawait confirm({\n\t\t\t\ttitle: t('files-empty-trash.title'),\n\t\t\t\tmessage: t('files-empty-trash.description'),\n\t\t\t\tactions: [\n\t\t\t\t\t{label: t('files-empty-trash.confirm'), value: 'confirm', variant: 'destructive'},\n\t\t\t\t\t{label: t('cancel'), value: 'cancel', variant: 'default'},\n\t\t\t\t],\n\t\t\t\ticon: FlameIcon,\n\t\t\t})\n\t\t\temptyTrash()\n\t\t} catch {\n\t\t\t// User cancelled\n\t\t}\n\t}\n\n\tconst disableActionsAndHidePath = isTrashEmpty || !!error\n\n\tconst additionalContextMenuItems = (\n\t\t<ContextMenuItem onClick={handleEmptyTrash} disabled={disableActionsAndHidePath}>\n\t\t\t{t('files-action.empty-trash')}\n\t\t</ContextMenuItem>\n\t)\n\n\tconst DesktopActions = (\n\t\t<IconButton\n\t\t\ticon={FlameIcon}\n\t\t\tonClick={handleEmptyTrash}\n\t\t\tdisabled={disableActionsAndHidePath}\n\t\t\tclassName={disableActionsAndHidePath ? 'pointer-events-none opacity-60' : ''}\n\t\t>\n\t\t\t{t('files-action.empty-trash')}\n\t\t</IconButton>\n\t)\n\n\tconst MobileActions = (\n\t\t<DropdownMenuItem onClick={handleEmptyTrash} disabled={disableActionsAndHidePath}>\n\t\t\t<FlameIcon className='mr-2 h-4 w-4 opacity-50' />\n\t\t\t{t('files-action.empty-trash')}\n\t\t</DropdownMenuItem>\n\t)\n\n\tuseEffect(() => {\n\t\tsetActionsBarConfig({\n\t\t\tdesktopActions: DesktopActions,\n\t\t\tmobileActions: MobileActions,\n\t\t\thidePath: !!error,\n\t\t\thideSearch: true,\n\t\t})\n\t}, [disableActionsAndHidePath])\n\n\treturn (\n\t\t<Listing\n\t\t\titems={items}\n\t\t\ttotalItems={listing?.totalFiles}\n\t\t\ttruncatedAt={listing?.truncatedAt}\n\t\t\tselectableItems={items}\n\t\t\tisLoading={isLoading}\n\t\t\terror={error}\n\t\t\thasMore={listing?.hasMore ?? false}\n\t\t\tonLoadMore={fetchMoreItems}\n\t\t\tenableFileDrop={false}\n\t\t\tadditionalContextMenuItems={additionalContextMenuItems}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/listing/virtualized-list.tsx",
    "content": "import React, {useCallback, useEffect, useRef, useState} from 'react'\nimport AutoSizer from 'react-virtualized-auto-sizer'\nimport {FixedSizeGrid, FixedSizeList, GridChildComponentProps, ListChildComponentProps} from 'react-window'\nimport InfiniteLoader from 'react-window-infinite-loader'\n\nimport {FileItem} from '@/features/files/components/listing/file-item'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {getGridColumnCount} from '@/features/files/utils/get-grid-column-count'\nimport {getItemKey} from '@/features/files/utils/get-item-key'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\n\n// Hook to detect scroll in react-window components so we can apply custom fade styling\nconst useScrollFade = () => {\n\tconst [isScrolled, setIsScrolled] = useState(false)\n\tconst containerRef = useRef<HTMLDivElement>(null)\n\n\t// Memoize the scroll handler to avoid recreation on re-renders\n\tconst handleScroll = useCallback((event: Event) => {\n\t\tconst scrollElement = event.target as HTMLElement\n\t\tsetIsScrolled(scrollElement.scrollTop > 0)\n\t}, [])\n\n\tuseEffect(() => {\n\t\tconst container = containerRef.current\n\t\tif (!container) return\n\n\t\t// Find the scrollable element created by react-window\n\t\tconst findScrollElement = () => {\n\t\t\treturn container.querySelector('[style*=\"overflow: auto\"], [style*=\"overflow:auto\"]') as HTMLElement | null\n\t\t}\n\n\t\t// Try to find the scroll element immediately\n\t\tlet scrollElement = findScrollElement()\n\n\t\t// If not found immediately, use a mutation observer to detect when it's added\n\t\tlet observer: MutationObserver | null = null\n\n\t\tif (!scrollElement) {\n\t\t\tobserver = new MutationObserver(() => {\n\t\t\t\tscrollElement = findScrollElement()\n\t\t\t\tif (scrollElement) {\n\t\t\t\t\tscrollElement.addEventListener('scroll', handleScroll)\n\t\t\t\t\t// Check initial position\n\t\t\t\t\tsetIsScrolled(scrollElement.scrollTop > 0)\n\t\t\t\t\tobserver?.disconnect()\n\t\t\t\t\tobserver = null\n\t\t\t\t}\n\t\t\t})\n\n\t\t\tobserver.observe(container, {childList: true, subtree: true})\n\t\t} else {\n\t\t\t// Element found immediately\n\t\t\tscrollElement.addEventListener('scroll', handleScroll)\n\t\t\t// Check initial position\n\t\t\tsetIsScrolled(scrollElement.scrollTop > 0)\n\t\t}\n\n\t\t// Cleanup function\n\t\treturn () => {\n\t\t\tif (scrollElement) {\n\t\t\t\tscrollElement.removeEventListener('scroll', handleScroll)\n\t\t\t}\n\t\t\tif (observer) {\n\t\t\t\tobserver.disconnect()\n\t\t\t}\n\t\t}\n\t}, [handleScroll])\n\n\treturn {containerRef, isScrolled}\n}\n\n// These overscan amounts control how many rows are rendered outside the visible react-window area (both above and below the area)\n// so that items do not appear to render suddenly when scrolling\n// We use a lower value for grid view to prevent performance issues during marquee selection. If there are 6 items (columns) in a row,\n// then an overscan of 2 will render an extra 24 items (12 items above and 12 items below) which becomes expensive for marquee selection.\nconst LIST_OVERSCAN_AMOUNT = 20\nconst GRID_OVERSCAN_AMOUNT = 2\n\n// Used to trigger fetching more items when only a certain number of items are left to render\nconst INFINITE_LOADER_THRESHOLD = 100\n\ninterface VirtualizedListProps {\n\titems: FileSystemItem[]\n\thasMore: boolean\n\tisLoading: boolean\n\tonLoadMore: (startIndex: number) => Promise<boolean>\n\tscrollAreaRef: React.RefObject<HTMLDivElement | null>\n\tview: 'list' | 'icons'\n}\n\n/**\n * Common index range used for virtualized rendering\n * - visibleStartIndex/visibleStopIndex: The first/last item indexes currently visible\n * - overscanStartIndex/overscanStopIndex: The first/last item indexes in the render buffer\n */\ninterface IndexRange {\n\tvisibleStartIndex: number\n\tvisibleStopIndex: number\n\toverscanStartIndex: number\n\toverscanStopIndex: number\n}\n\n/**\n * Props provided by InfiniteLoader to its render function\n * - onItemsRendered: Callback to notify which items are currently rendered\n * - ref: Ref to be passed to the underlying List/Grid component\n */\ninterface InfiniteLoaderRenderProps {\n\tonItemsRendered: (indices: IndexRange) => void\n\tref: React.Ref<FixedSizeList | FixedSizeGrid>\n}\n\n/**\n * Position information provided by Grid's onItemsRendered callback\n * Used to calculate which rows and columns are currently visible\n */\ninterface GridVisibleIndices {\n\tvisibleRowStartIndex: number\n\tvisibleRowStopIndex: number\n\tvisibleColumnStartIndex: number\n\tvisibleColumnStopIndex: number\n}\n\n/**\n * Data passed to grid cells for rendering items\n * Contains both the item array and layout dimensions\n */\ninterface GridItemData {\n\titems: FileSystemItem[]\n\tcolumnCount: number\n\thorizontalGap: number\n\tverticalGap: number\n\titemHeight: number\n\titemWidth: number\n\tborderAllowance: number\n\ttotalWidth: number\n}\n\nexport const VirtualizedList: React.FC<VirtualizedListProps> = ({\n\titems,\n\thasMore,\n\tisLoading,\n\tonLoadMore,\n\tscrollAreaRef,\n\tview,\n}) => {\n\tconst infiniteLoaderRef = useRef<InfiniteLoader>(null)\n\tconst isMobile = useIsMobile()\n\tconst {containerRef, isScrolled} = useScrollFade()\n\n\tconst isItemsEmpty = items.length === 0\n\n\t// Reset the loader cache when items change significantly\n\tuseEffect(() => {\n\t\tif (infiniteLoaderRef.current) {\n\t\t\tinfiniteLoaderRef.current.resetloadMoreItemsCache(true)\n\t\t}\n\t}, [isItemsEmpty])\n\n\t// Add an extra slot when more items can be loaded - this acts as a trigger point\n\t// for InfiniteLoader but doesn't render anything visible (both rendering functions return null for this slot)\n\tconst itemCount = hasMore ? items.length + 1 : items.length\n\n\t// Callback for loading more items - passed to InfiniteLoader\n\tconst loadMoreItems = useCallback(\n\t\tasync (startIndex: number) => {\n\t\t\tawait onLoadMore(startIndex)\n\t\t},\n\t\t[onLoadMore],\n\t)\n\n\t// Check if an item at a given index is loaded - passed to InfiniteLoader\n\tconst isItemLoaded = useCallback(\n\t\t(index: number) => {\n\t\t\treturn !hasMore || index < items.length\n\t\t},\n\t\t[hasMore, items.length],\n\t)\n\n\t// Render row for list view\n\tconst renderListRow = useCallback(\n\t\t({index, style, data}: ListChildComponentProps<number>) => {\n\t\t\t// Skip rendering if we don't have the item yet (instead of showing a loader)\n\t\t\tif (!isItemLoaded(index) || index >= items.length) {\n\t\t\t\treturn null\n\t\t\t}\n\n\t\t\tconst item = items[index]\n\t\t\t// We apply background color directly based on item index instead of relying on CSS :nth-child because we are using infinite scrolling where the item count is dynamic\n\t\t\tconst isEvenRow = index % 2 === 1\n\n\t\t\treturn (\n\t\t\t\t<div\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\t...style,\n\t\t\t\t\t\t// data contains the container width in pixels (passed via itemData prop)\n\t\t\t\t\t\t// Using fixed width prevents rows from shrinking when scrollbar appears\n\t\t\t\t\t\twidth: data,\n\t\t\t\t\t}}\n\t\t\t\t\tkey={getItemKey(item)}\n\t\t\t\t\tdata-marquee-selection-item-path={item.path}\n\t\t\t\t\tclassName={`files-list-view-file-item relative rounded-lg ${isEvenRow ? 'bg-white/3' : ''}`}\n\t\t\t\t>\n\t\t\t\t\t<FileItem item={item} items={items} />\n\t\t\t\t</div>\n\t\t\t)\n\t\t},\n\t\t[items, isItemLoaded],\n\t)\n\n\t// Calculate grid dimensions based on container width\n\t// We cannot use simple flexbox css because we are using react-window for virtualization\n\tconst getGridDimensions = useCallback((width: number) => {\n\t\tconst itemWidth = 112 // Fixed item width of 112px\n\t\tconst minGap = 8 // Prevents borders overlapping at certain screen sizes\n\t\tconst borderAllowance = 2 // Extra space on each side for selection borders\n\t\tconst fixedVerticalGap = 24 // Prevents multi-line file name items from overlapping\n\n\t\t// Adjust item width to include border allowance\n\t\tconst containerWidth = itemWidth + borderAllowance * 2\n\n\t\t// Calculate how many columns can fit with minimum gap enforced\n\t\tconst columnCount = getGridColumnCount(width)\n\n\t\t// Now calculate the actual horizontal gap that will be used\n\t\t// We'll ensure this is at least minGap\n\t\tlet horizontalGap = minGap\n\n\t\tif (columnCount > 1) {\n\t\t\t// Calculate the total width available for gaps\n\t\t\tconst totalItemsWidth = columnCount * containerWidth\n\t\t\tconst availableSpaceForGaps = width - totalItemsWidth\n\n\t\t\t// Calculate gap size that would evenly distribute items\n\t\t\tconst calculatedGap = availableSpaceForGaps / (columnCount - 1)\n\n\t\t\t// Use the calculated gap if it's larger than our minimum\n\t\t\thorizontalGap = Math.max(minGap, calculatedGap)\n\t\t}\n\n\t\t// Use a larger fixed vertical gap to prevent wrapped text from overlapping\n\t\tconst verticalGap = fixedVerticalGap\n\n\t\t// Set item height and row height separately - row height includes the gap\n\t\tconst itemHeight = 120 // Height of each item itself\n\t\tconst rowHeight = itemHeight + verticalGap // Row height includes vertical gap\n\n\t\treturn {\n\t\t\tcolumnCount,\n\t\t\tcolumnWidth: containerWidth, // Column width includes border allowance\n\t\t\titemWidth, // The actual item width without border allowance\n\t\t\trowHeight,\n\t\t\titemHeight,\n\t\t\thorizontalGap,\n\t\t\tverticalGap,\n\t\t\ttotalWidth: width,\n\t\t\tborderAllowance,\n\t\t}\n\t}, [])\n\n\t// Render cell for grid view\n\tconst renderGridCell = useCallback(\n\t\t({columnIndex, rowIndex, style, data}: GridChildComponentProps) => {\n\t\t\tconst {items, columnCount, horizontalGap, verticalGap, itemHeight, itemWidth, borderAllowance, totalWidth} =\n\t\t\t\tdata as GridItemData\n\n\t\t\tconst index = rowIndex * columnCount + columnIndex\n\n\t\t\t// Skip rendering if index is out of bounds or item not loaded\n\t\t\tif (index >= itemCount || !isItemLoaded(index) || index >= items.length) return null\n\n\t\t\tconst item = items[index]\n\t\t\tif (!item) return null\n\n\t\t\t// Calculate the container width (item width + border allowance)\n\t\t\tconst containerWidth = itemWidth + borderAllowance * 2\n\n\t\t\t// Handle special case for single column to center it\n\t\t\tconst leftPosition =\n\t\t\t\tcolumnCount === 1 ? (totalWidth - containerWidth) / 2 : columnIndex * (containerWidth + horizontalGap)\n\n\t\t\t// Calculate top position based on row index\n\t\t\tconst topPosition = rowIndex * (itemHeight + verticalGap)\n\n\t\t\t// Apply proper margin and spacing for grid items\n\t\t\tconst adjustedStyle = {\n\t\t\t\t...style,\n\t\t\t\tleft: leftPosition,\n\t\t\t\ttop: topPosition,\n\t\t\t\twidth: containerWidth,\n\t\t\t\theight: itemHeight, // Use the full item height\n\t\t\t}\n\n\t\t\treturn (\n\t\t\t\t<div\n\t\t\t\t\tstyle={adjustedStyle}\n\t\t\t\t\tkey={getItemKey(item)}\n\t\t\t\t\tclassName='relative flex items-start justify-center overflow-visible pt-3'\n\t\t\t\t\tdata-marquee-selection-item-path={item.path}\n\t\t\t\t>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName='flex h-full w-full flex-col items-center justify-start'\n\t\t\t\t\t\tstyle={{padding: `${rowIndex === 0 ? borderAllowance : 0}px ${borderAllowance}px 0`}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<FileItem item={item} items={items} />\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t)\n\t\t},\n\t\t[itemCount, isItemLoaded],\n\t)\n\n\t/**\n\t * Converts grid-based indices to flat list indices for InfiniteLoader\n\t * InfiniteLoader works with a flat list of items, but Grid uses row/column indices\n\t */\n\tconst gridToListIndices = useCallback(\n\t\t(gridIndices: GridVisibleIndices): IndexRange => {\n\t\t\tconst {visibleRowStartIndex, visibleRowStopIndex, visibleColumnStartIndex, visibleColumnStopIndex} = gridIndices\n\t\t\tconst columnCount = getGridDimensions(window.innerWidth).columnCount\n\n\t\t\treturn {\n\t\t\t\tvisibleStartIndex: visibleRowStartIndex * columnCount + visibleColumnStartIndex,\n\t\t\t\tvisibleStopIndex: visibleRowStopIndex * columnCount + visibleColumnStopIndex,\n\t\t\t\toverscanStartIndex: Math.max(0, (visibleRowStartIndex - GRID_OVERSCAN_AMOUNT) * columnCount),\n\t\t\t\toverscanStopIndex: Math.min(itemCount - 1, (visibleRowStopIndex + GRID_OVERSCAN_AMOUNT + 1) * columnCount - 1),\n\t\t\t}\n\t\t},\n\t\t[getGridDimensions, itemCount],\n\t)\n\n\tif (isLoading) return null\n\n\treturn (\n\t\t<div\n\t\t\tref={containerRef}\n\t\t\tclassName={`umbrel-files-fade-scroller h-full w-full overflow-auto p-6 pt-0 ${isScrolled ? 'scrolled' : ''}`}\n\t\t>\n\t\t\t<AutoSizer>\n\t\t\t\t{({height, width}: {height: number; width: number}) => {\n\t\t\t\t\t// ======== LIST VIEW ========\n\t\t\t\t\tif (view === 'list') {\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<InfiniteLoader\n\t\t\t\t\t\t\t\tref={infiniteLoaderRef}\n\t\t\t\t\t\t\t\tisItemLoaded={isItemLoaded}\n\t\t\t\t\t\t\t\titemCount={itemCount}\n\t\t\t\t\t\t\t\tloadMoreItems={loadMoreItems}\n\t\t\t\t\t\t\t\tthreshold={INFINITE_LOADER_THRESHOLD}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{/* InfiniteLoader's render prop provides methods to attach to the List */}\n\t\t\t\t\t\t\t\t{({onItemsRendered, ref}: InfiniteLoaderRenderProps) => (\n\t\t\t\t\t\t\t\t\t<FixedSizeList\n\t\t\t\t\t\t\t\t\t\tref={ref as React.Ref<FixedSizeList>}\n\t\t\t\t\t\t\t\t\t\theight={height}\n\t\t\t\t\t\t\t\t\t\twidth={width + 24} // Add 24px to push scrollbar into parent padding\n\t\t\t\t\t\t\t\t\t\titemCount={itemCount}\n\t\t\t\t\t\t\t\t\t\titemSize={isMobile ? 50 : 40}\n\t\t\t\t\t\t\t\t\t\titemData={width} // Pass the actual width for fixed row width\n\t\t\t\t\t\t\t\t\t\tonItemsRendered={onItemsRendered}\n\t\t\t\t\t\t\t\t\t\touterRef={scrollAreaRef} // For marquee selection\n\t\t\t\t\t\t\t\t\t\toverscanCount={LIST_OVERSCAN_AMOUNT}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{renderListRow}\n\t\t\t\t\t\t\t\t\t</FixedSizeList>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</InfiniteLoader>\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\t// ======== GRID VIEW ========\n\t\t\t\t\telse {\n\t\t\t\t\t\tconst dimensions = getGridDimensions(width)\n\t\t\t\t\t\tconst {columnCount, columnWidth, rowHeight} = dimensions\n\n\t\t\t\t\t\t// Calculate the exact number of rows needed\n\t\t\t\t\t\tconst itemsRowCount = Math.ceil(items.length / columnCount)\n\t\t\t\t\t\tconst rowCount = hasMore ? itemsRowCount + 1 : itemsRowCount\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<InfiniteLoader\n\t\t\t\t\t\t\t\tref={infiniteLoaderRef}\n\t\t\t\t\t\t\t\tisItemLoaded={isItemLoaded}\n\t\t\t\t\t\t\t\titemCount={itemCount}\n\t\t\t\t\t\t\t\tloadMoreItems={loadMoreItems}\n\t\t\t\t\t\t\t\tthreshold={INFINITE_LOADER_THRESHOLD}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{/* InfiniteLoader's render prop provides methods to attach to the Grid */}\n\t\t\t\t\t\t\t\t{({onItemsRendered, ref}: InfiniteLoaderRenderProps) => (\n\t\t\t\t\t\t\t\t\t<FixedSizeGrid\n\t\t\t\t\t\t\t\t\t\tref={ref as React.Ref<FixedSizeGrid>}\n\t\t\t\t\t\t\t\t\t\theight={height}\n\t\t\t\t\t\t\t\t\t\twidth={width + 24}\n\t\t\t\t\t\t\t\t\t\trowCount={rowCount}\n\t\t\t\t\t\t\t\t\t\tcolumnCount={columnCount}\n\t\t\t\t\t\t\t\t\t\trowHeight={rowHeight}\n\t\t\t\t\t\t\t\t\t\tcolumnWidth={columnWidth}\n\t\t\t\t\t\t\t\t\t\toverscanRowCount={GRID_OVERSCAN_AMOUNT}\n\t\t\t\t\t\t\t\t\t\titemData={{...dimensions, items}} // Grid cells need both dimensions and items\n\t\t\t\t\t\t\t\t\t\touterRef={scrollAreaRef} // For marquee selection\n\t\t\t\t\t\t\t\t\t\tonItemsRendered={(gridIndices: GridVisibleIndices) => {\n\t\t\t\t\t\t\t\t\t\t\t// Convert grid coordinates to flat list indices for InfiniteLoader\n\t\t\t\t\t\t\t\t\t\t\tonItemsRendered(gridToListIndices(gridIndices))\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{renderGridCell}\n\t\t\t\t\t\t\t\t\t</FixedSizeGrid>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</InfiniteLoader>\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t</AutoSizer>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/mini-browser/index.tsx",
    "content": "import {ChevronRight, FolderPlus, Loader2} from 'lucide-react'\nimport {useEffect, useMemo, useRef, useState} from 'react'\n\nimport {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'\nimport {toast} from '@/components/ui/toast'\nimport {BACKUP_FILE_NAME} from '@/features/backups/utils/filepath-helpers'\nimport {EmptyFolderIcon} from '@/features/files/assets/empty-folder-icon'\nimport externalStorageIcon from '@/features/files/assets/external-storage-icon.png'\nimport activeNasIcon from '@/features/files/assets/nas-icon-active.png'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {useListDirectory} from '@/features/files/hooks/use-list-directory'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {getFilesErrorMessage} from '@/features/files/utils/error-messages'\nimport {isDirectoryANetworkDevice} from '@/features/files/utils/is-directory-a-network-device-or-share'\nimport {useIsMobile, useIsSmallMobile} from '@/hooks/use-is-mobile'\nimport {cn} from '@/lib/utils'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\ntype MiniBrowserProps = {\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n\t// The root path to start the tree from\n\trootPath: string\n\t// The path to expand to when the browser is opened\n\tonOpenPath?: string\n\t// If true (default), preselects onOpenPath when opened. Set false to require explicit selection.\n\tpreselectOnOpen?: boolean\n\tonSelect?: (path: string) => void\n\t// Allow selecting files and folders, or only folders (defaults to files-and-folders)\n\tselectionMode?: 'files-and-folders' | 'folders'\n\t// Disabled paths\n\tdisabledPaths?: string[]\n\ttitle?: string\n\t// Optional faded description below the title\n\tsubtitle?: React.ReactNode\n\t// optional actions to render in the browser. e.g., \"add NAS\" button to open the add NAS dialog\n\tactions?: React.ReactNode\n\t// Optional function to determine which items are selectable\n\tselectableFilter?: (entry: FileSystemItem) => boolean\n\t// Allow creating new folders\n\tallowNewFolderCreation?: boolean\n\t// Custom button label (overrides default based on selectionMode)\n\tselectButtonLabel?: string\n}\n\n// Visual indentation cap the Tree so it doesn't get too wide and overflow\nconst INDENT_PER_LEVEL = 16\nconst MAX_INDENT_LEVELS = 9\nconst MOBILE_MAX_INDENT_LEVELS = 6\n// Number of ancestor segments (in addition to the final name) to include in the compact path label\nconst PATH_ANCESTORS_TO_SHOW = 1\n\n// Truncates a path to a compact string with the given number of ancestors to show\n// e.g., /a/b/c/d.txt -> a/b/c/d.txt -> …/d.txt\nfunction formatCompactPath(path: string, ancestorsToShow: number) {\n\tconst parts = path.replace(/\\/+$/, '').split('/').filter(Boolean)\n\tconst take = Math.max(1, Math.min(parts.length, ancestorsToShow + 1))\n\tconst last = parts.slice(-take)\n\tconst prefix = parts.length > last.length ? '…/' : ''\n\treturn prefix + last.join('/')\n}\n\n// MAIN COMPONENT\nexport function MiniBrowser({\n\topen,\n\tonOpenChange,\n\trootPath,\n\tdisabledPaths = [],\n\tonOpenPath = rootPath,\n\tpreselectOnOpen = true,\n\tonSelect,\n\tselectionMode = 'files-and-folders',\n\ttitle = t('mini-browser.default-title'),\n\tsubtitle,\n\tactions,\n\tselectableFilter,\n\tallowNewFolderCreation = false,\n\tselectButtonLabel,\n}: MiniBrowserProps) {\n\tconst [selected, setSelected] = useState<{path: string; isDirectory: boolean} | null>(null)\n\tconst [newFolder, setNewFolder] = useState<(FileSystemItem & {isNew: boolean}) | null>(null)\n\tconst utils = trpcReact.useUtils()\n\tconst isMobile = useIsMobile()\n\n\t// Set selection on open if preselectOnOpen is true\n\t// e.g., if we want to show a previously selected path when the mini browser is opened\n\tuseEffect(() => {\n\t\tif (!open) return\n\t\tif (preselectOnOpen) setSelected({path: onOpenPath, isDirectory: true})\n\t\telse setSelected(null)\n\t\tsetNewFolder(null) // Clear any pending new folder when dialog opens\n\t}, [open, onOpenPath, preselectOnOpen])\n\n\tconst finalSelectButtonLabel = selectButtonLabel ?? t('mini-browser.select')\n\n\tconst isSelectionValid = (() => {\n\t\t// Must have a selection\n\t\tif (!selected) return false\n\n\t\t// Must not be in disabled paths\n\t\tif (disabledPaths.includes(selected.path)) return false\n\n\t\t// Use custom filter if provided\n\t\tif (selectableFilter) {\n\t\t\treturn selectableFilter({\n\t\t\t\tpath: selected.path,\n\t\t\t\ttype: selected.isDirectory ? 'directory' : 'file',\n\t\t\t\tname: selected.path.split('/').pop() || '',\n\t\t\t} as FileSystemItem)\n\t\t}\n\n\t\t// Default validation based on selection mode\n\t\t// Allow both files and folders\n\t\tif (selectionMode === 'files-and-folders') return true\n\n\t\t// Only allow folders\n\t\tif (selectionMode === 'folders') return selected.isDirectory\n\n\t\t// Fallback: no valid selection\n\t\treturn false\n\t})()\n\n\tconst createFolder = trpcReact.files.createDirectory.useMutation({\n\t\tonSuccess: (_, {path}: {path: string}) => {\n\t\t\tsetNewFolder(null)\n\t\t\tutils.files.list.invalidate()\n\t\t\t// Select the newly created folder\n\t\t\tsetSelected({path, isDirectory: true})\n\t\t},\n\t\tonError: (error) => {\n\t\t\ttoast.error(t('files-error.create-folder', {message: getFilesErrorMessage(error.message)}))\n\t\t\tsetNewFolder(null)\n\t\t},\n\t})\n\n\tconst currentPath = selected?.isDirectory ? selected.path : rootPath\n\n\tconst handleNewFolder = () => {\n\t\tconst parentPath = currentPath\n\n\t\t// Prevent folder creation at Network host level (folders here would be shares, not regular folders)\n\t\t// External device level is allowed\n\t\tif (isDirectoryANetworkDevice(parentPath)) return\n\n\t\tconst name = t('files-folder')\n\n\t\tconst timestamp = new Date().getTime()\n\t\tconst newFolderItem: FileSystemItem & {isNew: boolean} = {\n\t\t\tname,\n\t\t\tpath: parentPath + '/' + name,\n\t\t\ttype: 'directory',\n\t\t\tsize: 0,\n\t\t\tmodified: timestamp,\n\t\t\toperations: [],\n\t\t\tisNew: true,\n\t\t}\n\n\t\tsetNewFolder(newFolderItem)\n\t\t// Clear selection so the parent folder doesn't show selected while editing the new folder\n\t\tsetSelected(null)\n\t}\n\n\tconst newFolderButton = allowNewFolderCreation ? (\n\t\t<Button\n\t\t\tvariant='default'\n\t\t\tonClick={handleNewFolder}\n\t\t\tdisabled={!!newFolder || isDirectoryANetworkDevice(currentPath)}\n\t\t\tsize='default'\n\t\t\tclassName={isMobile ? '' : 'mr-auto'}\n\t\t>\n\t\t\t<FolderPlus className={isMobile ? 'size-4' : 'mr-2 size-4'} />\n\t\t\t{t('files-action.new-folder')}\n\t\t</Button>\n\t) : null\n\n\treturn (\n\t\t<Dialog open={open} onOpenChange={onOpenChange}>\n\t\t\t<DialogContent className='max-w-[720px]'>\n\t\t\t\t<DialogHeader>\n\t\t\t\t\t<div className='flex items-center justify-between gap-2'>\n\t\t\t\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t\t\t\t<DialogTitle>{title}</DialogTitle>\n\t\t\t\t\t\t\t{subtitle ? <p className='mt-1 text-xs text-white/60'>{subtitle}</p> : null}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/* Show new folder button on mobile in header */}\n\t\t\t\t\t\t{isMobile && newFolderButton}\n\t\t\t\t\t</div>\n\t\t\t\t</DialogHeader>\n\n\t\t\t\t<div className='h-[min(60vh,480px)] overflow-x-hidden overflow-y-auto rounded-xl border border-white/10 bg-white/5 p-2'>\n\t\t\t\t\t{/* Optional actions to render in the browser. e.g., \"add NAS\" button to open the add NAS dialog */}\n\t\t\t\t\t{actions ? <div className='flex items-center justify-end'>{actions}</div> : null}\n\n\t\t\t\t\t{/* The tree of files and folders */}\n\t\t\t\t\t<Tree\n\t\t\t\t\t\tinitialPath={rootPath}\n\t\t\t\t\t\texpandTo={onOpenPath}\n\t\t\t\t\t\tonSelect={(p, isDirectory) => setSelected({path: p, isDirectory})}\n\t\t\t\t\t\tselectedPath={selected?.path ?? null}\n\t\t\t\t\t\tselectionMode={selectionMode}\n\t\t\t\t\t\tselectableFilter={selectableFilter}\n\t\t\t\t\t\tnewFolder={newFolder}\n\t\t\t\t\t\tonCancelNewFolder={() => setNewFolder(null)}\n\t\t\t\t\t\tonCreateFolder={(path) => createFolder.mutate({path})}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\n\t\t\t\t<DialogFooter className='mt-4'>\n\t\t\t\t\t{/* Show new folder button on desktop in footer */}\n\t\t\t\t\t{!isMobile && newFolderButton}\n\t\t\t\t\t<Button variant='primary' onClick={() => selected && onSelect?.(selected.path)} disabled={!isSelectionValid}>\n\t\t\t\t\t\t{finalSelectButtonLabel}\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button variant='default' onClick={() => onOpenChange(false)}>\n\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t</Button>\n\t\t\t\t</DialogFooter>\n\t\t\t</DialogContent>\n\t\t</Dialog>\n\t)\n}\n\n// This Tree component renders the root of the tree (top-level nodes). Each Node can expand to show its Subtree.\n// This component kicks off the recursive rendering: Tree -> Node -> Subtree -> Node -> ...\nfunction Tree({\n\tinitialPath,\n\tonSelect,\n\tselectedPath,\n\texpandTo,\n\tselectionMode,\n\tselectableFilter,\n\tnewFolder,\n\tonCancelNewFolder,\n\tonCreateFolder,\n}: {\n\tinitialPath: string\n\tonSelect: (p: string, isDirectory: boolean) => void\n\tselectedPath: string | null\n\texpandTo?: string\n\tselectionMode: 'folders' | 'files-and-folders'\n\tselectableFilter?: (entry: FileSystemItem) => boolean\n\tnewFolder: (FileSystemItem & {isNew: boolean}) | null\n\tonCancelNewFolder: () => void\n\tonCreateFolder: (path: string) => void\n}) {\n\tconst {listing, isLoading} = useListDirectory(initialPath)\n\n\t// Tailored empty state message and icon for known roots\n\tconst emptyStateText = useMemo(() => {\n\t\tif (initialPath.startsWith('/Network')) return t('mini-browser.empty-network')\n\t\tif (initialPath.startsWith('/External')) return t('mini-browser.empty-external')\n\t\treturn t('files-empty.directory')\n\t}, [initialPath])\n\n\tconst EmptyIcon = useMemo(() => {\n\t\tif (initialPath.startsWith('/Network'))\n\t\t\treturn (props: {className?: string}) => (\n\t\t\t\t<img src={activeNasIcon} alt={t('nas')} className={props.className} draggable={false} />\n\t\t\t)\n\t\tif (initialPath.startsWith('/External'))\n\t\t\treturn (props: {className?: string}) => (\n\t\t\t\t<img src={externalStorageIcon} alt={t('external-drive')} className={props.className} draggable={false} />\n\t\t\t)\n\t\treturn EmptyFolderIcon\n\t}, [initialPath])\n\n\tconst entries: FileSystemItem[] = useMemo(() => {\n\t\treturn (listing?.items as FileSystemItem[]) ?? []\n\t}, [listing])\n\n\t// Check if the new folder should be rendered at this level\n\tconst shouldRenderNewFolder = newFolder && newFolder.path.startsWith(initialPath + '/')\n\tconst newFolderParent = newFolder ? newFolder.path.split('/').slice(0, -1).join('/') : ''\n\tconst isNewFolderAtThisLevel = shouldRenderNewFolder && newFolderParent === initialPath\n\n\treturn (\n\t\t<div className='space-y-1'>\n\t\t\t{isLoading ? (\n\t\t\t\t<div className='py-6 text-center text-white/60'>{t('files-listing.loading')}</div>\n\t\t\t) : entries.length === 0 && !isNewFolderAtThisLevel ? (\n\t\t\t\t<div className='mt-28 flex flex-col items-center justify-center gap-3 text-center'>\n\t\t\t\t\t<div className='flex flex-col items-center gap-3'>\n\t\t\t\t\t\t<div className='inline-flex size-[60px] items-center justify-center'>\n\t\t\t\t\t\t\t<EmptyIcon className={'max-h-full max-w-full opacity-40'} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className='w-3/4 text-12 text-white/40'>{emptyStateText}</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<>\n\t\t\t\t\t{entries.map((d) => (\n\t\t\t\t\t\t<Node\n\t\t\t\t\t\t\tkey={d.path}\n\t\t\t\t\t\t\tentry={d}\n\t\t\t\t\t\t\t// depth=0 means root-level nodes\n\t\t\t\t\t\t\tdepth={0}\n\t\t\t\t\t\t\tonSelect={onSelect}\n\t\t\t\t\t\t\tselectedPath={selectedPath}\n\t\t\t\t\t\t\texpandTo={expandTo}\n\t\t\t\t\t\t\tselectionMode={selectionMode}\n\t\t\t\t\t\t\tselectableFilter={selectableFilter}\n\t\t\t\t\t\t\tnewFolder={newFolder}\n\t\t\t\t\t\t\tonCancelNewFolder={onCancelNewFolder}\n\t\t\t\t\t\t\tonCreateFolder={onCreateFolder}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t\t{isNewFolderAtThisLevel && (\n\t\t\t\t\t\t<NewFolderNode entry={newFolder} depth={0} onCancel={onCancelNewFolder} onCreate={onCreateFolder} />\n\t\t\t\t\t)}\n\t\t\t\t</>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\n// This is a tree node that renders a single file or folder and, if expanded and it's a directory, its Subtree\nfunction Node({\n\tentry,\n\tdepth,\n\tonSelect,\n\tselectedPath,\n\texpandTo,\n\tselectionMode,\n\tselectableFilter,\n\tnewFolder,\n\tonCancelNewFolder,\n\tonCreateFolder,\n}: {\n\tentry: FileSystemItem\n\tdepth: number\n\tonSelect: (p: string, isDirectory: boolean) => void\n\tselectedPath: string | null\n\texpandTo?: string\n\tselectionMode: 'folders' | 'files-and-folders'\n\tselectableFilter?: (entry: FileSystemItem) => boolean\n\tnewFolder: (FileSystemItem & {isNew: boolean}) | null\n\tonCancelNewFolder: () => void\n\tonCreateFolder: (path: string) => void\n}) {\n\tconst [expanded, setExpanded] = useState(false)\n\tconst userInteractedRef = useRef(false)\n\tconst isMobile = useIsSmallMobile()\n\tconst maxIndentLevels = isMobile ? MOBILE_MAX_INDENT_LEVELS : MAX_INDENT_LEVELS\n\n\tconst toggle = async () => {\n\t\tuserInteractedRef.current = true\n\t\tsetExpanded((prev) => !prev)\n\t}\n\n\t// Auto-expand toward target path, but respect user collapse afterwards\n\tuseEffect(() => {\n\t\tif (!expandTo) return\n\t\tif (userInteractedRef.current) return\n\t\tconst isSelfOrAncestor = expandTo === entry.path || expandTo.startsWith(entry.path + '/')\n\t\tconst isDir = entry.type === 'directory'\n\t\tif (isSelfOrAncestor && !expanded && isDir) {\n\t\t\tsetExpanded(true)\n\t\t}\n\t}, [expandTo, expanded, entry.path, entry.type])\n\n\t// Auto-expand when a new folder is being created inside this directory\n\tuseEffect(() => {\n\t\tif (!newFolder) return\n\t\tconst newFolderParent = newFolder.path.split('/').slice(0, -1).join('/')\n\t\tconst isDir = entry.type === 'directory'\n\t\tif (newFolderParent === entry.path && !expanded && isDir) {\n\t\t\tsetExpanded(true)\n\t\t}\n\t}, [newFolder, expanded, entry.path, entry.type])\n\n\tconst isSelected = selectedPath === entry.path\n\t// TODO: get rid of this, and have the backend return the repository directory as a file type instead\n\tconst isRepositoryDir = entry.type === 'directory' && entry.name === BACKUP_FILE_NAME\n\n\t// Selection logic: when selectableFilter is provided we use it; otherwise use default directory/file rules\n\tconst isSelectable = selectableFilter\n\t\t? selectableFilter(entry)\n\t\t: entry.type === 'directory' || selectionMode === 'files-and-folders'\n\n\t// Visual disabling: only show disabled state when NOT using selectableFilter (preserves expand/collapse UX)\n\tconst isDisabled = !selectableFilter && !isSelectable\n\tconst isFaded = (isDisabled || !isSelectable) && entry.type !== 'directory'\n\n\treturn (\n\t\t<div>\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'flex min-w-0 items-center gap-2 rounded-md p-2',\n\t\t\t\t\tisDisabled && 'cursor-not-allowed opacity-60',\n\t\t\t\t\tisSelected ? 'border border-brand bg-brand/15' : 'border border-transparent hover:bg-white/10',\n\t\t\t\t\tisFaded && 'opacity-50',\n\t\t\t\t)}\n\t\t\t\tstyle={{paddingLeft: 8 + Math.min(depth, maxIndentLevels) * INDENT_PER_LEVEL}}\n\t\t\t\tonClick={() => {\n\t\t\t\t\tif (isDisabled || !isSelectable) return\n\t\t\t\t\tonSelect(entry.path, entry.type === 'directory')\n\t\t\t\t}}\n\t\t\t\tonDoubleClick={() => {\n\t\t\t\t\tif (isRepositoryDir || entry.type !== 'directory') return\n\t\t\t\t\ttoggle()\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<ChevronRight\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t'size-4 shrink-0 transition-transform',\n\t\t\t\t\t\texpanded && 'rotate-90',\n\t\t\t\t\t\t(isRepositoryDir || entry.type !== 'directory') && 'opacity-0',\n\t\t\t\t\t)}\n\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\tif (isRepositoryDir || entry.type !== 'directory') return\n\t\t\t\t\t\ttoggle()\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<FileItemIcon item={entry} className='size-5' />\n\t\t\t\t<span className='min-w-0 flex-1 truncate text-sm' title={entry.path}>\n\t\t\t\t\t{depth > maxIndentLevels ? formatCompactPath(entry.path, PATH_ANCESTORS_TO_SHOW) : entry.name}\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t\t{expanded && entry.type === 'directory' && !isRepositoryDir && (\n\t\t\t\t<Subtree\n\t\t\t\t\tpath={entry.path}\n\t\t\t\t\tdepth={depth + 1}\n\t\t\t\t\tonSelect={onSelect}\n\t\t\t\t\tselectedPath={selectedPath}\n\t\t\t\t\texpandTo={expandTo}\n\t\t\t\t\tselectionMode={selectionMode}\n\t\t\t\t\tselectableFilter={selectableFilter}\n\t\t\t\t\tnewFolder={newFolder}\n\t\t\t\t\tonCancelNewFolder={onCancelNewFolder}\n\t\t\t\t\tonCreateFolder={onCreateFolder}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\n// The Subtree renders the children of a directory (recursively)\nfunction Subtree({\n\tpath,\n\tdepth,\n\tonSelect,\n\tselectedPath,\n\texpandTo,\n\tselectionMode,\n\tselectableFilter,\n\tnewFolder,\n\tonCancelNewFolder,\n\tonCreateFolder,\n}: {\n\tpath: string\n\tdepth: number\n\tonSelect: (p: string, isDirectory: boolean) => void\n\tselectedPath: string | null\n\texpandTo?: string\n\tselectionMode: 'folders' | 'files-and-folders'\n\tselectableFilter?: (entry: FileSystemItem) => boolean\n\tnewFolder: (FileSystemItem & {isNew: boolean}) | null\n\tonCancelNewFolder: () => void\n\tonCreateFolder: (path: string) => void\n}) {\n\tconst {listing, isLoading, fetchMoreItems} = useListDirectory(path)\n\tconst children: FileSystemItem[] = useMemo(() => (listing?.items as FileSystemItem[]) ?? [], [listing])\n\tconst hasMore = listing?.hasMore ?? false\n\n\t// Visual indentation for the Subtree\n\t// We need less indentation for mobile devices so the tree doesn't get too wide and become unreadable\n\tconst isMobile = useIsSmallMobile()\n\tconst maxIndentLevels = isMobile ? MOBILE_MAX_INDENT_LEVELS : MAX_INDENT_LEVELS\n\tconst leftPad = 8 + Math.min(depth, maxIndentLevels) * INDENT_PER_LEVEL\n\tconst folderName = useMemo(() => path.split('/').filter(Boolean).pop() || '/', [path])\n\n\tconst [isLoadingMore, setIsLoadingMore] = useState(false)\n\n\t// Check if the new folder should be rendered at this level\n\tconst newFolderParent = newFolder ? newFolder.path.split('/').slice(0, -1).join('/') : ''\n\tconst isNewFolderAtThisLevel = newFolder && newFolderParent === path\n\n\treturn (\n\t\t<div className='mt-1 space-y-1'>\n\t\t\t{children.map((c) => (\n\t\t\t\t<Node\n\t\t\t\t\tkey={c.path}\n\t\t\t\t\tentry={c}\n\t\t\t\t\tdepth={depth}\n\t\t\t\t\tonSelect={onSelect}\n\t\t\t\t\tselectedPath={selectedPath}\n\t\t\t\t\texpandTo={expandTo}\n\t\t\t\t\tselectionMode={selectionMode}\n\t\t\t\t\tselectableFilter={selectableFilter}\n\t\t\t\t\tnewFolder={newFolder}\n\t\t\t\t\tonCancelNewFolder={onCancelNewFolder}\n\t\t\t\t\tonCreateFolder={onCreateFolder}\n\t\t\t\t/>\n\t\t\t))}\n\t\t\t{isNewFolderAtThisLevel && (\n\t\t\t\t<NewFolderNode entry={newFolder} depth={depth} onCancel={onCancelNewFolder} onCreate={onCreateFolder} />\n\t\t\t)}\n\t\t\t{/* We render the \"Load more\" control when listing is paginated */}\n\t\t\t{!isLoading && children.length > 0 && hasMore && (\n\t\t\t\t<button\n\t\t\t\t\ttype='button'\n\t\t\t\t\tonClick={async () => {\n\t\t\t\t\t\tif (isLoadingMore) return\n\t\t\t\t\t\tsetIsLoadingMore(true)\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait fetchMoreItems()\n\t\t\t\t\t\t} finally {\n\t\t\t\t\t\t\tsetIsLoadingMore(false)\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\tclassName='flex w-full items-center gap-2 rounded-md p-2 text-sm text-white/80 hover:bg-white/10'\n\t\t\t\t\tstyle={{paddingLeft: leftPad}}\n\t\t\t\t\taria-label={t('mini-browser.load-more-in-folder', {name: folderName})}\n\t\t\t\t>\n\t\t\t\t\t{isLoadingMore ? <Loader2 className='size-3 animate-spin opacity-70' /> : null}\n\t\t\t\t\t<span>\n\t\t\t\t\t\t{isLoadingMore ? t('mini-browser.loading-more') : t('mini-browser.load-more')}\n\t\t\t\t\t\t<span className='opacity-60'> · {folderName}</span>\n\t\t\t\t\t</span>\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\n// NewFolderNode renders an inline editable input for creating a new folder\nfunction NewFolderNode({\n\tentry,\n\tdepth,\n\tonCancel,\n\tonCreate,\n}: {\n\tentry: FileSystemItem & {isNew: boolean}\n\tdepth: number\n\tonCancel: () => void\n\tonCreate: (path: string) => void\n}) {\n\tconst [name, setName] = useState(entry.name)\n\tconst inputRef = useRef<HTMLInputElement>(null)\n\tconst isMobile = useIsSmallMobile()\n\tconst maxIndentLevels = isMobile ? MOBILE_MAX_INDENT_LEVELS : MAX_INDENT_LEVELS\n\n\tuseEffect(() => {\n\t\tconst timer = setTimeout(() => {\n\t\t\tif (inputRef.current) {\n\t\t\t\tinputRef.current.focus()\n\t\t\t\tinputRef.current.select()\n\t\t\t}\n\t\t}, 100)\n\t\treturn () => clearTimeout(timer)\n\t}, [])\n\n\tconst handleSubmit = () => {\n\t\tconst trimmedName = name.trim()\n\t\tif (!trimmedName) {\n\t\t\tonCancel()\n\t\t\treturn\n\t\t}\n\n\t\tconst parentPath = entry.path.split('/').slice(0, -1).join('/')\n\t\tconst fullPath = `${parentPath}/${trimmedName}`\n\t\tonCreate(fullPath)\n\t}\n\n\tconst handleKeyDown = (e: React.KeyboardEvent) => {\n\t\tif (e.key === 'Enter') {\n\t\t\te.preventDefault()\n\t\t\te.stopPropagation()\n\t\t\thandleSubmit()\n\t\t}\n\t\tif (e.key === 'Escape') {\n\t\t\te.preventDefault()\n\t\t\te.stopPropagation()\n\t\t\tonCancel()\n\t\t}\n\t}\n\n\tconst handleBlur = () => {\n\t\thandleSubmit()\n\t}\n\n\treturn (\n\t\t<div>\n\t\t\t<div\n\t\t\t\tclassName={cn('flex min-w-0 items-center gap-2 rounded-md border border-brand bg-brand/15 p-2')}\n\t\t\t\tstyle={{paddingLeft: 8 + Math.min(depth, maxIndentLevels) * INDENT_PER_LEVEL}}\n\t\t\t>\n\t\t\t\t<ChevronRight className='size-4 shrink-0 opacity-0' />\n\t\t\t\t<FileItemIcon item={entry} className='size-5' />\n\t\t\t\t<input\n\t\t\t\t\tref={inputRef}\n\t\t\t\t\ttype='text'\n\t\t\t\t\tvalue={name}\n\t\t\t\t\tonChange={(e) => setName(e.target.value)}\n\t\t\t\t\tonKeyDown={handleKeyDown}\n\t\t\t\t\tonBlur={handleBlur}\n\t\t\t\t\tonClick={(e) => e.stopPropagation()}\n\t\t\t\t\tonDoubleClick={(e) => e.stopPropagation()}\n\t\t\t\t\tclassName='min-w-0 flex-1 truncate bg-transparent text-sm outline-hidden'\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/rewind/index.tsx",
    "content": "import * as DialogPrimitive from '@radix-ui/react-dialog'\nimport {MotionConfig} from 'motion/react'\nimport {useEffect, useMemo, useRef, useState} from 'react'\nimport {RiCloseLine} from 'react-icons/ri'\nimport {useSearchParams} from 'react-router-dom'\n\nimport {ChevronDown} from '@/components/chevron-down'\nimport {Button} from '@/components/ui/button'\nimport {Dialog} from '@/components/ui/dialog'\nimport {DialogCloseButton} from '@/components/ui/dialog-close-button'\nimport {\n\tDropdownMenu,\n\tDropdownMenuCheckboxItem,\n\tDropdownMenuContent,\n\tDropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport {ChevronLeftIcon} from '@/features/files/assets/chevron-left'\nimport {ChevronRightIcon} from '@/features/files/assets/chevron-right'\nimport {RewindIcon} from '@/features/files/assets/rewind-icon'\nimport {useRewindOverlay} from '@/features/files/components/rewind/overlay-context'\nimport {PreRewindDialog} from '@/features/files/components/rewind/prerewind-dialog'\nimport {RestoreProgressDialog} from '@/features/files/components/rewind/restore-progress-dialog'\nimport {SnapshotCarousel} from '@/features/files/components/rewind/snapshot-carousel'\nimport {getSnapshotDateLabel} from '@/features/files/components/rewind/snapshot-date-label'\nimport {TimelineBar as TimelineBarComponent} from '@/features/files/components/rewind/timeline-bar'\nimport {TooltipProvider} from '@/features/files/components/rewind/tooltip'\nimport {useFilesOperations} from '@/features/files/hooks/use-files-operations'\nimport {useRewind} from '@/features/files/hooks/use-rewind'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport {formatFilesystemDate} from '@/features/files/utils/format-filesystem-date'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {useLanguage} from '@/hooks/use-language'\nimport {cn} from '@/lib/utils'\nimport {useWallpaper} from '@/providers/wallpaper'\nimport {t} from '@/utils/i18n'\n\nimport {groupRestoreByDestination} from './restore-grouping'\n\nexport function SidebarRewind() {\n\tconst {setRepoOpen} = useRewindOverlay()\n\n\treturn (\n\t\t<div className='mt-2 mr-4 flex flex-col rounded-xl'>\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'flex w-full items-center gap-1.5 rounded-lg border border-transparent from-white/[0.04] to-white/[0.08] text-12',\n\t\t\t\t\t'text-white/60 transition-colors hover:bg-white/10 hover:bg-linear-to-b hover:text-white',\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t<button className='flex w-full items-center gap-[0.45rem] px-2 py-1.5' onClick={() => setRepoOpen(true)}>\n\t\t\t\t\t<RewindIcon className='size-5' />\n\t\t\t\t\t<span className='truncate'>{t('backups-rewind')}</span>\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\nexport function RewindOverlay() {\n\tconst isMobile = useIsMobile()\n\tconst explorerContainerRef = useRef<HTMLDivElement | null>(null)\n\tconst explorerScale = 0.8\n\tconst {overlayOpen, setOverlayOpen, repoOpen, setRepoOpen} = useRewindOverlay()\n\tconst [searchParams, setSearchParams] = useSearchParams()\n\n\t// Check for rewind=open parameter and open dialog if present\n\t// We use this to redirect the user from restore to rewind\n\t// if they don't want to do a full restore\n\tuseEffect(() => {\n\t\tif (searchParams.get('rewind') === 'open') {\n\t\t\tsetRepoOpen(true)\n\t\t\t// Remove the parameter from URL\n\t\t\tsearchParams.delete('rewind')\n\t\t\tsetSearchParams(searchParams, {replace: true})\n\t\t}\n\t}, [searchParams, setSearchParams, setRepoOpen])\n\n\tconst {\n\t\tview,\n\t\trepositories,\n\t\tbackupsRaw,\n\t\tbackupsLoading,\n\t\tbackupsForTimeline,\n\t\tactiveIndex,\n\t\tearliestDateLabel,\n\t\tsetSelectedRepoId,\n\t\tpendingRepoId,\n\t\tsetPendingRepoId,\n\t\tselectedBackupId,\n\t\tsetSelectedBackupId,\n\t\tmountedDir,\n\t\tselectSnapshot,\n\t\tunmountIfNeeded,\n\t\tcanRecover,\n\t} = useRewind({overlayOpen, repoOpen})\n\tconst [lang] = useLanguage()\n\tconst {resolveCopyCollisionsOrAbort, executeCopyWorkItems} = useFilesOperations()\n\n\tconst explorerVisible = view !== 'switching-snapshot'\n\tconst [restoreModalOpen, setRestoreModalOpen] = useState(false)\n\tconst [restorePhase, setRestorePhase] = useState<'idle' | 'running' | 'success' | 'error'>('idle')\n\tconst isSwitching = view === 'switching-snapshot'\n\tconst isRestoring = restorePhase === 'running'\n\n\tconst handleRecoverSelected = async () => {\n\t\tif (!canRecover || !mountedDir) return\n\t\ttry {\n\t\t\tconst groups = groupRestoreByDestination(useFilesStore.getState().selectedItems, mountedDir)\n\n\t\t\t// Resolve collisions per destination; abort if nothing to do\n\t\t\tconst workItems: {path: string; toDirectory: string; collision: 'error' | 'replace' | 'keep-both'}[] = []\n\t\t\tfor (const [destDir, paths] of groups) {\n\t\t\t\tconst items = await resolveCopyCollisionsOrAbort({fromPaths: paths, toDirectory: destDir})\n\t\t\t\tworkItems.push(...items)\n\t\t\t}\n\t\t\tif (workItems.length === 0) return\n\n\t\t\t// Show progress modal and yield before executing copies\n\t\t\tsetRestorePhase('running')\n\t\t\tsetRestoreModalOpen(true)\n\t\t\tawait new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)))\n\n\t\t\t// Execute copies according to preflight decisions\n\t\t\tawait executeCopyWorkItems({workItems})\n\n\t\t\t// Immediately show success, then briefly pause before closing and navigating back to \"Now\"\n\t\t\tsetRestorePhase('success')\n\t\t\t// Show success for 1 second to avoid janky flashing for quick operations\n\t\t\tconst successPauseMs = 1000\n\t\t\tsetTimeout(async () => {\n\t\t\t\tsetRestoreModalOpen(false)\n\t\t\t\tsetRestorePhase('idle')\n\t\t\t\tawait selectSnapshot('current')\n\t\t\t}, successPauseMs)\n\t\t} catch {\n\t\t\tsetRestorePhase('error')\n\t\t\tsetTimeout(() => {\n\t\t\t\tsetRestoreModalOpen(false)\n\t\t\t\tsetRestorePhase('idle')\n\t\t\t}, 1400)\n\t\t}\n\t}\n\n\tconst snapshotsCount = backupsRaw.length\n\tconst countLabel = backupsLoading\n\t\t? t('rewind.loading-snapshots')\n\t\t: snapshotsCount === 0\n\t\t\t? t('backups-restore.no-backups-yet')\n\t\t\t: t('rewind.snapshots-count', {count: snapshotsCount})\n\n\tconst handleOpenChange = async (isOpen: boolean) => {\n\t\tif (!isOpen) await unmountIfNeeded()\n\t\tsetOverlayOpen(isOpen)\n\t}\n\n\tconst {wallpaper} = useWallpaper()\n\tconst carousel = useMemo(\n\t\t() => (\n\t\t\t<SnapshotCarousel\n\t\t\t\tbackupsForTimeline={backupsForTimeline}\n\t\t\t\tactiveIndex={activeIndex}\n\t\t\t\tnoCarousel={isMobile}\n\t\t\t\texplorerVisible={explorerVisible}\n\t\t\t\texplorerScale={explorerScale}\n\t\t\t\tlang={lang}\n\t\t\t\twallpaperUrl={wallpaper?.url}\n\t\t\t\tmountedDir={mountedDir}\n\t\t\t\texplorerContainerRef={explorerContainerRef}\n\t\t\t/>\n\t\t),\n\t\t[backupsForTimeline, activeIndex, isMobile, explorerVisible, explorerScale, lang, wallpaper?.url, mountedDir],\n\t)\n\n\treturn (\n\t\t<TooltipProvider>\n\t\t\t<PreRewindDialog\n\t\t\t\topen={repoOpen}\n\t\t\t\tonOpenChange={setRepoOpen}\n\t\t\t\trepos={repositories as any[]}\n\t\t\t\tpendingRepoId={pendingRepoId}\n\t\t\t\tsetPendingRepoId={(id) => setPendingRepoId(id)}\n\t\t\t\tonStart={(repo) => {\n\t\t\t\t\tsetSelectedRepoId(repo)\n\t\t\t\t\tsetSelectedBackupId('current')\n\t\t\t\t\tsetRepoOpen(false)\n\t\t\t\t\tsetOverlayOpen(true)\n\t\t\t\t}}\n\t\t\t/>\n\n\t\t\t<Dialog open={overlayOpen} onOpenChange={handleOpenChange}>\n\t\t\t\t<DialogPrimitive.Portal>\n\t\t\t\t\t<DialogPrimitive.Overlay className='fixed inset-0 z-50 bg-black' />\n\t\t\t\t\t{/* Simple but hacky: We add a Rewind-specific marker (data-rewind) so the Files keyboard shortcuts hook can cheaply detect that Rewind is open and ignore shortcut commands without us having to do any global focus plumbing */}\n\t\t\t\t\t<DialogPrimitive.Content\n\t\t\t\t\t\tdata-rewind='open'\n\t\t\t\t\t\tclassName='fixed inset-0 z-50 m-0 h-svh w-screen translate-x-0 translate-y-0 rounded-none p-0 outline-hidden'\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className='flex size-full flex-col'>\n\t\t\t\t\t\t\t{/* Mobile close button (standard dialog style) */}\n\t\t\t\t\t\t\t<DialogCloseButton className='absolute top-3 right-3 z-[60] md:hidden' />\n\t\t\t\t\t\t\t{/* Upper area with centered card */}\n\t\t\t\t\t\t\t<div className='flex flex-[2] items-center justify-center px-2 py-4 md:px-4 md:py-10'>\n\t\t\t\t\t\t\t\t<div className='flex w-full max-w-[980px] flex-col items-center gap-4'>\n\t\t\t\t\t\t\t\t\t{/* Date indicator */}\n\t\t\t\t\t\t\t\t\t<div className='text-center text-sm'>\n\t\t\t\t\t\t\t\t\t\t<span className='text-white/60'>{t('rewind.files-as-of')} </span>\n\t\t\t\t\t\t\t\t\t\t{/* Desktop: static date label */}\n\t\t\t\t\t\t\t\t\t\t<span className='hidden text-white md:inline'>\n\t\t\t\t\t\t\t\t\t\t\t{getSnapshotDateLabel(selectedBackupId, backupsRaw as any[], lang as any)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t{/* Mobile: inline dropdown selector */}\n\t\t\t\t\t\t\t\t\t\t<span className='ml-1 inline md:hidden'>\n\t\t\t\t\t\t\t\t\t\t\t<DropdownMenu>\n\t\t\t\t\t\t\t\t\t\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize='sm'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='h-7 justify-between px-2'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={t('rewind')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tvariant='secondary'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={isSwitching || isRestoring || backupsForTimeline.length === 0}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='truncate'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{getSnapshotDateLabel(selectedBackupId, backupsRaw as any[], lang as any)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='opacity-70'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<ChevronDown />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t\t\t\t\t\t\t\t\t<DropdownMenuContent\n\t\t\t\t\t\t\t\t\t\t\t\t\talign='center'\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='max-h-[60vh] overflow-y-auto overscroll-contain p-2.5'\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{backupsForTimeline.map((b) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<DropdownMenuCheckboxItem\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={b.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tchecked={selectedBackupId === b.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonSelect={() => selectSnapshot(b.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{b.id === 'current' ? t('rewind.now') : formatFilesystemDate(b.time, lang)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</DropdownMenuCheckboxItem>\n\t\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t\t</DropdownMenuContent>\n\t\t\t\t\t\t\t\t\t\t\t</DropdownMenu>\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className='relative h-[68vh] w-full md:h-[64vh]'>\n\t\t\t\t\t\t\t\t\t\t<MotionConfig transition={{type: 'tween', ease: [0.25, 0.1, 0.25, 1], duration: 0.6}}>\n\t\t\t\t\t\t\t\t\t\t\t{carousel}\n\t\t\t\t\t\t\t\t\t\t</MotionConfig>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\t\t\t\t\tvariant='secondary'\n\t\t\t\t\t\t\t\t\t\tclassName='w-auto min-w-[150px] md:min-w-[80px]'\n\t\t\t\t\t\t\t\t\t\tonClick={handleRecoverSelected}\n\t\t\t\t\t\t\t\t\t\tdisabled={isSwitching || isRestoring || !canRecover}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{t('rewind.restore-selected')}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{/* Bottom controls (snapshot # summary + timeline + control buttons). Mobile: use safe-area bottom padding and hide snapshot metadata to avoid browser UI overlap */}\n\t\t\t\t\t\t\t<div className='flex flex-1 flex-col gap-3 px-4 pb-[calc(env(safe-area-inset-bottom,0px)+12px)] md:px-6 md:pb-0'>\n\t\t\t\t\t\t\t\t<div className='flex items-center justify-between' />\n\t\t\t\t\t\t\t\t<div className='flex w-full flex-col items-stretch gap-3 md:flex-row md:items-center'>\n\t\t\t\t\t\t\t\t\t<div className='flex flex-col items-center text-center md:items-start md:text-left'>\n\t\t\t\t\t\t\t\t\t\t<div className='hidden items-center gap-2 md:flex'>\n\t\t\t\t\t\t\t\t\t\t\t<RewindIcon className='size-5 md:size-6' />\n\t\t\t\t\t\t\t\t\t\t\t<div className='text-base leading-none font-semibold text-white md:text-lg'>\n\t\t\t\t\t\t\t\t\t\t\t\t{t('backups-rewind')}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className='mt-1 hidden min-w-0 items-center gap-1 text-xs text-white/70 md:block md:h-8'>\n\t\t\t\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className={backupsLoading ? 'opacity-50' : ''}>{countLabel}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t{snapshotsCount > 0 && earliestDateLabel && !backupsLoading ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='truncate'>{earliestDateLabel}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className='relative order-2 mx-0 w-full min-w-0 flex-1 px-0 md:order-none md:mx-2 md:w-auto md:px-0'>\n\t\t\t\t\t\t\t\t\t\t{/* Desktop timeline */}\n\t\t\t\t\t\t\t\t\t\t<div className='hidden md:block'>\n\t\t\t\t\t\t\t\t\t\t\t<TimelineBarComponent\n\t\t\t\t\t\t\t\t\t\t\t\tbackups={backupsForTimeline}\n\t\t\t\t\t\t\t\t\t\t\t\tselectedId={selectedBackupId}\n\t\t\t\t\t\t\t\t\t\t\t\tonSelect={(id) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\tselectSnapshot(id)\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className='order-3 hidden w-full items-center justify-center gap-2 md:order-none md:flex md:w-auto md:justify-end'>\n\t\t\t\t\t\t\t\t\t\t{(() => {\n\t\t\t\t\t\t\t\t\t\t\tconst idx = selectedBackupId ? backupsForTimeline.findIndex((b) => b.id === selectedBackupId) : -1\n\t\t\t\t\t\t\t\t\t\t\tconst canPrev = !backupsLoading && idx > 0\n\t\t\t\t\t\t\t\t\t\t\tconst canNext = !backupsLoading && idx >= 0 && idx < backupsForTimeline.length - 1\n\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='inline-flex h-8 items-center justify-center rounded-full bg-white/10 px-3 text-white shadow-[inset_0.5px_0.5px_1px_0px_#FFFFFF3D,inset_-0.5px_-0.5px_1px_0px_#FFFFFF1F] hover:bg-white/20 disabled:opacity-40'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={isSwitching || isRestoring || !canPrev}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tif (!canPrev) return\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tconst prev = backupsForTimeline[idx - 1]\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tif (prev) selectSnapshot(prev.id)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<ChevronLeftIcon className='size-4' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='inline-flex h-8 items-center justify-center rounded-full bg-white/10 px-3 text-white shadow-[inset_0.5px_0.5px_1px_0px_#FFFFFF3D,inset_-0.5px_-0.5px_1px_0px_#FFFFFF1F] hover:bg-white/20 disabled:opacity-40'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={isSwitching || isRestoring || !canNext}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tif (!canNext) return\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tconst next = backupsForTimeline[idx + 1]\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tif (next) selectSnapshot(next.id)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<ChevronRightIcon className='size-4' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<DialogPrimitive.Close asChild>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{/* We always allow closing the dialog*/}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={t('close')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='inline-flex h-8 items-center justify-center rounded-full bg-white/10 px-3 text-white shadow-[inset_0.5px_0.5px_1px_0px_#FFFFFF3D,inset_-0.5px_-0.5px_1px_0px_#FFFFFF1F] hover:bg-white/20 disabled:opacity-40'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<RiCloseLine className='size-4' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</DialogPrimitive.Close>\n\t\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t})()}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</DialogPrimitive.Content>\n\t\t\t\t</DialogPrimitive.Portal>\n\t\t\t</Dialog>\n\n\t\t\t<RestoreProgressDialog open={restoreModalOpen} phase={restorePhase} />\n\t\t</TooltipProvider>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/rewind/overlay-context.tsx",
    "content": "import React, {createContext, useContext, useState} from 'react'\n\ntype RewindOverlayContextValue = {\n\toverlayOpen: boolean\n\tsetOverlayOpen: (v: boolean) => void\n\trepoOpen: boolean\n\tsetRepoOpen: (v: boolean) => void\n}\n\nconst Ctx = createContext<RewindOverlayContextValue | null>(null)\n\nexport function RewindOverlayProvider({children}: {children: React.ReactNode}) {\n\tconst [overlayOpen, setOverlayOpen] = useState(false)\n\tconst [repoOpen, setRepoOpen] = useState(false)\n\treturn <Ctx value={{overlayOpen, setOverlayOpen, repoOpen, setRepoOpen}}>{children}</Ctx>\n}\n\nexport function useRewindOverlay() {\n\tconst ctx = useContext(Ctx)\n\tif (!ctx) throw new Error('useRewindOverlay must be used within RewindOverlayProvider')\n\treturn ctx\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/rewind/prerewind-dialog.tsx",
    "content": "import {ChevronDown, Server} from 'lucide-react'\nimport {TbHistory} from 'react-icons/tb'\n\nimport {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogDescription, DialogFooter, DialogTitle} from '@/components/ui/dialog'\nimport {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'\nimport {BackupDeviceIcon} from '@/features/backups/components/backup-device-icon'\nimport {isRepoConnected} from '@/features/backups/utils/backup-location-helpers'\nimport {getRepositoryDisplayName} from '@/features/backups/utils/filepath-helpers'\nimport {RewindIcon} from '@/features/files/assets/rewind-icon'\nimport {useExternalStorage} from '@/features/files/hooks/use-external-storage'\nimport {useNetworkStorage} from '@/features/files/hooks/use-network-storage'\nimport {formatFilesystemDate} from '@/features/files/utils/format-filesystem-date'\nimport {useLanguage} from '@/hooks/use-language'\nimport {t} from '@/utils/i18n'\n\n// We show this dialog when the user clicks Rewind in Files to select a backup repository\nexport function PreRewindDialog({\n\topen,\n\tonOpenChange,\n\trepos,\n\tpendingRepoId,\n\tsetPendingRepoId,\n\tonStart,\n}: {\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n\trepos: Array<{id: string; path: string; lastBackup?: any}> | undefined\n\tpendingRepoId: string | null\n\tsetPendingRepoId: (id: string) => void\n\tonStart: (repoId: string) => void\n}) {\n\tconst [lang] = useLanguage()\n\tconst {doesHostHaveMountedShares} = useNetworkStorage()\n\tconst {disks} = useExternalStorage()\n\tconst list = repos || []\n\tconst activeId = pendingRepoId ?? list[0]?.id ?? ''\n\tconst active = list.find((r) => r.id === activeId) || null\n\tconst path: string = active?.path || ''\n\tconst makeName = (p: string) => {\n\t\tconst name = getRepositoryDisplayName(p)\n\t\tif (name) return name\n\t\t// Defensive fallbacks for malformed paths (should not occur)\n\t\tif (p.startsWith('/Network/')) return t('nas')\n\t\tif (p.startsWith('/External/')) return t('external-drive')\n\t\treturn t('unknown')\n\t}\n\tconst makeLastBackupText = (lastBackup: any) => {\n\t\tif (!lastBackup) return t('backups-restore.no-backups-yet')\n\t\treturn `${t('backups-configure.last-backup')}: ${formatFilesystemDate(Number(lastBackup), lang)}`\n\t}\n\n\tconst connectedById = new Map<string, boolean>(\n\t\tlist.map((r) => [r.id, isRepoConnected(r.path, doesHostHaveMountedShares, disks as any)]),\n\t)\n\t// Selected repo connectivity: when false, we disable entering Rewind and show inactive indicators\n\tconst isActiveConnected = active ? Boolean(connectedById.get(active.id)) : false\n\n\tconst iconNode = path ? (\n\t\t<BackupDeviceIcon path={path} connected={isActiveConnected} className='size-8' />\n\t) : (\n\t\t<Server className='size-8 opacity-80' />\n\t)\n\n\treturn (\n\t\t<Dialog open={open} onOpenChange={onOpenChange}>\n\t\t\t<DialogContent\n\t\t\t\tclassName='flex flex-col'\n\t\t\t\tonInteractOutside={(e: any) => {\n\t\t\t\t\tconst target = e?.detail?.originalEvent?.target || e?.target\n\t\t\t\t\tif (target instanceof HTMLElement && target.closest('[data-ft-repo-popover]')) {\n\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\tonPointerDownOutside={(e: any) => {\n\t\t\t\t\tconst target = e?.target\n\t\t\t\t\tif (target instanceof HTMLElement && target.closest('[data-ft-repo-popover]')) {\n\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<div className='flex flex-col items-start gap-1'>\n\t\t\t\t\t<RewindIcon className='size-20' />\n\t\t\t\t\t<DialogTitle>{t('backups-rewind')}</DialogTitle>\n\t\t\t\t\t<DialogDescription className='text-white/80'>{t('rewind.preflight.description')}</DialogDescription>\n\t\t\t\t</div>\n\t\t\t\t<div className='mt-2'>\n\t\t\t\t\t{list.length === 0 ? (\n\t\t\t\t\t\t<div className='flex w-full min-w-0 flex-wrap items-start gap-1 rounded-xl border border-white/10 bg-white/5 p-3 text-left text-white/80'>\n\t\t\t\t\t\t\t<TbHistory className='mt-[1px] size-4 shrink-0' />\n\t\t\t\t\t\t\t<span className='min-w-0 flex-1 text-[13px] break-words whitespace-normal md:truncate md:whitespace-nowrap'>\n\t\t\t\t\t\t\t\t{t('rewind.preflight.enable-backups')}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<div className='mb-2 text-13 font-medium text-white/90'>{t('backups.backup-location')}</div>\n\t\t\t\t\t\t\t<DropdownMenu>\n\t\t\t\t\t\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\t\t\tclassName='flex w-full min-w-0 items-center justify-between rounded-xl border border-white/10 bg-white/5 p-3 text-left hover:bg-white/10'\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<span className='flex min-w-0 items-center gap-3'>\n\t\t\t\t\t\t\t\t\t\t\t{iconNode}\n\t\t\t\t\t\t\t\t\t\t\t<span className='min-w-0 truncate'>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className='block text-sm font-medium'>{makeName(path)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t{active ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='block text-11 leading-tight opacity-60'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{makeLastBackupText(active.lastBackup)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{!isActiveConnected ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='mt-1 block text-11 leading-tight opacity-60'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='inline-flex items-center gap-1'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t('backups-configure.not-connected')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='grid size-3 place-items-center rounded-full'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{backgroundColor: '#DF1F1F3D'}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='size-1.5 rounded-full' style={{backgroundColor: '#DF1F1F'}} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<ChevronDown className='size-4 opacity-70' />\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t\t\t\t\t<DropdownMenuContent\n\t\t\t\t\t\t\t\t\talign='start'\n\t\t\t\t\t\t\t\t\tclassName='z-[60] w-[var(--radix-dropdown-menu-trigger-width)] max-w-[92vw] rounded-xl p-1'\n\t\t\t\t\t\t\t\t\tdata-ft-repo-popover\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className='space-y-1'>\n\t\t\t\t\t\t\t\t\t\t{list.map((r) => {\n\t\t\t\t\t\t\t\t\t\t\tconst p = r.path || ''\n\t\t\t\t\t\t\t\t\t\t\tconst sel = r.id === activeId\n\t\t\t\t\t\t\t\t\t\t\tconst connected = Boolean(connectedById.get(r.id))\n\t\t\t\t\t\t\t\t\t\t\tconst icon = p ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<BackupDeviceIcon path={p} connected={connected} className='size-8' />\n\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t<Server className='size-8 opacity-80' />\n\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t<DropdownMenuItem\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={r.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={[\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t'w-full px-2 py-1.5',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsel ? 'border border-brand bg-brand/15' : 'border border-transparent hover:bg-white/10',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t!connected ? 'opacity-60' : '',\n\t\t\t\t\t\t\t\t\t\t\t\t\t].join(' ')}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetPendingRepoId(r.id)\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='flex min-w-0 items-center gap-3'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{icon}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='min-w-0 truncate'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='block text-sm font-medium'>{makeName(p)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='block text-11 opacity-60'>{makeLastBackupText(r.lastBackup)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{!connected ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='inline-flex items-center gap-1 text-11 opacity-60'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t('backups-configure.not-connected')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='grid size-3 place-items-center rounded-full'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{backgroundColor: '#DF1F1F3D'}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='size-1.5 rounded-full' style={{backgroundColor: '#DF1F1F'}} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</DropdownMenuContent>\n\t\t\t\t\t\t\t</DropdownMenu>\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t\t<DialogFooter className='mt-2 gap-2 pt-2'>\n\t\t\t\t\t{/* We disable the Start Rewind button if the selected repo's device isn't connected */}\n\t\t\t\t\t<Button\n\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\tdisabled={!list.length || !isActiveConnected}\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tif (!isActiveConnected) return\n\t\t\t\t\t\t\tconst repo = pendingRepoId ?? list[0]?.id ?? null\n\t\t\t\t\t\t\tif (!repo) return\n\t\t\t\t\t\t\tonStart(repo)\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('backups-rewind.start')}\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button size='dialog' onClick={() => onOpenChange(false)}>\n\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t</Button>\n\t\t\t\t</DialogFooter>\n\t\t\t</DialogContent>\n\t\t</Dialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/rewind/restore-grouping.ts",
    "content": "import type {FileSystemItem} from '@/features/files/types'\n\n/**\n * Group snapshot item paths by their present-day destination directory for restore.\n *\n * Context:\n * - In Rewind feature, users browse a mounted snapshot at virtual roots:\n *   - `/Backups/<mountedDir>/Home`\n *   - `/Backups/<mountedDir>/Apps`\n * - When restoring, we want to copy selected items back to their logical parents\n *   under `/Home` or `/Apps` in the current filesystem, preserving their folder structure.\n * - Our copy API (`copyItems`) accepts a single `toDirectory` per batch, so we need\n *   to group selected source paths by their target parent directory.\n *\n * Example:\n * - Selected from snapshot:\n *   - `/Backups/<dir>/Home/Documents/Notes/todo.txt`\n *   - `/Backups/<dir>/Home/Documents/Notes/ideas.md`\n *   - `/Backups/<dir>/Apps/Memos/data.db`\n *\n * - Grouped result map:\n *   - key `/Home/Documents/Notes` -> [both notes files]\n *   - key `/Apps/Memos` -> [data.db]\n */\nexport function groupRestoreByDestination(selectedItems: FileSystemItem[], mountedDir: string): Map<string, string[]> {\n\tconst baseHome = `/Backups/${mountedDir}/Home`\n\tconst baseApps = `/Backups/${mountedDir}/Apps`\n\tconst groups = new Map<string, string[]>()\n\tfor (const item of selectedItems) {\n\t\tlet destRoot = ''\n\t\tlet relative = ''\n\t\tif (item.path.startsWith(baseHome)) {\n\t\t\tdestRoot = '/Home'\n\t\t\trelative = item.path.slice(baseHome.length)\n\t\t} else if (item.path.startsWith(baseApps)) {\n\t\t\tdestRoot = '/Apps'\n\t\t\trelative = item.path.slice(baseApps.length)\n\t\t} else {\n\t\t\t// Ignore items outside the mounted snapshot roots.\n\t\t\tcontinue\n\t\t}\n\t\tif (relative.startsWith('/')) relative = relative.slice(1)\n\t\tconst parts = relative.split('/').filter(Boolean)\n\t\t// Drop the file/folder name; we want just the parent directory.\n\t\tparts.pop()\n\t\tconst destDir = `${destRoot}${parts.length ? `/${parts.join('/')}` : ''}`\n\t\tif (!groups.has(destDir)) groups.set(destDir, [])\n\t\tgroups.get(destDir)!.push(item.path)\n\t}\n\treturn groups\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/rewind/restore-progress-dialog.tsx",
    "content": "import {AlertOctagon, CheckCircle2} from 'lucide-react'\n\nimport {Alert, ErrorAlert, WarningAlert} from '@/components/ui/alert'\nimport {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog'\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {RewindIcon} from '@/features/files/assets/rewind-icon'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {formatItemName} from '@/features/files/utils/format-filesystem-name'\nimport {formatFilesystemSize} from '@/features/files/utils/format-filesystem-size'\nimport {OperationsInProgress, useGlobalFiles} from '@/providers/global-files'\nimport {t} from '@/utils/i18n'\nimport {formatNumberI18n} from '@/utils/number'\nimport {secondsToEta} from '@/utils/seconds-to-eta'\n\nexport function RestoreProgressDialog({open, phase}: {open: boolean; phase: 'idle' | 'running' | 'success' | 'error'}) {\n\tconst {operations} = useGlobalFiles()\n\n\t// Filter restore operations (copies from /Backups/), and sort them so that items with higher progress appear first\n\tconst restoreOperations = [...operations]\n\t\t.filter((op) => op.file.path.startsWith('/Backups/') && op.type === 'copy')\n\t\t.sort((a, b) => {\n\t\t\t// Treat missing values as 0\n\t\t\treturn (b.percent ?? 0) - (a.percent ?? 0)\n\t\t})\n\n\treturn (\n\t\t<Dialog open={open} onOpenChange={() => {}}>\n\t\t\t<DialogContent className='flex flex-col'>\n\t\t\t\t<div>\n\t\t\t\t\t<div className='flex flex-col items-center space-y-2 text-center'>\n\t\t\t\t\t\t<RewindIcon className='size-20' />\n\t\t\t\t\t\t<DialogTitle className='text-center leading-none'>\n\t\t\t\t\t\t\t{phase === 'success' || phase === 'idle'\n\t\t\t\t\t\t\t\t? t('rewind.restore-complete')\n\t\t\t\t\t\t\t\t: phase === 'error'\n\t\t\t\t\t\t\t\t\t? t('rewind.restore-failed')\n\t\t\t\t\t\t\t\t\t: phase === 'running' && (\n\t\t\t\t\t\t\t\t\t\t\t<span className='inline-flex items-center'>\n\t\t\t\t\t\t\t\t\t\t\t\t{t('rewind.restoring')}\n\t\t\t\t\t\t\t\t\t\t\t\t<span className='ml-0'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='animate-pulse [animation-delay:0ms] [animation-duration:1.4s]'>.</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='animate-pulse [animation-delay:200ms] [animation-duration:1.4s]'>.</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='animate-pulse [animation-delay:400ms] [animation-duration:1.4s]'>.</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</DialogTitle>\n\t\t\t\t\t</div>\n\t\t\t\t\t<DialogDescription>\n\t\t\t\t\t\t{phase === 'success' || phase === 'idle' ? (\n\t\t\t\t\t\t\t<Alert variant='success' className='mt-4 rounded-lg py-[0.85rem]' icon={CheckCircle2}>\n\t\t\t\t\t\t\t\t{t('rewind.restore-success-description')}\n\t\t\t\t\t\t\t</Alert>\n\t\t\t\t\t\t) : phase === 'error' ? (\n\t\t\t\t\t\t\t<ErrorAlert icon={AlertOctagon} description={t('rewind.restore-error-description')} />\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t</DialogDescription>\n\t\t\t\t</div>\n\t\t\t\t<div>\n\t\t\t\t\t{phase === 'running' &&\n\t\t\t\t\t\t(() => {\n\t\t\t\t\t\t\tconst count = operations.length\n\t\t\t\t\t\t\tif (count > 0) {\n\t\t\t\t\t\t\t\tlet totalPercent = 0\n\t\t\t\t\t\t\t\tlet totalSpeed = 0\n\t\t\t\t\t\t\t\tfor (const op of operations) {\n\t\t\t\t\t\t\t\t\tif (typeof op.percent === 'number') totalPercent += op.percent\n\t\t\t\t\t\t\t\t\tif (typeof op.bytesPerSecond === 'number') totalSpeed += op.bytesPerSecond\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tconst progress = Math.round(totalPercent / count)\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<RestoringItems operations={restoreOperations} progress={progress} count={count} speed={totalSpeed} />\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn null\n\t\t\t\t\t\t})()}\n\t\t\t\t\t{phase === 'running' && (\n\t\t\t\t\t\t<WarningAlert icon={AlertOctagon} description={t('rewind.restore-running-description')} />\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</DialogContent>\n\t\t</Dialog>\n\t)\n}\n\nfunction RestoringItems({\n\tprogress,\n\tcount,\n\tspeed,\n\toperations,\n}: {\n\tprogress: number\n\tcount: number\n\tspeed: number\n\toperations: OperationsInProgress\n}) {\n\treturn (\n\t\t<div className='flex h-full w-full flex-col overflow-hidden py-3'>\n\t\t\t<div className='mb-4 flex items-center justify-between'>\n\t\t\t\t<span className='text-xs text-white/60'>\n\t\t\t\t\t{t('files-listing.item-count', {formattedCount: formatNumberI18n({n: count, showDecimals: false}), count})}{' '}\n\t\t\t\t\t&bull; {progress}%\n\t\t\t\t</span>\n\t\t\t\t<span className='text-xs text-white/60'>{formatFilesystemSize(speed)}/s</span>\n\t\t\t</div>\n\n\t\t\t<ScrollArea className='flex-1 pb-2'>\n\t\t\t\t<div className='max-h-[200px] space-y-3'>\n\t\t\t\t\t{operations.map((operation) => {\n\t\t\t\t\t\tconst parts = operation.destinationPath.split('/')\n\t\t\t\t\t\tconst destinationFolderName = parts.length >= 2 ? parts[parts.length - 2] : parts[0]\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={`${operation.file.path}-${operation.destinationPath}-${operation.type}`}\n\t\t\t\t\t\t\t\tclassName='flex items-center gap-2'\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className='flex-shrink-0'>\n\t\t\t\t\t\t\t\t\t<FileItemIcon item={operation.file} className='h-7 w-7' />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t\t\t\t\t\t<div className='mb-1 flex items-center justify-between gap-2'>\n\t\t\t\t\t\t\t\t\t\t<span className='block max-w-[16rem] text-xs whitespace-nowrap text-white/90'>\n\t\t\t\t\t\t\t\t\t\t\t<span className='text-white/60'>\n\t\t\t\t\t\t\t\t\t\t\t\t{t('files-operations-island.restoring', {\n\t\t\t\t\t\t\t\t\t\t\t\t\tfrom: formatItemName({name: operation.file.name, maxLength: 12}),\n\t\t\t\t\t\t\t\t\t\t\t\t\tto: formatItemName({name: destinationFolderName, maxLength: 12}),\n\t\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span className='flex-shrink-0 text-right text-xs text-white/60'>\n\t\t\t\t\t\t\t\t\t\t\t{secondsToEta(operation.secondsRemaining)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className='relative h-1 overflow-hidden rounded-full bg-white/20'>\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName='transition-w absolute top-0 left-0 h-full rounded-full bg-brand duration-300'\n\t\t\t\t\t\t\t\t\t\t\tstyle={{width: `${operation.percent}%`}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</div>\n\t\t\t</ScrollArea>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/rewind/snapshot-carousel.tsx",
    "content": "import {AnimatePresence, motion} from 'motion/react'\nimport {useMemo, useState} from 'react'\nimport {TbLoader} from 'react-icons/tb'\n\nimport {Card} from '@/components/ui/card'\nimport stickerBgUrl from '@/features/files/assets/rewind-sticker-bg.svg'\nimport {EmbeddedFiles} from '@/features/files/components/embedded'\nimport {APPS_PATH, HOME_PATH} from '@/features/files/constants'\nimport {useNavigate as useFilesNavigate} from '@/features/files/hooks/use-navigate'\nimport {formatFilesystemDateOnly} from '@/features/files/utils/format-filesystem-date'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\nimport type {SupportedLanguageCode} from '@/utils/language'\n\nexport function SnapshotCarousel({\n\tbackupsForTimeline,\n\tactiveIndex,\n\tnoCarousel,\n\texplorerVisible,\n\texplorerScale,\n\tlang,\n\twallpaperUrl,\n\tmountedDir,\n\texplorerContainerRef,\n}: {\n\tbackupsForTimeline: {id: string; time: number}[]\n\tactiveIndex: number\n\tnoCarousel: boolean\n\texplorerVisible: boolean\n\texplorerScale: number\n\tlang: string\n\twallpaperUrl?: string\n\tmountedDir: string | null\n\texplorerContainerRef: React.RefObject<HTMLDivElement | null>\n}) {\n\tconst windowStart = noCarousel ? activeIndex : Math.max(0, activeIndex - 2)\n\tconst windowEnd = noCarousel ? activeIndex : Math.min(backupsForTimeline.length - 1, activeIndex + 2)\n\n\t// Derive initial path for the embedded Files explorer:\n\t// - If the user is currently in /Home or /Apps, keep that path\n\t// - Otherwise, fall back to /Home\n\tconst {currentPath} = useFilesNavigate()\n\tconst safeInitialPath =\n\t\tcurrentPath.startsWith(APPS_PATH) || currentPath.startsWith(HOME_PATH) ? currentPath : HOME_PATH\n\n\t// Persist the user's in‑Rewind navigation across snapshot switches.\n\tconst [rewindPath, setRewindPath] = useState<string>(safeInitialPath)\n\n\t// Calculate animation properties\n\tconst visibleSnapshots = useMemo(() => {\n\t\treturn backupsForTimeline.slice(windowStart, windowEnd + 1).map((b, i) => {\n\t\t\tconst index = windowStart + i\n\t\t\tconst delta = index - activeIndex\n\t\t\tconst isActive = index === activeIndex\n\n\t\t\t// Positioning with equidistant spacing\n\t\t\tconst overlapX = noCarousel ? 0 : Math.max(-2, Math.min(2, delta)) * 60\n\n\t\t\t// Smooth scale transitions gradual scaling\n\t\t\tconst scale = noCarousel ? 1 : Math.max(0.9, 1 - Math.min(Math.abs(delta), 2) * 0.05)\n\n\t\t\t// z-index layering\n\t\t\tconst z = 100 - Math.abs(delta)\n\n\t\t\t// Gentle blur effect\n\t\t\tconst blurPx = isActive ? 0 : Math.min(1.5, Math.max(0, Math.abs(delta)) * 0.6)\n\n\t\t\t// Calculate initial positions for smooth entry\n\t\t\tconst initialX = delta > 0 ? overlapX + 80 : delta < 0 ? overlapX - 80 : overlapX\n\t\t\tconst initialScale = delta !== 0 ? Math.max(0.7, scale * 0.9) : scale\n\n\t\t\t// Calculate animation delay for staggered effect\n\t\t\tconst delay = Math.abs(delta) * 0.1\n\n\t\t\treturn {\n\t\t\t\tbackup: b,\n\t\t\t\tindex,\n\t\t\t\tdelta,\n\t\t\t\tisActive,\n\t\t\t\toverlapX,\n\t\t\t\tscale,\n\t\t\t\tz,\n\t\t\t\tblurPx,\n\t\t\t\tinitialX,\n\t\t\t\tinitialScale,\n\t\t\t\tdelay,\n\t\t\t}\n\t\t})\n\t}, [backupsForTimeline, windowStart, windowEnd, activeIndex, noCarousel])\n\n\treturn (\n\t\t<AnimatePresence mode='popLayout'>\n\t\t\t{visibleSnapshots.map((snapshot) => (\n\t\t\t\t<motion.div\n\t\t\t\t\tkey={snapshot.backup.id}\n\t\t\t\t\tclassName='absolute inset-0'\n\t\t\t\t\tstyle={{zIndex: snapshot.z, filter: `blur(${snapshot.blurPx}px)`}}\n\t\t\t\t\tinitial={{x: snapshot.initialX, scale: snapshot.initialScale, opacity: 0}}\n\t\t\t\t\tanimate={{\n\t\t\t\t\t\tx: snapshot.overlapX,\n\t\t\t\t\t\tscale: snapshot.scale,\n\t\t\t\t\t\topacity: 1,\n\t\t\t\t\t}}\n\t\t\t\t\texit={{x: snapshot.initialX, scale: snapshot.initialScale, opacity: 0}}\n\t\t\t\t\ttransition={{\n\t\t\t\t\t\ttype: 'spring',\n\t\t\t\t\t\tstiffness: 300,\n\t\t\t\t\t\tdamping: 30,\n\t\t\t\t\t\tdelay: snapshot.delay,\n\t\t\t\t\t\topacity: {duration: 0.2},\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Card className='relative h-full w-full overflow-hidden rounded-2xl bg-black p-0 shadow-2xl lg:p-0'>\n\t\t\t\t\t\t<div className='pointer-events-none absolute inset-0'>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName='absolute inset-0 bg-cover bg-center opacity-90'\n\t\t\t\t\t\t\t\tstyle={{backgroundImage: `url(${wallpaperUrl || ''})`}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t{!noCarousel &&\n\t\t\t\t\t\t\t\t(() => {\n\t\t\t\t\t\t\t\t\tconst label =\n\t\t\t\t\t\t\t\t\t\tsnapshot.backup.id === 'current'\n\t\t\t\t\t\t\t\t\t\t\t? t('rewind.now')\n\t\t\t\t\t\t\t\t\t\t\t: formatFilesystemDateOnly(snapshot.backup.time, lang as SupportedLanguageCode)\n\t\t\t\t\t\t\t\t\tconst useStickerFont = (\n\t\t\t\t\t\t\t\t\t\t['en', 'de', 'es', 'fr', 'it', 'hu', 'nl', 'pt', 'tr'] as Array<SupportedLanguageCode>\n\t\t\t\t\t\t\t\t\t).includes(lang as SupportedLanguageCode)\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<div className='absolute top-24 left-1 z-20'>\n\t\t\t\t\t\t\t\t\t\t\t<div className='origin-top-left translate-y-full -rotate-[88deg]'>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='relative inline-flex items-center justify-center'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<img src={stickerBgUrl} alt='' className='h-8 w-auto' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t'absolute text-xs font-normal tracking-wide text-black uppercase',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tuseStickerFont ? 'font-sticker' : 'font-sans',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{label}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})()}\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{snapshot.isActive ? (\n\t\t\t\t\t\t\t<div className='flex h-full items-end justify-center px-4 pt-4 pb-0 lg:px-8 lg:pt-8'>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tref={explorerContainerRef}\n\t\t\t\t\t\t\t\t\tclassName='relative size-full max-w-[clamp(320px,92vw,1040px)] overflow-hidden rounded-t-2xl backdrop-blur-3xl backdrop-brightness-[0.3] backdrop-saturate-[1.2]'\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t'absolute inset-0 flex items-center justify-center transition-opacity duration-200',\n\t\t\t\t\t\t\t\t\t\t\texplorerVisible ? 'pointer-events-none opacity-0' : 'opacity-100',\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<TbLoader className='h-8 w-8 animate-spin text-white/60' />\n\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t{/* Without drag-and-drop, we make sure to disable all text selection in the embedded Files feature with select-none. */}\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t'absolute inset-0 origin-top-left scale-[var(--ft-scale)] px-3 pt-4 transition-opacity duration-200 md:px-6 md:pt-6',\n\t\t\t\t\t\t\t\t\t\t\texplorerVisible ? 'opacity-100' : 'pointer-events-none opacity-0',\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t['--ft-scale' as any]: explorerScale,\n\t\t\t\t\t\t\t\t\t\t\t\twidth: `calc(100% / ${explorerScale})`,\n\t\t\t\t\t\t\t\t\t\t\t\theight: `calc(100% / ${explorerScale})`,\n\t\t\t\t\t\t\t\t\t\t\t} as any\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<EmbeddedFiles\n\t\t\t\t\t\t\t\t\t\t\tmode='read-only'\n\t\t\t\t\t\t\t\t\t\t\tinitialPath={safeInitialPath}\n\t\t\t\t\t\t\t\t\t\t\tcurrentPath={rewindPath}\n\t\t\t\t\t\t\t\t\t\t\tonNavigate={setRewindPath}\n\t\t\t\t\t\t\t\t\t\t\t// Pass the scale used by the carousel so marquee selection inside the embedded explorer compensates for the transform.\n\t\t\t\t\t\t\t\t\t\t\texplorerScale={explorerScale}\n\t\t\t\t\t\t\t\t\t\t\tpathAliases={\n\t\t\t\t\t\t\t\t\t\t\t\tmountedDir\n\t\t\t\t\t\t\t\t\t\t\t\t\t? {['/Home']: `/Backups/${mountedDir}/Home`, ['/Apps']: `/Backups/${mountedDir}/Apps`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<div className='absolute inset-0 bg-black/20' />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</Card>\n\t\t\t\t</motion.div>\n\t\t\t))}\n\t\t</AnimatePresence>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/rewind/snapshot-date-label.ts",
    "content": "import {formatFilesystemDate} from '@/features/files/utils/format-filesystem-date'\nimport {t} from '@/utils/i18n'\nimport type {SupportedLanguageCode} from '@/utils/language'\n\nexport function getSnapshotDateLabel(\n\tid: string,\n\tbackups: {id: string; time: number}[],\n\tlang: SupportedLanguageCode,\n): string {\n\tif (id === 'current') return t('rewind.now')\n\tconst found = backups.find((b) => b.id === id)\n\tif (!found) return ''\n\treturn formatFilesystemDate(found.time, lang)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/rewind/timeline-bar.tsx",
    "content": "import {Tooltip, TooltipContent, TooltipTrigger} from '@/features/files/components/rewind/tooltip'\nimport {formatFilesystemDate} from '@/features/files/utils/format-filesystem-date'\nimport {useLanguage} from '@/hooks/use-language'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\nexport function TimelineBar({\n\tbackups,\n\tselectedId,\n\tonSelect,\n}: {\n\tbackups: {id: string; time: number}[]\n\tselectedId: string | null | string\n\tonSelect: (id: string) => void\n}) {\n\tconst [lang] = useLanguage()\n\n\t// Determine if we have actual backup data (more than just \"current\")\n\tconst hasBackups = backups && backups.length > 1\n\n\t// Calculate timeline positioning\n\tconst min = hasBackups ? backups[0].time : 0\n\tconst max = hasBackups ? backups[backups.length - 1].time : 1\n\tconst span = Math.max(1, max - min)\n\n\t// Find selected backup and calculate its position\n\tconst selected = selectedId && hasBackups ? backups.find((b) => b.id === selectedId) || null : null\n\tconst selectedPct = selected ? ((selected.time - min) / span) * 100 : null\n\n\treturn (\n\t\t<div className='relative w-full py-3'>\n\t\t\t<div className='relative h-2 w-full rounded-full bg-[#D9D9D9]/20'>\n\t\t\t\t{selectedPct !== null ? (\n\t\t\t\t\t<div className='absolute inset-y-0 -translate-x-1/2' style={{left: `${selectedPct}%`}}>\n\t\t\t\t\t\t<div className='h-full w-px bg-white/30' />\n\t\t\t\t\t</div>\n\t\t\t\t) : null}\n\n\t\t\t\t{backups.map((b) => {\n\t\t\t\t\t// For loading state (only \"current\"), position on the right\n\t\t\t\t\tconst pct = hasBackups ? ((b.time - min) / span) * 100 : b.id === 'current' ? 100 : 0\n\t\t\t\t\tconst isSel = selectedId === b.id\n\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<Tooltip key={b.id}>\n\t\t\t\t\t\t\t<TooltipTrigger asChild>\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tstyle={{left: `${pct}%`}}\n\t\t\t\t\t\t\t\t\tonClick={() => onSelect(b.id)}\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t'absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full p-2 md:p-0',\n\t\t\t\t\t\t\t\t\t\tisSel ? 'z-10 hover:z-10 focus-visible:z-10' : 'hover:z-10 focus-visible:z-10',\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\taria-label={new Date(b.time).toLocaleString()}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t'block rounded-full',\n\t\t\t\t\t\t\t\t\t\t\tisSel\n\t\t\t\t\t\t\t\t\t\t\t\t? 'size-2 bg-white shadow-[0_0_4px_2px_rgba(255,255,255,0.5)]'\n\t\t\t\t\t\t\t\t\t\t\t\t: 'size-2 bg-[#5B5B5B] hover:bg-[#6d6d6d]',\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</TooltipTrigger>\n\t\t\t\t\t\t\t<TooltipContent side='bottom' className='text-[12px] text-black'>\n\t\t\t\t\t\t\t\t{b.id === 'current' ? t('rewind.now') : formatFilesystemDate(b.time, lang)}\n\t\t\t\t\t\t\t</TooltipContent>\n\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t)\n\t\t\t\t})}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/rewind/tooltip.tsx",
    "content": "// This is a pure shadcn component that we use specifically for Rewind feature\n// The other shadcn tooltip component in the codebase is legacy and used elsewhere without a TooltipPrimitive.Arrow\nimport * as TooltipPrimitive from '@radix-ui/react-tooltip'\nimport * as React from 'react'\n\nimport {cn} from '@/lib/utils'\n\nfunction TooltipProvider({delayDuration = 0, ...props}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n\treturn <TooltipPrimitive.Provider data-slot='tooltip-provider' delayDuration={delayDuration} {...props} />\n}\n\nfunction Tooltip({...props}: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n\treturn (\n\t\t<TooltipProvider>\n\t\t\t<TooltipPrimitive.Root data-slot='tooltip' {...props} />\n\t\t</TooltipProvider>\n\t)\n}\n\nfunction TooltipTrigger({...props}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n\treturn <TooltipPrimitive.Trigger data-slot='tooltip-trigger' {...props} />\n}\n\nfunction TooltipContent({\n\tclassName,\n\tsideOffset = 0,\n\tchildren,\n\t...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n\treturn (\n\t\t<TooltipPrimitive.Portal>\n\t\t\t<TooltipPrimitive.Content\n\t\t\t\tdata-slot='tooltip-content'\n\t\t\t\tsideOffset={sideOffset}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] animate-in rounded-md bg-white px-3 py-1.5 text-xs text-balance text-neutral-950 shadow-md fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',\n\t\t\t\t\tclassName,\n\t\t\t\t)}\n\t\t\t\t{...props}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t\t<TooltipPrimitive.Arrow className='z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-white fill-white' />\n\t\t\t</TooltipPrimitive.Content>\n\t\t</TooltipPrimitive.Portal>\n\t)\n}\n\nexport {Tooltip, TooltipTrigger, TooltipContent, TooltipProvider}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/shared/circular-progress.tsx",
    "content": "import React from 'react'\n\ninterface CircularProgressProps {\n\tprogress: number\n\tsize?: number\n\tstrokeWidth?: number\n\tchildren?: React.ReactNode\n}\n\nexport const CircularProgress: React.FC<CircularProgressProps> = ({progress, size = 20, strokeWidth = 2, children}) => {\n\tconst radius = (size - strokeWidth) / 2\n\tconst circumference = radius * 2 * Math.PI\n\tconst offset = circumference - (progress / 100) * circumference\n\n\treturn (\n\t\t<div className='relative inline-flex items-center justify-center transition-transform'>\n\t\t\t<svg width={size} height={size} className='-rotate-90 transform'>\n\t\t\t\t{/* Background circle */}\n\t\t\t\t<circle\n\t\t\t\t\tcx={size / 2}\n\t\t\t\t\tcy={size / 2}\n\t\t\t\t\tr={radius}\n\t\t\t\t\tstrokeWidth={strokeWidth}\n\t\t\t\t\tstroke='rgba(255, 255, 255, 0.2)'\n\t\t\t\t\tfill='none'\n\t\t\t\t/>\n\t\t\t\t{/* Progress circle */}\n\t\t\t\t<circle\n\t\t\t\t\tcx={size / 2}\n\t\t\t\t\tcy={size / 2}\n\t\t\t\t\tr={radius}\n\t\t\t\t\tstrokeWidth={strokeWidth}\n\t\t\t\t\tfill='none'\n\t\t\t\t\tstrokeLinecap='round'\n\t\t\t\t\tclassName='stroke-brand'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tstrokeDasharray: circumference,\n\t\t\t\t\t\tstrokeDashoffset: offset,\n\t\t\t\t\t\ttransition: 'stroke-dashoffset 0.3s ease',\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</svg>\n\t\t\t<div className='absolute inset-0 flex items-center justify-center text-white'>{children}</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/shared/drag-and-drop.tsx",
    "content": "import {useDraggable, useDroppable} from '@dnd-kit/core'\nimport React, {ElementType, useEffect, useState} from 'react'\nimport {useTimeoutFn} from 'react-use'\n\nimport {useIsTouchDevice} from '@/features/files/hooks/use-is-touch-device'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport type {FileSystemItem, PolymorphicPropsWithoutRef} from '@/features/files/types'\nimport {cn} from '@/lib/utils'\n\ninterface DraggableProps {\n\tid: string\n\tchildren: React.ReactNode\n\titem: FileSystemItem\n\tclassName?: string\n\tdisabled?: boolean\n}\n\ninterface DroppableProps {\n\tid: string\n\tchildren: React.ReactNode | ((isOver: boolean) => React.ReactNode)\n\tpath: string\n\tnavigateToPath?: boolean\n\tclassName?: string\n\tdropOverClassName?: string\n\tdisabled?: boolean\n}\n\nexport const Draggable = ({id, children, className, item, disabled = false, ...props}: DraggableProps) => {\n\tconst isReadOnly = useIsFilesReadOnly()\n\tconst {attributes, listeners, setNodeRef} = useDraggable({\n\t\tid: id,\n\t\tdata: item,\n\t})\n\n\tconst isTouchDevice = useIsTouchDevice()\n\n\tif (disabled || isTouchDevice || isReadOnly) return <div className={className}>{children}</div>\n\n\treturn (\n\t\t<div\n\t\t\tref={setNodeRef}\n\t\t\t{...listeners}\n\t\t\t{...attributes}\n\t\t\t{...props}\n\t\t\tclassName={cn('touch-none outline-hidden', className)}\n\t\t>\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n\nexport const Droppable = <T extends ElementType = 'div'>({\n\tid,\n\tchildren,\n\tclassName,\n\tpath,\n\tnavigateToPath = true,\n\tdropOverClassName = 'bg-brand text-white',\n\tdisabled = false,\n\tas,\n\t...props\n}: PolymorphicPropsWithoutRef<T, DroppableProps>) => {\n\tconst Component = as || 'div'\n\tconst isReadOnly = useIsFilesReadOnly()\n\tconst {setNodeRef, isOver, over} = useDroppable({\n\t\tid: id,\n\t\tdata: {\n\t\t\tpath: path,\n\t\t},\n\t})\n\n\tconst isTouchDevice = useIsTouchDevice()\n\tconst {currentPath, navigateToDirectory} = useNavigate()\n\tconst [isReadyToNavigate, setIsReadyToNavigate] = useState(false)\n\tconst draggedItems = useFilesStore((s) => s.draggedItems)\n\tconst [isOverValidDropTarget, setIsOverValidDropTarget] = useState(false)\n\n\t// Start blinking animation after hover\n\tconst [, cancelBlinkTimer, resetBlinkTimer] = useTimeoutFn(() => {\n\t\tsetIsReadyToNavigate(true)\n\t}, 1000) // 1s\n\n\t// Navigate after animation completes\n\tconst [, cancelNavigateTimer, resetNavigateTimer] = useTimeoutFn(() => {\n\t\tsetIsReadyToNavigate(false)\n\t\tnavigateToDirectory(path)\n\t}, 1500) // 1.5s\n\n\tuseEffect(() => {\n\t\tif (isOver && currentPath !== path && isOverValidDropTarget && navigateToPath) {\n\t\t\t// Start both timers\n\t\t\tresetBlinkTimer()\n\t\t\tresetNavigateTimer()\n\t\t} else {\n\t\t\t// Cancel both timers and reset blinking state\n\t\t\tcancelBlinkTimer()\n\t\t\tcancelNavigateTimer()\n\t\t\tsetIsReadyToNavigate(false)\n\t\t}\n\t}, [\n\t\tisOver,\n\t\tpath,\n\t\tcurrentPath,\n\t\tresetBlinkTimer,\n\t\tresetNavigateTimer,\n\t\tcancelBlinkTimer,\n\t\tcancelNavigateTimer,\n\t\tisOverValidDropTarget,\n\t\tnavigateToPath,\n\t])\n\n\tuseEffect(() => {\n\t\tif (over?.data?.current?.path) {\n\t\t\t// check if the user isn't trying to drop inside a folder that's a part of the dragged items\n\t\t\tconst isDroppingInsideDraggedItems = draggedItems.some((item) => item.path === over?.data.current?.path)\n\t\t\tsetIsOverValidDropTarget(!isDroppingInsideDraggedItems)\n\t\t}\n\t}, [over, draggedItems])\n\n\tconst isReadyToDrop = isOver && isOverValidDropTarget\n\tconst renderedChildren = typeof children === 'function' ? children(isReadyToDrop) : children\n\n\tif (disabled || isTouchDevice || isReadOnly)\n\t\treturn (\n\t\t\t<div className={className} {...props}>\n\t\t\t\t{renderedChildren}\n\t\t\t</div>\n\t\t)\n\n\treturn (\n\t\t<Component\n\t\t\tref={setNodeRef}\n\t\t\tclassName={cn(\n\t\t\t\tclassName,\n\t\t\t\tisReadyToDrop && dropOverClassName,\n\t\t\t\tisReadyToNavigate && 'animate-files-folder-blink-on-drag-hover',\n\t\t\t)}\n\t\t\t{...props}\n\t\t>\n\t\t\t{renderedChildren}\n\t\t</Component>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/shared/file-item-icon/animated-folder-icon.tsx",
    "content": "import {motion, Transition, Variants} from 'motion/react'\n\ninterface AnimatedFolderIconProps {\n\toverlayIcon?: React.ComponentType<React.SVGProps<SVGSVGElement>>\n\tclassName?: string\n\t// We use isHovered to allow a parent component to trigger the hover state\n\tisHovered?: boolean\n}\n\nconst transition: Transition = {\n\ttype: 'spring',\n\tbounce: 0,\n\tduration: 0.4,\n}\n\n// -- Variants for each part: numeric transforms only --\nconst folderSecondTabVariants: Variants = {\n\tidle: {\n\t\trotateZ: 0,\n\t\ty: 0,\n\t\ttransition,\n\t},\n\thover: {\n\t\trotateZ: 14,\n\t\ty: 1,\n\t\ttransition,\n\t},\n}\n\nconst folderFirstTabVariants: Variants = {\n\tidle: {\n\t\trotateZ: 0,\n\t\ty: 0,\n\t\ttransition,\n\t},\n\thover: {\n\t\trotateZ: 9,\n\t\ty: 3,\n\t\ttransition,\n\t},\n}\n\nconst folderBodyVariants: Variants = {\n\tidle: {\n\t\trotateX: 0,\n\t\ty: 0,\n\t\ttransition,\n\t},\n\thover: {\n\t\trotateX: -30,\n\t\ty: 4, // tweak this value to control how much the folder \"opens outwards\"\n\t\ttransition,\n\t},\n}\n\nconst overlayVariants: Variants = {\n\tidle: {\n\t\trotateX: 0,\n\t\ty: 0,\n\t\ttransition,\n\t},\n\thover: {\n\t\trotateX: -30,\n\t\ty: 1, // tweak this value to control how much the overlay icon \"opens outwards\". This looks better being less than the folder body.\n\t\ttransition,\n\t},\n}\n\nexport const AnimatedFolderIcon = ({\n\tclassName,\n\toverlayIcon: OverlayIcon,\n\tisHovered = false,\n\t...svgProps\n}: AnimatedFolderIconProps) => {\n\treturn (\n\t\t// Non-animating parent with perspective\n\t\t<div\n\t\t\tstyle={{\n\t\t\t\tperspective: '1000px',\n\t\t\t\ttransformStyle: 'preserve-3d',\n\t\t\t}}\n\t\t>\n\t\t\t{/* Child motion.div that toggles between \"idle\" and \"hover\" using numeric transforms only. */}\n\t\t\t<motion.div\n\t\t\t\tinitial='idle'\n\t\t\t\tanimate={isHovered ? 'hover' : 'idle'}\n\t\t\t\tstyle={{\n\t\t\t\t\t// Ensures nested elements also respect 3D transforms\n\t\t\t\t\ttransformStyle: 'preserve-3d',\n\t\t\t\t\ttransformOrigin: 'center bottom',\n\t\t\t\t}}\n\t\t\t\tclassName='relative'\n\t\t\t>\n\t\t\t\t<svg\n\t\t\t\t\twidth='57'\n\t\t\t\t\theight='50'\n\t\t\t\t\tviewBox='0 0 57 50'\n\t\t\t\t\tfill='none'\n\t\t\t\t\txmlns='http://www.w3.org/2000/svg'\n\t\t\t\t\tclassName={className}\n\t\t\t\t\t{...svgProps}\n\t\t\t\t>\n\t\t\t\t\t{/* Gray insert */}\n\t\t\t\t\t<g clipPath='url(#animated-folder-clip0)'>\n\t\t\t\t\t\t<g opacity='0.6' filter={isHovered ? 'url(#animated-folder-filter0)' : undefined}>\n\t\t\t\t\t\t\t<motion.path\n\t\t\t\t\t\t\t\tvariants={folderSecondTabVariants}\n\t\t\t\t\t\t\t\td='M17.0326 6.81456C16.7616 5.22668 17.8377 3.77724 19.436 3.57715L41.1392 0.86014C42.7376 0.660043 44.253 1.78506 44.5239 3.37294L45.8488 11.1373L18.3575 14.579L17.0326 6.81456Z'\n\t\t\t\t\t\t\t\tfill='white'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</g>\n\n\t\t\t\t\t\t{/* White instert */}\n\t\t\t\t\t\t<g filter={isHovered ? 'url(#animated-folder-filter1)' : undefined}>\n\t\t\t\t\t\t\t<motion.path\n\t\t\t\t\t\t\t\tvariants={folderFirstTabVariants}\n\t\t\t\t\t\t\t\td='M11.5798 9.90146C11.3176 8.31211 12.4022 6.87309 14.0023 6.68733L50.4821 2.45217C52.0821 2.26641 53.5918 3.40424 53.8539 4.9936L54.7352 10.3361L12.461 15.244L11.5798 9.90146Z'\n\t\t\t\t\t\t\t\tfill='white'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</g>\n\n\t\t\t\t\t\t{/* Folder body */}\n\t\t\t\t\t\t<g filter='url(#animated-folder-filter2)'>\n\t\t\t\t\t\t\t<motion.path\n\t\t\t\t\t\t\t\tvariants={folderBodyVariants}\n\t\t\t\t\t\t\t\td='M0.70639 32V15.261C0.70639 9.40584 0.70639 6.47828 2.01666 4.32368C2.7654 3.09245 3.79884 2.05901 5.03007 1.31027C7.18467 0 10.1122 0 15.9674 0C17.4916 0 18.2537 0 18.9574 0.211449C19.3656 0.334078 19.7564 0.508388 20.1204 0.730141C20.7479 1.11251 21.2571 1.67954 22.2755 2.81358L22.7476 3.33928L22.7476 3.3393C24.2151 4.9735 24.9489 5.7906 25.9211 6.22422C26.8933 6.65785 27.9915 6.65785 30.1879 6.65785H44.7064C50.3632 6.65785 53.1917 6.65785 54.949 8.41521C56.7064 10.1726 56.7064 13.001 56.7064 18.6578V32C56.7064 40.4853 56.7064 44.7279 54.0704 47.364C51.4343 50 47.1917 50 38.7064 50H18.7064C10.2211 50 5.97847 50 3.34243 47.364C0.70639 44.7279 0.70639 40.4853 0.70639 32Z'\n\t\t\t\t\t\t\t\tfill='hsl(var(--color-brand))'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<motion.path\n\t\t\t\t\t\t\t\tvariants={folderBodyVariants}\n\t\t\t\t\t\t\t\td='M0.70639 32V15.261C0.70639 9.40584 0.70639 6.47828 2.01666 4.32368C2.7654 3.09245 3.79884 2.05901 5.03007 1.31027C7.18467 0 10.1122 0 15.9674 0C17.4916 0 18.2537 0 18.9574 0.211449C19.3656 0.334078 19.7564 0.508388 20.1204 0.730141C20.7479 1.11251 21.2571 1.67954 22.2755 2.81358L22.7476 3.33928L22.7476 3.3393C24.2151 4.9735 24.9489 5.7906 25.9211 6.22422C26.8933 6.65785 27.9915 6.65785 30.1879 6.65785H44.7064C50.3632 6.65785 53.1917 6.65785 54.949 8.41521C56.7064 10.1726 56.7064 13.001 56.7064 18.6578V32C56.7064 40.4853 56.7064 44.7279 54.0704 47.364C51.4343 50 47.1917 50 38.7064 50H18.7064C10.2211 50 5.97847 50 3.34243 47.364C0.70639 44.7279 0.70639 40.4853 0.70639 32Z'\n\t\t\t\t\t\t\t\tfill='url(#animated-folder-paint0)'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</g>\n\t\t\t\t\t</g>\n\n\t\t\t\t\t{/* -- Defs -- */}\n\t\t\t\t\t<defs>\n\t\t\t\t\t\t{/* Shadow on right side of gray insert. Only seen when hovering, so only applied when isHovered */}\n\t\t\t\t\t\t<filter\n\t\t\t\t\t\t\tid='animated-folder-filter0'\n\t\t\t\t\t\t\tx='15.4353'\n\t\t\t\t\t\t\ty='-0.912903'\n\t\t\t\t\t\t\twidth='32.1636'\n\t\t\t\t\t\t\theight='16.2697'\n\t\t\t\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<feFlood floodOpacity='0' result='BackgroundImageFix' />\n\t\t\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<feOffset dx='-0.777778' />\n\t\t\t\t\t\t\t<feGaussianBlur stdDeviation='0.388889' />\n\t\t\t\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\t\t\tvalues='0 0 0 0 0.0745098 0 0 0 0 0.0745098 0 0 0 0 0.0823529 0 0 0 0.16 0'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<feBlend mode='normal' in2='BackgroundImageFix' result='effect1_dropShadow_1_651' />\n\t\t\t\t\t\t\t<feBlend mode='normal' in='SourceGraphic' in2='effect1_dropShadow_1_651' result='shape' />\n\t\t\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<feOffset dx='2.625' dy='-2.625' />\n\t\t\t\t\t\t\t<feGaussianBlur stdDeviation='0.875' />\n\t\t\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0.159774 0 0 0 0 0.20031 0 0 0 0 0.413127 0 0 0 0.26 0' />\n\t\t\t\t\t\t\t<feBlend mode='normal' in2='shape' result='effect2_innerShadow_1_651' />\n\t\t\t\t\t\t</filter>\n\n\t\t\t\t\t\t{/* Shadow on left side of white insert. Only seen when hovering, so only applied when isHovered */}\n\t\t\t\t\t\t<filter\n\t\t\t\t\t\t\tid='animated-folder-filter1'\n\t\t\t\t\t\t\tx='9.98512'\n\t\t\t\t\t\t\ty='0.682312'\n\t\t\t\t\t\t\twidth='46.5001'\n\t\t\t\t\t\t\theight='15.3394'\n\t\t\t\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<feFlood floodOpacity='0' result='BackgroundImageFix' />\n\t\t\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<feOffset dx='-0.777778' />\n\t\t\t\t\t\t\t<feGaussianBlur stdDeviation='0.388889' />\n\t\t\t\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\t\t\tvalues='0 0 0 0 0.0745098 0 0 0 0 0.0745098 0 0 0 0 0.0823529 0 0 0 0.16 0'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<feBlend mode='normal' in2='BackgroundImageFix' result='effect1_dropShadow_1_651' />\n\t\t\t\t\t\t\t<feBlend mode='normal' in='SourceGraphic' in2='effect1_dropShadow_1_651' result='shape' />\n\t\t\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<feOffset dx='2.625' dy='-2.625' />\n\t\t\t\t\t\t\t<feGaussianBlur stdDeviation='0.875' />\n\t\t\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0.159774 0 0 0 0 0.20031 0 0 0 0 0.413127 0 0 0 0.26 0' />\n\t\t\t\t\t\t\t<feBlend mode='normal' in2='shape' result='effect2_innerShadow_1_651' />\n\t\t\t\t\t\t</filter>\n\n\t\t\t\t\t\t{/* Reflective/curved edges of the folder body */}\n\t\t\t\t\t\t<filter\n\t\t\t\t\t\t\tid='animated-folder-filter2'\n\t\t\t\t\t\t\tx='-0.29361'\n\t\t\t\t\t\t\ty='-1'\n\t\t\t\t\t\t\twidth='57.5'\n\t\t\t\t\t\t\theight='51.5'\n\t\t\t\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<feFlood floodOpacity='0' result='BackgroundImageFix' />\n\t\t\t\t\t\t\t<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />\n\t\t\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<feOffset dx='1' dy='1' />\n\t\t\t\t\t\t\t<feGaussianBlur stdDeviation='0.25' />\n\t\t\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0' />\n\t\t\t\t\t\t\t<feBlend mode='normal' in2='shape' result='effect1_innerShadow_1_651' />\n\t\t\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<feOffset dx='-1' dy='-1' />\n\t\t\t\t\t\t\t<feGaussianBlur stdDeviation='0.5' />\n\t\t\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0' />\n\t\t\t\t\t\t\t<feBlend mode='normal' in2='effect1_innerShadow_1_651' result='effect2_innerShadow_1_651' />\n\t\t\t\t\t\t</filter>\n\n\t\t\t\t\t\t{/* Shaded dark gradient for the folder body */}\n\t\t\t\t\t\t<linearGradient\n\t\t\t\t\t\t\tid='animated-folder-paint0'\n\t\t\t\t\t\t\tx1='28.7064'\n\t\t\t\t\t\t\ty1='0'\n\t\t\t\t\t\t\tx2='28.7064'\n\t\t\t\t\t\t\ty2='50'\n\t\t\t\t\t\t\tgradientUnits='userSpaceOnUse'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<stop offset='0.315' stopOpacity='0' />\n\t\t\t\t\t\t\t<stop offset='0.965' stopOpacity='0.48' />\n\t\t\t\t\t\t</linearGradient>\n\n\t\t\t\t\t\t{/* Clip path for the folder body */}\n\t\t\t\t\t\t<clipPath id='animated-folder-clip0'>\n\t\t\t\t\t\t\t<rect x='0.833344' width='56' height='50' rx='10' fill='white' />\n\t\t\t\t\t\t</clipPath>\n\t\t\t\t\t</defs>\n\t\t\t\t</svg>\n\n\t\t\t\t{/* Overlay icon */}\n\t\t\t\t{OverlayIcon && (\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tclassName='pointer-events-none absolute top-[28%] left-[23%] h-[56%] w-[56%]'\n\t\t\t\t\t\tvariants={overlayVariants}\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\ttransformStyle: 'preserve-3d',\n\t\t\t\t\t\t\ttransformOrigin: 'bottom center',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<OverlayIcon className='h-full w-full text-black/30' />\n\t\t\t\t\t</motion.div>\n\t\t\t\t)}\n\t\t\t</motion.div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/shared/file-item-icon/embedded-overlay-icons.tsx",
    "content": "import {SVGProps} from 'react'\n\nexport const DocumentsIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='26' height='30' viewBox='0 0 26 30' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g filter='url(#filter0_ddi_1032_7467)'>\n\t\t\t<path\n\t\t\t\td='M23.8572 9.54785L16.6777 2.36836C16.5824 2.27314 16.4692 2.19764 16.3447 2.14617C16.2202 2.09469 16.0868 2.06825 15.9521 2.06836H3.64438C3.10034 2.06836 2.57859 2.28448 2.1939 2.66917C1.80921 3.05386 1.59309 3.57561 1.59309 4.11964V26.6837C1.59309 27.2278 1.80921 27.7495 2.1939 28.1342C2.57859 28.5189 3.10034 28.735 3.64438 28.735H22.1059C22.6499 28.735 23.1717 28.5189 23.5564 28.1342C23.9411 27.7495 24.1572 27.2278 24.1572 26.6837V10.2735C24.1573 10.1388 24.1309 10.0053 24.0794 9.88082C24.0279 9.75631 23.9524 9.64317 23.8572 9.54785ZM16.6754 10.2735C16.2759 10.2735 15.9521 9.94962 15.9521 9.55011V6.37886C15.9521 5.73439 16.7312 5.41164 17.187 5.86735L20.3582 9.0386C20.8139 9.4943 20.4912 10.2735 19.8467 10.2735H16.6754Z'\n\t\t\t\tfill='currentColor'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<filter\n\t\t\t\tid='filter0_ddi_1032_7467'\n\t\t\t\tx='0.146335'\n\t\t\t\ty='0.6216'\n\t\t\t\twidth='25.0959'\n\t\t\t\theight='29.1978'\n\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t>\n\t\t\t\t<feFlood floodOpacity='0' result='BackgroundImageFix' />\n\t\t\t\t<feColorMatrix\n\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\ttype='matrix'\n\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t/>\n\t\t\t\t<feOffset dx='-0.72338' dy='-0.72338' />\n\t\t\t\t<feGaussianBlur stdDeviation='0.36169' />\n\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0' />\n\t\t\t\t<feBlend mode='normal' in2='BackgroundImageFix' result='effect1_dropShadow_1032_7467' />\n\t\t\t\t<feColorMatrix\n\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\ttype='matrix'\n\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t/>\n\t\t\t\t<feOffset dx='0.36169' dy='0.36169' />\n\t\t\t\t<feGaussianBlur stdDeviation='0.36169' />\n\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.32 0' />\n\t\t\t\t<feBlend mode='overlay' in2='effect1_dropShadow_1032_7467' result='effect2_dropShadow_1032_7467' />\n\t\t\t\t<feBlend mode='normal' in='SourceGraphic' in2='effect2_dropShadow_1032_7467' result='shape' />\n\t\t\t\t<feColorMatrix\n\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\ttype='matrix'\n\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t/>\n\t\t\t\t<feOffset dx='0.36169' dy='0.36169' />\n\t\t\t\t<feGaussianBlur stdDeviation='0.36169' />\n\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.29 0' />\n\t\t\t\t<feBlend mode='overlay' in2='shape' result='effect3_innerShadow_1032_7467' />\n\t\t\t</filter>\n\t\t</defs>\n\t</svg>\n)\n\nexport const VideosIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='32' height='28' viewBox='0 0 32 28' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g filter='url(#filter0_ddi_1032_7485)'>\n\t\t\t<path\n\t\t\t\td='M28.5622 1.91602H3.70385C3.1045 1.91602 2.5297 2.15411 2.1059 2.57791C1.68209 3.00172 1.444 3.57652 1.444 4.17587V24.5145C1.444 25.1139 1.68209 25.6887 2.1059 26.1125C2.5297 26.5363 3.1045 26.7744 3.70385 26.7744H28.5622C29.1616 26.7744 29.7364 26.5363 30.1602 26.1125C30.584 25.6887 30.8221 25.1139 30.8221 24.5145V4.17587C30.8221 3.57652 30.584 3.00172 30.1602 2.57791C29.7364 2.15411 29.1616 1.91602 28.5622 1.91602ZM24.0425 4.85019C24.0425 4.47777 24.3444 4.17587 24.7168 4.17587H27.8879C28.2603 4.17587 28.5622 4.47777 28.5622 4.85019V5.76139C28.5622 6.13381 28.2603 6.43572 27.8879 6.43572H24.7168C24.3444 6.43572 24.0425 6.13381 24.0425 5.76139V4.85019ZM8.22356 23.8402C8.22356 24.2126 7.92165 24.5145 7.54923 24.5145H4.37818C4.00576 24.5145 3.70385 24.2126 3.70385 23.8402V22.929C3.70385 22.5566 4.00576 22.2547 4.37818 22.2547H7.54923C7.92165 22.2547 8.22356 22.5566 8.22356 22.929V23.8402ZM8.22356 5.76139C8.22356 6.13381 7.92165 6.43572 7.54923 6.43572H4.37818C4.00576 6.43572 3.70385 6.13381 3.70385 5.76139V4.85019C3.70385 4.47777 4.00576 4.17587 4.37818 4.17587H7.54923C7.92165 4.17587 8.22356 4.47777 8.22356 4.85019V5.76139ZM15.0031 23.8402C15.0031 24.2126 14.7012 24.5145 14.3288 24.5145H11.1577C10.7853 24.5145 10.4834 24.2126 10.4834 23.8402V22.929C10.4834 22.5566 10.7853 22.2547 11.1577 22.2547H14.3288C14.7012 22.2547 15.0031 22.5566 15.0031 22.929V23.8402ZM15.0031 5.76139C15.0031 6.13381 14.7012 6.43572 14.3288 6.43572H11.1577C10.7853 6.43572 10.4834 6.13381 10.4834 5.76139V4.85019C10.4834 4.47777 10.7853 4.17587 11.1577 4.17587H14.3288C14.7012 4.17587 15.0031 4.47777 15.0031 4.85019V5.76139ZM21.7827 23.8402C21.7827 24.2126 21.4808 24.5145 21.1083 24.5145H17.9373C17.5649 24.5145 17.263 24.2126 17.263 23.8402V22.929C17.263 22.5566 17.5649 22.2547 17.9373 22.2547H21.1083C21.4808 22.2547 21.7827 22.5566 21.7827 22.929V23.8402ZM21.7827 5.76139C21.7827 6.13381 21.4808 6.43572 21.1083 6.43572H17.9373C17.5649 6.43572 17.263 6.13381 17.263 5.76139V4.85019C17.263 4.47777 17.5649 4.17587 17.9373 4.17587H21.1083C21.4808 4.17587 21.7827 4.47777 21.7827 4.85019V5.76139ZM28.5622 23.8402C28.5622 24.2126 28.2603 24.5145 27.8879 24.5145H24.7168C24.3444 24.5145 24.0425 24.2126 24.0425 23.8402V22.929C24.0425 22.5566 24.3444 22.2547 24.7168 22.2547H27.8879C28.2603 22.2547 28.5622 22.5566 28.5622 22.929V23.8402Z'\n\t\t\t\tfill='currentColor'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<filter\n\t\t\t\tid='filter0_ddi_1032_7485'\n\t\t\t\tx='0.0953472'\n\t\t\t\ty='0.567363'\n\t\t\t\twidth='31.7382'\n\t\t\t\theight='27.2176'\n\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t>\n\t\t\t\t<feFlood floodOpacity='0' result='BackgroundImageFix' />\n\t\t\t\t<feColorMatrix\n\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\ttype='matrix'\n\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t/>\n\t\t\t\t<feOffset dx='-0.674327' dy='-0.674327' />\n\t\t\t\t<feGaussianBlur stdDeviation='0.337163' />\n\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0' />\n\t\t\t\t<feBlend mode='normal' in2='BackgroundImageFix' result='effect1_dropShadow_1032_7485' />\n\t\t\t\t<feColorMatrix\n\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\ttype='matrix'\n\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t/>\n\t\t\t\t<feOffset dx='0.337163' dy='0.337163' />\n\t\t\t\t<feGaussianBlur stdDeviation='0.337163' />\n\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.32 0' />\n\t\t\t\t<feBlend mode='overlay' in2='effect1_dropShadow_1032_7485' result='effect2_dropShadow_1032_7485' />\n\t\t\t\t<feBlend mode='normal' in='SourceGraphic' in2='effect2_dropShadow_1032_7485' result='shape' />\n\t\t\t\t<feColorMatrix\n\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\ttype='matrix'\n\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t/>\n\t\t\t\t<feOffset dx='0.337163' dy='0.337163' />\n\t\t\t\t<feGaussianBlur stdDeviation='0.337163' />\n\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.29 0' />\n\t\t\t\t<feBlend mode='overlay' in2='shape' result='effect3_innerShadow_1032_7485' />\n\t\t\t</filter>\n\t\t</defs>\n\t</svg>\n)\n\nexport const DownloadsIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='29' height='29' viewBox='0 0 29 29' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g filter='url(#filter0_ddi_1032_7458)'>\n\t\t\t<path\n\t\t\t\td='M14.625 2.02148C14.3525 2.02148 14.085 2.02398 13.8225 2.02773L13.0525 2.04898L12.6788 2.06523L11.955 2.10773L11.2638 2.16523C5.2825 2.74523 2.84875 5.17898 2.26875 11.1602L2.21125 11.8515L2.16875 12.5752C2.1625 12.6977 2.15625 12.8227 2.1525 12.949L2.13125 13.719L2.12625 14.1165L2.125 14.5215C2.125 14.794 2.1275 15.0615 2.13125 15.324L2.1525 16.094L2.16875 16.4677L2.21125 17.1915L2.26875 17.8827C2.84875 23.864 5.2825 26.2977 11.2638 26.8777L11.955 26.9352L12.6788 26.9777C12.8012 26.984 12.9262 26.9902 13.0525 26.994L13.8225 27.0152L14.625 27.0215L15.4275 27.0152L16.1975 26.994L16.5712 26.9777L17.295 26.9352L17.9862 26.8777C23.9675 26.2977 26.4013 23.864 26.9813 17.8827L27.0387 17.1915L27.0812 16.4677C27.0875 16.3452 27.0938 16.2202 27.0975 16.094L27.1188 15.324L27.125 14.5215L27.1188 13.719L27.0975 12.949L27.0812 12.5752L27.0387 11.8515L26.9813 11.1602C26.4013 5.17898 23.9675 2.74523 17.9862 2.16523L17.295 2.10773L16.5712 2.06523C16.4467 2.05915 16.3221 2.05374 16.1975 2.04898L15.4275 2.02773L15.03 2.02273L14.625 2.02148ZM14.625 8.27148C14.9312 8.27152 15.2267 8.38393 15.4555 8.58738C15.6843 8.79083 15.8304 9.07117 15.8663 9.37523L15.875 9.52148V14.7569C15.875 15.4013 16.654 15.724 17.1098 15.2685L18.7413 13.6377C18.9565 13.4225 19.2429 13.2932 19.5466 13.2741C19.8504 13.255 20.1507 13.3474 20.3913 13.534L20.5088 13.6377C20.724 13.853 20.8533 14.1393 20.8724 14.4431C20.8915 14.7469 20.7991 15.0472 20.6125 15.2877L20.5088 15.4052L15.5087 20.4052C15.4651 20.449 15.4183 20.4896 15.3688 20.5265L15.2312 20.6152L15.0888 20.6827L14.9575 20.7265L14.8236 20.7534C14.7888 20.7605 14.7534 20.7649 14.7179 20.7667L14.625 20.7715L14.5312 20.7677L14.3737 20.7465L14.235 20.709L14.0962 20.654L13.9738 20.589L13.8588 20.509L13.7413 20.4052L8.74125 15.4052C8.51707 15.1803 8.38691 14.8784 8.37721 14.561C8.36752 14.2436 8.47902 13.9343 8.68906 13.6961C8.8991 13.4579 9.19194 13.3086 9.50809 13.2785C9.82425 13.2484 10.14 13.3397 10.3912 13.534L10.5087 13.6377L12.1402 15.2685C12.596 15.724 13.375 15.4013 13.375 14.7569V9.52148C13.375 9.18996 13.5067 8.87202 13.7411 8.6376C13.9755 8.40318 14.2935 8.27148 14.625 8.27148Z'\n\t\t\t\tfill='currentColor'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<filter\n\t\t\t\tid='filter0_ddi_1032_7458'\n\t\t\t\tx='0.678241'\n\t\t\t\ty='0.574725'\n\t\t\t\twidth='27.5318'\n\t\t\t\theight='27.5318'\n\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t>\n\t\t\t\t<feFlood floodOpacity='0' result='BackgroundImageFix' />\n\t\t\t\t<feColorMatrix\n\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\ttype='matrix'\n\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t/>\n\t\t\t\t<feOffset dx='-0.72338' dy='-0.72338' />\n\t\t\t\t<feGaussianBlur stdDeviation='0.36169' />\n\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0' />\n\t\t\t\t<feBlend mode='normal' in2='BackgroundImageFix' result='effect1_dropShadow_1032_7458' />\n\t\t\t\t<feColorMatrix\n\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\ttype='matrix'\n\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t/>\n\t\t\t\t<feOffset dx='0.36169' dy='0.36169' />\n\t\t\t\t<feGaussianBlur stdDeviation='0.36169' />\n\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.32 0' />\n\t\t\t\t<feBlend mode='overlay' in2='effect1_dropShadow_1032_7458' result='effect2_dropShadow_1032_7458' />\n\t\t\t\t<feBlend mode='normal' in='SourceGraphic' in2='effect2_dropShadow_1032_7458' result='shape' />\n\t\t\t\t<feColorMatrix\n\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\ttype='matrix'\n\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t/>\n\t\t\t\t<feOffset dx='0.36169' dy='0.36169' />\n\t\t\t\t<feGaussianBlur stdDeviation='0.36169' />\n\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.29 0' />\n\t\t\t\t<feBlend mode='overlay' in2='shape' result='effect3_innerShadow_1032_7458' />\n\t\t\t</filter>\n\t\t</defs>\n\t</svg>\n)\n\nexport const PhotosIcon = (props: SVGProps<SVGSVGElement>) => (\n\t<svg width='30' height='30' viewBox='0 0 30 30' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>\n\t\t<g filter='url(#filter0_ddi_1032_7476)'>\n\t\t\t<path\n\t\t\t\td='M10.8757 14.3941C11.485 13.8875 12.0997 13.8875 12.7237 14.4088L12.8677 14.5395L19.5157 21.1875L19.641 21.2981C19.8975 21.497 20.2178 21.5954 20.5418 21.575C20.8657 21.5545 21.1711 21.4166 21.4006 21.1871C21.6302 20.9576 21.7681 20.6522 21.7885 20.3283C21.809 20.0043 21.7105 19.684 21.5117 19.4275L21.401 19.3021L20.0682 17.9683C19.8527 17.7527 19.8525 17.4033 20.0677 17.1875L20.209 17.0608C20.8183 16.5541 21.433 16.5541 22.057 17.0755L22.201 17.2061L28.1908 23.1973C28.3441 23.3506 28.4234 23.5651 28.3882 23.779C28.2004 24.9189 27.6466 25.9705 26.806 26.772C25.8825 27.6524 24.6739 28.1726 23.3997 28.2381L23.125 28.2448H7.12499C5.8022 28.2447 4.52664 27.7531 3.546 26.8653C2.65026 26.0544 2.05897 24.9659 1.86324 23.7804C1.82792 23.5664 1.90721 23.3519 2.06051 23.1985L10.7343 14.5208L10.8757 14.3941ZM23.125 1.57813C24.4933 1.57812 25.8093 2.10404 26.8007 3.04709C27.7922 3.99014 28.3832 5.27818 28.4517 6.64479L28.4583 6.91146V17.9454C28.4583 18.5898 27.6792 18.9126 27.2235 18.457L24.0677 15.3021L23.8677 15.1195C22.193 13.6595 20.0677 13.6568 18.4063 15.0968L18.201 15.2835C17.9748 15.5089 17.6088 15.5087 17.3829 15.2829L14.7343 12.6355L14.5343 12.4528C12.8597 10.9928 10.7343 10.9901 9.07299 12.4301L8.86766 12.6168L3.02649 18.4569C2.57077 18.9125 1.79166 18.5897 1.79166 17.9453V6.91146C1.79166 5.54313 2.31757 4.22715 3.26062 3.23571C4.20367 2.24426 5.49171 1.65321 6.85832 1.58479L7.12499 1.57813H23.125ZM19.1383 8.24479L18.969 8.25412C18.6449 8.29267 18.3462 8.44873 18.1295 8.69274C17.9128 8.93676 17.7931 9.25177 17.7931 9.57812C17.7931 9.90448 17.9128 10.2195 18.1295 10.4635C18.3462 10.7075 18.6449 10.8636 18.969 10.9021L19.125 10.9115L19.2943 10.9021C19.6184 10.8636 19.9171 10.7075 20.1338 10.4635C20.3505 10.2195 20.4702 9.90448 20.4702 9.57812C20.4702 9.25177 20.3505 8.93676 20.1338 8.69274C19.9171 8.44873 19.6184 8.29267 19.2943 8.25412L19.1383 8.24479Z'\n\t\t\t\tfill='currentColor'\n\t\t\t/>\n\t\t</g>\n\t\t<defs>\n\t\t\t<filter\n\t\t\t\tid='filter0_ddi_1032_7476'\n\t\t\t\tx='0.344897'\n\t\t\t\ty='0.131366'\n\t\t\t\twidth='29.1985'\n\t\t\t\theight='29.1978'\n\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t>\n\t\t\t\t<feFlood floodOpacity='0' result='BackgroundImageFix' />\n\t\t\t\t<feColorMatrix\n\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\ttype='matrix'\n\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t/>\n\t\t\t\t<feOffset dx='-0.72338' dy='-0.72338' />\n\t\t\t\t<feGaussianBlur stdDeviation='0.36169' />\n\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0' />\n\t\t\t\t<feBlend mode='normal' in2='BackgroundImageFix' result='effect1_dropShadow_1032_7476' />\n\t\t\t\t<feColorMatrix\n\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\ttype='matrix'\n\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t/>\n\t\t\t\t<feOffset dx='0.36169' dy='0.36169' />\n\t\t\t\t<feGaussianBlur stdDeviation='0.36169' />\n\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.32 0' />\n\t\t\t\t<feBlend mode='overlay' in2='effect1_dropShadow_1032_7476' result='effect2_dropShadow_1032_7476' />\n\t\t\t\t<feBlend mode='normal' in='SourceGraphic' in2='effect2_dropShadow_1032_7476' result='shape' />\n\t\t\t\t<feColorMatrix\n\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\ttype='matrix'\n\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t/>\n\t\t\t\t<feOffset dx='0.36169' dy='0.36169' />\n\t\t\t\t<feGaussianBlur stdDeviation='0.36169' />\n\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.29 0' />\n\t\t\t\t<feBlend mode='overlay' in2='shape' result='effect3_innerShadow_1032_7476' />\n\t\t\t</filter>\n\t\t</defs>\n\t</svg>\n)\n"
  },
  {
    "path": "packages/ui/src/features/files/components/shared/file-item-icon/folder-icon.tsx",
    "content": "import {useId} from 'react'\n\ninterface FolderIconProps {\n\toverlayIcon?: React.ComponentType<React.SVGProps<SVGSVGElement>>\n\tclassName?: string\n\tskipFilter?: boolean\n}\n\nexport const FolderIcon = ({className, overlayIcon: OverlayIcon, skipFilter, ...svgProps}: FolderIconProps) => {\n\tconst id = useId()\n\n\tconst svg = (\n\t\t<svg\n\t\t\twidth='57'\n\t\t\theight='50'\n\t\t\tviewBox='0 0 57 50'\n\t\t\tfill='none'\n\t\t\txmlns='http://www.w3.org/2000/svg'\n\t\t\tclassName={className}\n\t\t\t{...svgProps}\n\t\t>\n\t\t\t<g clipPath={`url(#folder-clip-${id})`}>\n\t\t\t\t{/* Gray insert */}\n\t\t\t\t<g opacity='0.6'>\n\t\t\t\t\t<path\n\t\t\t\t\t\td='M17.0326 6.81456C16.7616 5.22668 17.8377 3.77724 19.436 3.57715L41.1392 0.86014C42.7376 0.660043 44.253 1.78506 44.5239 3.37294L45.8488 11.1373L18.3575 14.579L17.0326 6.81456Z'\n\t\t\t\t\t\tfill='white'\n\t\t\t\t\t/>\n\t\t\t\t</g>\n\t\t\t\t{/* White instert */}\n\t\t\t\t<g>\n\t\t\t\t\t<path\n\t\t\t\t\t\td='M11.5798 9.90146C11.3176 8.31211 12.4022 6.87309 14.0023 6.68733L50.4821 2.45217C52.0821 2.26641 53.5918 3.40424 53.8539 4.9936L54.7352 10.3361L12.461 15.244L11.5798 9.90146Z'\n\t\t\t\t\t\tfill='white'\n\t\t\t\t\t/>\n\t\t\t\t</g>\n\t\t\t\t{/* Folder body */}\n\t\t\t\t<g filter={skipFilter ? undefined : `url(#folder-filter-${id})`}>\n\t\t\t\t\t<path\n\t\t\t\t\t\td='M0.70639 32V15.261C0.70639 9.40584 0.70639 6.47828 2.01666 4.32368C2.7654 3.09245 3.79884 2.05901 5.03007 1.31027C7.18467 0 10.1122 0 15.9674 0C17.4916 0 18.2537 0 18.9574 0.211449C19.3656 0.334078 19.7564 0.508388 20.1204 0.730141C20.7479 1.11251 21.2571 1.67954 22.2755 2.81358L22.7476 3.33928L22.7476 3.3393C24.2151 4.9735 24.9489 5.7906 25.9211 6.22422C26.8933 6.65785 27.9915 6.65785 30.1879 6.65785H44.7064C50.3632 6.65785 53.1917 6.65785 54.949 8.41521C56.7064 10.1726 56.7064 13.001 56.7064 18.6578V32C56.7064 40.4853 56.7064 44.7279 54.0704 47.364C51.4343 50 47.1917 50 38.7064 50H18.7064C10.2211 50 5.97847 50 3.34243 47.364C0.70639 44.7279 0.70639 40.4853 0.70639 32Z'\n\t\t\t\t\t\tfill='hsl(var(--color-brand))'\n\t\t\t\t\t/>\n\t\t\t\t\t<path\n\t\t\t\t\t\td='M0.70639 32V15.261C0.70639 9.40584 0.70639 6.47828 2.01666 4.32368C2.7654 3.09245 3.79884 2.05901 5.03007 1.31027C7.18467 0 10.1122 0 15.9674 0C17.4916 0 18.2537 0 18.9574 0.211449C19.3656 0.334078 19.7564 0.508388 20.1204 0.730141C20.7479 1.11251 21.2571 1.67954 22.2755 2.81358L22.7476 3.33928L22.7476 3.3393C24.2151 4.9735 24.9489 5.7906 25.9211 6.22422C26.8933 6.65785 27.9915 6.65785 30.1879 6.65785H44.7064C50.3632 6.65785 53.1917 6.65785 54.949 8.41521C56.7064 10.1726 56.7064 13.001 56.7064 18.6578V32C56.7064 40.4853 56.7064 44.7279 54.0704 47.364C51.4343 50 47.1917 50 38.7064 50H18.7064C10.2211 50 5.97847 50 3.34243 47.364C0.70639 44.7279 0.70639 40.4853 0.70639 32Z'\n\t\t\t\t\t\tfill={`url(#folder-paint-${id})`}\n\t\t\t\t\t/>\n\t\t\t\t</g>\n\t\t\t</g>\n\n\t\t\t{/* -- Defs -- */}\n\t\t\t<defs>\n\t\t\t\t{/* Shadow on right side of gray insert. Not shown in non-animating version */}\n\t\t\t\t{/* <filter\n\t\t\t\t\t\tid='folder-filter0'\n\t\t\t\t\t\tx='15.4353'\n\t\t\t\t\t\ty='-0.912903'\n\t\t\t\t\t\twidth='32.1636'\n\t\t\t\t\t\theight='16.2697'\n\t\t\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\t\t>\n\t\t\t\t\t\t<feFlood floodOpacity='0' result='BackgroundImageFix' />\n\t\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<feOffset dx='-0.777778' />\n\t\t\t\t\t\t<feGaussianBlur stdDeviation='0.388889' />\n\t\t\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0.0745098 0 0 0 0 0.0745098 0 0 0 0 0.0823529 0 0 0 0.16 0' />\n\t\t\t\t\t\t<feBlend mode='normal' in2='BackgroundImageFix' result='effect1_dropShadow_1_651' />\n\t\t\t\t\t\t<feBlend mode='normal' in='SourceGraphic' in2='effect1_dropShadow_1_651' result='shape' />\n\t\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<feOffset dx='2.625' dy='-2.625' />\n\t\t\t\t\t\t<feGaussianBlur stdDeviation='0.875' />\n\t\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0.159774 0 0 0 0 0.20031 0 0 0 0 0.413127 0 0 0 0.26 0' />\n\t\t\t\t\t\t<feBlend mode='normal' in2='shape' result='effect2_innerShadow_1_651' />\n\t\t\t\t\t</filter> */}\n\n\t\t\t\t{/* Shadow on left side of white insert. Not shown in non-animating version */}\n\t\t\t\t{/* <filter\n\t\t\t\t\t\tid='folder-filter1'\n\t\t\t\t\t\tx='9.98512'\n\t\t\t\t\t\ty='0.682312'\n\t\t\t\t\t\twidth='46.5001'\n\t\t\t\t\t\theight='15.3394'\n\t\t\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\t\t>\n\t\t\t\t\t\t<feFlood floodOpacity='0' result='BackgroundImageFix' />\n\t\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<feOffset dx='-0.777778' />\n\t\t\t\t\t\t<feGaussianBlur stdDeviation='0.388889' />\n\t\t\t\t\t\t<feComposite in2='hardAlpha' operator='out' />\n\t\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0.0745098 0 0 0 0 0.0745098 0 0 0 0 0.0823529 0 0 0 0.16 0' />\n\t\t\t\t\t\t<feBlend mode='normal' in2='BackgroundImageFix' result='effect1_dropShadow_1_651' />\n\t\t\t\t\t\t<feBlend mode='normal' in='SourceGraphic' in2='effect1_dropShadow_1_651' result='shape' />\n\t\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<feOffset dx='2.625' dy='-2.625' />\n\t\t\t\t\t\t<feGaussianBlur stdDeviation='0.875' />\n\t\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0.159774 0 0 0 0 0.20031 0 0 0 0 0.413127 0 0 0 0.26 0' />\n\t\t\t\t\t\t<feBlend mode='normal' in2='shape' result='effect2_innerShadow_1_651' />\n\t\t\t\t\t</filter> */}\n\n\t\t\t\t{/* Reflective/curved edges of the folder body */}\n\t\t\t\t<filter\n\t\t\t\t\tid={`folder-filter-${id}`}\n\t\t\t\t\tx='-0.29361'\n\t\t\t\t\ty='-1'\n\t\t\t\t\twidth='57.5'\n\t\t\t\t\theight='51.5'\n\t\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\t>\n\t\t\t\t\t<feFlood floodOpacity='0' result='BackgroundImageFix' />\n\t\t\t\t\t<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t/>\n\t\t\t\t\t<feOffset dx='1' dy='1' />\n\t\t\t\t\t<feGaussianBlur stdDeviation='0.25' />\n\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0' />\n\t\t\t\t\t<feBlend mode='normal' in2='shape' result='effect1_innerShadow_1_651' />\n\t\t\t\t\t<feColorMatrix\n\t\t\t\t\t\tin='SourceAlpha'\n\t\t\t\t\t\ttype='matrix'\n\t\t\t\t\t\tvalues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'\n\t\t\t\t\t\tresult='hardAlpha'\n\t\t\t\t\t/>\n\t\t\t\t\t<feOffset dx='-1' dy='-1' />\n\t\t\t\t\t<feGaussianBlur stdDeviation='0.5' />\n\t\t\t\t\t<feComposite in2='hardAlpha' operator='arithmetic' k2='-1' k3='1' />\n\t\t\t\t\t<feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0' />\n\t\t\t\t\t<feBlend mode='normal' in2='effect1_innerShadow_1_651' result='effect2_innerShadow_1_651' />\n\t\t\t\t</filter>\n\n\t\t\t\t{/* Shaded dark gradient for the folder body */}\n\t\t\t\t<linearGradient\n\t\t\t\t\tid={`folder-paint-${id}`}\n\t\t\t\t\tx1='28.7064'\n\t\t\t\t\ty1='0'\n\t\t\t\t\tx2='28.7064'\n\t\t\t\t\ty2='50'\n\t\t\t\t\tgradientUnits='userSpaceOnUse'\n\t\t\t\t>\n\t\t\t\t\t<stop offset='0.315' stopOpacity='0' />\n\t\t\t\t\t<stop offset='0.965' stopOpacity='0.48' />\n\t\t\t\t</linearGradient>\n\n\t\t\t\t{/* Clip path for the folder body */}\n\t\t\t\t<clipPath id={`folder-clip-${id}`}>\n\t\t\t\t\t<rect x='0.833344' width='56' height='50' rx='10' fill='white' />\n\t\t\t\t</clipPath>\n\t\t\t</defs>\n\t\t</svg>\n\t)\n\n\tif (!OverlayIcon) return svg\n\n\treturn (\n\t\t<div className='relative'>\n\t\t\t{svg}\n\t\t\t<div className='pointer-events-none absolute top-[28%] left-[23%] h-[56%] w-[56%]'>\n\t\t\t\t{/* text-black/30 makes the embedded overlay icon slightly darker than surrounding icon*/}\n\t\t\t\t<OverlayIcon className='h-full w-full text-black/30' />\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/shared/file-item-icon/index.tsx",
    "content": "import React, {useEffect, useState} from 'react'\nimport {BsTrash2} from 'react-icons/bs'\nimport {IoPlay} from 'react-icons/io5'\n\nimport backupsIcon from '@/features/backups/assets/backups-icon.png'\nimport {AppsIcon} from '@/features/files/assets/apps-icon'\nimport externalStorageIcon from '@/features/files/assets/external-storage-icon.png'\nimport {HomeIcon} from '@/features/files/assets/home-icon'\nimport activeNasIcon from '@/features/files/assets/nas-icon-active.png'\nimport nasIconInactive from '@/features/files/assets/nas-icon-inactive.png'\nimport networkIcon from '@/features/files/assets/network-icon.png'\nimport {RecentsIcon} from '@/features/files/assets/recents-icon'\nimport {SharedFolderBadge} from '@/features/files/assets/shared-folder-badge'\nimport umbrelDeviceActive from '@/features/files/assets/umbrel-device-icon-active.png'\nimport umbrelDeviceInactive from '@/features/files/assets/umbrel-device-icon-inactive.png'\nimport {AnimatedFolderIcon} from '@/features/files/components/shared/file-item-icon/animated-folder-icon'\nimport {\n\tDocumentsIcon,\n\tDownloadsIcon,\n\tPhotosIcon,\n\tVideosIcon,\n} from '@/features/files/components/shared/file-item-icon/embedded-overlay-icons'\nimport {FolderIcon as SimpleFolderIcon} from '@/features/files/components/shared/file-item-icon/folder-icon'\nimport {UnknownFileThumbnail} from '@/features/files/components/shared/file-item-icon/unknown-file-thumbnail'\nimport {\n\tAPPS_PATH,\n\tBACKUPS_PATH,\n\tFILE_TYPE_MAP,\n\tHOME_PATH,\n\tIMAGE_EXTENSIONS_WITH_IMAGE_THUMBNAILS,\n\tRECENTS_PATH,\n\tTRASH_PATH,\n\tVIDEO_EXTENSIONS_WITH_IMAGE_THUMBNAILS,\n} from '@/features/files/constants'\nimport {useNetworkDeviceType} from '@/features/files/hooks/use-network-device-type'\nimport {useNetworkStorage} from '@/features/files/hooks/use-network-storage'\nimport {useShares} from '@/features/files/hooks/use-shares'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {splitFileName} from '@/features/files/utils/format-filesystem-name'\nimport {isDirectoryANetworkDevice} from '@/features/files/utils/is-directory-a-network-device-or-share'\nimport {isDirectoryAnExternalDrivePartition} from '@/features/files/utils/is-directory-an-external-drive-partition'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\ninterface FileItemIcon {\n\titem: FileSystemItem\n\tonlySVG?: boolean\n\tclassName?: string\n\tuseAnimatedIcon?: boolean\n\tisHovered?: boolean\n}\n\nexport const FileItemIcon = ({item, onlySVG, className, useAnimatedIcon = false, isHovered = false}: FileItemIcon) => {\n\tconst {isPathShared} = useShares()\n\tconst isShared = isPathShared(item.path)\n\n\t// Check if this is an app folder in either normal mode or rewind mode\n\t// Normal: /Apps/bitcoin\n\t// Rewind: /Backups/some-mount-dir/Apps/bitcoin\n\tconst isAppFolder = (() => {\n\t\t// Match normal app path: /Apps/appId (but not /Apps/appId/data)\n\t\tif (item.path.startsWith(APPS_PATH)) {\n\t\t\treturn item.path.slice(APPS_PATH.length).split('/').length === 2\n\t\t}\n\n\t\t// Match rewind app path: /Backups/xxx/Apps/appId (but not /Backups/xxx/Apps/appId/data)\n\t\tif (item.path.startsWith(BACKUPS_PATH)) {\n\t\t\t// Example: /Backups/2025-10-29T20:32:32.710Z/Apps/transmission\n\t\t\t// Split: ['', 'Backups', '2025-10-29T20:32:32.710Z', 'Apps', 'transmission']\n\t\t\tconst parts = item.path.split('/')\n\t\t\t// Check: parts[0] === '', parts[1] === 'Backups', parts[3] === 'Apps', parts[4] === appId, parts[5] === undefined\n\t\t\treturn parts.length === 5 && parts[1] === 'Backups' && parts[3] === 'Apps'\n\t\t}\n\n\t\treturn false\n\t})()\n\n\t// External storage icon if the user directly navigates to umbrel.local/files/External\n\tif (item.type === 'directory' && isDirectoryAnExternalDrivePartition(item.path)) {\n\t\treturn <img src={externalStorageIcon} alt={t('external-drive')} className={className} draggable={false} />\n\t}\n\n\t// Network share icon when browsing /Network\n\tif (item.type === 'directory' && isDirectoryANetworkDevice(item.path)) {\n\t\treturn <NetworkDeviceIcon path={item.path} className={className} />\n\t}\n\n\tif (item.type === 'directory' && item.name === 'Umbrel Backup.backup') {\n\t\treturn <img src={backupsIcon} alt='Umbrel Backup' className={className} draggable={false} />\n\t}\n\n\t// External storage for sidebar and pathbar\n\tif (item.type === 'external-storage') {\n\t\treturn <img src={externalStorageIcon} alt={t('external-drive')} className={className} draggable={false} />\n\t}\n\n\t// Network root for sidebar and pathbar\n\tif (item.type === 'network-root') {\n\t\treturn <img src={networkIcon} alt='Network' className={className + 'w-auto'} draggable={false} />\n\t}\n\n\t// Network share for sidebar and pathbar\n\tif (item.type === 'network-share') {\n\t\treturn <NetworkDeviceIcon path={item.path} className={className} />\n\t}\n\n\t// Folder\n\tif (item.type === 'directory') {\n\t\tif (onlySVG) {\n\t\t\treturn <SimpleFolderIcon className={className} skipFilter />\n\t\t}\n\n\t\treturn (\n\t\t\t<div className='relative'>\n\t\t\t\t<FolderIcon className={className} path={item.path} useAnimatedIcon={useAnimatedIcon} isHovered={isHovered} />\n\t\t\t\t{isAppFolder ? <AppFolderBottomIcon appId={extractAppIdFromPath(item.path)} /> : null}\n\n\t\t\t\t{/* we add it here because only folders can be shared */}\n\t\t\t\t{isShared ? (\n\t\t\t\t\t<div className='absolute top-0 left-0 flex size-1/2 max-h-8 min-h-[0.9rem] max-w-8 min-w-[0.9rem] translate-x-[-30%] translate-y-[-20%] items-center justify-center rounded-full border border-white/15 bg-linear-to-b from-brand to-[color-mix(in_srgb,hsl(var(--color-brand))_80%,black_20%)] shadow-md'>\n\t\t\t\t\t\t<SharedFolderBadge className='size-4/5' />\n\t\t\t\t\t</div>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t)\n\t}\n\n\t// Unknown file\n\tif (\n\t\t!item.type ||\n\t\t!FILE_TYPE_MAP[item.type as keyof typeof FILE_TYPE_MAP] ||\n\t\t!FILE_TYPE_MAP[item.type as keyof typeof FILE_TYPE_MAP].thumbnail\n\t) {\n\t\treturn <UnknownFileThumbnail type={item.type || ''} className={className} />\n\t}\n\n\t// Get the thumbnail component\n\tconst Thumbnail = FILE_TYPE_MAP[item.type as keyof typeof FILE_TYPE_MAP].thumbnail as unknown as React.ComponentType<{\n\t\tclassName?: string\n\t}>\n\n\t// When rendering inside an SVG context, only return SVG-safe elements\n\tif (onlySVG) {\n\t\treturn <Thumbnail className={className} />\n\t}\n\n\tconst {extension} = splitFileName(item.name)\n\t// Image file\n\tif (extension && IMAGE_EXTENSIONS_WITH_IMAGE_THUMBNAILS.has(extension.toLowerCase())) {\n\t\treturn <ImageThumbnail item={item} fallback={Thumbnail} className={className} />\n\t}\n\n\t// Video file\n\tif (extension && VIDEO_EXTENSIONS_WITH_IMAGE_THUMBNAILS.has(extension.toLowerCase())) {\n\t\treturn <VideoThumbnail item={item} fallback={Thumbnail} className={className} />\n\t}\n\n\t// All other supported file types\n\treturn <Thumbnail className={className} />\n}\n\nconst FolderIcon = ({\n\tclassName = '',\n\tpath,\n\tuseAnimatedIcon,\n\tisHovered = false,\n}: {\n\tclassName?: string\n\tpath: string\n\tuseAnimatedIcon: boolean\n\tisHovered?: boolean\n}) => {\n\tif (path === HOME_PATH) {\n\t\treturn <HomeIcon className={className} />\n\t}\n\tif (path === TRASH_PATH) {\n\t\treturn <BsTrash2 className={className} />\n\t}\n\tif (path === RECENTS_PATH) {\n\t\treturn <RecentsIcon className={className} />\n\t}\n\tif (path === APPS_PATH) {\n\t\treturn <AppsIcon className={className} />\n\t}\n\n\tconst FolderComponent = useAnimatedIcon ? AnimatedFolderIcon : SimpleFolderIcon\n\n\tif (path === `${HOME_PATH}/Videos`) {\n\t\treturn useAnimatedIcon ? (\n\t\t\t<FolderComponent className={className} overlayIcon={VideosIcon} isHovered={isHovered} />\n\t\t) : (\n\t\t\t<FolderComponent className={className} overlayIcon={VideosIcon} />\n\t\t)\n\t}\n\tif (path === `${HOME_PATH}/Downloads`) {\n\t\treturn useAnimatedIcon ? (\n\t\t\t<FolderComponent className={className} overlayIcon={DownloadsIcon} isHovered={isHovered} />\n\t\t) : (\n\t\t\t<FolderComponent className={className} overlayIcon={DownloadsIcon} />\n\t\t)\n\t}\n\tif (path === `${HOME_PATH}/Documents`) {\n\t\treturn useAnimatedIcon ? (\n\t\t\t<FolderComponent className={className} overlayIcon={DocumentsIcon} isHovered={isHovered} />\n\t\t) : (\n\t\t\t<FolderComponent className={className} overlayIcon={DocumentsIcon} />\n\t\t)\n\t}\n\tif (path === `${HOME_PATH}/Photos`) {\n\t\treturn useAnimatedIcon ? (\n\t\t\t<FolderComponent className={className} overlayIcon={PhotosIcon} isHovered={isHovered} />\n\t\t) : (\n\t\t\t<FolderComponent className={className} overlayIcon={PhotosIcon} />\n\t\t)\n\t}\n\treturn useAnimatedIcon ? (\n\t\t<FolderComponent className={className} isHovered={isHovered} />\n\t) : (\n\t\t<FolderComponent className={className} />\n\t)\n}\n\nconst AppFolderBottomIcon = ({appId}: {appId: string}) => {\n\tconst [error, setError] = useState(false)\n\tconst [loaded, setLoaded] = useState(false)\n\n\treturn (\n\t\t<img\n\t\t\tonError={() => setError(true)}\n\t\t\tonLoad={() => setLoaded(true)}\n\t\t\tsrc={`https://getumbrel.github.io/umbrel-apps-gallery/${appId}/icon.svg`}\n\t\t\talt={appId}\n\t\t\tclassName={`absolute right-0 bottom-0 flex h-1/2 max-h-8 min-h-5 w-1/2 max-w-8 min-w-5 translate-x-[16%] translate-y-[10%] items-center justify-center overflow-hidden rounded-[25%] border border-white/15 object-contain shadow-md md:min-h-[0.9rem] md:min-w-[0.9rem] ${\n\t\t\t\t!loaded || error ? 'opacity-0' : 'opacity-100'\n\t\t\t}`}\n\t\t/>\n\t)\n}\n\n// Thumbnail component with on‑demand fetch\nfunction useOnDemandThumbnail(item: FileSystemItem) {\n\tconst [url, setUrl] = useState<string | undefined>(item.thumbnail)\n\n\tconst getThumbnailMutation = trpcReact.files.getThumbnail.useMutation()\n\n\t// Reset state when the file item changes\n\tuseEffect(() => {\n\t\tsetUrl(item.thumbnail)\n\t}, [item.path, item.thumbnail])\n\n\tuseEffect(() => {\n\t\tif (url !== undefined) return\n\n\t\tgetThumbnailMutation.mutateAsync({path: item.path}).then((res) => {\n\t\t\tif (res) {\n\t\t\t\tsetUrl(res)\n\t\t\t}\n\t\t})\n\t}, [url, item.path])\n\n\treturn {thumbnailUrl: url}\n}\n\nconst Thumbnail = ({\n\titem,\n\tfallback: Fallback,\n\tclassName,\n\toverlay,\n}: {\n\titem: FileSystemItem\n\tfallback: React.ComponentType<{className?: string}>\n\tclassName?: string\n\toverlay?: React.ReactNode\n}) => {\n\tconst {thumbnailUrl} = useOnDemandThumbnail(item)\n\n\t// Track if the image failed to load so we can gracefully fall back to the\n\t// default thumbnail component\n\tconst [hadError, setHadError] = useState(false)\n\n\t// Reset the error flag whenever the thumbnail url or file changes\n\tuseEffect(() => {\n\t\tsetHadError(false)\n\t}, [thumbnailUrl, item.path])\n\n\tconst imageNode =\n\t\tthumbnailUrl && !hadError ? (\n\t\t\t<img\n\t\t\t\tsrc={thumbnailUrl}\n\t\t\t\talt={item.name}\n\t\t\t\tonError={() => setHadError(true)}\n\t\t\t\tclassName={`rounded-xs object-contain ${className || ''}`}\n\t\t\t/>\n\t\t) : null\n\n\tconst content = imageNode ?? <Fallback className={className} />\n\n\t// Only display overlay when we have a real thumbnail to show\n\tif (overlay && imageNode) {\n\t\treturn (\n\t\t\t<div className='relative'>\n\t\t\t\t{imageNode}\n\t\t\t\t{overlay}\n\t\t\t</div>\n\t\t)\n\t}\n\n\treturn content\n}\n\n// Image thumbnail\nconst ImageThumbnail = (props: {\n\titem: FileSystemItem\n\tfallback: React.ComponentType<{className?: string}>\n\tclassName?: string\n}) => <Thumbnail {...props} />\n\n// Video thumbnail\nconst VideoThumbnail = ({\n\titem,\n\tfallback,\n\tclassName,\n}: {\n\titem: FileSystemItem\n\tfallback: React.ComponentType<{className?: string}>\n\tclassName?: string\n}) => (\n\t<Thumbnail\n\t\titem={item}\n\t\tfallback={fallback}\n\t\tclassName={className}\n\t\toverlay={\n\t\t\t<div className='absolute top-1/2 left-1/2 flex h-full w-full -translate-x-1/2 -translate-y-1/2 items-center justify-center'>\n\t\t\t\t<IoPlay className='h-1/3 w-1/3 text-white shadow-md' />\n\t\t\t</div>\n\t\t}\n\t/>\n)\n\n// Component to render network device icon with Umbrel detection\nconst NetworkDeviceIcon = ({path, className}: {path: string; className?: string}) => {\n\tconst {doesHostHaveMountedShares} = useNetworkStorage()\n\tconst {deviceType, isLoading} = useNetworkDeviceType(path)\n\n\tconst isMounted = doesHostHaveMountedShares(path)\n\n\t// While detecting, show generic NAS icon\n\tif (isLoading) {\n\t\treturn (\n\t\t\t<img src={isMounted ? activeNasIcon : nasIconInactive} alt='Network' className={className} draggable={false} />\n\t\t)\n\t}\n\n\t// Show appropriate icon based on device type and mount status\n\tif (deviceType === 'umbrel') {\n\t\treturn (\n\t\t\t<img\n\t\t\t\tsrc={isMounted ? umbrelDeviceActive : umbrelDeviceInactive}\n\t\t\t\talt='Umbrel'\n\t\t\t\tclassName={className}\n\t\t\t\tdraggable={false}\n\t\t\t/>\n\t\t)\n\t}\n\n\t// Default to generic NAS icon\n\treturn <img src={isMounted ? activeNasIcon : nasIconInactive} alt='NAS' className={className} draggable={false} />\n}\n\n// Helper function to extract app ID from both normal and rewind paths\nfunction extractAppIdFromPath(path: string): string {\n\t// For /Apps/bitcoin or /Backups/xxx/Apps/bitcoin, extract \"bitcoin\"\n\tconst pattern = new RegExp(`${APPS_PATH}/([^/]+)`)\n\tconst match = path.match(pattern)\n\treturn match?.[1] || ''\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/shared/file-item-icon/unknown-file-thumbnail.tsx",
    "content": "import {SVGProps, useId} from 'react'\n\n// Helper function to convert a string to a hue value\nconst stringToHue = (str: string): number => {\n\tlet hash = 0x811c9dc5 // FNV-1a 32-bit offset basis\n\tfor (let i = 0; i < str.length; i++) {\n\t\thash ^= str.charCodeAt(i)\n\t\t// FNV magic prime multiplication (shift + addition trick)\n\t\thash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)\n\t}\n\n\t// >>> 0 forces unsigned 32-bit\n\treturn (hash >>> 0) % 360\n}\n\n// Helper function to convert HSL to RGB\nconst hslToRgb = (h: number, s: number, l: number): [number, number, number] => {\n\ts /= 100\n\tl /= 100\n\n\tconst c = (1 - Math.abs(2 * l - 1)) * s\n\tconst x = c * (1 - Math.abs(((h / 60) % 2) - 1))\n\tconst m = l - c / 2\n\n\tlet r = 0\n\tlet g = 0\n\tlet b = 0\n\n\tif (h >= 0 && h < 60) {\n\t\tr = c\n\t\tg = x\n\t\tb = 0\n\t} else if (h >= 60 && h < 120) {\n\t\tr = x\n\t\tg = c\n\t\tb = 0\n\t} else if (h >= 120 && h < 180) {\n\t\tr = 0\n\t\tg = c\n\t\tb = x\n\t} else if (h >= 180 && h < 240) {\n\t\tr = 0\n\t\tg = x\n\t\tb = c\n\t} else if (h >= 240 && h < 300) {\n\t\tr = x\n\t\tg = 0\n\t\tb = c\n\t} else if (h >= 300 && h < 360) {\n\t\tr = c\n\t\tg = 0\n\t\tb = x\n\t}\n\n\treturn [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]\n}\n\nexport const UnknownFileThumbnail = ({type = '', ...props}: {type: string} & SVGProps<SVGSVGElement>) => {\n\tconst id = useId()\n\tconst hue = stringToHue(type.toLowerCase())\n\n\t// Get the colors for the fold and paper\n\tconst foldGradientDark = `hsl(${hue}, 50%, 69%)`\n\tconst foldGradientLight = `hsl(${hue}, 61%, 90%)`\n\tconst paperGradientDark = `hsl(${hue}, 50%, 70%)`\n\tconst paperGradientLight = `hsl(${hue}, 53%, 90%)`\n\n\t// Get the color of the fold shadow in RGB\n\tconst [r, g, b] = hslToRgb(hue, 50, 30)\n\n\t// Divide by 255 to get a value between 0 and 1\n\tconst foldShadowColorRed = r / 255\n\tconst foldShadowColorGreen = g / 255\n\tconst foldShadowColorBlue = b / 255\n\n\t// Create a color matrix for the fold shadow\n\tconst foldShadowColorMatrix = `0 0 0 0 ${foldShadowColorRed} 0 0 0 0 ${foldShadowColorGreen} 0 0 0 0 ${foldShadowColorBlue} 0 0 0 1 0`\n\n\treturn (\n\t\t<svg xmlns='http://www.w3.org/2000/svg' width={61} height={60} viewBox='0 0 61 60' fill='none' {...props}>\n\t\t\t<g clipPath={`url(#${id}_svg__a)`}>\n\t\t\t\t<g filter={`url(#${id}_svg__b)`}>\n\t\t\t\t\t<path\n\t\t\t\t\t\tfill={`url(#${id}_svg__c)`}\n\t\t\t\t\t\td='m30.624 5 .293.018a2.5 2.5 0 0 1 2.19 2.19l.017.292v10l.013.375a5 5 0 0 0 4.595 4.61l.392.015h10l.293.017a2.5 2.5 0 0 1 2.19 2.19l.017.293v22.5a7.5 7.5 0 0 1-7.06 7.487l-.44.013h-25a7.5 7.5 0 0 1-7.487-7.06l-.013-.44v-35a7.5 7.5 0 0 1 7.06-7.487l.44-.013z'\n\t\t\t\t\t/>\n\t\t\t\t</g>\n\t\t\t\t<path\n\t\t\t\t\tstroke={`url(#${id}_svg__d)`}\n\t\t\t\t\tstrokeOpacity={0.2}\n\t\t\t\t\tstrokeWidth={0.75}\n\t\t\t\t\td='M32.75 17.5v.012l.012.375v.016a5.374 5.374 0 0 0 4.94 4.956h.016l.392.016h10.004l.27.016a2.125 2.125 0 0 1 1.85 1.85l.015.27V47.5a7.125 7.125 0 0 1-6.701 7.113l-.429.012H18.124a7.125 7.125 0 0 1-7.112-6.701l-.013-.43V12.5a7.125 7.125 0 0 1 6.702-7.113l.429-.012h12.483l.27.016a2.125 2.125 0 0 1 1.85 1.85l.016.27z'\n\t\t\t\t/>\n\t\t\t\t<path\n\t\t\t\t\tfill={`url(#${id}_svg__e)`}\n\t\t\t\t\tstroke={`url(#${id}_svg__f)`}\n\t\t\t\t\tstrokeWidth={0.75}\n\t\t\t\t\td='M32.982 6.78 49.72 23.518h-11.11a5.625 5.625 0 0 1-5.624-5.623z'\n\t\t\t\t/>\n\t\t\t</g>\n\t\t\t<defs>\n\t\t\t\t{/* Paper */}\n\t\t\t\t<linearGradient id={`${id}_svg__c`} x1={30.624} x2={30.624} y1={5} y2={55} gradientUnits='userSpaceOnUse'>\n\t\t\t\t\t<stop stopColor={paperGradientDark} />\n\t\t\t\t\t<stop offset={1} stopColor={paperGradientLight} />\n\t\t\t\t</linearGradient>\n\n\t\t\t\t{/* Paper border */}\n\t\t\t\t<linearGradient id={`${id}_svg__d`} x1={30.624} x2={30.624} y1={5} y2={55} gradientUnits='userSpaceOnUse'>\n\t\t\t\t\t<stop stopColor='#fff' />\n\t\t\t\t\t<stop offset={1} stopColor='#fff' />\n\t\t\t\t</linearGradient>\n\n\t\t\t\t{/* Fold */}\n\t\t\t\t<linearGradient\n\t\t\t\t\tid={`${id}_svg__e`}\n\t\t\t\t\tx1={32.607}\n\t\t\t\t\tx2={45.039}\n\t\t\t\t\ty1={23.892}\n\t\t\t\t\ty2={11.811}\n\t\t\t\t\tgradientUnits='userSpaceOnUse'\n\t\t\t\t>\n\t\t\t\t\t<stop stopColor={foldGradientDark} />\n\t\t\t\t\t<stop offset={1} stopColor={foldGradientLight} />\n\t\t\t\t</linearGradient>\n\n\t\t\t\t{/* Fold border */}\n\t\t\t\t<linearGradient\n\t\t\t\t\tid={`${id}_svg__f`}\n\t\t\t\t\tx1={41.616}\n\t\t\t\t\tx2={32.607}\n\t\t\t\t\ty1={14.252}\n\t\t\t\t\ty2={23.892}\n\t\t\t\t\tgradientUnits='userSpaceOnUse'\n\t\t\t\t>\n\t\t\t\t\t<stop offset={0.146} stopColor='#fff' stopOpacity={0} />\n\t\t\t\t\t<stop offset={1} stopColor='#fff' />\n\t\t\t\t</linearGradient>\n\t\t\t\t<clipPath id={`${id}_svg__a`}>\n\t\t\t\t\t<path fill='#fff' d='M.625 0h60v60h-60z' />\n\t\t\t\t</clipPath>\n\t\t\t\t<filter\n\t\t\t\t\tid={`${id}_svg__b`}\n\t\t\t\t\twidth={41.5}\n\t\t\t\t\theight={51.5}\n\t\t\t\t\tx={9.874}\n\t\t\t\t\ty={4.25}\n\t\t\t\t\tcolorInterpolationFilters='sRGB'\n\t\t\t\t\tfilterUnits='userSpaceOnUse'\n\t\t\t\t>\n\t\t\t\t\t<feFlood floodOpacity={0} result='BackgroundImageFix' />\n\t\t\t\t\t<feBlend in='SourceGraphic' in2='BackgroundImageFix' result='shape' />\n\t\t\t\t\t<feColorMatrix in='SourceAlpha' result='hardAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' />\n\t\t\t\t\t<feOffset dx={1.5} dy={1.5} />\n\t\t\t\t\t<feGaussianBlur stdDeviation={0.375} />\n\t\t\t\t\t<feComposite in2='hardAlpha' k2={-1} k3={1} operator='arithmetic' />\n\t\t\t\t\t<feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.44 0' />\n\t\t\t\t\t<feBlend in2='shape' result='effect1_innerShadow_1032_7543' />\n\t\t\t\t\t<feColorMatrix in='SourceAlpha' result='hardAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0' />\n\t\t\t\t\t<feOffset dx={-0.75} dy={-0.75} />\n\t\t\t\t\t<feGaussianBlur stdDeviation={1.5} />\n\t\t\t\t\t<feComposite in2='hardAlpha' k2={-1} k3={1} operator='arithmetic' />\n\t\t\t\t\t{/* Inner shadow */}\n\t\t\t\t\t<feColorMatrix values={foldShadowColorMatrix} />\n\t\t\t\t\t<feBlend in2='effect1_innerShadow_1032_7543' result='effect2_innerShadow_1032_7543' />\n\t\t\t\t</filter>\n\t\t\t</defs>\n\t\t</svg>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/shared/file-upload-drop-zone.tsx",
    "content": "import React, {CSSProperties} from 'react'\nimport {useDropzone} from 'react-dropzone'\n\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {cn} from '@/lib/utils'\nimport {useGlobalFiles} from '@/providers/global-files'\nimport {t} from '@/utils/i18n'\n\ninterface FileUploadDropZoneProps {\n\tchildren: React.ReactNode\n}\n\nexport function FileUploadDropZone({children}: FileUploadDropZoneProps) {\n\tconst {startUpload} = useGlobalFiles()\n\tconst {currentPath} = useNavigate()\n\n\tconst onDrop = (acceptedFiles: File[]) => {\n\t\tstartUpload(acceptedFiles, currentPath)\n\t}\n\n\tconst {getRootProps, getInputProps, isDragActive} = useDropzone({\n\t\tonDrop,\n\t\tnoClick: true,\n\t\tnoKeyboard: true,\n\t})\n\n\treturn (\n\t\t<div {...getRootProps()} className='relative h-full'>\n\t\t\t<input {...getInputProps()} />\n\t\t\t{children}\n\t\t\t{isDragActive && <DropOverlay />}\n\t\t</div>\n\t)\n}\n\nconst DropOverlay = () => {\n\treturn (\n\t\t<div className='absolute inset-0 flex h-full w-full flex-col items-center justify-center overflow-hidden rounded-12 border-2 border-[hsl(var(--color-brand))]/30 bg-black/50'>\n\t\t\t<span className='z-10 text-center text-5xl font-medium tracking-tighter whitespace-pre-wrap text-white'>\n\t\t\t\t{t('files-action.drop-to-upload')}\n\t\t\t</span>\n\t\t\t<Ripple />\n\t\t</div>\n\t)\n}\n\ninterface RippleProps {\n\tmainCircleSize?: number\n\tmainCircleOpacity?: number\n\tnumCircles?: number\n\tclassName?: string\n}\n\nconst Ripple = React.memo(function Ripple({\n\tmainCircleSize = 210,\n\tmainCircleOpacity = 0.24,\n\tnumCircles = 8,\n\tclassName,\n}: RippleProps) {\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t'pointer-events-none absolute inset-0 [mask-image:linear-gradient(to_bottom,white,transparent)]',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t>\n\t\t\t{Array.from({length: numCircles}, (_, i) => {\n\t\t\t\tconst size = mainCircleSize + i * 70\n\t\t\t\tconst opacity = mainCircleOpacity - i * 0.03\n\t\t\t\tconst animationDelay = `${i * 0.06}s`\n\t\t\t\tconst borderStyle = i === numCircles - 1 ? 'dashed' : 'solid'\n\t\t\t\tconst borderOpacity = 5 + i * 5\n\n\t\t\t\treturn (\n\t\t\t\t\t<div\n\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\tclassName={`absolute animate-files-drop-zone-ripple rounded-full border bg-brand/25 shadow-xl [--i:${i}]`}\n\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\twidth: `${size}px`,\n\t\t\t\t\t\t\t\theight: `${size}px`,\n\t\t\t\t\t\t\t\topacity,\n\t\t\t\t\t\t\t\tanimationDelay,\n\t\t\t\t\t\t\t\tborderStyle,\n\t\t\t\t\t\t\t\tborderWidth: '1px',\n\t\t\t\t\t\t\t\tborderColor: `hsl(var(--brand), ${borderOpacity / 100})`,\n\t\t\t\t\t\t\t\ttop: '50%',\n\t\t\t\t\t\t\t\tleft: '50%',\n\t\t\t\t\t\t\t\ttransform: 'translate(-50%, -50%) scale(1)',\n\t\t\t\t\t\t\t} as CSSProperties\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t)\n\t\t\t})}\n\t\t</div>\n\t)\n})\n"
  },
  {
    "path": "packages/ui/src/features/files/components/shared/upload-input.tsx",
    "content": "import {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useGlobalFiles} from '@/providers/global-files'\n\nexport function UploadInput({ref}: {ref?: React.Ref<HTMLInputElement>}) {\n\tconst {startUpload} = useGlobalFiles()\n\tconst {currentPath} = useNavigate()\n\n\tconst handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n\t\tif (e.target.files && e.target.files.length > 0) {\n\t\t\tstartUpload(e.target.files, currentPath)\n\t\t\te.target.value = ''\n\t\t}\n\t}\n\treturn <input type='file' ref={ref} style={{display: 'none'}} multiple accept='*' onChange={handleFileChange} />\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/sidebar/index.tsx",
    "content": "// Note: the sidebar and sidebar-link components re-render on every navigation click.\n// While we could memoize these components to prevent re-renders,\n// the performance impact is negligible with so few items and simple DOM updates.\n// So we've opted for simpler code over premature optimization.\nimport {AnimatePresence, motion} from 'motion/react'\n\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {SidebarRewind} from '@/features/files/components/rewind'\nimport {SidebarApps} from '@/features/files/components/sidebar/sidebar-apps'\nimport {SidebarExternalStorage} from '@/features/files/components/sidebar/sidebar-external-storage'\nimport {SidebarFavorites} from '@/features/files/components/sidebar/sidebar-favorites'\nimport {SidebarHome} from '@/features/files/components/sidebar/sidebar-home'\nimport {SidebarNetworkStorage} from '@/features/files/components/sidebar/sidebar-network-storage'\nimport {SidebarRecents} from '@/features/files/components/sidebar/sidebar-recents'\nimport {SidebarShares} from '@/features/files/components/sidebar/sidebar-shares'\nimport {SidebarTrash} from '@/features/files/components/sidebar/sidebar-trash'\nimport {HOME_PATH} from '@/features/files/constants'\nimport {useExternalStorage} from '@/features/files/hooks/use-external-storage'\nimport {useFavorites} from '@/features/files/hooks/use-favorites'\nimport {useShares} from '@/features/files/hooks/use-shares'\nimport {useFilesCapabilities} from '@/features/files/providers/files-capabilities-context'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\nexport function Sidebar({className}: {className?: string}) {\n\tconst capabilities = useFilesCapabilities()\n\tconst {shares, isLoadingShares} = useShares()\n\tconst {favorites, isLoadingFavorites} = useFavorites()\n\tconst {disks, isLoadingExternalStorage, isExternalStorageSupported} = useExternalStorage()\n\n\tconst displayShares = shares?.filter((share) => share && share.path !== HOME_PATH)\n\n\t// Visibility flags\n\tconst hidden = capabilities.hiddenSidebarItems || {}\n\tconst showFavorites = !isLoadingFavorites && !!favorites && favorites.length > 0\n\tconst showShares = !isLoadingShares && !!displayShares && displayShares.length > 0\n\tconst showNetwork = !hidden.network\n\tconst showExternal =\n\t\tisExternalStorageSupported && !hidden.external && !isLoadingExternalStorage && !!disks && disks.length > 0\n\tconst showTrash = !hidden.trash\n\tconst showRewind = !hidden.rewind\n\n\treturn (\n\t\t<nav className={cn('flex flex-col', className)} aria-label={t('files-sidebar.navigation')}>\n\t\t\t<ScrollArea className='h-full'>\n\t\t\t\t{/* Hardcoded home link */}\n\t\t\t\t<SidebarSection>\n\t\t\t\t\t<SidebarHome />\n\t\t\t\t\t<SidebarRecents />\n\t\t\t\t\t<SidebarApps />\n\t\t\t\t</SidebarSection>\n\t\t\t\t{/* Favorites */}\n\t\t\t\t<AnimatePresence initial={!isLoadingFavorites}>\n\t\t\t\t\t{showFavorites && (\n\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\tinitial={isLoadingFavorites ? {opacity: 0, height: 0} : false}\n\t\t\t\t\t\t\tanimate={{opacity: 1, height: 'auto'}}\n\t\t\t\t\t\t\texit={{opacity: 0, height: 0}}\n\t\t\t\t\t\t\ttransition={{duration: 0.2}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<SidebarDivider />\n\t\t\t\t\t\t\t<SidebarSection label={t('files-sidebar.favorites')}>\n\t\t\t\t\t\t\t\t<SidebarFavorites favorites={favorites} />\n\t\t\t\t\t\t\t</SidebarSection>\n\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t)}\n\t\t\t\t</AnimatePresence>\n\n\t\t\t\t{/* Shared folders */}\n\t\t\t\t<AnimatePresence initial={!isLoadingShares}>\n\t\t\t\t\t{showShares && (\n\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\tinitial={isLoadingShares ? {opacity: 0, height: 0} : false}\n\t\t\t\t\t\t\tanimate={{opacity: 1, height: 'auto'}}\n\t\t\t\t\t\t\texit={{opacity: 0, height: 0}}\n\t\t\t\t\t\t\ttransition={{duration: 0.2}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<SidebarDivider />\n\t\t\t\t\t\t\t<SidebarSection label={t('files-sidebar.shared-folders')}>\n\t\t\t\t\t\t\t\t<SidebarShares shares={displayShares} />\n\t\t\t\t\t\t\t</SidebarSection>\n\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t)}\n\t\t\t\t</AnimatePresence>\n\n\t\t\t\t{/* Network storage */}\n\t\t\t\t{/* We don't wrap in AnimatePresence because this section is always rendered */}\n\t\t\t\t<SidebarDivider />\n\t\t\t\t{showNetwork ? (\n\t\t\t\t\t<SidebarSection label={t('files-sidebar.network')}>\n\t\t\t\t\t\t<SidebarNetworkStorage />\n\t\t\t\t\t</SidebarSection>\n\t\t\t\t) : null}\n\n\t\t\t\t{/* External Storage */}\n\t\t\t\t<AnimatePresence initial={!isLoadingExternalStorage}>\n\t\t\t\t\t{showExternal && (\n\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\tinitial={isLoadingExternalStorage ? {opacity: 0, height: 0} : false}\n\t\t\t\t\t\t\tanimate={{opacity: 1, height: 'auto'}}\n\t\t\t\t\t\t\texit={{opacity: 0, height: 0}}\n\t\t\t\t\t\t\ttransition={{duration: 0.2}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<SidebarDivider />\n\t\t\t\t\t\t\t<SidebarSection label={t('files-sidebar.external-storage')}>\n\t\t\t\t\t\t\t\t<SidebarExternalStorage />\n\t\t\t\t\t\t\t</SidebarSection>\n\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t)}\n\t\t\t\t</AnimatePresence>\n\n\t\t\t\t{/* Spacer */}\n\t\t\t\t<div className='h-6' />\n\t\t\t</ScrollArea>\n\t\t\t{/* Trash */}\n\t\t\t{showTrash ? <SidebarTrash /> : null}\n\t\t\t{showRewind ? <SidebarRewind /> : null}\n\t\t</nav>\n\t)\n}\n\nconst SidebarSection = ({children, label = ''}: {children: React.ReactNode; label?: string}) => {\n\treturn (\n\t\t<section className='flex flex-col gap-0.5 pr-4' aria-label={label}>\n\t\t\t<div className='px-2 py-1 text-[11px] font-medium text-white/40'>{label}</div>\n\t\t\t{children}\n\t\t</section>\n\t)\n}\n\nconst SidebarDivider = () => {\n\treturn (\n\t\t<div\n\t\t\tclassName='my-3 h-px w-full bg-[radial-gradient(35%_35%_at_35%_35%,rgba(255,255,255,0.35)_0%,transparent_70%)]'\n\t\t\trole='separator'\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/sidebar/mobile-sidebar-wrapper.tsx",
    "content": "import {ChevronLeft} from 'lucide-react'\nimport {AnimatePresence, motion, useMotionValue} from 'motion/react'\n\ninterface MobileSidebarProps {\n\tchildren: React.ReactNode\n\tisOpen: boolean\n\tonClose: () => void\n}\n\nexport function MobileSidebarWrapper({children, isOpen, onClose}: MobileSidebarProps) {\n\tconst x = useMotionValue(0)\n\n\tconst handleDragEnd = (event: any, {offset, velocity}: any) => {\n\t\t// Close if:\n\t\t// 1. Dragged more than 100px left OR\n\t\t// 2. Dragged more than 50px left with high velocity\n\t\tconst shouldClose = offset.x < -100 || (offset.x < -50 && velocity.x < -500)\n\n\t\tif (shouldClose) {\n\t\t\tonClose()\n\t\t} else {\n\t\t\t// Always animate back to 0 when released\n\t\t\tx.set(0)\n\t\t}\n\t}\n\n\treturn (\n\t\t<AnimatePresence>\n\t\t\t{isOpen && (\n\t\t\t\t<>\n\t\t\t\t\t{/* Backdrop */}\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tinitial={{opacity: 0}}\n\t\t\t\t\t\tanimate={{opacity: 1}}\n\t\t\t\t\t\texit={{opacity: 0}}\n\t\t\t\t\t\ttransition={{duration: 0.2}}\n\t\t\t\t\t\tclassName='fixed inset-0 z-40 h-[100svh] w-[100svw] bg-black/50'\n\t\t\t\t\t\tonClick={onClose}\n\t\t\t\t\t/>\n\t\t\t\t\t{/* Sidebar */}\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tdrag='x'\n\t\t\t\t\t\tdragConstraints={{left: -256, right: 0}}\n\t\t\t\t\t\tdragElastic={0.2}\n\t\t\t\t\t\tdragMomentum={false}\n\t\t\t\t\t\tonDragEnd={handleDragEnd}\n\t\t\t\t\t\tinitial={{x: '-100%'}}\n\t\t\t\t\t\tanimate={{x: 0}}\n\t\t\t\t\t\texit={{x: '-100%'}}\n\t\t\t\t\t\ttransition={{type: 'spring', damping: 20, stiffness: 200}}\n\t\t\t\t\t\tstyle={{x}}\n\t\t\t\t\t\tclassName='fixed inset-y-0 left-0 z-50 -ml-10 w-[256px] border-r border-white/10 bg-black pl-14 md:-ml-3'\n\t\t\t\t\t>\n\t\t\t\t\t\t{/* Close button */}\n\t\t\t\t\t\t<div className='absolute top-8 right-3 sm:top-10 md:top-12'>\n\t\t\t\t\t\t\t<ChevronLeft role='button' className='h-4 w-4 text-white/60 transition-colors' onClick={onClose} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className='h-12 sm:h-16 md:h-20' /> {/* Spacer for top padding */}\n\t\t\t\t\t\t{/* The actual <Sidebar /> component will be passed in as children */}\n\t\t\t\t\t\t{children}\n\t\t\t\t\t\t{/* Full height drag area with centered visual handle */}\n\t\t\t\t\t\t<div className='absolute top-0 right-[-12px] h-full w-3'>\n\t\t\t\t\t\t\t{/* Invisible touch target */}\n\t\t\t\t\t\t\t<div className='absolute inset-0 w-full' />\n\t\t\t\t\t\t\t{/* Visual handle centered */}\n\t\t\t\t\t\t\t<div className='absolute top-1/2 left-0 flex h-16 w-full -translate-y-1/2 items-center justify-center'>\n\t\t\t\t\t\t\t\t<div className='h-12 w-1 rounded-full bg-white/20' />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</motion.div>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</AnimatePresence>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/sidebar/sidebar-apps.tsx",
    "content": "import {SidebarItem} from '@/features/files/components/sidebar/sidebar-item'\nimport {APPS_PATH} from '@/features/files/constants'\nimport {useNavigate as useFilesNavigate} from '@/features/files/hooks/use-navigate'\nimport {t} from '@/utils/i18n'\n\nexport function SidebarApps() {\n\tconst {navigateToDirectory, currentPath} = useFilesNavigate()\n\n\treturn (\n\t\t<SidebarItem\n\t\t\titem={{\n\t\t\t\tname: t('files-sidebar.apps'),\n\t\t\t\tpath: APPS_PATH,\n\t\t\t\ttype: 'directory',\n\t\t\t}}\n\t\t\tisActive={currentPath === APPS_PATH}\n\t\t\tonClick={() => navigateToDirectory(APPS_PATH)}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/sidebar/sidebar-external-storage-item.tsx",
    "content": "import {FaEject} from 'react-icons/fa6'\nimport {RiErrorWarningFill, RiHardDrive2Fill} from 'react-icons/ri'\nimport {useNavigate as useReactRouterNavigate} from 'react-router-dom'\n\nimport {Droppable} from '@/features/files/components/shared/drag-and-drop'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {useExternalStorage} from '@/features/files/hooks/use-external-storage'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport type {ExternalStorageDevice} from '@/features/files/types'\nimport {formatFilesystemSize} from '@/features/files/utils/format-filesystem-size'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nconst selectedClass = tw`\n  bg-linear-to-b from-white/[0.04] to-white/[0.08]\n  border-white/6  \n  shadow-button-highlight-soft-hpx \n`\n\nexport interface SidebarExternalStorageItemProps {\n\titem: ExternalStorageDevice\n}\n\nexport function SidebarExternalStorageItem({item}: SidebarExternalStorageItemProps) {\n\tconst {ejectDisk} = useExternalStorage()\n\tconst {navigateToDirectory, currentPath} = useNavigate()\n\tconst navigate = useReactRouterNavigate()\n\tconst {addLinkSearchParams} = useQueryParams()\n\tconst isDiskActive = item.partitions.length === 1 && currentPath === item.partitions[0].mountpoints[0]\n\n\tconst handleClick = () => {\n\t\tif (!item.isMounted && !item.isFormatting) {\n\t\t\t// Open format dialog\n\t\t\tnavigate({\n\t\t\t\tsearch: addLinkSearchParams({\n\t\t\t\t\tdialog: 'files-format-drive',\n\t\t\t\t\tdeviceId: item.id,\n\t\t\t\t}),\n\t\t\t})\n\t\t} else if (item.partitions.length === 1) {\n\t\t\t// Navigate to the partition\n\t\t\tnavigateToDirectory(item.partitions[0].mountpoints[0])\n\t\t}\n\t}\n\n\t// For disks with a single partition, we display the partition name/label\n\t// For disks with multiple partitions, we display the disk name\n\t// Note: Unmounted partitions are filtered out before being passed here\n\tconst displayName =\n\t\titem.partitions.length === 1 ? item.partitions[0].label || item.partitions[0].mountpoints[0] : item.name\n\n\tconst ExternalStorageDisk = (\n\t\t<div\n\t\t\tonClick={handleClick}\n\t\t\trole={!item.isMounted || item.partitions.length === 1 ? 'button' : undefined}\n\t\t\tclassName={cn(\n\t\t\t\t'flex w-full items-center gap-1.5 px-2 py-1.5',\n\t\t\t\titem.isFormatting && 'cursor-not-allowed opacity-50',\n\t\t\t)}\n\t\t>\n\t\t\t<div className='relative'>\n\t\t\t\t<FileItemIcon\n\t\t\t\t\titem={{...item, path: item.id, type: 'external-storage', operations: [], size: 0, modified: 0}}\n\t\t\t\t\tclassName='h-5 w-5 flex-shrink-0'\n\t\t\t\t/>\n\t\t\t\t{/* Warning badge for drives that need formatting */}\n\t\t\t\t{!item.isMounted && !item.isFormatting && (\n\t\t\t\t\t<div className='absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-[#FF9500]'>\n\t\t\t\t\t\t<RiErrorWarningFill className='size-3.5 text-black' />\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t\t<div className='flex min-w-0 flex-1 items-center justify-between gap-1'>\n\t\t\t\t<div className='flex min-w-0 flex-1 flex-col'>\n\t\t\t\t\t<span className='min-w-0 overflow-hidden text-ellipsis whitespace-nowrap'>\n\t\t\t\t\t\t{item.isFormatting ? t('files-format.formatting') : displayName}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t\t<div className='flex flex-shrink-0 items-center gap-2'>\n\t\t\t\t\t<span className='text-white/30'>{formatFilesystemSize(item.size)}</span>\n\t\t\t\t\t{item.isMounted && (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\te.stopPropagation() // Prevent the click from triggering a navigation\n\t\t\t\t\t\t\t\tejectDisk({deviceId: item.id})\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\taria-label={t('files-action.eject-disk')}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<FaEject className='text-white/60 hover:text-white' />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n\n\treturn (\n\t\t<div className='flex w-full flex-col'>\n\t\t\t{item.partitions.length > 1 ? (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t'flex w-full rounded-lg border border-transparent from-white/[0.04] to-white/[0.08] text-12 text-white/60',\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{ExternalStorageDisk}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className='flex flex-col gap-0.5 pl-7'>\n\t\t\t\t\t\t{item.partitions.map((partition: ExternalStorageDevice['partitions'][number]) => (\n\t\t\t\t\t\t\t<Droppable\n\t\t\t\t\t\t\t\tkey={partition.mountpoints[0]}\n\t\t\t\t\t\t\t\tid={`sidebar-${partition.mountpoints[0]}`}\n\t\t\t\t\t\t\t\tpath={partition.mountpoints[0]}\n\t\t\t\t\t\t\t\tonClick={() => navigateToDirectory(partition.mountpoints[0])}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t'flex items-center gap-1.5 rounded-lg border border-transparent from-white/[0.04] to-white/[0.08] px-2 py-1.5 text-12 hover:bg-linear-to-b',\n\t\t\t\t\t\t\t\t\tcurrentPath === partition.mountpoints[0]\n\t\t\t\t\t\t\t\t\t\t? selectedClass\n\t\t\t\t\t\t\t\t\t\t: 'text-white/60 transition-colors hover:bg-white/10 hover:text-white',\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\trole='button'\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<RiHardDrive2Fill className='h-3 w-3 flex-shrink-0' />\n\t\t\t\t\t\t\t\t<span className='min-w-0 flex-1 overflow-hidden text-11 text-ellipsis whitespace-nowrap'>\n\t\t\t\t\t\t\t\t\t{partition.label || partition.mountpoints[0]}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<span className='text-11 text-white/30'>{formatFilesystemSize(partition.size)}</span>\n\t\t\t\t\t\t\t</Droppable>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t) : (\n\t\t\t\t<Droppable\n\t\t\t\t\tid={`sidebar-${item.id}`}\n\t\t\t\t\tpath={item.partitions[0]?.mountpoints[0]}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t'flex w-full rounded-lg border border-transparent from-white/[0.04] to-white/[0.08] text-12 hover:bg-linear-to-b',\n\t\t\t\t\t\tisDiskActive ? selectedClass : 'text-white/60 transition-colors hover:bg-white/10 hover:text-white',\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t{ExternalStorageDisk}\n\t\t\t\t</Droppable>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/sidebar/sidebar-external-storage.tsx",
    "content": "import {AnimatePresence, motion} from 'motion/react'\nimport {useNavigate} from 'react-router-dom'\n\nimport {ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger} from '@/components/ui/context-menu'\nimport {SidebarExternalStorageItem} from '@/features/files/components/sidebar/sidebar-external-storage-item'\nimport {useExternalStorage} from '@/features/files/hooks/use-external-storage'\nimport type {ExternalStorageDevice} from '@/features/files/types'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {t} from '@/utils/i18n'\n\nexport function SidebarExternalStorage() {\n\tconst {disks, isLoadingExternalStorage, ejectDisk, isExternalStorageSupported} = useExternalStorage()\n\tconst navigate = useNavigate()\n\tconst {addLinkSearchParams} = useQueryParams()\n\n\t// Don't render anything for non-supported devices\n\tif (!isExternalStorageSupported) {\n\t\treturn null\n\t}\n\n\t// Don't render anything if the disks are still loading or there are no disks\n\tif (isLoadingExternalStorage || !disks?.length) {\n\t\treturn null\n\t}\n\n\treturn (\n\t\t<AnimatePresence initial={false}>\n\t\t\t{disks.map((disk: ExternalStorageDevice) => (\n\t\t\t\t<motion.div\n\t\t\t\t\tkey={`sidebar-external-storage-${disk.id}`}\n\t\t\t\t\tinitial={{opacity: 0, height: 0}}\n\t\t\t\t\tanimate={{opacity: 1, height: 'auto'}}\n\t\t\t\t\texit={{opacity: 0, height: 0}}\n\t\t\t\t\ttransition={{duration: 0.2}}\n\t\t\t\t>\n\t\t\t\t\t<ContextMenu>\n\t\t\t\t\t\t<ContextMenuTrigger asChild>\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<SidebarExternalStorageItem\n\t\t\t\t\t\t\t\t\titem={{\n\t\t\t\t\t\t\t\t\t\tname: disk.name,\n\t\t\t\t\t\t\t\t\t\tid: disk.id,\n\t\t\t\t\t\t\t\t\t\t// If the drive requires formatting, don't show any partitions\n\t\t\t\t\t\t\t\t\t\t// Otherwise, only show mounted partitions\n\t\t\t\t\t\t\t\t\t\tpartitions: !disk.isMounted\n\t\t\t\t\t\t\t\t\t\t\t? []\n\t\t\t\t\t\t\t\t\t\t\t: disk.partitions\n\t\t\t\t\t\t\t\t\t\t\t\t\t.filter((partition: any) => partition.mountpoints?.length > 0)\n\t\t\t\t\t\t\t\t\t\t\t\t\t.map((partition: any) => ({\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t...partition,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tmountpoint: partition.mountpoints?.[0] ?? '',\n\t\t\t\t\t\t\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t\t\t\t\tsize: disk.size,\n\t\t\t\t\t\t\t\t\t\tisMounted: disk.isMounted,\n\t\t\t\t\t\t\t\t\t\tisFormatting: disk.isFormatting,\n\t\t\t\t\t\t\t\t\t\ttransport: disk.transport,\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</ContextMenuTrigger>\n\t\t\t\t\t\t<ContextMenuContent>\n\t\t\t\t\t\t\t{disk.isMounted && (\n\t\t\t\t\t\t\t\t<ContextMenuItem onClick={() => ejectDisk({deviceId: disk.id})}>\n\t\t\t\t\t\t\t\t\t{t('files-action.eject-disk')}\n\t\t\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tnavigate({\n\t\t\t\t\t\t\t\t\t\tsearch: addLinkSearchParams({\n\t\t\t\t\t\t\t\t\t\t\tdialog: 'files-format-drive',\n\t\t\t\t\t\t\t\t\t\t\tdeviceId: disk.id,\n\t\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t('files-action.format-drive')}\n\t\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t</ContextMenuContent>\n\t\t\t\t\t</ContextMenu>\n\t\t\t\t</motion.div>\n\t\t\t))}\n\t\t</AnimatePresence>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/sidebar/sidebar-favorites.tsx",
    "content": "import {AnimatePresence, motion} from 'motion/react'\n\nimport {ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger} from '@/components/ui/context-menu'\nimport {SidebarItem} from '@/features/files/components/sidebar/sidebar-item'\nimport {useFavorites} from '@/features/files/hooks/use-favorites'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\nimport {t} from '@/utils/i18n'\n\nexport function SidebarFavorites({favorites}: {favorites: (string | null)[]}) {\n\tconst {navigateToDirectory, currentPath} = useNavigate()\n\tconst {removeFavorite} = useFavorites()\n\tconst isReadOnly = useIsFilesReadOnly()\n\n\treturn (\n\t\t<AnimatePresence initial={false}>\n\t\t\t{favorites.map((favoritePath: string | null) => {\n\t\t\t\tif (!favoritePath) return null\n\t\t\t\tconst name = favoritePath.split('/').pop() || favoritePath\n\n\t\t\t\treturn (\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tkey={`sidebar-favorite-${favoritePath}`}\n\t\t\t\t\t\tinitial={{opacity: 0, height: 0}}\n\t\t\t\t\t\tanimate={{opacity: 1, height: 'auto'}}\n\t\t\t\t\t\texit={{opacity: 0, height: 0}}\n\t\t\t\t\t\ttransition={{duration: 0.2}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<ContextMenu>\n\t\t\t\t\t\t\t<ContextMenuTrigger asChild>\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<SidebarItem\n\t\t\t\t\t\t\t\t\t\titem={{\n\t\t\t\t\t\t\t\t\t\t\tname: name,\n\t\t\t\t\t\t\t\t\t\t\tpath: favoritePath,\n\t\t\t\t\t\t\t\t\t\t\ttype: 'directory',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tisActive={currentPath === favoritePath}\n\t\t\t\t\t\t\t\t\t\tonClick={() => navigateToDirectory(favoritePath)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</ContextMenuTrigger>\n\t\t\t\t\t\t\t{!isReadOnly ? (\n\t\t\t\t\t\t\t\t<ContextMenuContent>\n\t\t\t\t\t\t\t\t\t<ContextMenuItem onClick={() => removeFavorite({path: favoritePath})}>\n\t\t\t\t\t\t\t\t\t\t{t('files-action.remove-favorite')}\n\t\t\t\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t\t\t</ContextMenuContent>\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t</ContextMenu>\n\t\t\t\t\t</motion.div>\n\t\t\t\t)\n\t\t\t})}\n\t\t</AnimatePresence>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/sidebar/sidebar-home.tsx",
    "content": "import {useNavigate as useRouterNavigate} from 'react-router-dom'\n\nimport {ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger} from '@/components/ui/context-menu'\nimport {SidebarItem} from '@/features/files/components/sidebar/sidebar-item'\nimport {HOME_PATH} from '@/features/files/constants'\nimport {useHomeDirectoryName} from '@/features/files/hooks/use-home-directory-name'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useShares} from '@/features/files/hooks/use-shares'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {t} from '@/utils/i18n'\n\nexport function SidebarHome() {\n\tconst homeDirectoryName = useHomeDirectoryName()\n\tconst {navigateToDirectory, currentPath} = useNavigate()\n\tconst navigate = useRouterNavigate()\n\tconst {addLinkSearchParams} = useQueryParams()\n\tconst {isHomeShared} = useShares()\n\n\tconst isShared = isHomeShared()\n\n\tconst openShareInfoDialog = () => {\n\t\tnavigate({\n\t\t\tsearch: addLinkSearchParams({\n\t\t\t\tdialog: 'files-share-info',\n\t\t\t\t'files-share-info-name': homeDirectoryName,\n\t\t\t\t'files-share-info-path': HOME_PATH,\n\t\t\t}),\n\t\t})\n\t}\n\n\treturn (\n\t\t<ContextMenu>\n\t\t\t<ContextMenuTrigger asChild>\n\t\t\t\t<div>\n\t\t\t\t\t<SidebarItem\n\t\t\t\t\t\titem={{\n\t\t\t\t\t\t\tname: homeDirectoryName,\n\t\t\t\t\t\t\tpath: HOME_PATH,\n\t\t\t\t\t\t\ttype: 'directory',\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tisActive={currentPath === HOME_PATH}\n\t\t\t\t\t\tonClick={() => navigateToDirectory(HOME_PATH)}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</ContextMenuTrigger>\n\t\t\t<ContextMenuContent>\n\t\t\t\t<ContextMenuItem onClick={openShareInfoDialog}>\n\t\t\t\t\t{isShared ? t('files-action.sharing') : t('files-action.share')}\n\t\t\t\t</ContextMenuItem>\n\t\t\t</ContextMenuContent>\n\t\t</ContextMenu>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/sidebar/sidebar-item.tsx",
    "content": "import {Droppable} from '@/features/files/components/shared/drag-and-drop'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {RECENTS_PATH} from '@/features/files/constants'\nimport {formatItemName} from '@/features/files/utils/format-filesystem-name'\nimport {cn} from '@/lib/utils'\nimport {tw} from '@/utils/tw'\n\nconst selectedClass = tw`\n  bg-linear-to-b from-white/[0.04] to-white/[0.08]\n  border-white/6  \n  shadow-button-highlight-soft-hpx \n`\n\ntype SidebarItem = {\n\tname: string\n\tpath: string\n\ttype: 'directory'\n}\n\nexport interface SidebarItemProps {\n\titem: SidebarItem\n\tisActive: boolean\n\tonClick: () => void\n\tdisabled?: boolean\n}\n\nexport function SidebarItem({item, isActive, onClick, disabled = false}: SidebarItemProps) {\n\treturn (\n\t\t<Droppable\n\t\t\tid={`sidebar-${item.path}`}\n\t\t\tpath={item.path}\n\t\t\tclassName={cn(\n\t\t\t\t'flex w-full rounded-lg border border-transparent from-white/[0.04] to-white/[0.08] text-12',\n\t\t\t\tdisabled ? 'cursor-default opacity-50' : 'hover:bg-linear-to-b',\n\t\t\t\tisActive && !disabled\n\t\t\t\t\t? selectedClass\n\t\t\t\t\t: disabled\n\t\t\t\t\t\t? 'text-white/40'\n\t\t\t\t\t\t: 'text-white/60 transition-colors hover:bg-white/10 hover:text-white',\n\t\t\t)}\n\t\t\tdisabled={disabled || item.path === RECENTS_PATH} // Disable dropping on recents and when disabled\n\t\t>\n\t\t\t<button\n\t\t\t\tonClick={() => {\n\t\t\t\t\tif (disabled) return\n\t\t\t\t\tonClick()\n\t\t\t\t}}\n\t\t\t\taria-disabled={disabled}\n\t\t\t\tdisabled={disabled}\n\t\t\t\tclassName={cn('flex w-full items-center gap-1.5 px-2 py-1.5', disabled && 'cursor-default')}\n\t\t\t>\n\t\t\t\t{/* We add default modified, size, and operations to satisfy FileItemIcon's expected FileSystemItem type */}\n\t\t\t\t<FileItemIcon item={{...item, modified: 0, size: 0, operations: []}} className='h-5 w-5' />\n\t\t\t\t<span className='truncate'>{formatItemName({name: item.name, maxLength: 21})}</span>\n\t\t\t</button>\n\t\t</Droppable>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/sidebar/sidebar-network-share-item.tsx",
    "content": "import {FaEject} from 'react-icons/fa6'\n\nimport {Droppable} from '@/features/files/components/shared/drag-and-drop'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nconst selectedClass = tw`\n  bg-linear-to-b from-white/[0.04] to-white/[0.08]\n  border-white/6\n  shadow-button-highlight-soft-hpx\n`\n\nexport interface SidebarNetworkShareItemProps {\n\thost: string\n\trootPath: string // /Network/<host>\n\tonEject: () => Promise<void> | void\n\tdisabled?: boolean\n}\n\nexport function SidebarNetworkShareItem({host, rootPath, onEject, disabled}: SidebarNetworkShareItemProps) {\n\tconst {navigateToDirectory, currentPath} = useNavigate()\n\tconst isActive = currentPath.startsWith(rootPath)\n\n\treturn (\n\t\t<Droppable\n\t\t\tid={`sidebar-${rootPath}`}\n\t\t\tpath={rootPath}\n\t\t\tonClick={() => navigateToDirectory(rootPath)}\n\t\t\tclassName={cn(\n\t\t\t\t'flex items-center gap-1.5 rounded-lg border border-transparent from-white/[0.04] to-white/[0.08] px-2 py-1.5 text-12 hover:bg-linear-to-b',\n\t\t\t\tisActive ? selectedClass : 'text-white/60 transition-colors hover:bg-white/10 hover:text-white',\n\t\t\t)}\n\t\t\trole='button'\n\t\t>\n\t\t\t<FileItemIcon\n\t\t\t\titem={{path: rootPath, type: 'directory', operations: [], size: 0, modified: 0, name: host}}\n\t\t\t\tclassName='h-5 w-5 flex-shrink-0'\n\t\t\t/>\n\t\t\t<span className='min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap'>{host}</span>\n\n\t\t\t{/* Eject button */}\n\t\t\t<button\n\t\t\t\tonClick={(e) => {\n\t\t\t\t\t// prevent navigating into /Network\n\t\t\t\t\te.stopPropagation()\n\n\t\t\t\t\t// eject (remove) the host\n\t\t\t\t\tonEject()\n\t\t\t\t}}\n\t\t\t\taria-label={t('files-action.eject-disk')}\n\t\t\t\tdisabled={disabled}\n\t\t\t\tclassName={cn(disabled ? 'cursor-not-allowed opacity-50' : 'hover:text-white')}\n\t\t\t>\n\t\t\t\t<FaEject className='text-white/60' />\n\t\t\t</button>\n\t\t</Droppable>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/sidebar/sidebar-network-storage.tsx",
    "content": "import {AnimatePresence, motion} from 'motion/react'\nimport {useMemo} from 'react'\nimport {FaPlus} from 'react-icons/fa6'\nimport {useNavigate as useReactRouterNavigate} from 'react-router-dom'\n\nimport {ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger} from '@/components/ui/context-menu'\nimport networkIcon from '@/features/files/assets/network-icon.png'\nimport {Droppable} from '@/features/files/components/shared/drag-and-drop'\nimport {SidebarNetworkShareItem} from '@/features/files/components/sidebar/sidebar-network-share-item'\nimport {NETWORK_STORAGE_PATH} from '@/features/files/constants'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useNetworkStorage} from '@/features/files/hooks/use-network-storage'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nexport function SidebarNetworkStorage() {\n\tconst {shares, isLoadingShares, removeHostOrShare, isRemovingShare} = useNetworkStorage()\n\n\t// Group the mounted shares by host so that we render single network device items even when there are multiple shares for the same host.\n\tconst hosts = useMemo(() => {\n\t\tif (!shares) return []\n\t\tconst map = new Map<string, {host: string; hostPath: string}>()\n\t\tfor (const s of shares) {\n\t\t\t// only render hosts with mounted shares in the sidebar\n\t\t\tif (!s.isMounted) continue\n\t\t\tconst hostPath = s.mountPath.split('/').slice(0, -1).join('/') // /Network/<host>\n\t\t\tif (!map.has(s.host)) {\n\t\t\t\tmap.set(s.host, {host: s.host, hostPath})\n\t\t\t}\n\t\t}\n\t\treturn Array.from(map.values())\n\t}, [shares])\n\n\treturn (\n\t\t<>\n\t\t\t{/* Permanent /Network root item with \"Add Network Share\" button */}\n\t\t\t<NetworkRootItem />\n\n\t\t\t{/* Mounted network devices (if any) */}\n\t\t\t{!isLoadingShares && hosts.length > 0 && (\n\t\t\t\t<AnimatePresence initial={false}>\n\t\t\t\t\t{hosts.map(({host, hostPath}) => (\n\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\tkey={`sidebar-network-${host}`}\n\t\t\t\t\t\t\tinitial={{opacity: 0, height: 0}}\n\t\t\t\t\t\t\tanimate={{opacity: 1, height: 'auto'}}\n\t\t\t\t\t\t\texit={{opacity: 0, height: 0}}\n\t\t\t\t\t\t\ttransition={{duration: 0.2}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ContextMenu>\n\t\t\t\t\t\t\t\t<ContextMenuTrigger asChild>\n\t\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t\t<SidebarNetworkShareItem\n\t\t\t\t\t\t\t\t\t\t\thost={host}\n\t\t\t\t\t\t\t\t\t\t\trootPath={hostPath}\n\t\t\t\t\t\t\t\t\t\t\tonEject={() => removeHostOrShare(hostPath)}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isRemovingShare}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</ContextMenuTrigger>\n\t\t\t\t\t\t\t\t<ContextMenuContent>\n\t\t\t\t\t\t\t\t\t<ContextMenuItem disabled={isRemovingShare} onClick={() => removeHostOrShare(hostPath)}>\n\t\t\t\t\t\t\t\t\t\t{t('files-action.remove-network-host')}\n\t\t\t\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t\t\t</ContextMenuContent>\n\t\t\t\t\t\t\t</ContextMenu>\n\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t))}\n\t\t\t\t</AnimatePresence>\n\t\t\t)}\n\t\t</>\n\t)\n}\n\n/* ------------------------------------------------------------------\n * Always rendered /Network root item with \"Add Network Share\" button\n * ---------------------------------------------------------------- */\nconst selectedClass = tw`\n  bg-linear-to-b from-white/[0.04] to-white/[0.08]\n  border-white/6\n  shadow-button-highlight-soft-hpx\n`\n\nfunction NetworkRootItem() {\n\tconst {navigateToDirectory, currentPath} = useNavigate()\n\tconst isActive = currentPath === NETWORK_STORAGE_PATH\n\tconst navigate = useReactRouterNavigate()\n\tconst {addLinkSearchParams} = useQueryParams()\n\n\treturn (\n\t\t<Droppable\n\t\t\tid={`sidebar-${NETWORK_STORAGE_PATH}`}\n\t\t\tpath={NETWORK_STORAGE_PATH}\n\t\t\tonClick={() => navigateToDirectory(NETWORK_STORAGE_PATH)}\n\t\t\tclassName='group flex items-stretch gap-0.5 rounded-lg text-12'\n\t\t\trole='button'\n\t\t>\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'flex flex-1 items-center gap-1.5 rounded-l-lg border border-r-0 border-transparent from-white/[0.04] to-white/[0.08] px-2 py-1.5 group-hover:bg-linear-to-b',\n\t\t\t\t\tisActive ? selectedClass : 'text-white/60 transition-colors group-hover:bg-white/10 group-hover:text-white',\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t<img src={networkIcon} alt='' className='h-5 w-auto flex-shrink-0' />\n\t\t\t\t<span className='min-w-0 overflow-hidden text-ellipsis whitespace-nowrap'>\n\t\t\t\t\t{t('files-sidebar.network-sidebar')}\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'group/plus flex items-center justify-center rounded-r-lg border border-l-0 border-transparent from-white/[0.04] to-white/[0.08] px-2 py-1.5 group-hover:bg-linear-to-b',\n\t\t\t\t\tisActive ? selectedClass : 'transition-colors group-hover:bg-white/10',\n\t\t\t\t)}\n\t\t\t\tonClick={(e) => {\n\t\t\t\t\t// prevent navigating into /Network\n\t\t\t\t\te.stopPropagation()\n\n\t\t\t\t\t// open the add network share dialog\n\t\t\t\t\tnavigate({search: addLinkSearchParams({dialog: 'files-add-network-share'})})\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<button className='flex items-center justify-center text-white/60 transition-colors group-hover/plus:text-white'>\n\t\t\t\t\t<FaPlus className='size-3' strokeWidth={5} />\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</Droppable>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/sidebar/sidebar-recents.tsx",
    "content": "import {useLocation, useNavigate} from 'react-router-dom'\n\nimport {SidebarItem} from '@/features/files/components/sidebar/sidebar-item'\nimport {BASE_ROUTE_PATH, RECENTS_PATH} from '@/features/files/constants'\nimport {useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\nimport {t} from '@/utils/i18n'\n\nexport function SidebarRecents() {\n\tconst navigate = useNavigate()\n\tconst {pathname} = useLocation()\n\t// We disable (but still show) the recents sidebar item in read-only mode\n\tconst isReadOnly = useIsFilesReadOnly()\n\n\treturn (\n\t\t<SidebarItem\n\t\t\titem={{\n\t\t\t\tname: t('files-sidebar.recents'),\n\t\t\t\tpath: RECENTS_PATH,\n\t\t\t\ttype: 'directory',\n\t\t\t}}\n\t\t\tisActive={pathname === `${BASE_ROUTE_PATH}${RECENTS_PATH}`}\n\t\t\tonClick={() => navigate(`${BASE_ROUTE_PATH}${RECENTS_PATH}`)}\n\t\t\tdisabled={isReadOnly}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/sidebar/sidebar-shares.tsx",
    "content": "import {AnimatePresence, motion} from 'motion/react'\nimport {useNavigate} from 'react-router-dom'\n\nimport {ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger} from '@/components/ui/context-menu'\nimport {SidebarItem} from '@/features/files/components/sidebar/sidebar-item'\nimport {useNavigate as useFilesNavigate} from '@/features/files/hooks/use-navigate'\nimport {useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\nimport {Share} from '@/features/files/types'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {t} from '@/utils/i18n'\n\nexport function SidebarShares({shares}: {shares: (Share | null)[]}) {\n\tconst {navigateToDirectory, currentPath} = useFilesNavigate()\n\tconst navigate = useNavigate()\n\tconst {addLinkSearchParams} = useQueryParams()\n\tconst isReadOnly = useIsFilesReadOnly()\n\n\tconst openShareInfoDialog = (share: Share) => {\n\t\tnavigate({\n\t\t\tsearch: addLinkSearchParams({\n\t\t\t\tdialog: 'files-share-info',\n\t\t\t\t'files-share-info-name': share?.name || '',\n\t\t\t\t'files-share-info-path': share?.path || '',\n\t\t\t}),\n\t\t})\n\t}\n\n\treturn (\n\t\t<AnimatePresence initial={false}>\n\t\t\t{shares.map((share: Share | null) => {\n\t\t\t\tif (!share) return null\n\t\t\t\treturn (\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tkey={`sidebar-share-${share.path}`}\n\t\t\t\t\t\tinitial={{opacity: 0, height: 0}}\n\t\t\t\t\t\tanimate={{opacity: 1, height: 'auto'}}\n\t\t\t\t\t\texit={{opacity: 0, height: 0}}\n\t\t\t\t\t\ttransition={{duration: 0.2}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<ContextMenu>\n\t\t\t\t\t\t\t<ContextMenuTrigger asChild>\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<SidebarItem\n\t\t\t\t\t\t\t\t\t\titem={{\n\t\t\t\t\t\t\t\t\t\t\tname: share.name,\n\t\t\t\t\t\t\t\t\t\t\tpath: share.path,\n\t\t\t\t\t\t\t\t\t\t\ttype: 'directory',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tisActive={currentPath === share.path}\n\t\t\t\t\t\t\t\t\t\tonClick={() => navigateToDirectory(share.path)}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</ContextMenuTrigger>\n\t\t\t\t\t\t\t{/* We don't allow context menu in read-only mode */}\n\t\t\t\t\t\t\t{!isReadOnly ? (\n\t\t\t\t\t\t\t\t<ContextMenuContent>\n\t\t\t\t\t\t\t\t\t<ContextMenuItem onClick={() => openShareInfoDialog(share)}>\n\t\t\t\t\t\t\t\t\t\t{t('files-action.sharing')}\n\t\t\t\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t\t\t</ContextMenuContent>\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t</ContextMenu>\n\t\t\t\t\t</motion.div>\n\t\t\t\t)\n\t\t\t})}\n\t\t</AnimatePresence>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/components/sidebar/sidebar-trash.tsx",
    "content": "import {motion, MotionConfig} from 'motion/react'\nimport {useId, useState} from 'react'\n\nimport {Button} from '@/components/ui/button'\nimport {FlameIcon} from '@/features/files/assets/flame-icon'\nimport {Droppable} from '@/features/files/components/shared/drag-and-drop'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {TRASH_PATH} from '@/features/files/constants'\nimport {useFilesOperations} from '@/features/files/hooks/use-files-operations'\nimport {useListDirectory} from '@/features/files/hooks/use-list-directory'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {useConfirmation} from '@/providers/confirmation'\nimport {t} from '@/utils/i18n'\n\nexport function SidebarTrash() {\n\tconst {navigateToDirectory, currentPath} = useNavigate()\n\tconst isTrash = currentPath === TRASH_PATH\n\tconst [isHovering, setIsHovering] = useState(false)\n\tconst {listing} = useListDirectory(TRASH_PATH, {\n\t\titemsOnScrollEnd: 3,\n\t\tinitialItems: 3,\n\t})\n\tconst isTrashEmpty = listing?.items?.length === 0\n\tconst {emptyTrash} = useFilesOperations()\n\tconst confirm = useConfirmation()\n\tconst id = useId()\n\tconst isMobile = useIsMobile()\n\n\tconst handleEmptyTrash = async () => {\n\t\tif (isTrashEmpty) return\n\t\ttry {\n\t\t\tawait confirm({\n\t\t\t\ttitle: t('files-empty-trash.title'),\n\t\t\t\tmessage: t('files-empty-trash.description'),\n\t\t\t\tactions: [\n\t\t\t\t\t{label: t('files-empty-trash.confirm'), value: 'confirm', variant: 'destructive'},\n\t\t\t\t\t{label: t('cancel'), value: 'cancel', variant: 'default'},\n\t\t\t\t],\n\t\t\t\ticon: FlameIcon,\n\t\t\t})\n\t\t\temptyTrash()\n\t\t} catch {\n\t\t\t// User cancelled\n\t\t}\n\t}\n\n\treturn (\n\t\t<MotionConfig transition={{duration: 0.2, ease: [0.29, 0.01, 0, 1]}}>\n\t\t\t<Droppable\n\t\t\t\tclassName='mr-4 flex flex-col rounded-xl'\n\t\t\t\tdropOverClassName='border border-brand'\n\t\t\t\tid='sidebar-trash'\n\t\t\t\tpath={TRASH_PATH}\n\t\t\t\tdisabled={isTrash}\n\t\t\t\tnavigateToPath={false}\n\t\t\t\tonMouseEnter={(e: React.MouseEvent) => {\n\t\t\t\t\t/* Exclude hover when user is dropping files */\n\t\t\t\t\tif (e.buttons === 0) {\n\t\t\t\t\t\tsetIsHovering(true)\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t\tonMouseLeave={() => setIsHovering(false)}\n\t\t\t>\n\t\t\t\t{(isReadyToDrop) => {\n\t\t\t\t\tconst isExpanded = (isReadyToDrop || (isHovering && !isTrash)) && !isMobile\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\tlayout\n\t\t\t\t\t\t\tclassName={`flex flex-col items-center ${\n\t\t\t\t\t\t\t\tisExpanded\n\t\t\t\t\t\t\t\t\t? 'rounded-xl border border-white/6 bg-linear-to-b from-white/[0.04] to-white/[0.08] p-3'\n\t\t\t\t\t\t\t\t\t: 'h-[35px] rounded-lg'\n\t\t\t\t\t\t\t} ${isTrash && 'border-white/6 bg-linear-to-b !from-white/[0.04] !to-white/[0.08] shadow-button-highlight-soft-hpx'}`}\n\t\t\t\t\t\t\tinitial={false}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tif (isMobile) {\n\t\t\t\t\t\t\t\t\tnavigateToDirectory(TRASH_PATH)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\tlayout='position'\n\t\t\t\t\t\t\t\tclassName={`flex justify-end ${isExpanded ? 'flex-col items-center' : 'w-full flex-row-reverse'}`}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{/* \"Trash\" text */}\n\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\tlayout='position'\n\t\t\t\t\t\t\t\t\tclassName={`text-12 text-white/60 ${isExpanded ? 'mb-2' : 'mt-[10px] ml-[-18px]'}`}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{t('files-sidebar.trash')}\n\t\t\t\t\t\t\t\t</motion.div>\n\n\t\t\t\t\t\t\t\t{isExpanded && !isHovering && (\n\t\t\t\t\t\t\t\t\t<span className='mt-0 flex opacity-70'>\n\t\t\t\t\t\t\t\t\t\t<svg width='32' height='17' viewBox='0 0 32 17' fill='none'>\n\t\t\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\t\t\td='M13.4956 10.3327L8.82894 14.9993L4.16227 10.3327L6.82894 10.3327C6.82894 2.33268 3.49561 1.66602 3.49561 1.66602C3.49561 1.66602 10.8289 2.33268 10.8289 10.3327L13.4956 10.3327Z'\n\t\t\t\t\t\t\t\t\t\t\t\tfill='#3C3C3C'\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\t\t\td='M20.68848 10.3327L25.35514 14.9993L30.0218 10.3327L27.35514 10.3327C27.35514 2.33268 30.6885 1.66602 30.6885 1.66602C30.6885 1.66602 23.35514 2.33268 23.35514 10.3327L20.68848 10.3327Z'\n\t\t\t\t\t\t\t\t\t\t\t\tfill='#3C3C3C'\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t\t{/* Trash SVG */}\n\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\tlayout='position'\n\t\t\t\t\t\t\t\t\tclassName={`${isExpanded ? 'mt-4' : 'mt-[-18px] ml-[-16px]'} flex-shrink-0`}\n\t\t\t\t\t\t\t\t\tanimate={{\n\t\t\t\t\t\t\t\t\t\tscale: isExpanded ? 1 : 0.3,\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tinitial={false}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<svg className='mt-0 overflow-visible' width='70' height='74' viewBox='0 0 70 74' fill='none'>\n\t\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\t\td='M69.4114 5.10535C69.4114 7.37914 53.8764 9.22241 34.713 9.22241C15.5496 9.22241 0.0146484 7.37914 0.0146484 5.10535C0.0146484 2.83155 15.5496 0.988281 34.713 0.988281C53.8764 0.988281 69.4114 2.83155 69.4114 5.10535Z'\n\t\t\t\t\t\t\t\t\t\t\tfill={`url(#gradient-1-${id})`}\n\t\t\t\t\t\t\t\t\t\t\tfillOpacity='0.4'\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\t\td='M69.4114 5.10535C69.4114 7.37914 53.8764 9.22241 34.713 9.22241C15.5496 9.22241 0.0146484 7.37914 0.0146484 5.10535C0.0146484 2.83155 15.5496 0.988281 34.713 0.988281C53.8764 0.988281 69.4114 2.83155 69.4114 5.10535Z'\n\t\t\t\t\t\t\t\t\t\t\tfill='black'\n\t\t\t\t\t\t\t\t\t\t\tfillOpacity='0.7'\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t{/* Reflective edge on trash opening */}\n\t\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\t\td='M69.4114 5.10535C69.4114 7.37914 53.8764 9.22241 34.713 9.22241C15.5496 9.22241 0.0146484 7.37914 0.0146484 5.10535C0.0146484 2.83155 15.5496 0.988281 34.713 0.988281C53.8764 0.988281 69.4114 2.83155 69.4114 5.10535Z'\n\t\t\t\t\t\t\t\t\t\t\tfill='none'\n\t\t\t\t\t\t\t\t\t\t\tstroke='white'\n\t\t\t\t\t\t\t\t\t\t\tstrokeOpacity='0.08'\n\t\t\t\t\t\t\t\t\t\t\tstrokeWidth='0.5'\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<g id={`files-${id}`}>\n\t\t\t\t\t\t\t\t\t\t\t{listing?.items[2] && (\n\t\t\t\t\t\t\t\t\t\t\t\t<g\n\t\t\t\t\t\t\t\t\t\t\t\t\ttransform-origin='50% 50%'\n\t\t\t\t\t\t\t\t\t\t\t\t\ttransform={\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tlisting?.items[2].type === 'directory'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? `translate(16,-28) rotate(-70) scale(0.75)`\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'translate(12,-22) rotate(18) scale(0.7)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<FileItemIcon item={listing.items[2]} onlySVG />\n\t\t\t\t\t\t\t\t\t\t\t\t</g>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t{listing?.items[1] && (\n\t\t\t\t\t\t\t\t\t\t\t\t<g\n\t\t\t\t\t\t\t\t\t\t\t\t\ttransform-origin='50% 50%'\n\t\t\t\t\t\t\t\t\t\t\t\t\ttransform={\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tlisting?.items[1].type === 'directory'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? `translate(10,-28) rotate(-80) scale(0.75)`\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'translate(6,-18) rotate(10) scale(0.8)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<FileItemIcon item={listing.items[1]} onlySVG />\n\t\t\t\t\t\t\t\t\t\t\t\t</g>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t{listing?.items[0] && (\n\t\t\t\t\t\t\t\t\t\t\t\t<g\n\t\t\t\t\t\t\t\t\t\t\t\t\ttransform-origin='50% 50%'\n\t\t\t\t\t\t\t\t\t\t\t\t\ttransform={\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tlisting?.items[0].type === 'directory'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? `translate(2,-24) rotate(-90) scale(0.75)`\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'translate(0,-10) rotate(-2)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<FileItemIcon item={listing.items[0]} onlySVG />\n\t\t\t\t\t\t\t\t\t\t\t\t</g>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</g>\n\t\t\t\t\t\t\t\t\t\t<g>\n\t\t\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\t\t\td='M0.0146484 5.10535L2.58973 5.52427C23.8653 8.98642 45.5607 8.98642 66.8363 5.52426L69.4114 5.10535L58.0067 61.4707C56.4969 68.9328 49.9379 74.2976 42.3245 74.2976H27.1015C19.4881 74.2976 12.9292 68.9328 11.4193 61.4707L0.0146484 5.10535Z'\n\t\t\t\t\t\t\t\t\t\t\t\tfill={`url(#gradient-2-${id})`}\n\t\t\t\t\t\t\t\t\t\t\t\tfillOpacity='0.4'\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\t\t\td='M0.0146484 5.10535L2.58973 5.52427C23.8653 8.98642 45.5607 8.98642 66.8363 5.52426L69.4114 5.10535L58.0067 61.4707C56.4969 68.9328 49.9379 74.2976 42.3245 74.2976H27.1015C19.4881 74.2976 12.9292 68.9328 11.4193 61.4707L0.0146484 5.10535Z'\n\t\t\t\t\t\t\t\t\t\t\t\tfill={`url(#gradient-3-${id})`}\n\t\t\t\t\t\t\t\t\t\t\t\tfillOpacity='0.7'\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\t\t\td='M0.0146484 5.10535L2.58973 5.52427C23.8653 8.98642 45.5607 8.98642 66.8363 5.52426L69.4114 5.10535L58.0067 61.4707C56.4969 68.9328 49.9379 74.2976 42.3245 74.2976H27.1015C19.4881 74.2976 12.9292 68.9328 11.4193 61.4707L0.0146484 5.10535Z'\n\t\t\t\t\t\t\t\t\t\t\t\tfill='black'\n\t\t\t\t\t\t\t\t\t\t\t\tfillOpacity='0.2'\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t{/* Reflective edge highlight (replaces glow SVG filter for Safari compatibility) */}\n\t\t\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\t\t\td='M0.0146484 5.10535L2.58973 5.52427C23.8653 8.98642 45.5607 8.98642 66.8363 5.52426L69.4114 5.10535L58.0067 61.4707C56.4969 68.9328 49.9379 74.2976 42.3245 74.2976H27.1015C19.4881 74.2976 12.9292 68.9328 11.4193 61.4707L0.0146484 5.10535Z'\n\t\t\t\t\t\t\t\t\t\t\t\tfill='none'\n\t\t\t\t\t\t\t\t\t\t\t\tstroke='white'\n\t\t\t\t\t\t\t\t\t\t\t\tstrokeOpacity='0.08'\n\t\t\t\t\t\t\t\t\t\t\t\tstrokeWidth='0.5'\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</g>\n\t\t\t\t\t\t\t\t\t\t<g style={{clipPath: `url(#clip-${id})`}}>\n\t\t\t\t\t\t\t\t\t\t\t<use href={`#files-${id}`} filter={`url(#blur-${id})`} />\n\t\t\t\t\t\t\t\t\t\t</g>\n\t\t\t\t\t\t\t\t\t\t<clipPath id={`clip-${id}`}>\n\t\t\t\t\t\t\t\t\t\t\t<path d='M0.0146484 5.10535L2.58973 5.52427C23.8653 8.98642 45.5607 8.98642 66.8363 5.52426L69.4114 5.10535L58.0067 61.4707C56.4969 68.9328 49.9379 74.2976 42.3245 74.2976H27.1015C19.4881 74.2976 12.9292 68.9328 11.4193 61.4707L0.0146484 5.10535Z' />\n\t\t\t\t\t\t\t\t\t\t</clipPath>\n\t\t\t\t\t\t\t\t\t\t<defs>\n\t\t\t\t\t\t\t\t\t\t\t<filter id={`blur-${id}`} width='160%' height='200%' x='-30%'>\n\t\t\t\t\t\t\t\t\t\t\t\t<feGaussianBlur in='SourceGraphic' stdDeviation='4' result='blur' />\n\t\t\t\t\t\t\t\t\t\t\t\t<feColorMatrix type='saturate' in='blur' result='dimmed' values='0.4' />\n\t\t\t\t\t\t\t\t\t\t\t\t<feComponentTransfer in='dimmed' result='output'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<feFuncR type='linear' slope='0.2' intercept='0' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t<feFuncG type='linear' slope='0.2' intercept='0' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t<feFuncB type='linear' slope='0.2' intercept='0' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t<feFuncA type='linear' slope='1' intercept='0' />\n\t\t\t\t\t\t\t\t\t\t\t\t</feComponentTransfer>\n\t\t\t\t\t\t\t\t\t\t\t</filter>\n\t\t\t\t\t\t\t\t\t\t\t<linearGradient\n\t\t\t\t\t\t\t\t\t\t\t\tid={`gradient-1-${id}`}\n\t\t\t\t\t\t\t\t\t\t\t\tx1='0.0146484'\n\t\t\t\t\t\t\t\t\t\t\t\ty1='37.6429'\n\t\t\t\t\t\t\t\t\t\t\t\tx2='69.4114'\n\t\t\t\t\t\t\t\t\t\t\t\ty2='37.643'\n\t\t\t\t\t\t\t\t\t\t\t\tgradientUnits='userSpaceOnUse'\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<stop stopColor='#2D2D2D' />\n\t\t\t\t\t\t\t\t\t\t\t\t<stop offset='0.487377' stopColor='#3F3F3F' />\n\t\t\t\t\t\t\t\t\t\t\t\t<stop offset='1' stopColor='#272727' />\n\t\t\t\t\t\t\t\t\t\t\t</linearGradient>\n\t\t\t\t\t\t\t\t\t\t\t<linearGradient\n\t\t\t\t\t\t\t\t\t\t\t\tid={`gradient-2-${id}`}\n\t\t\t\t\t\t\t\t\t\t\t\tx1='-1.98535'\n\t\t\t\t\t\t\t\t\t\t\t\ty1='39.7017'\n\t\t\t\t\t\t\t\t\t\t\t\tx2='71.4114'\n\t\t\t\t\t\t\t\t\t\t\t\ty2='39.7017'\n\t\t\t\t\t\t\t\t\t\t\t\tgradientUnits='userSpaceOnUse'\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<stop stopColor='#787878' />\n\t\t\t\t\t\t\t\t\t\t\t\t<stop offset='0.330518' stopColor='#797979' />\n\t\t\t\t\t\t\t\t\t\t\t\t<stop offset='1' stopColor='#262626' />\n\t\t\t\t\t\t\t\t\t\t\t</linearGradient>\n\t\t\t\t\t\t\t\t\t\t\t<linearGradient\n\t\t\t\t\t\t\t\t\t\t\t\tid={`gradient-3-${id}`}\n\t\t\t\t\t\t\t\t\t\t\t\tx1='34.713'\n\t\t\t\t\t\t\t\t\t\t\t\ty1='5.10547'\n\t\t\t\t\t\t\t\t\t\t\t\tx2='34.713'\n\t\t\t\t\t\t\t\t\t\t\t\ty2='74.2978'\n\t\t\t\t\t\t\t\t\t\t\t\tgradientUnits='userSpaceOnUse'\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<stop stopOpacity='0' />\n\t\t\t\t\t\t\t\t\t\t\t\t<stop offset='0.815' />\n\t\t\t\t\t\t\t\t\t\t\t</linearGradient>\n\t\t\t\t\t\t\t\t\t\t\t<linearGradient\n\t\t\t\t\t\t\t\t\t\t\t\tid={`gradient-4-${id}`}\n\t\t\t\t\t\t\t\t\t\t\t\tx1='34.713'\n\t\t\t\t\t\t\t\t\t\t\t\ty1='5.10547'\n\t\t\t\t\t\t\t\t\t\t\t\tx2='34.713'\n\t\t\t\t\t\t\t\t\t\t\t\ty2='74.2978'\n\t\t\t\t\t\t\t\t\t\t\t\tgradientUnits='userSpaceOnUse'\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<stop stopColor='#959595' stopOpacity='0' />\n\t\t\t\t\t\t\t\t\t\t\t\t<stop offset='1' stopColor='#A3A3A3' stopOpacity='0.06' />\n\t\t\t\t\t\t\t\t\t\t\t</linearGradient>\n\t\t\t\t\t\t\t\t\t\t</defs>\n\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t\t</motion.div>\n\n\t\t\t\t\t\t\t{isExpanded && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t{isHovering && (\n\t\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\t\tclassName='mt-4 h-px w-full bg-[radial-gradient(80%_50%_at_50%_50%,rgba(255,255,255,0.35)_0%,transparent_70%)]'\n\t\t\t\t\t\t\t\t\t\t\tinitial={{scaleX: 0, opacity: 0}}\n\t\t\t\t\t\t\t\t\t\t\tanimate={{scaleX: 1, opacity: 1}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t<motion.div className='mt-4 flex gap-2' initial={{y: 10, opacity: 0}} animate={{y: 0, opacity: 1}}>\n\t\t\t\t\t\t\t\t\t\t{isHovering && (\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t<Button variant='default' onClick={() => navigateToDirectory(TRASH_PATH)}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t('files-sidebar.trash.open')}\n\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={handleEmptyTrash}\n\t\t\t\t\t\t\t\t\t\t\t\t\tvariant='default'\n\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled={isTrashEmpty}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={isTrashEmpty ? 'pointer-events-none opacity-50' : ''}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<FlameIcon />\n\t\t\t\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t)\n\t\t\t\t}}\n\t\t\t</Droppable>\n\t\t</MotionConfig>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/constants.ts",
    "content": "import {lazy} from 'react'\n\nimport {\n\tAiThumbnail,\n\tAudioThumbnail,\n\tCsvThumbnail,\n\tDmgThumbnail,\n\tDocxThumbnail,\n\tEbookThumbnail,\n\tExeThumbnail,\n\tImageThumbnail,\n\tIsoThumbnail,\n\tPdfThumbnail,\n\tPptThumbnail,\n\tPsdThumbnail,\n\tTxtThumbnail,\n\tVideoThumbnail,\n\tZipThumbnail,\n} from '@/features/files/assets/file-items-thumbnails'\nimport {AudioViewer} from '@/features/files/components/file-viewer/audio-viewer'\n\n// lazy load viewers, except audio viewer since it's a floating ui component\nconst ImageViewer = lazy(() => import('@/features/files/components/file-viewer/image-viewer'))\nconst PdfViewer = lazy(() => import('@/features/files/components/file-viewer/pdf-viewer'))\nconst VideoViewer = lazy(() => import('@/features/files/components/file-viewer/video-viewer'))\n\nexport const BASE_ROUTE_PATH = '/files' as const\nexport const HOME_PATH = '/Home' as const\nexport const TRASH_PATH = '/Trash' as const\nexport const APPS_PATH = '/Apps' as const\nexport const EXTERNAL_STORAGE_PATH = '/External' as const\nexport const NETWORK_STORAGE_PATH = '/Network' as const\nexport const BACKUPS_PATH = '/Backups' as const\n\n// NOTE: Search and Recents are not real directories on disk. They are\n// pseudo-directories, i.e. they are handled client-side only and are just\n// virtual routes that show a flat list of file items returned by the backend\n// search and recents endpoints.\nexport const SEARCH_PATH = '/Search' as const\nexport const RECENTS_PATH = '/Recents' as const\n\n// Directory listing constants\nexport const USE_LIST_DIRECTORY_LOAD_ITEMS = {\n\tINITIAL: 250, // Number of items to load when first viewing a directory\n\tON_SCROLL_END: 250, // Number of items to load when user scrolls near the end\n} as const\n\n// TODO: define it in a common place for client and server\nexport const SUPPORTED_ARCHIVE_EXTRACT_EXTENSIONS = [\n\t'.tar.gz',\n\t'.tgz',\n\t'.tar.bz2',\n\t'.tar.xz',\n\t'.tar',\n\t'.zip',\n\t'.rar',\n\t'.7z',\n] as const\n\nexport const SORT_BY_OPTIONS = [\n\t{sortBy: 'name', labelTKey: 'files-sort.name'},\n\t{sortBy: 'modified', labelTKey: 'files-sort.modified'},\n\t{sortBy: 'size', labelTKey: 'files-sort.size'},\n\t// {sortBy: 'created', labelTKey: 'files-sort.created'},\n\t{sortBy: 'type', labelTKey: 'files-sort.type'},\n] as const\n\n// ENSURE THESE 2 SETS MATCH THE ONES IN umbreld/source/modules/files/thumbnails.ts\nexport const IMAGE_EXTENSIONS_WITH_IMAGE_THUMBNAILS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'])\nexport const VIDEO_EXTENSIONS_WITH_IMAGE_THUMBNAILS = new Set(['.mov', '.mp4', '.3gp', '.mkv', '.avi'])\n\nexport const FILE_TYPE_MAP = {\n\t// Folder\n\tdirectory: {nameTKey: 'files-type.directory', thumbnail: null, viewer: null},\n\n\t// Disk Images\n\t'application/x-iso9660-image': {nameTKey: 'files-type.iso', thumbnail: IsoThumbnail, viewer: null},\n\t'application/x-apple-diskimage': {nameTKey: 'files-type.dmg', thumbnail: DmgThumbnail, viewer: null},\n\n\t// Executables\n\t'application/x-msdownload': {nameTKey: 'files-type.exe', thumbnail: ExeThumbnail, viewer: null},\n\t'application/x-executable': {nameTKey: 'files-type.executable', thumbnail: ExeThumbnail, viewer: null},\n\n\t// Design Files\n\t'image/vnd.adobe.photoshop': {nameTKey: 'files-type.psd', thumbnail: PsdThumbnail, viewer: null},\n\t'application/illustrator': {nameTKey: 'files-type.ai', thumbnail: AiThumbnail, viewer: null},\n\n\t// Archives\n\t'application/vnd.rar': {nameTKey: 'files-type.rar', thumbnail: ZipThumbnail, viewer: null},\n\t'application/zip': {nameTKey: 'files-type.zip', thumbnail: ZipThumbnail, viewer: null},\n\t'application/x-7z-compressed': {nameTKey: 'files-type.7z', thumbnail: ZipThumbnail, viewer: null},\n\t'application/x-tar': {nameTKey: 'files-type.tar', thumbnail: ZipThumbnail, viewer: null},\n\t'application/x-gzip': {nameTKey: 'files-type.gzip', thumbnail: ZipThumbnail, viewer: null},\n\t'application/gzip': {nameTKey: 'files-type.gzip', thumbnail: ZipThumbnail, viewer: null},\n\t'application/x-bzip2': {nameTKey: 'files-type.bzip2', thumbnail: ZipThumbnail, viewer: null},\n\t'application/x-xz': {nameTKey: 'files-type.xz', thumbnail: ZipThumbnail, viewer: null},\n\t'application/x-lzip': {nameTKey: 'files-type.lzip', thumbnail: ZipThumbnail, viewer: null},\n\t'application/x-lzma': {nameTKey: 'files-type.lzma', thumbnail: ZipThumbnail, viewer: null},\n\t'application/x-lzop': {nameTKey: 'files-type.lzop', thumbnail: ZipThumbnail, viewer: null},\n\t'application/x-compress': {nameTKey: 'files-type.compressed', thumbnail: ZipThumbnail, viewer: null},\n\t'application/x-compressed': {nameTKey: 'files-type.compressed', thumbnail: ZipThumbnail, viewer: null},\n\n\t// Documents\n\t'application/pdf': {nameTKey: 'files-type.pdf', thumbnail: PdfThumbnail, viewer: PdfViewer},\n\t'text/plain': {nameTKey: 'files-type.txt', thumbnail: TxtThumbnail, viewer: null},\n\t'text/csv': {nameTKey: 'files-type.csv', thumbnail: CsvThumbnail, viewer: null},\n\n\t// Ebooks\n\t'application/epub+zip': {nameTKey: 'files-type.epub', thumbnail: EbookThumbnail, viewer: null},\n\t'application/x-mobipocket-ebook': {nameTKey: 'files-type.mobi', thumbnail: EbookThumbnail, viewer: null},\n\n\t// Microsoft Office\n\t'application/msword': {nameTKey: 'files-type.word', thumbnail: DocxThumbnail, viewer: null},\n\t'application/vnd.openxmlformats-officedocument.wordprocessingml.document': {\n\t\tnameTKey: 'files-type.word',\n\t\tthumbnail: DocxThumbnail,\n\t\tviewer: null,\n\t},\n\t'application/vnd.ms-excel': {nameTKey: 'files-type.excel', thumbnail: CsvThumbnail, viewer: null},\n\t'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {\n\t\tnameTKey: 'files-type.excel',\n\t\tthumbnail: CsvThumbnail,\n\t\tviewer: null,\n\t},\n\t'application/vnd.ms-powerpoint': {nameTKey: 'files-type.powerpoint', thumbnail: PptThumbnail, viewer: null},\n\t'application/vnd.openxmlformats-officedocument.presentationml.presentation': {\n\t\tnameTKey: 'files-type.powerpoint',\n\t\tthumbnail: PptThumbnail,\n\t\tviewer: null,\n\t},\n\n\t// Apple iWork\n\t'application/vnd.apple.numbers': {nameTKey: 'files-type.numbers', thumbnail: CsvThumbnail, viewer: null},\n\t'application/vnd.apple.pages': {nameTKey: 'files-type.pages', thumbnail: DocxThumbnail, viewer: null},\n\t'application/vnd.apple.keynote': {nameTKey: 'files-type.keynote', thumbnail: PptThumbnail, viewer: null},\n\n\t// Images\n\t'image/svg+xml': {nameTKey: 'files-type.svg', thumbnail: ImageThumbnail, viewer: ImageViewer},\n\t'image/avif': {nameTKey: 'files-type.avif', thumbnail: ImageThumbnail, viewer: ImageViewer},\n\t'image/webp': {nameTKey: 'files-type.webp', thumbnail: ImageThumbnail, viewer: ImageViewer},\n\t'image/heic': {nameTKey: 'files-type.heic', thumbnail: ImageThumbnail, viewer: null},\n\t'image/jpeg': {nameTKey: 'files-type.jpeg', thumbnail: ImageThumbnail, viewer: ImageViewer},\n\t'image/png': {nameTKey: 'files-type.png', thumbnail: ImageThumbnail, viewer: ImageViewer},\n\t'image/gif': {nameTKey: 'files-type.gif', thumbnail: ImageThumbnail, viewer: ImageViewer},\n\t'image/bmp': {nameTKey: 'files-type.bmp', thumbnail: ImageThumbnail, viewer: ImageViewer},\n\t'image/vnd.microsoft.icon': {nameTKey: 'files-type.ico', thumbnail: ImageThumbnail, viewer: ImageViewer},\n\t'image/tiff': {nameTKey: 'files-type.tiff', thumbnail: ImageThumbnail, viewer: null},\n\n\t// Audio\n\t'audio/mpeg': {nameTKey: 'files-type.mp3', thumbnail: AudioThumbnail, viewer: AudioViewer},\n\t'audio/mp4': {nameTKey: 'files-type.mp4-audio', thumbnail: AudioThumbnail, viewer: AudioViewer},\n\t'audio/wav': {nameTKey: 'files-type.wav', thumbnail: AudioThumbnail, viewer: AudioViewer},\n\t'audio/aac': {nameTKey: 'files-type.aac', thumbnail: AudioThumbnail, viewer: AudioViewer},\n\t'audio/aacp': {nameTKey: 'files-type.aac', thumbnail: AudioThumbnail, viewer: AudioViewer},\n\t'audio/webm': {nameTKey: 'files-type.webm-audio', thumbnail: AudioThumbnail, viewer: AudioViewer},\n\t'audio/ogg': {nameTKey: 'files-type.ogg', thumbnail: AudioThumbnail, viewer: AudioViewer},\n\t'audio/x-flac': {nameTKey: 'files-type.flac', thumbnail: AudioThumbnail, viewer: AudioViewer},\n\t'audio/x-m4a': {nameTKey: 'files-type.m4a', thumbnail: AudioThumbnail, viewer: AudioViewer},\n\t'audio/x-wav': {nameTKey: 'files-type.wav', thumbnail: AudioThumbnail, viewer: AudioViewer},\n\t'audio/x-caf': {nameTKey: 'files-type.caf', thumbnail: AudioThumbnail, viewer: AudioViewer},\n\t'audio/x-aiff': {nameTKey: 'files-type.aiff', thumbnail: AudioThumbnail, viewer: null},\n\t'audio/basic': {nameTKey: 'files-type.au', thumbnail: AudioThumbnail, viewer: null},\n\t'audio/midi': {nameTKey: 'files-type.midi', thumbnail: AudioThumbnail, viewer: null},\n\t'audio/x-midi': {nameTKey: 'files-type.midi', thumbnail: AudioThumbnail, viewer: null},\n\t'audio/flac': {nameTKey: 'files-type.flac', thumbnail: AudioThumbnail, viewer: null},\n\t'audio/x-matroska': {nameTKey: 'files-type.mka', thumbnail: AudioThumbnail, viewer: null},\n\t'audio/x-mpegurl': {nameTKey: 'files-type.m3u', thumbnail: AudioThumbnail, viewer: null},\n\t'audio/x-ms-wma': {nameTKey: 'files-type.wma', thumbnail: AudioThumbnail, viewer: null},\n\n\t// Video\n\t'video/mp4': {nameTKey: 'files-type.mp4', thumbnail: VideoThumbnail, viewer: VideoViewer},\n\t'video/quicktime': {nameTKey: 'files-type.quicktime', thumbnail: VideoThumbnail, viewer: VideoViewer},\n\t'video/webm': {nameTKey: 'files-type.webm', thumbnail: VideoThumbnail, viewer: VideoViewer},\n\t'video/ogg': {nameTKey: 'files-type.ogv', thumbnail: VideoThumbnail, viewer: VideoViewer},\n\t'video/mpeg': {nameTKey: 'files-type.mpeg', thumbnail: VideoThumbnail, viewer: VideoViewer},\n\t'video/x-m4v': {nameTKey: 'files-type.m4v', thumbnail: VideoThumbnail, viewer: VideoViewer},\n\t'video/x-matroska': {nameTKey: 'files-type.mkv', thumbnail: VideoThumbnail, viewer: null},\n\t'video/3gpp': {nameTKey: 'files-type.3gp', thumbnail: VideoThumbnail, viewer: null},\n\t'video/3gpp2': {nameTKey: 'files-type.3gp2', thumbnail: VideoThumbnail, viewer: null},\n\t'video/x-flv': {nameTKey: 'files-type.flv', thumbnail: VideoThumbnail, viewer: null},\n\t'video/x-msvideo': {nameTKey: 'files-type.avi', thumbnail: VideoThumbnail, viewer: null},\n\t'video/x-ms-wmv': {nameTKey: 'files-type.wmv', thumbnail: VideoThumbnail, viewer: null},\n\t'video/x-sgi-movie': {nameTKey: 'files-type.sgi', thumbnail: VideoThumbnail, viewer: null},\n\t'video/mp2t': {nameTKey: 'files-type.ts', thumbnail: VideoThumbnail, viewer: null},\n\t'video/x-dv': {nameTKey: 'files-type.dv', thumbnail: VideoThumbnail, viewer: null},\n\t'video/vnd.dlna.mpeg-tts': {nameTKey: 'files-type.mpeg-ts', thumbnail: VideoThumbnail, viewer: null},\n\t'video/x-mng': {nameTKey: 'files-type.mng', thumbnail: VideoThumbnail, viewer: null},\n} as const\n\nexport type FileType = keyof typeof FILE_TYPE_MAP\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-drag-and-drop.ts",
    "content": "import {DragEndEvent, DragStartEvent} from '@dnd-kit/core'\n\nimport {TRASH_PATH} from '@/features/files/constants'\nimport {useFilesOperations} from '@/features/files/hooks/use-files-operations'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport type {FilesStore} from '@/features/files/store/use-files-store'\nimport {FileSystemItem} from '@/features/files/types'\n\nexport function useDragAndDrop() {\n\tconst selectedItems = useFilesStore((s: FilesStore) => s.selectedItems)\n\tconst setSelectedItems = useFilesStore((s: FilesStore) => s.setSelectedItems)\n\tconst setDraggedItems = useFilesStore((s: FilesStore) => s.setDraggedItems)\n\tconst clearDraggedItems = useFilesStore((s: FilesStore) => s.clearDraggedItems)\n\tconst {moveDraggedItems, trashDraggedItems} = useFilesOperations()\n\n\tconst handleDragStart = (event: DragStartEvent) => {\n\t\tconst draggedItem = event.active.data.current as FileSystemItem\n\t\tif (!draggedItem) return\n\n\t\t// if the item is not already selected, reset the selection with the new item\n\t\tif (!selectedItems.find((item) => item.path === draggedItem.path)) {\n\t\t\tsetSelectedItems([draggedItem])\n\t\t\tsetDraggedItems([draggedItem])\n\t\t} else {\n\t\t\t// if the item is already selected, use all selected items for dragging\n\t\t\tsetDraggedItems([...selectedItems])\n\t\t}\n\t}\n\n\tconst handleDragEnd = (event: DragEndEvent) => {\n\t\tconst {over} = event\n\t\tconst targetPath = over?.data.current?.path as string\n\t\tclearDraggedItems()\n\t\tif (!targetPath) {\n\t\t\treturn // dropped outside a valid drop target\n\t\t}\n\n\t\t// if the target is the trash, move the selected items to the trash\n\t\tif (targetPath === TRASH_PATH) {\n\t\t\ttrashDraggedItems()\n\t\t} else {\n\t\t\t// otherwise move the selected items to the target directory\n\t\t\tmoveDraggedItems({toDirectory: targetPath})\n\t\t}\n\n\t\t// no need to clear dragged items after drop\n\t\t// as the above mutations will auto-clear it\n\t}\n\n\treturn {\n\t\thandleDragStart,\n\t\thandleDragEnd,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-external-storage.ts",
    "content": "import {keepPreviousData} from '@tanstack/react-query'\nimport {useEffect} from 'react'\n\nimport {toast} from '@/components/ui/toast'\nimport {HOME_PATH} from '@/features/files/constants'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {getFilesErrorMessage} from '@/features/files/utils/error-messages'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {trpcReact} from '@/trpc/trpc'\nimport type {RouterError} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\n/**\n * Hook to manage external storage devices.\n * Provides functionality to fetch and eject external storage devices.\n * Also handles showing warning dialog for unsupported (Raspberry Pi) devices.\n */\nexport function useExternalStorage() {\n\tconst utils = trpcReact.useUtils()\n\tconst {add} = useQueryParams()\n\n\t// Check device information to determine if external storage is supported (currently not supported on Raspberry Pi)\n\tconst {data: deviceInfo} = trpcReact.system.device.useQuery()\n\n\tconst isExternalStorageSupported = deviceInfo?.productName !== 'Raspberry Pi'\n\n\t// Subscribe to files:external-storage:change events that fire when devices are mounted/unmounted\n\t// and invalidate the external storage queries\n\ttrpcReact.eventBus.listen.useSubscription(\n\t\t{event: 'files:external-storage:change'},\n\t\t{\n\t\t\tonData() {\n\t\t\t\tutils.files.externalDevices.invalidate()\n\t\t\t\tutils.files.isExternalDeviceConnectedOnUnsupportedDevice.invalidate()\n\t\t\t},\n\t\t\tonError(err) {\n\t\t\t\tconsole.error('eventBus.listen(files:external-storage:change) subscription error', err)\n\t\t\t},\n\t\t},\n\t)\n\n\t// Query for external storage\n\tconst {data: disks, isLoading: isLoadingDisks} = trpcReact.files.externalDevices.useQuery(undefined, {\n\t\tplaceholderData: keepPreviousData,\n\t\trefetchInterval: isExternalStorageSupported ? 5000 : false, // Poll every 5 seconds because files:external-storage:change doesn't fire if a device is removed but all current devices have all their partitions mounted\n\t\tstaleTime: 0, // Don't cache the data\n\t\tenabled: isExternalStorageSupported, // Only run query on supported devices\n\t})\n\n\t// Query to check for external drives on non-supported devices\n\tconst {data: hasExternalDriveOnUnsupportedDevice} =\n\t\ttrpcReact.files.isExternalDeviceConnectedOnUnsupportedDevice.useQuery(undefined, {\n\t\t\tplaceholderData: keepPreviousData,\n\t\t\trefetchInterval: !isExternalStorageSupported ? 5000 : false, // Poll every 5 seconds because files:external-storage:change doesn't fire if a device is removed but all current devices have all their partitions mounted\n\t\t\tstaleTime: 0,\n\t\t\tenabled: !isExternalStorageSupported, // Only run query on unsupported devices\n\t\t})\n\n\tconst {currentPath, navigateToDirectory} = useNavigate()\n\n\t// Show dialog when external drive detected on unsupported devices\n\tuseEffect(() => {\n\t\tif (hasExternalDriveOnUnsupportedDevice) {\n\t\t\t// Check if dialog has already been shown in this session\n\t\t\tconst dialogShown = sessionStorage.getItem('files-external-storage-unsupported-dialog-shown')\n\n\t\t\tif (!dialogShown) {\n\t\t\t\tadd('dialog', 'files-external-storage-unsupported')\n\t\t\t\t// Mark dialog as shown for this session\n\t\t\t\tsessionStorage.setItem('files-external-storage-unsupported-dialog-shown', 'true')\n\t\t\t}\n\t\t}\n\t}, [hasExternalDriveOnUnsupportedDevice, add])\n\n\t// Eject disk mutation\n\tconst {mutateAsync: ejectDisk, isPending: isEjecting} = trpcReact.files.unmountExternalDevice.useMutation({\n\t\tonMutate: (id) => {\n\t\t\t// snapshot the ejected disk\n\t\t\treturn {\n\t\t\t\tejectedDisk: disks?.find((disk) => disk.id === id.deviceId),\n\t\t\t}\n\t\t},\n\t\tonSuccess: (_, id, context) => {\n\t\t\t// redirect to home path on ejection if the current path is in the ejected disk\n\t\t\tconst ejectedDisk = context?.ejectedDisk\n\t\t\tif (\n\t\t\t\tejectedDisk &&\n\t\t\t\tejectedDisk.partitions.some((partition) =>\n\t\t\t\t\t// mountpoints is an array of mountpoints for the partition\n\t\t\t\t\tpartition.mountpoints.some((mountpoint) => currentPath.startsWith(mountpoint)),\n\t\t\t\t)\n\t\t\t) {\n\t\t\t\tnavigateToDirectory(HOME_PATH)\n\t\t\t}\n\t\t},\n\t\tonError: (error: RouterError) => {\n\t\t\ttoast.error(t('files-error.eject-disk', {message: getFilesErrorMessage(error.message)}))\n\t\t},\n\t\tonSettled: () => {\n\t\t\tutils.files.externalDevices.invalidate()\n\t\t},\n\t})\n\n\t// Format disk mutation\n\tconst {mutateAsync: formatExternalStorageDevice, isPending: isFormatting} =\n\t\ttrpcReact.files.formatExternalDevice.useMutation({\n\t\t\tonError: (error: RouterError) => {\n\t\t\t\ttoast.error(error.message || t('files-format.error'))\n\t\t\t},\n\t\t\tonSettled: () => {\n\t\t\t\tutils.files.externalDevices.invalidate()\n\t\t\t},\n\t\t})\n\n\treturn {\n\t\tdisks,\n\t\tisLoadingExternalStorage: isLoadingDisks,\n\t\tejectDisk,\n\t\tisEjecting,\n\t\tformatExternalStorageDevice,\n\t\tisFormatting,\n\t\tisExternalStorageSupported,\n\t\thasExternalDriveOnUnsupportedDevice,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-favorites.ts",
    "content": "import {keepPreviousData} from '@tanstack/react-query'\n\nimport {toast} from '@/components/ui/toast'\nimport {getFilesErrorMessage} from '@/features/files/utils/error-messages'\nimport {trpcReact} from '@/trpc/trpc'\nimport type {RouterError} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\n/**\n * Hook to manage favorites in the file system.\n * Provides functionality to fetch favorites, add/remove favorites, and check if an item is favorited.\n */\nexport function useFavorites() {\n\tconst utils = trpcReact.useUtils()\n\n\t// Query to fetch favorites (an array of virtual path strings)\n\tconst {data: favorites, isLoading: isLoadingFavorites} = trpcReact.files.favorites.useQuery(undefined, {\n\t\tplaceholderData: keepPreviousData,\n\t\tstaleTime: 15_000,\n\t})\n\n\t// Check if item is favorited\n\tconst isPathFavorite = (path: string) => favorites?.some((favorite) => favorite && favorite === path)\n\n\t// Add favorite mutation\n\tconst {mutateAsync: addFavorite, isPending: isAddingFavorite} = trpcReact.files.addFavorite.useMutation({\n\t\tonSuccess: async () => {\n\t\t\tawait utils.files.favorites.invalidate()\n\t\t},\n\t\tonError: (error: RouterError) => {\n\t\t\ttoast.error(t('files-error.add-favorite', {message: getFilesErrorMessage(error.message)}))\n\t\t},\n\t})\n\n\t// Remove favorite mutation\n\tconst {mutateAsync: removeFavorite, isPending: isRemovingFavorite} = trpcReact.files.removeFavorite.useMutation({\n\t\tonSuccess: async () => {\n\t\t\tawait utils.files.favorites.invalidate()\n\t\t},\n\t\tonError: (error: RouterError) => {\n\t\t\ttoast.error(t('files-error.remove-favorite', {message: getFilesErrorMessage(error.message)}))\n\t\t},\n\t})\n\n\treturn {\n\t\t// Queries\n\t\tfavorites,\n\t\tisLoadingFavorites,\n\t\tisPathFavorite,\n\n\t\t// Add favorite\n\t\taddFavorite,\n\t\tisAddingFavorite,\n\n\t\t// Remove favorite\n\t\tremoveFavorite,\n\t\tisRemovingFavorite,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-files-keyboard-shortcuts.ts",
    "content": "import {useEffect, useRef} from 'react'\n\nimport {FILE_TYPE_MAP} from '@/features/files/constants'\nimport {useFilesOperations} from '@/features/files/hooks/use-files-operations'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport type {FilesStore} from '@/features/files/store/use-files-store'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {getGridColumnCount} from '@/features/files/utils/get-grid-column-count'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\n\n/**\n * Hook to handle keyboard shortcuts for file operations: copy, cut, paste, trash,\n * and arrow key navigation through file items.\n * We use both command and ctrl for every shortcut to mimic the behaviour of both macOS and windows.\n * Uses a single useEffect listener instead of react-use's useKey for React Compiler compatibility.\n */\nexport function useFilesKeyboardShortcuts({\n\titems,\n\tscrollAreaRef,\n\tview,\n}: {\n\titems: FileSystemItem[]\n\tscrollAreaRef: React.RefObject<HTMLDivElement | null>\n\tview: 'list' | 'icons'\n}) {\n\tconst isReadOnly = useIsFilesReadOnly()\n\t// In read-only mode, disable write/selection shortcuts but allow viewer and navigation shortcuts.\n\tconst shortcutsEnabled = !isReadOnly\n\tconst {currentPath} = useNavigate()\n\tconst copyItemsToClipboard = useFilesStore((s: FilesStore) => s.copyItemsToClipboard)\n\tconst cutItemsToClipboard = useFilesStore((s: FilesStore) => s.cutItemsToClipboard)\n\tconst setSelectedItems = useFilesStore((s: FilesStore) => s.setSelectedItems)\n\tconst selectedItems = useFilesStore((s: FilesStore) => s.selectedItems)\n\tconst viewerItem = useFilesStore((s: FilesStore) => s.viewerItem)\n\tconst setViewerItem = useFilesStore((s: FilesStore) => s.setViewerItem)\n\tconst {pasteItemsFromClipboard, trashSelectedItems} = useFilesOperations()\n\tconst isMobile = useIsMobile()\n\n\t// Search functionality\n\tconst searchBuffer = useRef('')\n\tconst searchTimer = useRef<NodeJS.Timeout | undefined>(undefined)\n\n\t// Use refs for values that change frequently so the useEffect doesn't need to re-register\n\tconst selectedItemsRef = useRef(selectedItems)\n\tselectedItemsRef.current = selectedItems\n\tconst viewerItemRef = useRef(viewerItem)\n\tviewerItemRef.current = viewerItem\n\tconst viewRef = useRef(view)\n\tviewRef.current = view\n\n\t// Track the anchor index for Shift+Arrow range selection\n\tconst selectionAnchorRef = useRef<number>(-1)\n\n\tuseEffect(() => {\n\t\t// Guard to check if we're in a text input or contentEditable element\n\t\tconst isInInput = (e: KeyboardEvent): boolean => {\n\t\t\tconst target = e.target as HTMLElement\n\t\t\treturn target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable\n\t\t}\n\n\t\tconst handleKeyDown = (e: KeyboardEvent) => {\n\t\t\tconst mod = e.metaKey || e.ctrlKey\n\n\t\t\t// Modifier shortcuts (copy, cut, paste, trash, select all)\n\t\t\tif (mod && shortcutsEnabled) {\n\t\t\t\tif (isInInput(e)) return\n\n\t\t\t\tif (e.key === 'c') {\n\t\t\t\t\te.preventDefault()\n\t\t\t\t\tcopyItemsToClipboard()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (e.key === 'x') {\n\t\t\t\t\te.preventDefault()\n\t\t\t\t\tcutItemsToClipboard()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (e.key === 'v') {\n\t\t\t\t\t// If Rewind is open, ignore paste to prevent collision dialogs\n\t\t\t\t\tif (document.querySelector('[data-rewind=\"open\"]')) return\n\t\t\t\t\te.preventDefault()\n\t\t\t\t\tpasteItemsFromClipboard({toDirectory: currentPath})\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (e.key === 'Backspace') {\n\t\t\t\t\te.preventDefault()\n\t\t\t\t\ttrashSelectedItems()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (e.key === 'a') {\n\t\t\t\t\te.preventDefault()\n\t\t\t\t\tsetSelectedItems(items)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Space bar to view selected item (allowed even in read-only)\n\t\t\tif (e.key === ' ') {\n\t\t\t\tif (\n\t\t\t\t\tisInInput(e) ||\n\t\t\t\t\tmod ||\n\t\t\t\t\te.altKey ||\n\t\t\t\t\tsearchBuffer.current.length > 0 ||\n\t\t\t\t\tselectedItemsRef.current.length !== 1 ||\n\t\t\t\t\tviewerItemRef.current !== null\n\t\t\t\t)\n\t\t\t\t\treturn\n\t\t\t\te.preventDefault()\n\t\t\t\tconst item = selectedItemsRef.current[0]\n\t\t\t\tconst fileType = FILE_TYPE_MAP[item.type as keyof typeof FILE_TYPE_MAP]\n\t\t\t\tif (fileType && fileType.viewer) {\n\t\t\t\t\tsetViewerItem(item)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Arrow key navigation (allowed even in read-only)\n\t\t\tif (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {\n\t\t\t\tif (isInInput(e) || mod || e.altKey || viewerItemRef.current !== null || items.length === 0) return\n\t\t\t\te.preventDefault()\n\n\t\t\t\tconst currentView = viewRef.current\n\t\t\t\tconst selected = selectedItemsRef.current\n\n\t\t\t\t// Find the current index — use the last selected item as the reference point\n\t\t\t\tlet currentIndex = -1\n\t\t\t\tif (selected.length > 0) {\n\t\t\t\t\tconst lastSelected = selected[selected.length - 1]\n\t\t\t\t\tcurrentIndex = items.findIndex((i) => i.path === lastSelected.path)\n\t\t\t\t}\n\n\t\t\t\t// If nothing is selected or the selected item was removed, select the first item\n\t\t\t\tif (currentIndex === -1) {\n\t\t\t\t\tsetSelectedItems([items[0]])\n\t\t\t\t\tselectionAnchorRef.current = 0\n\t\t\t\t\tscrollItemIntoView(0)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// Calculate the step based on view and direction\n\t\t\t\tlet step = 0\n\t\t\t\tif (currentView === 'list') {\n\t\t\t\t\t// List view: all arrows move by 1\n\t\t\t\t\tif (e.key === 'ArrowUp' || e.key === 'ArrowLeft') step = -1\n\t\t\t\t\telse step = 1\n\t\t\t\t} else {\n\t\t\t\t\t// Icons view: Left/Right move by 1, Up/Down move by column count\n\t\t\t\t\tif (e.key === 'ArrowLeft') step = -1\n\t\t\t\t\telse if (e.key === 'ArrowRight') step = 1\n\t\t\t\t\telse {\n\t\t\t\t\t\tconst scrollEl = scrollAreaRef.current\n\t\t\t\t\t\tconst columnCount = scrollEl ? getGridColumnCount(scrollEl.clientWidth - 24) : 1\n\t\t\t\t\t\tif (e.key === 'ArrowUp') step = -columnCount\n\t\t\t\t\t\telse step = columnCount\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Clamp target to valid range\n\t\t\t\tconst targetIndex = Math.max(0, Math.min(items.length - 1, currentIndex + step))\n\n\t\t\t\t// If we're already at the boundary and can't move, do nothing\n\t\t\t\tif (targetIndex === currentIndex) return\n\n\t\t\t\tif (e.shiftKey) {\n\t\t\t\t\t// Shift+Arrow: extend selection as a contiguous range from anchor to target\n\t\t\t\t\t// Set anchor on first shift-select if not already set\n\t\t\t\t\tif (selectionAnchorRef.current === -1) {\n\t\t\t\t\t\tselectionAnchorRef.current = currentIndex\n\t\t\t\t\t}\n\t\t\t\t\tconst anchor = selectionAnchorRef.current\n\t\t\t\t\tconst start = Math.min(anchor, targetIndex)\n\t\t\t\t\tconst end = Math.max(anchor, targetIndex)\n\t\t\t\t\tsetSelectedItems(items.slice(start, end + 1))\n\t\t\t\t} else {\n\t\t\t\t\t// Regular arrow: select only the target item and reset anchor\n\t\t\t\t\tsetSelectedItems([items[targetIndex]])\n\t\t\t\t\tselectionAnchorRef.current = targetIndex\n\t\t\t\t}\n\n\t\t\t\tscrollItemIntoView(targetIndex)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Search functionality\n\t\t\tif (!shortcutsEnabled) return\n\t\t\tif (isInInput(e) || mod || e.altKey) return\n\t\t\tif (e.key === ' ' && searchBuffer.current.length === 0) return\n\n\t\t\t// \"/\" is handled by SearchInput for focus\n\t\t\tif (e.key === '/') return\n\n\t\t\tif (e.key.length === 1) {\n\t\t\t\te.preventDefault()\n\t\t\t\tsearchBuffer.current += e.key.toLowerCase()\n\n\t\t\t\tif (searchTimer.current) {\n\t\t\t\t\tclearTimeout(searchTimer.current)\n\t\t\t\t}\n\t\t\t\tsearchTimer.current = setTimeout(() => {\n\t\t\t\t\tsearchBuffer.current = ''\n\t\t\t\t}, 700)\n\n\t\t\t\tconst matchingItem = items.find((item) => item.name.toLowerCase().startsWith(searchBuffer.current))\n\t\t\t\tif (matchingItem) {\n\t\t\t\t\tsetSelectedItems([matchingItem])\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t/** Scroll the item at `index` into view if it's outside the visible area */\n\t\tfunction scrollItemIntoView(index: number) {\n\t\t\tconst scrollEl = scrollAreaRef.current\n\t\t\tif (!scrollEl) return\n\n\t\t\tconst currentView = viewRef.current\n\t\t\tlet itemTop: number\n\t\t\tlet itemBottom: number\n\n\t\t\tif (currentView === 'list') {\n\t\t\t\tconst itemHeight = isMobile ? 50 : 40\n\t\t\t\titemTop = index * itemHeight\n\t\t\t\titemBottom = itemTop + itemHeight\n\t\t\t} else {\n\t\t\t\tconst columnCount = getGridColumnCount(scrollEl.clientWidth - 24)\n\t\t\t\tconst row = Math.floor(index / columnCount)\n\t\t\t\tconst rowHeight = 144 // 120px item + 24px gap\n\t\t\t\titemTop = row * rowHeight\n\t\t\t\titemBottom = itemTop + rowHeight\n\t\t\t}\n\n\t\t\tconst {scrollTop, clientHeight} = scrollEl\n\n\t\t\tif (itemTop < scrollTop) {\n\t\t\t\tscrollEl.scrollTop = itemTop\n\t\t\t} else if (itemBottom > scrollTop + clientHeight) {\n\t\t\t\tscrollEl.scrollTop = itemBottom - clientHeight\n\t\t\t}\n\t\t}\n\n\t\twindow.addEventListener('keydown', handleKeyDown)\n\t\treturn () => window.removeEventListener('keydown', handleKeyDown)\n\t}, [\n\t\tshortcutsEnabled,\n\t\tcurrentPath,\n\t\titems,\n\t\tisMobile,\n\t\tcopyItemsToClipboard,\n\t\tcutItemsToClipboard,\n\t\tsetSelectedItems,\n\t\tsetViewerItem,\n\t\tpasteItemsFromClipboard,\n\t\ttrashSelectedItems,\n\t\tscrollAreaRef,\n\t])\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-files-operations.ts",
    "content": "import {AiOutlineFileExclamation} from 'react-icons/ai'\n\nimport {toast} from '@/components/ui/toast'\nimport {TRASH_PATH} from '@/features/files/constants'\nimport {useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {getFilesErrorMessage} from '@/features/files/utils/error-messages'\nimport {splitFileName} from '@/features/files/utils/format-filesystem-name'\nimport {useConfirmation} from '@/providers/confirmation'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\n// Define a type for the operation function signature used by the helper\ntype OperationAsyncFn<TArgs extends object, TResult = any> = (args: TArgs) => Promise<TResult>\n// Define a type for the function that generates arguments for the operation\ntype GetOperationArgsFn<TArgs extends object> = (path: string) => TArgs\n// Define a type for the error toaster function\ntype ErrorToastFn = (message: string) => void\n\nexport function useFilesOperations() {\n\t// if read-only, we return the operations without doing anything\n\tconst isReadOnly = useIsFilesReadOnly()\n\tconst utils = trpcReact.useUtils()\n\tconst confirm = useConfirmation()\n\n\tconst clipboardMode = useFilesStore((s) => s.clipboardMode)\n\tconst clipboardItems = useFilesStore((s) => s.clipboardItems)\n\tconst clearClipboard = useFilesStore((s) => s.clearClipboard)\n\tconst selectedItems = useFilesStore((s) => s.selectedItems)\n\tconst draggedItems = useFilesStore((s) => s.draggedItems)\n\tconst setSelectedItems = useFilesStore((s) => s.setSelectedItems)\n\tconst clearDraggedItems = useFilesStore((s) => s.clearDraggedItems)\n\n\t// Internal helper for batch operations (move, copy, restore) with collision handling\n\t// ----------------------------------------------------------\n\tconst _executeBatchOperationWithCollisionHandling = async <TArgs extends object>({\n\t\tpaths,\n\t\toperationAsyncFn,\n\t\toperationType,\n\t\tgetOperationArgsFn,\n\t\ttargetDirectory,\n\t\tonErrorToastFn,\n\t\tonSuccessAll,\n\t}: {\n\t\tpaths: string[]\n\t\toperationAsyncFn: OperationAsyncFn<TArgs>\n\t\toperationType: 'move' | 'copy' | 'restore'\n\t\tgetOperationArgsFn: GetOperationArgsFn<TArgs>\n\t\ttargetDirectory?: string\n\t\tonErrorToastFn: ErrorToastFn\n\t\tonSuccessAll?: () => void\n\t}) => {\n\t\t// track if any operation ended with an unrecoverable error so we can avoid\n\t\t// firing the global success callback in that case.\n\t\tlet encounteredError = false\n\n\t\tlet globalCollisionDecision: 'replace' | 'keep-both' | 'skip' | null = null\n\t\tlet applyDecisionToAllRemaining = false\n\n\t\t// a simple FIFO queue so that only one confirmation dialog is shown at a\n\t\t// time even though multiple collisions may be detected concurrently.\n\t\tconst collisionQueue: {\n\t\t\tpath: string\n\t\t\tresolve: (decision: 'replace' | 'keep-both' | 'skip') => void\n\t\t}[] = []\n\t\tlet processingQueue = false\n\n\t\tconst processNextInQueue = async () => {\n\t\t\tif (processingQueue || collisionQueue.length === 0) return\n\t\t\tprocessingQueue = true\n\n\t\t\tconst {path, resolve} = collisionQueue.shift()!\n\n\t\t\t// if the user has already chosen to apply a decision to all remaining\n\t\t\t// collisions, we can resolve immediately without prompting.\n\t\t\tif (applyDecisionToAllRemaining && globalCollisionDecision) {\n\t\t\t\tresolve(globalCollisionDecision)\n\t\t\t\tprocessingQueue = false\n\t\t\t\t// process any queued items synchronously with the same decision\n\t\t\t\tprocessNextInQueue()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst fromName = splitFileName(path.split('/').pop() || '').name\n\t\t\tconst destinationName =\n\t\t\t\toperationType === 'restore'\n\t\t\t\t\t? t('files-collision.destination.original-location')\n\t\t\t\t\t: `\"${splitFileName(targetDirectory?.split('/').pop() || '').name}\"`\n\n\t\t\tlet decision: 'replace' | 'keep-both' | 'skip' = 'skip'\n\t\t\ttry {\n\t\t\t\tconst result = await confirm({\n\t\t\t\t\ttitle: t('files-collision.title', {\n\t\t\t\t\t\titemName: fromName,\n\t\t\t\t\t\tdestinationName,\n\t\t\t\t\t}),\n\t\t\t\t\tmessage: t('files-collision.message'),\n\t\t\t\t\tactions: [\n\t\t\t\t\t\t{label: t('files-collision.action.keep-both'), value: 'keep-both', variant: 'primary'},\n\t\t\t\t\t\t{label: t('files-collision.action.replace'), value: 'replace', variant: 'default'},\n\t\t\t\t\t\t{label: t('files-collision.action.skip'), value: 'skip', variant: 'default'},\n\t\t\t\t\t],\n\t\t\t\t\tshowApplyToAll: collisionQueue.length > 0, // i.e. when more collisions waiting\n\t\t\t\t\ticon: AiOutlineFileExclamation,\n\t\t\t\t})\n\t\t\t\tdecision = result.actionValue as 'replace' | 'keep-both' | 'skip'\n\t\t\t\tif (result.applyToAll) {\n\t\t\t\t\tapplyDecisionToAllRemaining = true\n\t\t\t\t\tglobalCollisionDecision = decision\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// dialog dismissed, default to skipping the file.\n\t\t\t\tdecision = 'skip'\n\t\t\t}\n\n\t\t\t// resolve the promise for the item currently being processed.\n\t\t\tresolve(decision)\n\t\t\tprocessingQueue = false\n\t\t\t// process subsequent queued collisions (if any).\n\t\t\tprocessNextInQueue()\n\t\t}\n\n\t\t// helper to enqueue a collision resolution and wait for the user's choice.\n\t\tconst getCollisionDecision = (path: string) =>\n\t\t\tnew Promise<'replace' | 'keep-both' | 'skip'>((resolve) => {\n\t\t\t\t// if a global decision has been set, we honour it immediately.\n\t\t\t\tif (applyDecisionToAllRemaining && globalCollisionDecision) {\n\t\t\t\t\tresolve(globalCollisionDecision)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tcollisionQueue.push({path, resolve})\n\t\t\t\tprocessNextInQueue()\n\t\t\t})\n\n\t\t// individual path handler\n\t\tconst tasks = paths.map(async (path) => {\n\t\t\tconst baseArgs = getOperationArgsFn(path) as Record<string, unknown>\n\t\t\ttry {\n\t\t\t\tawait operationAsyncFn({...baseArgs, collision: undefined} as any)\n\t\t\t\treturn\n\t\t\t} catch (error: any) {\n\t\t\t\t// handle collision errors specially, everything else is a hard error.\n\t\t\t\tif (error?.message === '[destination-already-exists]') {\n\t\t\t\t\tconst decision = await getCollisionDecision(path)\n\t\t\t\t\tif (decision === 'skip') {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait operationAsyncFn({...baseArgs, collision: decision} as any)\n\t\t\t\t\t} catch (err: any) {\n\t\t\t\t\t\tencounteredError = true\n\t\t\t\t\t\tonErrorToastFn(err.message)\n\t\t\t\t\t\tconsole.error(`Failed ${operationType} ${path} after collision (${decision}):`, err)\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// unrecoverable error\n\t\t\t\tencounteredError = true\n\t\t\t\tonErrorToastFn(error.message)\n\t\t\t\tconsole.error(`Failed ${operationType} ${path}:`, error)\n\t\t\t}\n\t\t})\n\n\t\tawait Promise.allSettled(tasks)\n\n\t\tif (!encounteredError) {\n\t\t\tonSuccessAll?.()\n\t\t}\n\t}\n\n\t// Basic file operations\n\t// --------------------\n\n\t// Rename item\n\tconst renameItemMutation = trpcReact.files.rename.useMutation({\n\t\tonError: (error) => {\n\t\t\ttoast.error(t('files-error.rename', {message: getFilesErrorMessage(error.message)}))\n\t\t},\n\t\tonSettled: () => {\n\t\t\tutils.files.list.invalidate()\n\t\t\tutils.files.recents.invalidate()\n\t\t\tutils.files.favorites.invalidate()\n\t\t\tutils.files.shares.invalidate()\n\t\t\tutils.files.search.invalidate()\n\t\t},\n\t}).mutateAsync\n\n\tconst renameItem = async ({item, newName}: {item: FileSystemItem; newName: string}) => {\n\t\tif (isReadOnly) return\n\t\tconst currentName = item.path.split('/').pop() || ''\n\n\t\t// do nothing if the name hasn't changed\n\t\tif (currentName === newName) {\n\t\t\treturn\n\t\t}\n\n\t\t// Wait for the rename mutation to complete and trigger list invalidation.\n\t\tawait renameItemMutation({path: item.path, newName})\n\n\t\t// After a successful rename, update the selection so the *new* item remains selected.\n\t\tconst renamedPath = `${item.path.substring(0, item.path.lastIndexOf('/') + 1)}${newName}`\n\n\t\tsetSelectedItems([{...item, name: newName, path: renamedPath}])\n\t}\n\n\t// File movement operations\n\t// -----------------------\n\n\t// Move item mutation hook\n\tconst moveItemMutation = trpcReact.files.move.useMutation({\n\t\tonSettled: () => {\n\t\t\tutils.files.list.invalidate()\n\t\t\tutils.files.recents.invalidate()\n\t\t\tutils.files.favorites.invalidate()\n\t\t\tutils.files.shares.invalidate()\n\t\t\tutils.files.search.invalidate()\n\t\t},\n\t}).mutateAsync\n\n\tconst moveItems = async ({fromPaths, toDirectory}: {fromPaths: FileSystemItem['path'][]; toDirectory: string}) => {\n\t\tif (isReadOnly) return\n\t\tawait _executeBatchOperationWithCollisionHandling({\n\t\t\tpaths: fromPaths,\n\t\t\toperationAsyncFn: moveItemMutation,\n\t\t\toperationType: 'move',\n\t\t\tgetOperationArgsFn: (path) => ({path, toDirectory}),\n\t\t\ttargetDirectory: toDirectory,\n\t\t\tonErrorToastFn: (message) => toast.error(t('files-error.move', {message: getFilesErrorMessage(message)})),\n\t\t\tonSuccessAll: () => {},\n\t\t})\n\t}\n\n\tconst moveSelectedItems = async ({toDirectory}: {toDirectory: string}) => {\n\t\tif (isReadOnly) return\n\t\tawait moveItems({fromPaths: selectedItems.map((item) => item.path), toDirectory})\n\t\tsetSelectedItems([])\n\t}\n\n\tconst moveDraggedItems = async ({toDirectory}: {toDirectory: string}) => {\n\t\tif (isReadOnly) return\n\t\tawait moveItems({fromPaths: draggedItems.map((item) => item.path), toDirectory})\n\t\tclearDraggedItems()\n\t}\n\n\t// Copy item mutation hook\n\tconst copyItemMutation = trpcReact.files.copy.useMutation({\n\t\tonSettled: () => {\n\t\t\tutils.files.list.invalidate()\n\t\t\tutils.files.recents.invalidate()\n\t\t\tutils.files.search.invalidate()\n\t\t},\n\t}).mutateAsync\n\n\tconst copyItems = async ({fromPaths, toDirectory}: {fromPaths: FileSystemItem['path'][]; toDirectory: string}) => {\n\t\tif (isReadOnly) return\n\t\tawait _executeBatchOperationWithCollisionHandling({\n\t\t\tpaths: fromPaths,\n\t\t\toperationAsyncFn: copyItemMutation,\n\t\t\toperationType: 'copy',\n\t\t\tgetOperationArgsFn: (path) => ({path, toDirectory}),\n\t\t\ttargetDirectory: toDirectory,\n\t\t\tonErrorToastFn: (message) => toast.error(t('files-error.copy', {message: getFilesErrorMessage(message)})),\n\t\t\tonSuccessAll: () => {},\n\t\t})\n\t}\n\n\t// Paste (copy or move) items from clipboard\n\tconst pasteItemsFromClipboard = async ({toDirectory}: {toDirectory: string}) => {\n\t\tif (isReadOnly) return\n\t\tconst paths = clipboardItems.map((item) => item.path)\n\t\tif (clipboardMode === 'copy') {\n\t\t\tawait copyItems({fromPaths: paths, toDirectory})\n\t\t} else if (clipboardMode === 'cut') {\n\t\t\tawait moveItems({fromPaths: paths, toDirectory})\n\t\t\t// only clear the clipboard on move, not copy\n\t\t\tclearClipboard()\n\t\t}\n\t}\n\n\t// Compression operations\n\t// ---------------------\n\n\t// Extract archive (umbreld always extracts archive contents into a new folder named after the archive)\n\tconst extract = trpcReact.files.unarchive.useMutation({\n\t\tonSuccess: () => {\n\t\t\tutils.files.list.invalidate()\n\t\t\tutils.files.search.invalidate()\n\t\t},\n\t\tonError: (error) => {\n\t\t\ttoast.error(t('files-error.extract', {message: getFilesErrorMessage(error.message)}))\n\t\t},\n\t}).mutateAsync\n\n\tconst extractSelectedItems = () => {\n\t\tif (isReadOnly) return\n\t\tfor (const item of selectedItems) {\n\t\t\textract({path: item.path})\n\t\t}\n\t\tsetSelectedItems([])\n\t}\n\n\t// Archive\n\tconst archive = trpcReact.files.archive.useMutation({\n\t\tonSuccess: (_, {paths}) => {\n\t\t\t// invalidate the parent directory of the item\n\t\t\tutils.files.list.invalidate({path: paths[0].split('/').slice(0, -1).join('/')})\n\t\t\t// invalidate the recents list\n\t\t\tutils.files.recents.invalidate()\n\t\t\t// invalidate the search list\n\t\t\tutils.files.search.invalidate()\n\t\t},\n\t\tonError: (error) => {\n\t\t\ttoast.error(t('files-error.compress', {message: getFilesErrorMessage(error.message)}))\n\t\t},\n\t}).mutateAsync\n\n\tconst archiveSelectedItems = () => {\n\t\tif (isReadOnly) return\n\t\tconst paths = selectedItems.map((item) => item.path)\n\t\tarchive({paths})\n\t\tsetSelectedItems([])\n\t}\n\n\t// Trash operations\n\t// ---------------\n\n\t// Trash item\n\tconst trashItem = trpcReact.files.trash.useMutation({\n\t\tonMutate: async ({path}) => {\n\t\t\tif (path.startsWith(TRASH_PATH)) {\n\t\t\t\tthrow new Error('ALREADY_IN_TRASH')\n\t\t\t}\n\t\t},\n\t\tonError: (error) => {\n\t\t\tif (error.message !== 'ALREADY_IN_TRASH') {\n\t\t\t\ttoast.error(t('files-error.trash', {message: getFilesErrorMessage(error.message)}))\n\t\t\t}\n\t\t},\n\t\tonSettled: () => {\n\t\t\tutils.files.list.invalidate()\n\t\t\tutils.files.recents.invalidate()\n\t\t\tutils.files.favorites.invalidate()\n\t\t\tutils.files.shares.invalidate()\n\t\t\tutils.files.search.invalidate()\n\t\t},\n\t}).mutateAsync\n\n\tconst trashSelectedItems = () => {\n\t\tif (isReadOnly) return\n\t\tfor (const item of selectedItems) {\n\t\t\ttrashItem({path: item.path})\n\t\t}\n\t\tsetSelectedItems([])\n\t}\n\n\tconst trashDraggedItems = () => {\n\t\tif (isReadOnly) return\n\t\tfor (const item of draggedItems) {\n\t\t\ttrashItem({path: item.path})\n\t\t}\n\t\tclearDraggedItems()\n\t}\n\n\t// Restore from trash\n\tconst restoreFromTrash = trpcReact.files.restore.useMutation({\n\t\tonSettled: () => {\n\t\t\tutils.files.list.invalidate()\n\t\t\tutils.files.recents.invalidate()\n\t\t\tutils.files.search.invalidate()\n\t\t},\n\t}).mutateAsync\n\n\t// Updated batch restore function\n\tconst restoreItems = async ({paths}: {paths: string[]}) => {\n\t\tif (isReadOnly) return\n\t\tawait _executeBatchOperationWithCollisionHandling({\n\t\t\tpaths,\n\t\t\toperationAsyncFn: restoreFromTrash,\n\t\t\toperationType: 'restore',\n\t\t\tgetOperationArgsFn: (path) => ({path}),\n\t\t\tonErrorToastFn: (message) => toast.error(t('files-error.restore', {message: getFilesErrorMessage(message)})),\n\t\t\tonSuccessAll: () => {},\n\t\t})\n\t}\n\n\tconst restoreSelectedItems = () => {\n\t\tif (isReadOnly) return\n\t\trestoreItems({paths: selectedItems.map((item) => item.path)})\n\t\tsetSelectedItems([])\n\t}\n\n\t// (Permanently) delete item\n\t// This is only possible in /Trash, /External, and /Network\n\tconst deleteItem = trpcReact.files.delete.useMutation({\n\t\tonSuccess: (_data, {path}) => {\n\t\t\t// If we're permanently deleting from Trash, we can just invalidate the Trash listing.\n\t\t\tif (path.startsWith(TRASH_PATH)) {\n\t\t\t\tutils.files.list.invalidate({path: TRASH_PATH})\n\t\t\t} else {\n\t\t\t\t// Otherwise invalidate the generic list\n\t\t\t\tutils.files.list.invalidate()\n\t\t\t\t// And invalidate favorites since they can include External/Network items\n\t\t\t\tutils.files.favorites.invalidate()\n\t\t\t}\n\t\t},\n\t\tonError: (error) => {\n\t\t\ttoast.error(t('files-error.delete', {message: getFilesErrorMessage(error.message)}))\n\t\t},\n\t}).mutateAsync\n\n\tconst deleteSelectedItems = () => {\n\t\tif (isReadOnly) return\n\t\tfor (const item of selectedItems) {\n\t\t\tdeleteItem({path: item.path})\n\t\t}\n\t\tsetSelectedItems([])\n\t}\n\n\t// Empty trash\n\tconst emptyTrash = trpcReact.files.emptyTrash.useMutation({\n\t\tonSuccess: () => {\n\t\t\t// invalidate the trash directory\n\t\t\tutils.files.list.invalidate({path: TRASH_PATH})\n\t\t},\n\t\tonError: (error) => {\n\t\t\ttoast.error(t('files-error.empty-trash', {message: getFilesErrorMessage(error.message)}))\n\t\t},\n\t}).mutateAsync\n\n\t// Download operations\n\t// ------------------\n\n\t// Download selected items\n\tconst downloadSelectedItems = () => {\n\t\t// For multiple items, construct URL with multiple path parameters\n\t\tconst paths = selectedItems.map((item) => `path=${encodeURIComponent(item.path)}`).join('&')\n\t\tconst url = `/api/files/download?${paths}`\n\t\t// create a temporary anchor element to download the files\n\t\tconst anchor = document.createElement('a')\n\t\tanchor.href = url\n\t\tanchor.setAttribute('download', '')\n\t\t// add the anchor to the body\n\t\tdocument.body.appendChild(anchor)\n\t\t// click the anchor to download the files\n\t\tanchor.click()\n\t\t// remove the anchor from the body\n\t\tdocument.body.removeChild(anchor)\n\t}\n\n\t// Some flows need to decide UI state before starting any long-running copy.\n\t// e.g., in Rewind feature, we want to:\n\t//  - Prompt for name collisions BEFORE showing a progress modal\n\t//  - Allow the user to skip all collisions and abort the entire operation cleanly\n\t// We fetch the destination listing once, detect per-item collisions, and prompt the\n\t// user. We then build and return a list of work items that\n\t// encode the chosen collision strategies per item. If the returned list is empty, the\n\t// caller should abort and not show any progress UI.\n\ttype CopyWorkItem = {path: string; toDirectory: string; collision: 'error' | 'replace' | 'keep-both'}\n\n\tconst resolveCopyCollisionsOrAbort = async ({\n\t\tfromPaths,\n\t\ttoDirectory,\n\t}: {\n\t\tfromPaths: string[]\n\t\ttoDirectory: string\n\t}): Promise<CopyWorkItem[]> => {\n\t\tif (isReadOnly) return []\n\t\t// Fetch destination once\n\t\tconst listing = await utils.files.list.fetch({path: toDirectory, limit: 10000})\n\t\tconst existing = new Set(listing.files.map((f) => f.name))\n\n\t\tconst collisionPaths = fromPaths.filter((p) => existing.has(p.split('/').pop() || ''))\n\t\tlet applyToAll = false\n\t\tlet globalDecision: 'replace' | 'keep-both' | 'skip' | null = null\n\n\t\tconst workItems: CopyWorkItem[] = []\n\t\tfor (const path of fromPaths) {\n\t\t\tconst base = path.split('/').pop() || ''\n\t\t\tconst isCollision = existing.has(base)\n\t\t\tif (!isCollision) {\n\t\t\t\tworkItems.push({path, toDirectory, collision: 'error'})\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlet decision: 'replace' | 'keep-both' | 'skip' = 'skip'\n\t\t\tif (applyToAll && globalDecision) {\n\t\t\t\tdecision = globalDecision\n\t\t\t} else {\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await confirm({\n\t\t\t\t\t\ttitle: t('files-collision.title', {\n\t\t\t\t\t\t\titemName: base,\n\t\t\t\t\t\t\tdestinationName: `\"${toDirectory.split('/').pop() || ''}\"`,\n\t\t\t\t\t\t}),\n\t\t\t\t\t\tmessage: t('files-collision.message'),\n\t\t\t\t\t\tactions: [\n\t\t\t\t\t\t\t{label: t('files-collision.action.keep-both'), value: 'keep-both', variant: 'primary'},\n\t\t\t\t\t\t\t{label: t('files-collision.action.replace'), value: 'replace', variant: 'default'},\n\t\t\t\t\t\t\t{label: t('files-collision.action.skip'), value: 'skip', variant: 'default'},\n\t\t\t\t\t\t],\n\t\t\t\t\t\tshowApplyToAll: collisionPaths.length > 1,\n\t\t\t\t\t\ticon: AiOutlineFileExclamation,\n\t\t\t\t\t})\n\t\t\t\t\tdecision = result.actionValue as 'replace' | 'keep-both' | 'skip'\n\t\t\t\t\tif (result.applyToAll) {\n\t\t\t\t\t\tapplyToAll = true\n\t\t\t\t\t\tglobalDecision = decision\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\tdecision = 'skip'\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (decision !== 'skip') workItems.push({path, toDirectory, collision: decision})\n\t\t}\n\n\t\treturn workItems\n\t}\n\n\tconst executeCopyWorkItems = async ({workItems}: {workItems: CopyWorkItem[]}) => {\n\t\tif (isReadOnly) return\n\t\tfor (const item of workItems) {\n\t\t\tawait copyItemMutation({path: item.path, toDirectory: item.toDirectory, collision: item.collision})\n\t\t}\n\t}\n\n\treturn {\n\t\t// Basic operations\n\t\trenameItem,\n\t\t// Movement operations\n\t\tcopyItems,\n\t\tmoveItems,\n\t\tmoveDraggedItems,\n\t\tmoveSelectedItems,\n\t\tpasteItemsFromClipboard,\n\t\t// Compression operations\n\t\textractSelectedItems,\n\t\tarchiveSelectedItems,\n\t\t// Trash operations\n\t\ttrashDraggedItems,\n\t\ttrashSelectedItems,\n\t\trestoreFromTrash,\n\t\trestoreSelectedItems,\n\t\tdeleteSelectedItems,\n\t\temptyTrash,\n\t\t// Download operations\n\t\tdownloadSelectedItems,\n\t\t// Copy planning helpers for flows that must resolve collisions before starting (e.g. Rewind feature)\n\t\tresolveCopyCollisionsOrAbort,\n\t\texecuteCopyWorkItems,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-home-directory-name.ts",
    "content": "import {trpcReact} from '@/trpc/trpc'\nimport {firstNameFromFullName} from '@/utils/misc'\n\nexport function useHomeDirectoryName() {\n\tconst userQuery = trpcReact.user.get.useQuery()\n\tconst userName = userQuery.data?.name\n\treturn userName ? `${firstNameFromFullName(userName)}'s Umbrel` : 'My Umbrel'\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-is-touch-device.ts",
    "content": "import {useMedia} from 'react-use'\n\nexport function useIsTouchDevice() {\n\tconst isHoverNone = useMedia('(hover: none)')\n\treturn isHoverNone\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-item-click.ts",
    "content": "import {SUPPORTED_ARCHIVE_EXTRACT_EXTENSIONS} from '@/features/files/constants'\nimport {useFilesOperations} from '@/features/files/hooks/use-files-operations'\nimport {useIsTouchDevice} from '@/features/files/hooks/use-is-touch-device'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useNetworkStorage} from '@/features/files/hooks/use-network-storage'\nimport {useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport {FileSystemItem} from '@/features/files/types'\nimport {isDirectoryANetworkDevice} from '@/features/files/utils/is-directory-a-network-device-or-share'\nimport {isDirectoryAnUmbrelBackup} from '@/features/files/utils/is-directory-an-umbrel-backup'\n\nexport const useItemClick = () => {\n\tconst isReadOnly = useIsFilesReadOnly()\n\tconst {selectedItems, setSelectedItems, isSelectingOnMobile, setViewerItem} = useFilesStore()\n\tconst {extractSelectedItems} = useFilesOperations()\n\tconst {navigateToDirectory} = useNavigate()\n\tconst {doesHostHaveMountedShares} = useNetworkStorage()\n\tconst isTouchDevice = useIsTouchDevice()\n\n\tconst isNetworkHostAccessible = (item: FileSystemItem) => {\n\t\tconst isNetworkHost = isDirectoryANetworkDevice(item.path)\n\t\treturn isNetworkHost ? doesHostHaveMountedShares(item.path) : true\n\t}\n\n\tconst handleClick = (e: React.MouseEvent, item: FileSystemItem, items: FileSystemItem[]) => {\n\t\t// Don't handle clicks on inaccessible network hosts\n\t\tif (!isNetworkHostAccessible(item)) return\n\t\tif (isTouchDevice) {\n\t\t\treturn handleClickOnMobile(item)\n\t\t}\n\t\treturn handleClickOnDesktop(e, item, items)\n\t}\n\n\tconst handleClickOnMobile = (item: FileSystemItem) => {\n\t\t// if not selecting, mimic a double click\n\t\tif (!isSelectingOnMobile) {\n\t\t\treturn handleDoubleClick(item)\n\t\t}\n\n\t\t// if selecting on mobile, toggle the item's selection\n\t\tif (selectedItems.some((selectedItem: FileSystemItem) => selectedItem.path === item.path)) {\n\t\t\treturn setSelectedItems(selectedItems.filter((i: FileSystemItem) => i.path !== item.path))\n\t\t}\n\t\treturn setSelectedItems([...selectedItems, item])\n\t}\n\n\tconst handleClickOnDesktop = (e: React.MouseEvent, item: FileSystemItem, items: FileSystemItem[]) => {\n\t\te.stopPropagation()\n\n\t\t// Disable selection if item is uploading\n\t\tif ('isUploading' in item && item.isUploading) return\n\n\t\t// if no items are selected, select the clicked item\n\t\tif (selectedItems.length === 0) {\n\t\t\treturn setSelectedItems([item])\n\t\t}\n\n\t\t// if no modifiers are pressed, select the clicked item\n\t\tif (!e.shiftKey && !e.ctrlKey && !e.metaKey) {\n\t\t\treturn setSelectedItems([item])\n\t\t}\n\n\t\t// if cmd or ctrl key is pressed, toggle the clicked item's selection\n\t\tif (e.metaKey || e.ctrlKey) {\n\t\t\tlet newSelectedItems = Array.from(selectedItems)\n\t\t\tif (newSelectedItems.some((selectedItem: FileSystemItem) => selectedItem.path === item.path)) {\n\t\t\t\tnewSelectedItems = newSelectedItems.filter((i: FileSystemItem) => i.path !== item.path)\n\t\t\t} else {\n\t\t\t\tnewSelectedItems = [...selectedItems, item]\n\t\t\t}\n\t\t\treturn setSelectedItems(newSelectedItems)\n\t\t}\n\n\t\t// if shift key is pressed, select a range of items\n\t\tif (e.shiftKey) {\n\t\t\t// get indices for range selection\n\t\t\tconst lastSelectedItem = selectedItems[selectedItems.length - 1]\n\t\t\tconst lastSelectedIndex = items.findIndex((i: FileSystemItem) => i.path === lastSelectedItem.path)\n\t\t\tconst clickedIndex = items.findIndex((i: FileSystemItem) => i.path === item.path)\n\n\t\t\t// determine range bounds\n\t\t\tconst start = Math.min(lastSelectedIndex, clickedIndex)\n\t\t\tconst end = Math.max(lastSelectedIndex, clickedIndex)\n\n\t\t\t// get items in range\n\t\t\tconst itemsInRange = items.slice(start, end + 1)\n\n\t\t\t// combine existing selections with the new range, removing duplicates\n\t\t\tconst combinedItems = [...selectedItems, ...itemsInRange]\n\t\t\tconst uniqueItems = Array.from(new Map(combinedItems.map((item) => [item.path, item])).values())\n\t\t\treturn setSelectedItems(uniqueItems)\n\t\t}\n\t}\n\n\tconst handleDoubleClick = (item: FileSystemItem) => {\n\t\t// Don't handle double clicks on inaccessible network hosts\n\t\tif (!isNetworkHostAccessible(item)) return\n\n\t\t// Don't open Umbrel Backup directory\n\t\tif (isDirectoryAnUmbrelBackup(item.name)) return\n\n\t\t// if touch device and the user is selecting, do nothing\n\t\tif (isTouchDevice && isSelectingOnMobile) {\n\t\t\treturn\n\t\t}\n\n\t\t// disable double click if the item is uploading\n\t\tif ('isUploading' in item && item.isUploading) {\n\t\t\treturn\n\t\t}\n\n\t\t// if the item is a directory, navigate to it\n\t\tif (item.type === 'directory') {\n\t\t\treturn navigateToDirectory(item.path)\n\t\t}\n\n\t\t// In read-only mode, allow opening the viewer but block write operations\n\t\tif (isReadOnly) {\n\t\t\t// set the item as the viewer item if supported\n\t\t\treturn setViewerItem(item)\n\t\t}\n\n\t\t// if the item is an archive file, extract it\n\t\tif (SUPPORTED_ARCHIVE_EXTRACT_EXTENSIONS.some((ext) => item.name.toLowerCase().endsWith(ext))) {\n\t\t\treturn extractSelectedItems()\n\t\t}\n\n\t\t// else set the item as the viewer item\n\t\t// the viewer will either render it, or show a download dialog if it's not supported\n\t\tsetViewerItem(item)\n\t}\n\n\treturn {\n\t\thandleClick,\n\t\thandleDoubleClick,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-list-directory.ts",
    "content": "import {keepPreviousData} from '@tanstack/react-query'\nimport {useCallback, useEffect, useMemo, useRef, useState} from 'react'\n\nimport {USE_LIST_DIRECTORY_LOAD_ITEMS} from '@/features/files/constants'\nimport {usePreferences} from '@/features/files/hooks/use-preferences'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {sortFilesystemItems} from '@/features/files/utils/sort-filesystem-items'\nimport {useGlobalFiles} from '@/providers/global-files'\nimport {trpcReact} from '@/trpc/trpc'\n\ninterface UseListDirectoryOptions {\n\titemsOnScrollEnd?: number\n\tinitialItems?: number\n}\n\nexport function useListDirectory(\n\tpath: string,\n\t{\n\t\titemsOnScrollEnd = USE_LIST_DIRECTORY_LOAD_ITEMS.ON_SCROLL_END,\n\t\tinitialItems = USE_LIST_DIRECTORY_LOAD_ITEMS.INITIAL,\n\t}: UseListDirectoryOptions = {},\n) {\n\tconst {preferences} = usePreferences()\n\tconst {uploadingItems} = useGlobalFiles()\n\tconst utils = trpcReact.useUtils()\n\n\tconst sortBy = preferences?.sortBy ?? 'name'\n\tconst sortOrder = preferences?.sortOrder ?? 'ascending'\n\n\t// Local “pagination” state\n\tconst [items, setItems] = useState<FileSystemItem[]>([])\n\tconst [hasMore, setHasMore] = useState(true)\n\tconst [isFetchingMore, setIsFetchingMore] = useState(false)\n\tconst [, setPaginationError] = useState<unknown>(null)\n\tconst [isLoadingItems, setIsLoadingItems] = useState(true)\n\n\t// helpers to know WHEN to skip refetch on sort changes\n\tconst prevSortRef = useRef<{sortBy: string; sortOrder: string} | undefined>(undefined)\n\tconst fullyLoaded = items.length > 0 && !hasMore\n\tconst sortChanged =\n\t\tprevSortRef.current && (prevSortRef.current.sortBy !== sortBy || prevSortRef.current.sortOrder !== sortOrder)\n\n\tconst skipBackendRequest = fullyLoaded && sortChanged\n\n\t// remember latest sort\n\tuseEffect(() => {\n\t\tprevSortRef.current = {sortBy, sortOrder}\n\t}, [sortBy, sortOrder])\n\n\t//\n\tconst {data, isLoading, isError, error} = trpcReact.files.list.useQuery(\n\t\t{path, limit: initialItems, sortBy, sortOrder},\n\t\t{\n\t\t\tenabled: !!path && !skipBackendRequest,\n\t\t\tplaceholderData: keepPreviousData,\n\t\t\tstaleTime: 5_000,\n\t\t\t// Don't retry on error. Backend errors like ENOENT/EIO/does-not-exist are deterministic, not transient.\n\t\t\t// This gives us quick feedback to the user.\n\t\t\tretry: false,\n\t\t\trefetchOnWindowFocus: false,\n\t\t},\n\t)\n\n\t// Reset items only when the *directory* changes\n\tuseEffect(() => {\n\t\t// Using our own loading state instead of the query's isLoading/isFetching to prevent\n\t\t// the empty view from briefly flashing when the items array is cleared during directory changes\n\t\tsetIsLoadingItems(true)\n\t\tsetItems([])\n\t\tsetHasMore(true)\n\t\tsetPaginationError(null)\n\t}, [path])\n\n\t// Seed items from the query result\n\tuseEffect(() => {\n\t\tif (data?.files) {\n\t\t\tsetIsLoadingItems(false)\n\t\t\tsetItems(data.files)\n\t\t\tsetHasMore(data.hasMore)\n\t\t}\n\t}, [path, data])\n\n\t// Stop loading if the query errors so the UI can render the error state\n\tuseEffect(() => {\n\t\tif (isError) {\n\t\t\tsetIsLoadingItems(false)\n\t\t}\n\t}, [isError])\n\n\t// Guard against late responses landing in the wrong directory\n\tconst requestIdRef = useRef(0)\n\n\tconst fetchMoreItems = useCallback(async (): Promise<boolean> => {\n\t\tif (isLoading || isFetchingMore || !hasMore) return false\n\n\t\tsetIsFetchingMore(true)\n\t\tsetPaginationError(null)\n\t\tconst thisRequest = ++requestIdRef.current\n\n\t\tconst lastItem = items[items.length - 1]\n\t\tconst lastFileName = lastItem?.path.split('/').pop()\n\n\t\ttry {\n\t\t\tconst result = await utils.files.list.fetch({\n\t\t\t\tpath,\n\t\t\t\tlastFile: lastFileName,\n\t\t\t\tlimit: itemsOnScrollEnd,\n\t\t\t\tsortBy,\n\t\t\t\tsortOrder,\n\t\t\t})\n\n\t\t\t// Ignore responses that belong to an outdated directory\n\t\t\tif (thisRequest !== requestIdRef.current) return false\n\n\t\t\tif (!result?.files?.length) {\n\t\t\t\tsetHasMore(false)\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\t// O( n ) dedupe\n\t\t\tsetItems((prev) => {\n\t\t\t\tconst map = new Map(prev.map((f) => [f.path, f]))\n\t\t\t\tresult.files.forEach((f: FileSystemItem) => map.set(f.path, f))\n\t\t\t\treturn Array.from(map.values())\n\t\t\t})\n\t\t\tsetHasMore(result.hasMore)\n\t\t\treturn true\n\t\t} catch (e) {\n\t\t\tif (thisRequest === requestIdRef.current) setPaginationError(e)\n\t\t\treturn false\n\t\t} finally {\n\t\t\tif (thisRequest === requestIdRef.current) setIsFetchingMore(false)\n\t\t}\n\t}, [items, path, itemsOnScrollEnd, sortBy, sortOrder, isLoading, isFetchingMore, hasMore, utils.files.list])\n\n\t// Merge optimistic uploading items & *always* sort locally\n\tconst directoryItems = useMemo(() => {\n\t\tconst optimistic = uploadingItems.filter((u) => u.path.substring(0, u.path.lastIndexOf('/')) === path)\n\t\treturn sortFilesystemItems([...optimistic, ...items], sortBy, sortOrder)\n\t}, [uploadingItems, items, path, sortBy, sortOrder])\n\n\treturn {\n\t\tlisting: data ? {...data, items: directoryItems, hasMore} : undefined,\n\t\tisLoading: isLoadingItems,\n\t\tisError,\n\t\terror,\n\t\tfetchMoreItems,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-list-recents.ts",
    "content": "import {keepPreviousData} from '@tanstack/react-query'\n\nimport {usePreferences} from '@/features/files/hooks/use-preferences'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {sortFilesystemItems} from '@/features/files/utils/sort-filesystem-items'\nimport {trpcReact} from '@/trpc/trpc'\n\n/**\n * Hook to fetch recently accessed files and directories\n *\n * @returns Object containing recently accessed items, loading state, and error information\n */\nexport function useListRecents() {\n\t// Fetch the directory contents\n\tconst {data, isLoading, isError, error} = trpcReact.files.recents.useQuery(undefined, {\n\t\tplaceholderData: keepPreviousData,\n\t\tstaleTime: 5_000,\n\t})\n\n\tconst {preferences} = usePreferences()\n\n\t// Sort the listing based on user preferences.\n\t// We sort them here instead of re-quering with updated preferences\n\t// because unlike useListDirectory, we know the max recent items is 50\n\t// so they're all already on the client side.\n\tconst sortedListing = data\n\t\t? sortFilesystemItems(\n\t\t\t\tdata.filter((item): item is FileSystemItem => item !== null),\n\t\t\t\tpreferences?.sortBy ?? 'name',\n\t\t\t\tpreferences?.sortOrder ?? 'ascending',\n\t\t\t)\n\t\t: []\n\n\treturn {\n\t\tlisting: sortedListing,\n\t\tisLoading,\n\t\tisError,\n\t\terror,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-navigate.ts",
    "content": "import {useLocation, useNavigate as useNavigateReactRouter} from 'react-router-dom'\n\nimport {\n\tAPPS_PATH,\n\tBASE_ROUTE_PATH,\n\tEXTERNAL_STORAGE_PATH,\n\tFILE_TYPE_MAP,\n\tHOME_PATH,\n\tNETWORK_STORAGE_PATH,\n\tRECENTS_PATH,\n\tSEARCH_PATH,\n\tTRASH_PATH,\n} from '@/features/files/constants'\nimport {useFilesCapabilities} from '@/features/files/providers/files-capabilities-context'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {uiToVirtualPath, virtualToUiPath} from '@/features/files/utils/path-alias'\n\nexport function toFsPath(urlPath: string): string {\n\treturn decodeURIComponent(urlPath).replace(BASE_ROUTE_PATH, '')\n}\n\n// Encode the URL path to handle special characters that can break URLs in the UI\nexport function encodePathSegments(fsPath: string): string {\n\t// We split by slashes and encode each segment individually\n\treturn fsPath\n\t\t.split('/')\n\t\t.map((segment) => (segment ? encodeURIComponent(segment) : ''))\n\t\t.join('/')\n}\n\nexport const useNavigate = () => {\n\tconst navigate = useNavigateReactRouter()\n\tconst location = useLocation()\n\tconst config = useFilesCapabilities()\n\t// Normalize path so '/Network/<host>/' === '/Network/<host>'.\n\t// This ensures network host-level detection works even if users add a trailing slash,\n\t// which we rely on to hide write UI when a NAS host is offline.\n\tconst currentPathFromRouter = toFsPath(location.pathname).replace(/\\/+$/, '') || '/'\n\tconst uiPath = (config.currentPath ?? currentPathFromRouter) as string\n\t// Map UI → virtual for internal consumers\n\tconst currentPath = uiToVirtualPath(uiPath, config.pathAliases)\n\tconst setViewerItem = useFilesStore((state) => state.setViewerItem)\n\tconst setSelectedItems = useFilesStore((state) => state.setSelectedItems)\n\n\tconst navigateToDirectory = (path: string) => {\n\t\t// clear any previous viewer item\n\t\tsetViewerItem(null)\n\n\t\t// Map virtual → UI for outbound navigation\n\t\tconst outUi = virtualToUiPath(path, config.pathAliases)\n\n\t\tif (config.onNavigate) {\n\t\t\tconfig.onNavigate(outUi)\n\t\t} else {\n\t\t\tnavigate(`${BASE_ROUTE_PATH}${encodePathSegments(outUi)}`)\n\t\t}\n\t}\n\n\tconst navigateToItem = (item: FileSystemItem) => {\n\t\t// if the item is a directory, navigate to it\n\t\tif (item.type === 'directory') {\n\t\t\treturn navigateToDirectory(item.path)\n\t\t}\n\n\t\t// for files we navigate to their parent directory and trigger the viewer\n\t\t// if a viewer is available\n\t\tconst lastSlash = item.path.lastIndexOf('/')\n\t\tconst parentDirectory = lastSlash === 0 ? '' : item.path.slice(0, lastSlash)\n\n\t\tnavigateToDirectory(parentDirectory)\n\n\t\tif (FILE_TYPE_MAP[item.type as keyof typeof FILE_TYPE_MAP]?.viewer) {\n\t\t\t// open viewer via global store (the Files feature will render the viewer\n\t\t\t// component once it sees `viewerItem` being set)\n\t\t\tsetViewerItem(item)\n\t\t}\n\n\t\t// TODO: This is a hack since we set selected items to []\n\t\t// on path change in packages/ui/src/features/files/index.tsx\n\t\t// so we wait 500ms for the path change to complete => setSelectedItems([])\n\t\t// to execute, and then set the selected item\n\t\tsetTimeout(() => {\n\t\t\tsetSelectedItems([item])\n\t\t}, 500)\n\t}\n\n\tconst isBrowsingTrash = currentPath.startsWith(TRASH_PATH)\n\n\tconst isInHome = currentPath === HOME_PATH\n\n\tconst isBrowsingHome = currentPath.startsWith(HOME_PATH)\n\n\tconst isBrowsingRecents = currentPath.startsWith(RECENTS_PATH)\n\n\tconst isBrowsingApps = currentPath.startsWith(APPS_PATH)\n\n\tconst isBrowsingSearch = currentPath.startsWith(SEARCH_PATH)\n\n\tconst isBrowsingExternalStorage = currentPath.startsWith(EXTERNAL_STORAGE_PATH)\n\n\tconst isViewingExternalDrives = currentPath === EXTERNAL_STORAGE_PATH\n\n\t// Anywhere within /Network\n\tconst isBrowsingNetworkStorage = currentPath.startsWith(NETWORK_STORAGE_PATH)\n\n\t// Is at /Network exactly (meaning we are viewing network devices if any)\n\tconst isViewingNetworkDevices = currentPath === NETWORK_STORAGE_PATH\n\n\t// Is at /Network/<host> (meaning we are viewing network shares for that host)\n\tconst isViewingNetworkShares =\n\t\tcurrentPath.startsWith(NETWORK_STORAGE_PATH + '/') && currentPath.split('/').length === 3\n\n\t// Backups root or any path under it\n\tconst isBrowsingBackups = currentPath.startsWith('/Backups')\n\n\treturn {\n\t\tnavigateToDirectory,\n\t\tnavigateToItem,\n\t\tcurrentPath,\n\t\t// Expose the UI path for components that need UI-space comparisons (e.g., isActive)\n\t\tuiPath,\n\t\tisBrowsingTrash,\n\t\tisInHome,\n\t\tisBrowsingHome,\n\t\tisBrowsingRecents,\n\t\tisBrowsingApps,\n\t\tisBrowsingSearch,\n\t\tisBrowsingExternalStorage,\n\t\tisViewingExternalDrives,\n\t\tisBrowsingNetworkStorage,\n\t\tisViewingNetworkDevices,\n\t\tisViewingNetworkShares,\n\t\tisBrowsingBackups,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-network-device-type.ts",
    "content": "import {trpcReact} from '@/trpc/trpc'\n\n/**\n * Service to detect the type of network device (Umbrel or generic NAS)\n */\n\ntype NetworkDeviceType = 'umbrel' | 'nas'\n\n/**\n * Extract hostname from network path\n * @param path Network path like \"/Network/umbrel.local\" or \"/Network/192.168.1.100\"\n * @returns hostname or null if invalid path\n */\nconst extractHostnameFromPath = (path: string): string | null => {\n\tif (!path.startsWith('/Network/')) return null\n\n\tconst segments = path.split('/').filter(Boolean)\n\tif (segments.length < 2) return null\n\n\treturn segments[1] // The hostname is the second segment after \"Network\"\n}\n\n/**\n * Hook to detect and cache the type of network device (Umbrel or generic NAS)\n * @param path Network path like \"/Network/umbrel.local\"\n * @returns Device type detection state\n */\nexport function useNetworkDeviceType(path: string) {\n\tconst hostname = extractHostnameFromPath(path)\n\n\t// Optimistically determine device type based on hostname\n\tconst optimisticDeviceType: NetworkDeviceType = hostname?.toLowerCase().includes('umbrel') ? 'umbrel' : 'nas'\n\n\tconst query = trpcReact.files.isServerAnUmbrelDevice.useQuery(\n\t\t{address: hostname!},\n\t\t{\n\t\t\t// Cache for 1 hour\n\t\t\tgcTime: 60 * 60 * 1000,\n\t\t\t// Consider data fresh for 30 minutes\n\t\t\tstaleTime: 30 * 60 * 1000,\n\t\t\t// Don't retry on failure - fail fast for better UX\n\t\t\tretry: false,\n\t\t\t// Only run if we have a valid hostname\n\t\t\tenabled: !!hostname,\n\t\t\t// Refetch when window regains focus\n\t\t\trefetchOnWindowFocus: true,\n\t\t},\n\t)\n\n\t// Determine final device type\n\tlet deviceType: NetworkDeviceType = 'nas'\n\tif (!hostname) {\n\t\tdeviceType = 'nas'\n\t} else if (query.data !== undefined) {\n\t\t// Use actual result from TRPC query\n\t\tdeviceType = query.data === true ? 'umbrel' : 'nas'\n\t} else {\n\t\t// Use optimistic value while loading or on error\n\t\tdeviceType = optimisticDeviceType\n\t}\n\n\treturn {\n\t\tdeviceType,\n\t\tisLoading: query.isLoading,\n\t\tisError: query.isError,\n\t\terror: query.error,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-network-storage.ts",
    "content": "import {keepPreviousData} from '@tanstack/react-query'\n\nimport {toast} from '@/components/ui/toast'\nimport {NETWORK_STORAGE_PATH} from '@/features/files/constants'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {getFilesErrorMessage} from '@/features/files/utils/error-messages'\nimport {\n\tisDirectoryANetworkDevice,\n\tisDirectoryANetworkShare,\n} from '@/features/files/utils/is-directory-a-network-device-or-share'\nimport {trpcReact} from '@/trpc/trpc'\nimport type {RouterError} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\n// We use `suppressNavigateOnAdd` to prevent navigating after adding a share from the backup/restore wizards.\nexport function useNetworkStorage(options?: {suppressNavigateOnAdd?: boolean}) {\n\tconst utils = trpcReact.useUtils()\n\tconst invalidateShares = () => utils.files.listNetworkShares.invalidate()\n\tconst invalidateNetworkShares = () => utils.files.list.invalidate({path: NETWORK_STORAGE_PATH})\n\n\tconst {currentPath, navigateToDirectory} = useNavigate()\n\n\t// Fetch the current shares (both mounted and unmounted)\n\tconst {\n\t\tdata: shares,\n\t\tisLoading: isLoadingShares,\n\t\trefetch: refetchShares,\n\t} = trpcReact.files.listNetworkShares.useQuery(undefined, {\n\t\tplaceholderData: keepPreviousData,\n\t\tstaleTime: 15_000,\n\t})\n\n\t// Check if a specific share is mounted\n\tconst isShareMounted = (mountPath: string) => shares?.some((s) => s.mountPath === mountPath && s.isMounted)\n\n\t// Check if any shares on this host are currently mounted\n\tconst doesHostHaveMountedShares = (rootPath: string) => {\n\t\tif (!shares) return false\n\t\t// rootPath = /Network/<host>\n\t\treturn shares.some((s) => s.isMounted && s.mountPath.startsWith(rootPath + '/'))\n\t}\n\n\t// Add a share\n\tconst {mutateAsync: addShare, isPending: isAddingShare} = trpcReact.files.addNetworkShare.useMutation({\n\t\tonSuccess: async (mountPath: string) => {\n\t\t\t// navigate to the host path of the share (e.g. /Network/<host>) unless suppressed by caller\n\t\t\tconst rootPath = mountPath.split('/').slice(0, -1).join('/')\n\n\t\t\t// only navigate if we're not suppressing it (e.g., from backup/restore wizards)\n\t\t\tif (!options?.suppressNavigateOnAdd) {\n\t\t\t\tnavigateToDirectory(rootPath)\n\t\t\t}\n\n\t\t\t// invalidate shares to show the new share in the sidebar\n\t\t\tawait invalidateShares()\n\t\t\t// invalidate the host directory listing to show the new share in the main view\n\t\t\tutils.files.list.invalidate({path: rootPath})\n\t\t\t// invalidate the network root to refresh MiniBrowser when browsing /Network\n\t\t\tutils.files.list.invalidate({path: NETWORK_STORAGE_PATH})\n\t\t},\n\t\tonError: (error: RouterError) =>\n\t\t\ttoast.error(t('files-network-storage-error.add-share', {message: getFilesErrorMessage(error.message)})),\n\t})\n\n\t// Remove a share\n\tconst {mutateAsync: removeShare, isPending: isRemovingShare} = trpcReact.files.removeNetworkShare.useMutation({\n\t\tonMutate: ({mountPath}) => {\n\t\t\tconst hostPath = mountPath.split('/').slice(0, -1).join('/')\n\t\t\tconst hostName = mountPath.split('/')[2]\n\t\t\t// Count how many shares this host will have after this removal\n\t\t\tconst remainingSharesForHost = shares?.filter((s) => s.host === hostName && s.mountPath !== mountPath).length || 0\n\n\t\t\treturn {\n\t\t\t\tmountPath,\n\t\t\t\thostPath,\n\t\t\t\thostName,\n\t\t\t\tremainingSharesForHost,\n\t\t\t}\n\t\t},\n\t\tonSuccess: (_, __, ctx) => {\n\t\t\tif (!ctx) return\n\n\t\t\t// We navigate based on user's current browsing location and remaining shares\n\t\t\tconst isUserBrowsingThisHost = currentPath.startsWith(ctx.hostPath)\n\t\t\tconst isLastShareForHost = ctx.remainingSharesForHost === 0\n\n\t\t\t// If we are browsing a host that's being completely removed, then we navigate to /Network\n\t\t\tif (isUserBrowsingThisHost && isLastShareForHost) navigateToDirectory(NETWORK_STORAGE_PATH)\n\n\t\t\t// Otherwise we don't navigate at all, which handles all other cases\n\t\t\t// (e.g., browsing a network device with another share that's not being removed, browsing /Downloads while ejecting a device from the sidebar, etc.)\n\n\t\t\t// Invalidate the /Network listing so the host device disappears if we’re browsing /Network directly\n\t\t\tinvalidateNetworkShares()\n\t\t\t// Invalidate the host directory listing in case we're viewing that device and removing a single share\n\t\t\tutils.files.list.invalidate({path: ctx.hostPath})\n\t\t},\n\t\tonError: (error: RouterError) =>\n\t\t\ttoast.error(t('files-network-storage-error.remove-share', {message: getFilesErrorMessage(error.message)})),\n\t\tonSettled: invalidateShares,\n\t})\n\n\t// Remove host or share by path\n\tconst removeHostOrShare = async (path: string) => {\n\t\tif (!shares) return\n\n\t\tif (isDirectoryANetworkDevice(path)) {\n\t\t\t// Host path: /Network/hostname - remove all shares for this host\n\t\t\tconst hostName = path.split('/')[2]\n\t\t\tconst hostShares = shares.filter((s) => s.host === hostName)\n\t\t\tfor (const share of hostShares) {\n\t\t\t\tawait removeShare({mountPath: share.mountPath})\n\t\t\t}\n\t\t} else if (isDirectoryANetworkShare(path)) {\n\t\t\t// Share path: /Network/hostname/share - remove just this share\n\t\t\tawait removeShare({mountPath: path})\n\t\t}\n\t}\n\n\t// Discover servers (disabled until fired)\n\tconst discoverServersQuery = trpcReact.files.discoverNetworkShareServers.useQuery(undefined, {\n\t\tenabled: false,\n\t\tretry: false,\n\t})\n\n\tconst discoverServers = async () => {\n\t\tconst res = await discoverServersQuery.refetch()\n\t\tif (res.error) {\n\t\t\ttoast.error(\n\t\t\t\tt('files-network-storage-error.discover-servers', {\n\t\t\t\t\tmessage: getFilesErrorMessage((res.error as RouterError).message),\n\t\t\t\t}),\n\t\t\t)\n\t\t}\n\t\treturn res.data\n\t}\n\n\t// Discover shares on a chosen server\n\tconst discoverSharesOnServer = async (host: string, username: string, password: string) => {\n\t\ttry {\n\t\t\treturn await utils.files.discoverNetworkSharesOnServer.fetch({\n\t\t\t\thost,\n\t\t\t\tusername,\n\t\t\t\tpassword,\n\t\t\t})\n\t\t} catch (error: any) {\n\t\t\ttoast.error(\n\t\t\t\tt('files-network-storage-error.discover-shares', {\n\t\t\t\t\tmessage: getFilesErrorMessage((error as RouterError).message),\n\t\t\t\t}),\n\t\t\t)\n\t\t\tthrow error\n\t\t}\n\t}\n\n\treturn {\n\t\tshares,\n\t\tisLoadingShares,\n\t\tisShareMounted,\n\t\tdoesHostHaveMountedShares,\n\t\trefetchShares,\n\t\taddShare,\n\t\tisAddingShare,\n\t\tisRemovingShare,\n\t\tremoveHostOrShare,\n\t\tdiscoverServers,\n\t\tdiscoveredServers: discoverServersQuery.data,\n\t\tisDiscoveringServers: discoverServersQuery.isFetching,\n\t\tdiscoverSharesOnServer,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-new-folder.ts",
    "content": "import {useEffect, useRef} from 'react'\n\nimport {toast} from '@/components/ui/toast'\nimport {useListDirectory} from '@/features/files/hooks/use-list-directory'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {getFilesErrorMessage} from '@/features/files/utils/error-messages'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function useNewFolder() {\n\tconst utils = trpcReact.useUtils()\n\tconst {currentPath} = useNavigate()\n\tconst {listing} = useListDirectory(currentPath)\n\tconst setNewFolder = useFilesStore((s) => s.setNewFolder)\n\tconst setSelectedItems = useFilesStore((s) => s.setSelectedItems)\n\n\t// These refs maintain a stable reference to the latest values of currentPath and listing.\n\t// This ensures that when startNewFolder is called, it will always have access to the\n\t// most up-to-date values for accurate folder creation and name validation.\n\tconst currentPathRef = useRef(currentPath)\n\tconst listingRef = useRef(listing)\n\n\t// keep the ref updated with the latest currentPath\n\tuseEffect(() => {\n\t\tcurrentPathRef.current = currentPath\n\t}, [currentPath])\n\n\t// keep the ref updated with the latest listing\n\tuseEffect(() => {\n\t\tlistingRef.current = listing\n\t}, [listing])\n\n\tconst createFolder = trpcReact.files.createDirectory.useMutation({\n\t\tonMutate: ({path}: {path: FileSystemItem['path']}) => {\n\t\t\tif (listingRef.current?.items) {\n\t\t\t\t// Extract name from path\n\t\t\t\tconst name = path.split('/').pop() || ''\n\t\t\t\t// Best-effort check for duplicate name\n\t\t\t\tif (!isNameAvailable(name, listingRef.current.items)) {\n\t\t\t\t\tthrow new Error(t('files-error.folder-already-exists'))\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\tonSuccess: (_, {path}: {path: FileSystemItem['path']}) => {\n\t\t\tsetNewFolder(null)\n\n\t\t\t// Extract name from path\n\t\t\tconst name = path.split('/').pop() || ''\n\n\t\t\t// Set the new folder as selected\n\t\t\tsetSelectedItems([\n\t\t\t\t{\n\t\t\t\t\tname,\n\t\t\t\t\tpath,\n\t\t\t\t\ttype: 'directory',\n\t\t\t\t\tsize: 0,\n\t\t\t\t\tmodified: new Date().getTime(),\n\t\t\t\t\toperations: [],\n\t\t\t\t},\n\t\t\t])\n\t\t},\n\t\tonError: (error) => {\n\t\t\ttoast.error(t('files-error.create-folder', {message: getFilesErrorMessage(error.message)}))\n\t\t},\n\t\tonSettled: () => {\n\t\t\tutils.files.list.invalidate()\n\t\t},\n\t})\n\n\t// NOTE: We can't reliably calculate the \"next available name\"\n\t// with infinite loading, as we don't have the full list of existing names.\n\t// So, we just check against the currently loaded items and start with the base name (e.g., \"Folder\").\n\t// and keep incrementing the index until we find an available name.\n\t// (e.g., \"Folder (2)\", \"Folder (3)\", etc.)\n\tconst startNewFolder = async () => {\n\t\tlet name = t('files-folder')\n\t\tif (listingRef.current?.items) {\n\t\t\t// Check if the base name already exists\n\t\t\tif (!isNameAvailable(name, listingRef.current.items)) {\n\t\t\t\t// If it does, find the next available name\n\t\t\t\tlet index = 2\n\t\t\t\twhile (!isNameAvailable(`${name} (${index})`, listingRef.current.items)) {\n\t\t\t\t\tindex++\n\t\t\t\t}\n\t\t\t\tname = `${name} (${index})`\n\t\t\t}\n\t\t}\n\n\t\tconst timeStamp = new Date().getTime()\n\t\tconst newFolder = {\n\t\t\tname,\n\t\t\tpath: currentPathRef.current + '/' + name,\n\t\t\ttype: 'directory',\n\t\t\tsize: 0,\n\t\t\tmodified: timeStamp,\n\t\t\toperations: [],\n\t\t\tisNew: true,\n\t\t}\n\t\tsetNewFolder(newFolder)\n\t\tsetSelectedItems([newFolder])\n\t}\n\n\tconst cancelNewFolder = () => {\n\t\tsetNewFolder(null)\n\t\tsetSelectedItems([])\n\t}\n\n\treturn {\n\t\tcreateFolder,\n\t\tstartNewFolder,\n\t\tcancelNewFolder,\n\t\tisLoading: createFolder.isPending,\n\t}\n}\n\n// This is a best-effort check as it only compares against the currently loaded items.\n// umbreld still returns true if EEXIST, but doesn't create the folder.\n// So even if we don't throw an error here, umbreld will still handle the duplicate name.\n// But when we do throw an error, the user will see a toast\nfunction isNameAvailable(name: string, existingItems: FileSystemItem[]) {\n\tconst existingNames = new Set(existingItems.map((item) => item.name))\n\treturn !existingNames.has(name)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-preferences.ts",
    "content": "import {keepPreviousData} from '@tanstack/react-query'\n\nimport {ViewPreferences} from '@/features/files/types'\nimport {RouterInput, trpcReact} from '@/trpc/trpc'\n\n/**\n * Hook to list favorite directories in the file system.\n */\nexport function usePreferences() {\n\tconst utils = trpcReact.useUtils()\n\n\t// Query to fetch favorites\n\tconst {\n\t\tdata: preferences,\n\t\tisLoading,\n\t\tisError,\n\t\terror,\n\t} = trpcReact.files.viewPreferences.useQuery(undefined, {\n\t\tplaceholderData: keepPreviousData,\n\t})\n\n\tconst setPreferences = trpcReact.files.updateViewPreferences.useMutation({\n\t\tonMutate: async (newPreferences: RouterInput['files']['updateViewPreferences']) => {\n\t\t\t// cancel any outgoing refetches (so they don't overwrite our optimistic update)\n\t\t\tawait utils.files.viewPreferences.cancel()\n\n\t\t\t// snapshot the previous preferences\n\t\t\tconst oldPreferences = utils.files.viewPreferences.getData()\n\n\t\t\t// optimistically update to the new value\n\t\t\tutils.files.viewPreferences.setData(undefined, () => ({\n\t\t\t\tview: oldPreferences?.view ?? 'list',\n\t\t\t\tsortBy: oldPreferences?.sortBy ?? 'name',\n\t\t\t\tsortOrder: oldPreferences?.sortOrder ?? 'ascending',\n\t\t\t\t...newPreferences,\n\t\t\t}))\n\t\t},\n\t\tonSettled: () => {\n\t\t\t// we don't need to revert the optimistic update on error\n\t\t\t// because it will be reverted by the invalidation\n\t\t\tutils.files.viewPreferences.invalidate()\n\t\t},\n\t})\n\n\tconst setView = (view: ViewPreferences['view']) => {\n\t\tsetPreferences.mutate({...preferences, view})\n\t}\n\n\tconst setSortBy = (sortBy: ViewPreferences['sortBy']) => {\n\t\t// if the same column is clicked again, toggle the sort order\n\t\tif (preferences?.sortBy === sortBy) {\n\t\t\tconst newSortOrder: ViewPreferences['sortOrder'] =\n\t\t\t\tpreferences?.sortOrder === 'ascending' ? 'descending' : 'ascending'\n\t\t\treturn setPreferences.mutate({...preferences, sortOrder: newSortOrder})\n\t\t}\n\t\t// otherwise, set to ascending for name, and descending for other columns\n\t\tconst defaultSortOrder: ViewPreferences['sortOrder'] = sortBy === 'name' ? 'ascending' : 'descending'\n\t\treturn setPreferences.mutate({...preferences, sortBy: sortBy, sortOrder: defaultSortOrder})\n\t}\n\n\treturn {\n\t\tpreferences,\n\t\tsetPreferences,\n\t\tsetView,\n\t\tsetSortBy,\n\t\tisLoading,\n\t\tisError,\n\t\terror,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-rewind-action.ts",
    "content": "// This is for the context menu \"Restore previous version\" action. It's distinct from `useRewind` which powers the internal Rewind overlay behavior\n\nimport {useNavigate} from 'react-router-dom'\n\nimport {useNavigate as useFilesNavigate} from '@/features/files/hooks/use-navigate'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {useQueryParams} from '@/hooks/use-query-params'\n\nexport function useRewindAction(selectedItems: FileSystemItem[]) {\n\tconst {isBrowsingHome, isBrowsingApps, isBrowsingRecents} = useFilesNavigate()\n\tconst navigate = useNavigate()\n\tconst {addLinkSearchParams} = useQueryParams()\n\n\tconst selectedCount = selectedItems.length\n\tconst allUnderSupported = selectedItems.every((i) => i.path.startsWith('/Home') || i.path.startsWith('/Apps'))\n\t// We only show the action if we are browsing /Home or /Apps, or if we are browsing Recents and all selected items are under /Home or /Apps\n\t// Recents will be opened to the enclosing folder of the first selected item\n\tconst canShowRewind =\n\t\tisBrowsingHome || isBrowsingApps || (isBrowsingRecents && selectedCount > 0 && allUnderSupported)\n\n\tconst onClick = () => {\n\t\tconst params: Record<string, string> = {rewind: 'open'}\n\t\tif (isBrowsingRecents && selectedCount > 0) {\n\t\t\tif (allUnderSupported) {\n\t\t\t\tconst first = selectedItems[0]\n\t\t\t\tconst lastSlash = first.path.lastIndexOf('/')\n\t\t\t\tconst parentUi = lastSlash > 0 ? first.path.slice(0, lastSlash) : '/Home'\n\t\t\t\tparams.rewindPath = parentUi\n\t\t\t}\n\t\t}\n\t\tnavigate({search: addLinkSearchParams(params)})\n\t}\n\n\treturn {canShowRewind, onClick}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-rewind.ts",
    "content": "import {useEffect, useMemo, useState} from 'react'\n\nimport {useBackups, useMountBackup, useRepositoryBackups, useUnmountBackup} from '@/features/backups/hooks/use-backups'\nimport type {Backup, BackupRepository} from '@/features/backups/hooks/use-backups'\nimport {useFilesOperations} from '@/features/files/hooks/use-files-operations'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport {trpcReact} from '@/trpc/trpc'\n\ntype ViewState = 'preflight' | 'browsing' | 'switching-snapshot' | 'restoring'\n\nexport function useRewind({overlayOpen, repoOpen}: {overlayOpen: boolean; repoOpen: boolean}) {\n\tconst [view, setView] = useState<ViewState>('preflight')\n\tconst [mountedDir, setMountedDir] = useState<string | null>(null)\n\tconst [selectedRepoId, setSelectedRepoId] = useState<string | null>(null)\n\tconst [selectedBackupId, setSelectedBackupId] = useState<string>('current')\n\tconst [pendingRepoId, setPendingRepoId] = useState<string | null>(null)\n\n\tconst {repositories: repositoriesRaw} = useBackups({repositoriesEnabled: repoOpen || overlayOpen})\n\tconst listBackupsQ = useRepositoryBackups(selectedRepoId || undefined, {\n\t\tenabled: overlayOpen && !!selectedRepoId,\n\t\tstaleTime: 10_000,\n\t})\n\tconst {mountBackup} = useMountBackup()\n\tconst {unmountBackup} = useUnmountBackup()\n\tconst utils = trpcReact.useUtils()\n\tconst {copyItems} = useFilesOperations()\n\tconst selectedItems = useFilesStore((s) => s.selectedItems)\n\tconst resetInteractionState = useFilesStore((s) => s.resetInteractionState)\n\n\t// repos/backups\n\tconst repositories = useMemo(() => (repositoriesRaw as BackupRepository[]) || [], [repositoriesRaw])\n\tconst backupsRaw = useMemo(() => (listBackupsQ.data as Backup[]) || [], [listBackupsQ.data])\n\tconst backupsLoading = listBackupsQ.isLoading\n\tconst backupsForTimeline = useMemo(() => {\n\t\tconst items = backupsRaw.map((b) => ({id: b.id as string, time: b.time as number})).sort((a, b) => a.time - b.time)\n\t\treturn [...items, {id: 'current', time: Date.now()}]\n\t}, [backupsRaw])\n\tconst activeIndex = useMemo(\n\t\t() => backupsForTimeline.findIndex((b) => b.id === selectedBackupId),\n\t\t[backupsForTimeline, selectedBackupId],\n\t)\n\tconst earliestDateLabel = useMemo(() => {\n\t\tif (!backupsRaw.length) return null\n\t\treturn new Date(backupsRaw[0].time).toLocaleDateString(undefined, {\n\t\t\tyear: 'numeric',\n\t\t\tmonth: 'short',\n\t\t\tday: 'numeric',\n\t\t})\n\t}, [backupsRaw])\n\n\t// preflight dialog auto-select\n\tuseEffect(() => {\n\t\tif ((overlayOpen || repoOpen) && !selectedRepoId) {\n\t\t\tconst first = repositories[0]\n\t\t\tif (first) setSelectedRepoId(first.id)\n\t\t}\n\t}, [overlayOpen, repoOpen, selectedRepoId, repositories])\n\n\tuseEffect(() => {\n\t\tif (repoOpen && !pendingRepoId) {\n\t\t\tconst first = repositories[0]\n\t\t\tif (first) setPendingRepoId(first.id)\n\t\t}\n\t}, [repoOpen, pendingRepoId, repositories])\n\n\t// TODO: check if we still need this logic after we make the umbreld kopia queue change\n\t/**\n\t * Attempt to detect if the requested snapshot is ALREADY mounted from a previous session\n\t * and can be reused without calling the backend again.\n\t *\n\t * How it works:\n\t * - The backend names mount directories using the snapshot's ISO time string\n\t *   (e.g., 2025-09-17T04:51:51.039Z)\n\t * - Given a backup id, we find its time and derive the expected directory name\n\t * - We then probe `/Backups/<dir>/Home` via files.list; if it succeeds, the bind mounts exist\n\t * - Returns that directory name on success, or null if not mounted\n\t */\n\tconst getExistingMountedDirForSnapshot = async (targetId: string): Promise<string | null> => {\n\t\tconst match = (backupsRaw as any[]).find((b) => b.id === targetId)\n\t\tif (!match) return null\n\t\tconst directoryName = new Date(match.time).toISOString()\n\t\ttry {\n\t\t\tawait utils.files.list.fetch({\n\t\t\t\tpath: `/Backups/${directoryName}/Home`,\n\t\t\t\tlimit: 1,\n\t\t\t\tsortBy: 'name',\n\t\t\t\tsortOrder: 'ascending',\n\t\t\t} as any)\n\t\t\treturn directoryName\n\t\t} catch {\n\t\t\treturn null\n\t\t}\n\t}\n\n\tconst unmountIfNeeded = async () => {\n\t\tif (!mountedDir) return\n\t\tawait unmountBackup(mountedDir)\n\t\t// No try/catch needed here since useUnmountBackup handles errors silently\n\t\t// We only clear mountedDir on successful unmount\n\t\t// If unmount fails, the new mount still succeeds, so user sees the selected snapshot\n\t\tsetMountedDir(null)\n\t}\n\n\tconst selectSnapshot = async (targetId: string) => {\n\t\t// We intentionally clear interaction state when switching snapshots.\n\t\t// This avoids carrying over selection or an open viewer from a previous snapshot into the next one\n\t\tresetInteractionState()\n\n\t\t// Store previous selection for potential revert if the mount fails\n\t\tconst previousBackupId = selectedBackupId\n\n\t\t// We start animation immediately for visual feedback\n\t\tsetSelectedBackupId(targetId)\n\t\tsetView('switching-snapshot')\n\n\t\ttry {\n\t\t\t// If this snapshot already has a leftover mount, reuse it\n\t\t\tif (targetId !== 'current') {\n\t\t\t\tconst existingDir = await getExistingMountedDirForSnapshot(targetId)\n\t\t\t\tif (existingDir) {\n\t\t\t\t\tsetMountedDir(existingDir)\n\t\t\t\t\tsetView('browsing')\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tawait unmountIfNeeded()\n\t\t\tif (targetId !== 'current') {\n\t\t\t\tconst dir = await mountBackup(targetId)\n\t\t\t\tsetMountedDir(dir)\n\t\t\t}\n\t\t\tsetView('browsing')\n\t\t} catch {\n\t\t\t// Error toasts are handled in the hooks\n\t\t\t// Revert the animation by going back to previous backup\n\t\t\tsetSelectedBackupId(previousBackupId)\n\t\t\tsetView('browsing')\n\t\t}\n\t}\n\n\tconst canRecover = useMemo(() => {\n\t\tif (!mountedDir) return false\n\t\tif (selectedBackupId === 'current') return false\n\t\tif (!selectedItems.length) return false\n\t\tconst baseHome = `/Backups/${mountedDir}/Home`\n\t\tconst baseApps = `/Backups/${mountedDir}/Apps`\n\t\treturn selectedItems.every((i) => i.path.startsWith(baseHome) || i.path.startsWith(baseApps))\n\t}, [mountedDir, selectedBackupId, selectedItems])\n\n\treturn {\n\t\t// view\n\t\tview,\n\t\tsetView,\n\n\t\t// entities\n\t\trepositories,\n\t\tbackupsRaw,\n\t\tbackupsLoading,\n\t\tbackupsForTimeline,\n\t\tactiveIndex,\n\t\tearliestDateLabel,\n\n\t\t// selection\n\t\tselectedRepoId,\n\t\tsetSelectedRepoId,\n\t\tpendingRepoId,\n\t\tsetPendingRepoId,\n\t\tselectedBackupId,\n\t\tsetSelectedBackupId,\n\n\t\t// mounting\n\t\tmountedDir,\n\t\tselectSnapshot,\n\t\tunmountIfNeeded,\n\n\t\t// restore helpers\n\t\tcopyItems,\n\t\tcanRecover,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-search-files.ts",
    "content": "// Hook to perform a filesystem search via the backend `files.search` endpoint.\n// The query must be a non-empty string – an empty query automatically\n// disables the request so we don't spam the backend with needless calls while\n// the user is still typing or after they clear the search input.\n\nimport {useState} from 'react'\nimport {useDebounce} from 'react-use'\n\nimport {USE_LIST_DIRECTORY_LOAD_ITEMS} from '@/features/files/constants'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {trpcReact} from '@/trpc/trpc'\n\nexport interface UseSearchFilesReturn {\n\tresults: FileSystemItem[]\n\tisLoading: boolean\n\tisError: boolean\n\terror: unknown\n}\n\nexport function useSearchFiles({\n\tquery,\n\tmaxResults = USE_LIST_DIRECTORY_LOAD_ITEMS.INITIAL,\n}: {\n\tquery: string\n\tmaxResults?: number\n}): UseSearchFilesReturn {\n\tconst trimmedQuery = query.trim()\n\tconst [debouncedQuery, setDebouncedQuery] = useState(trimmedQuery)\n\n\t// debounce the query param so we only hit the backend at most once every\n\t// 350ms while the user is typing their search term\n\tuseDebounce(\n\t\t() => {\n\t\t\tsetDebouncedQuery(trimmedQuery)\n\t\t},\n\t\t350,\n\t\t[trimmedQuery],\n\t)\n\n\tconst {data, isLoading, isError, error} = trpcReact.files.search.useQuery(\n\t\t{query: debouncedQuery, maxResults},\n\t\t{\n\t\t\t// disable the query if there is no search term\n\t\t\tenabled: debouncedQuery.length > 0,\n\t\t\t// keep the data in the cache for a minute\n\t\t\tgcTime: 60 * 1000,\n\t\t},\n\t)\n\n\treturn {\n\t\tresults: (data ?? []) as FileSystemItem[],\n\t\tisLoading,\n\t\tisError,\n\t\terror,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/hooks/use-shares.ts",
    "content": "import {keepPreviousData} from '@tanstack/react-query'\n\nimport {toast} from '@/components/ui/toast'\nimport {HOME_PATH} from '@/features/files/constants'\nimport type {Share} from '@/features/files/types'\nimport {getFilesErrorMessage} from '@/features/files/utils/error-messages'\nimport {trpcReact} from '@/trpc/trpc'\nimport type {RouterError} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\n/**\n * Hook to manage file shares in the file system.\n * Provides functionality to fetch shares, add/remove shares, and get share password.\n */\nexport function useShares() {\n\tconst utils = trpcReact.useUtils()\n\n\t// Query to fetch all shares\n\tconst {data: shares, isLoading: isLoadingShares} = trpcReact.files.shares.useQuery(undefined, {\n\t\tplaceholderData: keepPreviousData,\n\t\tstaleTime: 60_000, // Cache for 1 minute\n\t})\n\n\t// Check if item is shared\n\tconst isPathShared = (path: string) => shares?.some((share: Share) => share && share.path === path)\n\n\t// Check if the entire home directory is shared\n\tconst isHomeShared = () => shares?.some((share: Share) => share && share.path === HOME_PATH)\n\n\t// Query to get share password\n\tconst {data: sharePassword, isLoading: isLoadingSharesPassword} = trpcReact.files.sharePassword.useQuery(undefined, {\n\t\tstaleTime: Infinity, // Cache indefinitely until browser refresh\n\t})\n\n\t// Add share mutation\n\tconst {mutateAsync: addShare, isPending: isAddingShare} = trpcReact.files.addShare.useMutation({\n\t\tonSuccess: async () => {\n\t\t\tawait utils.files.shares.invalidate()\n\t\t},\n\t\tonError: (error: RouterError) => {\n\t\t\ttoast.error(t('files-error.add-share', {message: getFilesErrorMessage(error.message)}))\n\t\t},\n\t})\n\n\t// Remove share mutation\n\tconst {mutateAsync: removeShare, isPending: isRemovingShare} = trpcReact.files.removeShare.useMutation({\n\t\tonSuccess: async () => {\n\t\t\tawait utils.files.shares.invalidate()\n\t\t},\n\t\tonError: (error: RouterError) => {\n\t\t\ttoast.error(t('files-error.remove-share', {message: getFilesErrorMessage(error.message)}))\n\t\t},\n\t})\n\n\treturn {\n\t\t// Queries\n\t\tshares,\n\t\tisLoadingShares,\n\t\tsharePassword,\n\t\tisLoadingSharesPassword,\n\t\tisPathShared,\n\t\tisHomeShared,\n\n\t\t// Add share\n\t\taddShare,\n\t\tisAddingShare,\n\n\t\t// Remove share\n\t\tremoveShare,\n\t\tisRemovingShare,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/index.tsx",
    "content": "import {lazy, Suspense, useEffect, useState} from 'react'\nimport {ErrorBoundary} from 'react-error-boundary'\nimport {HiMenuAlt2} from 'react-icons/hi'\nimport {Outlet, useLocation} from 'react-router-dom'\n\nimport {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'\nimport {SheetHeader, SheetTitle} from '@/components/ui/sheet'\nimport {FileViewer} from '@/features/files/components/file-viewer'\nimport {FilesDndWrapper} from '@/features/files/components/files-dnd-wrapper'\nimport {ActionsBar} from '@/features/files/components/listing/actions-bar'\nimport {ActionsBarProvider} from '@/features/files/components/listing/actions-bar/actions-bar-context'\nimport {RewindOverlay} from '@/features/files/components/rewind'\nimport {RewindOverlayProvider} from '@/features/files/components/rewind/overlay-context'\nimport {Sidebar} from '@/features/files/components/sidebar'\nimport {MobileSidebarWrapper} from '@/features/files/components/sidebar/mobile-sidebar-wrapper'\nimport {useIsFilesReadOnly} from '@/features/files/providers/files-capabilities-context'\nimport {useFilesStore} from '@/features/files/store/use-files-store'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {t} from '@/utils/i18n'\n\nconst ShareInfoDialog = lazy(() => import('@/features/files/components/dialogs/share-info-dialog'))\nconst PermanentlyDeleteConfirmationDialog = lazy(\n\t() => import('@/features/files/components/dialogs/permanently-delete-confirmation-dialog'),\n)\nconst ExternalStorageUnsupportedDialog = lazy(\n\t() => import('@/features/files/components/dialogs/external-storage-unsupported-dialog'),\n)\nconst AddNetworkShareDialog = lazy(() => import('@/features/files/components/dialogs/add-network-share-dialog'))\nconst FormatDriveDialog = lazy(() => import('@/features/files/components/dialogs/format-drive-dialog'))\n\nexport default function FilesLayout() {\n\tconst {pathname} = useLocation()\n\tconst {setSelectedItems} = useFilesStore()\n\n\tconst setIsSelectingOnMobile = useFilesStore((state) => state.setIsSelectingOnMobile)\n\n\tconst isMobile = useIsMobile()\n\tconst [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false)\n\tconst isReadOnly = useIsFilesReadOnly()\n\n\tuseEffect(() => {\n\t\t// TODO: Find a better place to do this\n\t\t// clear selected items when navigating to a different path\n\t\t// NOTE: when we remove/change this, we need to update\n\t\t// packages/ui/src/features/files/cmdk-search-provider.tsx\n\t\t// to set the selected item correctly\n\t\tsetSelectedItems([])\n\n\t\t// set selecting on mobile to false when navigating to a different path\n\t\tsetIsSelectingOnMobile(false)\n\n\t\t// Close mobile sidebar on navigation\n\t\tsetIsMobileSidebarOpen(false)\n\t}, [pathname, setSelectedItems, setIsSelectingOnMobile])\n\n\treturn (\n\t\t<FilesDndWrapper>\n\t\t\t<RewindOverlayProvider>\n\t\t\t\t<SheetHeader className='flex flex-col gap-4 md:flex-row md:items-center md:gap-0'>\n\t\t\t\t\t<div className='flex items-center gap-4'>\n\t\t\t\t\t\t{isMobile ? (\n\t\t\t\t\t\t\t<HiMenuAlt2\n\t\t\t\t\t\t\t\trole='button'\n\t\t\t\t\t\t\t\tclassName='h-5 w-5 text-white/90'\n\t\t\t\t\t\t\t\tonClick={() => setIsMobileSidebarOpen(true)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t<SheetTitle className='mr-2 leading-none lg:mr-0 lg:min-w-[188px]'>{t('files')}</SheetTitle>\n\t\t\t\t\t</div>\n\t\t\t\t</SheetHeader>\n\t\t\t\t<ErrorBoundary FallbackComponent={ErrorBoundaryCardFallback}>\n\t\t\t\t\t{/* FileViewer renders the viewerItem from the store */}\n\t\t\t\t\t<FileViewer />\n\n\t\t\t\t\t<div className='mt-[-0.5rem] grid grid-cols-1 lg:mt-0 lg:grid-cols-[188px_1fr]'>\n\t\t\t\t\t\t{/* Sidebar */}\n\t\t\t\t\t\t{isMobile ? (\n\t\t\t\t\t\t\t<MobileSidebarWrapper isOpen={isMobileSidebarOpen} onClose={() => setIsMobileSidebarOpen(false)}>\n\t\t\t\t\t\t\t\t<Sidebar className='h-[calc(100svh-140px)]' />\n\t\t\t\t\t\t\t</MobileSidebarWrapper>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<Sidebar className='h-[calc(100vh-300px)]' />\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t<div className='flex flex-col gap-3 lg:gap-6'>\n\t\t\t\t\t\t\t<ActionsBarProvider>\n\t\t\t\t\t\t\t\t<ActionsBar />\n\t\t\t\t\t\t\t\t{/* Renders either DirectoryListing, AppsListing, RecentsListing, or TrashListing */}\n\t\t\t\t\t\t\t\t<Outlet />\n\t\t\t\t\t\t\t</ActionsBarProvider>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Rewind overlay rendered at root so that it doesn't disappear on Files re-render if user changes screensize*/}\n\t\t\t\t\t<RewindOverlay />\n\n\t\t\t\t\t{/* Lazy loaded dialogs on non-read-only mode */}\n\t\t\t\t\t{!isReadOnly ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<Suspense>\n\t\t\t\t\t\t\t\t<ShareInfoDialog />\n\t\t\t\t\t\t\t</Suspense>\n\t\t\t\t\t\t\t<Suspense>\n\t\t\t\t\t\t\t\t<PermanentlyDeleteConfirmationDialog />\n\t\t\t\t\t\t\t</Suspense>\n\t\t\t\t\t\t\t<Suspense>\n\t\t\t\t\t\t\t\t<ExternalStorageUnsupportedDialog />\n\t\t\t\t\t\t\t</Suspense>\n\t\t\t\t\t\t\t<Suspense>\n\t\t\t\t\t\t\t\t<AddNetworkShareDialog />\n\t\t\t\t\t\t\t</Suspense>\n\t\t\t\t\t\t\t<Suspense>\n\t\t\t\t\t\t\t\t<FormatDriveDialog />\n\t\t\t\t\t\t\t</Suspense>\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : null}\n\t\t\t\t</ErrorBoundary>\n\t\t\t</RewindOverlayProvider>\n\t\t</FilesDndWrapper>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/providers/files-capabilities-context.tsx",
    "content": "// This FilesCapabilities Context is a centralized configuration for the Files feature.\n// It allows us to embed the Files UI (e.g., in Rewind feature) without relying on the router\n// It allows us to configure the Files UI to be \"read-only\" and disable certain features like:\n// DnD, Keyboard shortcuts, Context menu, File upload drop zone, File viewer, Search, View toggle\n// and also allows us to hide or disable certain sidebar items like Network, External, Trash, Rewind\n\nimport React, {createContext, useContext, useMemo} from 'react'\n\nexport type FilesMode = 'full' | 'read-only'\n\nexport type FilesCapabilities = {\n\t// Controls read/write behavior and which UI elements are interactive.\n\t// 'full' enables interactions; 'read-only' disables operations, DnD, etc.\n\tmode: FilesMode\n\t// Optional: current logical path when embedding the Files UI.\n\t// If omitted, the router-driven path is used.\n\tcurrentPath?: string\n\t// Optional: navigation callback when embedding. If provided, the Files UI\n\t// will call this instead of pushing to the router.\n\tonNavigate?: (path: string) => void\n\t// Optional: logical→physical path remapping. Useful for Rewind feature where\n\t// logical roots like \"/Home\" map to \"/Backups/<dir>/Home\".\n\tpathAliases?: Record<string, string>\n\t// Optional: hide specific sidebar sections for focused embedded flows.\n\thiddenSidebarItems?: {\n\t\tnetwork?: boolean\n\t\texternal?: boolean\n\t\ttrash?: boolean\n\t\trewind?: boolean\n\t}\n}\n\nconst defaultCapabilities: FilesCapabilities = {\n\tmode: 'full',\n}\n\nconst FilesCapabilitiesContext = createContext<FilesCapabilities>(defaultCapabilities)\n\nexport function FilesCapabilitiesProvider({\n\tchildren,\n\tvalue,\n}: {\n\tchildren: React.ReactNode\n\tvalue?: Partial<FilesCapabilities>\n}) {\n\tconst computed = useMemo<FilesCapabilities>(() => {\n\t\treturn {\n\t\t\t...defaultCapabilities,\n\t\t\t...(value || {}),\n\t\t}\n\t}, [value])\n\n\treturn <FilesCapabilitiesContext value={computed}>{children}</FilesCapabilitiesContext>\n}\n\n// Read the current Files capabilities configuration.\nexport function useFilesCapabilities() {\n\treturn useContext(FilesCapabilitiesContext)\n}\n\n// Convenience helper: true when Files is configured as read‑only.\nexport function useIsFilesReadOnly() {\n\treturn useFilesCapabilities().mode === 'read-only'\n}\n\n// Convenience helper: true when Files is being embedded (not router-driven).\nexport function useIsFilesEmbedded() {\n\tconst {currentPath, onNavigate} = useFilesCapabilities()\n\treturn Boolean(currentPath || onNavigate)\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/routes.tsx",
    "content": "import {lazy} from 'react'\nimport {Navigate, RouteObject} from 'react-router-dom'\n\nimport {AppsListing} from '@/features/files/components/listing/apps-listing'\nimport {DirectoryListing} from '@/features/files/components/listing/directory-listing'\nimport {RecentsListing} from '@/features/files/components/listing/recents-listing'\nimport {SearchListing} from '@/features/files/components/listing/search-listing'\nimport {TrashListing} from '@/features/files/components/listing/trash-listing'\nimport {BASE_ROUTE_PATH, HOME_PATH} from '@/features/files/constants'\n\nconst Files = lazy(() => import('@/features/files'))\n\nexport const filesRoutes: RouteObject[] = [\n\t{\n\t\tpath: 'files',\n\t\telement: <Files />,\n\t\tchildren: [\n\t\t\t// if the user navigates to /files, redirect to /files/<HOME_PATH>\n\t\t\t{\n\t\t\t\tindex: true,\n\t\t\t\telement: <Navigate to={`${BASE_ROUTE_PATH}${HOME_PATH}`} replace />,\n\t\t\t},\n\t\t\t// \"Recents\" and not \"Recents/*\" because folders aren't tracked in the recents by the server\n\t\t\t{\n\t\t\t\tpath: 'Recents',\n\t\t\t\telement: <RecentsListing />,\n\t\t\t},\n\t\t\t{\n\t\t\t\t// \"Search\" and not \"Search/*\" because folders aren't tracked in the search by the server\n\t\t\t\tpath: 'Search',\n\t\t\t\telement: <SearchListing />,\n\t\t\t},\n\t\t\t{\n\t\t\t\t// \"Apps\" and not \"Apps/*\" because we want to allow uploads, new folders, etc. in \"Apps/<app-data>/*\"\"\n\t\t\t\t// which would instead be rendered by the DirectoryListing component\n\t\t\t\tpath: 'Apps',\n\t\t\t\telement: <AppsListing />,\n\t\t\t},\n\t\t\t{\n\t\t\t\t// \"Trash/*\" and not \"Trash\" because we want to disable new folder, upload, etc.\n\t\t\t\t// in the entire Trash directory and its subdirectories\n\t\t\t\tpath: 'Trash/*',\n\t\t\t\telement: <TrashListing />,\n\t\t\t},\n\t\t\t{\n\t\t\t\tpath: '*',\n\t\t\t\telement: <DirectoryListing />,\n\t\t\t},\n\t\t],\n\t},\n]\n"
  },
  {
    "path": "packages/ui/src/features/files/store/slices/clipboard-slice.ts",
    "content": "import {StateCreator} from 'zustand'\n\nimport {DragAndDropSlice} from '@/features/files/store/slices/drag-and-drop-slice'\nimport {FileViewerSlice} from '@/features/files/store/slices/file-viewer-slice'\nimport {NewFolderSlice} from '@/features/files/store/slices/new-folder-slice'\nimport {SelectionSlice} from '@/features/files/store/slices/selection-slice'\nimport type {FileSystemItem} from '@/features/files/types'\n\ntype ClipboardMode = 'copy' | 'cut' | null\n\nexport interface ClipboardSlice {\n\tclipboardItems: FileSystemItem[]\n\tclipboardMode: ClipboardMode\n\n\tcopyItemsToClipboard: () => void\n\tcutItemsToClipboard: () => void\n\n\thasItemsInClipboard: () => boolean\n\tclearClipboard: () => void\n\tisItemInClipboard: (item: FileSystemItem) => boolean\n}\n\nexport const createClipboardSlice: StateCreator<\n\tClipboardSlice & SelectionSlice & NewFolderSlice & DragAndDropSlice & FileViewerSlice,\n\t[],\n\t[],\n\tClipboardSlice\n> = (set, get) => ({\n\tclipboardItems: [],\n\tclipboardMode: null,\n\n\tcopyItemsToClipboard: () => {\n\t\tconst items = Array.from(get().selectedItems)\n\t\tif (!items.length) {\n\t\t\treturn get().clearClipboard()\n\t\t}\n\t\tconst copyableItems = items.filter((item) => item.operations.includes('copy'))\n\t\tset({clipboardItems: copyableItems, clipboardMode: 'copy'})\n\t},\n\n\tcutItemsToClipboard: () => {\n\t\tconst items = Array.from(get().selectedItems)\n\t\tif (!items.length) {\n\t\t\treturn get().clearClipboard()\n\t\t}\n\t\tconst movableItems = items.filter((item) => item.operations.includes('move'))\n\t\tset({clipboardItems: movableItems, clipboardMode: 'cut'})\n\t},\n\n\thasItemsInClipboard: () => {\n\t\treturn get().clipboardItems.length > 0\n\t},\n\n\tclearClipboard: () => {\n\t\tset({clipboardItems: [], clipboardMode: null})\n\t},\n\n\tisItemInClipboard: (item: FileSystemItem) => {\n\t\treturn get().clipboardItems.some((i) => i.path === item.path)\n\t},\n})\n"
  },
  {
    "path": "packages/ui/src/features/files/store/slices/drag-and-drop-slice.ts",
    "content": "import {StateCreator} from 'zustand'\n\nimport {ClipboardSlice} from '@/features/files/store/slices/clipboard-slice'\nimport {FileViewerSlice} from '@/features/files/store/slices/file-viewer-slice'\nimport {NewFolderSlice} from '@/features/files/store/slices/new-folder-slice'\nimport {SelectionSlice} from '@/features/files/store/slices/selection-slice'\nimport {FileSystemItem} from '@/features/files/types'\n\nexport interface DragAndDropSlice {\n\tdraggedItems: FileSystemItem[]\n\tsetDraggedItems: (items: FileSystemItem[]) => void\n\tclearDraggedItems: () => void\n}\n\nexport const createDragAndDropSlice: StateCreator<\n\tDragAndDropSlice & SelectionSlice & ClipboardSlice & NewFolderSlice & FileViewerSlice,\n\t[],\n\t[],\n\tDragAndDropSlice\n> = (set) => ({\n\tdraggedItems: [],\n\tsetDraggedItems: (items) => set({draggedItems: items}),\n\tclearDraggedItems: () => set({draggedItems: []}),\n})\n"
  },
  {
    "path": "packages/ui/src/features/files/store/slices/file-viewer-slice.ts",
    "content": "import {StateCreator} from 'zustand'\n\nimport {ClipboardSlice} from '@/features/files/store/slices/clipboard-slice'\nimport {DragAndDropSlice} from '@/features/files/store/slices/drag-and-drop-slice'\nimport {NewFolderSlice} from '@/features/files/store/slices/new-folder-slice'\nimport {SelectionSlice} from '@/features/files/store/slices/selection-slice'\nimport {FileSystemItem} from '@/features/files/types'\n\nexport interface FileViewerSlice {\n\tviewerItem: FileSystemItem | null\n\tsetViewerItem: (item: FileSystemItem | null) => void\n}\n\nexport const createFileViewerSlice: StateCreator<\n\tFileViewerSlice & SelectionSlice & ClipboardSlice & NewFolderSlice & DragAndDropSlice,\n\t[],\n\t[],\n\tFileViewerSlice\n> = (set) => ({\n\tviewerItem: null,\n\tsetViewerItem: (item) => set({viewerItem: item}),\n})\n"
  },
  {
    "path": "packages/ui/src/features/files/store/slices/interaction-slice.ts",
    "content": "import {StateCreator} from 'zustand'\n\nimport {ClipboardSlice} from '@/features/files/store/slices/clipboard-slice'\nimport {DragAndDropSlice} from '@/features/files/store/slices/drag-and-drop-slice'\nimport {FileViewerSlice} from '@/features/files/store/slices/file-viewer-slice'\nimport {NewFolderSlice} from '@/features/files/store/slices/new-folder-slice'\nimport {SelectionSlice} from '@/features/files/store/slices/selection-slice'\n\n// Slice for user interaction state spanning multiple slices (selection, viewer, DnD, etc.)\nexport interface InteractionSlice {\n\tresetInteractionState: () => void\n}\n\nexport const createInteractionSlice: StateCreator<\n\tInteractionSlice & SelectionSlice & ClipboardSlice & NewFolderSlice & DragAndDropSlice & FileViewerSlice,\n\t[],\n\t[],\n\tInteractionSlice\n> = (_set, get) => ({\n\tresetInteractionState: () => {\n\t\tget().setSelectedItems([])\n\t\tget().setViewerItem(null)\n\t\tget().setIsSelectingOnMobile(false)\n\t\tget().clearDraggedItems()\n\t},\n})\n"
  },
  {
    "path": "packages/ui/src/features/files/store/slices/new-folder-slice.ts",
    "content": "import {StateCreator} from 'zustand'\n\nimport {ClipboardSlice} from '@/features/files/store/slices/clipboard-slice'\nimport {DragAndDropSlice} from '@/features/files/store/slices/drag-and-drop-slice'\nimport {FileViewerSlice} from '@/features/files/store/slices/file-viewer-slice'\nimport {SelectionSlice} from '@/features/files/store/slices/selection-slice'\nimport type {FileSystemItem} from '@/features/files/types'\n\nexport interface NewFolderSlice {\n\tnewFolder: (FileSystemItem & {isNew: boolean}) | null\n\n\tsetNewFolder: (newFolder: (FileSystemItem & {isNew: boolean}) | null) => void\n}\n\nexport const createNewFolderSlice: StateCreator<\n\tNewFolderSlice & SelectionSlice & ClipboardSlice & DragAndDropSlice & FileViewerSlice,\n\t[],\n\t[],\n\tNewFolderSlice\n> = (set) => ({\n\tnewFolder: null,\n\n\tsetNewFolder: (newFolder) => {\n\t\tset({\n\t\t\tnewFolder,\n\t\t})\n\t},\n})\n"
  },
  {
    "path": "packages/ui/src/features/files/store/slices/rename-slice.ts",
    "content": "import {StateCreator} from 'zustand'\n\nimport {ClipboardSlice} from '@/features/files/store/slices/clipboard-slice'\nimport {DragAndDropSlice} from '@/features/files/store/slices/drag-and-drop-slice'\nimport {FileViewerSlice} from '@/features/files/store/slices/file-viewer-slice'\nimport {NewFolderSlice} from '@/features/files/store/slices/new-folder-slice'\nimport {SelectionSlice} from '@/features/files/store/slices/selection-slice'\n\nexport interface RenameSlice {\n\t// Path of the file/folder that is currently being renamed.\n\trenamingItemPath: string | null\n\tsetRenamingItemPath: (path: string | null) => void\n}\n\nexport const createRenameSlice: StateCreator<\n\tSelectionSlice & ClipboardSlice & NewFolderSlice & DragAndDropSlice & FileViewerSlice & RenameSlice,\n\t[],\n\t[],\n\tRenameSlice\n> = (set) => ({\n\trenamingItemPath: null,\n\tsetRenamingItemPath: (path) => set({renamingItemPath: path}),\n})\n"
  },
  {
    "path": "packages/ui/src/features/files/store/slices/selection-slice.ts",
    "content": "import {StateCreator} from 'zustand'\n\nimport {ClipboardSlice} from '@/features/files/store/slices/clipboard-slice'\nimport {DragAndDropSlice} from '@/features/files/store/slices/drag-and-drop-slice'\nimport {FileViewerSlice} from '@/features/files/store/slices/file-viewer-slice'\nimport {NewFolderSlice} from '@/features/files/store/slices/new-folder-slice'\nimport type {FileSystemItem} from '@/features/files/types'\n\nexport interface SelectionSlice {\n\tselectedItems: FileSystemItem[]\n\tisSelectingOnMobile: boolean\n\n\tsetSelectedItems: (items: FileSystemItem[]) => void\n\tsetIsSelectingOnMobile: (isSelecting: boolean) => void\n\tclearSelectedItems: () => void\n\tisItemSelected: (item: FileSystemItem) => boolean\n}\n\nexport const createSelectionSlice: StateCreator<\n\tSelectionSlice & ClipboardSlice & NewFolderSlice & DragAndDropSlice & FileViewerSlice,\n\t[],\n\t[],\n\tSelectionSlice\n> = (set, get) => ({\n\tselectedItems: [],\n\tisSelectingOnMobile: false,\n\tsetSelectedItems: (items) => {\n\t\tset({selectedItems: items})\n\t},\n\n\tsetIsSelectingOnMobile: (isSelecting: boolean) => {\n\t\tset({isSelectingOnMobile: isSelecting})\n\t\tif (!isSelecting) {\n\t\t\tget().clearSelectedItems()\n\t\t}\n\t},\n\n\tclearSelectedItems: () => {\n\t\tset({selectedItems: []})\n\t},\n\n\tisItemSelected: (item: FileSystemItem) => {\n\t\treturn get().selectedItems.some((i) => i.path === item.path)\n\t},\n})\n"
  },
  {
    "path": "packages/ui/src/features/files/store/use-files-store.ts",
    "content": "import {create} from 'zustand'\n\nimport {ClipboardSlice, createClipboardSlice} from '@/features/files/store/slices/clipboard-slice'\nimport {createDragAndDropSlice, DragAndDropSlice} from '@/features/files/store/slices/drag-and-drop-slice'\nimport {createFileViewerSlice, FileViewerSlice} from '@/features/files/store/slices/file-viewer-slice'\nimport {createInteractionSlice, InteractionSlice} from '@/features/files/store/slices/interaction-slice'\nimport {createNewFolderSlice, NewFolderSlice} from '@/features/files/store/slices/new-folder-slice'\nimport {createRenameSlice, RenameSlice} from '@/features/files/store/slices/rename-slice'\nimport {createSelectionSlice, SelectionSlice} from '@/features/files/store/slices/selection-slice'\n\nexport type FilesStore = SelectionSlice &\n\tClipboardSlice &\n\tNewFolderSlice &\n\tDragAndDropSlice &\n\tFileViewerSlice &\n\tRenameSlice &\n\tInteractionSlice\n\nexport const useFilesStore = create<FilesStore>()((...a) => ({\n\t...createSelectionSlice(...a),\n\t...createClipboardSlice(...a),\n\t...createNewFolderSlice(...a),\n\t...createDragAndDropSlice(...a),\n\t...createFileViewerSlice(...a),\n\t...createRenameSlice(...a),\n\t...createInteractionSlice(...a),\n}))\n"
  },
  {
    "path": "packages/ui/src/features/files/types.ts",
    "content": "import type {RouterOutput} from '@/trpc/trpc'\n\n// ---------------------------- umbreld (server) Types ----------------------------\n\n// ensure that the types are the same for files.list and files.recents\nexport type UmbreldFileSystemItem = RouterOutput['files']['list']['files'][number]\n\nexport type Favorite = RouterOutput['files']['favorites'][number]\n\nexport type Share = RouterOutput['files']['shares'][number]\n\nexport type ExternalStorageDevice = RouterOutput['files']['externalDevices'][number]\n\nexport type ViewPreferences = RouterOutput['files']['viewPreferences']\n\n// ---------------------------- Client Types ----------------------------\n\nexport interface FileSystemItem extends UmbreldFileSystemItem {\n\tisUploading?: boolean // true if the item is currently being uploaded\n\tprogress?: number // upload progress in percentage 0-100\n\tspeed?: number // upload speed in bytes per second\n\ttempId?: string // we don't use path since an item with the same name can be uploaded to the same path\n}\n\nexport interface UploadStats {\n\ttotalProgress: number // 0-100\n\ttotalSpeed: number // bytes per second\n\ttotalUploaded: number // bytes\n\ttotalSize: number // bytes\n\teta: string // formatted string like \"5s\", \"2m\", \"1hr 30m\"\n}\n\nexport type PolymorphicPropsWithoutRef<T extends React.ElementType, P> = P &\n\tOmit<React.ComponentPropsWithoutRef<T>, keyof P | 'as' | 'className'> & {\n\t\tas?: T\n\t\tclassName?: string\n\t}\n\nexport type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>['ref']\n\nexport type PolymorphicPropsWithRef<T extends React.ElementType, P> = PolymorphicPropsWithoutRef<T, P> & {\n\tref?: PolymorphicRef<T>\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/utils/error-messages.ts",
    "content": "import {t} from '@/utils/i18n'\n\n// Maps raw backend bracketed error codes to user-friendly translated messages.\n// If no known code is found, returns the raw message as-is.\nexport function getFilesErrorMessage(message: string): string {\n\tif (message.includes('[does-not-exist]')) return t('files-backend-error.does-not-exist')\n\tif (message.includes('[source-not-exists]')) return t('files-backend-error.source-not-exists')\n\tif (message.includes('[destination-not-exist]')) return t('files-backend-error.destination-not-exist')\n\tif (message.includes('[destination-already-exists]')) return t('files-backend-error.destination-already-exists')\n\tif (message.includes('[operation-not-allowed]')) return t('files-backend-error.operation-not-allowed')\n\tif (message.includes('[not-enough-space]')) return t('files-backend-error.not-enough-space')\n\tif (message.includes('[invalid-filename]')) return t('files-backend-error.invalid-filename')\n\tif (message.includes('[subdir-of-self]')) return t('files-backend-error.subdir-of-self')\n\tif (message.includes('[parent-not-exist]')) return t('files-backend-error.parent-not-exist')\n\tif (message.includes('[parent-not-directory]')) return t('files-backend-error.parent-not-directory')\n\tif (message.includes('[mkdir-failed]')) return t('files-backend-error.mkdir-failed')\n\tif (message.includes('[move-failed]')) return t('files-backend-error.move-failed')\n\tif (message.includes('[trash-meta-not-exists]')) return t('files-backend-error.trash-meta-not-exists')\n\tif (message.includes('[unique-name-index-exceeded]')) return t('files-backend-error.unique-name-index-exceeded')\n\tif (message.includes('[path-not-absolute]')) return t('files-backend-error.path-not-absolute')\n\tif (message.includes('[invalid-base]')) return t('files-backend-error.invalid-base')\n\tif (message.includes('[escapes-base]')) return t('files-backend-error.escapes-base')\n\tif (message.includes('[base-directory-not-found]')) return t('files-backend-error.base-directory-not-found')\n\tif (message.includes('[invalid-path]')) return t('files-backend-error.invalid-path')\n\tif (message.includes('[cant-find-root]')) return t('files-backend-error.cant-find-root')\n\tif (message.includes('[share-already-exists]')) return t('files-backend-error.share-already-exists')\n\tif (message.includes('[share-name-generation-failed]')) return t('files-backend-error.share-name-generation-failed')\n\n\treturn message\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/utils/format-filesystem-date.ts",
    "content": "import {format, formatRelative, isToday, isYesterday} from 'date-fns'\n\nimport {languageCodeToDateLocale} from '@/utils/date-time'\nimport {SupportedLanguageCode} from '@/utils/language'\n\n// Capitalizes the first letter of a string\nfunction capitalize(str: string): string {\n\treturn str.charAt(0).toUpperCase() + str.slice(1)\n}\n\n// Formats a date string or Date object into a user-friendly format\n// Returns:\n// - \"Today at 1:15 AM\" for today's dates\n// - \"Yesterday at 10:14 AM\" for yesterday's dates\n// - \"Mar 14, 2024 11:24 AM\" for older dates\nexport function formatFilesystemDate(date: number | undefined, languageCode: SupportedLanguageCode): string {\n\tif (!date) return ''\n\n\ttry {\n\t\tconst dateObj = new Date(date)\n\t\tconst locale = languageCodeToDateLocale[languageCode]\n\n\t\tif (isToday(dateObj) || isYesterday(dateObj)) {\n\t\t\treturn capitalize(formatRelative(dateObj, new Date(), {locale}))\n\t\t}\n\t\treturn format(dateObj, 'PPp', {locale}) // Shows \"Mar 14, 2024 11:24 AM\"\n\t} catch {\n\t\treturn ''\n\t}\n}\n\n// Formats date without time (always absolute), e.g. \"Mar 14, 2024\"\n// e.g., we use this for Rewind feature stickers to show just the date\nexport function formatFilesystemDateOnly(date: number | undefined, languageCode: SupportedLanguageCode): string {\n\tif (!date) return ''\n\ttry {\n\t\tconst dateObj = new Date(date)\n\t\tconst locale = languageCodeToDateLocale[languageCode]\n\t\treturn format(dateObj, 'PP', {locale})\n\t} catch {\n\t\treturn ''\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/utils/format-filesystem-name.ts",
    "content": "// TODO: this is mostly just a placeholder for now.\n// We need to implement this robustly. Look into css truncation.\nimport type {FileSystemItem} from '@/features/files/types'\n\n// Formats file/folder names for display, including truncation\nexport function formatItemName({name, maxLength = 30}: {name: FileSystemItem['name']; maxLength?: number}) {\n\t// For files, handle name and extension separately\n\tconst {name: fileName, extension} = splitFileName(name)\n\n\t// Account for extension length when truncating so that truncated files with extensions vs no extensions have the same length\n\tconst nameMaxLength = extension ? maxLength - extension.length : maxLength\n\tconst truncatedName = truncateName(fileName, nameMaxLength)\n\treturn extension ? `${truncatedName}${extension}` : truncatedName\n}\n\n// Splits a filename into its name and extension parts\n// Example: \"document.pdf\" -> { name: \"document\", extension: \".pdf\" }\n// Handles edge cases:\n// - Hidden files (.gitignore) -> { name: \".gitignore\", extension: null }\n// - Multiple dots (file.name.txt) -> { name: \"file.name\", extension: \".txt\" }\n// - No extension (README) -> { name: \"README\", extension: null }\n// - .tar.* extensions (file.tar.gz, file.tar.bz2, etc) -> { name: \"file\", extension: \".tar.gz\" }\nexport function splitFileName(fileName: string): {name: string; extension: string | null} {\n\t// Handle .tar.* compound extensions first\n\tconst compoundMatch = fileName.match(/^(.+?)(\\.tar\\.[^./]+)$/)\n\tif (compoundMatch) {\n\t\tconst [, name, extension] = compoundMatch\n\t\treturn {name, extension}\n\t}\n\n\t// Handle regular files\n\tconst matches = fileName.match(/^([^/]*?)(?:(\\.[^./]+))?$/)\n\n\tif (!matches) {\n\t\treturn {name: fileName, extension: null}\n\t}\n\n\tconst [, name, extension] = matches\n\treturn {\n\t\tname,\n\t\textension: extension || null,\n\t}\n}\n\n// Truncates a name while preserving meaningful parts\n// Example: \"very-long-document-name\" -> \"very-long...name\"\nfunction truncateName(name: string, maxLength: number): string {\n\tif (name.length <= maxLength) return name\n\n\t// Reserve 3 characters for \"...\"\n\tconst availableLength = maxLength - 3\n\tconst startLength = Math.ceil(availableLength * 0.6) // Keep more characters at start\n\tconst endLength = availableLength - startLength // Keep remaining at end\n\n\tconst start = name.slice(0, startLength)\n\tconst end = name.slice(-endLength)\n\n\treturn `${start}...${end}`\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/utils/format-filesystem-size.ts",
    "content": "import prettyBytes from 'pretty-bytes'\n\nexport function formatFilesystemSize(size: number | undefined | null): string {\n\tif (!size) return '-'\n\treturn prettyBytes(size).replace('kB', 'KB') // prettyBytes returns 'kB' instead of 'KB': https://github.com/sindresorhus/pretty-bytes?tab=readme-ov-file#why-kb-and-not-kb\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/utils/get-grid-column-count.ts",
    "content": "/**\n * Calculate the number of columns that fit in a grid view of the given width.\n * This formula is shared between VirtualizedList (for rendering) and\n * keyboard navigation (for arrow key row jumps).\n */\nexport function getGridColumnCount(width: number): number {\n\tconst itemWidth = 112\n\tconst minGap = 8\n\tconst borderAllowance = 2\n\tconst containerWidth = itemWidth + borderAllowance * 2\n\treturn Math.max(1, Math.floor((width + minGap) / (containerWidth + minGap)))\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/utils/get-item-key.ts",
    "content": "import type {FileSystemItem} from '@/features/files/types'\n\n/**\n * Generates a unique key for a file system item\n * Takes into account uploading status for items that are being uploaded\n *\n * @param item The file system item\n * @returns A unique string key\n */\nexport function getItemKey(item: FileSystemItem): string {\n\tconst isUploading = 'isUploading' in item && item.isUploading\n\treturn `${item.path}${isUploading ? '-uploading' : ''}`\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/utils/is-directory-a-network-device-or-share.ts",
    "content": "/**\n * Checks if a given path represents a network device.\n * Valid path example: \"/Network/samba.orb.local\"\n * @param path The file system path to check\n * @returns boolean indicating if the path is a network device\n */\nexport const isDirectoryANetworkDevice = (path: string): boolean => {\n\t// Path must start with /Network and have exactly one more segment (the host name)\n\treturn path.startsWith('/Network/') && path.split('/').filter(Boolean).length === 2\n}\n\n/**\n * Checks if a given path represents a network share.\n * Valid path example: \"/Network/samba.orb.local/Documents\"\n * @param path The file system path to check\n * @returns boolean indicating if the path is a network share\n */\nexport const isDirectoryANetworkShare = (path: string): boolean => {\n\t// Path must start with /Network and have exactly two more segments (host name + share name)\n\treturn path.startsWith('/Network/') && path.split('/').filter(Boolean).length === 3\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/utils/is-directory-an-external-drive-partition.ts",
    "content": "/**\n * Checks if a given path represents an external drive partition.\n * Valid path example: \"/External/usb1\"\n * @param path The file system path to check\n * @returns boolean indicating if the path is an external drive partition\n */\nexport const isDirectoryAnExternalDrivePartition = (path: string): boolean => {\n\t// Path must start with /External and have exactly one more segment (the partition name)\n\treturn path.startsWith('/External/') && path.split('/').filter(Boolean).length === 2\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/utils/is-directory-an-umbrel-backup.ts",
    "content": "export const isDirectoryAnUmbrelBackup = (name: string): boolean => {\n\treturn name === 'Umbrel Backup.backup'\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/utils/path-alias.ts",
    "content": "// Utilities for mapping between UI-visible virtual paths and the resolved\n// virtual paths used by the backend. We do a simple root-prefix\n// replacement based on a small alias map.\n\nexport type PathAliases = Record<string, string> | undefined\n\n// Replace a leading prefix in a path. Matches exact or prefix-with-slash.\n// Returns the original path when there is no match.\nexport function replaceLeadingPrefix(path: string, prefix: string, replacement: string): string {\n\tif (path === prefix) return replacement\n\tif (path.startsWith(prefix + '/')) return replacement + path.slice(prefix.length)\n\treturn path\n}\n\n// Map UI path → virtual path using the alias map, e.g. { \"/Home\": \"/Backups/<dir>/Home\" }.\nexport function uiToVirtualPath(path: string, aliases: PathAliases): string {\n\tif (!aliases) return path\n\tfor (const [from, to] of Object.entries(aliases)) {\n\t\tif (path === from || path.startsWith(from + '/')) {\n\t\t\treturn replaceLeadingPrefix(path, from, to)\n\t\t}\n\t}\n\treturn path\n}\n\n// Map virtual → UI by swapping the matched virtual root back to its UI root.\nexport function virtualToUiPath(path: string, aliases: PathAliases): string {\n\tif (!aliases) return path\n\tfor (const [uiRoot, virtualRoot] of Object.entries(aliases)) {\n\t\tif (path === virtualRoot || path.startsWith(virtualRoot + '/')) {\n\t\t\treturn replaceLeadingPrefix(path, virtualRoot, uiRoot)\n\t\t}\n\t}\n\treturn path\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/utils/sort-filesystem-items.ts",
    "content": "import type {FileSystemItem, ViewPreferences} from '@/features/files/types'\n\n// Static comparison utility for fast sorting\nconst collator = new Intl.Collator('en-US', {sensitivity: 'base', numeric: true})\n\nconst compareByName = (a: FileSystemItem, b: FileSystemItem) => collator.compare(a.name, b.name)\n\n// TODO: Add this back in when we have a file system index in umbreld\n// const compareByCreated = (a: FileSystemItem, b: FileSystemItem) => {\n// \tconst aCreated = a.created ? new Date(a.created).getTime() : 0\n// \tconst bCreated = b.created ? new Date(b.created).getTime() : 0\n// \treturn aCreated - bCreated\n// }\n\nconst compareByModified = (a: FileSystemItem, b: FileSystemItem) => {\n\tconst aModified = a.modified ? new Date(a.modified).getTime() : 0\n\tconst bModified = b.modified ? new Date(b.modified).getTime() : 0\n\treturn aModified - bModified\n}\n\nconst compareByType = (a: FileSystemItem, b: FileSystemItem) => {\n\tconst aType = a.type || ''\n\tconst bType = b.type || ''\n\treturn collator.compare(aType, bType)\n}\n\nconst compareBySize = (a: FileSystemItem, b: FileSystemItem) => {\n\tconst aSize = a.size || 0\n\tconst bSize = b.size || 0\n\treturn aSize - bSize\n}\n\n/**\n * Sort filesystem items based on the provided sort options\n * @param items - Array of filesystem items to sort\n * @param sortBy - Property to sort by\n * @param sortOrder - Sort order (ascending or descending)\n * @returns Sorted array of filesystem items\n */\nexport function sortFilesystemItems(\n\titems: FileSystemItem[],\n\tsortBy: ViewPreferences['sortBy'] = 'name',\n\tsortOrder: ViewPreferences['sortOrder'] = 'ascending',\n): FileSystemItem[] {\n\tconst ascending = sortOrder === 'ascending'\n\tconst compare =\n\t\t// TODO: Add this back in when we have a file system index in umbreld\n\t\t// sortBy === 'created'\n\t\t// \t? compareByCreated :\n\t\tsortBy === 'modified'\n\t\t\t? compareByModified\n\t\t\t: sortBy === 'type'\n\t\t\t\t? compareByType\n\t\t\t\t: sortBy === 'size'\n\t\t\t\t\t? compareBySize\n\t\t\t\t\t: compareByName\n\n\treturn [...items].sort((a, b) => {\n\t\t// Apply sort order and fall back to compare by name when comparing equal\n\t\tconst comparison = compare(a, b) || compareByName(a, b)\n\t\treturn ascending ? comparison : -comparison\n\t})\n}\n"
  },
  {
    "path": "packages/ui/src/features/files/widgets.tsx",
    "content": "import {t} from 'i18next'\nimport {motion} from 'motion/react'\nimport {useMemo} from 'react'\n\n// Files-specific utilities\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {useNavigate} from '@/features/files/hooks/use-navigate'\nimport {FileSystemItem} from '@/features/files/types'\nimport {formatItemName, splitFileName} from '@/features/files/utils/format-filesystem-name'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {cn} from '@/lib/utils'\nimport type {BaseWidget, Link, RegistryWidget} from '@/modules/widgets/shared/constants'\nimport {WidgetContainer} from '@/modules/widgets/shared/shared'\n\nexport type FilesListWidget = BaseWidget & {\n\ttype: 'files-list'\n\tlink?: Link\n\titems?: FileSystemItem[]\n\tnoItemsText?: string\n}\n\nexport type FilesGridWidget = BaseWidget & {\n\ttype: 'files-grid'\n\tlink?: Link\n\tpaths?: FileSystemItem['path'][]\n\tnoItemsText?: string\n}\n\nexport const filesWidgetTypes = ['files-list', 'files-grid'] as const\n\n// Dummy files widgets for the widget selector\nconst dummyFileAttributes = {\n\tmodified: Date.now(),\n\tsize: 100,\n\toperations: [],\n}\nexport const filesWidgets: RegistryWidget<'files-list' | 'files-grid'>[] = [\n\t{\n\t\t// These widgets are Umbrel widgets, so they are always prefixed with\n\t\t// `umbrel:`. The suffixes (here `files-recents` and `files-favorites`)\n\t\t// must match the key that we register in\n\t\t// packages/umbreld/source/modules/files/widgets.ts\n\t\tid: 'umbrel:files-recents',\n\t\ttype: 'files-list',\n\t\texample: {\n\t\t\titems: [\n\t\t\t\t{name: 'Notes.txt', path: '/Home/notes.txt', type: 'text/plain', ...dummyFileAttributes},\n\t\t\t\t{name: 'Vacation.jpg', path: '/Home/vacation-photo.jpg', type: 'image/jpeg', ...dummyFileAttributes},\n\t\t\t\t{\n\t\t\t\t\tname: 'Report.docx',\n\t\t\t\t\tpath: '/Home/report.docx',\n\t\t\t\t\ttype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n\t\t\t\t\t...dummyFileAttributes,\n\t\t\t\t},\n\t\t\t],\n\t\t\tnoItemsText: t('files-widgets.recents.no-items-text'),\n\t\t},\n\t},\n\t{\n\t\tid: 'umbrel:files-favorites',\n\t\ttype: 'files-grid',\n\t\texample: {\n\t\t\tpaths: ['/Home/Downloads', '/Home/Photos', '/Home/Videos', '/Home/Documents'],\n\t\t\tnoItemsText: t('files-widgets.favorites.no-items-text'),\n\t\t},\n\t},\n]\n\ninterface FilesListWidgetProps {\n\titems?: FileSystemItem[]\n\tlink?: Link\n\tnoItemsText?: string\n\tonClick?: (link?: string) => void\n}\n\ninterface FilesGridWidgetProps {\n\tpaths?: FileSystemItem['path'][]\n\tlink?: Link\n\tnoItemsText?: string\n\tonClick?: (link?: string) => void\n}\n\n// List widget (Recents)\nexport function FilesListWidget({\n\titems,\n\tlink,\n\tnoItemsText = 'files-widgets.recents.no-items-text',\n\tonClick,\n}: FilesListWidgetProps) {\n\treturn (\n\t\t<WidgetContainer\n\t\t\tonClick={(e: React.MouseEvent<HTMLDivElement>) => {\n\t\t\t\t// Ignore if the click was on an interactive element inside this container,\n\t\t\t\t// because if the user clicked on an item, we want to navigate to it\n\t\t\t\tif (e.target instanceof HTMLElement && e.target.closest('[role=\"button\"]')) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tonClick?.(link)\n\t\t\t}}\n\t\t\tclassName='overflow-hidden p-1 !pb-0 sm:p-2'\n\t\t>\n\t\t\t<span className='mt-1 -mb-2 ml-2 text-9 text-white/60 sm:-mb-1 sm:text-xs'>{t('files-sidebar.recents')}</span>\n\t\t\t<div\n\t\t\t\tclassName='flex h-full w-full flex-col'\n\t\t\t\tstyle={{maskImage: 'linear-gradient(to bottom, red 50px calc(100% - 30px), transparent)'}}\n\t\t\t>\n\t\t\t\t{/* Loading state */}\n\t\t\t\t{!items && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<SkeletonListItem />\n\t\t\t\t\t\t<SkeletonListItem />\n\t\t\t\t\t\t<SkeletonListItem />\n\t\t\t\t\t</>\n\t\t\t\t)}\n\n\t\t\t\t{/* Empty state */}\n\t\t\t\t{items?.length === 0 && (\n\t\t\t\t\t<div className='-mt-3 grid h-full w-full place-items-center pb-2 text-center text-xs text-white/50 sm:pb-4'>\n\t\t\t\t\t\t{t(`${noItemsText}`)}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{/* Actual items  */}\n\t\t\t\t{items && items.length > 0 && items.slice(0, 3).map((item) => <ListItem key={item.path} item={item} />)}\n\t\t\t</div>\n\t\t</WidgetContainer>\n\t)\n}\n\nfunction SkeletonListItem() {\n\treturn (\n\t\t<div className='flex items-center gap-1 px-2 py-[0.3rem] sm:gap-2'>\n\t\t\t{/* placeholder icon */}\n\t\t\t<div className='h-5 w-5 animate-pulse rounded-xs bg-white/10 sm:h-7 sm:w-7 sm:rounded-md' />\n\t\t\t<div className='flex flex-col gap-1 overflow-hidden'>\n\t\t\t\t{/* name */}\n\t\t\t\t<div className='h-2 w-24 animate-pulse rounded-md bg-white/10 sm:h-3 sm:w-32' />\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\nfunction ListItem({item}: {item: FileSystemItem}) {\n\tconst {navigateToItem} = useNavigate()\n\tconst isMobile = useIsMobile()\n\n\tconst {name, extension} = splitFileName(item.name)\n\tconst extensionWithoutDot = extension?.startsWith('.') ? extension.slice(1) : extension\n\n\treturn (\n\t\t<div\n\t\t\trole='button'\n\t\t\tonClick={() => navigateToItem(item)}\n\t\t\tclassName='flex items-center gap-1 rounded-lg px-2 py-[0.3rem] transition-all hover:bg-white/10 sm:gap-2'\n\t\t>\n\t\t\t<FileItemIcon item={item} className='h-5 w-5 max-w-5 sm:h-7 sm:w-7 sm:max-w-7' />\n\t\t\t<div className='flex items-baseline gap-1 overflow-hidden'>\n\t\t\t\t<p className='truncate text-11 text-white/80 sm:text-13'>\n\t\t\t\t\t{formatItemName({name, maxLength: isMobile ? 13 : 22})}\n\t\t\t\t</p>\n\t\t\t\t{extension && <p className='truncate text-9 text-white/50 uppercase'>{extensionWithoutDot}</p>}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\n// Grid widget (Favourites)\nexport function FilesGridWidget({\n\tpaths,\n\tnoItemsText = 'files-widgets.favorites.no-items-text',\n\tlink,\n\tonClick,\n}: FilesGridWidgetProps) {\n\t// Convert the raw paths into lightweight FileSystemItem stubs so that we can\n\t// reuse the existing <FileItemIcon> component.\n\tconst gridItems = useMemo(() => {\n\t\tif (!paths) return undefined // still loading\n\t\treturn paths\n\t\t\t.map((p) => ({\n\t\t\t\tpath: p,\n\t\t\t\tname: p.split('/').pop() || '',\n\t\t\t\ttype: 'directory',\n\t\t\t\tmodified: Date.now(),\n\t\t\t\tsize: 0,\n\t\t\t\toperations: [],\n\t\t\t}))\n\t\t\t.slice(0, 4)\n\t}, [paths])\n\n\tconst count = gridItems?.length ?? 0\n\n\treturn (\n\t\t<WidgetContainer\n\t\t\tclassName={cn(\n\t\t\t\t'gap-1 p-1.5 sm:gap-2 sm:p-2.5',\n\t\t\t\tcount === 1 && 'grid grid-cols-1 grid-rows-1',\n\t\t\t\tcount === 2 && 'grid grid-cols-2 grid-rows-1',\n\t\t\t\tcount === 3 && 'sm:grid sm:grid-cols-3 sm:grid-rows-1',\n\t\t\t\t(count >= 4 || !gridItems) && 'grid grid-cols-2 grid-rows-2',\n\t\t\t)}\n\t\t\tonClick={() => {\n\t\t\t\t// If there are no items, navigate to the link\n\t\t\t\t// otherwise there isn't enough empty space\n\t\t\t\t// to register a meaningful click compared to the\n\t\t\t\t// items themselves, so we ignore the click event.\n\n\t\t\t\tif (count === 0) {\n\t\t\t\t\treturn onClick?.(link)\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t{/* Loading state */}\n\t\t\t{!gridItems && (\n\t\t\t\t<>\n\t\t\t\t\t<SkeletonGridItem />\n\t\t\t\t\t<SkeletonGridItem />\n\t\t\t\t\t<SkeletonGridItem />\n\t\t\t\t\t<SkeletonGridItem />\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{/* Empty state */}\n\t\t\t{gridItems?.length === 0 && (\n\t\t\t\t<div className='grid h-full w-full place-items-center pb-2 text-center text-xs text-white/50 sm:pb-4'>\n\t\t\t\t\t{t(`${noItemsText}`)}\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{/* Actual items  */}\n\t\t\t{gridItems &&\n\t\t\t\tgridItems.length > 0 &&\n\t\t\t\tgridItems\n\t\t\t\t\t.slice(0, 4)\n\t\t\t\t\t.map((item, index) => <GridItem key={item.path} item={item} index={index} count={gridItems.length} />)}\n\t\t</WidgetContainer>\n\t)\n}\n\nfunction SkeletonGridItem() {\n\treturn (\n\t\t<div className='flex h-full w-full items-center gap-1 rounded-5 bg-white/5 px-1 leading-none text-white/70 sm:gap-2 sm:px-2 sm:py-3'>\n\t\t\t{/* Placeholder icon */}\n\t\t\t<div className='h-4 w-4 animate-pulse rounded-xs bg-white/10 sm:h-8 sm:w-8 sm:rounded-md' />\n\t\t\t{/* name */}\n\t\t\t<div className='h-1 w-8 animate-pulse rounded-sm bg-white/10 sm:h-3 sm:w-16' />\n\t\t</div>\n\t)\n}\n\nfunction GridItem({item, count}: {item: FileSystemItem; index: number; count: number}) {\n\tconst {navigateToDirectory} = useNavigate()\n\treturn (\n\t\t<motion.div\n\t\t\tonClick={() => navigateToDirectory(item.path)}\n\t\t\twhileHover={{scale: 1.04, backgroundColor: 'rgba(255, 255, 255, 0.1)'}}\n\t\t\twhileTap={{scale: 0.96, backgroundColor: 'rgba(255, 255, 255, 0.1)'}}\n\t\t\tclassName={cn(\n\t\t\t\t'flex h-full w-full items-center rounded-5 bg-white/5 px-1 leading-none text-white/70 sm:py-3',\n\t\t\t\t'overflow-hidden',\n\t\t\t\t'[overflow-wrap:anywhere]',\n\t\t\t\t'sm:rounded-12 sm:px-2',\n\t\t\t\tcount === 1 && 'flex-col justify-center gap-1 text-center',\n\t\t\t\tcount === 2 && 'flex-col justify-center gap-1 text-center',\n\t\t\t\tcount === 3 && 'flex-row justify-start gap-1 sm:flex-col sm:justify-center',\n\t\t\t\tcount === 4 && 'flex-col justify-center gap-[0.2rem] sm:flex-row sm:justify-start sm:gap-[0.35rem]',\n\t\t\t)}\n\t\t>\n\t\t\t<FileItemIcon\n\t\t\t\titem={item}\n\t\t\t\tclassName={cn(count <= 2 ? 'h-8 w-8 sm:h-12 sm:w-12' : 'h-4 w-4 sm:h-8 sm:w-8', 'shrink-0')}\n\t\t\t/>\n\t\t\t<p\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'line-clamp-2 text-9 font-medium sm:text-11',\n\t\t\t\t\tcount === 1 && 'h-[16px] sm:h-[24px]',\n\t\t\t\t\tcount === 2 && 'h-[16px] sm:h-[24px]',\n\t\t\t\t\tcount === 3 && 'h-[11px] text-left sm:h-[24px] sm:text-center',\n\t\t\t\t\tcount === 4 && 'text-center sm:text-left',\n\t\t\t\t)}\n\t\t\t\ttitle={item?.name}\n\t\t\t>\n\t\t\t\t{formatItemName({name: item.name, maxLength: 20})}\n\t\t\t</p>\n\t\t</motion.div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/dialogs/add-to-raid-dialog.tsx",
    "content": "import {useEffect, useState} from 'react'\nimport {Trans} from 'react-i18next/TransWithoutContext'\nimport {IoShieldHalf} from 'react-icons/io5'\nimport {TbAlertTriangle, TbCircleCheckFilled} from 'react-icons/tb'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {Button} from '@/components/ui/button'\nimport {\n\tDialog,\n\tDialogDescription,\n\tDialogFooter,\n\tDialogHeader,\n\tDialogScrollableContent,\n\tDialogTitle,\n} from '@/components/ui/dialog'\nimport {Switch} from '@/components/ui/switch'\nimport {toast} from '@/components/ui/toast'\nimport {useActiveRaidOperation} from '@/features/storage/hooks/use-active-raid-operation'\nimport {usePendingRaidOperation} from '@/features/storage/providers/pending-operation-context'\nimport {t} from '@/utils/i18n'\n\nimport {getDeviceHealth, RaidDevice, StorageDevice} from '../../hooks/use-storage'\nimport {formatStorageSize} from '../../utils'\nimport {StorageDonutChart} from '../storage-donut-chart'\nimport {OperationInProgressBanner} from './operation-in-progress-banner'\n\n// --- Info Text Component ---\n\nconst Highlight = ({children}: {children?: React.ReactNode}) => <span className='text-white'>{children}</span>\n\nconst WastedText = ({children}: {children?: React.ReactNode}) => <span className='text-[#F5A623]'>{children}</span>\n\ntype InfoTextProps = {\n\tshowFailSafeOption: boolean\n\teffectiveMode: 'storage' | 'failsafe' | undefined\n\tnewDrivesRawBytes: number\n\tnewToAvailable: number\n\tnewToProtection: number\n\tnewToWasted: number\n\tadditionalFailsafeUsable: number\n\tadditionalStorageCapacity: number\n\tfailsafeWasted: number\n}\n\nfunction InfoText({\n\tshowFailSafeOption,\n\teffectiveMode,\n\tnewDrivesRawBytes,\n\tnewToAvailable,\n\tnewToProtection,\n\tnewToWasted,\n\tadditionalFailsafeUsable,\n\tadditionalStorageCapacity,\n\tfailsafeWasted,\n}: InfoTextProps) {\n\tconst newSize = formatStorageSize(newDrivesRawBytes)\n\n\t// Shared components for Trans\n\tconst transComponents = {\n\t\thighlight: <Highlight />,\n\t\twasted: <WastedText />,\n\t}\n\n\t// Storage mode (no protection)\n\tif (effectiveMode !== 'failsafe') {\n\t\tconst availableSize = formatStorageSize(showFailSafeOption ? newDrivesRawBytes : additionalStorageCapacity)\n\t\treturn (\n\t\t\t<div className='flex flex-col gap-2'>\n\t\t\t\t<p className='text-[13px] text-white/50'>\n\t\t\t\t\t<Trans\n\t\t\t\t\t\ti18nKey='storage-manager.add-to-raid.info-capacity-added'\n\t\t\t\t\t\tvalues={{size: availableSize}}\n\t\t\t\t\t\tcomponents={transComponents}\n\t\t\t\t\t/>\n\t\t\t\t\t{showFailSafeOption && ` ${t('storage-manager.add-to-raid.info-no-protection')}`}\n\t\t\t\t</p>\n\t\t\t\t{showFailSafeOption && (\n\t\t\t\t\t<p className='text-[13px] text-yellow-500'>\n\t\t\t\t\t\t<TbAlertTriangle className='mr-1 mb-0.5 inline size-4 align-middle' />\n\t\t\t\t\t\t{t('storage-manager.add-to-raid.warning-failsafe-now-only')}\n\t\t\t\t\t</p>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t)\n\t}\n\n\t// FailSafe mode - choosing mode (transitioning from storage)\n\tif (showFailSafeOption) {\n\t\tif (newToAvailable > 0) {\n\t\t\treturn (\n\t\t\t\t<p className='text-[13px] text-white/50'>\n\t\t\t\t\t<Trans\n\t\t\t\t\t\ti18nKey='storage-manager.add-to-raid.info-capacity-adds-both'\n\t\t\t\t\t\tvalues={{\n\t\t\t\t\t\t\tsize: newSize,\n\t\t\t\t\t\t\tavailable: formatStorageSize(newToAvailable),\n\t\t\t\t\t\t\tprotection: formatStorageSize(newToProtection),\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tcomponents={transComponents}\n\t\t\t\t\t/>{' '}\n\t\t\t\t\t{newToWasted > 0 && (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\t\ti18nKey='storage-manager.add-to-raid.info-wasted'\n\t\t\t\t\t\t\t\tvalues={{size: formatStorageSize(newToWasted)}}\n\t\t\t\t\t\t\t\tcomponents={transComponents}\n\t\t\t\t\t\t\t/>{' '}\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t\t{t('storage-manager.add-to-raid.info-data-safe')}\n\t\t\t\t</p>\n\t\t\t)\n\t\t}\n\t\t// New drives go entirely to protection\n\t\treturn (\n\t\t\t<p className='text-[13px] text-white/50'>\n\t\t\t\t{newToWasted > 0 ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\ti18nKey='storage-manager.add-to-raid.info-capacity-protection-only'\n\t\t\t\t\t\t\tvalues={{size: newSize, protection: formatStorageSize(newToProtection)}}\n\t\t\t\t\t\t\tcomponents={transComponents}\n\t\t\t\t\t\t/>{' '}\n\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\ti18nKey='storage-manager.add-to-raid.info-wasted'\n\t\t\t\t\t\t\tvalues={{size: formatStorageSize(newToWasted)}}\n\t\t\t\t\t\t\tcomponents={transComponents}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t<Trans\n\t\t\t\t\t\ti18nKey='storage-manager.add-to-raid.info-capacity-protection-only-full'\n\t\t\t\t\t\tvalues={{size: newSize}}\n\t\t\t\t\t\tcomponents={transComponents}\n\t\t\t\t\t/>\n\t\t\t\t)}{' '}\n\t\t\t\t{t('storage-manager.add-to-raid.info-data-safe')}\n\t\t\t</p>\n\t\t)\n\t}\n\n\t// FailSafe mode - already in failsafe, just adding\n\treturn (\n\t\t<p className='text-[13px] text-white/50'>\n\t\t\t<Trans\n\t\t\t\ti18nKey='storage-manager.add-to-raid.info-capacity-adds-available'\n\t\t\t\tvalues={{\n\t\t\t\t\tsize: newSize,\n\t\t\t\t\tavailable: formatStorageSize(additionalFailsafeUsable),\n\t\t\t\t}}\n\t\t\t\tcomponents={transComponents}\n\t\t\t/>\n\t\t\t{failsafeWasted > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t{' '}\n\t\t\t\t\t<Trans\n\t\t\t\t\t\ti18nKey='storage-manager.add-to-raid.info-total-wasted'\n\t\t\t\t\t\tvalues={{size: formatStorageSize(failsafeWasted)}}\n\t\t\t\t\t\tcomponents={transComponents}\n\t\t\t\t\t/>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</p>\n\t)\n}\n\n// --- Add to RAID Dialog Component ---\n// TODO: Currently limited to adding 1 SSD at a time due to ZFS raidz1 expansion limitations.\n// When backend supports adding multiple SSDs at once, update this dialog to handle multiple devices.\n\ntype AddToRaidDialogProps = {\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n\tdevice: StorageDevice | null\n\tcanChooseMode: boolean\n\traidType?: 'storage' | 'failsafe'\n\traidDevices: RaidDevice[] // Devices currently in the RAID array\n\taddDeviceAsync: (params: {device: string}) => Promise<boolean>\n\ttransitionToFailsafeAsync: (params: {device: string}) => Promise<boolean>\n}\n\nexport function AddToRaidDialog({\n\topen,\n\tonOpenChange,\n\tdevice,\n\tcanChooseMode,\n\traidType,\n\traidDevices,\n\taddDeviceAsync,\n\ttransitionToFailsafeAsync,\n}: AddToRaidDialogProps) {\n\t// State for FailSafe toggle and confirmation dialog\n\tconst [failSafeEnabled, setFailSafeEnabled] = useState(false)\n\tconst [showRestartConfirmation, setShowRestartConfirmation] = useState(false)\n\n\t// Context for showing island immediately for non-blocking operations\n\tconst {setPendingOperation, clearPendingOperation} = usePendingRaidOperation()\n\n\t// Check if a RAID operation is already in progress\n\tconst activeOperation = useActiveRaidOperation()\n\tconst isOperationInProgress = !!activeOperation\n\n\t// Get existing RAID devices - these are the devices actually in the RAID array\n\t// (not to be confused with all detected devices)\n\tconst existingCount = raidDevices.length\n\tconst existingRoundedSizes = raidDevices.map((d) => d.roundedSize)\n\tconst existingSmallestRounded = existingRoundedSizes.length > 0 ? Math.min(...existingRoundedSizes) : 0\n\n\t// Size validation for ZFS operations (using roundedSize for consistency with backend partitioning):\n\t// - Failsafe mode add: new device must be >= smallest device in array\n\t// - Transition to failsafe: new device must be >= the current single device\n\t// - Storage mode add: no size restriction\n\tconst newDeviceRoundedSize = device?.roundedSize ?? 0\n\tconst isDeviceTooSmallForFailsafe = existingSmallestRounded > 0 && newDeviceRoundedSize < existingSmallestRounded\n\n\t// Calculate projected capacities after adding the new device (using roundedSize)\n\tconst allRoundedSizes = device ? [...existingRoundedSizes, newDeviceRoundedSize] : existingRoundedSizes\n\tconst totalRoundedBytes = allRoundedSizes.reduce((sum, size) => sum + size, 0)\n\tconst smallestRoundedSize = allRoundedSizes.length > 0 ? Math.min(...allRoundedSizes) : 0\n\tconst driveCount = allRoundedSizes.length\n\n\t// Check if all drives have the same roundedSize (no wasted space)\n\tconst allSameSize = allRoundedSizes.length > 0 && allRoundedSizes.every((s) => s === smallestRoundedSize)\n\n\t// Reset state when dialog opens\n\t// allSameSize intentionally excluded from deps - we only reset state on dialog open, not when sizes change\n\tuseEffect(() => {\n\t\tif (open) {\n\t\t\tsetFailSafeEnabled(allSameSize)\n\t\t\tsetShowRestartConfirmation(false)\n\t\t}\n\t}, [open])\n\n\t// Early return after all hooks\n\tif (!device) return null\n\n\t// Calculate capacity for each mode (after adding) - using roundedSize for failsafe calculations\n\tconst storageCapacity = totalRoundedBytes\n\tconst failsafeUsable = driveCount > 1 ? smallestRoundedSize * (driveCount - 1) : 0\n\tconst failsafeProtection = smallestRoundedSize\n\tconst failsafeWasted = Math.max(0, totalRoundedBytes - failsafeUsable - failsafeProtection)\n\n\t// Calculate current capacity (before adding)\n\tconst currentFailsafeUsable = existingCount > 1 ? existingSmallestRounded * (existingCount - 1) : 0\n\tconst existingAvailable = existingRoundedSizes.reduce((sum, size) => sum + size, 0)\n\n\t// Calculate what the new drive adds\n\tconst additionalStorageCapacity = newDeviceRoundedSize\n\tconst additionalFailsafeUsable = failsafeUsable - currentFailsafeUsable\n\n\t// For canChooseMode: calculate where the new drive's capacity goes\n\tconst newToAvailable = Math.max(0, failsafeUsable - existingAvailable)\n\tconst newToProtection = failsafeProtection\n\tconst newToWasted = failsafeWasted\n\n\t// Determine what to show based on canChooseMode and toggle\n\tconst showFailSafeOption = canChooseMode\n\tconst effectiveMode = canChooseMode ? (failSafeEnabled ? 'failsafe' : 'storage') : raidType\n\n\t// Block operation if device is too small for the selected/required mode\n\tconst isBlockedBySize =\n\t\t(raidType === 'failsafe' && isDeviceTooSmallForFailsafe) || // Adding to existing failsafe\n\t\t(canChooseMode && failSafeEnabled && isDeviceTooSmallForFailsafe) // Transitioning to failsafe\n\n\t// Chart data based on effective mode\n\tconst chartUsed = 0 // No used space yet for preview\n\tconst chartAvailable = effectiveMode === 'failsafe' ? failsafeUsable / 1e12 : storageCapacity / 1e12\n\tconst chartFailsafe = effectiveMode === 'failsafe' ? failsafeProtection / 1e12 : 0\n\tconst chartWasted = effectiveMode === 'failsafe' ? failsafeWasted / 1e12 : 0\n\n\t// Execute the actual add operation\n\t// All modes use the floating island for consistent UX\n\tconst executeAddDevice = (useFailsafe: boolean) => {\n\t\tif (!device?.id || !device.slot) return\n\n\t\tsetShowRestartConfirmation(false)\n\n\t\tif (useFailsafe) {\n\t\t\t// Failsafe transition: non-blocking - we show island immediately\n\t\t\tsetPendingOperation({\n\t\t\t\ttype: 'failsafe-transition',\n\t\t\t\tstate: 'starting',\n\t\t\t\tprogress: 0,\n\t\t\t})\n\t\t\tonOpenChange(false)\n\n\t\t\ttransitionToFailsafeAsync({device: device.id}).catch((error) => {\n\t\t\t\tclearPendingOperation()\n\t\t\t\ttoast.error(t('storage-manager.add-to-raid.failed-enable-failsafe'), {\n\t\t\t\t\tdescription: error instanceof Error ? error.message : t('unknown-error'),\n\t\t\t\t})\n\t\t\t})\n\t\t} else if (raidType === 'failsafe') {\n\t\t\t// Adding to existing failsafe: non-blocking expansion - we show island immediately\n\t\t\tsetPendingOperation({\n\t\t\t\ttype: 'expansion',\n\t\t\t\tstate: 'starting',\n\t\t\t\tprogress: 0,\n\t\t\t})\n\t\t\tonOpenChange(false)\n\n\t\t\taddDeviceAsync({device: device.id}).catch((error) => {\n\t\t\t\tclearPendingOperation()\n\t\t\t\ttoast.error(t('storage-manager.add-to-raid.failed-add'), {\n\t\t\t\t\tdescription: error instanceof Error ? error.message : t('unknown-error'),\n\t\t\t\t})\n\t\t\t})\n\t\t} else {\n\t\t\t// Storage mode add: blocking RPC with no progress events.\n\t\t\t// We still show the floating island (without percentage) for consistent UX across all\n\t\t\t// add operations. This lets us close the dialog immediately while giving visual feedback\n\t\t\t// that something is happening, rather than leaving the user wondering if their click worked or setting the UI in a loading state.\n\t\t\tsetPendingOperation({\n\t\t\t\ttype: 'expansion',\n\t\t\t\tstate: 'adding',\n\t\t\t\tprogress: 0,\n\t\t\t})\n\t\t\tonOpenChange(false)\n\n\t\t\taddDeviceAsync({device: device.id})\n\t\t\t\t.then(() => {\n\t\t\t\t\t// Show finished state briefly before island disappears\n\t\t\t\t\tsetPendingOperation({\n\t\t\t\t\t\ttype: 'expansion',\n\t\t\t\t\t\tstate: 'finished',\n\t\t\t\t\t\tprogress: 100,\n\t\t\t\t\t})\n\t\t\t\t\tsetTimeout(() => clearPendingOperation(), 2000)\n\t\t\t\t})\n\t\t\t\t.catch((error) => {\n\t\t\t\t\tclearPendingOperation()\n\t\t\t\t\ttoast.error(t('storage-manager.add-to-raid.failed-add'), {\n\t\t\t\t\t\tdescription: error instanceof Error ? error.message : t('unknown-error'),\n\t\t\t\t\t})\n\t\t\t\t})\n\t\t}\n\t}\n\n\tconst handleAddDevice = () => {\n\t\tif (!device?.id || !device.slot) return\n\n\t\t// If failsafe is enabled (canChooseMode), show confirmation dialog first\n\t\tif (canChooseMode && failSafeEnabled) {\n\t\t\tsetShowRestartConfirmation(true)\n\t\t\treturn\n\t\t}\n\n\t\t// Otherwise proceed directly\n\t\texecuteAddDevice(false)\n\t}\n\n\tconst {hasWarning} = getDeviceHealth(device)\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog open={open} onOpenChange={onOpenChange}>\n\t\t\t\t<DialogScrollableContent>\n\t\t\t\t\t<div className='flex flex-col gap-5 p-5'>\n\t\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t\t<DialogTitle>{t('storage-manager.add-to-raid.title')}</DialogTitle>\n\t\t\t\t\t\t\t<DialogDescription>{t('storage-manager.add-to-raid.description')}</DialogDescription>\n\t\t\t\t\t\t</DialogHeader>\n\n\t\t\t\t\t\t{/* SSD summary - \"SSD\" and \"Slot\" labels are not translated as they match physical device markings */}\n\t\t\t\t\t\t<div className='flex flex-col divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>\n\t\t\t\t\t\t\t<div className='flex items-center justify-between gap-2 px-3 py-2.5'>\n\t\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t\t{hasWarning ? (\n\t\t\t\t\t\t\t\t\t\t<TbAlertTriangle className='size-5 text-[#F5A623]' />\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t<TbCircleCheckFilled className='size-5 text-brand' />\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t<span className='text-[13px] font-medium text-white/60'>\n\t\t\t\t\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\t\t\t\t\ti18nKey='storage-manager.add-to-raid.ssd-in-slot'\n\t\t\t\t\t\t\t\t\t\t\tvalues={{size: formatStorageSize(device.size), slot: device.slot}}\n\t\t\t\t\t\t\t\t\t\t\tcomponents={{highlight: <Highlight />}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* Capacity preview card */}\n\t\t\t\t\t\t<div className='flex flex-col gap-4 rounded-12 bg-white/6 p-4'>\n\t\t\t\t\t\t\t{/* FailSafe toggle - only when user can choose */}\n\t\t\t\t\t\t\t{showFailSafeOption && (\n\t\t\t\t\t\t\t\t<div className='flex items-center justify-between'>\n\t\t\t\t\t\t\t\t\t<div className='flex items-center gap-3'>\n\t\t\t\t\t\t\t\t\t\t<Switch checked={failSafeEnabled} onCheckedChange={setFailSafeEnabled} />\n\t\t\t\t\t\t\t\t\t\t<span className='text-[15px] text-white/85'>\n\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.add-to-raid.enable-failsafe')}\n\t\t\t\t\t\t\t\t\t\t\t{/* Mobile: inline text instead of pill to save space */}\n\t\t\t\t\t\t\t\t\t\t\t{allSameSize && (\n\t\t\t\t\t\t\t\t\t\t\t\t<span className='text-white/50 sm:hidden'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{' '}\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.add-to-raid.recommended-inline')}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t{/* Desktop: pill on the right */}\n\t\t\t\t\t\t\t\t\t{allSameSize && (\n\t\t\t\t\t\t\t\t\t\t<div className='hidden items-center gap-1.5 rounded-full bg-white/10 px-3 py-1 sm:flex'>\n\t\t\t\t\t\t\t\t\t\t\t<IoShieldHalf className='size-4 text-white' />\n\t\t\t\t\t\t\t\t\t\t\t<span className='text-[13px] text-white'>{t('storage-manager.add-to-raid.recommended')}</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{/* Info text and donut chart - hidden when size validation fails */}\n\t\t\t\t\t\t\t{!isBlockedBySize && (\n\t\t\t\t\t\t\t\t<InfoText\n\t\t\t\t\t\t\t\t\tshowFailSafeOption={showFailSafeOption}\n\t\t\t\t\t\t\t\t\teffectiveMode={effectiveMode}\n\t\t\t\t\t\t\t\t\tnewDrivesRawBytes={newDeviceRoundedSize}\n\t\t\t\t\t\t\t\t\tnewToAvailable={newToAvailable}\n\t\t\t\t\t\t\t\t\tnewToProtection={newToProtection}\n\t\t\t\t\t\t\t\t\tnewToWasted={newToWasted}\n\t\t\t\t\t\t\t\t\tadditionalFailsafeUsable={additionalFailsafeUsable}\n\t\t\t\t\t\t\t\t\tadditionalStorageCapacity={additionalStorageCapacity}\n\t\t\t\t\t\t\t\t\tfailsafeWasted={failsafeWasted}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{/* Donut chart preview OR size warning */}\n\t\t\t\t\t\t\t{isBlockedBySize ? (\n\t\t\t\t\t\t\t\t<div className='flex items-start gap-3 rounded-12 bg-[#F5A623]/10 p-3'>\n\t\t\t\t\t\t\t\t\t<TbAlertTriangle className='mt-0.5 size-5 shrink-0 text-[#F5A623]' />\n\t\t\t\t\t\t\t\t\t<div className='flex flex-col gap-1'>\n\t\t\t\t\t\t\t\t\t\t<span className='text-13 font-semibold text-[#F5A623]'>\n\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.add-to-raid.too-small')}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span className='text-12 text-white/60'>\n\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.add-to-raid.too-small-description', {\n\t\t\t\t\t\t\t\t\t\t\t\tdeviceSize: formatStorageSize(device.size),\n\t\t\t\t\t\t\t\t\t\t\t\tminSize: formatStorageSize(existingSmallestRounded),\n\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<div className='flex items-center gap-4'>\n\t\t\t\t\t\t\t\t\t<StorageDonutChart\n\t\t\t\t\t\t\t\t\t\tused={chartUsed}\n\t\t\t\t\t\t\t\t\t\tavailable={chartAvailable}\n\t\t\t\t\t\t\t\t\t\tfailsafe={chartFailsafe}\n\t\t\t\t\t\t\t\t\t\twasted={chartWasted}\n\t\t\t\t\t\t\t\t\t\thideCenter\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<div className='flex flex-col gap-1 text-[13px]'>\n\t\t\t\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t\t\t\t<span className='size-2 rounded-full bg-brand' />\n\t\t\t\t\t\t\t\t\t\t\t<span className='text-white/60'>\n\t\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.add-to-raid.available')}{' '}\n\t\t\t\t\t\t\t\t\t\t\t\t<span className='text-white'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{formatStorageSize(effectiveMode === 'failsafe' ? failsafeUsable : storageCapacity)}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t{effectiveMode === 'failsafe' && (\n\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='size-2 rounded-full'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{backgroundColor: 'color-mix(in srgb, hsl(var(--color-brand)), white 60%)'}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='text-white/60'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.add-to-raid.failsafe-label')}{' '}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='text-white'>{formatStorageSize(failsafeProtection)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t{failsafeWasted > 0 && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='size-2 rounded-full bg-[#F5A623]' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='text-white/60'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.add-to-raid.wasted-label')}{' '}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='text-white'>{formatStorageSize(failsafeWasted)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{isOperationInProgress && <OperationInProgressBanner variant='wait' />}\n\n\t\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t\t<Button variant='primary' onClick={handleAddDevice} disabled={isBlockedBySize || isOperationInProgress}>\n\t\t\t\t\t\t\t\t{t('storage-manager.add-to-raid.add-ssd')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button variant='default' onClick={() => onOpenChange(false)}>\n\t\t\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</DialogFooter>\n\t\t\t\t\t</div>\n\t\t\t\t</DialogScrollableContent>\n\t\t\t</Dialog>\n\n\t\t\t{/* Confirmation dialog for failsafe transition - warns about restart */}\n\t\t\t<AlertDialog open={showRestartConfirmation} onOpenChange={setShowRestartConfirmation}>\n\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t<AlertDialogHeader icon={IoShieldHalf}>\n\t\t\t\t\t\t<AlertDialogTitle>{t('storage-manager.add-to-raid.restart-required')}</AlertDialogTitle>\n\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t<div className='flex flex-col gap-3 text-14 leading-tight -tracking-3 text-white/75'>\n\t\t\t\t\t\t<p>{t('storage-manager.add-to-raid.restart-intro')}</p>\n\t\t\t\t\t\t<p>{t('storage-manager.add-to-raid.restart-during')}</p>\n\t\t\t\t\t\t<ul className='flex list-disc flex-col gap-1 pl-4'>\n\t\t\t\t\t\t\t<li>{t('storage-manager.add-to-raid.restart-active-tasks')}</li>\n\t\t\t\t\t\t\t<li>{t('storage-manager.add-to-raid.restart-ui-inaccessible')}</li>\n\t\t\t\t\t\t</ul>\n\t\t\t\t\t\t<p>{t('storage-manager.add-to-raid.restart-after')}</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t\t<AlertDialogAction\n\t\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\t\tdisabled={isOperationInProgress}\n\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\t\texecuteAddDevice(true)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('storage-manager.add-to-raid.understand-continue')}\n\t\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t\t<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t</AlertDialogContent>\n\t\t\t</AlertDialog>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/dialogs/install-ssd-dialog.tsx",
    "content": "import {useEffect, useState} from 'react'\n\nimport {Button} from '@/components/ui/button'\nimport {\n\tDialog,\n\tDialogDescription,\n\tDialogFooter,\n\tDialogHeader,\n\tDialogScrollableContent,\n\tDialogTitle,\n} from '@/components/ui/dialog'\nimport {useActiveRaidOperation} from '@/features/storage/hooks/use-active-raid-operation'\nimport {t} from '@/utils/i18n'\n\nimport {InstallTipsCollapsible} from './install-tips-collapsible'\nimport {OperationInProgressBanner} from './operation-in-progress-banner'\nimport {ShutdownConfirmationDialog} from './shutdown-confirmation-dialog'\n\ntype InstallSsdDialogProps = {\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n\tisUmbrelPro: boolean\n}\n\nexport function InstallSsdDialog({open, onOpenChange, isUmbrelPro}: InstallSsdDialogProps) {\n\tconst [showInstallTips, setShowInstallTips] = useState(false)\n\tconst [showShutdownConfirmation, setShowShutdownConfirmation] = useState(false)\n\n\t// Check if a RAID operation is already in progress\n\tconst activeOperation = useActiveRaidOperation()\n\tconst isOperationInProgress = !!activeOperation\n\n\t// Reset state when dialog closes\n\tuseEffect(() => {\n\t\tif (!open) {\n\t\t\tsetShowShutdownConfirmation(false)\n\t\t\tsetShowInstallTips(false)\n\t\t}\n\t}, [open])\n\n\tconst deviceName = isUmbrelPro ? 'Umbrel Pro' : 'device'\n\tconst steps = [\n\t\tt('storage-manager.install-ssd.step-shut-down', {deviceName}),\n\t\t...(isUmbrelPro ? [t('storage-manager.install-ssd.step-remove-bottom-cover')] : []),\n\t\tt('storage-manager.install-ssd.step-insert'),\n\t\t...(isUmbrelPro ? [t('storage-manager.install-ssd.step-replace-bottom-cover')] : []),\n\t\tt('storage-manager.install-ssd.step-power-on', {deviceName}),\n\t\tt('storage-manager.install-ssd.step-return'),\n\t]\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog open={open} onOpenChange={onOpenChange}>\n\t\t\t\t<DialogScrollableContent>\n\t\t\t\t\t<div className='flex flex-col gap-5 p-5'>\n\t\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t\t<DialogTitle>{t('storage-manager.install-ssd.title')}</DialogTitle>\n\t\t\t\t\t\t\t<DialogDescription>{t('storage-manager.install-ssd.description')}</DialogDescription>\n\t\t\t\t\t\t</DialogHeader>\n\n\t\t\t\t\t\t{/* Instruction steps */}\n\t\t\t\t\t\t<div className='divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>\n\t\t\t\t\t\t\t{steps.map((step, index) => (\n\t\t\t\t\t\t\t\t<div key={index} className='flex items-center gap-3 p-3 text-12 font-medium -tracking-3'>\n\t\t\t\t\t\t\t\t\t<span className='flex size-5 shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold'>\n\t\t\t\t\t\t\t\t\t\t{index + 1}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span>{step}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* Collapsible installation tips - Umbrel Pro only */}\n\t\t\t\t\t\t{isUmbrelPro && (\n\t\t\t\t\t\t\t<InstallTipsCollapsible isOpen={showInstallTips} onToggle={() => setShowInstallTips(!showInstallTips)} />\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{isOperationInProgress && <OperationInProgressBanner variant='shutdown-safe' />}\n\n\t\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t\t<Button variant='destructive' onClick={() => setShowShutdownConfirmation(true)}>\n\t\t\t\t\t\t\t\t{t('shut-down')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button variant='default' onClick={() => onOpenChange(false)}>\n\t\t\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</DialogFooter>\n\t\t\t\t\t</div>\n\t\t\t\t</DialogScrollableContent>\n\t\t\t</Dialog>\n\n\t\t\t<ShutdownConfirmationDialog open={showShutdownConfirmation} onOpenChange={setShowShutdownConfirmation} />\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/dialogs/install-tips-collapsible.tsx",
    "content": "// We only ever render this component for Umbrel Pro since it is pro-specific instructions\n\nimport {ChevronDown, ChevronUp} from 'lucide-react'\nimport {AnimatePresence, motion} from 'motion/react'\n\nimport {t} from '@/utils/i18n'\n\ntype InstallTipsCollapsibleProps = {\n\tisOpen: boolean\n\tonToggle: () => void\n}\n\nexport function InstallTipsCollapsible({isOpen, onToggle}: InstallTipsCollapsibleProps) {\n\treturn (\n\t\t<div>\n\t\t\t<button\n\t\t\t\tonClick={onToggle}\n\t\t\t\tclassName='flex w-full items-center justify-between text-xs font-medium text-brand-lightest transition-opacity duration-300 hover:opacity-80'\n\t\t\t>\n\t\t\t\t{t('storage-manager.install-tips.toggle')}\n\t\t\t\t{isOpen ? <ChevronUp className='size-4' /> : <ChevronDown className='size-4' />}\n\t\t\t</button>\n\n\t\t\t<AnimatePresence>\n\t\t\t\t{isOpen && (\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tinitial={{height: 0, opacity: 0}}\n\t\t\t\t\t\tanimate={{height: 'auto', opacity: 1}}\n\t\t\t\t\t\texit={{height: 0, opacity: 0}}\n\t\t\t\t\t\ttransition={{duration: 0.3}}\n\t\t\t\t\t\tclassName='overflow-hidden'\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className='space-y-3'>\n\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\tsrc='/assets/storage/install-ssd-instruction.webp'\n\t\t\t\t\t\t\t\talt={t('storage-manager.install-tips.image-alt')}\n\t\t\t\t\t\t\t\tclassName='w-full rounded-8'\n\t\t\t\t\t\t\t\tdraggable={false}\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\taspectRatio: '1646 / 1186',\n\t\t\t\t\t\t\t\t\tmaskImage:\n\t\t\t\t\t\t\t\t\t\t'linear-gradient(to right, transparent 0%, black 15%), linear-gradient(to bottom, black 85%, transparent 100%)',\n\t\t\t\t\t\t\t\t\tmaskComposite: 'intersect',\n\t\t\t\t\t\t\t\t\tWebkitMaskImage:\n\t\t\t\t\t\t\t\t\t\t'linear-gradient(to right, transparent 0%, black 15%), linear-gradient(to bottom, black 85%, transparent 100%)',\n\t\t\t\t\t\t\t\t\tWebkitMaskComposite: 'source-in',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<p className='text-12 leading-relaxed text-white/60'>{t('storage-manager.install-tips.instructions')}</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</motion.div>\n\t\t\t\t)}\n\t\t\t</AnimatePresence>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/dialogs/operation-in-progress-banner.tsx",
    "content": "import {TbClock} from 'react-icons/tb'\n\nimport {t} from '@/utils/i18n'\n\n// Banner shown in storage dialogs when a RAID operation is in progress.\n// ZFS only allows one operation (expansion, rebuild, replace) at a time, so we\n// inform the user and disable actions that would start a new operation.\ntype OperationInProgressBannerProps = {\n\tvariant: 'wait' | 'shutdown-safe'\n}\n\nexport function OperationInProgressBanner({variant}: OperationInProgressBannerProps) {\n\treturn (\n\t\t<div className='flex items-start gap-3 rounded-12 bg-[#F5A623]/10 p-3'>\n\t\t\t<TbClock className='mt-0.5 size-5 shrink-0 text-[#F5A623]' />\n\t\t\t<div className='flex flex-col gap-1'>\n\t\t\t\t{/* - 'wait' variant: disables the action button. User must wait for operation to complete. */}\n\t\t\t\t<span className='text-13 font-semibold text-[#F5A623]'>\n\t\t\t\t\t{variant === 'wait'\n\t\t\t\t\t\t? t('storage-manager.operation-in-progress.wait-title')\n\t\t\t\t\t\t: t('storage-manager.operation-in-progress.shutdown-title')}\n\t\t\t\t</span>\n\t\t\t\t{/* - 'shutdown-safe' variant: shutdown button stays enabled because ZFS operations resume after restart, but we still provide a warning. */}\n\t\t\t\t<span className='text-12 text-white/60'>\n\t\t\t\t\t{variant === 'wait'\n\t\t\t\t\t\t? t('storage-manager.operation-in-progress.wait-description')\n\t\t\t\t\t\t: t('storage-manager.operation-in-progress.shutdown-description')}\n\t\t\t\t</span>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/dialogs/replace-failed-drive-dialog.tsx",
    "content": "import {Trans} from 'react-i18next/TransWithoutContext'\nimport {TbAlertTriangle, TbCircleCheckFilled} from 'react-icons/tb'\n\nimport {Button} from '@/components/ui/button'\nimport {\n\tDialog,\n\tDialogDescription,\n\tDialogFooter,\n\tDialogHeader,\n\tDialogScrollableContent,\n\tDialogTitle,\n} from '@/components/ui/dialog'\nimport {toast} from '@/components/ui/toast'\nimport {useActiveRaidOperation} from '@/features/storage/hooks/use-active-raid-operation'\nimport {usePendingRaidOperation} from '@/features/storage/providers/pending-operation-context'\nimport {t} from '@/utils/i18n'\n\nimport {getDeviceHealth, StorageDevice} from '../../hooks/use-storage'\nimport {formatStorageSize} from '../../utils'\nimport {OperationInProgressBanner} from './operation-in-progress-banner'\n\nconst Highlight = ({children}: {children?: React.ReactNode}) => <span className='text-white'>{children}</span>\n\ntype FailedRaidDevice = {\n\tid: string\n\tstatus: string\n\treadErrors: number\n\twriteErrors: number\n\tchecksumErrors: number\n}\n\ntype ReplaceFailedDriveDialogProps = {\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n\t/** The new physical device to use as replacement */\n\tnewDevice: StorageDevice | null\n\t/** The failed RAID device to replace */\n\tfailedDevice: FailedRaidDevice | null\n\t/** Minimum rounded size in the current RAID array (for size validation) */\n\tminRoundedDriveSize: number\n\treplaceDeviceAsync: (params: {oldDevice: string; newDevice: string}) => Promise<boolean>\n}\n\nexport function ReplaceFailedDriveDialog({\n\topen,\n\tonOpenChange,\n\tnewDevice,\n\tfailedDevice,\n\tminRoundedDriveSize,\n\treplaceDeviceAsync,\n}: ReplaceFailedDriveDialogProps) {\n\tconst {setPendingOperation, clearPendingOperation} = usePendingRaidOperation()\n\n\t// Check if a RAID operation is already in progress\n\tconst activeOperation = useActiveRaidOperation()\n\tconst isOperationInProgress = !!activeOperation\n\n\tif (!newDevice || !failedDevice) return null\n\n\t// Size validation: new device must be at least as large as the smallest device in the array\n\tconst isDeviceTooSmall = minRoundedDriveSize > 0 && (newDevice.roundedSize ?? newDevice.size) < minRoundedDriveSize\n\n\tconst {hasWarning} = getDeviceHealth(newDevice)\n\n\tconst handleReplace = () => {\n\t\tif (!newDevice?.id || !failedDevice?.id) return\n\n\t\t// Replace is non-blocking - show island immediately\n\t\tsetPendingOperation({\n\t\t\ttype: 'replace',\n\t\t\tstate: 'starting',\n\t\t\tprogress: 0,\n\t\t})\n\t\tonOpenChange(false)\n\n\t\treplaceDeviceAsync({\n\t\t\toldDevice: failedDevice.id,\n\t\t\tnewDevice: newDevice.id,\n\t\t}).catch((error) => {\n\t\t\tclearPendingOperation()\n\t\t\ttoast.error(t('storage-manager.replace-failed.error'), {\n\t\t\t\tdescription: error instanceof Error ? error.message : t('unknown-error'),\n\t\t\t})\n\t\t})\n\t}\n\n\treturn (\n\t\t<Dialog open={open} onOpenChange={onOpenChange}>\n\t\t\t<DialogScrollableContent>\n\t\t\t\t<div className='flex flex-col gap-5 p-5'>\n\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t<DialogTitle>{t('storage-manager.replace-failed.title')}</DialogTitle>\n\t\t\t\t\t\t<DialogDescription>{t('storage-manager.replace-failed.description')}</DialogDescription>\n\t\t\t\t\t</DialogHeader>\n\n\t\t\t\t\t{/* Degraded warning banner */}\n\t\t\t\t\t<div className='flex items-start gap-3 rounded-12 bg-destructive2/10 p-3'>\n\t\t\t\t\t\t<TbAlertTriangle className='mt-0.5 size-5 shrink-0 text-destructive2' />\n\t\t\t\t\t\t<div className='flex flex-col gap-1'>\n\t\t\t\t\t\t\t<span className='text-13 font-semibold text-destructive2'>\n\t\t\t\t\t\t\t\t{t('storage-manager.replace-failed.degraded')}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span className='text-12 text-white/60'>{t('storage-manager.replace-failed.degraded-description')}</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* New SSD summary */}\n\t\t\t\t\t<div className='flex flex-col divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>\n\t\t\t\t\t\t<div className='flex items-center justify-between gap-2 px-3 py-2.5'>\n\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t{hasWarning ? (\n\t\t\t\t\t\t\t\t\t<TbAlertTriangle className='size-5 text-[#F5A623]' />\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t<TbCircleCheckFilled className='size-5 text-brand' />\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{/* \"SSD\" and \"Slot\" labels are not translated as they match physical device markings */}\n\t\t\t\t\t\t\t\t<span className='text-[13px] font-medium text-white/60'>\n\t\t\t\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\t\t\t\ti18nKey='storage-manager.replace-failed.ssd-in-slot'\n\t\t\t\t\t\t\t\t\t\tvalues={{size: formatStorageSize(newDevice.size), slot: newDevice.slot}}\n\t\t\t\t\t\t\t\t\t\tcomponents={{highlight: <Highlight />}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Size validation warning */}\n\t\t\t\t\t{isDeviceTooSmall ? (\n\t\t\t\t\t\t<div className='flex items-start gap-3 rounded-12 bg-[#F5A623]/10 p-3'>\n\t\t\t\t\t\t\t<TbAlertTriangle className='mt-0.5 size-5 shrink-0 text-[#F5A623]' />\n\t\t\t\t\t\t\t<div className='flex flex-col gap-1'>\n\t\t\t\t\t\t\t\t<span className='text-13 font-semibold text-[#F5A623]'>\n\t\t\t\t\t\t\t\t\t{t('storage-manager.replace-failed.too-small')}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<span className='text-12 text-white/60'>\n\t\t\t\t\t\t\t\t\t{t('storage-manager.replace-failed.too-small-description', {\n\t\t\t\t\t\t\t\t\t\tdeviceSize: formatStorageSize(newDevice.size),\n\t\t\t\t\t\t\t\t\t\tminSize: formatStorageSize(minRoundedDriveSize),\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t/* What happens next */\n\t\t\t\t\t\t<div className='flex flex-col gap-2'>\n\t\t\t\t\t\t\t<span className='text-13 font-medium text-white/60'>\n\t\t\t\t\t\t\t\t{t('storage-manager.replace-failed.what-happens')}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<div className='divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>\n\t\t\t\t\t\t\t\t{[\n\t\t\t\t\t\t\t\t\tt('storage-manager.replace-failed.step-rebuild'),\n\t\t\t\t\t\t\t\t\tt('storage-manager.replace-failed.step-time'),\n\t\t\t\t\t\t\t\t\tt('storage-manager.replace-failed.step-protected'),\n\t\t\t\t\t\t\t\t].map((step, index) => (\n\t\t\t\t\t\t\t\t\t<div key={index} className='flex items-center gap-3 p-3 text-12 font-medium -tracking-3'>\n\t\t\t\t\t\t\t\t\t\t<span className='flex size-5 shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold'>\n\t\t\t\t\t\t\t\t\t\t\t{index + 1}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span>{step}</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{isOperationInProgress && <OperationInProgressBanner variant='wait' />}\n\n\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t<Button variant='primary' onClick={handleReplace} disabled={isDeviceTooSmall || isOperationInProgress}>\n\t\t\t\t\t\t\t{t('storage-manager.replace-failed.replace-now')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<Button variant='default' onClick={() => onOpenChange(false)}>\n\t\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</DialogFooter>\n\t\t\t\t</div>\n\t\t\t</DialogScrollableContent>\n\t\t</Dialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/dialogs/shutdown-confirmation-dialog.tsx",
    "content": "import {RiShutDownLine} from 'react-icons/ri'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {useGlobalSystemState} from '@/providers/global-system-state/index'\nimport {t} from '@/utils/i18n'\n\ntype ShutdownConfirmationDialogProps = {\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n}\n\nexport function ShutdownConfirmationDialog({open, onOpenChange}: ShutdownConfirmationDialogProps) {\n\tconst {shutdown} = useGlobalSystemState()\n\n\treturn (\n\t\t<AlertDialog open={open} onOpenChange={onOpenChange}>\n\t\t\t<AlertDialogContent>\n\t\t\t\t<AlertDialogHeader icon={RiShutDownLine}>\n\t\t\t\t\t<AlertDialogTitle>{t('shut-down.confirm.title')}</AlertDialogTitle>\n\t\t\t\t</AlertDialogHeader>\n\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t<AlertDialogAction\n\t\t\t\t\t\tvariant='destructive'\n\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\tshutdown()\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('shut-down.confirm.submit')}\n\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t</AlertDialogFooter>\n\t\t\t</AlertDialogContent>\n\t\t</AlertDialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/dialogs/ssd-health-dialog.tsx",
    "content": "import {useState} from 'react'\nimport {TbActivityHeartbeat, TbAlertTriangle, TbAlertTriangleFilled} from 'react-icons/tb'\n\nimport {FadeScroller} from '@/components/fade-scroller'\nimport {Dialog, DialogHeader, DialogScrollableContent, DialogTitle} from '@/components/ui/dialog'\nimport {useTemperatureUnit} from '@/hooks/use-temperature-unit'\nimport {t} from '@/utils/i18n'\nimport {formatTemperature} from '@/utils/temperature'\nimport {tw} from '@/utils/tw'\n\nimport {getDeviceHealth, RaidDevice, raidStatusLabels, StorageDevice} from '../../hooks/use-storage'\nimport {formatStorageSize} from '../../utils'\n\ntype Warning = {\n\tmessage: string\n\tadvice: string\n}\n\ntype SsdHealthDialogProps = {\n\tdevice: StorageDevice\n\tslotNumber: number\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n\t/** RAID device info - undefined if device is not in RAID */\n\traidDevice?: RaidDevice\n}\n\nexport function SsdHealthDialog({device, slotNumber, open, onOpenChange, raidDevice}: SsdHealthDialogProps) {\n\tconst {smartUnhealthy, lifeRemaining, lifeWarning, tempWarning, tempCritical} = getDeviceHealth(device)\n\tconst [temperatureUnit] = useTemperatureUnit()\n\n\tconst healthStatus =\n\t\tdevice.smartStatus === 'healthy'\n\t\t\t? t('storage-manager.health.status-healthy')\n\t\t\t: device.smartStatus === 'unhealthy'\n\t\t\t\t? t('storage-manager.health.status-unhealthy')\n\t\t\t\t: t('storage-manager.health.status-unknown')\n\n\t// Check if drive has failed in RAID\n\tconst isRaidFailed = raidDevice && raidDevice.raidStatus !== 'ONLINE'\n\n\tconst warnings: Warning[] = []\n\n\tif (smartUnhealthy) {\n\t\twarnings.push({\n\t\t\tmessage: t('storage-manager.health.warning-unhealthy-message'),\n\t\t\tadvice: t('storage-manager.health.warning-unhealthy-advice'),\n\t\t})\n\t}\n\n\tif (lifeWarning) {\n\t\twarnings.push({\n\t\t\tmessage: t('storage-manager.health.warning-life-message', {percent: lifeRemaining}),\n\t\t\tadvice: t('storage-manager.health.warning-life-advice'),\n\t\t})\n\t}\n\n\tif (tempCritical) {\n\t\twarnings.push({\n\t\t\tmessage: t('storage-manager.health.warning-temp-critical', {\n\t\t\t\ttemperature: formatTemperature(device.temperature, temperatureUnit),\n\t\t\t}),\n\t\t\tadvice: t('storage-manager.health.warning-temp-advice'),\n\t\t})\n\t} else if (tempWarning) {\n\t\twarnings.push({\n\t\t\tmessage: t('storage-manager.health.warning-temp-overheating', {\n\t\t\t\ttemperature: formatTemperature(device.temperature, temperatureUnit),\n\t\t\t}),\n\t\t\tadvice: t('storage-manager.health.warning-temp-advice'),\n\t\t})\n\t}\n\n\treturn (\n\t\t<Dialog open={open} onOpenChange={onOpenChange}>\n\t\t\t<DialogScrollableContent showClose>\n\t\t\t\t<div className='space-y-5 px-5 py-6'>\n\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t<TbActivityHeartbeat className='size-5' />\n\t\t\t\t\t\t\t<DialogTitle>{t('storage-manager.health.title')}</DialogTitle>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</DialogHeader>\n\n\t\t\t\t\t{/* SSD Depiction */}\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName='relative -mr-5'\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tmaskImage: 'linear-gradient(to right, black 60%, transparent 100%)',\n\t\t\t\t\t\t\tWebkitMaskImage: 'linear-gradient(to right, black 60%, transparent 100%)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<img src='/assets/onboarding/ssd-info.webp' alt='SSD' draggable={false} className='ml-auto w-[95%]' />\n\t\t\t\t\t\t<div className='absolute flex flex-col' style={{left: '20%', top: '50%', transform: 'translateY(-50%)'}}>\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName='leading-tight font-bold'\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tfontSize: 'clamp(20px, 5vw, 30px)',\n\t\t\t\t\t\t\t\t\ttextShadow: '0 0 8px rgba(255, 255, 255, 0.2), 0 0 16px rgba(255, 255, 255, 0.15)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{formatStorageSize(device.size)}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span className='text-white/50' style={{fontSize: 'clamp(12px, 2.5vw, 14px)'}}>\n\t\t\t\t\t\t\t\tSSD {slotNumber}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName='absolute font-medium text-white/90'\n\t\t\t\t\t\t\tstyle={{right: '5%', top: '70%', transform: 'translateY(-50%)', fontSize: 'clamp(12px, 2.5vw, 15px)'}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{device.model}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* RAID Status Section - shown when drive status is not ONLINE */}\n\t\t\t\t\t{isRaidFailed && raidDevice && (\n\t\t\t\t\t\t<div className='rounded-12 border border-[#FF3434]/30 bg-[#FF3434]/10 p-4'>\n\t\t\t\t\t\t\t<div className='mb-3 flex items-center gap-2 text-[#FF3434]'>\n\t\t\t\t\t\t\t\t<TbAlertTriangleFilled className='size-5' />\n\t\t\t\t\t\t\t\t<span className='font-semibold'>\n\t\t\t\t\t\t\t\t\tStatus:{' '}\n\t\t\t\t\t\t\t\t\t{raidStatusLabels[raidDevice.raidStatus]\n\t\t\t\t\t\t\t\t\t\t? t(raidStatusLabels[raidDevice.raidStatus])\n\t\t\t\t\t\t\t\t\t\t: raidDevice.raidStatus}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className='text-sm'>\n\t\t\t\t\t\t\t\t<p className='font-medium text-white/90'>{t('storage-manager.health.raid-failed-advice')}</p>\n\t\t\t\t\t\t\t\t{(raidDevice.readErrors > 0 || raidDevice.writeErrors > 0 || raidDevice.checksumErrors > 0) && (\n\t\t\t\t\t\t\t\t\t<div className='mt-3 flex gap-4 text-xs text-white/40'>\n\t\t\t\t\t\t\t\t\t\t<span>{t('storage-manager.health.read-errors', {count: raidDevice.readErrors})}</span>\n\t\t\t\t\t\t\t\t\t\t<span>{t('storage-manager.health.write-errors', {count: raidDevice.writeErrors})}</span>\n\t\t\t\t\t\t\t\t\t\t<span>{t('storage-manager.health.checksum-errors', {count: raidDevice.checksumErrors})}</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* Warnings Section */}\n\t\t\t\t\t{warnings.length > 0 && (\n\t\t\t\t\t\t<div className='rounded-12 border border-[#F5A623]/30 bg-[#F5A623]/10 p-4'>\n\t\t\t\t\t\t\t<div className='mb-3 flex items-center gap-2 text-[#F5A623]'>\n\t\t\t\t\t\t\t\t<TbAlertTriangle className='size-5' />\n\t\t\t\t\t\t\t\t<span className='font-semibold'>{t('storage-manager.health.warnings')}</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className='divide-y divide-[#F5A623]/20'>\n\t\t\t\t\t\t\t\t{warnings.map((warning, index) => (\n\t\t\t\t\t\t\t\t\t<div key={index} className='py-2 text-sm first:pt-0 last:pb-0'>\n\t\t\t\t\t\t\t\t\t\t<p className='font-medium text-white/90'>{warning.message}</p>\n\t\t\t\t\t\t\t\t\t\t<p className='mt-0.5 text-white/50'>{warning.advice}</p>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* General Section */}\n\t\t\t\t\t<div className='space-y-2'>\n\t\t\t\t\t\t<span className='text-xs font-medium tracking-wider text-white/40 uppercase'>\n\t\t\t\t\t\t\t{t('storage-manager.health.general')}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<div className={listClass}>\n\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t<span className='shrink-0'>{t('storage-manager.health.model-and-capacity')}</span>\n\t\t\t\t\t\t\t\t<FadeScroller direction='x' className='umbrel-hide-scrollbar min-w-0 overflow-x-auto font-normal'>\n\t\t\t\t\t\t\t\t\t<span className='whitespace-nowrap select-all'>{device.model}</span>\n\t\t\t\t\t\t\t\t\t<span className='whitespace-nowrap'> · {formatStorageSize(device.size)}</span>\n\t\t\t\t\t\t\t\t</FadeScroller>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t<span className='shrink-0'>{t('storage-manager.health.serial-number')}</span>\n\t\t\t\t\t\t\t\t<FadeScroller direction='x' className='umbrel-hide-scrollbar min-w-0 overflow-x-auto font-normal'>\n\t\t\t\t\t\t\t\t\t<span className='whitespace-nowrap select-all'>{device.serial}</span>\n\t\t\t\t\t\t\t\t</FadeScroller>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Wear Section */}\n\t\t\t\t\t<div className='space-y-2'>\n\t\t\t\t\t\t<span className='text-xs font-medium tracking-wider text-white/40 uppercase'>\n\t\t\t\t\t\t\t{t('storage-manager.health.wear')}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<div className={listClass}>\n\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t<span>{t('storage-manager.health.health-status')}</span>\n\t\t\t\t\t\t\t\t<span className='flex items-center gap-2 font-normal'>\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclassName='size-[5px] rounded-full ring-3'\n\t\t\t\t\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor:\n\t\t\t\t\t\t\t\t\t\t\t\t\tdevice.smartStatus === 'healthy'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? '#00D084'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: device.smartStatus === 'unhealthy'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? '#F5A623'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'rgba(255,255,255,0.5)',\n\t\t\t\t\t\t\t\t\t\t\t\t'--tw-ring-color':\n\t\t\t\t\t\t\t\t\t\t\t\t\tdevice.smartStatus === 'healthy'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'rgba(0, 208, 132, 0.3)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: device.smartStatus === 'unhealthy'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'rgba(245, 166, 35, 0.3)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'rgba(255, 255, 255, 0.15)',\n\t\t\t\t\t\t\t\t\t\t\t} as React.CSSProperties\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t{healthStatus}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{lifeRemaining !== undefined && (\n\t\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t\t<span>{t('storage-manager.health.estimated-life')}</span>\n\t\t\t\t\t\t\t\t\t<span className='flex items-center gap-2 font-normal'>\n\t\t\t\t\t\t\t\t\t\t{lifeWarning && (\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclassName='size-[5px] rounded-full ring-3'\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: '#F5A623',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t'--tw-ring-color': 'rgba(245, 166, 35, 0.3)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t} as React.CSSProperties\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{lifeRemaining}%{lifeWarning && ` · ${t('storage-manager.health.low')}`}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Temperature Section */}\n\t\t\t\t\t{device.temperature !== undefined && (\n\t\t\t\t\t\t<div className='space-y-2'>\n\t\t\t\t\t\t\t<span className='text-xs font-medium tracking-wider text-white/40 uppercase'>\n\t\t\t\t\t\t\t\t{t('storage-manager.health.temperature')}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<div className={listClass}>\n\t\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t\t<span>{t('storage-manager.health.current-temperature')}</span>\n\t\t\t\t\t\t\t\t\t<span className='flex items-center gap-2 font-normal'>\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tclassName='size-[5px] rounded-full ring-3'\n\t\t\t\t\t\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: tempCritical ? '#FF2F63' : tempWarning ? '#F5A623' : '#00D084',\n\t\t\t\t\t\t\t\t\t\t\t\t\t'--tw-ring-color': tempCritical\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'rgba(255, 47, 99, 0.3)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: tempWarning\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'rgba(245, 166, 35, 0.3)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'rgba(0, 208, 132, 0.3)',\n\t\t\t\t\t\t\t\t\t\t\t\t} as React.CSSProperties\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t{formatTemperature(device.temperature, temperatureUnit)}\n\t\t\t\t\t\t\t\t\t\t{tempCritical && ` · ${t('storage-manager.health.critical')}`}\n\t\t\t\t\t\t\t\t\t\t{tempWarning && ` · ${t('storage-manager.health.overheating')}`}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{device.temperatureWarning !== undefined && (\n\t\t\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t\t\t<span>{t('storage-manager.health.warning-threshold')}</span>\n\t\t\t\t\t\t\t\t\t\t<span className='font-normal'>{formatTemperature(device.temperatureWarning, temperatureUnit)}</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{device.temperatureCritical !== undefined && (\n\t\t\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t\t\t<span>{t('storage-manager.health.critical-threshold')}</span>\n\t\t\t\t\t\t\t\t\t\t<span className='font-normal'>\n\t\t\t\t\t\t\t\t\t\t\t{formatTemperature(device.temperatureCritical, temperatureUnit)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</DialogScrollableContent>\n\t\t</Dialog>\n\t)\n}\n\nconst listClass = tw`divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6`\nconst listItemClass = tw`flex items-center gap-3 px-3 h-[42px] text-14 font-medium -tracking-3 justify-between text-white/90`\n\n// Hook to manage SSD health dialog state\n// Stores deviceId instead of device object so parent can look up fresh data\nexport function useSsdHealthDialog() {\n\tconst [selectedDevice, setSelectedDevice] = useState<{deviceId: string; slotNumber: number} | null>(null)\n\n\treturn {\n\t\tselectedDevice,\n\t\topen: selectedDevice !== null,\n\t\tonOpenChange: (open: boolean) => {\n\t\t\tif (!open) setSelectedDevice(null)\n\t\t},\n\t\topenDialog: (device: StorageDevice, slotNumber: number) => {\n\t\t\tif (!device.id) return\n\t\t\tsetSelectedDevice({deviceId: device.id, slotNumber})\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/dialogs/swap-dialog.tsx",
    "content": "import {useEffect, useState} from 'react'\nimport {IoShieldHalf} from 'react-icons/io5'\nimport {TbAlertTriangle, TbInfoCircle} from 'react-icons/tb'\n\nimport {Button} from '@/components/ui/button'\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\n\tDialogFooter,\n\tDialogHeader,\n\tDialogScrollableContent,\n\tDialogTitle,\n} from '@/components/ui/dialog'\nimport {toast} from '@/components/ui/toast'\nimport {useActiveRaidOperation} from '@/features/storage/hooks/use-active-raid-operation'\nimport {usePendingRaidOperation} from '@/features/storage/providers/pending-operation-context'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\nimport {StorageDevice} from '../../hooks/use-storage'\nimport {formatStorageSize} from '../../utils'\nimport {InstallTipsCollapsible} from './install-tips-collapsible'\nimport {OperationInProgressBanner} from './operation-in-progress-banner'\nimport {ShutdownConfirmationDialog} from './shutdown-confirmation-dialog'\n\ntype SwapDialogProps = {\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n\traidType?: 'storage' | 'failsafe'\n\tslot: number | null\n\tisUmbrelPro: boolean\n\traidDriveCount: number\n\tavailableDevices: StorageDevice[]\n\tallDevices: StorageDevice[]\n\treplaceDeviceAsync: (params: {oldDevice: string; newDevice: string}) => Promise<boolean>\n}\n\nexport function SwapDialog({\n\topen,\n\tonOpenChange,\n\traidType,\n\tslot,\n\tisUmbrelPro,\n\traidDriveCount,\n\tavailableDevices,\n\tallDevices,\n\treplaceDeviceAsync,\n}: SwapDialogProps) {\n\tconst {setPendingOperation, clearPendingOperation} = usePendingRaidOperation()\n\n\t// Check if a RAID operation is already in progress\n\tconst activeOperation = useActiveRaidOperation()\n\tconst isOperationInProgress = !!activeOperation\n\n\tconst [showInstallTips, setShowInstallTips] = useState(false)\n\tconst [selectedReplacementId, setSelectedReplacementId] = useState<string | null>(null)\n\tconst [showShutdownConfirmation, setShowShutdownConfirmation] = useState(false)\n\n\tconst deviceName = isUmbrelPro ? 'Umbrel Pro' : 'device'\n\tconst isStorageMode = raidType === 'storage'\n\tconst maxSlots = isUmbrelPro ? 4 : 4 // TODO: Make configurable for custom devices later\n\tconst hasFreeSlot = raidDriveCount < maxSlots\n\n\t// Get the device being replaced (needed for size validation)\n\tconst oldDevice = slot ? allDevices.find((d) => d.slot === slot) : null\n\n\t// Filter available devices to only show those large enough for replacement.\n\t// ZFS requires replacement devices to be at least as large as the device being replaced.\n\t// We compare roundedSize (not raw size) because the backend partitions devices using roundedSize,\n\t// so ZFS validates based on partition sizes which are determined by roundedSize.\n\tconst validReplacementDevices = oldDevice\n\t\t? availableDevices.filter((d) => (d.roundedSize ?? d.size) >= (oldDevice.roundedSize ?? oldDevice.size))\n\t\t: availableDevices\n\n\tconst hasAvailableDevices = availableDevices.length > 0\n\n\t// Initialize selection when dialog opens, reset when it closes\n\t// Note: validReplacementDevices intentionally omitted from deps - it's a new array ref each render,\n\t// and we only want to auto-select once when the dialog opens, not reset on every render\n\tuseEffect(() => {\n\t\tif (open) {\n\t\t\tsetSelectedReplacementId(validReplacementDevices.length === 1 ? (validReplacementDevices[0].id ?? null) : null)\n\t\t} else {\n\t\t\tsetShowInstallTips(false)\n\t\t\tsetShowShutdownConfirmation(false)\n\t\t\tsetSelectedReplacementId(null)\n\t\t}\n\t}, [open])\n\n\t// Storage mode with free slot AND available devices - we show replacement selection\n\tif (isStorageMode && hasFreeSlot && hasAvailableDevices) {\n\t\tconst selectedDevice = validReplacementDevices.find((d) => d.id === selectedReplacementId)\n\n\t\tconst handleReplace = () => {\n\t\t\tif (!selectedDevice?.id || !selectedDevice?.slot || !oldDevice?.id) return\n\n\t\t\t// Replace is non-blocking - we show island immediately\n\t\t\tsetPendingOperation({\n\t\t\t\ttype: 'replace',\n\t\t\t\tstate: 'starting',\n\t\t\t\tprogress: 0,\n\t\t\t})\n\t\t\tonOpenChange(false)\n\n\t\t\treplaceDeviceAsync({\n\t\t\t\toldDevice: oldDevice.id,\n\t\t\t\tnewDevice: selectedDevice.id,\n\t\t\t}).catch((error) => {\n\t\t\t\tclearPendingOperation()\n\t\t\t\ttoast.error(t('storage-manager.swap.failed-to-start'), {\n\t\t\t\t\tdescription: error instanceof Error ? error.message : t('unknown-error'),\n\t\t\t\t})\n\t\t\t})\n\t\t}\n\n\t\treturn (\n\t\t\t<Dialog open={open} onOpenChange={onOpenChange}>\n\t\t\t\t<DialogScrollableContent onOpenAutoFocus={(e) => e.preventDefault()}>\n\t\t\t\t\t<div className='flex flex-col gap-5 p-5'>\n\t\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t\t{/* \"SSD\" slot labels are not translated - they match the physical device markings */}\n\t\t\t\t\t\t\t<DialogTitle>\n\t\t\t\t\t\t\t\t{t('storage-manager.replace')} {slot ? `SSD ${slot}` : 'SSD'}\n\t\t\t\t\t\t\t</DialogTitle>\n\t\t\t\t\t\t\t<DialogDescription>{t('storage-manager.swap.description-replace')}</DialogDescription>\n\t\t\t\t\t\t</DialogHeader>\n\n\t\t\t\t\t\t{/* Info banner */}\n\t\t\t\t\t\t<div className='flex items-start gap-3 rounded-12 bg-brand/10 p-3'>\n\t\t\t\t\t\t\t<TbInfoCircle className='mt-0.5 size-5 shrink-0 text-brand' />\n\t\t\t\t\t\t\t<div className='flex flex-col gap-1'>\n\t\t\t\t\t\t\t\t<span className='text-13 font-semibold text-brand'>{t('storage-manager.swap.no-data-loss')}</span>\n\t\t\t\t\t\t\t\t<span className='text-12 text-white/60'>{t('storage-manager.swap.no-data-loss-description')}</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* Drive selection - show all available, disable those too small */}\n\t\t\t\t\t\t<div className='flex flex-col gap-2'>\n\t\t\t\t\t\t\t<span className='text-13 font-medium text-white/60'>{t('storage-manager.swap.select-new-ssd')}</span>\n\t\t\t\t\t\t\t<div className='flex flex-col gap-2'>\n\t\t\t\t\t\t\t\t{availableDevices.map((device) => {\n\t\t\t\t\t\t\t\t\tconst isSelected = selectedReplacementId === device.id\n\t\t\t\t\t\t\t\t\tconst isTooSmall = oldDevice\n\t\t\t\t\t\t\t\t\t\t? (device.roundedSize ?? device.size) < (oldDevice.roundedSize ?? oldDevice.size)\n\t\t\t\t\t\t\t\t\t\t: false\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\tkey={device.id}\n\t\t\t\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => !isTooSmall && setSelectedReplacementId(device.id ?? null)}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isTooSmall}\n\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t'flex items-center gap-3 rounded-12 border p-3 text-left transition-colors',\n\t\t\t\t\t\t\t\t\t\t\t\tisTooSmall\n\t\t\t\t\t\t\t\t\t\t\t\t\t? 'cursor-not-allowed border-white/5 bg-white/[0.02] opacity-60'\n\t\t\t\t\t\t\t\t\t\t\t\t\t: isSelected\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'border-brand bg-brand/10'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'border-white/10 bg-white/5 hover:bg-white/8',\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t'flex size-5 items-center justify-center rounded-full border-2',\n\t\t\t\t\t\t\t\t\t\t\t\t\tisTooSmall ? 'border-white/20' : isSelected ? 'border-brand bg-brand' : 'border-white/30',\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{isSelected && !isTooSmall && <div className='size-2 rounded-full bg-white' />}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className='flex flex-1 flex-col gap-0.5'>\n\t\t\t\t\t\t\t\t\t\t\t\t{/* \"SSD\" and \"Slot\" labels are not translated - they match the physical device markings */}\n\t\t\t\t\t\t\t\t\t\t\t\t<span className={cn('text-13 font-medium', isTooSmall ? 'text-white/50' : 'text-white')}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.swap.ssd-in-slot', {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize: formatStorageSize(device.size),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tslot: device.slot,\n\t\t\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t{device.name && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className={cn('text-12', isTooSmall ? 'text-white/30' : 'text-white/40')}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{device.name}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t{isTooSmall && (\n\t\t\t\t\t\t\t\t\t\t\t\t<span className='shrink-0 text-11 font-medium text-[#F5A623]'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.swap.too-small', {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsize: formatStorageSize(oldDevice?.roundedSize ?? oldDevice?.size ?? 0),\n\t\t\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* What happens next */}\n\t\t\t\t\t\t<div className='flex flex-col gap-2'>\n\t\t\t\t\t\t\t<span className='text-13 font-medium text-white/60'>{t('storage-manager.swap.what-happens-next')}</span>\n\t\t\t\t\t\t\t<div className='divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>\n\t\t\t\t\t\t\t\t{[\n\t\t\t\t\t\t\t\t\tt('storage-manager.swap.step-data-copied'),\n\t\t\t\t\t\t\t\t\tt('storage-manager.swap.step-may-take-while'),\n\t\t\t\t\t\t\t\t\tt('storage-manager.swap.step-remove-old', {ssd: slot ? `SSD ${slot}` : 'the old SSD'}),\n\t\t\t\t\t\t\t\t].map((step, index) => (\n\t\t\t\t\t\t\t\t\t<div key={index} className='flex items-center gap-3 p-3 text-12 font-medium -tracking-3'>\n\t\t\t\t\t\t\t\t\t\t<span className='flex size-5 shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold'>\n\t\t\t\t\t\t\t\t\t\t\t{index + 1}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span>{step}</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{isOperationInProgress && <OperationInProgressBanner variant='wait' />}\n\n\t\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\t\t\tonClick={handleReplace}\n\t\t\t\t\t\t\t\tdisabled={!selectedDevice || !oldDevice || isOperationInProgress}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t('storage-manager.replace')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button variant='default' onClick={() => onOpenChange(false)}>\n\t\t\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</DialogFooter>\n\t\t\t\t\t</div>\n\t\t\t\t</DialogScrollableContent>\n\t\t\t</Dialog>\n\t\t)\n\t}\n\n\t// Storage mode with free slot but NO available devices - we show \"add a drive first\" instructions\n\tif (isStorageMode && hasFreeSlot) {\n\t\tconst steps = [\n\t\t\tt('storage-manager.swap.step-shut-down', {deviceName}),\n\t\t\t...(isUmbrelPro ? [t('storage-manager.swap.step-remove-bottom-cover')] : []),\n\t\t\tt('storage-manager.swap.step-insert-new-ssd'),\n\t\t\t...(isUmbrelPro ? [t('storage-manager.swap.step-replace-bottom-cover')] : []),\n\t\t\tt('storage-manager.swap.step-power-on', {deviceName}),\n\t\t\tt('storage-manager.swap.step-return-to-swap'),\n\t\t]\n\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<Dialog open={open} onOpenChange={onOpenChange}>\n\t\t\t\t\t<DialogScrollableContent onOpenAutoFocus={(e) => e.preventDefault()}>\n\t\t\t\t\t\t<div className='flex flex-col gap-5 p-5'>\n\t\t\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t\t\t{/* \"SSD\" slot labels are not translated - they match the physical device markings */}\n\t\t\t\t\t\t\t\t<DialogTitle>\n\t\t\t\t\t\t\t\t\t{t('storage-manager.swap')} {slot ? `SSD ${slot}` : 'SSD'}\n\t\t\t\t\t\t\t\t</DialogTitle>\n\t\t\t\t\t\t\t\t<DialogDescription>{t('storage-manager.swap.description-full-storage')}</DialogDescription>\n\t\t\t\t\t\t\t</DialogHeader>\n\n\t\t\t\t\t\t\t{/* Info banner */}\n\t\t\t\t\t\t\t<div className='flex items-start gap-3 rounded-12 bg-brand/10 p-3'>\n\t\t\t\t\t\t\t\t<TbInfoCircle className='mt-0.5 size-5 shrink-0 text-brand' />\n\t\t\t\t\t\t\t\t<div className='flex flex-col gap-1'>\n\t\t\t\t\t\t\t\t\t<span className='text-13 font-semibold text-brand'>\n\t\t\t\t\t\t\t\t\t\t{t('storage-manager.swap.safe-swap-available')}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span className='text-12 text-white/60'>{t('storage-manager.swap.safe-swap-description')}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t{/* Steps */}\n\t\t\t\t\t\t\t<div className='divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>\n\t\t\t\t\t\t\t\t{steps.map((step, index) => (\n\t\t\t\t\t\t\t\t\t<div key={index} className='flex items-center gap-3 p-3 text-12 font-medium -tracking-3'>\n\t\t\t\t\t\t\t\t\t\t<span className='flex size-5 shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold'>\n\t\t\t\t\t\t\t\t\t\t\t{index + 1}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span>{step}</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t{/* Collapsible installation tips - Umbrel Pro only */}\n\t\t\t\t\t\t\t{isUmbrelPro && (\n\t\t\t\t\t\t\t\t<InstallTipsCollapsible\n\t\t\t\t\t\t\t\t\tisOpen={showInstallTips}\n\t\t\t\t\t\t\t\t\tonToggle={() => setShowInstallTips(!showInstallTips)}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{isOperationInProgress && <OperationInProgressBanner variant='shutdown-safe' />}\n\n\t\t\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t\t\t<Button variant='destructive' onClick={() => setShowShutdownConfirmation(true)}>\n\t\t\t\t\t\t\t\t\t{t('shut-down')}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<Button variant='default' onClick={() => onOpenChange(false)}>\n\t\t\t\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</DialogFooter>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</DialogScrollableContent>\n\t\t\t\t</Dialog>\n\n\t\t\t\t<ShutdownConfirmationDialog open={showShutdownConfirmation} onOpenChange={setShowShutdownConfirmation} />\n\t\t\t</>\n\t\t)\n\t}\n\n\tif (isStorageMode) {\n\t\t// No free slot because all 4 slots are in use - you would need to use backup, factory reset, and restore workflow\n\t\tconst ssdLabel = slot ? `SSD ${slot}` : 'the SSD'\n\t\tconst steps = [\n\t\t\t{\n\t\t\t\ttitle: t('storage-manager.swap.step-backup'),\n\t\t\t\tdescription: t('storage-manager.swap.step-backup-description'),\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: t('storage-manager.swap.step-factory-reset'),\n\t\t\t\tdescription: t('storage-manager.swap.step-factory-reset-description', {deviceName}),\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: t('storage-manager.swap.step-shut-down-and-swap', {ssd: ssdLabel}),\n\t\t\t\tdescription: isUmbrelPro\n\t\t\t\t\t? t('storage-manager.swap.step-shut-down-and-swap-description-pro')\n\t\t\t\t\t: t('storage-manager.swap.step-shut-down-and-swap-description-other'),\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: t('storage-manager.swap.step-setup-new-storage'),\n\t\t\t\tdescription: t('storage-manager.swap.step-setup-new-storage-description', {deviceName}),\n\t\t\t},\n\t\t\t{\n\t\t\t\ttitle: t('storage-manager.swap.step-restore'),\n\t\t\t\tdescription: t('storage-manager.swap.step-restore-description'),\n\t\t\t},\n\t\t]\n\n\t\treturn (\n\t\t\t<Dialog open={open} onOpenChange={onOpenChange}>\n\t\t\t\t<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>\n\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t{/* \"SSD\" slot labels are not translated - they match the physical device markings */}\n\t\t\t\t\t\t<DialogTitle>\n\t\t\t\t\t\t\t{t('storage-manager.swap')} {slot ? `SSD ${slot}` : 'SSD'}\n\t\t\t\t\t\t</DialogTitle>\n\t\t\t\t\t\t<DialogDescription>{t('storage-manager.swap.description-no-free-slot')}</DialogDescription>\n\t\t\t\t\t</DialogHeader>\n\n\t\t\t\t\t{/* Warning banner */}\n\t\t\t\t\t<div className='flex items-start gap-3 rounded-12 bg-destructive2/10 p-3'>\n\t\t\t\t\t\t<TbAlertTriangle className='mt-0.5 size-5 shrink-0 text-destructive2' />\n\t\t\t\t\t\t<div className='flex flex-col gap-1'>\n\t\t\t\t\t\t\t<span className='text-13 font-semibold text-destructive2'>\n\t\t\t\t\t\t\t\t{t('storage-manager.swap.data-will-be-erased')}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span className='text-12 text-white/60'>\n\t\t\t\t\t\t\t\t{t('storage-manager.swap.data-erased-description', {deviceName})}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Steps */}\n\t\t\t\t\t<div className='divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>\n\t\t\t\t\t\t{steps.map((step, index) => (\n\t\t\t\t\t\t\t<div key={index} className='flex items-start gap-3 p-3'>\n\t\t\t\t\t\t\t\t<span className='flex size-5 shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold'>\n\t\t\t\t\t\t\t\t\t{index + 1}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<div className='flex flex-col gap-0.5'>\n\t\t\t\t\t\t\t\t\t<span className='text-12 font-semibold text-white'>{step.title}</span>\n\t\t\t\t\t\t\t\t\t<span className='text-12 text-white/50'>{step.description}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t<Button variant='default' onClick={() => onOpenChange(false)}>\n\t\t\t\t\t\t\t{t('done')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</DialogFooter>\n\t\t\t\t</DialogContent>\n\t\t\t</Dialog>\n\t\t)\n\t}\n\n\t// FailSafe mode\n\tconst ssdLabel = slot ? `SSD ${slot}` : 'the SSD'\n\tconst steps = [\n\t\tt('storage-manager.swap.step-shut-down', {deviceName}),\n\t\t...(isUmbrelPro ? [t('storage-manager.swap.step-remove-bottom-cover')] : []),\n\t\tt('storage-manager.swap.step-swap-ssd', {ssd: ssdLabel}),\n\t\t...(isUmbrelPro ? [t('storage-manager.swap.step-replace-bottom-cover')] : []),\n\t\tt('storage-manager.swap.step-power-on', {deviceName}),\n\t\tt('storage-manager.swap.step-return-to-storage-manager'),\n\t]\n\n\treturn (\n\t\t<>\n\t\t\t<Dialog open={open} onOpenChange={onOpenChange}>\n\t\t\t\t<DialogScrollableContent>\n\t\t\t\t\t<div className='flex flex-col gap-5 p-5'>\n\t\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t\t{/* \"SSD\" slot labels are not translated - they match the physical device markings */}\n\t\t\t\t\t\t\t<DialogTitle>\n\t\t\t\t\t\t\t\t{t('storage-manager.swap')} {slot ? `SSD ${slot}` : 'SSD'}\n\t\t\t\t\t\t\t</DialogTitle>\n\t\t\t\t\t\t\t<DialogDescription>{t('storage-manager.swap.description-failsafe')}</DialogDescription>\n\t\t\t\t\t\t</DialogHeader>\n\n\t\t\t\t\t\t{/* Safe banner */}\n\t\t\t\t\t\t<div className='flex items-start gap-3 rounded-12 bg-brand/10 p-3'>\n\t\t\t\t\t\t\t<IoShieldHalf className='mt-0.5 size-5 shrink-0 text-brand' />\n\t\t\t\t\t\t\t<div className='flex flex-col gap-1'>\n\t\t\t\t\t\t\t\t<span className='text-13 font-semibold text-brand'>{t('storage-manager.swap.data-protected')}</span>\n\t\t\t\t\t\t\t\t<span className='text-12 text-white/60'>{t('storage-manager.swap.data-protected-description')}</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* Steps */}\n\t\t\t\t\t\t<div className='divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>\n\t\t\t\t\t\t\t{steps.map((step, index) => (\n\t\t\t\t\t\t\t\t<div key={index} className='flex items-center gap-3 p-3 text-12 font-medium -tracking-3'>\n\t\t\t\t\t\t\t\t\t<span className='flex size-5 shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold'>\n\t\t\t\t\t\t\t\t\t\t{index + 1}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<span>{step}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* Collapsible installation tips - Umbrel Pro only */}\n\t\t\t\t\t\t{isUmbrelPro && (\n\t\t\t\t\t\t\t<InstallTipsCollapsible isOpen={showInstallTips} onToggle={() => setShowInstallTips(!showInstallTips)} />\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{isOperationInProgress && <OperationInProgressBanner variant='shutdown-safe' />}\n\n\t\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t\t<Button variant='destructive' onClick={() => setShowShutdownConfirmation(true)}>\n\t\t\t\t\t\t\t\t{t('shut-down')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t<Button variant='default' onClick={() => onOpenChange(false)}>\n\t\t\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</DialogFooter>\n\t\t\t\t\t</div>\n\t\t\t\t</DialogScrollableContent>\n\t\t\t</Dialog>\n\n\t\t\t<ShutdownConfirmationDialog open={showShutdownConfirmation} onOpenChange={setShowShutdownConfirmation} />\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/floating-island/data-stream-icon.tsx",
    "content": "// Animated icons for RAID progress in floating island.\n//\n// DataStreamIcon: SSD-shaped icon with flickering squares (for expanded view)\n// DataStreamIconMini: Circular grid of flickering squares (for minimized view)\n\nimport {useEffect, useState} from 'react'\n\n// --- DataStreamIcon ---\n// SSD-shaped icon with flickering squares and M.2 connector bars.\n\ninterface DataStreamIconProps {\n\tsize?: number\n\tisActive?: boolean\n}\n\nexport function DataStreamIcon({size = 32, isActive = true}: DataStreamIconProps) {\n\tconst [activeCells, setActiveCells] = useState<Set<number>>(new Set())\n\n\tconst gridCols = 5\n\tconst gridRows = 10\n\n\tuseEffect(() => {\n\t\tif (!isActive) {\n\t\t\tsetActiveCells(new Set())\n\t\t\treturn\n\t\t}\n\n\t\tconst updateInterval = 90\n\t\tconst minActive = 2\n\t\tconst maxActive = 6\n\t\tconst persistChance = 0.5\n\n\t\tconst interval = setInterval(() => {\n\t\t\tsetActiveCells((prev) => {\n\t\t\t\tconst next = new Set<number>()\n\t\t\t\tconst numActive = Math.floor(Math.random() * (maxActive - minActive + 1)) + minActive\n\n\t\t\t\tfor (let i = 0; i < numActive; i++) {\n\t\t\t\t\tconst cellIndex = Math.floor(Math.random() * gridCols * gridRows)\n\t\t\t\t\tnext.add(cellIndex)\n\t\t\t\t}\n\n\t\t\t\tprev.forEach((cell) => {\n\t\t\t\t\tif (Math.random() > persistChance) {\n\t\t\t\t\t\tnext.add(cell)\n\t\t\t\t\t}\n\t\t\t\t})\n\n\t\t\t\treturn next\n\t\t\t})\n\t\t}, updateInterval)\n\n\t\treturn () => clearInterval(interval)\n\t}, [isActive])\n\n\tconst width = size\n\tconst height = size * 2.5\n\tconst borderRadius = 3\n\tconst teethHeight = 4\n\n\tconst gridPadding = 4\n\tconst gridTop = teethHeight + 8\n\tconst gridHeight = height - gridTop - gridPadding\n\tconst gridWidth = width - gridPadding * 2\n\tconst cellWidth = gridWidth / gridCols\n\tconst cellHeight = gridHeight / gridRows\n\tconst gapSize = 1\n\n\tconst cells = []\n\tfor (let row = 0; row < gridRows; row++) {\n\t\tfor (let col = 0; col < gridCols; col++) {\n\t\t\tconst index = row * gridCols + col\n\t\t\tconst isActiveCell = activeCells.has(index)\n\n\t\t\tconst x = gridPadding + col * cellWidth + gapSize / 2\n\t\t\tconst y = gridTop + row * cellHeight + gapSize / 2\n\t\t\tconst actualWidth = cellWidth - gapSize\n\t\t\tconst actualHeight = cellHeight - gapSize\n\n\t\t\tcells.push(\n\t\t\t\t<div\n\t\t\t\t\tkey={index}\n\t\t\t\t\tclassName='absolute bg-brand transition-all duration-75'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tleft: x,\n\t\t\t\t\t\ttop: y,\n\t\t\t\t\t\twidth: actualWidth,\n\t\t\t\t\t\theight: actualHeight,\n\t\t\t\t\t\tborderRadius: 1,\n\t\t\t\t\t\topacity: isActiveCell ? 1 : 0.3,\n\t\t\t\t\t\tboxShadow: isActiveCell ? '0 0 4px hsl(var(--color-brand)), 0 0 6px hsl(var(--color-brand) / 0.5)' : 'none',\n\t\t\t\t\t}}\n\t\t\t\t/>,\n\t\t\t)\n\t\t}\n\t}\n\n\t// Connector bar styling - M.2 diagram style\n\tconst connectorColor = 'rgba(255, 255, 255, 0.15)'\n\tconst connectorHeight = 2\n\tconst connectorTop = teethHeight\n\tconst notchGap = 2\n\tconst notchPosition = width * 0.7\n\tconst leftBarWidth = notchPosition - 2\n\tconst rightBarWidth = width - notchPosition - notchGap - 2\n\n\treturn (\n\t\t<div className='relative' style={{width, height}}>\n\t\t\t{/* SSD body */}\n\t\t\t<div\n\t\t\t\tclassName='absolute bg-white/10'\n\t\t\t\tstyle={{\n\t\t\t\t\ttop: teethHeight + connectorHeight,\n\t\t\t\t\tleft: 0,\n\t\t\t\t\tright: 0,\n\t\t\t\t\tbottom: 0,\n\t\t\t\t\tborderRadius,\n\t\t\t\t}}\n\t\t\t/>\n\n\t\t\t{/* Connector bar - left section */}\n\t\t\t<div\n\t\t\t\tclassName='absolute'\n\t\t\t\tstyle={{\n\t\t\t\t\tleft: 2,\n\t\t\t\t\ttop: connectorTop,\n\t\t\t\t\twidth: leftBarWidth,\n\t\t\t\t\theight: connectorHeight,\n\t\t\t\t\tbackgroundColor: connectorColor,\n\t\t\t\t\tborderRadius: '1px 1px 0 0',\n\t\t\t\t}}\n\t\t\t/>\n\n\t\t\t{/* Connector bar - right section */}\n\t\t\t<div\n\t\t\t\tclassName='absolute'\n\t\t\t\tstyle={{\n\t\t\t\t\tleft: notchPosition + notchGap,\n\t\t\t\t\ttop: connectorTop,\n\t\t\t\t\twidth: rightBarWidth,\n\t\t\t\t\theight: connectorHeight,\n\t\t\t\t\tbackgroundColor: connectorColor,\n\t\t\t\t\tborderRadius: '1px 1px 0 0',\n\t\t\t\t}}\n\t\t\t/>\n\n\t\t\t{/* Flickering grid cells */}\n\t\t\t{cells}\n\t\t</div>\n\t)\n}\n\n// --- DataStreamIconMini ---\n// Circular grid of flickering squares for minimized island view.\n\ninterface DataStreamIconMiniProps {\n\tsize?: number\n\tisActive?: boolean\n}\n\nexport function DataStreamIconMini({size = 20, isActive = true}: DataStreamIconMiniProps) {\n\tconst [activeCells, setActiveCells] = useState<Set<number>>(new Set())\n\n\tconst gridSize = 5\n\tconst cellSize = size / gridSize\n\tconst gapSize = 1\n\n\tuseEffect(() => {\n\t\tif (!isActive) {\n\t\t\tsetActiveCells(new Set())\n\t\t\treturn\n\t\t}\n\n\t\tconst interval = setInterval(() => {\n\t\t\tsetActiveCells((prev) => {\n\t\t\t\tconst next = new Set<number>()\n\t\t\t\tconst numActive = Math.floor(Math.random() * 4) + 2 // 2-5 cells\n\n\t\t\t\tfor (let i = 0; i < numActive; i++) {\n\t\t\t\t\tconst cellIndex = Math.floor(Math.random() * gridSize * gridSize)\n\t\t\t\t\tnext.add(cellIndex)\n\t\t\t\t}\n\n\t\t\t\tprev.forEach((cell) => {\n\t\t\t\t\tif (Math.random() > 0.5) {\n\t\t\t\t\t\tnext.add(cell)\n\t\t\t\t\t}\n\t\t\t\t})\n\n\t\t\t\treturn next\n\t\t\t})\n\t\t}, 60)\n\n\t\treturn () => clearInterval(interval)\n\t}, [isActive])\n\n\tconst cells = []\n\tfor (let row = 0; row < gridSize; row++) {\n\t\tfor (let col = 0; col < gridSize; col++) {\n\t\t\tconst index = row * gridSize + col\n\t\t\tconst isActiveCell = activeCells.has(index)\n\n\t\t\tconst x = col * cellSize + gapSize / 2\n\t\t\tconst y = row * cellSize + gapSize / 2\n\t\t\tconst actualSize = cellSize - gapSize\n\n\t\t\t// Circular mask - fade out cells near edges\n\t\t\tconst centerX = gridSize / 2 - 0.5\n\t\t\tconst centerY = gridSize / 2 - 0.5\n\t\t\tconst distFromCenter = Math.sqrt(Math.pow(col - centerX, 2) + Math.pow(row - centerY, 2))\n\t\t\tconst maxDist = gridSize / 2\n\t\t\tconst opacity = Math.max(0, 1 - distFromCenter / maxDist)\n\n\t\t\tif (opacity < 0.2) continue\n\n\t\t\tcells.push(\n\t\t\t\t<div\n\t\t\t\t\tkey={index}\n\t\t\t\t\tclassName='absolute bg-brand transition-all duration-75'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tleft: x,\n\t\t\t\t\t\ttop: y,\n\t\t\t\t\t\twidth: actualSize,\n\t\t\t\t\t\theight: actualSize,\n\t\t\t\t\t\tborderRadius: 1,\n\t\t\t\t\t\topacity: isActiveCell ? opacity : opacity * 0.3,\n\t\t\t\t\t\tboxShadow: isActiveCell ? '0 0 6px hsl(var(--color-brand)), 0 0 8px hsl(var(--color-brand) / 0.5)' : 'none',\n\t\t\t\t\t}}\n\t\t\t\t/>,\n\t\t\t)\n\t\t}\n\t}\n\n\treturn (\n\t\t<div className='relative' style={{width: size, height: size}}>\n\t\t\t{isActive && (\n\t\t\t\t<div\n\t\t\t\t\tclassName='absolute inset-0 rounded-full'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackground: 'radial-gradient(circle, hsl(var(--color-brand) / 0.2) 0%, transparent 70%)',\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t<div className='absolute inset-0'>{cells}</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/floating-island/expanded.tsx",
    "content": "import {motion} from 'motion/react'\n\nimport {type RaidProgress} from '@/features/storage/hooks/use-raid-progress'\nimport {t} from '@/utils/i18n'\n\nimport {DataStreamIcon} from './data-stream-icon'\nimport {raidOperationLabels} from './index'\n\nexport function ExpandedContent({operation}: {operation: RaidProgress}) {\n\tconst label = t(raidOperationLabels[operation.type])\n\tconst isRebooting = operation.state === 'rebooting'\n\n\tconst getStateDescription = () => {\n\t\t// restart warning for failsafe-transition syncing phase\n\t\tif (isRebooting) {\n\t\t\t// TODO: Add countdown timer when we use realtime events for system status instead of polling\n\t\t\treturn t('storage-manager.operation.restarting')\n\t\t}\n\t\tif (operation.type === 'failsafe-transition' && operation.state === 'syncing') {\n\t\t\treturn t('storage-manager.operation.syncing-restarts')\n\t\t}\n\t\tif (operation.state === 'adding') {\n\t\t\treturn t('storage-manager.operation.adding-ssd')\n\t\t}\n\t\tif (operation.state === 'starting') {\n\t\t\treturn t('storage-manager.operation.starting')\n\t\t}\n\t\treturn operation.state\n\t}\n\tconst stateDescription = getStateDescription()\n\n\t// Progress ring calculations\n\tconst radius = 40\n\tconst circumference = 2 * Math.PI * radius\n\tconst strokeDashoffset = circumference - (operation.progress / 100) * circumference\n\n\t// Check if operation is complete\n\tconst isComplete = operation.state === 'finished' || operation.state === 'complete'\n\tconst isCanceled = operation.state === 'canceled'\n\n\treturn (\n\t\t<div className='flex size-full items-center justify-between overflow-hidden px-8 py-6'>\n\t\t\t{/* Left side */}\n\t\t\t<div className='flex flex-col gap-1'>\n\t\t\t\t<div className='truncate text-sm tracking-tight text-white/90'>{label}</div>\n\t\t\t\t<div className='truncate text-xs font-normal text-white/50'>{stateDescription}</div>\n\t\t\t\t<div className='mt-2 flex items-baseline gap-1'>\n\t\t\t\t\t<div className='text-5xl font-light tracking-tight text-white'>{Math.round(operation.progress)}</div>\n\t\t\t\t\t<div className='font-medium text-white/40'>%</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* Right side - Progress ring */}\n\t\t\t<motion.div\n\t\t\t\tclassName='relative flex items-center justify-center'\n\t\t\t\tinitial={{scale: 0.6, opacity: 0, rotate: -10}}\n\t\t\t\tanimate={{scale: 1, opacity: 1, rotate: 0}}\n\t\t\t\texit={{scale: 0.6, opacity: 0, rotate: 10}}\n\t\t\t\ttransition={{\n\t\t\t\t\ttype: 'spring',\n\t\t\t\t\tstiffness: 300,\n\t\t\t\t\tdamping: 20,\n\t\t\t\t\tdelay: 0.05,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{/* Subtle background glow */}\n\t\t\t\t<motion.div\n\t\t\t\t\tclassName={`absolute inset-0 rounded-full bg-linear-to-br ${\n\t\t\t\t\t\tisComplete ? 'from-brand/50' : 'from-brand/30'\n\t\t\t\t\t} to-transparent`}\n\t\t\t\t\tinitial={{scale: 0.8, opacity: 0}}\n\t\t\t\t\tanimate={{scale: 1, opacity: 1}}\n\t\t\t\t\texit={{scale: 0.8, opacity: 0}}\n\t\t\t\t\ttransition={{\n\t\t\t\t\t\ttype: 'spring',\n\t\t\t\t\t\tstiffness: 400,\n\t\t\t\t\t\tdamping: 25,\n\t\t\t\t\t\tdelay: 0.1,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\n\t\t\t\t{/* Main progress ring */}\n\t\t\t\t<svg className='relative size-28 -rotate-90' viewBox='0 0 112 112'>\n\t\t\t\t\t<defs>\n\t\t\t\t\t\t<linearGradient id='raidProgressGradient' x1='0%' y1='0%' x2='100%' y2='100%'>\n\t\t\t\t\t\t\t<stop offset='0%' stopColor='hsl(var(--color-brand))' />\n\t\t\t\t\t\t\t<stop offset='100%' stopColor='hsl(var(--color-brand-lightest))' />\n\t\t\t\t\t\t</linearGradient>\n\t\t\t\t\t\t<filter id='raidGlow'>\n\t\t\t\t\t\t\t<feGaussianBlur stdDeviation='2' result='coloredBlur' />\n\t\t\t\t\t\t\t<feMerge>\n\t\t\t\t\t\t\t\t<feMergeNode in='coloredBlur' />\n\t\t\t\t\t\t\t\t<feMergeNode in='SourceGraphic' />\n\t\t\t\t\t\t\t</feMerge>\n\t\t\t\t\t\t</filter>\n\t\t\t\t\t</defs>\n\t\t\t\t\t{/* Background circle */}\n\t\t\t\t\t<circle\n\t\t\t\t\t\tcx='56'\n\t\t\t\t\t\tcy='56'\n\t\t\t\t\t\tr={radius}\n\t\t\t\t\t\tstroke='currentColor'\n\t\t\t\t\t\tstrokeWidth='3'\n\t\t\t\t\t\tfill='none'\n\t\t\t\t\t\tclassName='text-white/10'\n\t\t\t\t\t/>\n\t\t\t\t\t{/* Progress circle with gradient */}\n\t\t\t\t\t<circle\n\t\t\t\t\t\tcx='56'\n\t\t\t\t\t\tcy='56'\n\t\t\t\t\t\tr={radius}\n\t\t\t\t\t\tstroke='url(#raidProgressGradient)'\n\t\t\t\t\t\tstrokeWidth='3'\n\t\t\t\t\t\tfill='none'\n\t\t\t\t\t\tstrokeDasharray={circumference}\n\t\t\t\t\t\tstrokeDashoffset={isCanceled ? circumference : strokeDashoffset}\n\t\t\t\t\t\tclassName='transition-all duration-700 ease-out'\n\t\t\t\t\t\tstrokeLinecap='round'\n\t\t\t\t\t\tfilter='url(#raidGlow)'\n\t\t\t\t\t/>\n\t\t\t\t</svg>\n\n\t\t\t\t{/* Data stream visualization */}\n\t\t\t\t<motion.div\n\t\t\t\t\tclassName='absolute inset-0 flex items-center justify-center'\n\t\t\t\t\tinitial={{scale: 0.7, opacity: 0}}\n\t\t\t\t\tanimate={{scale: 1, opacity: 1}}\n\t\t\t\t\texit={{scale: 0.7, opacity: 0}}\n\t\t\t\t\ttransition={{\n\t\t\t\t\t\ttype: 'spring',\n\t\t\t\t\t\tstiffness: 350,\n\t\t\t\t\t\tdamping: 22,\n\t\t\t\t\t\tdelay: 0.2,\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<DataStreamIcon size={22} isActive={!isComplete && !isCanceled} />\n\t\t\t\t</motion.div>\n\t\t\t</motion.div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/floating-island/index.tsx",
    "content": "import {useEffect} from 'react'\n\nimport {useRaidProgress, type RaidOperationType, type RaidProgress} from '@/features/storage/hooks/use-raid-progress'\nimport {usePendingRaidOperation} from '@/features/storage/providers/pending-operation-context'\nimport {Island, IslandExpanded, IslandMinimized} from '@/modules/floating-island/bare-island'\n\nimport {ExpandedContent} from './expanded'\nimport {MinimizedContent} from './minimized'\n\n// Re-export types for use in child components\nexport type {RaidOperationType, RaidProgress}\n\n// i18n translation keys for operation types - call t() with these at render time\n// t('storage-manager.operation.expanding')\n// t('storage-manager.operation.rebuilding')\n// t('storage-manager.operation.replacing')\n// t('storage-manager.operation.enabling-failsafe')\nexport const raidOperationLabels: Record<RaidOperationType, string> = {\n\texpansion: 'storage-manager.operation.expanding',\n\trebuild: 'storage-manager.operation.rebuilding',\n\treplace: 'storage-manager.operation.replacing',\n\t'failsafe-transition': 'storage-manager.operation.enabling-failsafe',\n}\n\nexport function RaidIsland() {\n\tconst realOperation = useRaidProgress()\n\tconst {pendingOperation, clearPendingOperation} = usePendingRaidOperation()\n\n\t// When real events arrive, clear the pending operation\n\tuseEffect(() => {\n\t\tif (realOperation && pendingOperation) {\n\t\t\tclearPendingOperation()\n\t\t}\n\t}, [realOperation, pendingOperation, clearPendingOperation])\n\n\t// Use real operation if available, otherwise fall back to pending\n\tconst activeOperation = realOperation ?? pendingOperation\n\n\t// Don't render if no active operation\n\t// Container handles visibility check, but this is a safety fallback\n\tif (!activeOperation) return null\n\n\t// Force the island to stay expanded when rebooting so the countdown is always visible.\n\t// This helps ensure users see this critical warning before the system restarts.\n\tconst isRebooting = activeOperation.state === 'rebooting'\n\n\treturn (\n\t\t<Island id='raid-island' nonDismissable forceExpanded={isRebooting}>\n\t\t\t<IslandMinimized>\n\t\t\t\t<MinimizedContent operation={activeOperation} />\n\t\t\t</IslandMinimized>\n\t\t\t<IslandExpanded>\n\t\t\t\t<ExpandedContent operation={activeOperation} />\n\t\t\t</IslandExpanded>\n\t\t</Island>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/floating-island/minimized.tsx",
    "content": "import {type RaidProgress} from '@/features/storage/hooks/use-raid-progress'\nimport {t} from '@/utils/i18n'\n\nimport {DataStreamIconMini} from './data-stream-icon'\nimport {raidOperationLabels} from './index'\n\n// No restart countdown here - island is force-expanded when rebooting (see index.tsx)\nexport function MinimizedContent({operation}: {operation: RaidProgress}) {\n\tconst label = t(raidOperationLabels[operation.type])\n\tconst isActive = operation.state !== 'finished' && operation.state !== 'complete' && operation.state !== 'canceled'\n\n\treturn (\n\t\t<div className='flex size-full items-center gap-2 px-2'>\n\t\t\t<div className='relative flex size-5 items-center justify-center'>\n\t\t\t\t<DataStreamIconMini size={20} isActive={isActive} />\n\t\t\t</div>\n\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t<span className='block truncate text-center text-xs text-white/90'>{label}</span>\n\t\t\t</div>\n\t\t\t<div className='flex shrink-0 items-center gap-2'>\n\t\t\t\t<span className='text-xs text-white/60'>{Math.round(operation.progress)}%</span>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/ssd-shape.tsx",
    "content": "import {useEffect, useState} from 'react'\n// TODO: Consider changing TbBattery1 (low life) and TbHeartBroken (unhealthy) icons to something more intuitive\nimport {TbActivityHeartbeat, TbAlertTriangleFilled, TbBattery1, TbFlame, TbHeartBroken} from 'react-icons/tb'\n\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\nimport {formatTemperature} from '@/utils/temperature'\n\nimport {getDeviceHealth, RaidDevice, raidStatusLabels, StorageDevice} from '../hooks/use-storage'\nimport {formatStorageSize} from '../utils'\n\ntype SsdShapeProps = {\n\tdevice: StorageDevice\n\tslotNumber: number\n\tonHealthClick: () => void\n\tminRoundedDriveSize: number\n\traidType?: 'storage' | 'failsafe'\n\ttemperatureUnit: 'c' | 'f'\n\tisReadyToAdd?: boolean\n\t/** RAID device info - undefined if device is not in RAID */\n\traidDevice?: RaidDevice\n}\n\nexport function SsdShape({\n\tdevice,\n\tslotNumber,\n\tonHealthClick,\n\tminRoundedDriveSize,\n\traidType,\n\ttemperatureUnit,\n\tisReadyToAdd = false,\n\traidDevice,\n}: SsdShapeProps) {\n\t// Check for RAID device failure (not ONLINE means the drive has issues in the RAID array)\n\tconst isRaidDeviceFailed = raidDevice && raidDevice.raidStatus !== 'ONLINE'\n\n\t// Check for warnings\n\tconst {hasWarning, smartUnhealthy, lifeWarning, lifeRemaining, tempWarning, tempCritical} = getDeviceHealth(device)\n\n\t// Combined warning state: health warnings OR RAID device failure\n\tconst hasAnyWarning = hasWarning || isRaidDeviceFailed\n\n\t// Build array of active warnings for cycling\n\t// If RAID failure, only show that since any other warnings are not as important and can be seen in the health dialog\n\ttype WarningType = 'temperature' | 'unhealthy' | 'lowLife' | 'raidFailed'\n\tconst activeWarnings: WarningType[] = []\n\tif (isRaidDeviceFailed) {\n\t\tactiveWarnings.push('raidFailed')\n\t} else {\n\t\tif (tempWarning || tempCritical) activeWarnings.push('temperature')\n\t\tif (smartUnhealthy) activeWarnings.push('unhealthy')\n\t\tif (lifeWarning) activeWarnings.push('lowLife')\n\t}\n\n\t// Cycle through warnings with fade transition\n\tconst [currentWarningIndex, setCurrentWarningIndex] = useState(0)\n\tconst [isVisible, setIsVisible] = useState(true)\n\tuseEffect(() => {\n\t\tif (activeWarnings.length <= 1) return\n\t\tconst interval = setInterval(() => {\n\t\t\t// Fade out\n\t\t\tsetIsVisible(false)\n\t\t\t// After fade out, change warning and fade in\n\t\t\tsetTimeout(() => {\n\t\t\t\tsetCurrentWarningIndex((prev) => (prev + 1) % activeWarnings.length)\n\t\t\t\tsetIsVisible(true)\n\t\t\t}, 200) // 200ms fade out duration\n\t\t}, 2000) // 3 seconds per warning\n\t\treturn () => clearInterval(interval)\n\t}, [activeWarnings.length])\n\n\tconst currentWarning = activeWarnings[currentWarningIndex % activeWarnings.length]\n\n\t// In failsafe mode, drives with larger roundedSize than the minimum have wasted space\n\tconst wastedBytes =\n\t\traidType === 'failsafe' && minRoundedDriveSize > 0 ? Math.max(0, device.roundedSize - minRoundedDriveSize) : 0\n\tconst hasWastedSpace = wastedBytes > 0\n\tconst usableSize = hasWastedSpace ? minRoundedDriveSize : device.roundedSize\n\n\t// Dimensions\n\tconst width = 85\n\tconst height = 340\n\tconst notchRadius = 11\n\tconst cornerRadius = 6\n\tconst notchCenterX = width / 2\n\n\t// SVG path for SSD shape with bottom notch cut out\n\tconst path = `\n\t\tM ${cornerRadius} 0\n\t\tH ${width - cornerRadius}\n\t\tQ ${width} 0 ${width} ${cornerRadius}\n\t\tV ${height - cornerRadius}\n\t\tQ ${width} ${height} ${width - cornerRadius} ${height}\n\t\tH ${notchCenterX + notchRadius}\n\t\tA ${notchRadius} ${notchRadius} 0 0 0 ${notchCenterX - notchRadius} ${height}\n\t\tH ${cornerRadius}\n\t\tQ 0 ${height} 0 ${height - cornerRadius}\n\t\tV ${cornerRadius}\n\t\tQ 0 0 ${cornerRadius} 0\n\t\tZ\n\t`\n\n\t// Gold fingers configuration\n\tconst fingerCount = 29\n\tconst fingerWidth = 2.5\n\tconst fingerHeight = 16\n\tconst keyNotchGap = 6 // Gap between main fingers and last 4 (M.2 key)\n\tconst mainFingerCount = fingerCount - 4\n\tconst keyFingerCount = 4\n\tconst fingersWidth = fingerCount * fingerWidth + keyNotchGap\n\tconst fingersStartX = (width - fingersWidth) / 2\n\n\t// Extend viewBox to include fingers above the SSD\n\tconst viewBoxY = -fingerHeight\n\tconst totalHeight = height + fingerHeight\n\n\t// Unique ID for gradient (needed when multiple SSDs on page)\n\tconst gradientId = `ssd-gradient-${slotNumber}`\n\n\treturn (\n\t\t<div className={cn('relative shrink-0', isReadyToAdd && 'animate-pulse')} style={{width, height: totalHeight}}>\n\t\t\t{/* SVG outline shape */}\n\t\t\t<svg\n\t\t\t\tclassName='absolute inset-0'\n\t\t\t\twidth={width}\n\t\t\t\theight={totalHeight}\n\t\t\t\tviewBox={`0 ${viewBoxY} ${width} ${totalHeight}`}\n\t\t\t\tfill='none'\n\t\t\t>\n\t\t\t\t<defs>\n\t\t\t\t\t<linearGradient id={gradientId} x1='0%' y1='0%' x2='0%' y2='100%'>\n\t\t\t\t\t\t{isReadyToAdd ? (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<stop offset='0%' stopColor='rgba(255, 255, 255, 0.15)' />\n\t\t\t\t\t\t\t\t<stop offset='100%' stopColor='rgba(255, 255, 255, 0.05)' />\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t) : hasAnyWarning ? (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<stop offset='0%' stopColor='#FF2F32' />\n\t\t\t\t\t\t\t\t<stop offset='100%' stopColor='#991C1E' />\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t<stop offset='0%' style={{stopColor: 'hsl(var(--color-brand) / 0)'}} />\n\t\t\t\t\t\t\t\t<stop offset='100%' style={{stopColor: 'hsl(var(--color-brand) / 0.1)'}} />\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</linearGradient>\n\t\t\t\t\t<linearGradient id={`finger-gradient-${slotNumber}`} x1='0%' y1='0%' x2='0%' y2='100%'>\n\t\t\t\t\t\t<stop offset='0%' stopColor='rgba(255, 255, 255, 0.12)' />\n\t\t\t\t\t\t<stop offset='100%' stopColor='rgba(255, 255, 255, 0)' />\n\t\t\t\t\t</linearGradient>\n\t\t\t\t\t<linearGradient id={`finger-stroke-${slotNumber}`} x1='0%' y1='0%' x2='0%' y2='100%'>\n\t\t\t\t\t\t<stop offset='0%' stopColor='rgba(255, 255, 255, 0.2)' />\n\t\t\t\t\t\t<stop offset='100%' stopColor='rgba(255, 255, 255, 0)' />\n\t\t\t\t\t</linearGradient>\n\t\t\t\t</defs>\n\t\t\t\t<path d={path} fill={`url(#${gradientId})`} stroke='rgba(255, 255, 255, 0.12)' strokeWidth='2' />\n\t\t\t\t{/* Gold fingers at top - outside the SSD */}\n\t\t\t\t{/* Main group of fingers */}\n\t\t\t\t{Array.from({length: mainFingerCount}).map((_, i) => (\n\t\t\t\t\t<rect\n\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\tx={fingersStartX + i * fingerWidth}\n\t\t\t\t\t\ty={-fingerHeight}\n\t\t\t\t\t\twidth={fingerWidth}\n\t\t\t\t\t\theight={fingerHeight}\n\t\t\t\t\t\trx={3}\n\t\t\t\t\t\tfill={`url(#finger-gradient-${slotNumber})`}\n\t\t\t\t\t\tstroke={`url(#finger-stroke-${slotNumber})`}\n\t\t\t\t\t\tstrokeWidth={0.5}\n\t\t\t\t\t/>\n\t\t\t\t))}\n\t\t\t\t{/* Key fingers (last 4) with gap */}\n\t\t\t\t{Array.from({length: keyFingerCount}).map((_, i) => (\n\t\t\t\t\t<rect\n\t\t\t\t\t\tkey={`key-${i}`}\n\t\t\t\t\t\tx={fingersStartX + mainFingerCount * fingerWidth + keyNotchGap + i * fingerWidth}\n\t\t\t\t\t\ty={-fingerHeight}\n\t\t\t\t\t\twidth={fingerWidth}\n\t\t\t\t\t\theight={fingerHeight}\n\t\t\t\t\t\trx={3}\n\t\t\t\t\t\tfill={`url(#finger-gradient-${slotNumber})`}\n\t\t\t\t\t\tstroke={`url(#finger-stroke-${slotNumber})`}\n\t\t\t\t\t\tstrokeWidth={0.5}\n\t\t\t\t\t/>\n\t\t\t\t))}\n\t\t\t</svg>\n\n\t\t\t{/* Content overlay div */}\n\t\t\t<div\n\t\t\t\tclassName='absolute z-10 flex flex-col items-center justify-between rounded-[4px] border py-3'\n\t\t\t\tstyle={{\n\t\t\t\t\ttop: fingerHeight + 20,\n\t\t\t\t\tleft: 10,\n\t\t\t\t\tright: 10,\n\t\t\t\t\tbottom: 30,\n\t\t\t\t\tborderColor: isReadyToAdd\n\t\t\t\t\t\t? 'rgba(255, 255, 255, 0.2)'\n\t\t\t\t\t\t: hasAnyWarning\n\t\t\t\t\t\t\t? '#E22C2C'\n\t\t\t\t\t\t\t: 'hsl(var(--color-brand))',\n\t\t\t\t\tbackground: isReadyToAdd\n\t\t\t\t\t\t? 'linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.08) 100%)'\n\t\t\t\t\t\t: hasAnyWarning\n\t\t\t\t\t\t\t? 'linear-gradient(180deg, rgba(255, 255, 255, 0.37) 0%, rgba(255, 255, 255, 0.12) 100%)'\n\t\t\t\t\t\t\t: 'linear-gradient(177.39deg, hsl(var(--color-brand) / 0.48) 0.11%, hsl(var(--color-brand) / 0.12) 99.89%)',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{/* SSD Size - we show usable size, with actual size crossed out if wasted */}\n\t\t\t\t<div className='relative mt-4' style={{transform: 'rotate(-90deg)'}}>\n\t\t\t\t\t<span\n\t\t\t\t\t\tclassName='font-bold text-white'\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tfontSize: '25px',\n\t\t\t\t\t\t\ttextShadow: '0px 0px 6px rgba(255, 255, 255, 0.25)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{formatStorageSize(usableSize)}\n\t\t\t\t\t</span>\n\t\t\t\t\t{/* Crossed-out actual size - positioned below*/}\n\t\t\t\t\t{hasWastedSpace && (\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName='absolute font-bold text-white/40 line-through'\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tfontSize: '25px',\n\t\t\t\t\t\t\t\tright: '100%',\n\t\t\t\t\t\t\t\ttop: '50%',\n\t\t\t\t\t\t\t\ttransform: 'translateY(-50%)',\n\t\t\t\t\t\t\t\tmarginRight: '16px',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{formatStorageSize(device.size)}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t{/* Warning indicators + Health pulse pill grouped together at bottom */}\n\t\t\t\t<div className='flex flex-col items-center gap-3'>\n\t\t\t\t\t{hasWastedSpace && (\n\t\t\t\t\t\t<span className='text-center text-[13px] leading-tight font-medium text-white/50'>\n\t\t\t\t\t\t\t{t('storage-manager.wasted-size', {size: formatStorageSize(wastedBytes)})}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* Cycling warning indicators */}\n\t\t\t\t\t{activeWarnings.length > 0 && (\n\t\t\t\t\t\t<div className='flex flex-col items-center gap-1'>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName='flex flex-col items-center gap-0.5 transition-opacity duration-200'\n\t\t\t\t\t\t\t\tstyle={{opacity: isVisible ? 1 : 0}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{currentWarning === 'raidFailed' && raidDevice && (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t<TbAlertTriangleFilled\n\t\t\t\t\t\t\t\t\t\t\tclassName='size-4 text-white'\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tfilter: 'drop-shadow(0 0 6px rgba(255, 255, 255, 0.8))',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<span className='text-[13px] font-bold text-white'>\n\t\t\t\t\t\t\t\t\t\t\t{raidStatusLabels[raidDevice.raidStatus]\n\t\t\t\t\t\t\t\t\t\t\t\t? t(raidStatusLabels[raidDevice.raidStatus])\n\t\t\t\t\t\t\t\t\t\t\t\t: raidDevice.raidStatus}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{currentWarning === 'temperature' && (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t<TbFlame\n\t\t\t\t\t\t\t\t\t\t\tclassName='size-4 text-white'\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tfill: 'currentColor',\n\t\t\t\t\t\t\t\t\t\t\t\tfilter: 'drop-shadow(0 0 6px rgba(255, 255, 255, 0.8))',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<span className='text-[13px] font-bold text-white'>\n\t\t\t\t\t\t\t\t\t\t\t{formatTemperature(device.temperature, temperatureUnit)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{currentWarning === 'unhealthy' && (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t<TbHeartBroken\n\t\t\t\t\t\t\t\t\t\t\tclassName='size-4 text-white'\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tfilter: 'drop-shadow(0 0 6px rgba(255, 255, 255, 0.8))',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<span className='text-[13px] font-bold text-white'>{t('storage-manager.ssd-failing')}</span>\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{currentWarning === 'lowLife' && (\n\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t<TbBattery1\n\t\t\t\t\t\t\t\t\t\t\tclassName='size-4 text-white'\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tfilter: 'drop-shadow(0 0 6px rgba(255, 255, 255, 0.8))',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<span className='text-[13px] font-bold text-white'>{lifeRemaining}%</span>\n\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{/* Carousel dots - only show if multiple warnings */}\n\t\t\t\t\t\t\t{activeWarnings.length > 1 && (\n\t\t\t\t\t\t\t\t<div className='flex gap-1'>\n\t\t\t\t\t\t\t\t\t{activeWarnings.map((_, index) => (\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tkey={index}\n\t\t\t\t\t\t\t\t\t\t\tclassName='size-1 rounded-full transition-opacity duration-200'\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: 'white',\n\t\t\t\t\t\t\t\t\t\t\t\topacity: index === currentWarningIndex ? 1 : 0.3,\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* Health pulse pill */}\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\tonClick={onHealthClick}\n\t\t\t\t\t\tclassName='relative flex items-center justify-center rounded-full border border-white/[0.16] bg-white/[0.08] px-4 py-1 transition-colors hover:bg-white/[0.12]'\n\t\t\t\t\t>\n\t\t\t\t\t\t<TbActivityHeartbeat className='size-4 text-white' />\n\t\t\t\t\t\t{/* Warning dot - upper right of pill */}\n\t\t\t\t\t\t{hasAnyWarning && (\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName='absolute'\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\ttop: '-2px',\n\t\t\t\t\t\t\t\t\tright: '-2px',\n\t\t\t\t\t\t\t\t\twidth: '10px',\n\t\t\t\t\t\t\t\t\theight: '10px',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{/* Solid center dot */}\n\t\t\t\t\t\t\t\t<span className='absolute inset-0 rounded-full bg-white' />\n\t\t\t\t\t\t\t\t{/* Expanding ping ring */}\n\t\t\t\t\t\t\t\t<span className='absolute inset-0 animate-ping rounded-full bg-white opacity-75' />\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/storage-donut-chart.tsx",
    "content": "import {TbAlertTriangleFilled} from 'react-icons/tb'\nimport {Cell, Label, Pie, PieChart} from 'recharts'\n\nimport {t} from '@/utils/i18n'\nimport {maybePrettyBytes} from '@/utils/pretty-bytes'\n\ntype StorageDonutChartProps = {\n\tused: number\n\tavailable: number\n\tfailsafe: number\n\twasted: number\n\tusedBytes?: number // Raw bytes for center label formatting\n\thideCenter?: boolean // Hide center label (for preview charts)\n\tisLoading?: boolean // Show skeleton loading state\n}\n\nexport function StorageDonutChart({\n\tused,\n\tavailable,\n\tfailsafe,\n\twasted,\n\tusedBytes,\n\thideCenter,\n\tisLoading,\n}: StorageDonutChartProps) {\n\tconst size = 140\n\tconst innerRadius = 50\n\tconst outerRadius = 70\n\n\t// Loading skeleton state - we show empty gray ring with pulse animation\n\tif (isLoading) {\n\t\tconst LoadingCenterLabel = ({viewBox}: {viewBox?: {cx?: number; cy?: number}}) => {\n\t\t\tconst {cx = 0, cy = 0} = viewBox || {}\n\t\t\treturn (\n\t\t\t\t<text x={cx} y={cy} textAnchor='middle' dominantBaseline='central' className='animate-pulse'>\n\t\t\t\t\t<tspan x={cx} dy='-0.3em' fill='rgba(255,255,255,0.3)' fontSize='16' fontWeight='bold'>\n\t\t\t\t\t\t—\n\t\t\t\t\t</tspan>\n\t\t\t\t\t<tspan x={cx} dy='1.5em' fill='rgba(255,255,255,0.3)' fontSize='13'>\n\t\t\t\t\t\t{t('storage-manager.used')}\n\t\t\t\t\t</tspan>\n\t\t\t\t</text>\n\t\t\t)\n\t\t}\n\n\t\treturn (\n\t\t\t<div className='[&_*]:outline-hidden [&_*]:focus:outline-hidden' style={{pointerEvents: 'none'}} tabIndex={-1}>\n\t\t\t\t<PieChart width={size} height={size} style={{cursor: 'default'}} tabIndex={-1}>\n\t\t\t\t\t<Pie\n\t\t\t\t\t\tdata={[{name: 'loading', value: 1, color: 'rgba(255, 255, 255, 0.1)'}]}\n\t\t\t\t\t\tcx='50%'\n\t\t\t\t\t\tcy='50%'\n\t\t\t\t\t\tinnerRadius={innerRadius}\n\t\t\t\t\t\touterRadius={outerRadius}\n\t\t\t\t\t\tpaddingAngle={0}\n\t\t\t\t\t\tdataKey='value'\n\t\t\t\t\t\tstartAngle={90}\n\t\t\t\t\t\tendAngle={-270}\n\t\t\t\t\t\tcornerRadius={4}\n\t\t\t\t\t\tstroke='none'\n\t\t\t\t\t\tisAnimationActive={false}\n\t\t\t\t\t\tactiveIndex={-1}\n\t\t\t\t\t\tclassName='animate-pulse'\n\t\t\t\t\t>\n\t\t\t\t\t\t<Cell fill='rgba(255, 255, 255, 0.1)' style={{outline: 'none', cursor: 'default'}} />\n\t\t\t\t\t\t{!hideCenter && <Label content={<LoadingCenterLabel />} position='center' />}\n\t\t\t\t\t</Pie>\n\t\t\t\t</PieChart>\n\t\t\t</div>\n\t\t)\n\t}\n\n\t// Colors\n\tconst BRAND_COLOR = 'hsl(var(--color-brand))'\n\t// FailSafe: brand color with 60% white overlay = lighter version\n\tconst BRAND_LIGHT = 'color-mix(in srgb, hsl(var(--color-brand)), white 60%)'\n\tconst WASTED_COLOR = '#F5A623'\n\tconst USED_COLOR = 'rgba(255, 255, 255, 0.8)'\n\n\t// Outer ring: capacity breakdown (available, failsafe overhead, wasted)\n\tconst capacityData = [\n\t\t{name: 'wasted', value: wasted, color: WASTED_COLOR},\n\t\t{name: 'failsafe', value: failsafe, color: BRAND_LIGHT},\n\t\t{name: 'available', value: available, color: BRAND_COLOR},\n\t].filter((d) => d.value > 0)\n\n\t// Inner ring: \"used\" overlay positioned within the \"available\" segment (with 2% inset from edges)\n\tconst total = wasted + failsafe + available\n\tconst inset = total * 0.02\n\tconst usedData = [\n\t\t{name: 'spacer', value: wasted + failsafe + inset, color: 'transparent'},\n\t\t{name: 'used', value: Math.max(0, used - inset), color: USED_COLOR},\n\t\t{name: 'free', value: Math.max(0, available - used), color: 'transparent'},\n\t]\n\n\t// Custom center label component - uses same formatting as Settings/Live Usage\n\tconst CenterLabel = ({viewBox}: {viewBox?: {cx?: number; cy?: number}}) => {\n\t\tconst {cx = 0, cy = 0} = viewBox || {}\n\t\tconst usedDisplay = maybePrettyBytes(usedBytes ?? 0)\n\t\treturn (\n\t\t\t<text x={cx} y={cy} textAnchor='middle' dominantBaseline='central'>\n\t\t\t\t<tspan x={cx} dy='-0.3em' fill='white' fontSize='16' fontWeight='bold'>\n\t\t\t\t\t{usedDisplay}\n\t\t\t\t</tspan>\n\t\t\t\t<tspan x={cx} dy='1.5em' fill='rgba(255,255,255,0.5)' fontSize='13'>\n\t\t\t\t\t{t('storage-manager.used')}\n\t\t\t\t</tspan>\n\t\t\t</text>\n\t\t)\n\t}\n\n\t// Calculate position for warning icon in the middle of wasted segment\n\tconst center = size / 2\n\tconst midRadius = (innerRadius + outerRadius) / 2\n\t// Wasted segment starts at 90 degrees and spans clockwise\n\tconst wastedDegrees = (wasted / total) * 360\n\tconst wastedMidAngle = 90 - wastedDegrees / 2 // Center of wasted segment\n\tconst wastedMidRad = (wastedMidAngle * Math.PI) / 180\n\tconst warningX = center + midRadius * Math.cos(wastedMidRad)\n\tconst warningY = center - midRadius * Math.sin(wastedMidRad)\n\n\treturn (\n\t\t<div className='[&_*]:outline-hidden [&_*]:focus:outline-hidden' style={{pointerEvents: 'none'}} tabIndex={-1}>\n\t\t\t<PieChart width={size} height={size} style={{cursor: 'default'}} tabIndex={-1}>\n\t\t\t\t{/* Outer ring: capacity breakdown */}\n\t\t\t\t<Pie\n\t\t\t\t\tdata={capacityData}\n\t\t\t\t\tcx='50%'\n\t\t\t\t\tcy='50%'\n\t\t\t\t\tinnerRadius={innerRadius}\n\t\t\t\t\touterRadius={outerRadius}\n\t\t\t\t\tpaddingAngle={4}\n\t\t\t\t\tdataKey='value'\n\t\t\t\t\tstartAngle={90}\n\t\t\t\t\tendAngle={-270}\n\t\t\t\t\tcornerRadius={4}\n\t\t\t\t\tstroke='none'\n\t\t\t\t\tisAnimationActive={false}\n\t\t\t\t\tactiveIndex={-1}\n\t\t\t\t>\n\t\t\t\t\t{capacityData.map((entry, index) => (\n\t\t\t\t\t\t<Cell key={`capacity-${index}`} fill={entry.color} style={{outline: 'none', cursor: 'default'}} />\n\t\t\t\t\t))}\n\t\t\t\t\t{!hideCenter && <Label content={<CenterLabel />} position='center' />}\n\t\t\t\t</Pie>\n\n\t\t\t\t{/* Inner ring: \"used\" overlay on the available segment */}\n\t\t\t\t<Pie\n\t\t\t\t\tdata={usedData}\n\t\t\t\t\tcx='50%'\n\t\t\t\t\tcy='50%'\n\t\t\t\t\tinnerRadius={innerRadius + 4}\n\t\t\t\t\touterRadius={outerRadius - 4}\n\t\t\t\t\tpaddingAngle={0}\n\t\t\t\t\tdataKey='value'\n\t\t\t\t\tstartAngle={90}\n\t\t\t\t\tendAngle={-270}\n\t\t\t\t\tcornerRadius={4}\n\t\t\t\t\tstroke='none'\n\t\t\t\t\tisAnimationActive={false}\n\t\t\t\t\tactiveIndex={-1}\n\t\t\t\t>\n\t\t\t\t\t{usedData.map((entry, index) => (\n\t\t\t\t\t\t<Cell key={`used-${index}`} fill={entry.color} style={{outline: 'none', cursor: 'default'}} />\n\t\t\t\t\t))}\n\t\t\t\t</Pie>\n\n\t\t\t\t{/* Warning icon in wasted segment */}\n\t\t\t\t{wasted > 0 && (\n\t\t\t\t\t<foreignObject x={warningX - 8} y={warningY - 8} width={16} height={16}>\n\t\t\t\t\t\t<TbAlertTriangleFilled className='size-4 text-white' />\n\t\t\t\t\t</foreignObject>\n\t\t\t\t)}\n\t\t\t</PieChart>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/components/storage-mode-display.tsx",
    "content": "import {useState} from 'react'\nimport {IoShieldHalf} from 'react-icons/io5'\nimport {TbInfoCircle, TbServer} from 'react-icons/tb'\n\nimport {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\nimport {RaidType} from '../hooks/use-storage'\n\n// i18n keys used dynamically via modeOptions[].titleKey, descriptionKey, etc:\n// t('storage-manager.mode.full-storage')\n// t('storage-manager.mode.full-storage.description')\n// t('storage-manager.mode.full-storage.info-title')\n// t('storage-manager.mode.full-storage.info-description')\n// t('storage-manager.mode.failsafe')\n// t('storage-manager.mode.failsafe.description')\n// t('storage-manager.mode.failsafe.info-title')\n// t('storage-manager.mode.failsafe.info-description')\n\ntype ModeOption = {\n\tid: RaidType\n\ticon: React.ReactNode\n\ttitleKey: string\n\tdescriptionKey: string\n\tinfoTitleKey: string\n\tinfoDescriptionKey: string\n}\n\nconst modeOptions: ModeOption[] = [\n\t{\n\t\tid: 'storage',\n\t\ticon: <TbServer className='size-5' />,\n\t\ttitleKey: 'storage-manager.mode.full-storage',\n\t\tdescriptionKey: 'storage-manager.mode.full-storage.description',\n\t\tinfoTitleKey: 'storage-manager.mode.full-storage.info-title',\n\t\tinfoDescriptionKey: 'storage-manager.mode.full-storage.info-description',\n\t},\n\t{\n\t\tid: 'failsafe',\n\t\ticon: <IoShieldHalf className='size-5' />,\n\t\ttitleKey: 'storage-manager.mode.failsafe',\n\t\tdescriptionKey: 'storage-manager.mode.failsafe.description',\n\t\tinfoTitleKey: 'storage-manager.mode.failsafe.info-title',\n\t\tinfoDescriptionKey: 'storage-manager.mode.failsafe.info-description',\n\t},\n]\n\ntype StorageModeDisplayProps = {\n\tvalue: RaidType\n\tcanEnableFailsafe: boolean\n}\n\nexport function StorageModeDisplay({value, canEnableFailsafe}: StorageModeDisplayProps) {\n\tconst [infoDialogOption, setInfoDialogOption] = useState<ModeOption | null>(null)\n\n\t// Dynamic \"why not available\" messages based on current state\n\tconst getWhyNotAvailable = (optionId: RaidType): string | null => {\n\t\tif (optionId === 'storage' && value === 'failsafe') {\n\t\t\treturn t('storage-manager.mode.switch-from-failsafe-unavailable')\n\t\t}\n\t\tif (optionId === 'failsafe' && value === 'storage') {\n\t\t\t// User has 1 SSD so they CAN enable FailSafe by adding more drives\n\t\t\tif (canEnableFailsafe) return null\n\n\t\t\t// User has 2+ SSDs in storage mode so cannot enable FailSafe\n\t\t\treturn t('storage-manager.mode.switch-to-failsafe-unavailable')\n\t\t}\n\t\treturn null\n\t}\n\n\t// We show a \"why can't I switch\" message if applicable\n\tconst whyNotAvailable = infoDialogOption ? getWhyNotAvailable(infoDialogOption.id) : null\n\n\treturn (\n\t\t<>\n\t\t\t{/* Mobile: Compact row showing both modes */}\n\t\t\t<div className='flex gap-2 rounded-24 bg-white/5 p-2 md:hidden'>\n\t\t\t\t{modeOptions.map((option) => {\n\t\t\t\t\tconst isSelected = value === option.id\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={option.id}\n\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\tonClick={() => setInfoDialogOption(option)}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t'flex flex-1 items-center justify-center gap-2 rounded-17 border px-3 py-2.5 transition-colors',\n\t\t\t\t\t\t\t\tisSelected ? 'border-brand bg-brand/15' : 'border-transparent opacity-50',\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className={cn(isSelected ? 'text-white' : 'text-white/80')}>{option.icon}</span>\n\t\t\t\t\t\t\t<span className={cn('text-13 font-semibold', isSelected ? 'text-white' : 'text-white/80')}>\n\t\t\t\t\t\t\t\t{t(option.titleKey)}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)\n\t\t\t\t})}\n\t\t\t</div>\n\n\t\t\t{/* Desktop: Show both modes with descriptions */}\n\t\t\t<div className='hidden rounded-24 bg-white/5 p-2 md:block'>\n\t\t\t\t<div className='grid grid-cols-2 gap-2'>\n\t\t\t\t\t{modeOptions.map((option) => {\n\t\t\t\t\t\tconst isSelected = value === option.id\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={option.id}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t'flex flex-col gap-2 rounded-17 border px-4 py-3 text-left',\n\t\t\t\t\t\t\t\t\tisSelected ? 'border-brand bg-brand/15' : 'border-transparent opacity-50',\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t\t<span className={cn(isSelected ? 'text-white' : 'text-white/80')}>{option.icon}</span>\n\t\t\t\t\t\t\t\t\t<span className={cn('text-15 font-semibold', isSelected ? 'text-white' : 'text-white/80')}>\n\t\t\t\t\t\t\t\t\t\t{t(option.titleKey)}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\t\t\tonClick={() => setInfoDialogOption(option)}\n\t\t\t\t\t\t\t\t\t\tclassName='-ml-1 text-white/40 transition-colors hover:text-white/60'\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<TbInfoCircle className='size-4' />\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<p className='text-13 leading-snug font-medium text-white/60'>{t(option.descriptionKey)}</p>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* Info Dialog */}\n\t\t\t<Dialog open={infoDialogOption !== null} onOpenChange={(open) => !open && setInfoDialogOption(null)}>\n\t\t\t\t<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>\n\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t{infoDialogOption?.icon}\n\t\t\t\t\t\t\t<DialogTitle>{infoDialogOption && t(infoDialogOption.infoTitleKey)}</DialogTitle>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</DialogHeader>\n\n\t\t\t\t\t<div className='space-y-4'>\n\t\t\t\t\t\t<p className='text-13 leading-relaxed text-white/70'>\n\t\t\t\t\t\t\t{infoDialogOption && t(infoDialogOption.infoDescriptionKey)}\n\t\t\t\t\t\t</p>\n\n\t\t\t\t\t\t{whyNotAvailable && (\n\t\t\t\t\t\t\t<div className='rounded-12 bg-white/6 p-3'>\n\t\t\t\t\t\t\t\t<p className='text-13 font-medium text-white/50'>\n\t\t\t\t\t\t\t\t\t<span className='text-white/70'>{t('storage-manager.mode.why-cant-switch')}</span>\n\t\t\t\t\t\t\t\t\t<br />\n\t\t\t\t\t\t\t\t\t{whyNotAvailable}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t<Button variant='default' onClick={() => setInfoDialogOption(null)}>\n\t\t\t\t\t\t\t{t('done')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</DialogFooter>\n\t\t\t\t</DialogContent>\n\t\t\t</Dialog>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/hooks/use-active-raid-operation.ts",
    "content": "import {usePendingRaidOperation} from '../providers/pending-operation-context'\nimport {RaidProgress, useRaidProgress} from './use-raid-progress'\n\n/**\n * Hook to check if any RAID operation is currently active.\n * Combines real operations (from backend events) with pending operations (optimistic UI).\n * Use this to prevent starting new operations while one is in progress.\n */\nexport function useActiveRaidOperation(): RaidProgress | null {\n\tconst realOperation = useRaidProgress()\n\tconst {pendingOperation} = usePendingRaidOperation()\n\treturn realOperation ?? pendingOperation\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/hooks/use-raid-progress.ts",
    "content": "import {useState} from 'react'\n\nimport {toast} from '@/components/ui/toast'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\n// Types matching the backend event types\ntype ExpansionStatus = {\n\tstate: 'expanding' | 'finished' | 'canceled'\n\tprogress: number\n}\n\ntype RebuildStatus = {\n\tstate: 'rebuilding' | 'finished' | 'canceled'\n\tprogress: number\n}\n\ntype ReplaceStatus = RebuildStatus\n\ntype FailsafeTransitionStatus = {\n\tstate: 'syncing' | 'rebooting' | 'rebuilding' | 'complete' | 'error'\n\tprogress: number\n\terror?: string\n}\n\nexport type RaidOperationType = 'expansion' | 'rebuild' | 'replace' | 'failsafe-transition'\n\nexport type RaidProgress = {\n\ttype: RaidOperationType\n\tstate: string\n\tprogress: number\n}\n\n// Hook to subscribe to all RAID progress events and return the active operation.\n// Returns null when no operation is in progress.\nexport function useRaidProgress(): RaidProgress | null {\n\t// Track all RAID operation states\n\tconst [expansion, setExpansion] = useState<ExpansionStatus | null>(null)\n\tconst [rebuild, setRebuild] = useState<RebuildStatus | null>(null)\n\tconst [replace, setReplace] = useState<ReplaceStatus | null>(null)\n\tconst [failsafeTransition, setFailsafeTransition] = useState<FailsafeTransitionStatus | null>(null)\n\n\t// Subscribe to all RAID progress events\n\ttrpcReact.eventBus.listen.useSubscription(\n\t\t{event: 'raid:expansion-progress'},\n\t\t{\n\t\t\tonData(data) {\n\t\t\t\tconst status = data as ExpansionStatus\n\t\t\t\t// Clear when finished or canceled\n\t\t\t\tif (status.state === 'finished' || status.state === 'canceled') {\n\t\t\t\t\t// Keep visible briefly so user sees completion\n\t\t\t\t\tsetTimeout(() => setExpansion(null), 2000)\n\t\t\t\t}\n\t\t\t\tsetExpansion(status)\n\t\t\t},\n\t\t\tonError(err) {\n\t\t\t\tconsole.error('eventBus.listen(raid:expansion-progress) subscription error', err)\n\t\t\t},\n\t\t},\n\t)\n\n\ttrpcReact.eventBus.listen.useSubscription(\n\t\t{event: 'raid:rebuild-progress'},\n\t\t{\n\t\t\tonData(data) {\n\t\t\t\tconst status = data as RebuildStatus\n\t\t\t\tif (status.state === 'finished' || status.state === 'canceled') {\n\t\t\t\t\tsetTimeout(() => setRebuild(null), 2000)\n\t\t\t\t}\n\t\t\t\tsetRebuild(status)\n\t\t\t},\n\t\t\tonError(err) {\n\t\t\t\tconsole.error('eventBus.listen(raid:rebuild-progress) subscription error', err)\n\t\t\t},\n\t\t},\n\t)\n\n\ttrpcReact.eventBus.listen.useSubscription(\n\t\t{event: 'raid:replace-progress'},\n\t\t{\n\t\t\tonData(data) {\n\t\t\t\tconst status = data as ReplaceStatus\n\t\t\t\tif (status.state === 'finished' || status.state === 'canceled') {\n\t\t\t\t\tsetTimeout(() => setReplace(null), 2000)\n\t\t\t\t}\n\t\t\t\tsetReplace(status)\n\t\t\t},\n\t\t\tonError(err) {\n\t\t\t\tconsole.error('eventBus.listen(raid:replace-progress) subscription error', err)\n\t\t\t},\n\t\t},\n\t)\n\n\ttrpcReact.eventBus.listen.useSubscription(\n\t\t{event: 'raid:failsafe-transition-progress'},\n\t\t{\n\t\t\tonData(data) {\n\t\t\t\tconst status = data as FailsafeTransitionStatus\n\t\t\t\t// On error: show toast and clear immediately\n\t\t\t\tif (status.state === 'error') {\n\t\t\t\t\ttoast.error(status.error || t('storage-manager.failsafe-transition-failed'))\n\t\t\t\t\tsetFailsafeTransition(null)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// On complete: keep visible briefly so user sees completion\n\t\t\t\tif (status.state === 'complete') {\n\t\t\t\t\tsetTimeout(() => setFailsafeTransition(null), 2000)\n\t\t\t\t}\n\t\t\t\tsetFailsafeTransition(status)\n\t\t\t},\n\t\t\tonError(err) {\n\t\t\t\tconsole.error('eventBus.listen(raid:failsafe-transition-progress) subscription error', err)\n\t\t\t},\n\t\t},\n\t)\n\n\t// Determine which operation to display (priority order)\n\t// Failsafe transition takes priority as it's a major operation\n\tif (failsafeTransition) {\n\t\treturn {\n\t\t\ttype: 'failsafe-transition',\n\t\t\tstate: failsafeTransition.state,\n\t\t\tprogress: failsafeTransition.progress,\n\t\t}\n\t}\n\n\tif (replace) {\n\t\treturn {\n\t\t\ttype: 'replace',\n\t\t\tstate: replace.state,\n\t\t\tprogress: replace.progress,\n\t\t}\n\t}\n\n\tif (rebuild) {\n\t\treturn {\n\t\t\ttype: 'rebuild',\n\t\t\tstate: rebuild.state,\n\t\t\tprogress: rebuild.progress,\n\t\t}\n\t}\n\n\tif (expansion) {\n\t\treturn {\n\t\t\ttype: 'expansion',\n\t\t\tstate: expansion.state,\n\t\t\tprogress: expansion.progress,\n\t\t}\n\t}\n\n\treturn null\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/hooks/use-storage.ts",
    "content": "import {RouterOutput, trpcReact} from '@/trpc/trpc'\n\n// Types from backend\nexport type RaidStatus = RouterOutput['hardware']['raid']['getStatus']\nexport type StorageDevice = RouterOutput['hardware']['internalStorage']['getDevices'][number]\nexport type RaidType = 'storage' | 'failsafe'\n\n// RAID device status from ZFS pool\nexport type RaidDeviceStatus = 'ONLINE' | 'DEGRADED' | 'FAULTED' | 'OFFLINE' | 'UNAVAIL' | 'REMOVED'\n\n// i18n translation keys for RAID device statuses - call t() with these at render time\n// t('storage-manager.raid-status.online')\n// t('storage-manager.raid-status.degraded')\n// t('storage-manager.raid-status.failed')\n// t('storage-manager.raid-status.offline')\n// t('storage-manager.raid-status.unavailable')\n// t('storage-manager.raid-status.removed')\nexport const raidStatusLabels: Record<RaidDeviceStatus, string> = {\n\tONLINE: 'storage-manager.raid-status.online',\n\tDEGRADED: 'storage-manager.raid-status.degraded',\n\tFAULTED: 'storage-manager.raid-status.failed',\n\tOFFLINE: 'storage-manager.raid-status.offline',\n\tUNAVAIL: 'storage-manager.raid-status.unavailable',\n\tREMOVED: 'storage-manager.raid-status.removed',\n}\n\n// Threshold % for lifetime usage warning (100 = the rated endurance being fully used)\nexport const LIFETIME_WARNING_THRESHOLD = 80\n\n// Get device health status - single source of truth for all health checks\nexport function getDeviceHealth(device: StorageDevice) {\n\t// SMART status\n\tconst smartUnhealthy = device.smartStatus === 'unhealthy'\n\n\t// Lifetime remaining (inverse of lifetimeUsed)\n\tconst lifeRemaining = device.lifetimeUsed !== undefined ? Math.max(0, 100 - device.lifetimeUsed) : undefined\n\tconst lifeWarning = device.lifetimeUsed !== undefined && device.lifetimeUsed >= LIFETIME_WARNING_THRESHOLD\n\n\t// Temperature checks (critical takes precedence over warning)\n\tconst tempCritical =\n\t\tdevice.temperature !== undefined &&\n\t\tdevice.temperatureCritical !== undefined &&\n\t\tdevice.temperature >= device.temperatureCritical\n\n\tconst tempWarning =\n\t\t!tempCritical &&\n\t\tdevice.temperature !== undefined &&\n\t\tdevice.temperatureWarning !== undefined &&\n\t\tdevice.temperature >= device.temperatureWarning\n\n\t// Any warning present\n\tconst hasWarning = smartUnhealthy || lifeWarning || tempWarning || tempCritical\n\n\treturn {\n\t\thasWarning,\n\t\tsmartUnhealthy,\n\t\tlifeRemaining,\n\t\tlifeWarning,\n\t\ttempWarning,\n\t\ttempCritical,\n\t}\n}\n\n// Device in RAID with merged health info from internal storage\nexport type RaidDevice = StorageDevice & {\n\t// From RAID status\n\traidStatus: 'ONLINE' | 'DEGRADED' | 'FAULTED' | 'OFFLINE' | 'UNAVAIL' | 'REMOVED'\n\treadErrors: number\n\twriteErrors: number\n\tchecksumErrors: number\n}\n\n// Hook options\ntype UseStorageOptions = {\n\t/** Polling interval in ms for detecting new devices. Set to false to disable polling. */\n\tpollInterval?: number | false\n}\n\n/**\n * Main hook for storage management.\n * Provides RAID status, device info, and mutations for managing storage.\n */\nexport function useStorage(options: UseStorageOptions = {}) {\n\tconst {pollInterval = false} = options\n\n\t// Query: RAID pool status\n\tconst raidStatusQ = trpcReact.hardware.raid.getStatus.useQuery(undefined, {\n\t\trefetchInterval: pollInterval,\n\t})\n\n\t// Query: All internal storage devices (NVMe SSDs)\n\tconst devicesQ = trpcReact.hardware.internalStorage.getDevices.useQuery(undefined, {\n\t\trefetchInterval: pollInterval,\n\t})\n\n\t// Mutation: Add device to RAID\n\tconst addDeviceMut = trpcReact.hardware.raid.addDevice.useMutation({\n\t\tonSuccess: () => {\n\t\t\t// Refetch both queries after adding a device\n\t\t\traidStatusQ.refetch()\n\t\t\tdevicesQ.refetch()\n\t\t},\n\t})\n\n\t// Mutation: Transition from single-disk storage to failsafe mode\n\tconst transitionToFailsafeMut = trpcReact.hardware.raid.transitionToFailsafe.useMutation({\n\t\tonSuccess: () => {\n\t\t\traidStatusQ.refetch()\n\t\t\tdevicesQ.refetch()\n\t\t},\n\t})\n\n\t// Mutation: Replace a device in the RAID array\n\tconst replaceDeviceMut = trpcReact.hardware.raid.replaceDevice.useMutation({\n\t\tonSuccess: () => {\n\t\t\traidStatusQ.refetch()\n\t\t\tdevicesQ.refetch()\n\t\t},\n\t})\n\n\t// Refetch data when RAID operations complete (non-blocking RPCs return before operation finishes)\n\tconst refetchAll = () => {\n\t\traidStatusQ.refetch()\n\t\tdevicesQ.refetch()\n\t}\n\n\ttrpcReact.eventBus.listen.useSubscription(\n\t\t{event: 'raid:expansion-progress'},\n\t\t{\n\t\t\tonData(data) {\n\t\t\t\tconst status = data as {state: string}\n\t\t\t\tif (status.state === 'finished' || status.state === 'canceled') {\n\t\t\t\t\trefetchAll()\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t)\n\n\ttrpcReact.eventBus.listen.useSubscription(\n\t\t{event: 'raid:rebuild-progress'},\n\t\t{\n\t\t\tonData(data) {\n\t\t\t\tconst status = data as {state: string}\n\t\t\t\tif (status.state === 'finished' || status.state === 'canceled') {\n\t\t\t\t\trefetchAll()\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t)\n\n\ttrpcReact.eventBus.listen.useSubscription(\n\t\t{event: 'raid:replace-progress'},\n\t\t{\n\t\t\tonData(data) {\n\t\t\t\tconst status = data as {state: string}\n\t\t\t\tif (status.state === 'finished' || status.state === 'canceled') {\n\t\t\t\t\trefetchAll()\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t)\n\n\ttrpcReact.eventBus.listen.useSubscription(\n\t\t{event: 'raid:failsafe-transition-progress'},\n\t\t{\n\t\t\tonData(data) {\n\t\t\t\tconst status = data as {state: string}\n\t\t\t\tif (status.state === 'complete' || status.state === 'error') {\n\t\t\t\t\trefetchAll()\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t)\n\n\t// Derived: All detected devices\n\tconst allDevices = devicesQ.data ?? []\n\n\t// Derived: RAID status\n\tconst raidStatus = raidStatusQ.data\n\n\t// Derived: Device IDs that are in the RAID\n\tconst raidDeviceIds = new Set(raidStatus?.devices?.map((d) => d.id) ?? [])\n\n\t// Derived: RAID devices with merged health info\n\tconst raidDevices: RaidDevice[] = (raidStatus?.devices ?? [])\n\t\t.map((raidDevice) => {\n\t\t\t// Find matching device from internal storage for health info\n\t\t\tconst storageDevice = allDevices.find((d) => d.id === raidDevice.id)\n\t\t\tif (!storageDevice) return null\n\n\t\t\treturn {\n\t\t\t\t...storageDevice,\n\t\t\t\traidStatus: raidDevice.status,\n\t\t\t\treadErrors: raidDevice.readErrors,\n\t\t\t\twriteErrors: raidDevice.writeErrors,\n\t\t\t\tchecksumErrors: raidDevice.checksumErrors,\n\t\t\t}\n\t\t})\n\t\t.filter((d): d is RaidDevice => d !== null)\n\n\t// Derived: Available devices (not in RAID, can be added)\n\t// TODO: Currently UI is limited to adding 1 SSD at a time due to ZFS raidz1 expansion limitations.\n\t// When backend supports adding multiple SSDs at once, the UI can show all available devices.\n\tconst availableDevices = allDevices.filter((device) => device.id && !raidDeviceIds.has(device.id))\n\n\t// Derived: Failed/missing RAID devices that need replacement\n\t// A device needs replacement if:\n\t// 1. It's in the RAID with a non-ONLINE status (FAULTED, UNAVAIL, DEGRADED, OFFLINE, REMOVED)\n\t// 2. OR it's in the RAID but has no matching physical device (was physically removed)\n\tconst failedRaidDevices = (raidStatus?.devices ?? []).filter((rd) => {\n\t\tconst hasMatchingPhysical = allDevices.some((d) => d.id === rd.id)\n\t\tconst isNotOnline = rd.status !== 'ONLINE'\n\t\t// Include if status is bad OR if physically missing\n\t\treturn isNotOnline || !hasMatchingPhysical\n\t})\n\n\t// Derived: Can we replace a failed device? (degraded array + failed device + available replacement)\n\tconst isDegraded = raidStatus?.status === 'DEGRADED'\n\tconst canReplaceFailedDevice = isDegraded && failedRaidDevices.length > 0 && availableDevices.length > 0\n\n\t// Derived: Loading state\n\tconst isLoading = raidStatusQ.isLoading || devicesQ.isLoading\n\n\t// Derived: Error state\n\tconst error = raidStatusQ.error || devicesQ.error\n\n\t// --- Chart data calculations ---\n\t// TODO: Consider adding device sizes directly to raid.getStatus() backend response\n\t// instead of cross-referencing with internalStorage.getDevices()\n\n\t// Get rounded sizes of all RAID devices (backend rounds to nearest 250GB for drives ≥1TB)\n\t// This eliminates wasted space from manufacturer variance (e.g., Samsung vs Phison \"4TB\" drives)\n\tconst getRaidDeviceRoundedSizes = (): number[] => {\n\t\tif (!raidStatus?.exists || !raidStatus.devices) return []\n\t\treturn raidStatus.devices\n\t\t\t.map((raidDevice) => {\n\t\t\t\tconst storageDevice = allDevices.find((d) => d.id === raidDevice.id)\n\t\t\t\treturn storageDevice?.roundedSize ?? 0\n\t\t\t})\n\t\t\t.filter((size) => size > 0)\n\t}\n\n\tconst raidDeviceRoundedSizes = getRaidDeviceRoundedSizes()\n\tconst minRoundedDriveSize = raidDeviceRoundedSizes.length > 0 ? Math.min(...raidDeviceRoundedSizes) : 0\n\n\t// Calculate wasted space in failsafe mode (when drives have different roundedSize values)\n\t// In RAIDZ1, all drives can only contribute as much as the smallest drive\n\t// Since backend partitions drives using roundedSize, wasted space only occurs when\n\t// roundedSize values differ (e.g., mixing 2TB and 4TB drives)\n\tconst calculateWastedSpace = (): number => {\n\t\tif (!raidStatus?.exists || raidStatus.raidType !== 'failsafe' || raidDeviceRoundedSizes.length < 2) {\n\t\t\treturn 0\n\t\t}\n\n\t\tconst totalRoundedSize = raidDeviceRoundedSizes.reduce((sum, size) => sum + size, 0)\n\t\tconst usableRoundedSize = minRoundedDriveSize * raidDeviceRoundedSizes.length\n\t\treturn Math.max(0, totalRoundedSize - usableRoundedSize)\n\t}\n\n\t// Calculate chart data from RAID status (works with both mock and real data)\n\tconst wastedBytes = calculateWastedSpace()\n\tconst availableBytes = raidStatus?.usableSpace ?? 0\n\tconst failsafeOverheadBytes =\n\t\traidStatus?.raidType === 'failsafe' && raidStatus.totalSpace && raidStatus.usableSpace\n\t\t\t? raidStatus.totalSpace - raidStatus.usableSpace\n\t\t\t: 0\n\tconst totalCapacityBytes = availableBytes + failsafeOverheadBytes + wastedBytes\n\n\t// Chart data in TB (for donut chart proportions)\n\tconst chartData = {\n\t\tused: raidStatus?.usedSpace ? raidStatus.usedSpace / 1e12 : 0,\n\t\tavailable: availableBytes / 1e12,\n\t\tfailsafe: failsafeOverheadBytes / 1e12,\n\t\twasted: wastedBytes / 1e12,\n\t}\n\n\t// Total for the chart = available + failsafe + wasted\n\tconst chartTotal = chartData.available + chartData.failsafe + chartData.wasted\n\n\t// Derived: Number of drives currently in RAID\n\tconst raidDriveCount = raidStatus?.devices?.length ?? 0\n\n\t// Derived: Can user choose between Storage and FailSafe when adding?\n\t// Only when they have exactly 1 drive in storage mode\n\tconst canChooseMode = raidStatus?.exists && raidStatus.raidType === 'storage' && raidDriveCount === 1\n\n\treturn {\n\t\t// RAID pool info\n\t\traidStatus,\n\t\traidExists: raidStatus?.exists ?? false,\n\t\traidType: raidStatus?.raidType,\n\t\tpoolStatus: raidStatus?.status,\n\n\t\t// Space info (raw bytes) for display with formatStorageSize\n\t\tusedSpace: raidStatus?.usedSpace,\n\t\tavailableBytes,\n\t\tfailsafeOverheadBytes,\n\t\twastedBytes,\n\t\ttotalCapacityBytes,\n\n\t\t// Chart data (in TB, for donut chart proportions)\n\t\tchartData: {\n\t\t\t...chartData,\n\t\t\ttotal: chartTotal,\n\t\t},\n\n\t\t// Per-drive wasted calculation (for failsafe mode with mismatched drives)\n\t\t// In failsafe mode, each drive can only contribute up to the smallest rounded size\n\t\tminRoundedDriveSize,\n\n\t\t// Devices\n\t\tallDevices,\n\t\traidDevices,\n\t\traidDriveCount,\n\t\tavailableDevices,\n\t\t// Map devices to 4 slots (Umbrel Pro has 4 SSD slots)\n\t\tssdSlots: Array.from({length: 4}, (_, i) => allDevices.find((d) => d.slot === i + 1) ?? null),\n\t\t// Set of device IDs that are detected but not in RAID (ready to add)\n\t\treadyToAddIds: new Set(availableDevices.map((d) => d.id)),\n\n\t\t// Mode selection\n\t\t// True when user has exactly 1 drive in storage mode and is adding more\n\t\t// In this case they can choose to stay in storage or switch to failsafe\n\t\tcanChooseMode,\n\n\t\t// Degraded/failed device replacement\n\t\t// Failed RAID devices that need replacement (non-ONLINE status or physically missing)\n\t\tfailedRaidDevices,\n\t\t// True when array is degraded AND has failed devices AND has available replacement devices\n\t\tcanReplaceFailedDevice,\n\t\tisDegraded,\n\n\t\t// State\n\t\tisLoading,\n\t\terror,\n\n\t\t// Mutations\n\t\taddDevice: addDeviceMut.mutate,\n\t\taddDeviceAsync: addDeviceMut.mutateAsync,\n\t\tisAddingDevice: addDeviceMut.isPending,\n\t\taddDeviceError: addDeviceMut.error,\n\n\t\ttransitionToFailsafe: transitionToFailsafeMut.mutate,\n\t\ttransitionToFailsafeAsync: transitionToFailsafeMut.mutateAsync,\n\t\tisTransitioning: transitionToFailsafeMut.isPending,\n\t\ttransitionError: transitionToFailsafeMut.error,\n\n\t\treplaceDevice: replaceDeviceMut.mutate,\n\t\treplaceDeviceAsync: replaceDeviceMut.mutateAsync,\n\t\tisReplacingDevice: replaceDeviceMut.isPending,\n\t\treplaceDeviceError: replaceDeviceMut.error,\n\n\t\t// Refetch\n\t\trefetch: () => {\n\t\t\traidStatusQ.refetch()\n\t\t\tdevicesQ.refetch()\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/index.tsx",
    "content": "import {DialogPortal} from '@radix-ui/react-dialog'\nimport {useState} from 'react'\nimport {\n\tTbActivityHeartbeat,\n\tTbAlertTriangle,\n\tTbAlertTriangleFilled,\n\tTbCircleCheckFilled,\n\tTbPlus,\n\tTbRefreshDot,\n} from 'react-icons/tb'\nimport {useNavigate} from 'react-router-dom'\n\nimport {\n\tImmersiveDialog,\n\tImmersiveDialogContent,\n\tImmersiveDialogOverlay,\n\timmersiveDialogTitleClass,\n} from '@/components/ui/immersive-dialog'\nimport {Spinner} from '@/components/ui/loading'\nimport {useIsUmbrelPro} from '@/hooks/use-is-umbrel-pro'\nimport {useTemperatureUnit} from '@/hooks/use-temperature-unit'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\nimport {AddToRaidDialog} from './components/dialogs/add-to-raid-dialog'\nimport {InstallSsdDialog} from './components/dialogs/install-ssd-dialog'\nimport {ReplaceFailedDriveDialog} from './components/dialogs/replace-failed-drive-dialog'\nimport {SsdHealthDialog, useSsdHealthDialog} from './components/dialogs/ssd-health-dialog'\nimport {SwapDialog} from './components/dialogs/swap-dialog'\nimport {SsdShape} from './components/ssd-shape'\nimport {StorageDonutChart} from './components/storage-donut-chart'\nimport {StorageModeDisplay} from './components/storage-mode-display'\nimport {StorageDevice, useStorage} from './hooks/use-storage'\nimport {formatStorageSize} from './utils'\n\n// Simple divider for storage info section\nconst StorageDivider = () => <div className='h-px w-2/3 bg-linear-to-r from-transparent via-white/15 to-transparent' />\n\n// Storage stats display - shared between mobile and desktop layouts\nfunction StorageStats({\n\tisLoading,\n\ttotalCapacityBytes,\n\tavailableBytes,\n\tfailsafeOverheadBytes,\n\twastedBytes,\n}: {\n\tisLoading: boolean\n\ttotalCapacityBytes: number\n\tavailableBytes: number\n\tfailsafeOverheadBytes: number\n\twastedBytes: number\n}) {\n\treturn (\n\t\t<div className='flex w-full flex-col items-center'>\n\t\t\t{/* Total capacity */}\n\t\t\t<div className='py-2.5 text-center'>\n\t\t\t\t<div className={cn('text-[16px] font-semibold text-white', isLoading && 'animate-pulse text-white/30')}>\n\t\t\t\t\t{isLoading ? '—' : formatStorageSize(totalCapacityBytes)}\n\t\t\t\t</div>\n\t\t\t\t<div className='text-[13px] font-semibold text-white/50'>{t('storage-manager.total-capacity-added')}</div>\n\t\t\t</div>\n\n\t\t\t<StorageDivider />\n\n\t\t\t{/* Available storage */}\n\t\t\t<div className='py-2.5 text-center'>\n\t\t\t\t<div className={cn('text-[16px] font-semibold text-white', isLoading && 'animate-pulse text-white/30')}>\n\t\t\t\t\t{isLoading ? '—' : formatStorageSize(availableBytes)}\n\t\t\t\t</div>\n\t\t\t\t<div className='flex items-center justify-center gap-1.5'>\n\t\t\t\t\t<span className='size-2 rounded-full bg-brand' />\n\t\t\t\t\t<span className='text-[13px] font-semibold text-white/50'>{t('storage-manager.available-storage')}</span>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* FailSafe - hide entirely when loading */}\n\t\t\t{!isLoading && failsafeOverheadBytes > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t<StorageDivider />\n\t\t\t\t\t<div className='py-2.5 text-center'>\n\t\t\t\t\t\t<div className='text-[16px] font-semibold text-white'>{formatStorageSize(failsafeOverheadBytes)}</div>\n\t\t\t\t\t\t<div className='flex items-center justify-center gap-1.5'>\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName='size-2 rounded-full'\n\t\t\t\t\t\t\t\tstyle={{backgroundColor: 'color-mix(in srgb, hsl(var(--color-brand)), white 60%)'}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className='text-[13px] font-semibold text-white/50'>{t('storage-manager.for-failsafe')}</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t{/* Wasted - hide entirely when loading */}\n\t\t\t{!isLoading && wastedBytes > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t<StorageDivider />\n\t\t\t\t\t<div className='py-2.5 text-center'>\n\t\t\t\t\t\t<div className='text-[16px] font-semibold text-white'>{formatStorageSize(wastedBytes)}</div>\n\t\t\t\t\t\t<div className='flex items-center justify-center gap-1.5'>\n\t\t\t\t\t\t\t<TbAlertTriangleFilled className='size-3.5 text-[#F5A623]' />\n\t\t\t\t\t\t\t<span className='text-[13px] font-semibold text-white/50'>{t('storage-manager.wasted')}</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\n// Umbrel Pro has 4 SSD slots\nconst SLOT_INDICES = [0, 1, 2, 3] as const\n\nexport default function StorageManagerDialog() {\n\tconst navigate = useNavigate()\n\tconst [temperatureUnit] = useTemperatureUnit()\n\n\tconst {isUmbrelPro} = useIsUmbrelPro()\n\n\t// Get actual storage data\n\t// We poll every 15 seconds to keep temperature and health status up to date\n\tconst {\n\t\tallDevices,\n\t\traidDevices,\n\t\tavailableDevices,\n\t\tssdSlots,\n\t\treadyToAddIds,\n\t\tchartData,\n\t\tusedSpace,\n\t\tavailableBytes,\n\t\tfailsafeOverheadBytes,\n\t\twastedBytes,\n\t\ttotalCapacityBytes,\n\t\traidType,\n\t\traidDriveCount,\n\t\tminRoundedDriveSize,\n\t\tcanChooseMode,\n\t\traidStatus,\n\t\tisLoading: isStorageLoading,\n\t\t// Degraded replacement\n\t\tfailedRaidDevices,\n\t\tcanReplaceFailedDevice,\n\t\t// Mutations\n\t\taddDeviceAsync,\n\t\ttransitionToFailsafeAsync,\n\t\treplaceDeviceAsync,\n\t} = useStorage({pollInterval: 15_000})\n\n\t// Check if any RAID device doesn't have a matching physical device\n\t// This means there's a drive the RAID knows about but we can't display accurately in the UI\n\tconst hasMissingDrive = raidStatus?.devices?.some((rd: {id: string}) => !allDevices.find((d) => d.id === rd.id))\n\tconst showMissingDriveWarning = hasMissingDrive\n\n\t// Health dialog state\n\tconst healthDialog = useSsdHealthDialog()\n\t// Look up device from live data so polling keeps health dialog fresh (device health + RAID status)\n\tconst healthDialogDevice = allDevices?.find((d) => d.id === healthDialog.selectedDevice?.deviceId)\n\n\t// Dialog states\n\tconst [isInstallSsdDialogOpen, setIsInstallSsdDialogOpen] = useState(false)\n\tconst [isAddDialogOpen, setIsAddDialogOpen] = useState(false)\n\tconst [deviceToAdd, setDeviceToAdd] = useState<StorageDevice | null>(null)\n\tconst [isSwapDialogOpen, setIsSwapDialogOpen] = useState(false)\n\tconst [swapSlot, setSwapSlot] = useState<number | null>(null)\n\tconst [isReplaceFailedDialogOpen, setIsReplaceFailedDialogOpen] = useState(false)\n\tconst [deviceForReplacement, setDeviceForReplacement] = useState<StorageDevice | null>(null)\n\n\t// Pre-compute slot states to avoid logic duplication between mobile and desktop\n\tconst slotStates = SLOT_INDICES.map((i) => {\n\t\tconst device = ssdSlots[i]\n\t\tconst isReadyToAdd = device && readyToAddIds.has(device.id)\n\t\tconst isInRaid = device && !isReadyToAdd\n\t\tconst raidDevice = device ? raidDevices.find((rd) => rd.id === device.id) : undefined\n\t\tconst isFailedDrive = raidDevice && raidDevice.raidStatus !== 'ONLINE'\n\t\tconst hasWarning = device && (isFailedDrive || device.smartStatus === 'unhealthy')\n\t\treturn {device, isReadyToAdd, isInRaid, raidDevice, isFailedDrive, hasWarning}\n\t})\n\n\treturn (\n\t\t<ImmersiveDialog\n\t\t\topen={true}\n\t\t\tonOpenChange={(isOpen) => {\n\t\t\t\tif (!isOpen) {\n\t\t\t\t\tnavigate('/settings', {preventScrollReset: true})\n\t\t\t\t}\n\t\t\t}}\n\t\t>\n\t\t\t<DialogPortal>\n\t\t\t\t<ImmersiveDialogOverlay />\n\t\t\t\t<ImmersiveDialogContent\n\t\t\t\t\tsize='md'\n\t\t\t\t\tshowScroll\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackgroundColor: 'rgba(8, 8, 8, 0.5)',\n\t\t\t\t\t\tbackdropFilter: 'blur(80px)',\n\t\t\t\t\t\tboxShadow: '0px 32px 32px 0px #00000052, inset 1px 1px 1px 0px #FFFFFF14',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<div className='flex h-full flex-col gap-6'>\n\t\t\t\t\t\t<h1 className={immersiveDialogTitleClass}>{t('storage-manager')}</h1>\n\n\t\t\t\t\t\t{/* Mode display */}\n\t\t\t\t\t\t<div className='flex flex-col gap-2.5'>\n\t\t\t\t\t\t\t<span className='text-13 font-semibold text-white/50'>{t('storage-manager.mode')}</span>\n\t\t\t\t\t\t\t<StorageModeDisplay value={raidType ?? 'storage'} canEnableFailsafe={canChooseMode ?? false} />\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* Warning banner for missing/unavailable drive */}\n\t\t\t\t\t\t{showMissingDriveWarning && (\n\t\t\t\t\t\t\t<div className='flex items-center gap-2 rounded-8 bg-[#3C1C1C] p-2.5 text-13 leading-tight -tracking-2 text-[#FF3434]'>\n\t\t\t\t\t\t\t\t<TbAlertTriangle className='h-5 w-5 shrink-0' />\n\t\t\t\t\t\t\t\t<span className='opacity-90'>{t('storage-manager.missing-ssd-warning')}</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{/* Mobile: SSD List Card */}\n\t\t\t\t\t\t<div className='flex flex-col gap-6 md:hidden'>\n\t\t\t\t\t\t\t<div className='flex flex-col rounded-xl bg-white/5 p-3'>\n\t\t\t\t\t\t\t\t{slotStates.map(({device, isReadyToAdd, isInRaid, isFailedDrive, hasWarning}, i) => (\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tkey={`mobile-slot-${i}`}\n\t\t\t\t\t\t\t\t\t\tclassName='flex items-center justify-between gap-2 rounded-lg px-2 py-2'\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{/* Left: Status + Slot info */}\n\t\t\t\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t\t\t\t{/* Only show checkmark when device is in RAID, warning if issues, empty otherwise */}\n\t\t\t\t\t\t\t\t\t\t\t{isInRaid ? (\n\t\t\t\t\t\t\t\t\t\t\t\thasWarning ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<TbAlertTriangle className='size-5 shrink-0 text-[#F5A623]' />\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<TbCircleCheckFilled className='size-5 shrink-0 text-brand' />\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='size-5 shrink-0' />\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t{/* \"SSD\" labels are not translated - they match the physical device markings */}\n\t\t\t\t\t\t\t\t\t\t\t<span className='text-[14px] font-medium text-white/60'>\n\t\t\t\t\t\t\t\t\t\t\t\tSSD {i + 1}\n\t\t\t\t\t\t\t\t\t\t\t\t{device && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{' · '}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='text-white'>{formatStorageSize(device.size)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t{!device && <span className='text-white/40'> · {t('storage-manager.empty')}</span>}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t\t\t{/* Right: Health pill + Action button */}\n\t\t\t\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t\t\t\t{/* Health pill - only when device present */}\n\t\t\t\t\t\t\t\t\t\t\t{device && (\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => healthDialog.openDialog(device, i + 1)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='relative flex items-center justify-center rounded-full border border-white/[0.16] bg-white/[0.08] px-3 py-0.5'\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<TbActivityHeartbeat className='size-4 text-white/60' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t{hasWarning && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='absolute -top-0.5 right-1.5'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='absolute inset-0 size-2.5 rounded-full bg-[#F5A623]' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='absolute inset-0 size-2.5 animate-ping rounded-full bg-[#F5A623] opacity-75' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t\t\t\t\t{/* Action button - fixed width container for alignment */}\n\t\t\t\t\t\t\t\t\t\t\t<div className='w-[76px]'>\n\t\t\t\t\t\t\t\t\t\t\t\t{isInRaid ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetSwapSlot(i + 1)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetIsSwapDialogOpen(true)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t'flex w-full items-center justify-center gap-1 rounded-full py-1 text-[12px] font-medium transition-colors',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tisFailedDrive\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'bg-[#FF3434] text-white hover:bg-[#FF3434]/90'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'border border-white/[0.08] bg-white/[0.06] text-white/80 hover:bg-white/10',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<TbRefreshDot className='size-3.5' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{isFailedDrive ? t('storage-manager.replace') : t('storage-manager.swap')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t) : isReadyToAdd ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\tcanReplaceFailedDevice ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t// Degraded array - offer to replace failed device\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetDeviceForReplacement(device)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetIsReplaceFailedDialogOpen(true)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='flex w-full items-center justify-center gap-1 rounded-full bg-[#FF3434] py-1 text-[12px] font-medium text-white transition-colors hover:bg-[#FF3434]/90'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<TbRefreshDot className='size-3.5' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.replace')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t// Normal add flow\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetDeviceToAdd(device)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetIsAddDialogOpen(true)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='flex w-full animate-pulse items-center justify-center gap-1 rounded-full border border-white/20 bg-white/15 py-1 text-[12px] font-medium text-white transition-colors hover:bg-white/20'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<TbPlus className='size-3' strokeWidth={3} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.add')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => setIsInstallSsdDialogOpen(true)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='flex w-full items-center justify-center gap-1 rounded-full bg-brand py-1 text-[12px] font-medium text-white transition-colors hover:bg-brand/90'\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<TbPlus className='size-3' strokeWidth={3} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.add')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t{/* Storage info for mobile */}\n\t\t\t\t\t\t\t<div className='flex flex-col items-center gap-4'>\n\t\t\t\t\t\t\t\t<StorageDonutChart\n\t\t\t\t\t\t\t\t\tused={chartData.used}\n\t\t\t\t\t\t\t\t\tavailable={chartData.available}\n\t\t\t\t\t\t\t\t\tfailsafe={chartData.failsafe}\n\t\t\t\t\t\t\t\t\twasted={chartData.wasted}\n\t\t\t\t\t\t\t\t\tusedBytes={usedSpace}\n\t\t\t\t\t\t\t\t\tisLoading={isStorageLoading}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<StorageStats\n\t\t\t\t\t\t\t\t\tisLoading={isStorageLoading}\n\t\t\t\t\t\t\t\t\ttotalCapacityBytes={totalCapacityBytes}\n\t\t\t\t\t\t\t\t\tavailableBytes={availableBytes}\n\t\t\t\t\t\t\t\t\tfailsafeOverheadBytes={failsafeOverheadBytes}\n\t\t\t\t\t\t\t\t\twastedBytes={wastedBytes}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* Desktop: Device visualization and info */}\n\t\t\t\t\t\t<div className='hidden flex-1 items-start gap-6 px-6 md:flex'>\n\t\t\t\t\t\t\t{/* Left: Device visualization */}\n\t\t\t\t\t\t\t<div className='flex flex-col items-center gap-3'>\n\t\t\t\t\t\t\t\t{/* Gradient border using pseudo-element technique */}\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName='relative h-[480px] w-[480px] rounded-[69px] border-[3px] border-transparent bg-[radial-gradient(78%_100%_at_50%_0%,_rgba(255,255,255,0.12)_0%,_rgba(255,255,255,0.04)_100%)] bg-clip-padding'\n\t\t\t\t\t\t\t\t\tstyle={{containerType: 'inline-size'}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{/* Gradient border overlay */}\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName='pointer-events-none absolute -inset-[3px] rounded-[69px] p-[3px]'\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackground:\n\t\t\t\t\t\t\t\t\t\t\t\t'linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.05) 100%)',\n\t\t\t\t\t\t\t\t\t\t\tWebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',\n\t\t\t\t\t\t\t\t\t\t\tWebkitMaskComposite: 'xor',\n\t\t\t\t\t\t\t\t\t\t\tmaskComposite: 'exclude',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t\t\t\t{/* Loading overlay */}\n\t\t\t\t\t\t\t\t\t{isStorageLoading && (\n\t\t\t\t\t\t\t\t\t\t<div className='absolute inset-0 z-10 flex items-center justify-center rounded-[66px] bg-black/30'>\n\t\t\t\t\t\t\t\t\t\t\t<Spinner size='8' />\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t\t\t{/* Slot labels - \"SSD\" is not translated as it matches the physical device markings */}\n\t\t\t\t\t\t\t\t\t{SLOT_INDICES.map((i) => {\n\t\t\t\t\t\t\t\t\t\tconst hasDevice = !!ssdSlots[i]\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tkey={`label-${i}`}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn('absolute text-center font-medium', hasDevice ? 'text-white' : 'text-white/20')}\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tleft: `${12 + i * 20}%`,\n\t\t\t\t\t\t\t\t\t\t\t\t\ttop: '8%',\n\t\t\t\t\t\t\t\t\t\t\t\t\twidth: '15%',\n\t\t\t\t\t\t\t\t\t\t\t\t\tfontSize: 'clamp(8px, 2.5cqi, 12px)',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tSSD {i + 1}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t})}\n\n\t\t\t\t\t\t\t\t\t{/* SSD slots - either SSD shape if present or a dot grid if empty */}\n\t\t\t\t\t\t\t\t\t{SLOT_INDICES.map((i) => {\n\t\t\t\t\t\t\t\t\t\tconst device = ssdSlots[i]\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tkey={`slot-${i}`}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName='absolute flex items-center justify-center'\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\tleft: `${12 + i * 20}%`,\n\t\t\t\t\t\t\t\t\t\t\t\t\ttop: '51%',\n\t\t\t\t\t\t\t\t\t\t\t\t\ttransform: 'translateY(-50%)',\n\t\t\t\t\t\t\t\t\t\t\t\t\twidth: '15%',\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t{/* Dot grid - only show when no SSD present */}\n\t\t\t\t\t\t\t\t\t\t\t\t{!device && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<div className='grid grid-cols-5' style={{gap: 'clamp(4px, 1.5cqi, 8px)'}}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{Array.from({length: 100}).map((_, dotIndex) => (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={dotIndex}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='rounded-full bg-black/[0.56]'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\twidth: 'clamp(4px, 2cqi, 8px)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\theight: 'clamp(4px, 2cqi, 8px)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t{/* SSD shape - only show when SSD present */}\n\t\t\t\t\t\t\t\t\t\t\t\t{device && (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<SsdShape\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdevice={device}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tslotNumber={i + 1}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonHealthClick={() => healthDialog.openDialog(device, i + 1)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tminRoundedDriveSize={minRoundedDriveSize}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\traidType={raidType}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttemperatureUnit={temperatureUnit}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tisReadyToAdd={readyToAddIds.has(device.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\traidDevice={raidDevices.find((rd) => rd.id === device.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t})}\n\n\t\t\t\t\t\t\t\t\t{/* Action buttons below each slot */}\n\t\t\t\t\t\t\t\t\t{slotStates.map(({device, isReadyToAdd, isInRaid, isFailedDrive}, i) => (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tkey={`button-${i}`}\n\t\t\t\t\t\t\t\t\t\t\tclassName='absolute flex justify-center'\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tleft: `${12 + i * 20}%`,\n\t\t\t\t\t\t\t\t\t\t\t\tbottom: '4%',\n\t\t\t\t\t\t\t\t\t\t\t\twidth: '15%',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{isInRaid ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetSwapSlot(i + 1)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetIsSwapDialogOpen(true)\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tisFailedDrive\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'flex items-center gap-0.5 rounded-full bg-[#FF3434] px-2 py-1 text-[11px] font-medium text-white transition-colors hover:bg-[#FF3434]/90'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'flex items-center gap-0.5 rounded-full border border-white/[0.08] bg-white/[0.06] px-2 py-1 text-[11px] font-medium text-white/80 transition-colors hover:bg-white/10'\n\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<TbRefreshDot className='size-3.5' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t{isFailedDrive ? t('storage-manager.replace') : t('storage-manager.swap')}\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t) : isReadyToAdd ? (\n\t\t\t\t\t\t\t\t\t\t\t\tcanReplaceFailedDevice ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t// Degraded array - offer to replace failed device\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetDeviceForReplacement(device)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetIsReplaceFailedDialogOpen(true)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='flex items-center gap-0.5 rounded-full bg-[#FF3434] px-2 py-1 text-[11px] font-medium text-white transition-colors hover:bg-[#FF3434]/90'\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<TbRefreshDot className='size-3.5' />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.replace')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t// Normal add flow\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetDeviceToAdd(device)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsetIsAddDialogOpen(true)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='flex animate-pulse items-center gap-0.5 rounded-full border border-white/20 bg-white/15 px-2 py-1 text-[11px] font-medium text-white transition-colors hover:bg-white/20'\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='flex size-3 items-center justify-center rounded-full bg-white/30'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<TbPlus className='size-2 text-white' strokeWidth={3} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.add')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => setIsInstallSsdDialogOpen(true)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName='flex items-center gap-0.5 rounded-full bg-brand px-2 py-1 text-[11px] font-medium text-white transition-colors hover:bg-brand/90'\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span className='flex size-3 items-center justify-center rounded-full bg-white'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<TbPlus className='size-2 text-brand' strokeWidth={3} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t('storage-manager.add')}\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<span className='text-13 font-semibold text-white/50'>{t('storage-manager.umbrel-pro')}</span>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t{/* Right: Storage info */}\n\t\t\t\t\t\t\t<div className='relative flex flex-1 flex-col items-center justify-start gap-4 pt-8'>\n\t\t\t\t\t\t\t\t<StorageDonutChart\n\t\t\t\t\t\t\t\t\tused={chartData.used}\n\t\t\t\t\t\t\t\t\tavailable={chartData.available}\n\t\t\t\t\t\t\t\t\tfailsafe={chartData.failsafe}\n\t\t\t\t\t\t\t\t\twasted={chartData.wasted}\n\t\t\t\t\t\t\t\t\tusedBytes={usedSpace}\n\t\t\t\t\t\t\t\t\tisLoading={isStorageLoading}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<StorageStats\n\t\t\t\t\t\t\t\t\tisLoading={isStorageLoading}\n\t\t\t\t\t\t\t\t\ttotalCapacityBytes={totalCapacityBytes}\n\t\t\t\t\t\t\t\t\tavailableBytes={availableBytes}\n\t\t\t\t\t\t\t\t\tfailsafeOverheadBytes={failsafeOverheadBytes}\n\t\t\t\t\t\t\t\t\twastedBytes={wastedBytes}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</ImmersiveDialogContent>\n\t\t\t</DialogPortal>\n\n\t\t\t{/* SSD Health Dialog - device and RAID status looked up from live data so polling keeps it fresh */}\n\t\t\t{healthDialogDevice && (\n\t\t\t\t<SsdHealthDialog\n\t\t\t\t\tdevice={healthDialogDevice}\n\t\t\t\t\tslotNumber={healthDialog.selectedDevice!.slotNumber}\n\t\t\t\t\topen={healthDialog.open}\n\t\t\t\t\tonOpenChange={healthDialog.onOpenChange}\n\t\t\t\t\traidDevice={raidDevices.find((rd) => rd.id === healthDialogDevice.id)}\n\t\t\t\t/>\n\t\t\t)}\n\n\t\t\t{/* Install SSD Dialog (for empty slots) */}\n\t\t\t<InstallSsdDialog\n\t\t\t\topen={isInstallSsdDialogOpen}\n\t\t\t\tonOpenChange={setIsInstallSsdDialogOpen}\n\t\t\t\tisUmbrelPro={isUmbrelPro}\n\t\t\t/>\n\n\t\t\t{/* Add to RAID Dialog (for detected but unadded device) */}\n\t\t\t{/* TODO: Currently limited to adding 1 SSD at a time due to ZFS raidz1 limitations.\n\t\t\t    Update UI to support adding multiple SSDs when backend supports it. */}\n\t\t\t<AddToRaidDialog\n\t\t\t\topen={isAddDialogOpen}\n\t\t\t\tonOpenChange={(open) => {\n\t\t\t\t\tsetIsAddDialogOpen(open)\n\t\t\t\t\tif (!open) setDeviceToAdd(null)\n\t\t\t\t}}\n\t\t\t\tdevice={deviceToAdd}\n\t\t\t\tcanChooseMode={canChooseMode ?? false}\n\t\t\t\traidType={raidType}\n\t\t\t\traidDevices={raidDevices}\n\t\t\t\taddDeviceAsync={addDeviceAsync}\n\t\t\t\ttransitionToFailsafeAsync={transitionToFailsafeAsync}\n\t\t\t/>\n\n\t\t\t{/* Swap SSD Dialog */}\n\t\t\t<SwapDialog\n\t\t\t\topen={isSwapDialogOpen}\n\t\t\t\tonOpenChange={setIsSwapDialogOpen}\n\t\t\t\traidType={raidType}\n\t\t\t\tslot={swapSlot}\n\t\t\t\tisUmbrelPro={isUmbrelPro}\n\t\t\t\traidDriveCount={raidDriveCount}\n\t\t\t\tavailableDevices={availableDevices}\n\t\t\t\tallDevices={allDevices}\n\t\t\t\treplaceDeviceAsync={replaceDeviceAsync}\n\t\t\t/>\n\n\t\t\t{/* Replace Failed Drive Dialog (for degraded arrays) */}\n\t\t\t<ReplaceFailedDriveDialog\n\t\t\t\topen={isReplaceFailedDialogOpen}\n\t\t\t\tonOpenChange={(open) => {\n\t\t\t\t\tsetIsReplaceFailedDialogOpen(open)\n\t\t\t\t\tif (!open) setDeviceForReplacement(null)\n\t\t\t\t}}\n\t\t\t\tnewDevice={deviceForReplacement}\n\t\t\t\tfailedDevice={failedRaidDevices[0] ?? null}\n\t\t\t\tminRoundedDriveSize={minRoundedDriveSize}\n\t\t\t\treplaceDeviceAsync={replaceDeviceAsync}\n\t\t\t/>\n\t\t</ImmersiveDialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/providers/pending-operation-context.tsx",
    "content": "import {createContext, ReactNode, useCallback, useContext, useState} from 'react'\n\nimport {RaidProgress} from '../hooks/use-raid-progress'\n\ntype PendingRaidOperationContextType = {\n\tpendingOperation: RaidProgress | null\n\tsetPendingOperation: (op: RaidProgress | null) => void\n\tclearPendingOperation: () => void\n}\n\nconst PendingRaidOperationContext = createContext<PendingRaidOperationContextType | null>(null)\n\nexport function PendingRaidOperationProvider({children}: {children: ReactNode}) {\n\tconst [pendingOperation, setPendingOperation] = useState<RaidProgress | null>(null)\n\n\tconst clearPendingOperation = useCallback(() => setPendingOperation(null), [])\n\n\treturn (\n\t\t<PendingRaidOperationContext value={{pendingOperation, setPendingOperation, clearPendingOperation}}>\n\t\t\t{children}\n\t\t</PendingRaidOperationContext>\n\t)\n}\n\nexport function usePendingRaidOperation() {\n\tconst context = useContext(PendingRaidOperationContext)\n\tif (!context) {\n\t\tthrow new Error('usePendingRaidOperation must be used within PendingRaidOperationProvider')\n\t}\n\treturn context\n}\n"
  },
  {
    "path": "packages/ui/src/features/storage/utils.ts",
    "content": "import prettyBytes from 'pretty-bytes'\n\n// Format bytes without space, rounding to integer only for 3+ digit values (>=100) to avoid overflow\n// e.g., \"4.5TB\", \"45.2GB\", \"256GB\" - only 256.1GB gets rounded because 256 >= 100\nexport const formatStorageSize = (bytes: number) => {\n\t// First format with 1 decimal to determine the numeric value\n\tconst formatted = prettyBytes(bytes, {maximumFractionDigits: 1})\n\tconst numericValue = parseFloat(formatted)\n\n\t// If 3+ digits (>=100), round to integer to keep string short\n\tconst fractionDigits = numericValue >= 100 ? 0 : 1\n\n\treturn prettyBytes(bytes, {maximumFractionDigits: fractionDigits}).replace(' ', '')\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-2fa.ts",
    "content": "import {useCallback, useState} from 'react'\n\nimport {trpcReact} from '@/trpc/trpc'\n\nexport function use2fa(onEnableChange?: (enabled: boolean) => void) {\n\tconst ctx = trpcReact.useUtils()\n\n\tconst enableMut = trpcReact.user.enable2fa.useMutation({\n\t\tonSuccess: () => {\n\t\t\tctx.user.is2faEnabled.invalidate()\n\t\t\tsetTimeout(() => {\n\t\t\t\tonEnableChange?.(true)\n\t\t\t}, 500)\n\t\t},\n\t})\n\n\tconst disableMut = trpcReact.user.disable2fa.useMutation({\n\t\tonSuccess: () => {\n\t\t\tctx.user.is2faEnabled.invalidate()\n\t\t\tsetTimeout(() => {\n\t\t\t\tonEnableChange?.(false)\n\t\t\t}, 500)\n\t\t},\n\t})\n\n\tconst is2faEndabledQ = trpcReact.user.is2faEnabled.useQuery()\n\n\t// TOTP URI\n\tconst [totpUri, setTotpUri] = useState('')\n\tconst generateTotpUri = useCallback(() => {\n\t\tctx.user.generateTotpUri.fetch().then((res) => setTotpUri(res))\n\t}, [ctx])\n\n\tconst enable = useCallback(\n\t\tasync (totpToken: string) => {\n\t\t\treturn enableMut.mutateAsync({totpToken, totpUri})\n\t\t},\n\t\t[enableMut, totpUri],\n\t)\n\n\tconst disable = useCallback(\n\t\tasync (totpToken: string) => {\n\t\t\treturn disableMut.mutateAsync({totpToken})\n\t\t},\n\t\t[disableMut],\n\t)\n\n\treturn {\n\t\tisEnabled: is2faEndabledQ.data,\n\t\tenable,\n\t\tdisable,\n\t\ttotpUri,\n\t\tgenerateTotpUri,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-app-install.ts",
    "content": "import {useMutation} from '@tanstack/react-query'\nimport {useEffect} from 'react'\nimport {useInterval, usePrevious} from 'react-use'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {toast} from '@/components/ui/toast'\nimport {AppState, AppStateOrLoading, trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\n// TODO: consider adding `stopped` and `unknown`\n/** States where we want to frequently poll (on the order of seconds) */\nexport const pollStates = [\n\t'installing',\n\t'uninstalling',\n\t'updating',\n\t'starting',\n\t'restarting',\n\t'stopping',\n] as const satisfies readonly AppState[]\n\nexport function useUninstallAllApps() {\n\tconst apps = trpcReact.apps.list.useQuery().data\n\tconst utils = trpcReact.useUtils()\n\tconst uninstallMut = trpcReact.apps.uninstall.useMutation()\n\n\tconst mut = useMutation({\n\t\tmutationFn: async () => {\n\t\t\tfor (const app of apps ?? []) {\n\t\t\t\tawait uninstallMut.mutateAsync({appId: app.id})\n\t\t\t}\n\t\t},\n\n\t\tonSuccess: () => {\n\t\t\ttoast(t('apps.uninstalled-all.success'))\n\t\t\tutils.invalidate()\n\t\t},\n\t})\n\n\treturn () => mut.mutate()\n}\n\n// TODO: rename to something that covers more than install\nexport function useAppInstall(id: string) {\n\tconst utils = trpcReact.useUtils()\n\tconst appStateQ = trpcReact.apps.state.useQuery({appId: id})\n\n\tconst refreshAppStates = () => {\n\t\t// Invalidate this app's state\n\t\tutils.apps.state.invalidate({appId: id})\n\t\t// Invalidate list of apps on desktop\n\t\tutils.apps.list.invalidate()\n\t\t// Invalidate latest app opens\n\t\tutils.user.get.invalidate()\n\t}\n\n\tconst makeOptimisticOnMutate = (optimisticState: (typeof pollStates)[number]) => () => {\n\t\t// Optimistic because actions do not return until complete\n\t\t// see: https://create.t3.gg/en/usage/trpc#optimistic-updates\n\t\tutils.apps.state.cancel()\n\t\tutils.apps.state.setData({appId: id}, {state: optimisticState, progress: 0})\n\n\t\t// Make sure apps list reflects the change in time. This is necessary\n\t\t// because a request to, say, install an app does not return until the\n\t\t// action is complete. TODO: Refactor the backend to set the state, return\n\t\t// early and run the actual action asynchronously.\n\t\tsetTimeout(() => utils.apps.list.invalidate(), 2000)\n\t}\n\n\tconst startMut = trpcReact.apps.start.useMutation({\n\t\tonMutate: makeOptimisticOnMutate('starting'),\n\t\tonSettled: refreshAppStates,\n\t})\n\tconst stopMut = trpcReact.apps.stop.useMutation({\n\t\tonMutate: makeOptimisticOnMutate('stopping'),\n\t\tonSettled: refreshAppStates,\n\t})\n\tconst installMut = trpcReact.apps.install.useMutation({\n\t\tonMutate: makeOptimisticOnMutate('installing'),\n\t\tonSettled: refreshAppStates,\n\t})\n\tconst uninstallMut = trpcReact.apps.uninstall.useMutation({\n\t\tonMutate: makeOptimisticOnMutate('uninstalling'),\n\t\tonSettled: refreshAppStates,\n\t})\n\tconst restartMut = trpcReact.apps.restart.useMutation({\n\t\tonMutate: makeOptimisticOnMutate('restarting'),\n\t\tonSettled: refreshAppStates,\n\t})\n\n\tconst appState = appStateQ.data?.state\n\tconst progress = appStateQ.data?.progress\n\n\t// Poll for install status if we're installing or uninstalling\n\tconst shouldPollForStatus = appState && arrayIncludes(pollStates, appState)\n\tuseInterval(appStateQ.refetch, shouldPollForStatus ? 2000 : null)\n\n\t// Also refresh app states when polling ends in case this tab isn't the one\n\t// owning the mutation and hence isn't notified when it settles\n\tconst prevShouldPollForStatus = usePrevious(shouldPollForStatus)\n\tuseEffect(() => {\n\t\tif (!shouldPollForStatus && prevShouldPollForStatus === true) {\n\t\t\trefreshAppStates()\n\t\t}\n\t}, [shouldPollForStatus, prevShouldPollForStatus])\n\n\tconst start = async () => startMut.mutate({appId: id})\n\tconst stop = async () => stopMut.mutate({appId: id})\n\tconst install = async (alternatives?: Record<string, string>) => {\n\t\treturn installMut.mutate({appId: id, alternatives})\n\t}\n\tconst getAppsToUninstallFirst = async () => {\n\t\tconst appsToUninstallFirst = await utils.apps.dependents.fetch(id)\n\t\t// We expect to have an array, even if it's empty\n\t\tif (!appsToUninstallFirst) throw new Error(t('apps.uninstall.failed-to-get-required-apps'))\n\t\treturn appsToUninstallFirst\n\t}\n\tconst uninstall = async () => {\n\t\tconst uninstallTheseFirst = await getAppsToUninstallFirst()\n\t\tif (uninstallTheseFirst.length > 0) {\n\t\t\treturn {uninstallTheseFirst}\n\t\t}\n\t\tuninstallMut.mutate({appId: id})\n\t}\n\tconst restart = async () => restartMut.mutate({appId: id})\n\n\t// Ready means the app can be installed\n\tconst state: AppStateOrLoading = appStateQ.isLoading ? 'loading' : (appState ?? 'not-installed')\n\n\treturn {\n\t\tstart,\n\t\tstop,\n\t\trestart,\n\t\tinstall,\n\t\tgetAppsToUninstallFirst,\n\t\tuninstall,\n\t\tprogress,\n\t\tstate,\n\t} as const\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-apps-with-updates.ts",
    "content": "import {useApps} from '@/providers/apps'\nimport {useAllAvailableApps} from '@/providers/available-apps'\n\nexport function useAppsWithUpdates() {\n\tconst apps = useApps()\n\tconst availableApps = useAllAvailableApps()\n\n\t// NOTE: a parent should have the apps loaded before we get here, but don't wanna assume\n\tif (apps.isLoading || availableApps.isLoading) {\n\t\treturn {\n\t\t\tappsWithUpdates: [],\n\t\t\tisLoading: true,\n\t\t} as const\n\t}\n\n\tconst appsWithUpdates = (apps.userApps ?? [])\n\t\t.filter((app) => {\n\t\t\tconst availableApp = availableApps.appsKeyed[app.id]\n\t\t\treturn availableApp && availableApp.version !== app.version\n\t\t})\n\t\t.map((app) => availableApps.appsKeyed[app.id])\n\n\treturn {appsWithUpdates, isLoading: apps.isLoading || availableApps.isLoading} as const\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-auto-height-animation.tsx",
    "content": "import {useAnimation, type LegacyAnimationControls} from 'motion/react'\nimport {useLayoutEffect, useRef} from 'react'\n\nexport function useAutoHeightAnimation(deps: any[]): [LegacyAnimationControls, React.RefObject<HTMLDivElement | null>] {\n\tconst controls = useAnimation()\n\tconst ref = useRef<HTMLDivElement>(null)\n\tconst height = useRef<number | null>(null)\n\n\tuseLayoutEffect(() => {\n\t\tif (!ref.current) return\n\t\tref.current.style.height = 'auto'\n\t\tconst newHeight = ref.current.offsetHeight\n\n\t\t//console.log( newHeight )\n\t\tif (height.current !== null) {\n\t\t\tcontrols.set({height: height.current})\n\t\t\tcontrols.start({height: newHeight, opacity: 1})\n\t\t}\n\n\t\theight.current = newHeight\n\t}, [ref, controls, ...deps])\n\n\treturn [controls, ref]\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-color-thief.ts",
    "content": "import ColorThief, {RGBColor} from 'colorthief'\nimport {useEffect, useState, type RefObject} from 'react'\nimport {useIntersection} from 'react-use'\n\nconst colorThief = new ColorThief()\nconst colorCount = 3\n\nexport function useColorThief(ref: React.RefObject<HTMLImageElement | null>) {\n\tconst [colors, setColors] = useState<string[] | undefined>()\n\n\tconst intersection = useIntersection(ref as RefObject<HTMLImageElement>, {\n\t\troot: null,\n\t\trootMargin: '0px',\n\t\tthreshold: 0,\n\t})\n\n\tuseEffect(() => {\n\t\tif (!ref.current) return\n\t\tif (!intersection) return\n\t\tif (intersection.intersectionRatio === 0) return\n\n\t\tconst img = ref.current\n\n\t\tconst handleLoad = () => {\n\t\t\ttry {\n\t\t\t\tconst rgbArr = colorThief.getPalette(img, colorCount)\n\t\t\t\tsetColors(processColors(rgbArr))\n\t\t\t} catch {\n\t\t\t\tsetColors(undefined) // Reset colors on error\n\t\t\t}\n\t\t}\n\n\t\tconst handleError = () => {\n\t\t\tsetColors(undefined) // Reset colors on image load error\n\t\t}\n\n\t\tif (img.complete) {\n\t\t\thandleLoad()\n\t\t} else {\n\t\t\timg.addEventListener('load', handleLoad)\n\t\t\timg.addEventListener('error', handleError)\n\t\t}\n\n\t\t// Cleanup function\n\t\treturn () => {\n\t\t\timg.removeEventListener('load', handleLoad)\n\t\t\timg.removeEventListener('error', handleError)\n\t\t}\n\t}, [intersection, ref])\n\n\treturn colors\n}\n\nfunction processColors(colors: RGBColor[] | null) {\n\t// TODO: consider pulling out hues and always set saturation to 100% and lightness to 50%\n\tif (!colors) return undefined\n\treturn colors\n\t\t.filter((c) => !isNeutralBright(c) && !isNeutralDark(c))\n\t\t.map((c) => {\n\t\t\tconst [h, s, l] = rgbToHsl(c[0], c[1], c[2])\n\t\t\tconst hslCss = `hsla(${h * 360}, ${s * 80 + 20}%, ${l * 10 + 30}%, 0.8)`\n\t\t\treturn hslCss\n\t\t})\n}\n\nfunction isNeutralBright(rgb: number[]) {\n\tif (rgb[0] > 200 && rgb[1] > 200 && rgb[2] > 200) {\n\t\treturn true\n\t\t// return `rgba(${rgb.map((c) => c / 3).join(',')}, 0.5)`\n\t}\n\treturn false\n}\n\nfunction isNeutralDark(rgb: number[]) {\n\tif (rgb[0] < 55 && rgb[1] < 55 && rgb[2] < 55) {\n\t\treturn true\n\t}\n\treturn false\n}\n\n/**\n * Converts an RGB color value to HSL. Conversion formula\n * adapted from http://en.wikipedia.org/wiki/HSL_color_space.\n * Assumes r, g, and b are contained in the set [0, 255] and\n * returns h, s, and l in the set [0, 1].\n *\n * @param   {number}  r       The red color value\n * @param   {number}  g       The green color value\n * @param   {number}  b       The blue color value\n * @return  {Array}           The HSL representation\n */\nfunction rgbToHsl(r: number, g: number, b: number) {\n\tconst {min, max} = Math\n\n\tr /= 255\n\tg /= 255\n\tb /= 255\n\tconst vmax = max(r, g, b),\n\t\tvmin = min(r, g, b)\n\tlet h = 0\n\tconst l = (vmax + vmin) / 2\n\n\tif (vmax === vmin) {\n\t\treturn [0, 0, l] // achromatic\n\t}\n\n\tconst d = vmax - vmin\n\tconst s = l > 0.5 ? d / (2 - vmax - vmin) : d / (vmax + vmin)\n\tif (vmax === r) h = (g - b) / d + (g < b ? 6 : 0)\n\tif (vmax === g) h = (b - r) / d + 2\n\tif (vmax === b) h = (r - g) / d + 4\n\th /= 6\n\n\treturn [h, s, l]\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-cpu-temperature.ts",
    "content": "import {trpcReact} from '@/trpc/trpc'\n\nexport function useCpuTemperature() {\n\tconst cpuTemperatureQ = trpcReact.system.cpuTemperature.useQuery(undefined, {\n\t\t// Sometimes we won't be able to get CPU temperature, so prevent retry\n\t\tretry: false,\n\t\t// We do want refetching to happen on a schedule though\n\t\trefetchInterval: 5_000,\n\t})\n\n\tconst temperature = cpuTemperatureQ.data?.temperature\n\tconst warning = cpuTemperatureQ.data?.warning\n\n\treturn {\n\t\ttemperature,\n\t\twarning,\n\t\tisLoading: cpuTemperatureQ.isLoading,\n\t\terror: cpuTemperatureQ.error,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-cpu.ts",
    "content": "import {sort} from 'remeda'\n\nimport {LOADING_DASH} from '@/constants'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function useCpu(options: {poll?: boolean} = {}) {\n\tconst cpuQ = trpcReact.system.cpuUsage.useQuery(undefined, {\n\t\t// Sometimes we won't be able to get disk usage, so prevent retry\n\t\tretry: false,\n\t\t// We do want refetching to happen on a schedule though\n\t\trefetchInterval: options.poll ? 1000 : undefined,\n\t})\n\n\treturn {\n\t\tdata: cpuQ.data,\n\t\tisLoading: cpuQ.isLoading,\n\t\t//\n\t\tpercentUsed: cpuQ.data?.totalUsed ?? 0,\n\t\tthreads: cpuQ.data?.threads ?? 0,\n\t\tapps: sort(\n\t\t\t[\n\t\t\t\t...(cpuQ.data?.apps ?? []),\n\t\t\t\t{\n\t\t\t\t\tid: 'umbreld-system',\n\t\t\t\t\tused: cpuQ.data?.system ?? 0,\n\t\t\t\t},\n\t\t\t],\n\t\t\t(a, b) => b.used - a.used,\n\t\t),\n\t}\n}\n\nexport function useCpuForUi(options: {poll?: boolean} = {}) {\n\tconst {isLoading, percentUsed, threads, apps} = useCpu({poll: options.poll})\n\n\tif (isLoading) {\n\t\treturn {\n\t\t\tisLoading: true,\n\t\t\tvalue: LOADING_DASH,\n\t\t\tprogress: 0,\n\t\t\tsecondaryValue: LOADING_DASH,\n\t\t} as const\n\t}\n\n\treturn {\n\t\tisLoading: false,\n\t\tvalue: Math.ceil(percentUsed) + '%',\n\t\tprogress: percentUsed / 100,\n\t\tsecondaryValue: t('cpu-core-count', {cores: threads}),\n\t\tapps,\n\t} as const\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-debug-install-random-apps.ts",
    "content": "import {shuffle} from 'remeda'\n\nimport {useAvailableApps} from '@/providers/available-apps'\nimport {trpcReact} from '@/trpc/trpc'\n\nexport function useDebugInstallRandomApps() {\n\tconst apps = useAvailableApps()\n\n\tconst installMut = trpcReact.apps.install.useMutation({\n\t\tonSuccess: () => {\n\t\t\twindow.location.reload()\n\t\t},\n\t})\n\n\tconst handleInstallABunch = () => {\n\t\tconst toInstall = shuffle(apps?.apps ?? []).slice(0, 20) ?? []\n\t\ttoInstall.map((app) => installMut.mutate({appId: app.id}))\n\t}\n\n\treturn handleInstallABunch\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-device-info.ts",
    "content": "import {hostEnvironmentMap, LOADING_DASH, UmbrelHostEnvironment, UNKNOWN} from '@/constants'\nimport {RouterOutput, trpcReact} from '@/trpc/trpc'\n\ntype UiHostInfo = {\n\ticon?: string\n\ttitle: string\n}\n\ntype DeviceInfoT =\n\t| {\n\t\t\tisLoading: true\n\t\t\tdata: undefined\n\t\t\tuiData: UiHostInfo\n\t  }\n\t| {\n\t\t\tisLoading: false\n\t\t\tdata: {\n\t\t\t\tumbrelHostEnvironment?: UmbrelHostEnvironment\n\t\t\t\tdevice?: string\n\t\t\t\tmodelNumber?: string\n\t\t\t\tserialNumber?: string\n\t\t\t\tosVersionName?: string\n\t\t\t}\n\t\t\tuiData: UiHostInfo\n\t  }\n\nexport function useDeviceInfo(): DeviceInfoT {\n\tconst osQ = trpcReact.system.version.useQuery()\n\tconst deviceInfoQ = trpcReact.system.device.useQuery()\n\n\tconst isLoading = osQ.isLoading || deviceInfoQ.isLoading\n\tif (isLoading) {\n\t\treturn {\n\t\t\tisLoading: true,\n\t\t\tdata: undefined,\n\t\t\tuiData: {\n\t\t\t\ticon: undefined,\n\t\t\t\ttitle: LOADING_DASH,\n\t\t\t},\n\t\t} as const\n\t}\n\n\tconst umbrelHostEnvironment: UmbrelHostEnvironment | undefined = deviceInfoToHostEnvironment(deviceInfoQ.data)\n\n\tconst device = deviceInfoQ.data?.device\n\tconst modelNumber = deviceInfoQ.data?.model\n\tconst serialNumber = deviceInfoQ.data?.serial\n\tconst osVersionName = osQ.data?.name\n\n\treturn {\n\t\tisLoading,\n\t\tdata: {\n\t\t\tumbrelHostEnvironment,\n\t\t\tdevice,\n\t\t\tmodelNumber,\n\t\t\tserialNumber,\n\t\t\tosVersionName,\n\t\t},\n\t\tuiData: umbrelHostEnvironment\n\t\t\t? {\n\t\t\t\t\ticon: hostEnvironmentMap[umbrelHostEnvironment].icon,\n\t\t\t\t\ttitle: device || LOADING_DASH,\n\t\t\t\t}\n\t\t\t: {\n\t\t\t\t\ticon: undefined,\n\t\t\t\t\ttitle: UNKNOWN(),\n\t\t\t\t},\n\t}\n}\n\ntype DeviceInfo = RouterOutput['system']['device']\n\nfunction deviceInfoToHostEnvironment(deviceInfo?: DeviceInfo): UmbrelHostEnvironment | undefined {\n\tif (!deviceInfo) {\n\t\treturn undefined\n\t}\n\n\tif (deviceInfo.productName.toLowerCase().includes('umbrel pro')) {\n\t\treturn 'umbrel-pro'\n\t}\n\n\tif (deviceInfo.productName.toLowerCase().includes('umbrel home')) {\n\t\treturn 'umbrel-home'\n\t}\n\n\tif (deviceInfo.productName.toLowerCase().includes('raspberry pi')) {\n\t\treturn 'raspberry-pi'\n\t}\n\n\tif (deviceInfo.productName.toLowerCase().includes('docker')) {\n\t\treturn 'docker-container'\n\t}\n\n\t// We return unknown and render a generic server icon\n\treturn 'unknown'\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-disk.ts",
    "content": "import BigNumber from 'bignumber.js'\nimport {sort} from 'remeda'\n\nimport {LOADING_DASH} from '@/constants'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {maybePrettyBytes} from '@/utils/pretty-bytes'\nimport {isDiskFull, isDiskLow, trpcDiskToLocal} from '@/utils/system'\n\nconst ONE_SECOND = 1000\n\nexport function useDisk(options: {poll?: boolean} = {}) {\n\tconst diskQ = trpcReact.system.diskUsage.useQuery(undefined, {\n\t\t// Sometimes we won't be able to get disk usage, so prevent retry\n\t\tretry: false,\n\t\t// We do want refetching to happen on a schedule though\n\t\trefetchInterval: options.poll ? ONE_SECOND * 10 : undefined,\n\t})\n\n\tconst transformed = trpcDiskToLocal(diskQ.data)\n\n\treturn {\n\t\tdata: diskQ.data,\n\t\tisLoading: diskQ.isLoading,\n\t\t//\n\t\t...transformed,\n\t\tapps: sort(\n\t\t\t[\n\t\t\t\t...(diskQ.data?.apps ?? []),\n\t\t\t\t{\n\t\t\t\t\tid: 'umbreld-system',\n\t\t\t\t\tused: diskQ.data?.system ?? 0,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tid: 'umbreld-files',\n\t\t\t\t\tused: diskQ.data?.files ?? 0,\n\t\t\t\t},\n\t\t\t],\n\t\t\t(a, b) => b.used - a.used,\n\t\t),\n\t\tisDiskLow: isDiskLow(transformed?.available),\n\t\tisDiskFull: isDiskFull(transformed?.available),\n\t}\n}\n\nexport function useDiskForUi(options: {poll?: boolean} = {}) {\n\tconst {isLoading, used, size, available, isDiskFull, isDiskLow, apps} = useDisk({\n\t\tpoll: options.poll,\n\t})\n\n\tif (isLoading) {\n\t\treturn {\n\t\t\tisLoading: true,\n\t\t\tvalue: LOADING_DASH,\n\t\t\tvalueSub: '/ ' + LOADING_DASH,\n\t\t\tsecondaryValue: LOADING_DASH,\n\t\t\tprogress: 0,\n\t\t} as const\n\t}\n\n\treturn {\n\t\tisLoading: false,\n\t\tvalue: maybePrettyBytes(used),\n\t\tvalueSub: `/ ${maybePrettyBytes(size)}`,\n\t\tsecondaryValue: t('something-left', {left: maybePrettyBytes(available)}),\n\t\tprogress: BigNumber(used ?? 0)\n\t\t\t.dividedBy(size ?? 0)\n\t\t\t.toNumber(),\n\t\tisDiskLow,\n\t\tisDiskFull,\n\t\tapps,\n\t} as const\n}\n\nexport function useSystemDisk(options: {poll?: boolean} = {}) {\n\tconst diskQ = trpcReact.system.systemDiskUsage.useQuery(undefined, {\n\t\t// Sometimes we won't be able to get disk usage, so prevent retry\n\t\tretry: false,\n\t\t// We do want refetching to happen on a schedule though\n\t\trefetchInterval: options.poll ? ONE_SECOND * 10 : undefined,\n\t})\n\n\tconst transformed = trpcDiskToLocal(diskQ.data)\n\n\treturn {\n\t\tdata: diskQ.data,\n\t\tisLoading: diskQ.isLoading,\n\t\t//\n\t\t...transformed,\n\t\tisDiskLow: isDiskLow(transformed?.available),\n\t\tisDiskFull: isDiskFull(transformed?.available),\n\t}\n}\n\nexport function useSystemDiskForUi(options: {poll?: boolean} = {}) {\n\tconst {isLoading, used, size, available, isDiskFull, isDiskLow} = useSystemDisk({\n\t\tpoll: options.poll,\n\t})\n\n\tif (isLoading) {\n\t\treturn {\n\t\t\tisLoading: true,\n\t\t\tvalue: LOADING_DASH,\n\t\t\tvalueSub: '/ ' + LOADING_DASH,\n\t\t\tsecondaryValue: LOADING_DASH,\n\t\t\tprogress: 0,\n\t\t} as const\n\t}\n\n\treturn {\n\t\tisLoading: false,\n\t\tvalue: maybePrettyBytes(used),\n\t\tvalueSub: `/ ${maybePrettyBytes(size)}`,\n\t\tsecondaryValue: t('something-left', {left: maybePrettyBytes(available)}),\n\t\tprogress: BigNumber(used ?? 0)\n\t\t\t.dividedBy(size ?? 0)\n\t\t\t.toNumber(),\n\t\tisDiskLow,\n\t\tisDiskFull,\n\t} as const\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-is-externaldns.ts",
    "content": "import {toast} from '@/components/ui/toast'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function useIsExternalDns({onSuccess}: {onSuccess?: (enabled: boolean) => void} = {}) {\n\tconst externalDnsQ = trpcReact.system.isExternalDns.useQuery()\n\tconst isChecked = externalDnsQ.data === true\n\n\tconst externalDnsMut = trpcReact.system.setExternalDns.useMutation({\n\t\tonSuccess: (enabled) => {\n\t\t\texternalDnsQ.refetch()\n\t\t\tonSuccess?.(enabled)\n\t\t},\n\t\tonError: (err) => {\n\t\t\ttoast.error(t('external-dns-error', {message: err.message}))\n\t\t},\n\t})\n\n\tconst change = (checked: boolean) => {\n\t\texternalDnsMut.mutate(checked)\n\t}\n\n\tconst isLoading = externalDnsMut.isPending || externalDnsQ.isLoading\n\n\treturn {isChecked, change, isLoading}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-is-home-or-pro.ts",
    "content": "import {useDeviceInfo} from '@/hooks/use-device-info'\nimport {t} from '@/utils/i18n'\n\n// Consider consolidating device detection hooks. Currently we have:\n// - useIsUmbrelHome (uses trpcReact.migration.isUmbrelHome)\n// - useIsUmbrelPro (uses trpcReact.hardware.umbrelPro.isUmbrelPro)\n// - useDeviceInfo (uses trpcReact.system.device)\n// - useIsHomeOrPro (uses useDeviceInfo)\n// These could potentially be unified into a single source of truth.\nexport function useIsHomeOrPro() {\n\tconst {isLoading, data} = useDeviceInfo()\n\tconst isUmbrelHome = data?.umbrelHostEnvironment === 'umbrel-home'\n\tconst isUmbrelPro = data?.umbrelHostEnvironment === 'umbrel-pro'\n\tconst isHomeOrPro = isUmbrelHome || isUmbrelPro\n\tconst deviceName = isUmbrelPro ? 'Umbrel Pro' : isUmbrelHome ? 'Umbrel Home' : t('device-name.home-or-pro')\n\n\treturn {\n\t\tisHomeOrPro,\n\t\tisLoading,\n\t\tdeviceName,\n\t\tisUmbrelPro,\n\t\tisUmbrelHome,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-is-mobile.ts",
    "content": "import {useBreakpoint} from '@/utils/tw'\n\n// Made for the '/settings' page, but probably useful elsewhere.\nexport function useIsMobile() {\n\tconst breakpoint = useBreakpoint()\n\tconst isMobile = breakpoint === 'sm' || breakpoint === 'md'\n\n\treturn isMobile\n}\n\nexport function useIsSmallMobile() {\n\tconst breakpoint = useBreakpoint()\n\tconst isMobile = breakpoint === 'sm'\n\n\treturn isMobile\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-is-umbrel-home.tsx",
    "content": "import {trpcReact} from '@/trpc/trpc'\n\nexport function useIsUmbrelHome() {\n\tconst isUmbrelHomeQ = trpcReact.migration.isUmbrelHome.useQuery()\n\tconst isUmbrelHome = !!isUmbrelHomeQ.data\n\treturn {\n\t\tisUmbrelHome,\n\t\tisLoading: isUmbrelHomeQ.isLoading,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-is-umbrel-pro.ts",
    "content": "import {trpcReact} from '@/trpc/trpc'\n\nexport function useIsUmbrelPro() {\n\tconst isUmbrelProQ = trpcReact.hardware.umbrelPro.isUmbrelPro.useQuery()\n\tconst isUmbrelPro = !!isUmbrelProQ.data\n\treturn {\n\t\tisUmbrelPro,\n\t\tisLoading: isUmbrelProQ.isLoading,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-language.ts",
    "content": "import i18next from 'i18next'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {JWT_LOCAL_STORAGE_KEY} from '@/modules/auth/shared'\nimport {trpcReact} from '@/trpc/trpc'\nimport {SupportedLanguageCode, supportedLanguageCodes} from '@/utils/language'\n\n/**\n * Hook for reading and updating the active UI language.\n *\n * NOTE: There are differences when the user is logged‑in vs during onboarding:\n *  • During onboarding: The language is not persisted on the backend because the user does not yet have a valid JWT.\n *    Instead, the langauge is saved to localStorage with `i18next` and the page is reloaded to apply the change.\n *  • When a user account exists, the language is persisted on the backend via `user.set`.\n *    The `RemoteLanguageInjector` component then detects this backend change, updates the local\n *    `i18next` state and localStorage, and reloads the page to apply the change globally.\n */\nexport function useLanguage(): [SupportedLanguageCode, (code: SupportedLanguageCode) => void] {\n\tconst utils = trpcReact.useUtils()\n\tconst userSetMut = trpcReact.user.set.useMutation({\n\t\tonSuccess() {\n\t\t\t// Keep local caches in sync\n\t\t\tutils.user.get.invalidate()\n\t\t\tutils.user.language.invalidate()\n\t\t},\n\t})\n\n\tconst setCode = (language: SupportedLanguageCode) => {\n\t\t// Return early if the language is not supported.\n\t\tif (!arrayIncludes(supportedLanguageCodes, language)) return\n\n\t\t// Return early if a user clicks the same language that is already active.\n\t\tif (i18next.language === language) return\n\n\t\tconst hasJwt = Boolean(localStorage.getItem(JWT_LOCAL_STORAGE_KEY))\n\t\tconst isOnboarding = window.location.pathname.startsWith('/onboarding')\n\n\t\t// If a user account has not been created yet (i.e., we are onboarding), we only persist the language locally\n\t\t// because we won't have a valid JWT yet.\n\t\t// We make the explicit check for `isOnboarding` to handle the edge case of a user having a JWT in localStorage from a previous install.\n\t\tif (isOnboarding || !hasJwt) {\n\t\t\ti18next.changeLanguage(language)\n\t\t\twindow.location.reload()\n\t\t\treturn\n\t\t}\n\n\t\t// If we reach here, the user is logged in, not onboarding, and language has changed.\n\t\t// We update the preferred language on the backend, which in turn notifies\n\t\t// RemoteLanguageInjector that the preferred language has changed. When\n\t\t// this happens, the injector sets the new language and reloads the page.\n\t\tuserSetMut.mutate({language})\n\t}\n\n\t// Default to English if active code is not supported\n\tconst code = arrayIncludes(supportedLanguageCodes, i18next.language)\n\t\t? (i18next.language as SupportedLanguageCode)\n\t\t: 'en'\n\n\treturn [code, setCode]\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-launch-app.ts",
    "content": "import {useNavigate} from 'react-router-dom'\n\nimport {toast} from '@/components/ui/toast'\nimport {useApps} from '@/providers/apps'\nimport {trpcReact} from '@/trpc/trpc'\nimport {useLinkToDialog} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\nimport {appToUrl, appToUrlWithAppPath, isOnionPage, urlJoin} from '@/utils/misc'\n\n/**\n * There's a strong temptation to make launching an app just a link to the app's URL:\n * - You get a url on mouse hover.\n * - You can right click and copy the link.\n * - You can cmd-click on a page to open it in a new tab.\n * - It's easy to append a path to the URL. If we just return a string, this would make appending paths way easier.\n * - It's more semantic.\n *\n * However, it's not worth the complexity on the consumer right now.\n *\n * The main reason to switch back to hrefs would be because we don't actually want to open in new tabs by default, and if we can find a better way to track app opens.\n *\n * Trying to satisfy multiple considerations here:\n * - Consumer API should be simple. We don't want the consumer to have to deal with the \"how\" of openining an app, only the intent. You want to open an app? Just tell us which app and we'll handle the rest.\n * - Not returning a href because then how do we open in a blank page? The consumer of this hook would have to deal with the logic of this.\n * - Want to track app opens to compute frequent apps for CMD K. If we only return a href, it's too easy to forget to track app opens.\n * - Want to show a dialog if the app has default credentials.\n * - API like `useLaunchApp(appId)` won't work because we want to sometimes loop through multiple apps and add an `onClick`to each one.\n * - If an app has been uninstalled, but the UI still shows it (maybe because some queries haven't been invalidated), we want to let the user know they can't open the app because it's be uninstalled?\n */\nexport function useLaunchApp() {\n\tconst userApp = useApps()\n\tconst navigate = useNavigate()\n\tconst linkToDialog = useLinkToDialog()\n\tconst utils = trpcReact.useUtils()\n\tconst trackOpenMut = trpcReact.apps.trackOpen.useMutation({\n\t\tonSuccess() {\n\t\t\tutils.apps.recentlyOpened.invalidate()\n\t\t},\n\t})\n\n\tconst handleLaunch = (appId: string, options?: {path?: string; direct?: boolean}) => {\n\t\tconst app = userApp.userAppsKeyed?.[appId]\n\n\t\tif (!app) {\n\t\t\t// return linkToDialog('app-not-found', {id: appId})\n\t\t\tthrow new Error(t('app-not-found', {app: appId}))\n\t\t}\n\n\t\tconst open = (path?: string) => {\n\t\t\ttrackOpenMut.mutate({appId})\n\t\t\tconst url = path ? urlJoin(appToUrl(app), path) : appToUrlWithAppPath(app)\n\t\t\twindow.open(url, '_blank')?.focus()\n\t\t}\n\n\t\t// If we're already in the credentials dialog, don't show the dialog again.\n\t\tif (app.credentials?.showBeforeOpen && !options?.direct) {\n\t\t\tnavigate(linkToDialog('default-credentials', {for: appId, direct: 'true'}))\n\t\t} else {\n\t\t\tif (app.torOnly) {\n\t\t\t\tif (isOnionPage()) {\n\t\t\t\t\topen(options?.path)\n\t\t\t\t} else {\n\t\t\t\t\ttoast.warning(t('app-only-over-tor', {app: app.name}))\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\topen(options?.path)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn handleLaunch\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-memory.ts",
    "content": "import BigNumber from 'bignumber.js'\nimport {sort} from 'remeda'\n\nimport {LOADING_DASH} from '@/constants'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {maybePrettyBytes} from '@/utils/pretty-bytes'\nimport {isMemoryLow, trpcMemoryToLocal} from '@/utils/system'\n\nexport function useSystemMemory(options: {poll?: boolean} = {}) {\n\tconst memoryQ = trpcReact.system.systemMemoryUsage.useQuery(undefined, {\n\t\t// Sometimes we won't be able to get disk usage, so prevent retry\n\t\tretry: false,\n\t\t// We do want refetching to happen on a schedule though\n\t\trefetchInterval: options.poll ? 1000 : undefined,\n\t})\n\n\tconst transformed = trpcMemoryToLocal(memoryQ.data)\n\n\treturn {\n\t\tdata: memoryQ.data,\n\t\tisLoading: memoryQ.isLoading,\n\t\t//\n\t\t...transformed,\n\t\tisMemoryLow: isMemoryLow({size: transformed?.size, used: transformed?.used}),\n\t}\n}\n\nexport function useMemory(options: {poll?: boolean} = {}) {\n\tconst memoryQ = trpcReact.system.memoryUsage.useQuery(undefined, {\n\t\t// Sometimes we won't be able to get disk usage, so prevent retry\n\t\tretry: false,\n\t\t// We do want refetching to happen on a schedule though\n\t\trefetchInterval: options.poll ? 1000 : undefined,\n\t})\n\n\tconst transformed = trpcMemoryToLocal(memoryQ.data)\n\n\treturn {\n\t\tdata: memoryQ.data,\n\t\tisLoading: memoryQ.isLoading,\n\t\t//\n\t\t...transformed,\n\t\tapps: sort(\n\t\t\t[\n\t\t\t\t...(memoryQ.data?.apps ?? []),\n\t\t\t\t{\n\t\t\t\t\tid: 'umbreld-system',\n\t\t\t\t\tused: memoryQ.data?.system ?? 0,\n\t\t\t\t},\n\t\t\t],\n\t\t\t(a, b) => b.used - a.used,\n\t\t),\n\t\tisMemoryLow: isMemoryLow({size: transformed?.size, used: transformed?.used}),\n\t}\n}\n\nexport function useSystemMemoryForUi(options: {poll?: boolean} = {}) {\n\tconst {isLoading, used, size, available, isMemoryLow} = useSystemMemory({poll: options.poll})\n\n\tif (isLoading) {\n\t\treturn {\n\t\t\tisLoading: true,\n\t\t\tvalue: LOADING_DASH,\n\t\t\tvalueSub: '/ ' + LOADING_DASH,\n\t\t\tsecondaryValue: LOADING_DASH,\n\t\t\tprogress: 0,\n\t\t} as const\n\t}\n\n\treturn {\n\t\tisLoading: false,\n\t\tvalue: maybePrettyBytes(used),\n\t\tvalueSub: `/ ${maybePrettyBytes(size)}`,\n\t\tsecondaryValue: t('something-left', {left: maybePrettyBytes(available)}),\n\t\tprogress: BigNumber(used ?? 0)\n\t\t\t.dividedBy(size ?? 0)\n\t\t\t.toNumber(),\n\t\tisMemoryLow,\n\t} as const\n}\n\nexport function useMemoryForUi(options: {poll?: boolean} = {}) {\n\tconst {isLoading, used, size, available, isMemoryLow, apps} = useMemory({poll: options.poll})\n\n\tif (isLoading) {\n\t\treturn {\n\t\t\tisLoading: true,\n\t\t\tvalue: LOADING_DASH,\n\t\t\tvalueSub: '/ ' + LOADING_DASH,\n\t\t\tsecondaryValue: LOADING_DASH,\n\t\t\tprogress: 0,\n\t\t} as const\n\t}\n\n\treturn {\n\t\tisLoading: false,\n\t\tvalue: maybePrettyBytes(used),\n\t\tvalueSub: `/ ${maybePrettyBytes(size)}`,\n\t\tsecondaryValue: t('something-left', {left: maybePrettyBytes(available)}),\n\t\tprogress: BigNumber(used ?? 0)\n\t\t\t.dividedBy(size ?? 0)\n\t\t\t.toNumber(),\n\t\tisMemoryLow,\n\t\tapps,\n\t} as const\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-notifications.ts",
    "content": "import {keepPreviousData} from '@tanstack/react-query'\n\nimport {trpcReact} from '@/trpc/trpc'\n\n/**\n * Hook to query and clear system notifications\n */\nexport function useNotifications() {\n\tconst utils = trpcReact.useUtils()\n\n\t// Query to fetch notifications\n\tconst {\n\t\tdata: notifications = [],\n\t\tisLoading,\n\t\tisError,\n\t\terror,\n\t} = trpcReact.notifications.get.useQuery(undefined, {\n\t\tplaceholderData: keepPreviousData,\n\t})\n\n\t// Mutation to clear a notification\n\tconst clearNotification = trpcReact.notifications.clear.useMutation({\n\t\tonMutate: async (notificationToRemove: string) => {\n\t\t\tawait utils.notifications.get.cancel()\n\t\t\tconst previousNotifications = utils.notifications.get.getData()\n\n\t\t\t// Optimistically update the notifications list\n\t\t\tutils.notifications.get.setData(undefined, (old = []) => old.filter((n) => n !== notificationToRemove))\n\n\t\t\treturn {previousNotifications}\n\t\t},\n\t\tonSettled: () => {\n\t\t\tutils.notifications.get.invalidate()\n\t\t},\n\t})\n\n\treturn {\n\t\tnotifications,\n\t\tclearNotification: (notification: string) => clearNotification.mutate(notification),\n\t\tisLoading,\n\t\tisError,\n\t\terror,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-password.ts",
    "content": "import {useState} from 'react'\n\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {sleep} from '@/utils/misc'\n\nexport function usePassword({onSuccess}: {onSuccess: () => void}) {\n\tconst [password, setPassword] = useState('')\n\tconst [newPassword, setNewPassword] = useState('')\n\tconst [newPasswordRepeat, setNewPasswordRepeat] = useState('')\n\tconst [localError, setLocalError] = useState('')\n\n\tconst changePasswordMut = trpcReact.user.changePassword.useMutation({\n\t\tonSuccess: async () => {\n\t\t\tawait sleep(500)\n\t\t\tonSuccess()\n\t\t},\n\t})\n\n\tconst handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n\t\te.preventDefault()\n\n\t\t// Reset errors\n\t\tchangePasswordMut.reset()\n\t\t// So setLocalError('') is not batched\n\t\tawait setLocalError('')\n\n\t\tif (!password) {\n\t\t\tsetLocalError(t('change-password.failed.current-required'))\n\t\t\treturn\n\t\t}\n\n\t\tif (!newPassword) {\n\t\t\tsetLocalError(t('change-password.failed.new-required'))\n\t\t\treturn\n\t\t}\n\n\t\tif (!newPasswordRepeat) {\n\t\t\tsetLocalError(t('change-password.failed.repeat-required'))\n\t\t\treturn\n\t\t}\n\n\t\tif (newPassword !== newPasswordRepeat) {\n\t\t\tsetLocalError(t('change-password.failed.no-match'))\n\t\t\treturn\n\t\t}\n\n\t\tif (password === newPassword) {\n\t\t\tsetLocalError(t('change-password.failed.must-be-unique'))\n\t\t\treturn\n\t\t}\n\n\t\tif (newPassword.length < 6) {\n\t\t\tsetLocalError(t('change-password.failed.min-length', {characters: 6}))\n\t\t\treturn\n\t\t}\n\n\t\tchangePasswordMut.mutate({oldPassword: password, newPassword})\n\t}\n\n\tconst remoteFormError = !changePasswordMut.error?.data?.zodError && changePasswordMut.error?.message\n\tconst formError = localError || remoteFormError\n\n\tconst fieldErrors = {\n\t\toldPassword: changePasswordMut.error?.data?.zodError?.fieldErrors['oldPassword']?.join('. '),\n\t\tnewPassword: changePasswordMut.error?.data?.zodError?.fieldErrors['newPassword']?.join('. '),\n\t}\n\n\treturn {\n\t\tpassword,\n\t\tsetPassword,\n\t\tnewPassword,\n\t\tsetNewPassword,\n\t\tnewPasswordRepeat,\n\t\tsetNewPasswordRepeat,\n\t\thandleSubmit,\n\t\tformError,\n\t\tfieldErrors,\n\t\tisLoading: changePasswordMut.isPending,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-prefixed-local-storage.ts",
    "content": "import {useEffect, useState} from 'react'\nimport {useLocalStorage} from 'react-use'\n\n/**\n * Just like `useLocalStorage`, but a few differences:\n * - The key is prefixed with `UMBREL_`\n * - Uses an effect to prevent ssr mismatch\n * Why: https://github.com/streamich/react-use/issues/702\n */\nexport function usePrefixedLocalStorage<TT>(key: string, defaultValue?: TT) {\n\tconst [s2, setS2] = useState<TT | undefined>(undefined)\n\tconst [s, ss] = useLocalStorage('UMBREL_' + key, defaultValue)\n\n\tuseEffect(() => {\n\t\tsetS2(s)\n\t}, [s])\n\n\treturn [s2, ss] as const\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-query-params.ts",
    "content": "import {NavigateOptions, useSearchParams} from 'react-router-dom'\nimport {pickBy} from 'remeda'\n\ntype QueryObject = {[key: string]: string}\n\n// TODO: test by adding and removing `?bla=1` into the URL bar and ensuring that adding and removing param `foo` doesn't modify `bla`\nexport function useQueryParams<T extends QueryObject>() {\n\tconst [searchParams, setSearchParams] = useSearchParams()\n\n\tconst object = Object.fromEntries(searchParams.entries()) as T\n\n\tconst add = (param: keyof T, value: string, navigateOpts?: NavigateOptions) => {\n\t\tconst newParams = {...object, [param]: value}\n\t\tsetSearchParams(newParams, navigateOpts)\n\t}\n\n\tconst remove = (param: keyof T, navigateOpts?: NavigateOptions) => {\n\t\tconst newParams = {...pickBy(object, (_, k) => k !== param)}\n\t\tsetSearchParams(newParams, navigateOpts)\n\t}\n\n\t// Adding `& string` because otherwise `key` can be a number if `T` is not specified when calling `useQueryParams`\n\tconst filter = (fn: (item: [key: keyof T & string, value: string]) => boolean, navigateOpts?: NavigateOptions) => {\n\t\tsetSearchParams(Object.entries(object).filter(fn), navigateOpts)\n\t}\n\n\treturn {\n\t\tparams: searchParams,\n\t\tobject,\n\t\tremove,\n\t\tadd,\n\t\tfilter,\n\t\t/**\n\t\t * For use in React Router `Link`:\n\t\t * ```jsx\n\t\t * // EXAMPLE\n\t\t * <Link to={{ search: addLinkSearchParams({ page: 1 }) }}>Page 1</Link>\n\t\t * ```\n\t\t */\n\t\taddLinkSearchParams: (newParams: T) => {\n\t\t\treturn new URLSearchParams({\n\t\t\t\t...object,\n\t\t\t\t...newParams,\n\t\t\t}).toString()\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-scroll-restoration.ts",
    "content": "import {useEffect} from 'react'\nimport {NavigationType, useLocation, useNavigation, useNavigationType} from 'react-router-dom'\nimport {usePrevious} from 'react-use'\n\n/** Whether to restore, reset to zero or ignore scroll position. */\nexport type ScrollRestorationAction = 'restore' | 'reset' | 'ignore'\n\n/**\n * Handler function testing if scroll position should be restored, reset to zero\n * or ignored.\n */\nexport type ScrollRestorationHandler = (\n\tthisPathname: string,\n\tprevPathname: string,\n\tnavigationType: NavigationType,\n) => ScrollRestorationAction\n\n/**\n * Given a ref to a scrolling container element, keep track of its scroll\n * position before navigation and restore it on return (e.g., back/forward nav).\n * Behavior is determined by the provided {@link ScrollRestorationHandler}.\n */\nexport function useScrollRestoration(\n\tcontainer: React.RefObject<HTMLElement | null>,\n\thandler: ScrollRestorationHandler,\n) {\n\tconst location = useLocation()\n\tconst thisPathname = location.pathname\n\tconst prevPathname = usePrevious(thisPathname)\n\t// `location.pathname` is used in the cache key, not `location.key`. This\n\t// means that query strings do not affect scroll restoration. This is mainly\n\t// to avoid scrolling for the `dialog` query param.\n\tconst cacheKey = `scroll-position-${thisPathname}`\n\tconst {state} = useNavigation()\n\tconst navigationType = useNavigationType()\n\n\tuseEffect(() => {\n\t\tconst scrollElement = container.current\n\t\tif (state === 'idle') {\n\t\t\tif (!prevPathname) {\n\t\t\t\t// Clear cache when first entering a scroll restoration context\n\t\t\t\tclearScrollPositions()\n\t\t\t} else if (thisPathname !== prevPathname) {\n\t\t\t\t// Restore or reset cached scroll position where applicable\n\t\t\t\tconst action = handler(thisPathname, prevPathname, navigationType)\n\t\t\t\tif (action === 'restore') {\n\t\t\t\t\tconst y = getScrollPosition(cacheKey)\n\t\t\t\t\tscrollElement?.scrollTo(0, y)\n\t\t\t\t\tsetScrollPosition(cacheKey, y)\n\t\t\t\t} else if (action === 'reset') {\n\t\t\t\t\tscrollElement?.scrollTo(0, 0)\n\t\t\t\t\tsetScrollPosition(cacheKey, 0)\n\t\t\t\t} else {\n\t\t\t\t\t// ignore\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Cache last known scroll position. TODO: Use 'scrollend' listener when\n\t\t// supported in Safari: https://caniuse.com/?search=scrollend\n\t\tconst handleScrollEnd = () => {\n\t\t\tconst y = Math.round(scrollElement?.scrollTop ?? 0)\n\t\t\tsetScrollPosition(cacheKey, y ?? 0)\n\t\t}\n\t\tscrollElement?.addEventListener('scroll' /*end*/, handleScrollEnd)\n\t\treturn () => {\n\t\t\tscrollElement?.removeEventListener('scroll' /*end*/, handleScrollEnd)\n\t\t}\n\t}, [cacheKey, state, container, thisPathname, prevPathname, navigationType])\n}\n\nfunction getScrollPosition(key: string) {\n\tconst pos = window.sessionStorage.getItem(key)\n\treturn pos && /^[0-9]+$/.test(pos) ? parseInt(pos, 10) : 0\n}\n\nfunction setScrollPosition(key: string, pos: number) {\n\tif (pos) {\n\t\twindow.sessionStorage.setItem(key, pos.toString())\n\t} else {\n\t\twindow.sessionStorage.removeItem(key)\n\t}\n}\n\nfunction clearScrollPositions() {\n\tlet index = 0\n\twhile (index < window.sessionStorage.length) {\n\t\tconst key = window.sessionStorage.key(index)\n\t\tif (key?.startsWith('scroll-position-')) {\n\t\t\twindow.sessionStorage.removeItem(key)\n\t\t} else {\n\t\t\tindex++\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-settings-notification-count.ts",
    "content": "import {useEffect, useState} from 'react'\nimport {useNavigate} from 'react-router-dom'\nimport {ExternalToast} from 'sonner'\n\nimport {toast} from '@/components/ui/toast'\nimport {getDeviceHealth} from '@/features/storage/hooks/use-storage'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {isCpuTooHot, isTrpcDiskFull, isTrpcMemoryLow} from '@/utils/system'\n\nfunction useMounted() {\n\tconst [mounted, setMounted] = useState(false)\n\t// First render sets mounted to true\n\tuseEffect(() => setMounted(true), [])\n\treturn mounted\n}\n\nexport function useSettingsNotificationCount() {\n\tconst navigate = useNavigate()\n\tconst utils = trpcReact.useUtils()\n\n\tconst mounted = useMounted()\n\tconst [count, setCount] = useState(0)\n\n\tuseEffect(() => {\n\t\t// Checking against `mounted` because of this issue:\n\t\t// https://github.com/emilkowalski/sonner/issues/322\n\t\tif (!mounted) return\n\n\t\tconst res = Promise.allSettled([\n\t\t\tutils.system.checkUpdate.fetch(),\n\t\t\tutils.system.cpuTemperature.fetch(),\n\t\t\tutils.system.systemMemoryUsage.fetch(),\n\t\t\tutils.system.systemDiskUsage.fetch(),\n\t\t\tutils.hardware.umbrelPro.isUmbrelPro.fetch(),\n\t\t\tutils.hardware.raid.getStatus.fetch(),\n\t\t\tutils.hardware.internalStorage.getDevices.fetch(),\n\t\t])\n\n\t\tconst toastIds: (string | number)[] = []\n\n\t\tres.then((allData) => {\n\t\t\tconst [\n\t\t\t\tcheckUpdateResult,\n\t\t\t\tcpuTempResult,\n\t\t\t\tmemoryResult,\n\t\t\t\tdiskResult,\n\t\t\t\tisUmbrelProResult,\n\t\t\t\traidStatusResult,\n\t\t\t\tdevicesResult,\n\t\t\t] = allData ?? []\n\n\t\t\tconst isUmbrelPro = isUmbrelProResult?.status === 'fulfilled' && isUmbrelProResult.value\n\n\t\t\tlet currCount = 0\n\n\t\t\tconst liveUsageToastOptions: ExternalToast = {\n\t\t\t\taction: {\n\t\t\t\t\tlabel: t('notifications.view'),\n\t\t\t\t\tonClick: () => {\n\t\t\t\t\t\tnavigate(`?dialog=live-usage`)\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t// Don't auto-close\n\t\t\t\tduration: Infinity,\n\t\t\t}\n\n\t\t\tconst cpuTempToastOptions: ExternalToast = {\n\t\t\t\taction: {\n\t\t\t\t\tlabel: t('notifications.view'),\n\t\t\t\t\tonClick: () => {\n\t\t\t\t\t\tnavigate(`/settings`)\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t// Don't auto-close\n\t\t\t\tduration: Infinity,\n\t\t\t}\n\n\t\t\tconst softwareUpdateToastOptions: ExternalToast = {\n\t\t\t\taction: {\n\t\t\t\t\tlabel: t('notifications.view'),\n\t\t\t\t\tonClick: () => {\n\t\t\t\t\t\tnavigate(`/settings/software-update/confirm`)\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t// Don't auto-close\n\t\t\t\tduration: Infinity,\n\t\t\t}\n\n\t\t\tconst storageManagerToastOptions: ExternalToast = {\n\t\t\t\taction: {\n\t\t\t\t\tlabel: t('notifications.view'),\n\t\t\t\t\tonClick: () => {\n\t\t\t\t\t\tnavigate(`/settings/storage`)\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t// Don't auto-close\n\t\t\t\tduration: Infinity,\n\t\t\t}\n\n\t\t\tif (checkUpdateResult.status === 'fulfilled') {\n\t\t\t\tconst {name, available} = checkUpdateResult.value\n\n\t\t\t\tif (available) {\n\t\t\t\t\tcurrCount++\n\t\t\t\t\tconst id = toast.info(t('notifications.new-version-available', {update: name}), softwareUpdateToastOptions)\n\t\t\t\t\ttoastIds.push(id)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (cpuTempResult.status === 'fulfilled') {\n\t\t\t\tconst warning = cpuTempResult.value.warning\n\n\t\t\t\tif (isCpuTooHot(warning)) {\n\t\t\t\t\tcurrCount++\n\t\t\t\t\tconst id = toast.warning(t('notifications.cpu.too-hot'), cpuTempToastOptions)\n\t\t\t\t\ttoastIds.push(id)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (diskResult.status === 'fulfilled') {\n\t\t\t\tconst disk = diskResult.value\n\n\t\t\t\tif (isTrpcDiskFull(disk)) {\n\t\t\t\t\tcurrCount++\n\t\t\t\t\tconst id = toast.warning(t('notifications.storage.full'), liveUsageToastOptions)\n\t\t\t\t\ttoastIds.push(id)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (memoryResult.status === 'fulfilled') {\n\t\t\t\tconst memory = memoryResult.value\n\n\t\t\t\tif (isTrpcMemoryLow(memory)) {\n\t\t\t\t\tcurrCount++\n\t\t\t\t\tconst id = toast.warning(t('notifications.memory.low'), liveUsageToastOptions)\n\t\t\t\t\ttoastIds.push(id)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Storage notifications only show on Umbrel Pro (Storage Manager is Pro-only)\n\t\t\t// TODO: Consider adding real-time notifications via eventBus subscription for RAID status changes\n\t\t\tif (isUmbrelPro) {\n\t\t\t\t// Check RAID status for issues\n\t\t\t\tif (raidStatusResult?.status === 'fulfilled') {\n\t\t\t\t\tconst raidStatus = raidStatusResult.value\n\n\t\t\t\t\tif (raidStatus.exists && raidStatus.status && raidStatus.status !== 'ONLINE') {\n\t\t\t\t\t\tcurrCount++\n\t\t\t\t\t\tconst id = toast.warning(t('notifications.raid.issue.title'), {\n\t\t\t\t\t\t\t...storageManagerToastOptions,\n\t\t\t\t\t\t\tdescription: t('notifications.raid.issue.description'),\n\t\t\t\t\t\t})\n\t\t\t\t\t\ttoastIds.push(id)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Check SSD health for issues (temperature, wear, SMART status)\n\t\t\t\tif (devicesResult?.status === 'fulfilled') {\n\t\t\t\t\tconst devices = devicesResult.value\n\t\t\t\t\tconst hasHealthIssue = devices.some((device) => getDeviceHealth(device).hasWarning)\n\n\t\t\t\t\tif (hasHealthIssue) {\n\t\t\t\t\t\tcurrCount++\n\t\t\t\t\t\tconst id = toast.warning(t('notifications.ssd.health.title'), {\n\t\t\t\t\t\t\t...storageManagerToastOptions,\n\t\t\t\t\t\t\tdescription: t('notifications.ssd.health.description'),\n\t\t\t\t\t\t})\n\t\t\t\t\t\ttoastIds.push(id)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tsetCount(currCount)\n\t\t})\n\n\t\treturn () => {\n\t\t\ttoastIds.map(toast.dismiss)\n\t\t}\n\t}, [mounted, navigate])\n\n\treturn count\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-software-update.ts",
    "content": "import {useCallback} from 'react'\n\nimport {toast} from '@/components/ui/toast'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport type UpdateState = 'initial' | 'checking' | 'at-latest' | 'update-available' | 'upgrading'\n\nexport function useSoftwareUpdate() {\n\tconst utils = trpcReact.useUtils()\n\tconst latestVersionQ = trpcReact.system.checkUpdate.useQuery(undefined, {\n\t\tretry: false,\n\t\trefetchOnReconnect: false,\n\t\trefetchOnWindowFocus: false,\n\t})\n\tconst osVersionQ = trpcReact.system.version.useQuery()\n\n\tconst currentVersion = osVersionQ.data\n\tconst latestVersion = latestVersionQ.data\n\n\tconst checkLatest = useCallback(async () => {\n\t\ttry {\n\t\t\tutils.system.checkUpdate.invalidate()\n\t\t\tconst latestVersion = await utils.system.checkUpdate.fetch()\n\n\t\t\tif (!latestVersion) {\n\t\t\t\tthrow new Error(t('software-update.failed-to-check'))\n\t\t\t}\n\t\t} catch {\n\t\t\ttoast.error(t('software-update.failed-to-check'))\n\t\t}\n\t}, [utils.system.checkUpdate])\n\n\tlet state: UpdateState = 'initial'\n\tif (latestVersionQ.isLoading) {\n\t\tstate = 'initial'\n\t} else if (latestVersionQ.isRefetching) {\n\t\tstate = 'checking'\n\t} else if (latestVersionQ.error) {\n\t\tstate = 'initial'\n\t} else if (!latestVersionQ.data?.available) {\n\t\tstate = 'at-latest'\n\t} else {\n\t\tstate = 'update-available'\n\t}\n\n\treturn {\n\t\tstate,\n\t\tcurrentVersion,\n\t\tlatestVersion,\n\t\tcheckLatest,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-temperature-unit.ts",
    "content": "import {useLanguage} from '@/hooks/use-language'\nimport {trpcReact} from '@/trpc/trpc'\nimport {SupportedLanguageCode} from '@/utils/language'\nimport {keyBy} from '@/utils/misc'\n\nconst languageCodesWithFahrenheitTemperature: SupportedLanguageCode[] = ['en']\n\nexport const temperatureDescriptions = [\n\t{id: 'c', label: '°C'},\n\t{id: 'f', label: '°F'},\n] as const\n\nexport type TemperatureUnit = (typeof temperatureDescriptions)[number]['id']\n\nexport const temperatureDescriptionsKeyed = keyBy(temperatureDescriptions, 'id')\n\nexport function useTemperatureUnit(\n\toptionalUnit?: TemperatureUnit,\n): [unit: TemperatureUnit, setTemp: (unit: TemperatureUnit) => void] {\n\tconst utils = trpcReact.useUtils()\n\tconst userGetQ = trpcReact.user.get.useQuery()\n\tconst userSetMut = trpcReact.user.set.useMutation({\n\t\tonSuccess() {\n\t\t\tutils.user.get.invalidate()\n\t\t},\n\t})\n\n\tconst setUnit = (temperatureUnit: TemperatureUnit) => {\n\t\tuserSetMut.mutate({temperatureUnit})\n\t}\n\n\t// Fall back to determine the unit from the user's language\n\tconst [languageCode] = useLanguage()\n\tconst languageUnit = languageCodesWithFahrenheitTemperature.includes(languageCode) ? 'f' : 'c'\n\tconst defaultUnit = optionalUnit || languageUnit\n\n\t// Use preferred unit stored on the backend once set\n\tconst preferredUnit = userGetQ.data?.temperatureUnit\n\tconst unit = temperatureDescriptions.some((description) => preferredUnit === description.id)\n\t\t? (preferredUnit as TemperatureUnit)\n\t\t: defaultUnit\n\n\treturn [unit, setUnit]\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-tor-enabled.ts",
    "content": "import {toast} from '@/components/ui/toast'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function useTorEnabled({onSuccess}: {onSuccess?: (enabled: boolean) => void} = {}) {\n\tconst utils = trpcReact.useUtils()\n\n\tconst torEnabledQ = trpcReact.apps.getTorEnabled.useQuery()\n\n\tconst setMut = trpcReact.apps.setTorEnabled.useMutation({\n\t\tonSuccess: (enabled) => {\n\t\t\tutils.apps.getTorEnabled.invalidate()\n\t\t\tonSuccess?.(enabled)\n\t\t},\n\t\tonError: (err) => {\n\t\t\ttoast.error(t('tor-error', {message: err.message}))\n\t\t},\n\t})\n\n\treturn {\n\t\tenabled: torEnabledQ.data,\n\t\tsetEnabled: (enabled: boolean) => setMut.mutate(enabled),\n\t\tisLoading: torEnabledQ.isLoading || setMut.isPending,\n\t\tisMutLoading: setMut.isPending,\n\t\tisError: setMut.isError,\n\t\terror: setMut.error,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-update-all-apps.ts",
    "content": "import {useAllAvailableApps} from '@/providers/available-apps'\nimport {trpcReact} from '@/trpc/trpc'\n\nexport function useUpdateAllApps() {\n\tconst allAvailableApps = useAllAvailableApps()\n\tconst utils = trpcReact.useUtils()\n\tconst appsQ = trpcReact.apps.list.useQuery()\n\tconst updateMut = trpcReact.apps.update.useMutation({\n\t\tonMutate: () => {\n\t\t\t// Optimistic updates because otherwise it's too slow and feels like nothing is happening\n\t\t\tutils.apps.state.cancel()\n\t\t\tallAvailableApps?.apps?.map((app) => {\n\t\t\t\tutils.apps.state.setData({appId: app.id}, {state: 'updating', progress: 0})\n\t\t\t})\n\t\t},\n\t\tonSuccess: () => utils.apps.list.invalidate(),\n\t})\n\n\tconst updateAll = () => {\n\t\tconst apps = appsQ.data ?? []\n\t\t// @ts-expect-error `version`\n\t\tconst appsWithUpdates = apps.filter((app) => allAvailableApps.appsKeyed?.[app.id]?.version !== app.version)\n\n\t\tappsWithUpdates.map((app) => updateMut.mutate({appId: app.id}))\n\t}\n\n\tconst isLoading = appsQ.isLoading || allAvailableApps.isLoading\n\tconst isUpdating = updateMut.isPending\n\n\treturn {updateAll, isLoading, isUpdating}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-user-name.ts",
    "content": "import {useState} from 'react'\n\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {sleep} from '@/utils/misc'\n\nexport function useUserName({onSuccess}: {onSuccess: () => void}) {\n\tconst userQ = trpcReact.user.get.useQuery()\n\n\tconst [name, setName] = useState(userQ.data?.name)\n\tconst [localError, setLocalError] = useState('')\n\n\tconst utils = trpcReact.useUtils()\n\n\tconst setMut = trpcReact.user.set.useMutation({\n\t\tonSuccess: async () => {\n\t\t\tawait sleep(500)\n\t\t\tutils.user.get.invalidate()\n\t\t\tonSuccess()\n\t\t},\n\t})\n\n\tconst handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n\t\te.preventDefault()\n\n\t\t// Reset errors\n\t\tsetMut.reset()\n\n\t\t// So setLocalError('') is not batched\n\t\tawait setLocalError('')\n\n\t\tif (!name) {\n\t\t\tsetLocalError(t('change-name.failed.name-required'))\n\t\t\treturn\n\t\t}\n\n\t\tsetMut.mutate({name})\n\t}\n\n\tconst remoteFormError = !setMut.error?.data?.zodError && setMut.error?.message\n\tconst formError = localError || remoteFormError\n\n\treturn {\n\t\tname,\n\t\tsetName,\n\t\thandleSubmit,\n\t\tformError,\n\t\tisLoading: setMut.isPending,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-version.ts",
    "content": "import {trpcReact} from '@/trpc/trpc'\n\nexport function useVersion() {\n\tconst {isLoading, data} = trpcReact.system.version.useQuery()\n\tif (isLoading || !data)\n\t\treturn {isLoading: true, version: undefined, name: undefined, manifestVersion: undefined} as const\n\treturn {\n\t\tisLoading: false,\n\t\t...data,\n\t} as const\n}\n"
  },
  {
    "path": "packages/ui/src/hooks/use-widgets.ts",
    "content": "// TODO: move to widgets module\nimport {useState} from 'react'\n\nimport {filesWidgets} from '@/features/files/widgets'\nimport {liveUsageWidgets, MAX_WIDGETS} from '@/modules/widgets/shared/constants'\nimport {systemAppsKeyed, useApps} from '@/providers/apps'\nimport {AppState, trpcReact} from '@/trpc/trpc'\n\nexport function useWidgets() {\n\t// Consider having `selectedTooMany` outside this hook\n\tconst [selectedTooMany, setSelectedTooMany] = useState(false)\n\tconst apps = useApps()\n\n\tconst {selected, enable, disable, isLoading: isSelectedLoading} = useEnableWidgets()\n\tconst isLoading = apps.isLoading || isSelectedLoading\n\n\tconst availableUserAppWidgets = apps.userApps\n\t\t? apps.userApps\n\t\t\t\t// Don't want to allow users to select widgets while installing\n\t\t\t\t// But after done installing, the app might not be reachable, but we still want to\n\t\t\t\t// show its widgets.\n\t\t\t\t.filter((app) => app.state !== 'installing')\n\t\t\t\t.map((app) => ({\n\t\t\t\t\tappId: app.id,\n\t\t\t\t\ticon: app.icon,\n\t\t\t\t\tname: app.name,\n\t\t\t\t\tstate: app.state,\n\t\t\t\t\twidgets: app.widgets?.map((w) => ({...w, id: app.id + ':' + w.id})) ?? [],\n\t\t\t\t}))\n\t\t: []\n\n\t// NOTE: the backend Umbrel system widgets always have an `umbrel:` prefix. For now this is good\n\t// because it means we can associate them with any system app. It used to be that some system widgets\n\t// were in the `settings` app. But they were moved to a new `live-usage` app.\n\tconst availableSystemWidgets = [\n\t\t{\n\t\t\tappId: 'live-usage',\n\t\t\ticon: systemAppsKeyed['UMBREL_live-usage'].icon,\n\t\t\tname: systemAppsKeyed['UMBREL_live-usage'].name,\n\t\t\tstate: 'ready' as const satisfies AppState,\n\t\t\twidgets: liveUsageWidgets,\n\t\t},\n\n\t\t// features/files widgets\n\t\t{\n\t\t\tappId: 'files',\n\t\t\ticon: systemAppsKeyed['UMBREL_files'].icon,\n\t\t\tname: systemAppsKeyed['UMBREL_files'].name,\n\t\t\tstate: 'ready' as const satisfies AppState,\n\t\t\twidgets: filesWidgets,\n\t\t},\n\t]\n\n\tconst availableWidgets = apps.userApps\n\t\t? [...availableSystemWidgets, ...availableUserAppWidgets].filter(({widgets}) => widgets?.length)\n\t\t: []\n\n\t// No need to specify app id because widget endpoints are unique\n\t// TODO: don't call it `toggle` because it's not a toggle\n\tconst toggleSelected = (widgetId: string, checked: boolean) => {\n\t\tif (selected.length >= MAX_WIDGETS && checked) {\n\t\t\tsetSelectedTooMany(true)\n\t\t\tsetTimeout(() => setSelectedTooMany(false), 500)\n\t\t\treturn\n\t\t}\n\t\tsetSelectedTooMany(false)\n\t\tif (selected.includes(widgetId)) {\n\t\t\tdisable(widgetId)\n\t\t} else {\n\t\t\tenable(widgetId)\n\t\t}\n\t}\n\n\tconst appFromWidgetId = (id: string) => {\n\t\treturn availableWidgets.find((app) => app.widgets?.find((widget) => widget.id === id))\n\t}\n\n\tconst selectedWithAppInfo = selected\n\t\t.filter((id) => {\n\t\t\tconst app = appFromWidgetId(id)\n\t\t\treturn !!app\n\t\t})\n\t\t.map((id) => {\n\t\t\t// Expect app to be found because we filtered out widgets without apps\n\t\t\tconst app = appFromWidgetId(id)!\n\n\t\t\t// Assume we'll always find a widget\n\t\t\tconst widget = app.widgets.find((w) => w.id === id)!\n\n\t\t\treturn {\n\t\t\t\t...widget,\n\t\t\t\tapp: {\n\t\t\t\t\tid: app.appId,\n\t\t\t\t\ticon: app.icon,\n\t\t\t\t\tname: app.name,\n\t\t\t\t\tstate: app.state,\n\t\t\t\t},\n\t\t\t}\n\t\t})\n\n\treturn {\n\t\tavailableWidgets,\n\t\tselected: selectedWithAppInfo,\n\t\ttoggleSelected,\n\t\tselectedTooMany,\n\t\tisLoading,\n\t}\n}\n\nfunction useEnableWidgets() {\n\tconst utils = trpcReact.useUtils()\n\tconst widgetQ = trpcReact.widget.enabled.useQuery()\n\n\tconst enableMut = trpcReact.widget.enable.useMutation({\n\t\tonSuccess: () => {\n\t\t\tutils.user.invalidate()\n\t\t\tutils.widget.enabled.invalidate()\n\t\t},\n\t})\n\n\tconst disableMut = trpcReact.widget.disable.useMutation({\n\t\tonSuccess: () => {\n\t\t\tutils.user.invalidate()\n\t\t\tutils.widget.enabled.invalidate()\n\t\t},\n\t})\n\n\tconst selected = widgetQ.data ?? []\n\t// const setSelected = (widgets: WidgetT[]) => enableMut.mutate({widgets})\n\n\tconst isLoading = widgetQ.isLoading || enableMut.isPending\n\n\treturn {\n\t\tisLoading,\n\t\tselected,\n\t\tenable: (widgetId: string) => enableMut.mutate({widgetId}),\n\t\tdisable: (widgetId: string) => disableMut.mutate({widgetId}),\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/index.css",
    "content": "@import 'tailwindcss';\n@import 'tw-animate-css';\n\n@config '../tailwind.config.ts';\n\n/*\n  The default border color has changed to `currentcolor` in Tailwind CSS v4,\n  so we've added these compatibility styles to make sure everything still\n  looks the same as it did with Tailwind CSS v3.\n\n  If we ever want to remove these styles, we need to add an explicit border\n  color utility to any element that depends on these defaults.\n*/\n@layer base {\n\t*,\n\t::after,\n\t::before,\n\t::backdrop,\n\t::file-selector-button {\n\t\tborder-color: var(--color-gray-200, currentcolor);\n\t}\n\n\t/* Tailwind v4 removed antialiased from preflight */\n\tbody {\n\t\t@apply antialiased;\n\t}\n}\n\n::view-transition-old(root),\n::view-transition-new(root) {\n\tanimation-duration: 0.1s;\n}\n\nhtml,\nbody {\n\tcursor: default;\n\t/* Prevent text selection and text cursor by default for native OS feel */\n\t-webkit-user-select: none;\n\tuser-select: none;\n\t/* To match Figma weight */\n\ttext-rendering: geometricPrecision;\n\t@apply -tracking-3 text-white/90;\n}\n\n/* Prevent image and link dragging for native OS feel */\nimg,\na {\n\t-webkit-user-drag: none;\n}\n\n/* Override browser default cursor: pointer on interactive elements for native OS feel */\na,\nbutton,\n[role='button'] {\n\tcursor: default;\n}\n\nhtml {\n\t--font-inter: 'Inter';\n\t/* Space above the sheet. Putting here because it's used in multiple places for calculations */\n\t--sheet-top: 2vh;\n\t--wallpaper-blur: 12px;\n}\n\n/* Global scrollbar styling */\n/* We set this here because some components cannot easily use ScrollArea or FadeScroller components (e.g., react-window) */\n::-webkit-scrollbar {\n\tbackground: transparent; /* Transparent track by default */\n\twidth: 11px;\n}\n\n::-webkit-scrollbar-track {\n\tbackground: transparent; /* Transparent track by default */\n}\n\n/* Show track background on hover */\n::-webkit-scrollbar:hover {\n\tbackground: rgba(255, 255, 255, 0.05);\n}\n\n::-webkit-scrollbar-thumb {\n\tborder: 4px solid transparent; /* Key trick for obtaining a thin scrollbar */\n\tborder-radius: 20px;\n\tbackground-clip: padding-box; /* Ensure the background only applies within the content box */\n\tbackground-color: rgba(255, 255, 255, 0.15);\n}\n\n::-webkit-scrollbar-thumb:hover {\n\tbackground-color: rgba(255, 255, 255, 0.4);\n}\n\n@supports (font-variation-settings: normal) {\n\thtml {\n\t\t--font-inter: 'Inter var';\n\t}\n}\n\n::selection {\n\t@apply bg-brand text-white;\n}\n\n/* https://stackoverflow.com/a/45050338 */\n@layer base {\n\tinput,\n\ttextarea,\n\tbutton,\n\tselect,\n\ta {\n\t\t-webkit-tap-highlight-color: transparent;\n\t}\n}\n\n/* Hide scrollbar for Chrome, Safari and Opera */\n.umbrel-hide-scrollbar::-webkit-scrollbar {\n\tdisplay: none;\n}\n\n/* Hide scrollbar for IE, Edge and Firefox */\n.umbrel-hide-scrollbar {\n\t-ms-overflow-style: none; /* IE and Edge */\n\tscrollbar-width: none; /* Firefox */\n}\n\n.umbrel-button:active {\n\tscale: 0.97;\n}\n\n@property --distance1 {\n\tsyntax: '<length>'; /* <- defined as type length for the transition to work */\n\tinitial-value: 0;\n\tinherits: false;\n}\n\n@property --distance2 {\n\tsyntax: '<length>'; /* <- defined as type length for the transition to work */\n\tinitial-value: 0;\n\tinherits: false;\n}\n\n.umbrel-fade-scroller-x,\n.umbrel-fade-scroller-y {\n\t/* 8ms animates in 5 frames assuming 60fps */\n\ttransition:\n\t\t--distance1 80ms ease-out,\n\t\t--distance2 80ms ease-out;\n}\n\n.umbrel-fade-scroller-y {\n\tmask-image: linear-gradient(to bottom, transparent, red var(--distance1) calc(100% - var(--distance2)), transparent);\n}\n\n.umbrel-fade-scroller-x {\n\tmask-image: linear-gradient(to right, transparent, red var(--distance1) calc(100% - var(--distance2)), transparent);\n}\n\n.umbrel-wallpaper-fade-scroller {\n\tmask-image: linear-gradient(to right, transparent, red 16px calc(100% - 16px), transparent);\n}\n\n.umbrel-dialog-fade-scroller {\n\tmask-image: linear-gradient(to bottom, transparent, red 32px calc(100% - 32px), transparent);\n}\n\n.umbrel-cmdk-fade-scroller {\n\tmask-image: linear-gradient(to bottom, transparent, red 20px calc(100% - 20px), transparent);\n}\n\n/* Targets the correct div inside the react-window component */\n.umbrel-files-fade-scroller > div > div {\n\tmask-image: linear-gradient(to bottom, red 0, red calc(100% - 24px), transparent);\n}\n\n/* Only fade the top when a user has scrolled */\n.umbrel-files-fade-scroller.scrolled > div > div {\n\tmask-image: linear-gradient(to bottom, transparent 0, red 24px, red calc(100% - 24px), transparent 100%);\n}\n\n.umbrel-divide-y > * {\n\t@apply relative;\n}\n.umbrel-divide-y > * + *::before {\n\tcontent: '';\n\t@apply absolute inset-x-0 top-0 h-px w-full bg-linear-to-r from-transparent via-white/5 to-transparent;\n}\n\n@keyframes blink-caret {\n\t50% {\n\t\tbackground: transparent;\n\t}\n}\n\n@keyframes pulse-border {\n\t50% {\n\t\tbox-shadow: var(--segment-color) 0 0 0 1px;\n\t\tborder-color: var(--segment-color);\n\t}\n}\n\n@keyframes slide-down {\n\tto {\n\t\ttransform: translateY(100px);\n\t}\n}\n\n@keyframes fade-out {\n\tto {\n\t\topacity: 0;\n\t}\n}\n\n@keyframes fade-in {\n\tto {\n\t\topacity: 1;\n\t}\n}\n\n@keyframes bounce-gradient {\n\t0%,\n\t100% {\n\t\tbackground-position: 0% 50%;\n\t}\n\t50% {\n\t\tbackground-position: 100% 50%;\n\t}\n}\n\n.umbrel-bouncing-gradient {\n\tanimation: bounce-gradient 2s ease infinite;\n\tbackground: linear-gradient(to right, transparent, white, transparent);\n\tbackground-size: 200% 100%;\n}\n\n[cmdk-list] {\n\ttransition: height 100ms ease;\n\t/* height: var(--cmdk-list-height); */\n\t/* min-height: 200px; */\n\tmax-height: 500px;\n\t/* scroll-padding-block-start: 20px; */\n\t/* scroll-padding-block-end: 20px; */\n}\n\n[cmdk-item] + [cmdk-item] {\n\t@apply mt-1 md:mt-2.5;\n}\n\n@keyframes animate-arc {\n\tfrom {\n\t\tstroke-dashoffset: var(--full-length);\n\t\tstroke-opacity: 0;\n\t}\n\tto {\n\t\tstroke-dashoffset: var(--final-offset);\n\t\tstroke-opacity: 1;\n\t}\n}\n\n@keyframes animate-unblur {\n\tfrom {\n\t\tscale: 1.25;\n\t\tfilter: blur(var(--wallpaper-blur));\n\t}\n\tto {\n\t\tscale: 1;\n\t\tfilter: blur(0);\n\t}\n}\n\n@keyframes pulse {\n\t0% {\n\t\topacity: 1;\n\t}\n\t50% {\n\t\topacity: 0.5;\n\t}\n\t100% {\n\t\topacity: 1;\n\t}\n}\n\n.umbrel-pulse {\n\tanimation-duration: 1s;\n\tanimation-timing-function: ease-in-out;\n\tanimation-iteration-count: infinite;\n\tanimation-fill-mode: forwards;\n\tanimation-name: pulse;\n}\n\n@keyframes pulse-a-few-times {\n\t0% {\n\t\topacity: 1;\n\t}\n\t20% {\n\t\topacity: 1;\n\t}\n\t40% {\n\t\topacity: 0.5;\n\t}\n\t60% {\n\t\topacity: 1;\n\t}\n\t80% {\n\t\topacity: 0.5;\n\t}\n\t100% {\n\t\topacity: 1;\n\t}\n}\n\n.umbrel-pulse-a-few-times {\n\tanimation-duration: 1s;\n\tanimation-timing-function: ease-in-out;\n\tanimation-iteration-count: 1;\n\tanimation-fill-mode: forwards;\n\tanimation-name: pulse-a-few-times;\n}\n\n/* Override Tailwind truncate class because  */\n/* https://github.com/tailwindlabs/tailwindcss/issues/10939 */\n.truncate {\n\toverflow: initial;\n\toverflow-x: hidden; /* for older browsers, though top and bottom of text in `tight-inter-trim` is going to be clipped at the top and bottom */\n\toverflow-x: clip;\n}\n\n/* Fixes action button not aligning to the right when content is too short */\n[data-sonner-toaster] [data-content] {\n\t@apply w-full;\n}\n\n.xterm .xterm-viewport {\n\tbackground-color: transparent !important;\n}\n\n.xterm .xterm-viewport {\n\t-ms-overflow-style: none; /* IE and Edge */\n\tscrollbar-width: none; /* Firefox */\n}\n\n/* Permanent Marker self-hosted font */\n@font-face {\n\tfont-style: normal;\n\tfont-weight: 400;\n\tsrc: url('/fonts/permanent-marker.woff2') format('woff2');\n\tfont-family: 'Permanent Marker';\n\tfont-display: swap;\n}\n"
  },
  {
    "path": "packages/ui/src/init.tsx",
    "content": "import 'inter-ui/inter.css'\nimport './index.css'\nimport './utils/i18n'\n\nimport i18next from 'i18next'\nimport React, {Suspense} from 'react'\nimport ReactDOM from 'react-dom/client'\nimport {ErrorBoundary} from 'react-error-boundary'\n\nimport {IframeChecker} from '@/components/iframe-checker'\nimport {CoverMessageTarget} from '@/components/ui/cover-message'\nimport {RootErrorFallback} from '@/components/ui/root-error-fallback'\nimport {Toaster} from '@/components/ui/toast'\nimport {TooltipProvider} from '@/components/ui/tooltip'\nimport {monkeyPatchConsoleLog} from '@/utils/logs'\n\nmonkeyPatchConsoleLog()\n\n// Disable default browser context menu\ndocument.addEventListener(\n\t'contextmenu',\n\t(event) => {\n\t\tevent.preventDefault()\n\t\treturn false\n\t},\n\t{passive: false},\n)\n\nexport function init(element: React.ReactNode) {\n\ti18next.on('initialized', () => {\n\t\t// React 19 error callbacks — centralized logging for all React errors.\n\t\t// These feed into the monkey-patched console.error, so errors are captured\n\t\t// in the downloadable log buffer even when error boundaries silently swallow them.\n\t\t// onUncaughtError and onRecoverableError have no react-error-boundary equivalent.\n\t\tReactDOM.createRoot(document.getElementById('root')!, {\n\t\t\tonCaughtError(error, errorInfo) {\n\t\t\t\tconsole.error('Caught by error boundary:', error)\n\t\t\t\tconsole.error('Component stack:', errorInfo.componentStack)\n\t\t\t},\n\t\t\tonUncaughtError(error, errorInfo) {\n\t\t\t\tconsole.error('Uncaught React error:', error)\n\t\t\t\tconsole.error('Component stack:', errorInfo.componentStack)\n\t\t\t},\n\t\t\tonRecoverableError(error, errorInfo) {\n\t\t\t\tconsole.error('Recoverable React error:', error)\n\t\t\t\tconsole.error('Component stack:', errorInfo.componentStack)\n\t\t\t},\n\t\t}).render(\n\t\t\t<React.StrictMode>\n\t\t\t\t<IframeChecker>\n\t\t\t\t\t<Suspense>\n\t\t\t\t\t\t<ErrorBoundary fallbackRender={({error}) => <RootErrorFallback error={error} />}>\n\t\t\t\t\t\t\t<TooltipProvider>\n\t\t\t\t\t\t\t\t{element}\n\t\t\t\t\t\t\t\t<Toaster />\n\t\t\t\t\t\t\t\t{/* Put `CoverMessageTarget` after `Toaster` because we don't want toasts to show up on these pages */}\n\t\t\t\t\t\t\t\t<CoverMessageTarget />\n\t\t\t\t\t\t\t</TooltipProvider>\n\t\t\t\t\t\t</ErrorBoundary>\n\t\t\t\t\t</Suspense>\n\t\t\t\t</IframeChecker>\n\t\t\t</React.StrictMode>,\n\t\t)\n\t})\n}\n"
  },
  {
    "path": "packages/ui/src/layouts/README.md",
    "content": "These are components that have an <Outlet />\n"
  },
  {
    "path": "packages/ui/src/layouts/app-store.tsx",
    "content": "import {motion} from 'motion/react'\nimport {memo, useDeferredValue, useEffect, useMemo, useRef, useState} from 'react'\nimport {TbDots, TbSearch} from 'react-icons/tb'\nimport {Link, Outlet, useSearchParams} from 'react-router-dom'\n\nimport {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'\nimport {Loading} from '@/components/ui/loading'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {cn} from '@/lib/utils'\nimport {CommunityAppStoreDialog} from '@/modules/app-store/community-app-store-dialog'\nimport {AppWithDescription} from '@/modules/app-store/discover/apps-grid-section'\nimport {\n\tappsGridClass,\n\tAppStoreSheetInner,\n\tcardFaintClass,\n\tsectionTitleClass,\n\tslideInFromBottomClass,\n} from '@/modules/app-store/shared'\nimport {UpdatesButton} from '@/modules/app-store/updates-button'\nimport {useAvailableApps} from '@/providers/available-apps'\nimport {t} from '@/utils/i18n'\nimport {createSearch} from '@/utils/search'\n\nexport function AppStoreLayout() {\n\tconst title = t('app-store.title')\n\n\tconst [searchParams, setSearchParams] = useSearchParams()\n\tconst [searchQuery, setSearchQuery] = useState(searchParams.get('q') ?? '')\n\tconst deferredSearchQuery = useDeferredValue(searchQuery)\n\n\tconst inputRef = useRef<HTMLInputElement>(null)\n\n\t// Remember query as part of the URL so we can navigate back to the results\n\tuseEffect(() => {\n\t\tif (deferredSearchQuery) searchParams.set('q', deferredSearchQuery)\n\t\telse searchParams.delete('q')\n\t\tsetSearchParams(searchParams, {replace: true})\n\t}, [deferredSearchQuery])\n\n\t// '/' shortcut to focus the search input\n\tuseEffect(() => {\n\t\tconst handler = (e: KeyboardEvent) => {\n\t\t\tif (e.key !== '/') return\n\t\t\tconst target = e.target as HTMLElement\n\t\t\tif (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable)\n\t\t\t\treturn\n\t\t\te.preventDefault()\n\t\t\tinputRef.current?.focus()\n\t\t}\n\t\twindow.addEventListener('keydown', handler)\n\t\treturn () => window.removeEventListener('keydown', handler)\n\t}, [])\n\n\treturn (\n\t\t<AppStoreSheetInner\n\t\t\ttitle={title}\n\t\t\ttitleRightChildren={\n\t\t\t\t<motion.div layout className='flex max-w-full flex-1 flex-row-reverse items-center gap-3'>\n\t\t\t\t\t<CommunityAppsDropdown />\n\t\t\t\t\t<UpdatesButton />\n\t\t\t\t\t<div className='flex-1 md:hidden' />\n\t\t\t\t\t<SearchInput inputRef={inputRef} value={searchQuery} onValueChange={setSearchQuery} />\n\t\t\t\t</motion.div>\n\t\t\t}\n\t\t>\n\t\t\t{deferredSearchQuery ? <SearchResultsMemoized query={deferredSearchQuery} /> : <Outlet />}\n\t\t</AppStoreSheetInner>\n\t)\n}\n\nfunction SearchInput({\n\tvalue,\n\tonValueChange,\n\tinputRef,\n}: {\n\tvalue: string\n\tonValueChange: (query: string) => void\n\tinputRef?: React.Ref<HTMLInputElement>\n}) {\n\treturn (\n\t\t<div className='-ml-2 flex min-w-0 items-center rounded-full border border-transparent bg-transparent pl-2 transition-colors focus-within:border-white/5 focus-within:bg-white/6 hover:border-white/5 hover:bg-white/6'>\n\t\t\t<TbSearch className='h-4 w-4 shrink-0 opacity-50' />\n\t\t\t{/* Set specific input width so it's consistent across browsers */}\n\t\t\t<input\n\t\t\t\tref={inputRef}\n\t\t\t\tclassName='w-[160px] bg-transparent p-1 text-15 outline-hidden placeholder:text-white/40'\n\t\t\t\tplaceholder={t('app-store.search-apps')}\n\t\t\t\tvalue={value}\n\t\t\t\tonChange={(e) => onValueChange(e.target.value)}\n\t\t\t\t// Two-stage Escape: first clears the query, second blurs the input\n\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\tif (e.key === 'Escape') {\n\t\t\t\t\t\tif (value) {\n\t\t\t\t\t\t\tonValueChange('')\n\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\te.currentTarget.blur()\n\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n\nfunction CommunityAppsDropdown() {\n\tconst {addLinkSearchParams} = useQueryParams()\n\treturn (\n\t\t<>\n\t\t\t<DropdownMenu>\n\t\t\t\t{/* tabIndex={-1} because we want user to be able to tab to results */}\n\t\t\t\t<DropdownMenuTrigger tabIndex={-1}>\n\t\t\t\t\t<TbDots className='h-5 w-5' />\n\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t<DropdownMenuContent align='end'>\n\t\t\t\t\t<DropdownMenuItem asChild>\n\t\t\t\t\t\t<Link to={{search: addLinkSearchParams({dialog: 'add-community-store'})}}>\n\t\t\t\t\t\t\t{t('app-store.menu.community-app-stores')}\n\t\t\t\t\t\t</Link>\n\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t</DropdownMenuContent>\n\t\t\t</DropdownMenu>\n\t\t\t<CommunityAppStoreDialog />\n\t\t</>\n\t)\n}\n\nfunction SearchResults({query}: {query: string}) {\n\tconst {isLoading, apps} = useAvailableApps()\n\n\tconst search = useMemo(\n\t\t() =>\n\t\t\tcreateSearch(apps ?? [], [\n\t\t\t\t{\n\t\t\t\t\tname: 'name',\n\t\t\t\t\tweight: 3,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname: 'tagline',\n\t\t\t\t\tweight: 1,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname: 'description',\n\t\t\t\t\tweight: 1,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname: 'website',\n\t\t\t\t\tweight: 1,\n\t\t\t\t},\n\t\t\t]),\n\t\t[apps],\n\t)\n\n\tconst appResults = search(query)\n\n\tif (isLoading) {\n\t\treturn <Loading />\n\t}\n\n\tconst title = (\n\t\t<span>\n\t\t\t<span className='opacity-60'>{t('app-store.search.results-for')}</span> {query}\n\t\t</span>\n\t)\n\n\treturn (\n\t\t<div className={cn(cardFaintClass, slideInFromBottomClass)}>\n\t\t\t<h3 className={cn(sectionTitleClass, 'p-2.5')}>{title}</h3>\n\t\t\t<div className={appsGridClass}>\n\t\t\t\t{appResults?.map((app) => (\n\t\t\t\t\t<AppWithDescription key={app.id} app={app} />\n\t\t\t\t))}\n\t\t\t</div>\n\t\t\t{(!appResults || appResults.length === 0) && <NoResults />}\n\t\t</div>\n\t)\n}\n\nconst NoResults = () => (\n\t<div className='py-4 text-center'>\n\t\t<span className='opacity-50'>{t('app-store.search.no-results')}</span> 👀\n\t</div>\n)\n\nconst SearchResultsMemoized = memo(SearchResults)\n"
  },
  {
    "path": "packages/ui/src/layouts/bare/bare-page.tsx",
    "content": "import {DarkenLayer} from '@/components/darken-layer'\nimport {Wallpaper} from '@/providers/wallpaper'\n\nexport function BarePage({children}: {children: React.ReactNode}) {\n\treturn (\n\t\t<>\n\t\t\t<Wallpaper stayBlurred />\n\t\t\t<DarkenLayer />\n\t\t\t<div className='relative flex min-h-[100dvh] flex-col items-center justify-between p-5'>{children}</div>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/layouts/bare/bare.tsx",
    "content": "import {Suspense} from 'react'\nimport {Outlet} from 'react-router-dom'\n\nimport {BarePage} from '@/layouts/bare/bare-page'\n\nexport function BareLayout() {\n\treturn (\n\t\t<BarePage>\n\t\t\t<Suspense>\n\t\t\t\t<Outlet />\n\t\t\t</Suspense>\n\t\t</BarePage>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/layouts/bare/onboarding-page.tsx",
    "content": "import {motion} from 'motion/react'\nimport {useLocation} from 'react-router-dom'\n\nimport {OnboardingBackground} from '@/components/onboarding-background'\n\nexport function OnboardingPage({children}: {children: React.ReactNode}) {\n\tconst location = useLocation()\n\n\tconst shouldAnimate = location.pathname === '/onboarding'\n\tconst cardProps = shouldAnimate\n\t\t? ({\n\t\t\t\tinitial: {opacity: 0, scale: 1.15},\n\t\t\t\tanimate: {opacity: 1, scale: 1},\n\t\t\t\ttransition: {\n\t\t\t\t\tduration: 2.5,\n\t\t\t\t\tdelay: 1.5,\n\t\t\t\t\tease: [0.16, 1, 0.3, 1],\n\t\t\t\t},\n\t\t\t} as const)\n\t\t: ({} as const)\n\n\treturn (\n\t\t<>\n\t\t\t<OnboardingBackground />\n\t\t\t<div className='relative flex min-h-dvh items-center justify-center p-0 md:p-5'>\n\t\t\t\t<motion.div\n\t\t\t\t\tclassName='flex min-h-dvh w-full max-w-none flex-col rounded-none bg-[#1E1E1E]/20 p-3 backdrop-blur-2xl md:max-h-[850px] md:min-h-[700px] md:max-w-[1000px] md:rounded-3xl md:bg-[#1E1E1E]/70 md:p-6'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tboxShadow: '0px 24px 48px 0px #000000A3, inset 1px 1px 1px 0px #FFFFFF1F',\n\t\t\t\t\t\tviewTransitionName: 'onboarding-card',\n\t\t\t\t\t}}\n\t\t\t\t\t{...cardProps}\n\t\t\t\t>\n\t\t\t\t\t{children}\n\t\t\t\t</motion.div>\n\t\t\t</div>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/layouts/bare/onboarding.tsx",
    "content": "import {Suspense} from 'react'\nimport {Outlet} from 'react-router-dom'\n\nimport {OnboardingPage} from '@/layouts/bare/onboarding-page'\n\nexport function OnboardingLayout() {\n\treturn (\n\t\t<OnboardingPage>\n\t\t\t<Suspense>\n\t\t\t\t<Outlet />\n\t\t\t</Suspense>\n\t\t</OnboardingPage>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/layouts/bare/shared.tsx",
    "content": "import {motion} from 'motion/react'\nimport {HTMLProps} from 'react'\n\nimport UmbrelLogo from '@/components/umbrel-logo'\nimport {cn} from '@/lib/utils'\nimport {tw} from '@/utils/tw'\n\nexport const UmbrelLogoLarge = () => (\n\t<UmbrelLogo className='w-[100px] opacity-85' style={{viewTransitionName: 'umbrel-logo'}} />\n)\n\nexport function Title({children}: {children: React.ReactNode}) {\n\treturn (\n\t\t<h1\n\t\t\tclassName='text-center text-[32px] leading-tight font-bold -tracking-2 opacity-85'\n\t\t\tstyle={{\n\t\t\t\tviewTransitionName: 'title',\n\t\t\t\ttextShadow: '0 0 8px rgba(255, 255, 255, 0.2), 0 0 16px rgba(255, 255, 255, 0.15)',\n\t\t\t}}\n\t\t>\n\t\t\t{children}\n\t\t</h1>\n\t)\n}\n\nexport function SubTitle({\n\tchildren,\n\tclassName,\n\t...props\n}: {\n\tchildren: React.ReactNode\n\tclassName?: string\n} & HTMLProps<HTMLParagraphElement>) {\n\treturn (\n\t\t<p className={cn('text-center text-[15px] font-medium text-white/70', className)} {...props}>\n\t\t\t{children}\n\t\t</p>\n\t)\n}\n\nexport const footerClass = tw`flex items-center justify-center gap-4`\nexport const footerLinkClass = tw`text-13 transition-colors font-normal text-white/60 -tracking-3 hover:text-white/80 focus:outline-hidden focus-visible:ring-3`\n\nexport const buttonClass = tw`flex h-[42px] items-center rounded-full bg-white/75 px-4 text-14 font-medium -tracking-1 text-black ring-white/40 transition-all duration-300 hover:bg-white/85 focus:outline-hidden focus-visible:ring-3 active:scale-100 active:bg-white/90 min-w-[112px] justify-center disabled:pointer-events-none disabled:opacity-50`\nexport const primaryButtonProps = {\n\tclassName: buttonClass,\n\tstyle: {boxShadow: '0px 2px 4px 0px #FFFFFF inset'},\n} as const\nexport const secondaryButtonClasss = tw`flex h-[42px] items-center rounded-full bg-neutral-600/40 backdrop-blur-xs px-4 text-14 font-medium -tracking-1 text-white ring-white/40 transition-all duration-300 hover:bg-neutral-600/60 focus:outline-hidden focus-visible:ring-3 active:scale-100 active:bg-neutral-600/60 min-w-[112px] justify-center disabled:pointer-events-none disabled:opacity-50`\n\nexport const formGroupClass = tw`flex w-full max-w-sm flex-col gap-2.5`\n\n// Think of it as a helper component to make it easier to be consistent between pages. It's a brittle abtraction that\n// shouldn't be taken too far.\nexport function Layout({\n\ttitle,\n\tsubTitle,\n\tsubTitleMaxWidth,\n\tsubTitleClassName,\n\tchildren,\n\tfooter,\n\tanimate,\n\tshowLogo = true,\n}: {\n\ttitle: string\n\tsubTitle: React.ReactNode\n\tsubTitleMaxWidth?: number\n\tsubTitleClassName?: string\n\tchildren: React.ReactNode\n\tfooter?: React.ReactNode\n\tanimate?: boolean\n\t/** Hide logo when showing device image */\n\tshowLogo?: boolean\n}) {\n\tconst footerAnimationProps = animate\n\t\t? ({\n\t\t\t\tinitial: {opacity: 0, x: -60, y: -40},\n\t\t\t\tanimate: {opacity: 1, x: 0, y: 0},\n\t\t\t\ttransition: {\n\t\t\t\t\tduration: 2.5,\n\t\t\t\t\tdelay: 1.5,\n\t\t\t\t\tease: [0.16, 1, 0.3, 1],\n\t\t\t\t},\n\t\t\t} as const)\n\t\t: ({} as const)\n\treturn (\n\t\t<>\n\t\t\t{/* TODO: probably want consumer to set the title */}\n\t\t\t<div className='flex-1' />\n\t\t\t<div className='flex w-full flex-col items-center gap-5'>\n\t\t\t\t{showLogo && <UmbrelLogoLarge />}\n\t\t\t\t<div className='flex flex-col items-center gap-1.5'>\n\t\t\t\t\t<Title>{title}</Title>\n\t\t\t\t\t<SubTitle className={subTitleClassName} style={{maxWidth: subTitleMaxWidth}}>\n\t\t\t\t\t\t{subTitle}\n\t\t\t\t\t</SubTitle>\n\t\t\t\t</div>\n\t\t\t\t{children}\n\t\t\t</div>\n\t\t\t<div className='flex-1' />\n\t\t\t<motion.div className={footerClass} {...footerAnimationProps}>\n\t\t\t\t{footer}\n\t\t\t</motion.div>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/layouts/desktop.tsx",
    "content": "import {useEffect} from 'react'\n\nimport {useCmdkOpen} from '@/components/cmdk'\nimport {AppSettingsDialog} from '@/modules/app-store/app-page/app-settings-dialog'\nimport {DefaultCredentialsDialog} from '@/modules/app-store/app-page/default-credentials-dialog'\nimport {DesktopContent} from '@/modules/desktop/desktop-content'\nimport {InstallFirstApp} from '@/modules/desktop/install-first-app'\nimport {DesktopWifiButtonConnected} from '@/modules/wifi/desktop-wifi-button-connected'\nimport {useApps} from '@/providers/apps'\nimport {tw} from '@/utils/tw'\n\nexport function Desktop() {\n\tconst {userApps, isLoading} = useApps()\n\n\tif (isLoading) {\n\t\treturn null\n\t}\n\n\tif (userApps?.length === 0) {\n\t\treturn <InstallFirstAppPage />\n\t}\n\n\treturn <DesktopPage />\n}\n\nfunction InstallFirstAppPage() {\n\treturn (\n\t\t<>\n\t\t\t<InstallFirstApp />\n\t\t\t<DesktopWifiButtonConnected className={topRightPositionerClass} />\n\t\t</>\n\t)\n}\n\nfunction prefetchRouteChunks() {\n\timport('@/routes/app-store/discover')\n\timport('@/routes/app-store/app-page')\n\timport('@/routes/app-store/category-page')\n\timport('@/routes/settings')\n\timport('@/features/files')\n\timport('@/routes/edit-widgets')\n}\n\nfunction DesktopPage() {\n\tconst {setOpen} = useCmdkOpen()\n\n\t// Prefetch main dock route chunks on idle so they're instant on first click.\n\t// These are static JS files — no auth required to fetch them.\n\tuseEffect(() => {\n\t\tif ('requestIdleCallback' in window) {\n\t\t\tconst id = requestIdleCallback(prefetchRouteChunks)\n\t\t\treturn () => cancelIdleCallback(id)\n\t\t}\n\t\t// Fallback for Safari (no requestIdleCallback): use a short timeout\n\t\tconst id = setTimeout(prefetchRouteChunks, 200)\n\t\treturn () => clearTimeout(id)\n\t}, [])\n\n\t// Prevent scrolling on the desktop because it interferes with `AppGridGradientMasking` and causes tearing effect\n\tuseEffect(() => {\n\t\tdocument.documentElement.style.overflow = 'hidden'\n\t\treturn () => {\n\t\t\tdocument.documentElement.style.overflow = ''\n\t\t}\n\t}, [])\n\n\treturn (\n\t\t<>\n\t\t\t<div\n\t\t\t\tclassName={\n\t\t\t\t\t// `relative` positioning keeps children above <Wallpaper /> since that element is positioned `fixed`\n\t\t\t\t\t'relative flex h-[100dvh] w-full flex-col items-center justify-between'\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t<DesktopContent onSearchClick={() => setOpen(true)} />\n\t\t\t\t<DesktopWifiButtonConnected className={topRightPositionerClass} />\n\t\t\t</div>\n\t\t\t<DefaultCredentialsDialog />\n\t\t\t<AppSettingsDialog />\n\t\t</>\n\t)\n}\n\nconst topRightPositionerClass = tw`absolute right-5 top-5 z-10`\n"
  },
  {
    "path": "packages/ui/src/layouts/sheet.tsx",
    "content": "import {Suspense, useRef, useState} from 'react'\nimport {NavigationType, Outlet, useLocation, useNavigate} from 'react-router-dom'\n\nimport {DialogCloseButton} from '@/components/ui/dialog-close-button'\nimport {Sheet, SheetContent} from '@/components/ui/sheet'\nimport {ScrollArea} from '@/components/ui/sheet-scroll-area'\nimport {useScrollRestoration} from '@/hooks/use-scroll-restoration'\nimport {DockSpacer} from '@/modules/desktop/dock'\nimport {SheetFixedTarget} from '@/modules/sheet-top-fixed'\nimport {SheetStickyHeaderProvider, SheetStickyHeaderTarget, useSheetStickyHeader} from '@/providers/sheet-sticky-header'\nimport {isFullscreenSettingsPath} from '@/routes/settings'\nimport {useAfterDelayedClose} from '@/utils/dialog'\n\n// Determine if scroll position should be restored (`true`), reset (`false`) or\n// ignored (`undefined`). SheetLayout is shared accross settings, app store and\n// so on, so we are handling multiple paths here, with the option to precisely\n// handle scroll restoration between any two paths using SheetLayout.\nconst scrollRestorationHandler = (thisPathname: string, prevPathname: string, navigationType: NavigationType) => {\n\t// Ignore scroll restoration in settings (only has dialogs)\n\tconst isSettings = /^\\/settings(\\/|$)/.test(thisPathname)\n\tif (isSettings) {\n\t\treturn 'ignore'\n\t}\n\t// Reset scroll position to zero unless going back in history\n\tif (navigationType !== 'POP') {\n\t\treturn 'reset'\n\t}\n\t// In app store, restore position when navigating back from an app\n\tconst isAppStore = /^\\/app-store(\\/|$)/.test(thisPathname)\n\tif (isAppStore) {\n\t\tconst cameFromApp = /^\\/app-store\\/[^/]+$/.test(prevPathname)\n\t\treturn cameFromApp ? 'restore' : 'reset'\n\t}\n\t// Otherwise reset scroll position to zero\n\treturn 'reset'\n}\n\nexport function SheetLayout() {\n\tconst navigate = useNavigate()\n\tconst location = useLocation()\n\n\tconst [open, setOpen] = useState(true)\n\n\tconst scrollRef = useRef<HTMLDivElement>(null)\n\n\tuseScrollRestoration(scrollRef, scrollRestorationHandler)\n\n\t// For fullscreen settings routes, render content outside the Sheet\n\tconst isFullscreenRoute = isFullscreenSettingsPath(location.pathname)\n\n\tuseAfterDelayedClose(open, () => {\n\t\t// Don't navigate away if we're on a fullscreen route\n\t\tif (!isFullscreenRoute) {\n\t\t\tnavigate('/')\n\t\t}\n\t})\n\n\treturn (\n\t\t<>\n\t\t\t{/* Render fullscreen content outside the Sheet */}\n\t\t\t{isFullscreenRoute && (\n\t\t\t\t<>\n\t\t\t\t\t{/* Immediate blur backdrop - renders before lazy component loads */}\n\t\t\t\t\t<div className='fixed inset-0 z-50 transform-gpu bg-black/30 backdrop-blur-xl will-change-[backdrop-filter]' />\n\t\t\t\t\t<Suspense fallback={null}>\n\t\t\t\t\t\t<Outlet />\n\t\t\t\t\t</Suspense>\n\t\t\t\t</>\n\t\t\t)}\n\t\t\t{/* Keep Sheet mounted but closed when on fullscreen route */}\n\t\t\t<Sheet open={open && !isFullscreenRoute} onOpenChange={setOpen} modal={false}>\n\t\t\t\t<SheetStickyHeaderProvider scrollRef={scrollRef}>\n\t\t\t\t\t<SheetContent\n\t\t\t\t\t\tside='bottom-zoom'\n\t\t\t\t\t\tclassName='mx-auto h-[calc(100dvh-var(--sheet-top))] max-w-[1320px] md:w-[calc(100vw-25px-25px)] lg:h-[calc(100dvh-60px)] lg:w-[calc(100vw-60px-60px)]'\n\t\t\t\t\t\tbackdrop={\n\t\t\t\t\t\t\topen && (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tdata-state={open ? 'open' : 'closed'}\n\t\t\t\t\t\t\t\t\tclassName='fixed inset-0 z-30'\n\t\t\t\t\t\t\t\t\tonClick={() => setOpen(false)}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcloseButton={<SheetCloseButton />}\n\t\t\t\t\t\tonOpenAutoFocus={(e) => e.preventDefault()}\n\t\t\t\t\t\tonInteractOutside={(e) => e.preventDefault()}\n\t\t\t\t\t\tonEscapeKeyDown={(e) => e.preventDefault()}\n\t\t\t\t\t>\n\t\t\t\t\t\t<SheetFixedTarget />\n\t\t\t\t\t\t<SheetStickyHeaderTarget />\n\t\t\t\t\t\t<ScrollArea className='h-full rounded-t-20' viewportRef={scrollRef}>\n\t\t\t\t\t\t\t<div className='flex flex-col gap-5 px-3 pt-6 md:px-[40px] md:pt-12 xl:px-[70px]'>\n\t\t\t\t\t\t\t\t<Suspense>\n\t\t\t\t\t\t\t\t\t<Outlet />\n\t\t\t\t\t\t\t\t</Suspense>\n\t\t\t\t\t\t\t\t<DockSpacer className='mt-4' />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</ScrollArea>\n\t\t\t\t\t</SheetContent>\n\t\t\t\t</SheetStickyHeaderProvider>\n\t\t\t</Sheet>\n\t\t</>\n\t)\n}\n\nfunction SheetCloseButton() {\n\tconst {showStickyHeader} = useSheetStickyHeader()\n\n\tif (showStickyHeader) return null\n\n\treturn <DialogCloseButton className='absolute top-2.5 right-2.5 z-50' />\n}\n"
  },
  {
    "path": "packages/ui/src/lib/utils.ts",
    "content": "import {clsx, type ClassValue} from 'clsx'\nimport {extendTailwindMerge} from 'tailwind-merge'\n\nconst num = (classPart: string) => /^\\d+$/.test(classPart)\n\nconst customTwMerge = extendTailwindMerge({\n\textend: {\n\t\tclassGroups: {\n\t\t\t// Without this, styles like text-12 don't work properly with other text-* styles\n\t\t\t'font-size': [{text: ['base', num]}],\n\t\t\t// Allows cn('rounded-12', 'rounded-20') to cause the 20 to override the 12\n\t\t\trounded: [{rounded: ['base', num]}],\n\t\t\t'border-w': [{border: ['hpx', 'px']}],\n\t\t},\n\t},\n})\n\nexport function cn(...inputs: ClassValue[]) {\n\treturn customTwMerge(clsx(inputs))\n}\n"
  },
  {
    "path": "packages/ui/src/main.tsx",
    "content": "import {RouterProvider} from 'react-router-dom'\n\nimport {PendingRaidOperationProvider} from '@/features/storage/providers/pending-operation-context'\nimport {init} from '@/init'\nimport {initTokenRenewal} from '@/modules/auth/shared'\nimport {ConfirmationProvider} from '@/providers/confirmation'\nimport {GlobalSystemStateProvider} from '@/providers/global-system-state/index'\nimport {ImmersiveDialogProvider} from '@/providers/immersive-dialog'\n\nimport {AuthBootstrap} from './providers/auth-bootstrap'\nimport {GlobalFilesProvider} from './providers/global-files'\nimport {RemoteLanguageInjector} from './providers/language'\nimport {Prefetcher} from './providers/prefetch'\nimport {RemoteWallpaperInjector, WallpaperProviderConnected} from './providers/wallpaper'\nimport {router} from './router'\nimport {TrpcProvider} from './trpc/trpc-provider'\n\ninitTokenRenewal()\n\ninit(\n\t<TrpcProvider>\n\t\t<AuthBootstrap />\n\t\t<RemoteLanguageInjector />\n\t\t{/* Wallpaper inside trpc because it requires backend call */}\n\t\t<WallpaperProviderConnected>\n\t\t\t<RemoteWallpaperInjector />\n\t\t\t<ConfirmationProvider>\n\t\t\t\t<GlobalSystemStateProvider>\n\t\t\t\t\t<GlobalFilesProvider>\n\t\t\t\t\t\t<PendingRaidOperationProvider>\n\t\t\t\t\t\t\t<ImmersiveDialogProvider>\n\t\t\t\t\t\t\t\t{/* v7_startTransition wraps navigations in React.startTransition(), which keeps the old page\n\t\t\t\t\t\t\t\tvisible while lazy components load. Without this, view transitions snapshot the Suspense\n\t\t\t\t\t\t\t\tfallback instead of the actual destination page. */}\n\t\t\t\t\t\t\t\t<RouterProvider router={router} future={{v7_startTransition: true}} />\n\t\t\t\t\t\t\t</ImmersiveDialogProvider>\n\t\t\t\t\t\t</PendingRaidOperationProvider>\n\t\t\t\t\t</GlobalFilesProvider>\n\t\t\t\t</GlobalSystemStateProvider>\n\t\t\t</ConfirmationProvider>\n\t\t</WallpaperProviderConnected>\n\t\t<Prefetcher />\n\t</TrpcProvider>,\n)\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/app-page/about-section.tsx",
    "content": "import {cn} from '@/lib/utils'\nimport {RegistryApp} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nimport {cardClass, cardTitleClass, ReadMoreMarkdownSection} from './shared'\n\nexport const AboutSection = ({app}: {app: RegistryApp}) => (\n\t<div className={cn(cardClass, 'gap-2.5')}>\n\t\t<h2 className={cardTitleClass}>{t('app-page.section.about')}</h2>\n\t\t{/* Adding key to reset state when updating content */}\n\t\t<ReadMoreMarkdownSection key={app.description}>{app.description}</ReadMoreMarkdownSection>\n\t</div>\n)\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/app-page/app-content.tsx",
    "content": "import {JSONTree} from 'react-json-tree'\nimport {isEmpty} from 'remeda'\n\nimport {DebugOnly} from '@/components/ui/debug-only'\nimport {cn} from '@/lib/utils'\nimport {AboutSection} from '@/modules/app-store/app-page/about-section'\nimport {DependenciesSection} from '@/modules/app-store/app-page/dependencies'\nimport {InfoSection} from '@/modules/app-store/app-page/info-section'\nimport {RecommendationsSection} from '@/modules/app-store/app-page/recommendations-section'\nimport {ReleaseNotesSection} from '@/modules/app-store/app-page/release-notes-section'\nimport {AppGallerySection} from '@/modules/app-store/gallery-section'\nimport {RegistryApp, UserApp} from '@/trpc/trpc'\n\nimport {SettingsSection} from './settings-section'\n\nexport function AppContent({\n\tapp,\n\tuserApp,\n\trecommendedApps = [],\n\tshowDependencies,\n}: {\n\tapp: RegistryApp\n\t/** When the user initiates an install, we now have a user app, even before install */\n\tuserApp?: UserApp\n\trecommendedApps?: RegistryApp[]\n\tshowDependencies?: (dependencyId?: string) => void\n}) {\n\tconst hasDependencies = app.dependencies && app.dependencies.length > 0\n\treturn (\n\t\t<>\n\t\t\t<AppGallerySection galleryId={'gallery-' + app.id} gallery={app.gallery} />\n\t\t\t{/* NOTE: consider conditionally rendering */}\n\t\t\t{/* Desktop */}\n\t\t\t<div className={cn('hidden flex-row gap-5 lg:flex')}>\n\t\t\t\t<div className='flex flex-1 flex-col gap-5'>\n\t\t\t\t\t<AboutSection app={app} />\n\t\t\t\t\t<ReleaseNotesSection app={app} />\n\t\t\t\t</div>\n\t\t\t\t{/* Since contents can be arbitrarily wide, we wanna limit */}\n\t\t\t\t<div className='flex flex-col gap-5 md:max-w-sm'>\n\t\t\t\t\t{userApp && <SettingsSection userApp={userApp} />}\n\t\t\t\t\t<InfoSection app={app} />\n\t\t\t\t\t{hasDependencies && <DependenciesSection app={app} showDependencies={showDependencies} />}\n\t\t\t\t\t{!isEmpty(recommendedApps) && <RecommendationsSection apps={recommendedApps} />}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t{/* Mobile */}\n\t\t\t<div className='space-y-5 lg:hidden'>\n\t\t\t\t{userApp && <SettingsSection userApp={userApp} />}\n\t\t\t\t<AboutSection app={app} />\n\t\t\t\t<InfoSection app={app} />\n\t\t\t\t{hasDependencies && <DependenciesSection app={app} showDependencies={showDependencies} />}\n\t\t\t\t<ReleaseNotesSection app={app} />\n\t\t\t\t{!isEmpty(recommendedApps) && <RecommendationsSection apps={recommendedApps} />}\n\t\t\t</div>\n\t\t\t<DebugOnly>\n\t\t\t\t<JSONTree data={app} />\n\t\t\t</DebugOnly>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/app-page/app-settings-dialog.tsx",
    "content": "import {Close, DialogDescription} from '@radix-ui/react-dialog'\nimport {useMemo, useState} from 'react'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {appStateToString} from '@/components/cmdk'\nimport {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogFooter, DialogHeader, DialogPortal, DialogTitle} from '@/components/ui/dialog'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {useApps, useUserApp} from '@/providers/apps'\nimport {useAllAvailableApps} from '@/providers/available-apps'\nimport {installedStates, progressStates, RegistryApp, trpcReact, UserApp} from '@/trpc/trpc'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nimport {SelectDependencies} from '../select-dependencies-dialog'\n\nexport function AppSettingsDialog() {\n\tconst {params} = useQueryParams()\n\tconst appId = params.get('app-settings-for')\n\tconst dependencyId = params.get('app-settings-dependency') ?? undefined\n\n\tconst {isLoading, app} = useUserApp(appId)\n\tconst {userApps, userAppsKeyed} = useApps()\n\tconst {apps: availableApps} = useAllAvailableApps()\n\n\tif (isLoading || !app || !userApps || !userAppsKeyed || !availableApps) {\n\t\treturn null\n\t}\n\n\treturn (\n\t\t<AppSettingsDialogForApp\n\t\t\tapp={app}\n\t\t\tuserApps={userApps}\n\t\t\tuserAppsKeyed={userAppsKeyed}\n\t\t\tavailableApps={availableApps}\n\t\t\topenDependency={dependencyId}\n\t\t/>\n\t)\n}\n\nfunction areSelectionsEqual(a?: Record<string, string>, b?: Record<string, string>) {\n\tif (a === b) return true\n\tconst keys1 = Object.keys((a ||= {}))\n\tconst keys2 = Object.keys((b ||= {}))\n\tif (keys1.length !== keys2.length) return false\n\tfor (const key of keys1) {\n\t\tif (b[key] !== a[key]) return false\n\t}\n\treturn true\n}\n\nfunction AppSettingsDialogForApp({\n\tapp,\n\tuserApps,\n\tuserAppsKeyed,\n\tavailableApps,\n\topenDependency,\n}: {\n\tapp: UserApp\n\tuserApps: UserApp[]\n\tuserAppsKeyed: Record<string, UserApp>\n\tavailableApps: RegistryApp[]\n\topenDependency?: string\n}) {\n\tconst dialogProps = useDialogOpenProps('app-settings')\n\tconst [selectedDependencies, setSelectedDependencies] = useState(app.selectedDependencies)\n\tconst [hadChanges, setHadChanges] = useState(false)\n\tconst utils = trpcReact.useUtils()\n\tconst setSelectedDependenciesMut = trpcReact.apps.setSelectedDependencies.useMutation({\n\t\tonSuccess() {\n\t\t\t// Invalidate this app's state\n\t\t\tutils.apps.state.invalidate({appId: app.id})\n\t\t\t// Invalidate list of apps on desktop\n\t\t\tutils.apps.list.invalidate()\n\t\t},\n\t})\n\n\tconst getAppsImplementing = (dependencyId: string) =>\n\t\tavailableApps\n\t\t\t// Filter out community apps that aren't installed\n\t\t\t.filter((registryApp) => {\n\t\t\t\tconst isCommunityApp = registryApp.appStoreId !== 'umbrel-app-store'\n\t\t\t\treturn !isCommunityApp || userAppsKeyed[registryApp.id]\n\t\t\t})\n\t\t\t// Prefer installed app over registry app\n\t\t\t.map((registryApp) => userAppsKeyed?.[registryApp.id] ?? registryApp)\n\t\t\t.filter((applicableApp) => applicableApp.implements?.includes(dependencyId))\n\t\t\t.map((implementingApp) => implementingApp.id)\n\n\tconst dependencies = useMemo(\n\t\t() =>\n\t\t\t(app.dependencies ?? []).map((dependencyId) =>\n\t\t\t\t[dependencyId, ...getAppsImplementing(dependencyId)].map((appId) => ({\n\t\t\t\t\tdependencyId,\n\t\t\t\t\tappId,\n\t\t\t\t})),\n\t\t\t),\n\t\t[app.dependencies],\n\t)\n\n\tconst areAllDependenciesInstalled = dependencies.every((alternatives) =>\n\t\talternatives.some((alternative) =>\n\t\t\tuserApps.some(\n\t\t\t\t(installedApp) =>\n\t\t\t\t\tinstalledApp.id === selectedDependencies[alternative.dependencyId] &&\n\t\t\t\t\tarrayIncludes(installedStates, installedApp.state),\n\t\t\t),\n\t\t),\n\t)\n\n\tfunction onSelectionChange(selectedDependencies: Record<string, string>) {\n\t\tsetSelectedDependencies(selectedDependencies)\n\t\tif (!areSelectionsEqual(app.selectedDependencies, selectedDependencies)) {\n\t\t\tsetHadChanges(true)\n\t\t}\n\t}\n\n\tfunction onSubmit() {\n\t\tif (areAllDependenciesInstalled) {\n\t\t\tsetSelectedDependenciesMut.mutate({\n\t\t\t\tappId: app.id,\n\t\t\t\tdependencies: selectedDependencies,\n\t\t\t})\n\t\t}\n\t}\n\n\tconst inProgress = arrayIncludes(progressStates, app.state)\n\tconst hasChanges = !areSelectionsEqual(app.selectedDependencies, selectedDependencies)\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogPortal>\n\t\t\t\t<DialogContent\n\t\t\t\t\tonOpenAutoFocus={(e) => {\n\t\t\t\t\t\t// `preventDefault` to prevent focus on first input\n\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t<DialogTitle className='flex items-center gap-2'>\n\t\t\t\t\t\t\t<AppIcon src={app.icon} size={24} className='rounded-6' />\n\t\t\t\t\t\t\t{t('app-settings.title')}\n\t\t\t\t\t\t</DialogTitle>\n\t\t\t\t\t</DialogHeader>\n\t\t\t\t\t<DialogDescription className='-mb-3 text-13 opacity-50'>\n\t\t\t\t\t\t{t('app-settings.connected-to', {appName: app.name})}\n\t\t\t\t\t</DialogDescription>\n\t\t\t\t\t{dependencies.length ? (\n\t\t\t\t\t\t<SelectDependencies\n\t\t\t\t\t\t\tdependencies={dependencies}\n\t\t\t\t\t\t\tselectedDependencies={selectedDependencies}\n\t\t\t\t\t\t\tsetSelectedDependencies={onSelectionChange}\n\t\t\t\t\t\t\tonInstallClick={() => dialogProps.onOpenChange(false)}\n\t\t\t\t\t\t\thighlightDependency={openDependency}\n\t\t\t\t\t\t/>\n\t\t\t\t\t) : null}\n\t\t\t\t\t{hadChanges && (\n\t\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t\t<Close asChild>\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\t\t\t\tdisabled={!areAllDependenciesInstalled || dependencies.length === 0 || inProgress || !hasChanges}\n\t\t\t\t\t\t\t\t\tonClick={() => onSubmit()}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{inProgress ? appStateToString(app.state) + '...' : t('app-settings.save-changes')}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</Close>\n\t\t\t\t\t\t\t<Close asChild>\n\t\t\t\t\t\t\t\t<Button size='dialog'>{t('cancel')}</Button>\n\t\t\t\t\t\t\t</Close>\n\t\t\t\t\t\t</DialogFooter>\n\t\t\t\t\t)}\n\t\t\t\t</DialogContent>\n\t\t\t</DialogPortal>\n\t\t</Dialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/app-page/default-credentials-dialog.tsx",
    "content": "import {useId} from 'react'\n\nimport {Button} from '@/components/ui/button'\nimport {Checkbox, checkboxContainerClass, checkboxLabelClass} from '@/components/ui/checkbox'\nimport {CopyableField} from '@/components/ui/copyable-field'\nimport {Dialog, DialogContent, DialogDescription, DialogHeader, DialogPortal, DialogTitle} from '@/components/ui/dialog'\nimport {Separator} from '@/components/ui/separator'\nimport {useLaunchApp} from '@/hooks/use-launch-app'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {cn} from '@/lib/utils'\nimport {useUserApp} from '@/providers/apps'\nimport {trpcReact} from '@/trpc/trpc'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\n// TODO: move out of app-store/app-page since it's used elsewhere\nexport function DefaultCredentialsDialog() {\n\tconst params = useQueryParams()\n\tconst dialogProps = useDialogOpenProps('default-credentials')\n\n\tconst appId = params.params.get('default-credentials-for')\n\tconst direct = params.params.get('default-credentials-direct') === 'true'\n\n\tconst launchApp = useLaunchApp()\n\n\tconst {app, isLoading} = useUserApp(appId)\n\n\tif (isLoading || !appId || !app) {\n\t\treturn null\n\t}\n\n\t// TODO: replace with API call\n\tconst appName = app.name\n\tconst defaultUsername = app.credentials.defaultUsername\n\tconst defaultPassword = app.credentials.defaultPassword\n\n\tconst title = t('default-credentials.title', {app: appName})\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogPortal>\n\t\t\t\t<DialogContent\n\t\t\t\t\tclassName='p-0'\n\t\t\t\t\tonOpenAutoFocus={(e) => {\n\t\t\t\t\t\t// `preventDefault` to prevent focus on first input\n\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<div className='umbrel-dialog-fade-scroller flex flex-col gap-y-4 overflow-y-auto p-7'>\n\t\t\t\t\t\t{/* <JSONTree data={app} /> */}\n\t\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t\t<DialogTitle className='flex flex-row items-center justify-between'>{title}</DialogTitle>\n\t\t\t\t\t\t\t<DialogDescription>{t('default-credentials.description')}</DialogDescription>\n\t\t\t\t\t\t</DialogHeader>\n\t\t\t\t\t\t<Separator />\n\t\t\t\t\t\t{defaultUsername && (\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className={textClass}>{t('default-credentials.username')}</label>\n\t\t\t\t\t\t\t\t<CopyableField value={defaultUsername} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{defaultPassword && (\n\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t<label className={textClass}>{t('default-credentials.password')}</label>\n\t\t\t\t\t\t\t\t<CopyableField isPassword value={defaultPassword} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<Separator />\n\t\t\t\t\t\t<div className='flex items-center justify-between'>\n\t\t\t\t\t\t\t{direct && <ShowCredentialsBeforeOpenCheckbox appId={appId} />}\n\t\t\t\t\t\t\t{direct ? (\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\t\t\t\tclassName='w-auto'\n\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\tlaunchApp(appId, {direct: true})\n\t\t\t\t\t\t\t\t\t\tdialogProps.onOpenChange(false)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{t('default-credentials.open', {app: appName})}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\t\t\t\tclassName='w-auto'\n\t\t\t\t\t\t\t\t\tonClick={() => dialogProps.onOpenChange(false)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{t('default-credentials.close')}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</DialogContent>\n\t\t\t</DialogPortal>\n\t\t</Dialog>\n\t)\n}\n\nfunction ShowCredentialsBeforeOpenCheckbox({appId}: {appId: string}) {\n\tconst checkboxId = useId()\n\tconst {app, isLoading} = useUserApp(appId)\n\n\tconst showCredentials = app?.credentials?.showBeforeOpen ?? false\n\n\tconst utils = trpcReact.useUtils()\n\n\tconst hideCredentialsBeforeOpenMut = trpcReact.apps.hideCredentialsBeforeOpen.useMutation({\n\t\tonSuccess: () => utils.apps.invalidate(),\n\t})\n\n\tconst handleHideCredentialsBeforeOpenChange = (value: boolean) => {\n\t\thideCredentialsBeforeOpenMut.mutate({appId, value})\n\t}\n\n\treturn (\n\t\t<div className='flex flex-col'>\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\tcheckboxContainerClass,\n\t\t\t\t\t// prevent interaction when loading\n\t\t\t\t\t(isLoading || hideCredentialsBeforeOpenMut.isPending) && 'pointer-events-none',\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t<Checkbox\n\t\t\t\t\tid={checkboxId}\n\t\t\t\t\tchecked={!showCredentials}\n\t\t\t\t\tonCheckedChange={(checked) => handleHideCredentialsBeforeOpenChange(!!checked)}\n\t\t\t\t/>\n\t\t\t\t<label htmlFor={checkboxId} className={cn(checkboxLabelClass, 'text-13')}>\n\t\t\t\t\t{t('default-credentials.dont-show-again')}\n\t\t\t\t</label>\n\t\t\t</div>\n\t\t\t{!showCredentials && (\n\t\t\t\t<div className='pt-2 pr-2 text-xs text-white/40'>{t('default-credentials.dont-show-again-notice')}</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\nconst textClass = tw`text-13 font-normal text-white/40 block pb-1`\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/app-page/dependencies.tsx",
    "content": "import {Fragment} from 'react'\nimport {TbCircleCheckFilled} from 'react-icons/tb'\nimport {Link} from 'react-router-dom'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {Loading} from '@/components/ui/loading'\nimport {cn} from '@/lib/utils'\nimport {useApps} from '@/providers/apps'\nimport {useAllAvailableApps} from '@/providers/available-apps'\nimport {installedStates, RegistryApp} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nimport {cardClass, cardTitleClass} from './shared'\n\nexport const DependenciesSection = ({\n\tapp,\n\tshowDependencies,\n}: {\n\tapp: RegistryApp\n\tshowDependencies?: (dependencyId?: string) => void\n}) => {\n\tconst {apps, appsKeyed, isLoading: isLoadingAvailableApps} = useAllAvailableApps()\n\tconst {userAppsKeyed, isLoading: isLoadingUserApps} = useApps()\n\n\tif (isLoadingAvailableApps || isLoadingUserApps) return <Loading />\n\n\treturn (\n\t\t<div className={cardClass}>\n\t\t\t<h2 className={cardTitleClass}>{t('app-page.section.requires')}</h2>\n\t\t\t{app.dependencies?.map((dependencyId) => {\n\t\t\t\tconst dependencyUserApp = userAppsKeyed?.[dependencyId]\n\t\t\t\tconst numberOfAlternativeApps = apps\n\t\t\t\t\t// Filter out community apps that aren't installed\n\t\t\t\t\t.filter((registryApp) => {\n\t\t\t\t\t\tconst isCommunityApp = registryApp.appStoreId !== 'umbrel-app-store'\n\t\t\t\t\t\treturn !isCommunityApp || userAppsKeyed?.[registryApp.id]\n\t\t\t\t\t})\n\t\t\t\t\t// Prefer installed app's implements over registry app's\n\t\t\t\t\t.filter((registryApp) =>\n\t\t\t\t\t\t(userAppsKeyed?.[registryApp.id] ?? registryApp).implements?.includes(dependencyId),\n\t\t\t\t\t).length\n\t\t\t\treturn (\n\t\t\t\t\t<Fragment key={`${dependencyId}:outer`}>\n\t\t\t\t\t\t<Dependency\n\t\t\t\t\t\t\tkey={dependencyId}\n\t\t\t\t\t\t\tapp={appsKeyed[dependencyId]}\n\t\t\t\t\t\t\tnumberOfAlternativeApps={numberOfAlternativeApps}\n\t\t\t\t\t\t\tinstalled={!!dependencyUserApp && arrayIncludes(installedStates, dependencyUserApp.state)}\n\t\t\t\t\t\t\tshowDependencies={showDependencies}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</Fragment>\n\t\t\t\t)\n\t\t\t})}\n\t\t</div>\n\t)\n}\n\nconst Dependency = ({\n\tapp,\n\tinstalled = false,\n\tnumberOfAlternativeApps = 0,\n\tshowDependencies,\n}: {\n\tapp: RegistryApp\n\tinstalled: boolean\n\tnumberOfAlternativeApps: number\n\tshowDependencies?: (dependencyId?: string) => void\n}) => {\n\treturn (\n\t\t<div className='flex w-full items-center gap-2.5 pl-2'>\n\t\t\t<Link to={`/app-store/${app.id}`}>\n\t\t\t\t<AppIcon src={app.icon} size={36} className='rounded-8' />\n\t\t\t</Link>\n\t\t\t<div className='flex-col gap-4'>\n\t\t\t\t<Link to={`/app-store/${app.id}`} className='flex gap-1.5'>\n\t\t\t\t\t<h3 className='truncate text-14 leading-tight font-semibold -tracking-3'>{app.name}</h3>\n\t\t\t\t\t{installed && <TbCircleCheckFilled className='h-[16px] w-[16px] text-slate-500' />}\n\t\t\t\t</Link>\n\t\t\t\t{numberOfAlternativeApps > 0 && (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName={cn('mt-0.5 text-xs', showDependencies && 'text-brand-lightest hover:text-brand-lighter')}\n\t\t\t\t\t\tonClick={() => showDependencies?.(app.id)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('app-page.section.dependencies.n-alternatives', {count: numberOfAlternativeApps + /* app itself */ 1})}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/app-page/get-recommendations.ts",
    "content": "import {sample} from 'remeda'\n\nimport {RegistryApp} from '@/trpc/trpc'\n\nexport function getRecommendationsFor(apps: RegistryApp[], appId: string) {\n\tconst {category} = apps.find((app) => app.id === appId)!\n\n\t// Filter apps by the same category, excluding the current app\n\tconst categoryApps = apps.filter((app) => app.category === category && app.id !== appId)\n\n\t// Sample 4 apps from the same category\n\treturn sample(categoryApps, 4)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/app-page/info-section.tsx",
    "content": "import {ReactNode} from 'react'\nimport semver from 'semver'\n\nimport {UNKNOWN} from '@/constants'\nimport {useVersion} from '@/hooks/use-version'\nimport {cn} from '@/lib/utils'\nimport {RegistryApp} from '@/trpc/trpc'\nimport {linkClass} from '@/utils/element-classes'\nimport {t} from '@/utils/i18n'\n\nimport {cardClass, cardTitleClass} from './shared'\n\nexport const InfoSection = ({app}: {app: RegistryApp}) => {\n\treturn (\n\t\t<div className={cardClass}>\n\t\t\t<h2 className={cardTitleClass}>{t('app-page.section.info.title')}</h2>\n\t\t\t<KV k={t('app-page.section.info.version')} v={app.version} />\n\t\t\t{app.repo && (\n\t\t\t\t<KV\n\t\t\t\t\tk={t('app-page.section.info.source-code')}\n\t\t\t\t\tv={\n\t\t\t\t\t\t<a href={app.repo} target='_blank' className={linkClass}>\n\t\t\t\t\t\t\t{t('app-page.section.info.source-code.public')}\n\t\t\t\t\t\t</a>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t<KV\n\t\t\t\tk={t('app-page.section.info.developer')}\n\t\t\t\tv={\n\t\t\t\t\t<a href={app.website} target='_blank' className={linkClass}>\n\t\t\t\t\t\t{app.developer}\n\t\t\t\t\t</a>\n\t\t\t\t}\n\t\t\t/>\n\t\t\t{app.submission && app.submitter && (\n\t\t\t\t<KV\n\t\t\t\t\tk={t('app-page.section.info.submitted-by')}\n\t\t\t\t\tv={\n\t\t\t\t\t\t<a href={app.submission} target='_blank' className={linkClass}>\n\t\t\t\t\t\t\t{app.submitter}\n\t\t\t\t\t\t</a>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t<KV k={t('app-page.section.info.compatibility')} v={<InfoSectionCompatibilityText app={app} />} />\n\t\t\t<a href={app.support} target='_blank' className={cn(linkClass, 'self-start text-14 font-medium')}>\n\t\t\t\t{t('app-page.section.info.support')}\n\t\t\t</a>\n\t\t</div>\n\t)\n}\n\nfunction KV({k, v}: {k: ReactNode; v: ReactNode}) {\n\treturn (\n\t\t<div className='flex flex-row items-center gap-2'>\n\t\t\t<span className='flex-1 text-14 opacity-50'>{k}</span>\n\t\t\t<span className='text-right text-14 font-medium'>{v || UNKNOWN()}</span>\n\t\t</div>\n\t)\n}\n\nfunction InfoSectionCompatibilityText({app}: {app: RegistryApp}) {\n\tconst os = useVersion()\n\treturn os.version && semver.lte(app.manifestVersion, os.version)\n\t\t? t('app-page.section.info.compatibility-compatible')\n\t\t: os.version\n\t\t\t? t('app-page.section.info.compatibility-not-compatible')\n\t\t\t: t('unknown')\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/app-page/recommendations-section.tsx",
    "content": "import {ReactNode} from 'react'\nimport {Link, useLocation} from 'react-router-dom'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {RegistryApp} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nimport {cardClass, cardTitleClass} from './shared'\n\nexport const RecommendationsSection = ({apps}: {apps: RegistryApp[]}) => {\n\tconst location = useLocation()\n\n\tif (location.pathname.startsWith('/community-app-store')) return null\n\n\treturn (\n\t\t<div className={cardClass}>\n\t\t\t<h2 className={cardTitleClass}>{t('app-page.section.recommendations.title')}</h2>\n\t\t\t{apps.map((app) => (\n\t\t\t\t<AppWithDescriptionSmall\n\t\t\t\t\tto={`/app-store/${app.id}`}\n\t\t\t\t\tkey={app.id}\n\t\t\t\t\tid={app.id}\n\t\t\t\t\ticon={app.icon}\n\t\t\t\t\tappName={app.name}\n\t\t\t\t\tappDescription={app.tagline}\n\t\t\t\t/>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n\nfunction AppWithDescriptionSmall({\n\tid,\n\tto,\n\ticon,\n\tappName,\n\tappDescription,\n}: {\n\tid?: string\n\tto?: string\n\ticon: string\n\tappName: ReactNode\n\tappDescription: ReactNode\n}) {\n\treturn (\n\t\t<Link\n\t\t\tto={to ? to : `/app-store/${id}`}\n\t\t\tclassName='group -m-2.5 flex w-full items-center gap-2.5 rounded-12 p-2.5 outline-hidden hover:bg-white/4 focus:bg-white/4'\n\t\t>\n\t\t\t<AppIcon src={icon} size={50} className='rounded-10' />\n\t\t\t<div className='flex min-w-0 flex-1 flex-col gap-0.5'>\n\t\t\t\t<h3 className='truncate text-14 leading-tight font-semibold -tracking-3'>{appName}</h3>\n\t\t\t\t<p className='line-clamp-2 w-full max-w-[220px] min-w-0 text-12 leading-tight opacity-40'>{appDescription}</p>\n\t\t\t</div>\n\t\t</Link>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/app-page/release-notes-section.tsx",
    "content": "import {cn} from '@/lib/utils'\nimport {RegistryApp} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nimport {cardClass, cardTitleClass, ReadMoreMarkdownSection} from './shared'\n\nexport const ReleaseNotesSection = ({app}: {app: RegistryApp}) => (\n\t<>\n\t\t{app.releaseNotes && (\n\t\t\t<div className={cn(cardClass, 'gap-2.5')}>\n\t\t\t\t<h2 className={cardTitleClass}>{t('app-page.section.release-notes.title')}</h2>\n\t\t\t\t<h3 className='text-16 font-semibold'>{t('app-page.section.release-notes.version', {version: app.version})}</h3>\n\t\t\t\t{/* Adding key to reset state when updating content */}\n\t\t\t\t<ReadMoreMarkdownSection key={app.releaseNotes}>{app.releaseNotes}</ReadMoreMarkdownSection>\n\t\t\t</div>\n\t\t)}\n\t</>\n)\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/app-page/settings-section.tsx",
    "content": "import {ReactNode} from 'react'\n\nimport {CopyableField} from '@/components/ui/copyable-field'\nimport {UNKNOWN} from '@/constants'\nimport {UserApp} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nimport {cardClass, cardTitleClass} from './shared'\n\nexport function SettingsSection({userApp}: {userApp: UserApp}) {\n\tif (!userApp.credentials) return null\n\n\tconst {defaultUsername, defaultPassword} = userApp.credentials\n\tif (!defaultUsername && !defaultPassword) return null\n\n\treturn (\n\t\t<div className={cardClass}>\n\t\t\t<h2 className={cardTitleClass}>{t('app-page.section.credentials.title')}</h2>\n\t\t\t{defaultUsername && (\n\t\t\t\t<KV\n\t\t\t\t\tk={t('default-credentials.username')}\n\t\t\t\t\tv={<CopyableField className='w-[120px]' narrow value={defaultUsername} />}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{defaultPassword && (\n\t\t\t\t<KV\n\t\t\t\t\tk={t('default-credentials.password')}\n\t\t\t\t\tv={<CopyableField narrow className='w-[120px]' value={defaultPassword} isPassword />}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\nfunction KV({k, v}: {k: ReactNode; v: ReactNode}) {\n\treturn (\n\t\t<div className='flex flex-row items-center gap-2'>\n\t\t\t<span className='flex-1 truncate text-15 font-medium -tracking-4 whitespace-nowrap'>{k}</span>\n\t\t\t<span className='text-right text-14 font-medium'>{v || UNKNOWN()}</span>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/app-page/shared.tsx",
    "content": "import {useEffect, useRef, useState} from 'react'\n\nimport {Markdown} from '@/components/markdown'\nimport {cn} from '@/lib/utils'\nimport {cardFaintClass} from '@/modules/app-store/shared'\nimport {linkClass} from '@/utils/element-classes'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nexport function ReadMoreMarkdownSection({children}: {children: string}) {\n\tconst contentRef = useRef<HTMLDivElement>(null)\n\tconst buttonRef = useRef<HTMLButtonElement>(null)\n\n\tconst [showReadMore, setShowReadMore] = useState(false)\n\tconst [isExpanded, setIsExpanded] = useState(false)\n\n\tconst toggle = () => {\n\t\tsetIsExpanded((prev) => !prev)\n\t\tbuttonRef.current?.focus()\n\t}\n\n\tuseEffect(() => {\n\t\tif (!contentRef.current) return\n\t\tconst el = contentRef.current\n\t\t/** If the available space is close to not enough, we don't collapse */\n\t\tconst WIGGLE_ROOM = 20\n\t\tsetShowReadMore(el.scrollHeight > el.clientHeight + WIGGLE_ROOM)\n\n\t\tconst handleFocus = () => setIsExpanded(true)\n\t\tel.addEventListener('focusin', handleFocus)\n\t\treturn () => {\n\t\t\tel.removeEventListener('focusin', handleFocus)\n\t\t}\n\t}, [children])\n\n\treturn (\n\t\t<>\n\t\t\t<div\n\t\t\t\tref={contentRef}\n\t\t\t\tstyle={{\n\t\t\t\t\tWebkitMaskImage:\n\t\t\t\t\t\tisExpanded || !showReadMore ? undefined : `linear-gradient(to bottom, black, black, transparent)`,\n\t\t\t\t\t// background: 'red',\n\t\t\t\t}}\n\t\t\t\tonClick={!isExpanded ? toggle : undefined}\n\t\t\t\tclassName={cn(\n\t\t\t\t\tcardTextClass,\n\t\t\t\t\t'transition-[max-height] duration-200',\n\t\t\t\t\tisExpanded || !showReadMore ? 'max-h-[9999px]' : 'max-h-[calc(1.7em*6)] overflow-hidden',\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t<Markdown>{children}</Markdown>\n\t\t\t</div>\n\t\t\t{showReadMore && (\n\t\t\t\t<button\n\t\t\t\t\tref={buttonRef}\n\t\t\t\t\tonClick={toggle}\n\t\t\t\t\tclassName={cn(linkClass, 'self-start text-13 font-medium group-hover:text-brand')}\n\t\t\t\t>\n\t\t\t\t\t{isExpanded ? t('read-less') : t('read-more')}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</>\n\t)\n}\n\nexport const appPageWrapperClass = tw`flex flex-col gap-8`\nexport const cardClass = cn(cardFaintClass, tw`rounded-12 px-[20px] py-[30px] flex flex-col gap-5`)\nexport const cardTitleClass = tw`text-12 opacity-50 uppercase leading-inter-trim font-semibold tracking-normal`\nexport const cardTextClass = tw`text-15 leading-snug`\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/app-page/top-header.tsx",
    "content": "import {ReactNode} from 'react'\nimport {TbArrowLeft, TbCircleArrowLeftFilled} from 'react-icons/tb'\nimport {useNavigate} from 'react-router-dom'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {Badge} from '@/components/ui/badge'\nimport {DialogCloseButton} from '@/components/ui/dialog-close-button'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {cn} from '@/lib/utils'\nimport {SheetFixedContent} from '@/modules/sheet-top-fixed'\nimport {SheetStickyHeader} from '@/providers/sheet-sticky-header'\nimport {RegistryApp} from '@/trpc/trpc'\nimport {dialogHeaderCircleButtonClass} from '@/utils/element-classes'\nimport {t} from '@/utils/i18n'\n\nexport const TopHeader = ({app, childrenRight}: {app: RegistryApp; childrenRight: ReactNode}) => {\n\tconst isMobile = useIsMobile()\n\treturn (\n\t\t<>\n\t\t\t{!isMobile && (\n\t\t\t\t<SheetStickyHeader className='flex h-full w-full items-center gap-2.5'>\n\t\t\t\t\t<BackButton />\n\t\t\t\t\t<div className='flex flex-1 items-center gap-2.5'>\n\t\t\t\t\t\t<AppIcon src={app.icon} size={32} className='rounded-8' />\n\t\t\t\t\t\t<span className='truncate text-16 font-semibold -tracking-4 md:text-19'>{app.name}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{childrenRight}\n\t\t\t\t\t<DialogCloseButton />\n\t\t\t\t</SheetStickyHeader>\n\t\t\t)}\n\t\t\t<div className='space-y-5'>\n\t\t\t\t{/*\n\t\t\t\tTricky to get good behavior for this:\n\t\t\t\t- Naturally, we want to just go back to the previous page\n\t\t\t\t- However, when coming from home page, we want to go back to the app store\n\t\t\t\t- After clicking related apps, it's not clear what the back button should do\n\t\t\t\t*/}\n\n\t\t\t\t{isMobile ? (\n\t\t\t\t\t<SheetFixedContent>\n\t\t\t\t\t\t<BackButton />\n\t\t\t\t\t</SheetFixedContent>\n\t\t\t\t) : (\n\t\t\t\t\t<BackButton />\n\t\t\t\t)}\n\n\t\t\t\t<div data-testid='app-top' className='flex flex-col items-center items-stretch gap-5 max-md:mt-5 md:flex-row'>\n\t\t\t\t\t<div className='flex min-w-0 flex-1 items-center gap-2.5 max-md:px-2.5 md:gap-5'>\n\t\t\t\t\t\t<AppIcon src={app.icon} size={isMobile ? 64 : 100} className='rounded-12 lg:rounded-20' />\n\t\t\t\t\t\t<div className='flex min-w-0 flex-col items-start gap-1.5 py-1 md:gap-2'>\n\t\t\t\t\t\t\t<h1 className='flex flex-wrap items-center gap-2 text-16 leading-inter-trimmed font-semibold md:text-24'>\n\t\t\t\t\t\t\t\t{app.name} {app.optimizedForUmbrelHome && <Badge>{t('app.optimized-for-umbrel-home')}</Badge>}\n\t\t\t\t\t\t\t</h1>\n\t\t\t\t\t\t\t<p className='line-clamp-2 w-full text-12 leading-tight opacity-50 md:line-clamp-1 md:text-16'>\n\t\t\t\t\t\t\t\t{app.tagline}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t{!isMobile && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<div className='flex-1' />\n\t\t\t\t\t\t\t\t\t<div className='animate-in text-12 delay-100 fill-mode-both fade-in slide-in-from-right-2 md:text-13'>\n\t\t\t\t\t\t\t\t\t\t{app.developer}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t{childrenRight}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</>\n\t)\n}\n\nfunction BackButton() {\n\tconst navigate = useNavigate()\n\tconst isMobile = useIsMobile()\n\n\tif (isMobile) {\n\t\treturn (\n\t\t\t<button\n\t\t\t\tclassName={cn(dialogHeaderCircleButtonClass, 'absolute top-2.5 left-2.5 z-50')}\n\t\t\t\tonClick={() => navigate(-1)}\n\t\t\t>\n\t\t\t\t<TbCircleArrowLeftFilled className='h-5 w-5' />\n\t\t\t</button>\n\t\t)\n\t}\n\n\treturn (\n\t\t<button onClick={() => navigate(-1)}>\n\t\t\t<TbArrowLeft className='h-5 w-5' />\n\t\t</button>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/app-store-nav.tsx",
    "content": "import {compute} from 'compute-scroll-into-view'\nimport {useEffect, useRef} from 'react'\nimport {useParams} from 'react-router-dom'\n\nimport {FadeScroller} from '@/components/fade-scroller'\nimport {ButtonLink} from '@/components/ui/button-link'\nimport {useAvailableApps} from '@/providers/available-apps'\nimport {useBreakpoint} from '@/utils/tw'\n\nimport {categoryishDescriptions} from './constants'\nimport {getAllCategories, getCategoryLabel} from './utils'\n\nexport function ConnectedAppStoreNav() {\n\tconst {categoryishId} = useParams<{categoryishId: string}>()\n\tconst {appsGroupedByCategory} = useAvailableApps()\n\n\t// Get all categories (predefined + others from actual app data)\n\tconst allCategories = getAllCategories(appsGroupedByCategory || {})\n\n\t// Filter to only show categories that have apps (plus discover/all)\n\tconst categoriesWithApps = allCategories.filter((categoryId) => {\n\t\t// Always include 'discover' and 'all' regardless of app count\n\t\tif (categoryId === 'discover' || categoryId === 'all') return true\n\t\t// For other categories, only include if they have apps\n\t\t// A category may have no apps if it is a hardcoded one in the OS and we have changed the app manifests to no longer include it.\n\t\treturn (appsGroupedByCategory as any)?.[categoryId]?.length > 0\n\t})\n\n\tconst activeId: string = categoryishId || categoryishDescriptions[0].id\n\n\treturn <AppStoreNav activeId={activeId} allCategories={categoriesWithApps} />\n}\n\nexport function AppStoreNav({activeId, allCategories}: {activeId: string; allCategories: string[]}) {\n\tconst scrollerRef = useRef<HTMLDivElement>(null)\n\tconst scrollToRef = useRef<HTMLAnchorElement>(null)\n\tconst breakpoint = useBreakpoint()\n\tconst size = breakpoint === 'sm' ? 'default' : 'lg'\n\n\t// Alternative to: scrollToRef.current?.scrollIntoView({inline: 'center'})\n\t// `overflow: hidden` items in parents are scrolled, which breaks the UI.\n\t// Fixing scrolling issue by setting a boundary\n\tuseEffect(() => {\n\t\tif (!scrollToRef.current) return\n\t\tconst node = scrollToRef.current\n\t\tconst actions = compute(node, {\n\t\t\tscrollMode: 'if-needed',\n\t\t\tinline: 'center',\n\t\t\tboundary: scrollerRef.current,\n\t\t})\n\t\tactions.forEach(({el, top, left}) => {\n\t\t\tel.scrollTop = top\n\t\t\tel.scrollLeft = left\n\t\t})\n\t}, [activeId])\n\n\treturn (\n\t\t<FadeScroller\n\t\t\tref={scrollerRef}\n\t\t\tdirection='x'\n\t\t\tclassName='umbrel-hide-scrollbar -my-2 flex gap-[5px] overflow-x-auto py-2'\n\t\t>\n\t\t\t{allCategories.map((categoryId) => (\n\t\t\t\t<ButtonLink\n\t\t\t\t\tkey={categoryId}\n\t\t\t\t\tto={categoryIdToPath(categoryId)}\n\t\t\t\t\tvariant={categoryId === activeId ? 'secondary' : 'default'}\n\t\t\t\t\tref={categoryId === activeId ? scrollToRef : undefined}\n\t\t\t\t\tsize={size}\n\t\t\t\t>\n\t\t\t\t\t{getCategoryLabel(categoryId)}\n\t\t\t\t</ButtonLink>\n\t\t\t))}\n\t\t</FadeScroller>\n\t)\n}\n\nfunction categoryIdToPath(categoryId: string) {\n\tif (categoryId === 'discover') {\n\t\treturn '/app-store'\n\t}\n\n\tif (categoryId === 'all') {\n\t\treturn '/app-store/category/all'\n\t}\n\n\treturn `/app-store/category/${categoryId}`\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/community-app-store-dialog.tsx",
    "content": "import {DialogDescription} from '@radix-ui/react-dialog'\nimport {useState} from 'react'\n\nimport {Button} from '@/components/ui/button'\nimport {ButtonLink} from '@/components/ui/button-link'\nimport {Card} from '@/components/ui/card'\nimport {CopyableField} from '@/components/ui/copyable-field'\nimport {\n\tDialog,\n\tDialogFooter,\n\tDialogHeader,\n\tDialogPortal,\n\tDialogScrollableContent,\n\tDialogTitle,\n} from '@/components/ui/dialog'\nimport {AnimatedInputError, Input} from '@/components/ui/input'\nimport {Separator} from '@/components/ui/separator'\nimport {toast} from '@/components/ui/toast'\nimport {UMBREL_APP_STORE_ID} from '@/modules/app-store/constants'\nimport {trpcReact} from '@/trpc/trpc'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nexport function CommunityAppStoreDialog() {\n\tconst title = t('app-store.menu.community-app-stores')\n\tconst dialogProps = useDialogOpenProps('add-community-store')\n\n\t// state\n\n\tconst [url, setUrl] = useState('')\n\tconst [localError, setLocalError] = useState('')\n\n\t// queries\n\n\tconst appStoresQ = trpcReact.appStore.registry.useQuery()\n\n\t// mutations\n\n\tconst addAppStoreMut = trpcReact.appStore.addRepository.useMutation({\n\t\tonSuccess: () => {\n\t\t\tsetUrl('')\n\t\t\tsetLocalError('')\n\t\t\tappStoresQ.refetch()\n\t\t},\n\t\tonError: (err) => {\n\t\t\ttoast.error(t('community-app-store.add-error', {message: err.message}))\n\t\t},\n\t})\n\n\tconst removeAppStoreMut = trpcReact.appStore.removeRepository.useMutation({\n\t\tonSuccess: () => {\n\t\t\tappStoresQ.refetch()\n\t\t},\n\t\tonError: (err) => {\n\t\t\ttoast.error(t('community-app-store.remove-error', {message: err.message}))\n\t\t},\n\t})\n\n\t// handlers\n\n\tconst handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n\t\taddAppStoreMut.reset()\n\t\t// So setLocalError('') is not batched\n\t\tawait setLocalError('')\n\t\te.preventDefault()\n\t\tif (!url) {\n\t\t\tsetLocalError('URL is required')\n\t\t\treturn\n\t\t}\n\t\taddAppStoreMut.mutate({url})\n\t}\n\n\tconst remoteFormError = !addAppStoreMut.error?.data?.zodError && addAppStoreMut.error?.message\n\tconst formError = localError || remoteFormError\n\n\tconst nonUmbrelAppStores = (appStoresQ.data ?? [])\n\t\t.filter((store) => store?.meta.id !== UMBREL_APP_STORE_ID)\n\t\t.filter((store) => store !== null)\n\t\t.map((store) => store!)\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogPortal>\n\t\t\t\t<DialogScrollableContent showClose>\n\t\t\t\t\t<div className='umbrel-dialog-fade-scroller flex flex-col gap-y-3 overflow-y-auto px-5 py-6'>\n\t\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t\t<DialogTitle>{title}</DialogTitle>\n\t\t\t\t\t\t\t<DialogDescription className='text-13 text-white/50'>\n\t\t\t\t\t\t\t\t{t('community-app-stores.description')}\n\t\t\t\t\t\t\t</DialogDescription>\n\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\thref='https://github.com/getumbrel/umbrel-community-app-store'\n\t\t\t\t\t\t\t\tclassName='text-13 text-brand underline'\n\t\t\t\t\t\t\t\ttarget='_blank'\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t('community-app-stores.learn-more')}\n\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t</DialogHeader>\n\t\t\t\t\t\t<p className='rounded-8 bg-yellow-700/50 p-3 text-13 text-yellow-300/80'>\n\t\t\t\t\t\t\t{t('community-app-stores.warning')}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<form onSubmit={handleSubmit}>\n\t\t\t\t\t\t\t<fieldset disabled={addAppStoreMut.isPending} className='flex flex-col gap-5'>\n\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\tplaceholder={t('url')}\n\t\t\t\t\t\t\t\t\tvalue={url}\n\t\t\t\t\t\t\t\t\tonValueChange={setUrl}\n\t\t\t\t\t\t\t\t\tvariant={formError ? 'destructive' : 'default'}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<div className='-my-2.5'>\n\t\t\t\t\t\t\t\t\t<AnimatedInputError>{formError}</AnimatedInputError>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t\t\t\t<Button type='submit' variant='primary' size='dialog'>\n\t\t\t\t\t\t\t\t\t\t{t('community-app-stores.add-button')}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t</DialogFooter>\n\t\t\t\t\t\t\t</fieldset>\n\t\t\t\t\t\t</form>\n\t\t\t\t\t\t<Separator />\n\t\t\t\t\t\t{nonUmbrelAppStores.map(({url, meta}) => (\n\t\t\t\t\t\t\t<Card key={meta.id} className='shrink-0 space-y-3'>\n\t\t\t\t\t\t\t\t<b>\n\t\t\t\t\t\t\t\t\t{meta.name} {t('community-app-store')}\n\t\t\t\t\t\t\t\t</b>\n\t\t\t\t\t\t\t\t{url && <CopyableField value={url} />}\n\t\t\t\t\t\t\t\t<div className='flex items-center justify-between'>\n\t\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\t\tvariant='destructive'\n\t\t\t\t\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\t\t\t\t\tclassName='w-auto'\n\t\t\t\t\t\t\t\t\t\tonClick={() => removeAppStoreMut.mutate({url})}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{t('community-app-store.remove-button')}\n\t\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t\t<ButtonLink size='dialog' className='ml-2 w-auto' to={`/community-app-store/${meta.id}`}>\n\t\t\t\t\t\t\t\t\t\t{t('community-app-store.open-button')}\n\t\t\t\t\t\t\t\t\t</ButtonLink>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</Card>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</DialogScrollableContent>\n\t\t\t</DialogPortal>\n\t\t</Dialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/constants.ts",
    "content": "import {t} from '@/utils/i18n'\nimport {keyBy} from '@/utils/misc'\n\nexport const categories = [\n\t'files',\n\t'bitcoin',\n\t'media',\n\t'networking',\n\t'social',\n\t'automation',\n\t'finance',\n\t'ai',\n\t'developer',\n\t'crypto',\n] as const\n\nexport type Category = (typeof categories)[number]\n\nexport type Categoryish = Category | 'all' | 'discover'\n\n// Same order as in this app store\n// https://apps.umbrel.com/category/developer\nexport const categoryishDescriptions = [\n\t// categoryishes\n\t{id: 'discover', label: () => t('app-store.category.discover')},\n\t{id: 'all', label: () => t('app-store.category.all')},\n\t// categories\n\t{id: 'files', label: () => t('app-store.category.files')},\n\t{id: 'ai', label: () => t('app-store.category.ai')},\n\t{id: 'bitcoin', label: () => t('app-store.category.bitcoin')},\n\t{id: 'media', label: () => t('app-store.category.media')},\n\t{id: 'finance', label: () => t('app-store.category.finance')},\n\t{id: 'networking', label: () => t('app-store.category.networking')},\n\t{id: 'automation', label: () => t('app-store.category.automation')},\n\t{id: 'social', label: () => t('app-store.category.social')},\n\t{id: 'developer', label: () => t('app-store.category.developer')},\n\t{id: 'crypto', label: () => t('app-store.category.crypto')},\n] as const satisfies readonly {id: Categoryish; label: () => string}[]\n\nexport const categoryDescriptionsKeyed = keyBy(categoryishDescriptions, 'id')\n\nexport const UMBREL_APP_STORE_ID = 'umbrel-app-store'\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/discover/apps-grid-section.tsx",
    "content": "import {ReactNode} from 'react'\nimport {Link} from 'react-router-dom'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {cn} from '@/lib/utils'\nimport {appsGridClass, cardClass, cardFaintClass, SectionTitle, sectionTitleClass} from '@/modules/app-store/shared'\nimport {preloadFirstFewGalleryImages} from '@/modules/app-store/utils'\nimport {RegistryApp} from '@/trpc/trpc'\n\nexport function AppsGridSection({overline, title, apps}: {overline: string; title: ReactNode; apps?: RegistryApp[]}) {\n\tconst isMobile = useIsMobile()\n\tconst appsToShow = isMobile ? (apps ?? []).slice(0, 6) : (apps ?? [])\n\treturn (\n\t\t<div className={cardClass}>\n\t\t\t<SectionTitle overline={overline} title={title} />\n\t\t\t<div className={appsGridClass}>\n\t\t\t\t{appsToShow.map((app) => (\n\t\t\t\t\t<AppWithDescription key={app.id} app={app} />\n\t\t\t\t))}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\nexport function AppsGridFaintSection({title, apps}: {title?: string; apps?: RegistryApp[]}) {\n\treturn (\n\t\t<div className={cardFaintClass}>\n\t\t\t{title && <h3 className={cn(sectionTitleClass, 'p-2.5')}>{title}</h3>}\n\t\t\t<div className={appsGridClass}>\n\t\t\t\t{apps?.map((app) => (\n\t\t\t\t\t<AppWithDescription key={app.id} app={app} />\n\t\t\t\t))}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\nexport function AppWithDescription({app, to}: {app: RegistryApp; to?: string}) {\n\treturn (\n\t\t<Link\n\t\t\tto={to ? to : `/app-store/${app.id}`}\n\t\t\tclassName='group flex w-full items-start gap-2.5 rounded-20 p-2.5 outline-hidden hover:bg-white/4 focus:bg-white/4'\n\t\t\tonMouseEnter={() => preloadFirstFewGalleryImages(app)}\n\t\t>\n\t\t\t<AppIcon src={app.icon} size={48} className='rounded-10 md:w-[55px]' />\n\t\t\t<div className='flex min-w-0 flex-1 flex-col'>\n\t\t\t\t<h3 className='truncate text-13 font-bold -tracking-3 md:text-15'>{app.name}</h3>\n\t\t\t\t<p className='line-clamp-2 w-full min-w-0 text-12 leading-tight opacity-40 md:text-13'>{app.tagline}</p>\n\t\t\t</div>\n\t\t</Link>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/discover/apps-row-section.tsx",
    "content": "import {useRef} from 'react'\nimport {Link} from 'react-router-dom'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {useColorThief} from '@/hooks/use-color-thief'\nimport {SectionTitle} from '@/modules/app-store/shared'\nimport {preloadFirstFewGalleryImages} from '@/modules/app-store/utils'\nimport {RegistryApp} from '@/trpc/trpc'\n\nexport const AppsRowSection = ({overline, title, apps}: {overline: string; title: string; apps: RegistryApp[]}) => {\n\treturn (\n\t\t<div>\n\t\t\t<SectionTitle overline={overline} title={title} />\n\t\t\t<div className='umbrel-hide-scrollbar -mx-[70px] mt-3 flex flex-row gap-3 overflow-x-auto px-[70px] md:gap-[40px]'>\n\t\t\t\t{apps.map((app, i) => (\n\t\t\t\t\t<App key={app.id} app={app} index={i} />\n\t\t\t\t))}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\nfunction App({app, index}: {app: RegistryApp; index: number}) {\n\tconst iconRef = useRef<HTMLImageElement>(null)\n\tconst colors = useColorThief(iconRef)\n\n\treturn (\n\t\t<Link\n\t\t\tto={`/app-store/${app.id}`}\n\t\t\tonMouseEnter={() => preloadFirstFewGalleryImages(app)}\n\t\t\tclassName='animate-in duration-200 fill-mode-both slide-in-from-right-10 fade-in'\n\t\t\tstyle={{animationDelay: `${index * 0.1}s`}}\n\t\t>\n\t\t\t<AppIcon\n\t\t\t\tref={iconRef}\n\t\t\t\t// size={100}\n\t\t\t\tsrc={app.icon}\n\t\t\t\tcrossOrigin='anonymous'\n\t\t\t\tclassName='relative z-10 -mb-[30px] ml-[27px] w-[60px] rounded-12 md:mb-[-50px] md:w-[100px] md:rounded-24'\n\t\t\t\tstyle={{\n\t\t\t\t\tfilter: 'drop-shadow(0px 18px 24px rgba(0, 0, 0, 0.12))',\n\t\t\t\t}}\n\t\t\t/>\n\t\t\t<div\n\t\t\t\tclassName='relative flex h-[150px] w-[267px] flex-col justify-start overflow-hidden rounded-20 p-[27px] md:h-[188px] md:w-[345px]'\n\t\t\t\tstyle={{\n\t\t\t\t\tbackground: `radial-gradient(circle farthest-side at 30% 10%, #ffffff22, #00000000), linear-gradient(123deg, ${\n\t\t\t\t\t\tcolors ? colors[0] : '#24242499'\n\t\t\t\t\t}, ${colors ? colors[1] : '#18181899'})`,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{/* <div className='absolute inset-0 z-0 bg-red-500'></div> */}\n\t\t\t\t<h3 className='mt-3 truncate text-24 font-semibold -tracking-3 md:mt-8 md:text-[28px]'>{app.name}</h3>\n\t\t\t\t<p className='line-clamp-2 text-12 -tracking-4 opacity-70 md:text-16'>{app.tagline}</p>\n\t\t\t</div>\n\t\t</Link>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/discover/apps-three-column-section.tsx",
    "content": "import {useRef} from 'react'\nimport {Link} from 'react-router-dom'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {FadeScroller} from '@/components/fade-scroller'\nimport {Button} from '@/components/ui/button'\nimport {useColorThief} from '@/hooks/use-color-thief'\nimport {cn} from '@/lib/utils'\nimport {cardClass, sectionOverlineClass, sectionTitleClass} from '@/modules/app-store/shared'\nimport {preloadFirstFewGalleryImages} from '@/modules/app-store/utils'\nimport {RegistryApp} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport type AppsThreeColumnSectionProps = {\n\tapps: RegistryApp[]\n\toverline: string\n\ttitle: string\n\tdescription: string\n\ttextLocation?: 'left' | 'right'\n\tchildren: React.ReactNode\n}\n\nexport const AppsThreeColumnSection: React.FC<AppsThreeColumnSectionProps> = ({\n\tapps,\n\toverline,\n\ttitle,\n\tdescription,\n\ttextLocation = 'left',\n\tchildren,\n}) => {\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\tcardClass,\n\t\t\t\t'flex flex-wrap justify-center gap-x-16 gap-y-8 overflow-hidden p-4 text-center xl:flex-nowrap xl:text-left',\n\t\t\t)}\n\t\t>\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'flex w-full flex-col items-center justify-center md:w-auto xl:items-start',\n\t\t\t\t\ttextLocation === 'right' && 'xl:order-2',\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t<p className={sectionOverlineClass}>{overline}</p>\n\t\t\t\t<h3 className={sectionTitleClass}>{title}</h3>\n\t\t\t\t<p className='max-w-md text-14 opacity-60'>{description}</p>\n\t\t\t\t<div className='pt-5' />\n\t\t\t\t{children}\n\t\t\t</div>\n\t\t\t{/* shrink-0 to prevent scrolling at larger sizes */}\n\t\t\t<FadeScroller direction='x' className='umbrel-hide-scrollbar flex gap-5 overflow-x-auto md:w-auto md:shrink-0'>\n\t\t\t\t<ColorApp app={apps[0]} />\n\t\t\t\t<ColorApp app={apps[1]} />\n\t\t\t\t<ColorApp app={apps[2]} />\n\t\t\t</FadeScroller>\n\t\t</div>\n\t)\n}\n\nfunction ColorApp({app, className}: {app: RegistryApp; className?: string}) {\n\tconst iconRef = useRef<HTMLImageElement>(null)\n\tconst colors = useColorThief(iconRef)\n\n\treturn (\n\t\t<div className={cn('relative', colors)}>\n\t\t\t<Link\n\t\t\t\tto={`/app-store/${app.id}`}\n\t\t\t\tclassName={cn('flex h-[268px] w-40 flex-col justify-stretch rounded-24 bg-white/10 px-3 py-4', className)}\n\t\t\t\tstyle={{\n\t\t\t\t\tbackgroundImage: colors\n\t\t\t\t\t\t? `linear-gradient(to bottom, ${colors.join(', ')})`\n\t\t\t\t\t\t: 'linear-gradient(to bottom, #24242499, #18181899',\n\t\t\t\t}}\n\t\t\t\tonMouseEnter={() => preloadFirstFewGalleryImages(app)}\n\t\t\t>\n\t\t\t\t<AppIcon\n\t\t\t\t\tref={iconRef}\n\t\t\t\t\tsrc={app.icon}\n\t\t\t\t\tcrossOrigin='anonymous'\n\t\t\t\t\tsize={128}\n\t\t\t\t\tclassName='shrink-0 self-center rounded-24'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tfilter: `drop-shadow(0px 8px 12.000000953674316px rgba(31, 33, 36, 0.32))`,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t\t<div className='flex-1' />\n\t\t\t\t<h3 className='font-16 truncate font-bold'>{app.name}</h3>\n\t\t\t\t<p className='truncate text-13 -tracking-3 opacity-50'>{app.developer}</p>\n\t\t\t\t<Button size='sm' variant='secondary' className='mt-2'>\n\t\t\t\t\t{t('app.view')}\n\t\t\t\t</Button>\n\t\t\t</Link>\n\t\t\t{/* <div\n\t\t\t\tclassName='absolute -top-4 left-1/2 -translate-x-1/2 rounded-24 bg-neutral-700 p-6'\n\t\t\t\tstyle={{\n\t\t\t\t\tboxShadow:\n\t\t\t\t\t\t'1px 1px 1px 0px rgba(255, 255, 255, 0.20) inset, -1px -1px 4px 0px rgba(0, 0, 0, 0.06) inset, 0px 8px 16px 0px rgba(0, 0, 0, 0.12)',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<AppIcon\n\t\t\t\t\tsrc={app.icon}\n\t\t\t\t\tsize={128}\n\t\t\t\t\tclassName='relative z-10 shrink-0 rounded-24'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tfilter: `drop-shadow(0px 8px 12.000000953674316px rgba(31, 33, 36, 0.32))`,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</div> */}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/gallery-section.tsx",
    "content": "import PhotoSwipeLightbox from 'photoswipe/lightbox'\nimport {Link} from 'react-router-dom'\n\nimport 'photoswipe/style.css'\n\nimport {useEffect} from 'react'\n\nimport {FadeInImg} from '@/components/ui/fade-in-img'\nimport {cn} from '@/lib/utils'\nimport {Banner} from '@/routes/app-store/use-discover-query'\nimport {tw} from '@/utils/tw'\n\nexport const AppsGallerySection: React.FC<{banners: Banner[]}> = ({banners}) => {\n\treturn (\n\t\t<div className={galleryRootClass}>\n\t\t\t{banners.map((banner, i) => (\n\t\t\t\t<Link\n\t\t\t\t\tkey={banner.id}\n\t\t\t\t\tto={`/app-store/${banner.id}`}\n\t\t\t\t\tclassName={cn(galleryItemClass, 'aspect-2.25 h-[140px] rounded-20 md:h-[316px]')}\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tanimationDelay: `${i * 0.1}s`,\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<FadeInImg src={banner.image} className='group-focus-visible:opacity-80' alt='' />\n\t\t\t\t</Link>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n\nexport const AppGallerySection: React.FC<{gallery: string[]; galleryId: string}> = ({gallery, galleryId}) => {\n\tuseEffect(() => {\n\t\tlet lightbox: PhotoSwipeLightbox | null = new PhotoSwipeLightbox({\n\t\t\tgallery: '#' + galleryId,\n\t\t\tchildren: 'a',\n\t\t\tpswpModule: () => import('photoswipe'),\n\t\t})\n\t\tlightbox.init()\n\n\t\treturn () => {\n\t\t\tlightbox?.destroy()\n\t\t\tlightbox = null\n\t\t}\n\t}, [galleryId])\n\n\treturn (\n\t\t<div className={cn(galleryRootClass, 'pswp-gallery')} id={galleryId}>\n\t\t\t{gallery.map((src, i) => (\n\t\t\t\t<a\n\t\t\t\t\tkey={src}\n\t\t\t\t\thref={src}\n\t\t\t\t\tdata-pswp-width={2880}\n\t\t\t\t\tdata-pswp-height={1800}\n\t\t\t\t\tclassName={cn(galleryItemClass, 'aspect-1.6 h-[200px] rounded-12 md:h-[292px]')}\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tanimationDelay: `${i * 0.1}s`,\n\t\t\t\t\t}}\n\t\t\t\t\ttarget='_blank'\n\t\t\t\t\trel='noreferrer'\n\t\t\t\t>\n\t\t\t\t\t<FadeInImg src={src} className='group-focus-visible:opacity-80' alt='' />\n\t\t\t\t</a>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n\nexport const galleryRootClass = tw`-mx-[70px] px-[70px] umbrel-hide-scrollbar flex gap-2 md:gap-5 overflow-x-auto`\n\nexport const galleryItemClass = tw`group shrink-0 bg-white/10 bg-cover outline-hidden ring-inset focus-visible:ring-4 ring-white/80 animate-in fade-in fill-mode-both slide-in-from-right-10 overflow-hidden`\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/os-update-required.tsx",
    "content": "import {t} from 'i18next'\nimport {RiArrowUpLine} from 'react-icons/ri'\nimport {useNavigate} from 'react-router-dom'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {RegistryApp} from '@/trpc/trpc'\n\nexport function OSUpdateRequiredDialog({\n\tapp,\n\topen,\n\tonOpenChange,\n}: {\n\tapp: RegistryApp\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n}) {\n\tconst navigate = useNavigate()\n\tconst version = app.manifestVersion.replace(/\\.0$/, '')\n\n\tconst handleConfirm = () => {\n\t\tonOpenChange(false)\n\t\tnavigate('/settings/software-update')\n\t}\n\n\treturn (\n\t\t<AlertDialog open={open} onOpenChange={onOpenChange}>\n\t\t\t<AlertDialogContent>\n\t\t\t\t<AlertDialogHeader icon={RiArrowUpLine}>\n\t\t\t\t\t<AlertDialogTitle>{t('app.os-update-required.title')}</AlertDialogTitle>\n\t\t\t\t\t<AlertDialogDescription>\n\t\t\t\t\t\t{t('app.os-update-required.description', {appName: app.name, version})}\n\t\t\t\t\t</AlertDialogDescription>\n\t\t\t\t</AlertDialogHeader>\n\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t<AlertDialogAction variant='primary' className='px-6' onClick={handleConfirm}>\n\t\t\t\t\t\t{t('app.os-update-required.confirm')}\n\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t</AlertDialogFooter>\n\t\t\t</AlertDialogContent>\n\t\t</AlertDialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/select-dependencies-dialog.tsx",
    "content": "import {Close} from '@radix-ui/react-dialog'\nimport {SetStateAction, useEffect, useMemo, useState} from 'react'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {ChevronDown} from '@/components/chevron-down'\nimport {appStateToString} from '@/components/cmdk'\nimport {Button} from '@/components/ui/button'\nimport {ButtonLink} from '@/components/ui/button-link'\nimport {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'\nimport {\n\tDropdownMenu,\n\tDropdownMenuCheckboxItem,\n\tDropdownMenuContent,\n\tDropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {cn} from '@/lib/utils'\nimport {useApps} from '@/providers/apps'\nimport {useAllAvailableApps} from '@/providers/available-apps'\nimport {AppState, installedStates, RegistryApp} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nexport function SelectDependenciesDialog({\n\topen,\n\tonOpenChange,\n\tappId,\n\tdependencies,\n\tonNext,\n\thighlightDependency,\n}: {\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n\tappId: string\n\tdependencies: {dependencyId: string; appId: string}[][]\n\tonNext: (selectedDeps: Record<string, string>) => void\n\thighlightDependency?: string\n}) {\n\tconst availableApps = useAllAvailableApps()\n\tconst {isLoading, userApps, userAppsKeyed} = useApps()\n\tconst [selectedDependencies, setSelectedDependencies] = useState<Record<string, string>>({})\n\n\t// Try user app first in case the app was installed at some point but is not\n\t// present in an app store anymore, for example because a community app store\n\t// has been removed. UserApp and RegistryApp share the necessary properties.\n\tconst registryApp = availableApps.appsKeyed?.[appId]\n\tconst userApp = userAppsKeyed?.[appId]\n\tconst app = userApp ?? registryApp\n\tif (!app) throw new Error('App not found')\n\n\tif (isLoading || !userApps || !userAppsKeyed || availableApps.isLoading) return null\n\n\tconst appName = app?.name\n\n\tconst areAllDependenciesInstalled = dependencies.every((alternatives) =>\n\t\talternatives.some((alternative) =>\n\t\t\tObject.values(userAppsKeyed).some(\n\t\t\t\t(installedApp) =>\n\t\t\t\t\tinstalledApp.id === selectedDependencies[alternative.dependencyId] &&\n\t\t\t\t\tarrayIncludes(installedStates, installedApp.state),\n\t\t\t),\n\t\t),\n\t)\n\n\treturn (\n\t\t<Dialog open={open} onOpenChange={onOpenChange}>\n\t\t\t<DialogContent\n\t\t\t\tonOpenAutoFocus={(e) => {\n\t\t\t\t\t// `preventDefault` to prevent focus on first input\n\t\t\t\t\te.preventDefault()\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<DialogHeader>\n\t\t\t\t\t<DialogTitle>{t('install-first.title', {app: appName})}</DialogTitle>\n\t\t\t\t</DialogHeader>\n\t\t\t\t<SelectDependencies\n\t\t\t\t\tdependencies={dependencies}\n\t\t\t\t\tselectedDependencies={selectedDependencies}\n\t\t\t\t\tsetSelectedDependencies={setSelectedDependencies}\n\t\t\t\t\tonInstallClick={() => onOpenChange(false)}\n\t\t\t\t\thighlightDependency={highlightDependency}\n\t\t\t\t/>\n\t\t\t\t<DialogFooter>\n\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t<Close asChild>\n\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\t\t\tdisabled={!areAllDependenciesInstalled}\n\t\t\t\t\t\t\t\tonClick={() => onNext(selectedDependencies)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t('install-first.install-app', {app: appName})}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</Close>\n\t\t\t\t\t\t<Close asChild>\n\t\t\t\t\t\t\t<Button size='dialog'>{t('cancel')}</Button>\n\t\t\t\t\t\t</Close>\n\t\t\t\t\t</DialogFooter>\n\t\t\t\t</DialogFooter>\n\t\t\t</DialogContent>\n\t\t</Dialog>\n\t)\n}\n\n// Reusable dependencies selection\nexport function SelectDependencies({\n\tdependencies,\n\tselectedDependencies,\n\tsetSelectedDependencies,\n\tonInstallClick,\n\thighlightDependency,\n}: {\n\tdependencies: {dependencyId: string; appId: string}[][]\n\tselectedDependencies: Record<string, string>\n\tsetSelectedDependencies: (selectedDependencies: Record<string, string>) => void\n\tonInstallClick: () => void\n\thighlightDependency?: string\n}) {\n\tconst {apps, appsKeyed} = useAllAvailableApps()\n\tconst {isLoading, userApps, userAppsKeyed} = useApps()\n\tconst [openDropdowns, setOpenDropdowns] = useState<Record<string, boolean>>({})\n\n\t// Reify dependency data once we have the list of available apps\n\tconst reifiedDependencies = useMemo(() => {\n\t\tif (!appsKeyed) return []\n\t\treturn dependencies.map((alternatives) =>\n\t\t\talternatives.map(({dependencyId, appId}) => ({\n\t\t\t\tdependencyId,\n\t\t\t\tapp: appsKeyed[appId],\n\t\t\t})),\n\t\t)\n\t}, [appsKeyed, dependencies])\n\n\t// Pre-select installed apps or main alternatives when dependencies change or\n\t// when the list of user/available apps becomes available\n\tuseEffect(() => {\n\t\tif (!userAppsKeyed || reifiedDependencies.length === 0) return\n\t\tconst newSelectedDependencies: Record<string, string> = {\n\t\t\t...selectedDependencies,\n\t\t}\n\n\t\treifiedDependencies.forEach((alternatives) => {\n\t\t\tconst dependencyId = alternatives[0].dependencyId\n\t\t\tif (newSelectedDependencies[dependencyId]) return\n\n\t\t\tconst installedOrInstallingApp = alternatives.find(({app}) => {\n\t\t\t\tconst userApp = userAppsKeyed?.[app.id]\n\t\t\t\treturn userApp && (arrayIncludes(installedStates, userApp.state) || userApp.state === 'installing')\n\t\t\t})\n\n\t\t\tnewSelectedDependencies[dependencyId] = installedOrInstallingApp\n\t\t\t\t? installedOrInstallingApp.app.id\n\t\t\t\t: alternatives[0].app.id\n\t\t})\n\n\t\tsetSelectedDependencies(newSelectedDependencies)\n\t}, [dependencies, userAppsKeyed, reifiedDependencies])\n\n\tif (isLoading || !userApps || !userAppsKeyed || !apps || !appsKeyed) return null\n\n\tconst selectDependency = (dependencyId: string, appId: string) => {\n\t\tconst newSelectedDependencies = {\n\t\t\t...selectedDependencies,\n\t\t\t[dependencyId]: appId,\n\t\t}\n\t\tsetSelectedDependencies(newSelectedDependencies)\n\t}\n\n\treturn (\n\t\t<div className={listClass}>\n\t\t\t{reifiedDependencies.map((alternatives) => {\n\t\t\t\tconst {dependencyId, app} = alternatives[0]\n\t\t\t\tconst hasAlternatives = alternatives.length > 1\n\n\t\t\t\tif (!hasAlternatives) {\n\t\t\t\t\t// If no alternatives, just show the app name and state\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<div key={dependencyId} className={listItemClass}>\n\t\t\t\t\t\t\t<span className='flex flex-1 flex-row items-center gap-2 pl-4'>\n\t\t\t\t\t\t\t\t{app.icon && <AppIcon size={26} src={app.icon} className='rounded-6' />}\n\t\t\t\t\t\t\t\t{app.name}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<DependencyStateText\n\t\t\t\t\t\t\t\tappId={app.id}\n\t\t\t\t\t\t\t\tappState={userAppsKeyed?.[app.id]?.state ?? 'not-installed'}\n\t\t\t\t\t\t\t\tonClick={onInstallClick}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\t// If has alternatives, show dropdown\n\t\t\t\treturn (\n\t\t\t\t\t<div key={dependencyId} className={listItemClassWithDropdown}>\n\t\t\t\t\t\t<DependencyDropdown\n\t\t\t\t\t\t\tdependencyId={dependencyId}\n\t\t\t\t\t\t\tselectedApp={appsKeyed[selectedDependencies[dependencyId]]}\n\t\t\t\t\t\t\talternatives={alternatives}\n\t\t\t\t\t\t\topenDropdowns={openDropdowns}\n\t\t\t\t\t\t\tsetOpenDropdowns={setOpenDropdowns}\n\t\t\t\t\t\t\tonSelectDependency={selectDependency}\n\t\t\t\t\t\t\thighlightDependency={highlightDependency}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<DependencyStateText\n\t\t\t\t\t\t\tappId={selectedDependencies[dependencyId]}\n\t\t\t\t\t\t\tappState={userAppsKeyed?.[selectedDependencies[dependencyId]]?.state ?? 'not-installed'}\n\t\t\t\t\t\t\tonClick={onInstallClick}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t)\n\t\t\t})}\n\t\t</div>\n\t)\n}\n\nconst listClass = tw`divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6`\nconst listItemClass = tw`flex items-center pl-3 pr-4 h-[50px] text-[14px] font-medium -tracking-3 justify-between`\nconst listItemClassWithDropdown = tw`flex items-center pl-3 pr-4 h-[60px] text-[14px] font-medium -tracking-3 justify-between`\n\nfunction DependencyStateText({appId, appState, onClick}: {appId: string; appState: AppState; onClick?: () => void}) {\n\tconst buttonClass = 'w-[70px]' // Fixed width for both buttons\n\n\tif (arrayIncludes(installedStates, appState)) {\n\t\treturn (\n\t\t\t<Button disabled={true} variant='default' size='sm' className={`opacity-50 ${buttonClass}`}>\n\t\t\t\t{t('app.installed')}\n\t\t\t</Button>\n\t\t)\n\t}\n\n\tif (appState === 'not-installed') {\n\t\treturn (\n\t\t\t// TODO: link to community app store if needed using `getAppStoreAppFromInstalledApp`\n\t\t\t<ButtonLink to={`/app-store/${appId}`} onClick={onClick} variant='primary' size='sm' className={buttonClass}>\n\t\t\t\t{t('app.install')}\n\t\t\t</ButtonLink>\n\t\t)\n\t}\n\n\treturn <span className='text-sm opacity-50'>{appStateToString(appState) + '...'}</span>\n}\n\nfunction DependencyDropdown({\n\tdependencyId,\n\tselectedApp,\n\talternatives,\n\topenDropdowns,\n\tsetOpenDropdowns,\n\tonSelectDependency,\n\thighlightDependency,\n}: {\n\tdependencyId: string\n\tselectedApp?: RegistryApp\n\talternatives: {dependencyId: string; app: RegistryApp}[]\n\topenDropdowns: Record<string, boolean>\n\tsetOpenDropdowns: (value: SetStateAction<Record<string, boolean>>) => void\n\tonSelectDependency: (dependencyId: string, appId: string) => void\n\thighlightDependency?: string\n}) {\n\tconst onOpenChange = (open: boolean) => setOpenDropdowns((prev) => ({...prev, [dependencyId]: open}))\n\treturn (\n\t\t<DropdownMenu open={openDropdowns[dependencyId] ?? false} onOpenChange={onOpenChange}>\n\t\t\t<DropdownMenuTrigger asChild className={cn(highlightDependency === dependencyId && 'umbrel-pulse-a-few-times')}>\n\t\t\t\t<Button className='h-[40px] w-[256px] max-w-[calc(100%-90px)] px-4'>\n\t\t\t\t\t<div className='flex min-w-0 flex-1 items-center gap-2 text-left'>\n\t\t\t\t\t\t{selectedApp ? (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{selectedApp.icon && <AppIcon size={26} src={selectedApp.icon} className='shrink-0 rounded-6' />}\n\t\t\t\t\t\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t\t\t\t\t\t<span className='block truncate text-[14px]'>{selectedApp.name}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t\t\t\t\t<span className='block truncate text-[14px]'>{t('app-picker.select-app')}</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t\t<ChevronDown />\n\t\t\t\t</Button>\n\t\t\t</DropdownMenuTrigger>\n\t\t\t<DropdownMenuContent className='flex max-h-72 w-[256px] flex-col gap-3' align='start'>\n\t\t\t\t<ScrollArea className='relative -mx-2.5 flex h-full flex-col px-2.5'>\n\t\t\t\t\t{alternatives.map(({app}) => (\n\t\t\t\t\t\t<DropdownMenuCheckboxItem\n\t\t\t\t\t\t\tkey={app.id}\n\t\t\t\t\t\t\tchecked={app.id === selectedApp?.id}\n\t\t\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\t\t\tonSelectDependency(dependencyId, app.id)\n\t\t\t\t\t\t\t\tonOpenChange(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName='flex gap-2'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<AppIcon size={20} src={app.icon} className='rounded-4' />\n\t\t\t\t\t\t\t{app.name}\n\t\t\t\t\t\t</DropdownMenuCheckboxItem>\n\t\t\t\t\t))}\n\t\t\t\t</ScrollArea>\n\t\t\t</DropdownMenuContent>\n\t\t</DropdownMenu>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/shared.tsx",
    "content": "import {ReactNode} from 'react'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {SheetHeader, SheetTitle} from '@/components/ui/sheet'\nimport {cn} from '@/lib/utils'\nimport {tw} from '@/utils/tw'\n\nexport const slideInFromBottomClass = tw`animate-in fade-in slide-in-from-bottom-8 duration-300`\n\nconst cardBaseClass = tw`rounded-20 px-2 py-2 md:px-[26px] md:py-[36px]`\nexport const cardClass = cn(cardBaseClass, tw`bg-linear-to-b from-[#24242499] to-[#18181899]`)\nexport const cardFaintClass = cn(cardBaseClass, tw`bg-white/4`)\n\nexport const appsGridClass = tw`grid gap-x-5 md:gap-y-5 sm:grid-cols-2 xl:grid-cols-3`\nexport const sectionOverlineClass = tw`text-12 font-bold uppercase leading-tight opacity-50 md:text-15 mt-1 mb-1.5 md:mb-2 md:my-0`\nexport const sectionTitleClass = tw`text-18 font-bold leading-tight md:text-32 md:mb-4`\n\nexport function SectionTitle({overline, title}: {overline: string; title: ReactNode}) {\n\treturn (\n\t\t<div className='p-2.5'>\n\t\t\t<p className={sectionOverlineClass}>{overline}</p>\n\t\t\t<h3 className={sectionTitleClass}>{title}</h3>\n\t\t</div>\n\t)\n}\n\nexport function AppStoreSheetInner({\n\tchildren,\n\tbeforeHeaderChildren,\n\ttitleRightChildren,\n\ttitle,\n}: {\n\tchildren: ReactNode\n\tbeforeHeaderChildren?: ReactNode\n\ttitleRightChildren?: ReactNode\n\ttitle: string\n}) {\n\treturn (\n\t\t<div className='flex flex-col gap-5 md:gap-8'>\n\t\t\t{beforeHeaderChildren}\n\t\t\t<SheetHeader className='gap-5'>\n\t\t\t\t<div className='flex flex-col gap-x-5 gap-y-5 px-2.5 md:flex-row md:items-center md:px-0'>\n\t\t\t\t\t<SheetTitle className='flex-1 leading-none whitespace-nowrap capitalize'>{title}</SheetTitle>\n\t\t\t\t\t{titleRightChildren}\n\t\t\t\t</div>\n\t\t\t</SheetHeader>\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n\nexport function AppWithName({\n\ticon,\n\tappName,\n\tchildrenRight,\n\tclassName,\n}: {\n\ticon: string\n\tappName: ReactNode\n\tchildrenRight?: ReactNode\n\tclassName?: string\n}) {\n\treturn (\n\t\t<div className={cn('flex w-full items-center gap-2.5', className)}>\n\t\t\t<AppIcon src={icon} size={36} className='rounded-8' />\n\t\t\t<h3 className='flex-1 truncate text-14 leading-tight font-semibold -tracking-3'>{appName}</h3>\n\t\t\t{childrenRight}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/updates-button.tsx",
    "content": "import {TbCircleArrowUp} from 'react-icons/tb'\n\nimport {ButtonLink} from '@/components/ui/button-link'\nimport {NotificationBadge} from '@/components/ui/notification-badge'\nimport {useAppsWithUpdates} from '@/hooks/use-apps-with-updates'\nimport {useLinkToDialog} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nimport {UpdatesDialogConnected} from './updates-dialog'\n\nexport function UpdatesButton() {\n\tconst linkToDialog = useLinkToDialog()\n\tconst {appsWithUpdates, isLoading} = useAppsWithUpdates()\n\n\tif (isLoading) return null\n\n\t// If we link to the updates dialog, show it even if there are no updates\n\tif (!appsWithUpdates.length) {\n\t\treturn <UpdatesDialogConnected />\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t{/* w-auto because 'dialog' size buttons take up full width on mobile */}\n\t\t\t<ButtonLink to={linkToDialog('updates')} size='md' className='relative w-auto' variant='primary'>\n\t\t\t\t<TbCircleArrowUp />\n\t\t\t\t{t('app-store.updates')}\n\t\t\t\t<NotificationBadge count={appsWithUpdates.length} />\n\t\t\t</ButtonLink>\n\t\t\t<UpdatesDialogConnected />\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/updates-dialog.tsx",
    "content": "import {DialogProps} from '@radix-ui/react-dialog'\nimport {Fragment, useState} from 'react'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {appStateToString} from '@/components/cmdk'\nimport {Markdown} from '@/components/markdown'\nimport {ProgressButton} from '@/components/progress-button'\nimport {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogHeader, DialogPortal, DialogTitle} from '@/components/ui/dialog'\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {Separator} from '@/components/ui/separator'\nimport {useAppsWithUpdates} from '@/hooks/use-apps-with-updates'\nimport {useUpdateAllApps} from '@/hooks/use-update-all-apps'\nimport {cn} from '@/lib/utils'\nimport {progressStates, RegistryApp, trpcReact} from '@/trpc/trpc'\nimport {MS_PER_SECOND} from '@/utils/date-time'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nexport function UpdatesDialogConnected() {\n\tconst dialogProps = useDialogOpenProps('updates')\n\tconst {appsWithUpdates, isLoading} = useAppsWithUpdates()\n\tconst updateAll = useUpdateAllApps()\n\n\tif (isLoading) return null\n\n\treturn (\n\t\t<UpdatesDialog\n\t\t\t{...dialogProps}\n\t\t\topen={dialogProps.open}\n\t\t\tappsWithUpdates={appsWithUpdates}\n\t\t\ttitleRightChildren={\n\t\t\t\t<Button\n\t\t\t\t\tsize='md'\n\t\t\t\t\tvariant='primary'\n\t\t\t\t\tonClick={updateAll.updateAll}\n\t\t\t\t\tclassName='w-auto'\n\t\t\t\t\tdisabled={updateAll.isLoading || updateAll.isUpdating || appsWithUpdates.length === 0}\n\t\t\t\t>\n\t\t\t\t\t{updateAll.isUpdating ? t('app-updates.updating') : t('app-updates.update-all')}\n\t\t\t\t</Button>\n\t\t\t}\n\t\t/>\n\t)\n}\n\nexport function UpdatesDialog({\n\tappsWithUpdates,\n\ttitleRightChildren,\n\t...dialogProps\n}: {\n\tappsWithUpdates: RegistryApp[]\n\ttitleRightChildren?: React.ReactNode\n} & DialogProps) {\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogPortal>\n\t\t\t\t<DialogContent\n\t\t\t\t\tclassName='top-[10%] max-h-[calc(100vh-20%)] translate-y-0 gap-0 p-0 py-5 data-[state=closed]:slide-out-to-top-[0%] data-[state=open]:slide-in-from-top-[0%]'\n\t\t\t\t\tslide={false}\n\t\t\t\t>\n\t\t\t\t\t<DialogHeader className='px-5 pb-5'>\n\t\t\t\t\t\t<DialogTitle className='flex flex-row items-center justify-between'>\n\t\t\t\t\t\t\t<span>{t('app-updates.updates-available-count', {count: appsWithUpdates.length})}</span>\n\t\t\t\t\t\t\t{titleRightChildren}\n\t\t\t\t\t\t</DialogTitle>\n\t\t\t\t\t</DialogHeader>\n\t\t\t\t\t<Separator />\n\t\t\t\t\t<ScrollArea className='flex max-h-[500px] flex-col gap-y-2.5 px-5'>\n\t\t\t\t\t\t{appsWithUpdates.length === 0 && (\n\t\t\t\t\t\t\t<p className='p-4 text-center text-13 opacity-40'>{t('app-updates.no-updates')}</p>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{appsWithUpdates.map((app, i) => (\n\t\t\t\t\t\t\t<Fragment key={app.id}>\n\t\t\t\t\t\t\t\t{i === 0 ? undefined : <Separator className='my-1' />}\n\t\t\t\t\t\t\t\t<AppItem app={app} />\n\t\t\t\t\t\t\t</Fragment>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</ScrollArea>\n\t\t\t\t</DialogContent>\n\t\t\t</DialogPortal>\n\t\t</Dialog>\n\t)\n}\nfunction AppItem({app}: {app: RegistryApp}) {\n\tconst appStateQ = trpcReact.apps.state.useQuery(\n\t\t{appId: app.id},\n\t\t{\n\t\t\trefetchInterval: 2 * MS_PER_SECOND,\n\t\t},\n\t)\n\tconst [showAll, setShowAll] = useState(false)\n\tconst utils = trpcReact.useUtils()\n\tconst updateMut = trpcReact.apps.update.useMutation({\n\t\tonMutate: () => {\n\t\t\t// Optimistic updates because otherwise it's too slow and feels like nothing is happening\n\t\t\tutils.apps.state.cancel()\n\t\t\tutils.apps.state.setData({appId: app.id}, {state: 'updating', progress: 0})\n\t\t},\n\t\tonSuccess: () => {\n\t\t\t// This should cause the app to be removed from the list\n\t\t\tutils.apps.list.invalidate()\n\t\t},\n\t})\n\tconst updateApp = () => updateMut.mutate({appId: app.id})\n\n\tconst progress = appStateQ.data?.progress\n\tconst appState = appStateQ.isLoading ? 'loading' : appStateQ.data!.state\n\tconst inProgress = arrayIncludes(progressStates, appState)\n\n\treturn (\n\t\t<div className='p-2.5'>\n\t\t\t<div className='flex items-center gap-2.5'>\n\t\t\t\t<AppIcon src={app.icon} size={36} className='rounded-8' />\n\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t<h3 className='text-13 font-semibold'>{app.name}</h3>\n\t\t\t\t\t<p className='text-13 opacity-40'>{app.version}</p>\n\t\t\t\t</div>\n\t\t\t\t<div className='flex-1' />\n\t\t\t\t<ProgressButton\n\t\t\t\t\tsize='sm'\n\t\t\t\t\tonClick={updateApp}\n\t\t\t\t\tdisabled={inProgress || updateMut.isPending}\n\t\t\t\t\tstate={appState}\n\t\t\t\t\tprogress={progress}\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\t['--progress-button-bg' as string]: 'hsl(0 0 30%)',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{inProgress ? appStateToString(appState) + '...' : t('app-updates.update')}\n\t\t\t\t</ProgressButton>\n\t\t\t</div>\n\t\t\t{app.releaseNotes && (\n\t\t\t\t<div className='relative mt-2 grid'>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName={cn('relative overflow-x-auto text-13 opacity-50 transition-all')}\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tmaskImage: showAll ? undefined : 'linear-gradient(-45deg, transparent 30px, white 60px, white)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tref={(ref) => {\n\t\t\t\t\t\t\tref?.addEventListener('focusin', () => {\n\t\t\t\t\t\t\t\tsetShowAll(true)\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<Markdown className={cn('text-13 leading-snug -tracking-3', !showAll && 'line-clamp-2')}>\n\t\t\t\t\t\t\t{app.releaseNotes}\n\t\t\t\t\t\t</Markdown>\n\t\t\t\t\t</div>\n\t\t\t\t\t<button\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t'justify-self-end text-13 text-brand underline underline-offset-2',\n\t\t\t\t\t\t\t!showAll && 'absolute right-0 bottom-0',\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tonClick={() => setShowAll((s) => !s)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{showAll ? t('app-updates.less') : t('app-updates.more')}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/app-store/utils.ts",
    "content": "import {RegistryApp} from '@/trpc/trpc'\nimport {preloadImage} from '@/utils/misc'\n\nimport {categoryDescriptionsKeyed, categoryishDescriptions, type Categoryish} from './constants'\n\nconst alreadyPreloadedFirstFewGalleryImages = new Set<string>()\n\nexport function preloadFirstFewGalleryImages(app: RegistryApp) {\n\tif (alreadyPreloadedFirstFewGalleryImages.has(app.id)) return\n\talreadyPreloadedFirstFewGalleryImages.add(app.id)\n\tapp.gallery.slice(0, 3).map(preloadImage)\n}\n\nexport function getCategoryLabel(categoryId: string): string {\n\tconst predefined = categoryDescriptionsKeyed[categoryId as Categoryish]\n\n\t// Return the translated label for a category if umbrelOS is aware of it\n\tif (predefined) return predefined.label()\n\n\t// Otherwise we just capitalize the first letter\n\treturn (\n\t\tcategoryId\n\t\t\t// Support snake_case and kebab-case manifest categories for future compatibility\n\t\t\t.split(/[-_]/)\n\t\t\t.map((word) => word.charAt(0).toUpperCase() + word.slice(1))\n\t\t\t.join(' ')\n\t)\n}\n\n// Returns all app categories (predefined + any others from actual app data)\n// keeping the predefined categories in the order given in `categoryishDescriptions`\nexport function getAllCategories(appsGroupedByCategory: Record<string, any[]>) {\n\tconst predefinedCategories = categoryishDescriptions.map((c) => c.id)\n\tconst dynamicCategories = Object.keys(appsGroupedByCategory).filter(\n\t\t(cat) => !predefinedCategories.includes(cat as any),\n\t)\n\n\treturn [...predefinedCategories, ...dynamicCategories]\n}\n"
  },
  {
    "path": "packages/ui/src/modules/auth/ensure-backend-available.tsx",
    "content": "import {BareCoverMessage} from '@/components/ui/cover-message'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function EnsureBackendAvailable({children}: {children: React.ReactNode}) {\n\t// TODO: probably want a straightforward `fetch` call here instead of using trpc. This will allow us to check if the backend is available before we even load the trpc provider.\n\tconst getQuery = trpcReact.system.online.useQuery(undefined, {\n\t\tretry: false,\n\t})\n\n\tif (getQuery.isLoading) {\n\t\treturn <BareCoverMessage delayed>{t('trpc.checking-backend')}</BareCoverMessage>\n\t}\n\n\tif (getQuery.error) {\n\t\treturn <BareCoverMessage>{t('trpc.backend-unavailable')}</BareCoverMessage>\n\t}\n\n\treturn children\n}\n"
  },
  {
    "path": "packages/ui/src/modules/auth/ensure-logged-in.tsx",
    "content": "import {BareCoverMessage} from '@/components/ui/cover-message'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nimport {RedirectHome, RedirectLogin} from './redirects'\n\nexport function EnsureLoggedIn({children}: {children?: React.ReactNode}) {\n\treturn (\n\t\t<EnsureLoggedInState loggedIn otherwise={<RedirectLogin />}>\n\t\t\t{children}\n\t\t</EnsureLoggedInState>\n\t)\n}\n\nexport function EnsureLoggedOut({children}: {children?: React.ReactNode}) {\n\treturn (\n\t\t<EnsureLoggedInState loggedIn={false} otherwise={<RedirectHome />}>\n\t\t\t{children}\n\t\t</EnsureLoggedInState>\n\t)\n}\n\n/** Don't show children unless logged in */\nfunction EnsureLoggedInState({\n\tloggedIn,\n\totherwise,\n\tchildren,\n}: {\n\tloggedIn: boolean\n\totherwise: React.ReactNode\n\tchildren?: React.ReactNode\n}) {\n\tconst isLoggedInQ = trpcReact.user.isLoggedIn.useQuery(undefined)\n\tconst isLoggedIn = isLoggedInQ.data ?? false\n\tconst wantsLoggedIn = loggedIn\n\n\t// ---\n\n\tif (isLoggedInQ.isLoading) {\n\t\treturn <BareCoverMessage delayed>{t('auth.checking-backend-for-user')}</BareCoverMessage>\n\t}\n\n\tif (isLoggedInQ.isError) {\n\t\treturn <BareCoverMessage>{t('auth.failed-checking-if-user-logged-in')}</BareCoverMessage>\n\t}\n\n\tif (isLoggedIn === wantsLoggedIn) {\n\t\treturn children\n\t} else {\n\t\treturn otherwise\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/modules/auth/ensure-no-raid-mount-failure.tsx",
    "content": "import {BareCoverMessage} from '@/components/ui/cover-message'\nimport {Loading} from '@/components/ui/loading'\nimport {trpcReact} from '@/trpc/trpc'\n\nimport {RedirectRaidError} from './redirects'\n\n// Checks if RAID mount failed during boot.\n// If mount failed, we redirect to the raid error screen.\nexport function EnsureNoRaidMountFailure({children}: {children?: React.ReactNode}) {\n\tconst mountFailureQ = trpcReact.hardware.raid.checkRaidMountFailure.useQuery(undefined, {\n\t\tretry: false,\n\t})\n\n\t// Still loading - show spinner\n\tif (mountFailureQ.isLoading) {\n\t\treturn (\n\t\t\t<BareCoverMessage delayed>\n\t\t\t\t<Loading />\n\t\t\t</BareCoverMessage>\n\t\t)\n\t}\n\n\t// Query failed - assume no mount failure (let app continue, other errors will surface)\n\tif (mountFailureQ.isError) {\n\t\treturn <>{children}</>\n\t}\n\n\t// Mount failed - redirect to raid error screen\n\tif (mountFailureQ.data === true) {\n\t\treturn <RedirectRaidError />\n\t}\n\n\t// Mount OK - render children\n\treturn <>{children}</>\n}\n"
  },
  {
    "path": "packages/ui/src/modules/auth/ensure-pro-device.tsx",
    "content": "import {Navigate} from 'react-router-dom'\n\nimport {useDeviceInfo} from '@/hooks/use-device-info'\n\n// Ensures device is Umbrel Pro before showing children.\n// Non-Pro devices are redirected to root, which routes them appropriately\n// (onboarding if no user, login if not logged in, dashboard if logged in).\nexport function EnsureProDevice({children}: {children?: React.ReactNode}) {\n\tconst {data: deviceInfo, isLoading} = useDeviceInfo()\n\n\tif (isLoading) return null\n\n\tconst isPro = deviceInfo?.umbrelHostEnvironment === 'umbrel-pro'\n\n\tif (!isPro) return <Navigate to='/' replace />\n\n\treturn <>{children}</>\n}\n"
  },
  {
    "path": "packages/ui/src/modules/auth/ensure-user-exists.tsx",
    "content": "import {useEffect} from 'react'\n\nimport {BareCoverMessage} from '@/components/ui/cover-message'\nimport {Loading} from '@/components/ui/loading'\nimport {toast} from '@/components/ui/toast'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nimport {RedirectLogin, RedirectOnboarding} from './redirects'\n\nexport function EnsureUserDoesntExist({children}: {children?: React.ReactNode}) {\n\treturn (\n\t\t<EnsureUser exists={false} otherwise={<RedirectLogin />}>\n\t\t\t{children}\n\t\t</EnsureUser>\n\t)\n}\n\n/** Don't show children unless logged in */\nexport function EnsureUserExists({children}: {children?: React.ReactNode}) {\n\treturn (\n\t\t<EnsureUser exists otherwise={<RedirectOnboarding />}>\n\t\t\t{children}\n\t\t</EnsureUser>\n\t)\n}\n\nfunction EnsureUser({\n\texists,\n\totherwise,\n\tchildren,\n}: {\n\texists: boolean\n\totherwise: React.ReactNode\n\tchildren?: React.ReactNode\n}) {\n\tconst userExistsQ = trpcReact.user.exists.useQuery(undefined, {\n\t\tretry: false,\n\t})\n\n\t// Show toast on error\n\tuseEffect(() => {\n\t\tif (userExistsQ.isError) {\n\t\t\ttoast.error(t('auth.failed-to-check-if-user-exists'))\n\t\t}\n\t}, [userExistsQ.isError])\n\n\tconst userExists = userExistsQ.data ?? false\n\tconst wantsUserExists = exists\n\n\tif (userExistsQ.isLoading) {\n\t\treturn (\n\t\t\t<BareCoverMessage delayed>\n\t\t\t\t<Loading />\n\t\t\t</BareCoverMessage>\n\t\t)\n\t}\n\n\tif (userExists === wantsUserExists) {\n\t\treturn children\n\t} else {\n\t\treturn otherwise\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/modules/auth/redirects.tsx",
    "content": "import {useEffect} from 'react'\nimport {useLocation, useNavigate} from 'react-router-dom'\n\nimport {BareCoverMessage} from '@/components/ui/cover-message'\nimport {t} from '@/utils/i18n'\nimport {IS_DEV, sleep} from '@/utils/misc'\n\nconst SLEEP_TIME = IS_DEV ? 600 : 0\n\ntype Page = 'onboarding' | 'login' | 'home' | 'raid-error'\n\nconst pageToPath = (page: Page) => {\n\tswitch (page) {\n\t\tcase 'onboarding':\n\t\t\treturn '/onboarding'\n\t\tcase 'login':\n\t\t\treturn '/login'\n\t\tcase 'home':\n\t\t\treturn '/'\n\t\tcase 'raid-error':\n\t\t\treturn '/raid-error'\n\t}\n}\n\nexport function RedirectOnboarding() {\n\tconst location = useLocation()\n\tconst navigate = useNavigate()\n\n\tconst path = pageToPath('onboarding')\n\tconst shouldRedirect = !location.pathname.startsWith(path)\n\n\tuseEffect(() => {\n\t\tif (shouldRedirect) {\n\t\t\tsleep(SLEEP_TIME).then(() => navigate(path))\n\t\t}\n\t}, [shouldRedirect, navigate, path])\n\n\tif (!shouldRedirect) return null\n\tif (SLEEP_TIME === 0) return null\n\treturn <BareCoverMessage>{t('redirect.to-onboarding')}</BareCoverMessage>\n}\n\nexport function RedirectLogin() {\n\tconst location = useLocation()\n\tconst navigate = useNavigate()\n\n\tconst path = pageToPath('login')\n\tconst shouldRedirect = !location.pathname.startsWith(path)\n\n\tuseEffect(() => {\n\t\tif (shouldRedirect) {\n\t\t\tsleep(SLEEP_TIME).then(() =>\n\t\t\t\tnavigate({\n\t\t\t\t\tpathname: path,\n\t\t\t\t\tsearch: redirect.createRedirectSearch(),\n\t\t\t\t}),\n\t\t\t)\n\t\t}\n\t}, [shouldRedirect, navigate, path])\n\n\tif (!shouldRedirect) return null\n\tif (SLEEP_TIME === 0) return null\n\treturn <BareCoverMessage>{t('redirect.to-login')}</BareCoverMessage>\n}\n\nexport function RedirectHome() {\n\tconst location = useLocation()\n\tconst navigate = useNavigate()\n\n\tconst path = pageToPath('home')\n\tconst shouldRedirect = location.pathname !== path\n\n\tuseEffect(() => {\n\t\tif (shouldRedirect) {\n\t\t\tsleep(SLEEP_TIME).then(() => navigate(path))\n\t\t}\n\t}, [shouldRedirect, navigate, path])\n\n\tif (!shouldRedirect) return null\n\tif (SLEEP_TIME === 0) return null\n\treturn <BareCoverMessage>{t('redirect.to-home')}</BareCoverMessage>\n}\n\nexport function RedirectRaidError() {\n\tconst location = useLocation()\n\tconst navigate = useNavigate()\n\n\tconst path = pageToPath('raid-error')\n\tconst shouldRedirect = !location.pathname.startsWith(path)\n\n\tuseEffect(() => {\n\t\tif (shouldRedirect) {\n\t\t\tsleep(SLEEP_TIME).then(() => navigate(path))\n\t\t}\n\t}, [shouldRedirect, navigate, path])\n\n\tif (!shouldRedirect) return null\n\tif (SLEEP_TIME === 0) return null\n\treturn <BareCoverMessage>{t('redirect.to-raid-error')}</BareCoverMessage>\n}\n\n// Keep redirect after login stuff here because url stuff is stringly typed\nexport const redirect = {\n\tcreateRedirectSearch() {\n\t\treturn `?redirect=${encodeURIComponent(location.pathname)}`\n\t},\n\tgetRedirectPath() {\n\t\treturn new URLSearchParams(window.location.search).get('redirect') || '/'\n\t},\n}\n"
  },
  {
    "path": "packages/ui/src/modules/auth/shared.ts",
    "content": "import {trpcClient} from '@/trpc/trpc'\nimport {callEveryInterval} from '@/utils/call-every-interval'\nimport {MS_PER_HOUR} from '@/utils/date-time'\n\nexport const JWT_LOCAL_STORAGE_KEY = 'jwt'\nexport const JWT_REFRESH_LOCAL_STORAGE_KEY = 'jwt-last-refreshed'\n\nexport function initTokenRenewal() {\n\tcallEveryInterval(\n\t\tJWT_REFRESH_LOCAL_STORAGE_KEY,\n\t\tasync () => {\n\t\t\tconst token = await trpcClient.user.renewToken.mutate()\n\t\t\tlocalStorage.setItem(JWT_LOCAL_STORAGE_KEY, token)\n\t\t},\n\t\tMS_PER_HOUR,\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/auth/use-auth.tsx",
    "content": "import {toast} from '@/components/ui/toast'\nimport {JWT_LOCAL_STORAGE_KEY} from '@/modules/auth/shared'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nimport {redirect} from './redirects'\n\n/**\n * Make sure to hard reload page after updating this because trpc client is created on page load and it's only possible to update it on page load.\n */\nexport function useJwt() {\n\tconst jwt = window.localStorage.getItem(JWT_LOCAL_STORAGE_KEY)\n\n\treturn {\n\t\tjwt,\n\t\tremoveJwt() {\n\t\t\twindow.localStorage.removeItem(JWT_LOCAL_STORAGE_KEY)\n\t\t},\n\t\tsetJwt(jwt: string) {\n\t\t\twindow.localStorage.setItem(JWT_LOCAL_STORAGE_KEY, jwt)\n\t\t},\n\t}\n}\n\nexport function useAuth() {\n\tconst {jwt, setJwt, removeJwt} = useJwt()\n\n\tconst logoutMut = trpcReact.user.logout.useMutation({\n\t\tonSuccess(didWork) {\n\t\t\t// TODO: add translation\n\t\t\tif (!didWork) throw new Error(\"Logout didn't work.\")\n\t\t\tremoveJwt()\n\t\t\t// Hard navigate to `/login` to force all parent layouts to re-render\n\t\t\twindow.location.href = '/login'\n\t\t},\n\t\tonError() {\n\t\t\ttoast.error(t('logout-error-generic'))\n\t\t},\n\t})\n\n\tconst refreshTokenQ = trpcReact.user.renewToken.useMutation()\n\n\tconst refreshToken = async () => {\n\t\tconst res = await refreshTokenQ.mutateAsync()\n\t\tsetJwt(res)\n\t}\n\n\treturn {\n\t\tjwt,\n\t\tasync logout() {\n\t\t\tlogoutMut.mutate()\n\t\t},\n\t\tloginWithJwt(jwt: string) {\n\t\t\tsetJwt(jwt)\n\n\t\t\t// Ensure we only treat the redirect path as a relative URL\n\t\t\tconst safeUrl = new URL(window.location.href)\n\t\t\tsafeUrl.hash = ''\n\t\t\tsafeUrl.search = ''\n\t\t\tsafeUrl.pathname = redirect.getRedirectPath()\n\n\t\t\t// Hard navigate to force all parent layouts to re-render\n\t\t\twindow.location.href = safeUrl.toString()\n\t\t},\n\t\tsignUpWithJwt(jwt: string, redirectTo: string = '/onboarding/account-created') {\n\t\t\tsetJwt(jwt)\n\t\t\twindow.location.href = redirectTo\n\t\t},\n\t\trefreshToken,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/modules/bare/alert.tsx",
    "content": "import {TbAlertTriangleFilled} from 'react-icons/tb'\n\nimport {cn} from '@/lib/utils'\n\nexport function Alert({children, className}: {children: React.ReactNode; className?: string}) {\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t'text-normal flex items-center gap-1.5 rounded-full bg-white/10 px-3 py-2 text-14 -tracking-2',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t>\n\t\t\t<TbAlertTriangleFilled className='h-5 w-5 shrink-0' />\n\t\t\t<span>{children}</span>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/bare/failed-layout.tsx",
    "content": "import {ReactNode} from 'react'\nimport {Link, To} from 'react-router-dom'\n\nimport {buttonClass, secondaryButtonClasss} from '@/layouts/bare/shared'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\n\nimport {bareContainerClass, BareLogoTitle, BareSpacer, bareTextClass} from './shared'\n\nexport default function FailedLayout({\n\ttitle,\n\tdescription,\n\tbuttonText,\n\tto,\n\tbuttonOnClick,\n}: {\n\ttitle: string\n\tdescription: ReactNode\n\tbuttonText: string\n\tto?: To\n\tbuttonOnClick?: () => void\n}) {\n\treturn (\n\t\t<div className={cn(bareContainerClass, 'animate-in slide-in-from-bottom-2')}>\n\t\t\t<BareLogoTitle>{title}</BareLogoTitle>\n\t\t\t<BareSpacer />\n\t\t\t<p className={bareTextClass}>{description}</p>\n\t\t\t<BareSpacer />\n\t\t\t{to && (\n\t\t\t\t<Link to={to} className={buttonClass} onClick={buttonOnClick}>\n\t\t\t\t\t{buttonText}\n\t\t\t\t</Link>\n\t\t\t)}\n\t\t\t{!to && (\n\t\t\t\t<div className='flex flex-row gap-2.5'>\n\t\t\t\t\t<a href='/' className={buttonClass}>\n\t\t\t\t\t\t{t('not-found-404.home')}\n\t\t\t\t\t</a>\n\t\t\t\t\t<button className={secondaryButtonClasss} onClick={buttonOnClick}>\n\t\t\t\t\t\t{buttonText}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/bare/progress-layout.tsx",
    "content": "import {motion} from 'motion/react'\n\nimport {Alert} from '@/modules/bare/alert'\nimport {Progress} from '@/modules/bare/progress'\nimport {bareContainerClass, BareLogoTitle, BareSpacer} from '@/modules/bare/shared'\nimport {t} from '@/utils/i18n'\n\nexport function ProgressLayout({\n\ttitle,\n\t// onSuccess,\n\t// onFail,\n\tprogress,\n\tmessage,\n\t// isStarting,\n\tisRunning,\n\tcallout,\n}: {\n\ttitle: string\n\t// onSuccess: () => void\n\t// onFail: () => void\n\tprogress?: number\n\tmessage?: string\n\t// isStarting: boolean\n\tisRunning: boolean\n\tcallout: string\n}) {\n\tconst isStarting = !progress && !isRunning\n\n\t// Empty string also gets the default message\n\tconst finalMessage = message || t('connecting')\n\n\treturn (\n\t\t<>\n\t\t\t<motion.div\n\t\t\t\tclassName={bareContainerClass}\n\t\t\t\tinitial={{opacity: 0}}\n\t\t\t\tanimate={{opacity: 1}}\n\t\t\t\ttransition={{duration: 0.4, delay: 0.2}}\n\t\t\t>\n\t\t\t\t<BareLogoTitle>{title}</BareLogoTitle>\n\t\t\t\t<BareSpacer />\n\t\t\t\t{/* Show indeterminate value if not running */}\n\t\t\t\t<Progress value={isStarting ? undefined : progress}>{finalMessage}</Progress>\n\t\t\t\t<div className='flex-1 pt-4' />\n\t\t\t\t<Alert>{callout}</Alert>\n\t\t\t</motion.div>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/bare/progress.tsx",
    "content": "import * as ProgressPrimitive from '@radix-ui/react-progress'\nimport {ReactNode} from 'react'\nimport {isNil} from 'remeda'\n\nimport {cn} from '@/lib/utils'\n\nexport function Progress({value, children}: {value?: number; children?: ReactNode}) {\n\treturn (\n\t\t<div className='flex w-full flex-col items-center gap-5'>\n\t\t\t<ProgressPrimitive.Root\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'relative h-1.5 w-full overflow-hidden rounded-full bg-white/10 sm:w-[80%]',\n\t\t\t\t\tisNil(value) && 'umbrel-bouncing-gradient',\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t<ProgressPrimitive.Indicator\n\t\t\t\t\tclassName='h-full w-full flex-1 rounded-full bg-white transition-all duration-700'\n\t\t\t\t\tstyle={{transform: `translateX(-${100 - (value || 0)}%)`}}\n\t\t\t\t/>\n\t\t\t</ProgressPrimitive.Root>\n\t\t\t{children && <span className='text-15 leading-none font-medium -tracking-2 opacity-80'>{children}</span>}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/bare/shared.tsx",
    "content": "import UmbrelLogo from '@/components/umbrel-logo'\nimport {tw} from '@/utils/tw'\n\nexport const bareContainerClass = tw`mt-[10vh] flex-1 flex h-full max-w-full flex-col items-center sm:w-auto`\nexport const bareTitleClass = tw`sm:text-36 text-24 font-bold -tracking-2`\nexport const bareTextClass = tw`text-center text-15 font-medium leading-tight -tracking-2 text-white/80`\n\nexport const BareLogoTitle = ({children}: {children: React.ReactNode}) => (\n\t<div className='flex flex-col items-center gap-4'>\n\t\t<UmbrelLogo />\n\t\t<h1 className={bareTitleClass}>{children}</h1>\n\t</div>\n)\n\nexport const BareSpacer = () => <div className='pt-[50px]' />\n"
  },
  {
    "path": "packages/ui/src/modules/bare/success-layout.tsx",
    "content": "import {Link, To} from 'react-router-dom'\n\nimport {buttonClass} from '@/layouts/bare/shared'\nimport {cn} from '@/lib/utils'\nimport {bareContainerClass, BareLogoTitle, BareSpacer, bareTextClass} from '@/modules/bare/shared'\n\nexport function SuccessLayout({\n\ttitle,\n\tdescription,\n\tbuttonText,\n\tto,\n\tbuttonOnClick,\n}: {\n\ttitle: string\n\tdescription: string\n\tbuttonText: string\n\tto?: To\n\tbuttonOnClick?: () => void\n}) {\n\treturn (\n\t\t<div className={cn(bareContainerClass, 'h-auto w-auto animate-in duration-1000 zoom-in-95 fade-in')}>\n\t\t\t<BareLogoTitle>{title}</BareLogoTitle>\n\t\t\t<p className={cn(bareTextClass, 'w-[80%] sm:w-[55%]')}>{description}</p>\n\t\t\t<BareSpacer />\n\t\t\t{to && (\n\t\t\t\t<Link to={to} className={buttonClass} onClick={buttonOnClick}>\n\t\t\t\t\t{buttonText}\n\t\t\t\t</Link>\n\t\t\t)}\n\t\t\t{!to && (\n\t\t\t\t<button className={buttonClass} onClick={buttonOnClick}>\n\t\t\t\t\t{buttonText}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/community-app-store/community-badge.tsx",
    "content": "import {Badge} from '@/components/ui/badge'\nimport {t} from '@/utils/i18n'\n\nexport function CommunityBadge({className}: {className?: string}) {\n\treturn (\n\t\t<Badge variant='primary' className={className}>\n\t\t\t{t('community-app-store')}\n\t\t</Badge>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/app-grid/app-grid.tsx",
    "content": "import {ReactNode, useEffect, useState} from 'react'\n\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nimport {AppGridGradientMasking} from '../desktop-misc'\nimport {usePager} from './app-pagination-utils'\nimport {ArrowButton, Page, PaginatorPills, usePaginator} from './paginator'\n\nexport function AppGrid({\n\tapps = [],\n\twidgets = [],\n\tonlyFirstPage = false,\n\tforceDesktop = false,\n}: {\n\tapps?: ReactNode[]\n\twidgets?: ReactNode[]\n\tonlyFirstPage?: boolean\n\tforceDesktop?: boolean\n}) {\n\tconst {pageInnerRef, pages, appsPerRow, hasMeasurement} = usePager({\n\t\tapps,\n\t\twidgets,\n\t\tforceBreakpoint: forceDesktop ? 'M' : undefined,\n\t})\n\tconst [showMasking, setShowMasking] = useState(false)\n\tconst pageCount = pages.length\n\n\tconst {scrollContainer, page, toPage, nextPage, nextPageDisabled, prevPage, prevPageDisabled} =\n\t\tusePaginator(pageCount)\n\n\tconst noRoom = hasMeasurement && apps.length > 0 && pages[0].apps.length === 0 && pages[0].widgets.length === 0\n\n\tconst appColumnsStyle: React.CSSProperties = {\n\t\tgridTemplateColumns: `repeat(${appsPerRow}, minmax(0, 1fr))`,\n\t}\n\n\t// Hide and show gradient masking based on whether we're scrolling\n\tuseEffect(() => {\n\t\tconst el = scrollContainer.current\n\t\tif (!el) return\n\t\tconst handleShowMasking = () => setShowMasking(true)\n\t\tconst handleHideMasking = () => setShowMasking(false)\n\n\t\tel.addEventListener('scroll', handleShowMasking)\n\t\tel.addEventListener('scrollend', handleHideMasking)\n\t\treturn () => {\n\t\t\tel?.removeEventListener('scroll', handleShowMasking)\n\t\t\tel?.removeEventListener('scrollend', handleHideMasking)\n\t\t}\n\t}, [scrollContainer])\n\n\treturn (\n\t\t<div className='flex h-full w-full flex-grow flex-col items-center'>\n\t\t\t<div className='relative flex w-full flex-grow justify-center overflow-hidden'>\n\t\t\t\t<div\n\t\t\t\t\tref={scrollContainer}\n\t\t\t\t\tclassName='umbrel-hide-scrollbar flex h-full w-full snap-x snap-mandatory overflow-hidden overflow-x-auto md:max-w-[var(--apps-max-w)]'\n\t\t\t\t>\n\t\t\t\t\t{/* Default page for calculating size */}\n\t\t\t\t\t<Page index={0}>\n\t\t\t\t\t\t<PageInner innerRef={pageInnerRef}>\n\t\t\t\t\t\t\t{noRoom && <div className='w-full text-center'>{t('desktop.not-enough-room')}</div>}\n\t\t\t\t\t\t\t{pages[0]?.widgets.length > 0 && (\n\t\t\t\t\t\t\t\t// NOTE: need to keep key\n\t\t\t\t\t\t\t\t<div className={widgetRowClass}>{pages[0].widgets}</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{pages[0]?.apps && pages[0]?.apps.length > 0 && (\n\t\t\t\t\t\t\t\t<div className={appGridClass} style={appColumnsStyle}>\n\t\t\t\t\t\t\t\t\t{/* TODO: use `appsPerRow` to split apps into separate rows */}\n\t\t\t\t\t\t\t\t\t{/* TODO: consider laying apps out manually */}\n\t\t\t\t\t\t\t\t\t{pages[0].apps}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</PageInner>\n\t\t\t\t\t</Page>\n\t\t\t\t\t{!onlyFirstPage &&\n\t\t\t\t\t\tpages.slice(1).map(({apps, widgets}, i) => (\n\t\t\t\t\t\t\t<Page key={i} index={i + 1}>\n\t\t\t\t\t\t\t\t<PageInner>\n\t\t\t\t\t\t\t\t\t{widgets.length > 0 && <div className={widgetRowClass}>{widgets}</div>}\n\t\t\t\t\t\t\t\t\t{apps && (\n\t\t\t\t\t\t\t\t\t\t<div className={appGridClass} style={appColumnsStyle}>\n\t\t\t\t\t\t\t\t\t\t\t{apps}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</PageInner>\n\t\t\t\t\t\t\t</Page>\n\t\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t\t{pageCount > 1 && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<ArrowButtonWrapper side='left'>\n\t\t\t\t\t\t\t<ArrowButton direction='left' disabled={prevPageDisabled} onClick={prevPage} />\n\t\t\t\t\t\t</ArrowButtonWrapper>\n\t\t\t\t\t\t<ArrowButtonWrapper side='right'>\n\t\t\t\t\t\t\t<ArrowButton direction='right' disabled={nextPageDisabled} onClick={nextPage} />\n\t\t\t\t\t\t</ArrowButtonWrapper>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t\t{showMasking && <AppGridGradientMasking />}\n\t\t\t{/* NOTE: always leave space for pills to avoid layout thrashing */}\n\t\t\t{/* Adding margin bottom so pills are clickable */}\n\t\t\t<div className='mt-6 mb-6'>\n\t\t\t\t<PaginatorPills total={pageCount} current={page} onCurrentChange={toPage} />\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\nfunction ArrowButtonWrapper({side, children}: {side: 'left' | 'right'; children: ReactNode}) {\n\treturn (\n\t\t<div\n\t\t\tclassName='absolute top-1/2 z-10 hidden lg:block'\n\t\t\tstyle={{\n\t\t\t\t[side]: 'calc((100% - var(--page-w)) / 2 - var(--apps-padding-x))',\n\t\t\t\ttransform: `translateX(${side === 'left' ? '-100%' : '100%'}) translateY(-50%)`,\n\t\t\t}}\n\t\t>\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n\nexport function PageInner({children, innerRef}: {children?: ReactNode; innerRef?: React.Ref<HTMLDivElement>}) {\n\treturn (\n\t\t// Size the container to fill parent so we can later calculate what can fit inside it\n\t\t<div className='flex h-full w-full items-stretch justify-center pt-2'>\n\t\t\t<div\n\t\t\t\tref={innerRef}\n\t\t\t\tclassName='flex w-full max-w-[var(--apps-max-w)] flex-col content-start items-center gap-y-[var(--app-y-gap)] px-[var(--apps-padding-x)]'\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\nconst widgetRowClass = tw`flex gap-[var(--app-x-gap)] w-full justify-center`\nconst appGridClass = tw`gap-x-[var(--app-x-gap)] gap-y-[var(--app-y-gap)] grid content-center justify-center`\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/app-grid/app-pagination-utils.tsx",
    "content": "import {ReactNode, Ref, useLayoutEffect} from 'react'\nimport {createBreakpoint, useMeasure} from 'react-use'\nimport {chunk} from 'remeda'\n\nconst useBreakpoint = createBreakpoint({S: 0, M: 640})\n\ntype PageT = {\n\twidgets: ReactNode[]\n\tapps: ReactNode[]\n}\n\n// TODO: consider refactoring into two parts:\n// 1. container size calculation\n// 2. grouping into pages\n\n// NOTE: everything is grouped together because into one hook because everything is using\n// the same variables. In the future it'll be more obvious if these vars should come from a context,\n// or if there's a config object will hold them and then get passed to functions that need it\n\n/**\n * Calculate which apps and widgets will go into which pages based on the returned `pageInnerRef`\n */\nexport function usePager({apps, widgets, forceBreakpoint}: PageT & {forceBreakpoint?: 'S' | 'M'}): {\n\tpages: PageT[]\n\tpageInnerRef: Ref<HTMLDivElement>\n\tappsPerRow: number\n\thasMeasurement: boolean\n} {\n\t// Using breakpoint instead of measure because max inner page width comes from breakpoint\n\tconst viewportBreakpoint = useBreakpoint()\n\tconst breakpoint = forceBreakpoint ?? viewportBreakpoint\n\tconst [pageInnerRef, pageSize] = useMeasure<HTMLDivElement>()\n\n\tconst pageW = pageSize.width\n\tconst pageH = pageSize.height\n\n\tconst responsive = (sizes: number | number[]) => {\n\t\tif (typeof sizes === 'number') {\n\t\t\treturn sizes\n\t\t}\n\t\tif (breakpoint === 'S') {\n\t\t\treturn sizes[0]\n\t\t}\n\t\treturn sizes[1]\n\t}\n\n\tconst widgetH = responsive([110, 150])\n\tconst widgetLabeledH = widgetH + 26 // widget rect + label\n\n\tconst paddingX = responsive([10, 32])\n\tconst appsPerRowMax = responsive([4, 6])\n\tconst appW = responsive([70, 120])\n\tconst appH = responsive([90, 110])\n\tconst appXGap = responsive([20, 30])\n\tconst appYGap = responsive([0, 12])\n\tconst widgetW = appW + appXGap + appW\n\n\tconst appsInnerW = (appW + appXGap) * appsPerRowMax - appXGap\n\tconst appsMaxW = appsInnerW + paddingX * 2\n\n\t// Putting on document so that app grid and widget selector both have access.\n\t// Skip when forceBreakpoint is set (preview mode) to avoid overwriting\n\t// the real desktop's values on document.documentElement.\n\tuseLayoutEffect(() => {\n\t\tif (forceBreakpoint) return\n\n\t\tconst el = document.documentElement\n\t\tel.style.setProperty('--page-w', `${pageW}px`)\n\t\tel.style.setProperty('--app-w', `${appW}px`)\n\t\tel.style.setProperty('--app-h', `${appH}px`)\n\t\tel.style.setProperty('--app-x-gap', `${appXGap}px`)\n\t\tel.style.setProperty('--app-y-gap', `${appYGap}px`)\n\t\tel.style.setProperty('--apps-max-w', `${appsMaxW}px`)\n\t\tel.style.setProperty('--apps-padding-x', `${paddingX}px`)\n\t\tel.style.setProperty('--widget-w', `${widgetW}px`)\n\t\tel.style.setProperty('--widget-h', `${widgetH}px`)\n\t\tel.style.setProperty('--widget-labeled-h', `${widgetLabeledH}px`)\n\t\t// All values depend on the breakpoint\n\t}, [breakpoint, pageW, forceBreakpoint])\n\n\tfunction countAppsPerRow({pageW}: {pageW: number}) {\n\t\treturn Math.floor((pageW + appXGap) / (appW + appXGap))\n\t}\n\n\tconst appsPerRow = countAppsPerRow({pageW})\n\n\tconst pages = groupIntoPages({apps, widgets, pageW, pageH})\n\n\tfunction groupIntoPages({\n\t\tapps,\n\t\twidgets,\n\t\tpageW,\n\t\tpageH,\n\t}: {\n\t\tapps: ReactNode[]\n\t\twidgets: ReactNode[]\n\t\tpageW: number\n\t\tpageH: number\n\t}): PageT[] {\n\t\tfunction countWidgetsPerPage({pageW}: {pageW: number}) {\n\t\t\tconst widgetsPerPage = (pageW + appXGap) / (widgetW + appXGap)\n\t\t\t// const pagesWithWidgetsCount = Math.ceil(widgetCount / widgetsPerPage);\n\t\t\treturn widgetsPerPage\n\t\t}\n\t\tfunction countAppsPerCol({pageH}: {pageH: number}) {\n\t\t\treturn Math.floor((pageH + appYGap) / (appH + appYGap))\n\t\t}\n\t\tfunction countAppsPerColWhenWidgetRow({pageH}: {pageH: number}) {\n\t\t\tconst restH = pageH - widgetLabeledH - appYGap\n\t\t\treturn Math.floor((restH + appYGap) / (appH + appYGap))\n\t\t}\n\n\t\tconst widgetsPerPage = countWidgetsPerPage({pageW})\n\t\tconst appsPerCol = countAppsPerCol({pageH})\n\t\tconst appsPerColWhenWidgetRow = countAppsPerColWhenWidgetRow({pageH})\n\t\tconst appsPerRow = countAppsPerRow({pageW})\n\n\t\tconst appsPerPageWithWidgetRow = appsPerRow * appsPerColWhenWidgetRow\n\t\tconst widgetsChunked = chunk(widgets, widgetsPerPage)\n\n\t\t/*\n    WIDGET PAGES\n    */\n\t\tconst widgetPages = widgetsChunked.map((pageWidgets, i) => {\n\t\t\treturn {\n\t\t\t\twidgets: pageWidgets,\n\t\t\t\tapps: apps.slice(appsPerPageWithWidgetRow * i, appsPerPageWithWidgetRow * (i + 1)),\n\t\t\t}\n\t\t})\n\n\t\t/*\n    PAGES WITHOUT WIDGETS\n    */\n\t\tconst maxAppsPerPage = appsPerRow * appsPerCol\n\t\t// Get the apps not used in widget pages\n\t\tconst restApps = apps.slice(appsPerPageWithWidgetRow * widgetsChunked.length)\n\t\tconst appsForPagesWithoutWidgetsChunked = maxAppsPerPage === 0 ? [] : chunk(restApps, maxAppsPerPage)\n\t\tconst nonWidgetPages = appsForPagesWithoutWidgetsChunked.map((apps) => {\n\t\t\treturn {\n\t\t\t\twidgets: [],\n\t\t\t\tapps,\n\t\t\t}\n\t\t})\n\n\t\treturn [...widgetPages, ...nonWidgetPages]\n\t}\n\n\tif (pageH < widgetLabeledH || !apps.length || apps.length === 0) {\n\t\t// Don't show any apps or widgets\n\t\treturn {\n\t\t\tpageInnerRef,\n\t\t\tpages: [{widgets: [], apps: []}],\n\t\t\tappsPerRow: 0,\n\t\t\thasMeasurement: pageH > 0 && pageW > 0,\n\t\t}\n\t}\n\n\treturn {pageInnerRef, pages, appsPerRow, hasMeasurement: true}\n}\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/app-grid/paginator.tsx",
    "content": "import {cva} from 'class-variance-authority'\nimport {useEffect, useRef, useState} from 'react'\n\nimport CaretRight from '@/components/caret-right'\nimport {cn} from '@/lib/utils'\n\nconst DATA_INDEX_ATTR = 'data-index'\n\nexport function usePaginator(pageCount: number) {\n\tconst [page, setPage] = useState<number>(0)\n\tconst [scrollingWithCode, setScrollingWithCode] = useState(false)\n\n\tconst scrollContainer = useRef<HTMLDivElement>(null)\n\n\tconst scrollToPage = (page: number) => {\n\t\tsetScrollingWithCode(true)\n\t\tsetPage(page)\n\t\tif (!scrollContainer.current) return\n\t\t// console.log(\"scroll with code\", page);\n\t\tscrollContainer.current.scrollTo({\n\t\t\tbehavior: 'smooth',\n\t\t\tleft: page * scrollContainer.current.clientWidth,\n\t\t})\n\t}\n\n\tconst handlePrev = () => scrollToPage(page - 1)\n\tconst handleNext = () => scrollToPage(page + 1)\n\n\tconst timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\tuseEffect(() => {\n\t\tconst el = scrollContainer.current\n\n\t\tconst handleScroll: EventListener = () => {\n\t\t\tif (timeoutRef.current) clearTimeout(timeoutRef.current)\n\t\t\ttimeoutRef.current = setTimeout(() => {\n\t\t\t\tif (!scrollingWithCode) {\n\t\t\t\t\tsetPage(index)\n\t\t\t\t}\n\t\t\t\tsetScrollingWithCode(false)\n\t\t\t}, 200)\n\n\t\t\tif (scrollingWithCode) return\n\t\t\tif (!el) return\n\t\t\tconst index = Math.round(el.scrollLeft / el.clientWidth)\n\t\t\tsetPage(index)\n\t\t}\n\n\t\t// scrollend doesn't work as well here\n\t\tel?.addEventListener('scroll', handleScroll)\n\n\t\treturn () => el?.removeEventListener('scroll', handleScroll)\n\t}, [scrollingWithCode])\n\n\treturn {\n\t\tscrollContainer,\n\t\tpage,\n\t\tpageCount,\n\t\ttoPage: scrollToPage,\n\t\tnextPage: handleNext,\n\t\tnextPageDisabled: page >= pageCount - 1,\n\t\tprevPage: handlePrev,\n\t\tprevPageDisabled: page <= 0,\n\t}\n}\n\nexport function Page({index, children, className}: {index: number; children: React.ReactNode; className?: string}) {\n\treturn (\n\t\t<div {...{[DATA_INDEX_ATTR]: index}} className={cn('relative h-full w-full flex-none snap-center', className)}>\n\t\t\t{/* <div className=\"absolute top-0 left-0\">{index}</div> */}\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n\nexport function ArrowButton({\n\tdirection,\n\tdisabled,\n\tonClick,\n}: {\n\tdirection: 'left' | 'right'\n\tdisabled?: boolean\n\tonClick?: () => void\n}) {\n\treturn (\n\t\t<button className={cn(glassButtonCva(), disabled && 'pointer-events-none')} onClick={onClick} disabled={disabled}>\n\t\t\t<CaretRight className={cn(direction === 'left' && 'rotate-180')} />\n\t\t</button>\n\t)\n}\n\nconst glassButtonCva = cva(\n\t'shrink-0 w-10 h-10 rounded-full backdrop-blur-xs contrast-more:bg-neutral-800 contrast-more:backdrop-blur-none grid place-items-center bg-white/5 shadow-glass-button text-white/75 disabled:text-white/30 transition-all hover:bg-white/10 contrast-more:hover:bg-neutral-700 active:bg-white/5 cursor-default',\n)\n\n// ---\n\nfunction PaginatorPill({active, onClick}: {active?: boolean; onClick: () => void}) {\n\treturn (\n\t\t// Adding padding and negative margin to make click target bigger\n\t\t<button\n\t\t\tonClick={onClick}\n\t\t\t// z-10 to make sure it's above peer elements so click target is bigger\n\t\t\tclassName={cn('group z-10 -my-3 py-3', active && 'pointer-events-none')}\n\t\t>\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'h-1 w-3 rounded-full bg-white/20 transition-all group-hover:bg-white/30',\n\t\t\t\t\tactive && 'w-5 bg-white',\n\t\t\t\t)}\n\t\t\t/>\n\t\t</button>\n\t)\n}\n\nexport function PaginatorPills({\n\ttotal,\n\tcurrent,\n\tonCurrentChange,\n}: {\n\ttotal: number\n\tcurrent: number\n\tonCurrentChange: (page: number) => void\n}) {\n\treturn (\n\t\t<div className={cn('flex gap-1', total === 1 && 'invisible')}>\n\t\t\t{Array.from({length: total}).map((_, i) => (\n\t\t\t\t<PaginatorPill onClick={() => onCurrentChange(i)} key={i} active={i === current} />\n\t\t\t))}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/app-icon.tsx",
    "content": "import {motion} from 'motion/react'\nimport {useState} from 'react'\nimport {FaRegPlayCircle} from 'react-icons/fa'\nimport {FaRegCirclePause} from 'react-icons/fa6'\nimport {Link, useNavigate} from 'react-router-dom'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger} from '@/components/ui/context-menu'\nimport {FadeInImg} from '@/components/ui/fade-in-img'\nimport {contextMenuClasses} from '@/components/ui/shared/menu'\nimport {useAppInstall} from '@/hooks/use-app-install'\nimport {useLaunchApp} from '@/hooks/use-launch-app'\nimport {cn} from '@/lib/utils'\nimport {UMBREL_APP_STORE_ID} from '@/modules/app-store/constants'\nimport {useUserApp} from '@/providers/apps'\nimport {AppStateOrLoading, progressBarStates, progressStates, trpcReact} from '@/trpc/trpc'\nimport {useLinkToDialog} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\nimport {assertUnreachable} from '@/utils/misc'\n\nimport {UninstallConfirmationDialog} from './uninstall-confirmation-dialog'\nimport {UninstallTheseFirstDialog} from './uninstall-these-first-dialog'\n\nexport const APP_ICON_PLACEHOLDER_SRC = '/assets/app-icon-placeholder.svg'\n\nexport function AppIcon({\n\tlabel,\n\tsrc,\n\tonClick,\n\tstate = 'ready',\n\tprogress,\n}: {\n\tlabel: string\n\tsrc: string\n\tonClick?: () => void\n\tstate?: AppStateOrLoading\n\tprogress?: number\n}) {\n\tconst [appIconSrc, setAppIconSrc] = useState(src)\n\n\tconst inProgress = arrayIncludes(progressStates, state)\n\tconst isStopped = state === 'stopped'\n\n\tconst appIcon = (\n\t\t<motion.button\n\t\t\tonClick={onClick}\n\t\t\tclassName={cn(\n\t\t\t\t'group flex h-[var(--app-h)] w-[var(--app-w)] flex-col items-center gap-2.5 py-3 focus:outline-hidden',\n\t\t\t)}\n\t\t\tlayout\n\t\t\tinitial={{\n\t\t\t\topacity: 1,\n\t\t\t\tscale: 0.8,\n\t\t\t}}\n\t\t\tanimate={{\n\t\t\t\topacity: 1,\n\t\t\t\tscale: 1,\n\t\t\t}}\n\t\t\texit={{\n\t\t\t\topacity: 0,\n\t\t\t\tscale: 0.5,\n\t\t\t}}\n\t\t\ttransition={{\n\t\t\t\ttype: 'spring',\n\t\t\t\tstiffness: 500,\n\t\t\t\tdamping: 30,\n\t\t\t}}\n\t\t>\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'relative aspect-square w-12 shrink-0 overflow-hidden rounded-10 bg-white/10 bg-cover bg-center ring-white/25 backdrop-blur-xs transition-all duration-300 group-hover:scale-110 group-hover:ring-6 group-focus-visible:ring-6 group-active:scale-95 group-data-[state=open]:ring-6 md:w-16 md:rounded-15',\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{appIconSrc && (\n\t\t\t\t\t<FadeInImg\n\t\t\t\t\t\tsrc={appIconSrc}\n\t\t\t\t\t\talt={label}\n\t\t\t\t\t\tonError={() => setAppIconSrc(APP_ICON_PLACEHOLDER_SRC)}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t'h-full w-full duration-500',\n\t\t\t\t\t\t\t(inProgress || isStopped) && 'brightness-50',\n\t\t\t\t\t\t\t!inProgress && !isStopped && 'animate-in fade-in',\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tdraggable={false}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t\t{inProgress && (\n\t\t\t\t\t<div className='absolute inset-0 flex items-center justify-center'>\n\t\t\t\t\t\t<div className='relative h-1 w-[75%] overflow-hidden rounded-full bg-white/40'>\n\t\t\t\t\t\t\t{arrayIncludes(progressBarStates, state) ? (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName='absolute inset-0 w-0 animate-in rounded-full bg-white/90 transition-[width] delay-200 duration-700 fill-mode-both slide-in-from-left-full'\n\t\t\t\t\t\t\t\t\tstyle={{width: `${progress}%`}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<div className='absolute inset-0 w-[30%] animate-sliding-loader rounded-full bg-white/90' />\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t{isStopped && (\n\t\t\t\t\t<div className='absolute inset-0 flex items-center justify-center'>\n\t\t\t\t\t\t<FaRegCirclePause className='h-6 w-6 text-white/90 group-hover:hidden md:h-8 md:w-8' />\n\t\t\t\t\t\t<FaRegPlayCircle className='hidden h-6 w-6 text-white/90 group-hover:block md:h-8 md:w-8' />\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t\t<div className='max-w-full text-11 leading-normal drop-shadow-desktop-label md:text-13'>\n\t\t\t\t<div className='truncate contrast-more:bg-black contrast-more:px-1'>\n\t\t\t\t\t<AppLabel state={state} label={label} />\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</motion.button>\n\t)\n\n\treturn appIcon\n}\n\nexport function AppLabel({state, label = ''}: {state: AppStateOrLoading; label?: string}) {\n\tswitch (state) {\n\t\tcase 'not-installed':\n\t\t\treturn t('app.installing')\n\t\tcase 'installing':\n\t\t\treturn label\n\t\tcase 'ready':\n\t\t\treturn label\n\t\tcase 'running':\n\t\t\treturn label\n\t\tcase 'starting':\n\t\t\treturn t('app.starting') + '...'\n\t\tcase 'restarting':\n\t\t\treturn t('app.restarting') + '...'\n\t\tcase 'stopping':\n\t\t\treturn t('app.stopping') + '...'\n\t\tcase 'uninstalling':\n\t\t\treturn t('app.uninstalling') + '...'\n\t\tcase 'updating':\n\t\t\treturn t('app.updating') + '...'\n\t\tcase 'loading':\n\t\t\treturn label\n\t\tcase 'stopped':\n\t\t\treturn label\n\t\tcase 'unknown':\n\t\t\treturn t('app.offline')\n\t}\n\treturn assertUnreachable(state)\n}\n\nexport function AppIconConnected({appId}: {appId: string}) {\n\tconst navigate = useNavigate()\n\tconst userApp = useUserApp(appId)\n\tconst appInstall = useAppInstall(appId)\n\tconst [openDepsDialog, setOpenDepsDialog] = useState(false)\n\tconst [toUninstallFirstIds, setToUninstallFirstIds] = useState<string[]>([])\n\tconst [showUninstallDialog, setShowUninstallDialog] = useState(false)\n\tconst launchApp = useLaunchApp()\n\tconst linkToDialog = useLinkToDialog()\n\n\tconst uninstall = async () => {\n\t\tconst res = await appInstall.uninstall()\n\t\tif (res?.uninstallTheseFirst) {\n\t\t\tsetToUninstallFirstIds(res.uninstallTheseFirst)\n\t\t\tsetOpenDepsDialog(true)\n\t\t} else {\n\t\t\tsetShowUninstallDialog(false)\n\t\t}\n\t}\n\n\tconst uninstallPrecheck = async () => {\n\t\tconst apps = await appInstall.getAppsToUninstallFirst()\n\t\tif (apps.length > 0) {\n\t\t\tsetToUninstallFirstIds(apps)\n\t\t\tsetOpenDepsDialog(true)\n\t\t} else {\n\t\t\tsetShowUninstallDialog(true)\n\t\t}\n\t}\n\n\tif (!userApp || !userApp.app) return <AppIcon label='' src='' />\n\n\tconst state = appInstall.state\n\n\t// Start is disabled if the app is not stopped or unknown\n\tconst startDisabled = !arrayIncludes(['stopped', 'unknown'], state)\n\t// Stop is disabled if the app is not running or ready\n\tconst stopDisabled = !arrayIncludes(['running', 'ready'], state)\n\t// Restart is disabled if the app is not running or ready or unknown\n\tconst restartDisabled = !arrayIncludes(['running', 'ready', 'unknown'], state)\n\t// Troubleshoot is disabled if the app is not running or ready or unknown\n\tconst troubleshootDisabled = !arrayIncludes(['running', 'ready', 'unknown'], state)\n\t// Uninstall is never disabled just so the user can always retry uninstalling if the app\n\t// ever gets stuck in an uninstalling state.\n\tconst uninstallDisabled = false\n\n\tconst handleAppClick = async () => {\n\t\t// Launch the app if it's ready\n\t\tif (state === 'ready') {\n\t\t\treturn launchApp(appId)\n\t\t}\n\t\t// Start the app if it's stopped\n\t\tif (state === 'stopped') {\n\t\t\treturn appInstall.start()\n\t\t}\n\t\t// Try restarting the app if it's 'unknown'\n\t\tif (state === 'unknown') {\n\t\t\treturn appInstall.restart()\n\t\t}\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<ContextMenu>\n\t\t\t\t<ContextMenuTrigger className='group'>\n\t\t\t\t\t<AppIcon\n\t\t\t\t\t\tlabel={userApp.app.name}\n\t\t\t\t\t\tsrc={userApp.app.icon}\n\t\t\t\t\t\tonClick={handleAppClick}\n\t\t\t\t\t\tstate={state}\n\t\t\t\t\t\tprogress={appInstall.progress}\n\t\t\t\t\t/>\n\t\t\t\t</ContextMenuTrigger>\n\t\t\t\t<ContextMenuContent>\n\t\t\t\t\t{userApp.app.credentials &&\n\t\t\t\t\t\t(userApp.app.credentials.defaultUsername || userApp.app.credentials.defaultPassword) && (\n\t\t\t\t\t\t\t<ContextMenuItem asChild>\n\t\t\t\t\t\t\t\t<Link to={linkToDialog('default-credentials', {for: appId})}>\n\t\t\t\t\t\t\t\t\t{t('desktop.app.context.show-default-credentials')}\n\t\t\t\t\t\t\t\t</Link>\n\t\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t{/* App settings (currently dependencies only) */}\n\t\t\t\t\t{!!userApp.app.dependencies?.length && (\n\t\t\t\t\t\t<ContextMenuItem asChild>\n\t\t\t\t\t\t\t<Link to={linkToDialog('app-settings', {for: appId})}>{t('desktop.app.context.settings')}</Link>\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* Start / Stop */}\n\t\t\t\t\t{state !== 'stopped' ? (\n\t\t\t\t\t\t<ContextMenuItem disabled={stopDisabled} onSelect={stopDisabled ? undefined : appInstall.stop}>\n\t\t\t\t\t\t\t{t('stop')}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<ContextMenuItem onSelect={appInstall.start} disabled={startDisabled}>\n\t\t\t\t\t\t\t{t('start')}\n\t\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* Restart */}\n\t\t\t\t\t<ContextMenuItem disabled={restartDisabled} onSelect={restartDisabled ? undefined : appInstall.restart}>\n\t\t\t\t\t\t{t('restart')}\n\t\t\t\t\t</ContextMenuItem>\n\n\t\t\t\t\t{/* Troubleshoot */}\n\t\t\t\t\t{/* TODO: Navigating to /settings/troubleshoot forces the Settings sheet to render first,\n\t\t\t\t\t   causing a slow two-step load. Consider making troubleshoot a standalone route/dialog. */}\n\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\tdisabled={troubleshootDisabled}\n\t\t\t\t\t\tonSelect={() => navigate(`/settings/troubleshoot/app/${appId}`)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('troubleshoot')}\n\t\t\t\t\t</ContextMenuItem>\n\n\t\t\t\t\t{/* Go to app store page */}\n\t\t\t\t\t<ContextMenuItemLinkToAppStore appId={appId} />\n\n\t\t\t\t\t{/* Uninstall */}\n\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\tclassName={contextMenuClasses.item.rootDestructive}\n\t\t\t\t\t\tdisabled={uninstallDisabled}\n\t\t\t\t\t\tonSelect={uninstallDisabled ? undefined : uninstallPrecheck}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('desktop.app.context.uninstall')}\n\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t</ContextMenuContent>\n\t\t\t</ContextMenu>\n\n\t\t\t{/* Dialogs */}\n\t\t\t{toUninstallFirstIds.length > 0 && (\n\t\t\t\t<UninstallTheseFirstDialog\n\t\t\t\t\tappId={appId}\n\t\t\t\t\ttoUninstallFirstIds={toUninstallFirstIds}\n\t\t\t\t\topen={openDepsDialog}\n\t\t\t\t\tonOpenChange={setOpenDepsDialog}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{showUninstallDialog && (\n\t\t\t\t<UninstallConfirmationDialog\n\t\t\t\t\tappId={appId}\n\t\t\t\t\topen={showUninstallDialog}\n\t\t\t\t\tonOpenChange={setShowUninstallDialog}\n\t\t\t\t\tonConfirm={uninstall}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</>\n\t)\n}\n\nfunction ContextMenuItemLinkToAppStore({appId}: {appId: string}) {\n\tconst navigate = useNavigate()\n\tconst utils = trpcReact.useUtils()\n\treturn (\n\t\t<ContextMenuItem asChild>\n\t\t\t<button\n\t\t\t\t// `w-full` because it doesn't fill the context menu otherwise\n\t\t\t\tclassName='w-full'\n\t\t\t\tonClick={async () => {\n\t\t\t\t\tconst installedApps = await utils.apps.list.fetch()\n\t\t\t\t\tconst installedApp = installedApps.find((app) => app.id === appId)\n\t\t\t\t\tif (!installedApp) return\n\n\t\t\t\t\tconst availableApps = await utils.appStore.registry.fetch()\n\t\t\t\t\tconst availableAppsFlat = availableApps.flatMap((group) =>\n\t\t\t\t\t\tgroup.apps.map((app) => ({...app, registryId: group.meta.id})),\n\t\t\t\t\t)\n\t\t\t\t\tconst appStoreApp = availableAppsFlat.find((app) => app.id === installedApp.id)\n\n\t\t\t\t\tconst registryId = appStoreApp?.registryId ?? UMBREL_APP_STORE_ID\n\t\t\t\t\tif (registryId !== UMBREL_APP_STORE_ID) {\n\t\t\t\t\t\tnavigate(`/community-app-store/${registryId}/${appId}`)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tnavigate(`/app-store/${appId}`)\n\t\t\t\t\t}\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{t('desktop.app.context.go-to-store-page')}\n\t\t\t</button>\n\t\t</ContextMenuItem>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/blur-below-dock.tsx",
    "content": "export const BlurBelowDock = () => (\n\t// Using 200px because we don't want to intersect the app icons\n\t<div\n\t\tclassName='pointer-events-none fixed inset-0 top-0 animate-in backdrop-blur-xl duration-500 fill-mode-both fade-in'\n\t\tstyle={{\n\t\t\tbackground: '#00000044',\n\t\t\tWebkitMaskImage: 'linear-gradient(transparent calc(100% - 200px), black calc(100% - 30px))',\n\t\t}}\n\t/>\n)\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/desktop-content.tsx",
    "content": "import {motion, Variant} from 'motion/react'\nimport {useLocation} from 'react-router-dom'\n\nimport {useWidgets} from '@/hooks/use-widgets'\nimport {Widget} from '@/modules/widgets'\nimport {WidgetContainer} from '@/modules/widgets/shared/shared'\nimport {WidgetWrapper} from '@/modules/widgets/shared/widget-wrapper'\nimport {useApps} from '@/providers/apps'\nimport {trpcReact} from '@/trpc/trpc'\n\nimport {AppGrid} from './app-grid/app-grid'\nimport {AppIconConnected, AppLabel} from './app-icon'\nimport {Search} from './desktop-misc'\nimport {DockSpacer} from './dock'\nimport {Header} from './header'\n\nexport function DesktopContent({onSearchClick}: {onSearchClick?: () => void}) {\n\tconst {pathname} = useLocation()\n\n\tconst getQuery = trpcReact.user.get.useQuery()\n\tconst name = getQuery.data?.name\n\n\tconst {userApps, isLoading} = useApps()\n\tconst widgets = useWidgets()\n\n\tif (isLoading || widgets.isLoading) return null\n\tif (!userApps) return null\n\tif (!name) return null\n\n\ttype DesktopVariant = 'default' | 'edit-widgets' | 'overlayed'\n\tconst variant: DesktopVariant =\n\t\tpathname === '/' ? 'default' : pathname.startsWith('/edit-widgets') ? 'edit-widgets' : 'overlayed'\n\n\tconst variants: Record<DesktopVariant, Variant> = {\n\t\tdefault: {\n\t\t\topacity: 1,\n\t\t},\n\t\t'edit-widgets': {\n\t\t\ttranslateY: -20,\n\t\t\topacity: 0,\n\t\t},\n\t\toverlayed: {\n\t\t\ttranslateY: 0,\n\t\t\topacity: 0,\n\t\t\ttransition: {\n\t\t\t\tduration: 0,\n\t\t\t},\n\t\t},\n\t}\n\n\treturn (\n\t\t<motion.div\n\t\t\tclassName='flex h-full w-full flex-col items-center justify-between'\n\t\t\tvariants={variants}\n\t\t\tanimate={variant}\n\t\t\tinitial={{opacity: 1}}\n\t\t\ttransition={{duration: 0.15, ease: 'easeOut'}}\n\t\t>\n\t\t\t<div className='pt-6 md:pt-8' />\n\t\t\t<Header userName={name} />\n\t\t\t<div className='pt-6 md:pt-8' />\n\t\t\t<div className='flex w-full grow overflow-hidden'>\n\t\t\t\t<AppGrid\n\t\t\t\t\twidgets={widgets.selected.map((widget) => (\n\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\tkey={widget.id}\n\t\t\t\t\t\t\tlayout\n\t\t\t\t\t\t\t// No opacity animation — backdrop-filter on widgets can't be smoothly\n\t\t\t\t\t\t\t// faded (browsers skip compositing it at opacity 0, causing a flash).\n\t\t\t\t\t\t\texit={{\n\t\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<WidgetWrapper\n\t\t\t\t\t\t\t\t// Get the app name from the endpoint\n\t\t\t\t\t\t\t\tlabel={widget.app.name}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{widget.app.state === 'ready' ? (\n\t\t\t\t\t\t\t\t\t<Widget appId={widget.app.id} config={widget} />\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t<WidgetContainer className='grid place-items-center text-13 text-white/50'>\n\t\t\t\t\t\t\t\t\t\t<AppLabel state={widget.app.state} />\n\t\t\t\t\t\t\t\t\t</WidgetContainer>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</WidgetWrapper>\n\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t))}\n\t\t\t\t\tapps={userApps.map((app, i) => (\n\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\tkey={app.id}\n\t\t\t\t\t\t\tlayout\n\t\t\t\t\t\t\tinitial={{\n\t\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t\t\tscale: 0.75,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tanimate={{\n\t\t\t\t\t\t\t\topacity: 1,\n\t\t\t\t\t\t\t\tscale: 1,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\texit={{\n\t\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t\t\tscale: 0.75,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\t\tdelay: (widgets.selected.length * 1.5 + i) * 0.01,\n\t\t\t\t\t\t\t\tduration: 0.2,\n\t\t\t\t\t\t\t\tease: 'easeOut',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<AppIconConnected appId={app.id} />\n\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t))}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t\t<Search onClick={onSearchClick} />\n\t\t\t<div className='pt-6' />\n\t\t\t<DockSpacer />\n\t\t</motion.div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/desktop-context-menu.tsx",
    "content": "import {useRef, useState} from 'react'\nimport {RiCloseCircleFill} from 'react-icons/ri'\nimport {Link} from 'react-router-dom'\n\nimport {ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger} from '@/components/ui/context-menu'\nimport {Popover, PopoverAnchor, PopoverClose, PopoverContent} from '@/components/ui/popover'\nimport {contextMenuClasses} from '@/components/ui/shared/menu'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {cn} from '@/lib/utils'\nimport {WallpaperPicker} from '@/routes/settings/_components/wallpaper-picker'\nimport {t} from '@/utils/i18n'\n\nexport function DesktopContextMenu({children}: {children: React.ReactNode}) {\n\tconst [show, setShow] = useState(false)\n\tconst contentRef = useRef<HTMLDivElement>(null)\n\tconst anchorRef = useRef<HTMLDivElement>(null)\n\tconst {params, addLinkSearchParams} = useQueryParams()\n\tconst isShowingDialog = params.get('dialog') !== null\n\n\treturn (\n\t\t<>\n\t\t\t<ContextMenu modal={false}>\n\t\t\t\t<ContextMenuTrigger disabled={isShowingDialog}>{children}</ContextMenuTrigger>\n\t\t\t\t<ContextMenuContent ref={contentRef}>\n\t\t\t\t\t<ContextMenuItem asChild>\n\t\t\t\t\t\t<Link to='/edit-widgets'>{t('desktop.context-menu.edit-widgets')}</Link>\n\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t<ContextMenuItem\n\t\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\t\t// get bounding box\n\t\t\t\t\t\t\tconst {top, left} = contentRef.current!.getBoundingClientRect()\n\t\t\t\t\t\t\tanchorRef.current!.style.top = `${top}px`\n\t\t\t\t\t\t\tanchorRef.current!.style.left = `${left}px`\n\t\t\t\t\t\t\t// Delay because otherwise just blinks into existence then disappears\n\t\t\t\t\t\t\tsetTimeout(() => setShow(true), 200)\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('desktop.context-menu.change-wallpaper')}\n\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t\t<ContextMenuItem asChild className={contextMenuClasses.item.rootDestructive}>\n\t\t\t\t\t\t<Link to={{search: addLinkSearchParams({dialog: 'logout'})}}>{t('desktop.context-menu.logout')}</Link>\n\t\t\t\t\t</ContextMenuItem>\n\t\t\t\t</ContextMenuContent>\n\t\t\t</ContextMenu>\n\n\t\t\t<Popover open={show} onOpenChange={(open) => setShow(open)}>\n\t\t\t\t<PopoverAnchor className='fixed' ref={anchorRef} />\n\t\t\t\t{/* `relative` fixes Safari paint bug caused by the `mask-image` property in the `WallpaperPicker` not playing well with the popover `transform: translate()`. On hovering the close button, Safari would jump the wallpaper picker in the wrong spot. */}\n\t\t\t\t<PopoverContent align='start' className='relative py-2.5 pr-5 pl-1.5'>\n\t\t\t\t\t<CloseButton className='absolute top-2 right-2' />\n\t\t\t\t\t<WallpaperPicker maxW={300} />\n\t\t\t\t</PopoverContent>\n\t\t\t</Popover>\n\t\t</>\n\t)\n}\n\nconst CloseButton = ({className}: {className: string}) => (\n\t<PopoverClose\n\t\tclassName={cn(\n\t\t\t'rounded-full opacity-30 ring-white/60 outline-hidden transition-opacity hover:opacity-40 focus-visible:opacity-40 focus-visible:ring-2',\n\t\t\tclassName,\n\t\t)}\n\t>\n\t\t<RiCloseCircleFill className='h-4 w-4' />\n\t\t<span className='sr-only'>{t('close')}</span>\n\t</PopoverClose>\n)\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/desktop-misc.tsx",
    "content": "import {useLocation} from 'react-router-dom'\n\nimport {useIsSmallMobile} from '@/hooks/use-is-mobile'\nimport {useWallpaper} from '@/providers/wallpaper'\nimport {t} from '@/utils/i18n'\nimport {cmdOrCtrl, platform} from '@/utils/misc'\n\nexport function Search({onClick}: {onClick?: () => void}) {\n\tconst isMobile = useIsSmallMobile()\n\treturn (\n\t\t<button\n\t\t\tclassName='z-10 animate-in rounded-full border border-white/5 bg-neutral-600/20 px-3 py-2.5 text-12 leading-inter-trimmed text-white/90 backdrop-blur-xs transition-colors duration-300 fill-mode-both fade-in hover:bg-neutral-600/30 active:bg-neutral-600/10'\n\t\t\tonClick={onClick}\n\t\t>\n\t\t\t{/* TODO: ideally, centralize shortcut preview and shortcut event listener so always in sync */}\n\t\t\t{t('search')} {platform() !== 'other' && !isMobile && <span className='text-white/20'>{cmdOrCtrl()}K</span>}\n\t\t</button>\n\t)\n}\n\nexport function AppGridGradientMasking() {\n\tconst {pathname} = useLocation()\n\n\t// Only show gradient on home page\n\t// Also, when transitioning between pages, this gradient can get in the way, so we hide it without animating it\n\tif (pathname !== '/') return null\n\n\treturn (\n\t\t<>\n\t\t\t<GradientMaskSide side='left' />\n\t\t\t<GradientMaskSide side='right' />\n\t\t</>\n\t)\n}\n\nfunction GradientMaskSide({side}: {side: 'left' | 'right'}) {\n\tconst {wallpaper, wallpaperFullyVisible, isLoading} = useWallpaper()\n\n\tif (!wallpaperFullyVisible || isLoading) return null\n\n\treturn (\n\t\t<div\n\t\t\t// Ideally, we'd match the `block` visibility to the arrow buttons, but that would require a lot of work.\n\t\t\t// Ideally we'd use a breakpoint based on the CSS var --app-max-w, but that's not possible\n\t\t\tclassName='pointer-events-none fixed top-0 hidden h-full bg-cover bg-center md:block'\n\t\t\tstyle={{\n\t\t\t\t// For debugging:\n\t\t\t\t// backgroundColor: 'red',\n\t\t\t\tbackgroundImage: `url(${wallpaper.url})`,\n\t\t\t\tbackgroundAttachment: 'fixed',\n\t\t\t\tWebkitMaskImage: `linear-gradient(to ${side}, transparent, black)`,\n\t\t\t\t[side]: 'calc((100% - (var(--page-w) + var(--apps-padding-x) * 2)) / 2)',\n\t\t\t\twidth: 'var(--apps-padding-x)',\n\t\t\t}}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/desktop-preview.tsx",
    "content": "import {useEffect, useState} from 'react'\n\nimport {FadeInImg} from '@/components/ui/fade-in-img'\nimport {useWidgets} from '@/hooks/use-widgets'\nimport {LoadingWidget} from '@/modules/widgets'\nimport {BackdropBlurVariantContext} from '@/modules/widgets/shared/backdrop-blur-context'\nimport {WidgetWrapper} from '@/modules/widgets/shared/widget-wrapper'\nimport {useApps} from '@/providers/apps'\nimport {useWallpaper} from '@/providers/wallpaper'\nimport {trpcReact} from '@/trpc/trpc'\n\nimport {AppGrid} from './app-grid/app-grid'\nimport {AppIcon} from './app-icon'\nimport {DockPreview} from './dock'\nimport {Header} from './header'\n\n/**\n * Miniature desktop preview shown in Settings.\n *\n * Renders the real desktop components (Header, AppGrid, AppIcon, DockPreview,\n * WidgetWrapper) so the preview stays in sync with the actual desktop layout\n * without maintaining a parallel implementation. The expensive part — live\n * widget data — is avoided by using <LoadingWidget> (renders the correct\n * widget type with placeholder dashes, zero API calls or iframes).\n *\n * Structure (bottom → top):\n *   1. Wallpaper — small pre-generated jpg, updates instantly on wallpaper change\n *   2. Content  — real components laid out at 1440×850, then scale3d(0.18) to ~259×153\n *   3. Dock     — DockPreview with fixed \"preview\" dimensions (viewport-independent)\n *\n * Note: On narrow viewports, responsive Tailwind classes (sm:/md:) still respond\n * to the viewport rather than the 1440px container, so some widget/icon styling\n * may differ slightly from the true desktop appearance. This is acceptable at\n * the tiny preview size (~259×153px).\n */\nexport function DesktopPreviewConnected() {\n\tconst W = 1440\n\tconst H = 850\n\tconst scale = 0.18\n\n\tconst wallpaper = useWallpaper()\n\n\t// Defer mounting so the Settings page paints first, then show the preview\n\t// on the next frame. No fade-in animation — backdrop-filter on widgets can't\n\t// be smoothly faded (browsers skip compositing it at opacity 0, causing a\n\t// flash when it kicks in). A clean pop-in looks fine at this tiny preview size.\n\tconst [show, setShow] = useState(false)\n\tuseEffect(() => {\n\t\tconst id = requestAnimationFrame(() => setShow(true))\n\t\treturn () => cancelAnimationFrame(id)\n\t}, [])\n\n\treturn (\n\t\t<>\n\t\t\t{/* Small wallpaper image — avoids loading the full-res Wallpaper component */}\n\t\t\t<FadeInImg\n\t\t\t\tkey={wallpaper.wallpaper.id}\n\t\t\t\tsrc={`/assets/wallpapers/generated-small/${wallpaper.wallpaper.id}.jpg`}\n\t\t\t\tclassName='absolute inset-0 h-full w-full object-cover object-center'\n\t\t\t\tstyle={{animation: 'animate-unblur 0.7s'}}\n\t\t\t/>\n\t\t\t<div\n\t\t\t\tclassName='shrink-0 origin-top-left'\n\t\t\t\tstyle={{\n\t\t\t\t\ttransform: `scale3d(${scale}, ${scale}, 1)`,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<div\n\t\t\t\t\tclassName='relative'\n\t\t\t\t\tstyle={\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\twidth: W,\n\t\t\t\t\t\t\theight: H,\n\t\t\t\t\t\t\t// Desktop (\"M\" breakpoint) CSS custom properties so descendants\n\t\t\t\t\t\t\t// using var(--app-w) etc. get desktop values regardless of viewport.\n\t\t\t\t\t\t\t'--app-w': '120px',\n\t\t\t\t\t\t\t'--app-h': '110px',\n\t\t\t\t\t\t\t'--app-x-gap': '30px',\n\t\t\t\t\t\t\t'--app-y-gap': '12px',\n\t\t\t\t\t\t\t'--apps-padding-x': '32px',\n\t\t\t\t\t\t\t'--widget-h': '150px',\n\t\t\t\t\t\t\t'--widget-w': '270px',\n\t\t\t\t\t\t\t'--widget-labeled-h': '176px',\n\t\t\t\t\t\t\t'--apps-max-w': '934px',\n\t\t\t\t\t\t} as React.CSSProperties\n\t\t\t\t\t}\n\t\t\t\t>\n\t\t\t\t\t{show && (\n\t\t\t\t\t\t<div className='flex h-full flex-col items-center justify-between overflow-hidden'>\n\t\t\t\t\t\t\t<DesktopPreviewContent />\n\t\t\t\t\t\t\t<div className='pb-5'>\n\t\t\t\t\t\t\t\t<DockPreview />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</>\n\t)\n}\n\n/** Real desktop content using actual components but with LoadingWidget instead of\n *  live Widget to avoid tRPC queries and cross-origin iframes. */\nfunction DesktopPreviewContent() {\n\tconst {userApps, isLoading} = useApps()\n\tconst {selected} = useWidgets()\n\tconst getQuery = trpcReact.user.get.useQuery()\n\tconst name = getQuery.data?.name\n\n\tif (isLoading) return null\n\tif (!userApps) return null\n\tif (!name) return null\n\n\treturn (\n\t\t<BackdropBlurVariantContext value='with-backdrop-blur'>\n\t\t\t{/* <BackdropBlurVariantContext value='default'> */}\n\t\t\t<div className='pt-12' />\n\t\t\t<Header userName={name} />\n\t\t\t<div className='pt-12' />\n\t\t\t<div className='flex w-full flex-grow overflow-hidden'>\n\t\t\t\t<AppGrid\n\t\t\t\t\tonlyFirstPage\n\t\t\t\t\tforceDesktop\n\t\t\t\t\twidgets={selected?.map((widget) => (\n\t\t\t\t\t\t<WidgetWrapper key={widget.id} label={widget.app.name}>\n\t\t\t\t\t\t\t<LoadingWidget type={widget.type} />\n\t\t\t\t\t\t</WidgetWrapper>\n\t\t\t\t\t))}\n\t\t\t\t\tapps={userApps.map((app) => (\n\t\t\t\t\t\t<AppIcon key={app.id} src={app.icon} label={app.name} onClick={() => {}} />\n\t\t\t\t\t))}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</BackdropBlurVariantContext>\n\t)\n}\n\n/** Decorative frame (border + shadow) that wraps the preview at its final scaled size. */\nexport function DesktopPreviewFrame({children}: {children: React.ReactNode}) {\n\tconst W = 1440\n\tconst H = 850\n\tconst scale = 0.18\n\n\treturn (\n\t\t<div\n\t\t\tclassName='max-h-fit max-w-fit rounded-15 p-[1px]'\n\t\t\tstyle={{\n\t\t\t\tbackgroundImage:\n\t\t\t\t\t'linear-gradient(135deg, rgba(237, 237, 237, 0.42) 0.13%, rgba(173, 173, 173, 0.12) 26.95%, rgba(0, 0, 0, 0.00) 81.15%, #404040 105.24%)',\n\t\t\t\tfilter:\n\t\t\t\t\t'drop-shadow(0px 0px 0.6332594156265259px rgba(0, 21, 64, 0.14)) drop-shadow(0px 0.6332594156265259px 1.2665188312530518px rgba(0, 21, 64, 0.05))',\n\t\t\t}}\n\t\t>\n\t\t\t<div className='rounded-15 bg-[#0C0D0C] p-[9px]'>\n\t\t\t\t<div\n\t\t\t\t\tclassName='relative animate-in overflow-hidden rounded-5 duration-100 fade-in'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\twidth: W * scale,\n\t\t\t\t\t\theight: H * scale,\n\t\t\t\t\t\ttransform: `translateZ(0)`, // Force rounded border clipping in Safari\n\t\t\t\t\t}}\n\t\t\t\t\t// Tell screen readers to ignore this element\n\t\t\t\t\taria-hidden='true'\n\t\t\t\t\t// Prevent browser from interacting with children\n\t\t\t\t\tref={(node) => {\n\t\t\t\t\t\tif (node) node.setAttribute('inert', '')\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{children}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/dock-item.tsx",
    "content": "import {HTMLMotionProps, motion, MotionValue, SpringOptions, useSpring, useTransform, Variants} from 'motion/react'\nimport {useEffect, useRef, useState} from 'react'\nimport {Link, LinkProps} from 'react-router-dom'\n\nimport {NotificationBadge} from '@/components/ui/notification-badge'\nimport {cn} from '@/lib/utils'\n\ntype HTMLDivProps = HTMLMotionProps<'div'>\ntype DockItemProps = {\n\tnotificationCount?: number\n\tbg?: string\n\topen?: boolean\n\tmouseX: MotionValue<number>\n\tto?: LinkProps['to']\n\ticonSize: number\n\ticonSizeZoomed: number\n\tclassName?: string\n\tstyle?: React.CSSProperties\n\tonClick?: (e: React.MouseEvent) => void\n} & HTMLDivProps\n\nconst BOUNCE_DURATION = 0.4\n\nexport function DockItem({\n\tbg,\n\tmouseX,\n\tnotificationCount,\n\topen,\n\tclassName,\n\tstyle,\n\tto,\n\tonClick,\n\ticonSize,\n\ticonSizeZoomed,\n\t...props\n}: DockItemProps) {\n\tconst [clickedOpen, setClickedOpen] = useState(false)\n\tconst ref = useRef<HTMLDivElement>(null)\n\n\tuseEffect(() => {\n\t\tif (!open) setClickedOpen(false)\n\t}, [open])\n\n\tconst distance = useTransform(mouseX, (val) => {\n\t\tconst bounds = ref.current?.getBoundingClientRect() ?? {x: 0, width: 0}\n\n\t\treturn val - bounds.x - bounds.width / 2\n\t})\n\n\tconst springOptions: SpringOptions = {\n\t\tmass: 0.1,\n\t\tstiffness: 150,\n\t\tdamping: 10,\n\t}\n\n\tconst widthSync = useTransform(distance, [-150, 0, 150], [iconSize, iconSizeZoomed, iconSize])\n\tconst width = useSpring(widthSync, springOptions)\n\n\tconst scaleSync = useTransform(distance, [-150, 0, 150], [1, iconSizeZoomed / iconSize, 1])\n\tconst transform = useSpring(scaleSync, springOptions)\n\n\t// Config from:\n\t// https://github.com/ysj151215/big-sur-dock/blob/04a7244beb0d35d22d1bb18ad91b4c0021bf5ec4/components/dock/DockItem.tsx\n\tconst variants: Variants = {\n\t\topen: {\n\t\t\ttransition: {\n\t\t\t\tdefault: {\n\t\t\t\t\tduration: 0.2,\n\t\t\t\t},\n\t\t\t\ttranslateY: {\n\t\t\t\t\tduration: BOUNCE_DURATION,\n\t\t\t\t\tease: 'easeInOut',\n\t\t\t\t\ttimes: [0, 0.5, 1],\n\t\t\t\t},\n\t\t\t},\n\t\t\ttranslateY: [0, -20, 0],\n\t\t},\n\t\tclosed: {},\n\t}\n\tconst variant = open && clickedOpen ? 'open' : 'closed'\n\n\treturn (\n\t\t<motion.div ref={ref} className='relative aspect-square' style={{width}}>\n\t\t\t{/* icon glow */}\n\t\t\t<div\n\t\t\t\tclassName='absolute hidden h-full w-full bg-cover opacity-30 md:block'\n\t\t\t\tstyle={{\n\t\t\t\t\tbackgroundImage: `url(${bg})`,\n\t\t\t\t\tfilter: 'blur(16px)',\n\t\t\t\t\ttransform: 'translateY(4px)',\n\t\t\t\t}}\n\t\t\t/>\n\t\t\t{/* icon */}\n\t\t\t<motion.div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'relative origin-top-left bg-cover transition-[filter] has-[:focus-visible]:brightness-125',\n\t\t\t\t\tclassName,\n\t\t\t\t)}\n\t\t\t\tstyle={{\n\t\t\t\t\twidth: iconSize,\n\t\t\t\t\theight: iconSize,\n\t\t\t\t\tbackgroundImage: bg\n\t\t\t\t\t\t? `url(${bg})`\n\t\t\t\t\t\t: // TODO: use a better default\n\t\t\t\t\t\t\t`linear-gradient(to bottom right, white, black)`,\n\t\t\t\t\tscale: transform,\n\t\t\t\t\t...style,\n\t\t\t\t}}\n\t\t\t\tonClick={(e) => {\n\t\t\t\t\tsetClickedOpen(true)\n\t\t\t\t\tonClick?.(e)\n\t\t\t\t}}\n\t\t\t\t{...props}\n\t\t\t\tvariants={variants}\n\t\t\t\tanimate={variant}\n\t\t\t>\n\t\t\t\t<Link to={to || '/'} className='absolute inset-0 outline-hidden' />\n\t\t\t\t{!!notificationCount && <NotificationBadge count={notificationCount} />}\n\t\t\t</motion.div>\n\t\t\t{open && <OpenPill />}\n\t\t</motion.div>\n\t)\n}\n\nfunction OpenPill() {\n\treturn (\n\t\t<motion.div\n\t\t\tclassName='absolute -bottom-[7px] left-1/2 h-[2px] w-[10px] -translate-x-1/2 rounded-full bg-white'\n\t\t\tinitial={{\n\t\t\t\topacity: 0,\n\t\t\t}}\n\t\t\tanimate={{\n\t\t\t\topacity: 1,\n\t\t\t\ttransition: {\n\t\t\t\t\tdelay: BOUNCE_DURATION,\n\t\t\t\t},\n\t\t\t}}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/dock.tsx",
    "content": "import {motion, useMotionValue} from 'motion/react'\nimport React, {Suspense} from 'react'\nimport {ErrorBoundary} from 'react-error-boundary'\nimport {useLocation} from 'react-router-dom'\n\nimport {useAppsWithUpdates} from '@/hooks/use-apps-with-updates'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {useSettingsNotificationCount} from '@/hooks/use-settings-notification-count'\nimport {cn} from '@/lib/utils'\nimport {systemAppsKeyed} from '@/providers/apps'\nimport {tw} from '@/utils/tw'\n\nimport {DockItem} from './dock-item'\nimport {LogoutDialog} from './logout-dialog'\n\nconst LiveUsageDialog = React.lazy(() => import('@/routes/live-usage'))\nconst WhatsNewModal = React.lazy(() => import('@/routes/whats-new-modal').then((m) => ({default: m.WhatsNewModal})))\n\nconst DOCK_BOTTOM_PADDING_PX = 10\n\nconst DOCK_DIMENSIONS_PX = {\n\tpreview: {\n\t\ticonSize: 50,\n\t\ticonSizeZoomed: 80,\n\t\tpadding: 12,\n\t},\n\tdesktop: {\n\t\ticonSize: 50,\n\t\ticonSizeZoomed: 80,\n\t\tpadding: 12,\n\t},\n\tmobile: {\n\t\ticonSize: 48,\n\t\ticonSizeZoomed: 60,\n\t\tpadding: 8,\n\t},\n} as const\n\ntype DockDimensionsPx = {\n\ticonSize: number\n\ticonSizeZoomed: number\n\tpadding: number\n\tdockHeight: number\n}\n\nfunction useDockDimensions(options?: {isPreview?: boolean}): DockDimensionsPx {\n\tconst isMobile = useIsMobile()\n\n\tif (options?.isPreview) {\n\t\tconst {iconSize, iconSizeZoomed, padding} = DOCK_DIMENSIONS_PX.preview\n\t\treturn {iconSize, iconSizeZoomed, padding, dockHeight: iconSize + padding * 2}\n\t}\n\n\tconst dimensions = isMobile ? DOCK_DIMENSIONS_PX.mobile : DOCK_DIMENSIONS_PX.desktop\n\tconst {iconSize, iconSizeZoomed, padding} = dimensions\n\treturn {iconSize, iconSizeZoomed, padding, dockHeight: iconSize + padding * 2}\n}\n\nexport function Dock() {\n\tconst {pathname} = useLocation()\n\tconst {addLinkSearchParams} = useQueryParams()\n\tconst mouseX = useMotionValue(Infinity)\n\tconst settingsNotificationCount = useSettingsNotificationCount()\n\tconst {appsWithUpdates} = useAppsWithUpdates()\n\tconst isMobile = useIsMobile()\n\tconst {iconSize, iconSizeZoomed, padding, dockHeight} = useDockDimensions()\n\n\tconst appUpdateCount = appsWithUpdates.length\n\n\t// TODO: THIS IS A HACK\n\t// We need a better approach to track the last visited path (possibly scroll position too?)\n\t// inside every page. We do this right now for the File app because it's has the most\n\t// UX-advantage (eg. user accidentally clicking close while they're in a deeply nested path)\n\tconst lastFilesPath = sessionStorage.getItem('lastFilesPath')\n\n\treturn (\n\t\t<>\n\t\t\t<motion.div\n\t\t\t\tinitial={{translateY: 80, opacity: 0}}\n\t\t\t\tanimate={{translateY: 0, opacity: 1}}\n\t\t\t\ttransition={{type: 'spring', stiffness: 200, damping: 20, delay: 0.2, duration: 0.2}}\n\t\t\t\tonPointerMove={(e) => e.pointerType === 'mouse' && mouseX.set(e.pageX)}\n\t\t\t\tonPointerLeave={() => mouseX.set(Infinity)}\n\t\t\t\tclassName={cn(dockClass, isMobile && 'gap-2')}\n\t\t\t\tstyle={{\n\t\t\t\t\theight: dockHeight,\n\t\t\t\t\tpaddingBottom: padding,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<DockItem\n\t\t\t\t\ticonSize={iconSize}\n\t\t\t\t\ticonSizeZoomed={iconSizeZoomed}\n\t\t\t\t\tto={systemAppsKeyed['UMBREL_home'].systemAppTo}\n\t\t\t\t\topen={pathname === '/'}\n\t\t\t\t\tbg={systemAppsKeyed['UMBREL_home'].icon}\n\t\t\t\t\tmouseX={mouseX}\n\t\t\t\t/>\n\t\t\t\t<DockItem\n\t\t\t\t\ticonSize={iconSize}\n\t\t\t\t\ticonSizeZoomed={iconSizeZoomed}\n\t\t\t\t\tto={systemAppsKeyed['UMBREL_app-store'].systemAppTo}\n\t\t\t\t\topen={pathname.startsWith(systemAppsKeyed['UMBREL_app-store'].systemAppTo)}\n\t\t\t\t\tbg={systemAppsKeyed['UMBREL_app-store'].icon}\n\t\t\t\t\tnotificationCount={appUpdateCount}\n\t\t\t\t\tmouseX={mouseX}\n\t\t\t\t/>\n\t\t\t\t<DockItem\n\t\t\t\t\ticonSize={iconSize}\n\t\t\t\t\ticonSizeZoomed={iconSizeZoomed}\n\t\t\t\t\tto={lastFilesPath || systemAppsKeyed['UMBREL_files'].systemAppTo}\n\t\t\t\t\t// TODO: This is hack, we should use the systemAppTo but currently systemAppTo is /files/Home\n\t\t\t\t\t// so this fails the check when the path is /files/Recents, /files/Trash, etc.\n\t\t\t\t\t// We need a proper redirect to /files/Home when the user navigates to /files\n\t\t\t\t\topen={pathname.startsWith('/files')}\n\t\t\t\t\tbg={systemAppsKeyed['UMBREL_files'].icon}\n\t\t\t\t\tmouseX={mouseX}\n\t\t\t\t/>\n\t\t\t\t<DockItem\n\t\t\t\t\ticonSize={iconSize}\n\t\t\t\t\ticonSizeZoomed={iconSizeZoomed}\n\t\t\t\t\tto={systemAppsKeyed['UMBREL_settings'].systemAppTo}\n\t\t\t\t\topen={pathname.startsWith(systemAppsKeyed['UMBREL_settings'].systemAppTo)}\n\t\t\t\t\tbg={systemAppsKeyed['UMBREL_settings'].icon}\n\t\t\t\t\tnotificationCount={settingsNotificationCount}\n\t\t\t\t\tmouseX={mouseX}\n\t\t\t\t/>\n\t\t\t\t<DockItem\n\t\t\t\t\ticonSize={iconSize}\n\t\t\t\t\ticonSizeZoomed={iconSizeZoomed}\n\t\t\t\t\tto={{search: addLinkSearchParams({dialog: 'live-usage'})}}\n\t\t\t\t\topen={pathname.startsWith(systemAppsKeyed['UMBREL_live-usage'].systemAppTo)}\n\t\t\t\t\tbg={systemAppsKeyed['UMBREL_live-usage'].icon}\n\t\t\t\t\tmouseX={mouseX}\n\t\t\t\t/>\n\t\t\t\t<DockItem\n\t\t\t\t\ticonSize={iconSize}\n\t\t\t\t\ticonSizeZoomed={iconSizeZoomed}\n\t\t\t\t\tto={systemAppsKeyed['UMBREL_widgets'].systemAppTo}\n\t\t\t\t\topen={pathname.startsWith(systemAppsKeyed['UMBREL_widgets'].systemAppTo)}\n\t\t\t\t\tbg={systemAppsKeyed['UMBREL_widgets'].icon}\n\t\t\t\t\tmouseX={mouseX}\n\t\t\t\t/>\n\t\t\t</motion.div>\n\t\t\t<LogoutDialog />\n\n\t\t\t<ErrorBoundary fallbackRender={() => null}>\n\t\t\t\t<Suspense>\n\t\t\t\t\t<LiveUsageDialog />\n\t\t\t\t</Suspense>\n\t\t\t</ErrorBoundary>\n\t\t\t<ErrorBoundary fallbackRender={() => null}>\n\t\t\t\t<Suspense>\n\t\t\t\t\t<WhatsNewModal />\n\t\t\t\t</Suspense>\n\t\t\t</ErrorBoundary>\n\t\t</>\n\t)\n}\n\nexport function DockPreview() {\n\tconst mouseX = useMotionValue(Infinity)\n\tconst {iconSize, iconSizeZoomed, padding, dockHeight} = useDockDimensions({isPreview: true})\n\n\treturn (\n\t\t<div\n\t\t\tclassName={dockPreviewClass}\n\t\t\tstyle={{\n\t\t\t\theight: dockHeight,\n\t\t\t\tpaddingBottom: padding,\n\t\t\t}}\n\t\t>\n\t\t\t<DockItem\n\t\t\t\tbg={systemAppsKeyed['UMBREL_home'].icon}\n\t\t\t\tmouseX={mouseX}\n\t\t\t\ticonSize={iconSize}\n\t\t\t\ticonSizeZoomed={iconSizeZoomed}\n\t\t\t/>\n\t\t\t<DockItem\n\t\t\t\tbg={systemAppsKeyed['UMBREL_app-store'].icon}\n\t\t\t\tmouseX={mouseX}\n\t\t\t\ticonSize={iconSize}\n\t\t\t\ticonSizeZoomed={iconSizeZoomed}\n\t\t\t/>\n\t\t\t<DockItem\n\t\t\t\tbg={systemAppsKeyed['UMBREL_files'].icon}\n\t\t\t\tmouseX={mouseX}\n\t\t\t\ticonSize={iconSize}\n\t\t\t\ticonSizeZoomed={iconSizeZoomed}\n\t\t\t/>\n\t\t\t<DockItem\n\t\t\t\tbg={systemAppsKeyed['UMBREL_settings'].icon}\n\t\t\t\tmouseX={mouseX}\n\t\t\t\ticonSize={iconSize}\n\t\t\t\ticonSizeZoomed={iconSizeZoomed}\n\t\t\t/>\n\t\t\t<DockDivider iconSize={iconSize} />\n\t\t\t<DockItem\n\t\t\t\tbg={systemAppsKeyed['UMBREL_live-usage'].icon}\n\t\t\t\tmouseX={mouseX}\n\t\t\t\ticonSize={iconSize}\n\t\t\t\ticonSizeZoomed={iconSizeZoomed}\n\t\t\t/>\n\t\t\t<DockItem\n\t\t\t\tbg={systemAppsKeyed['UMBREL_widgets'].icon}\n\t\t\t\tmouseX={mouseX}\n\t\t\t\ticonSize={iconSize}\n\t\t\t\ticonSizeZoomed={iconSizeZoomed}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n\nexport function DockSpacer({className}: {className?: string}) {\n\tconst {dockHeight} = useDockDimensions()\n\treturn <div className={cn('w-full shrink-0', className)} style={{height: dockHeight + DOCK_BOTTOM_PADDING_PX}} />\n}\n\nexport function DockBottomPositioner({children}: {children: React.ReactNode}) {\n\treturn (\n\t\t<div className='fixed bottom-0 left-1/2 z-50 -translate-x-1/2' style={{paddingBottom: DOCK_BOTTOM_PADDING_PX}}>\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n\nconst dockClass = tw`mx-auto flex items-end gap-3 rounded-2xl bg-black/10 contrast-more:bg-neutral-700 backdrop-blur-xl contrast-more:backdrop-blur-none px-3 shadow-dock shrink-0 will-change-transform transform-gpu border-hpx border-white/10`\nconst dockPreviewClass = tw`mx-auto flex items-end gap-4 rounded-2xl bg-neutral-900/80 px-3 shadow-dock shrink-0 border-hpx border-white/10`\n\nconst DockDivider = ({iconSize}: {iconSize: number}) => (\n\t<div className='br grid w-1 place-items-center' style={{height: iconSize}}>\n\t\t<div className='h-7 border-r border-white/10' />\n\t</div>\n)\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/greeting-message.ts",
    "content": "import {t} from '@/utils/i18n'\nimport {firstNameFromFullName} from '@/utils/misc'\n\nexport function greetingMessage(name: string) {\n\tconst firstName = firstNameFromFullName(name)\n\n\tconst greetingMap = {\n\t\tmorning: t('desktop.greeting.morning', {name: firstName}),\n\t\tafternoon: t('desktop.greeting.afternoon', {name: firstName}),\n\t\tevening: t('desktop.greeting.evening', {name: firstName}),\n\t}\n\n\treturn greetingMap[getPartofDay()] + '.'\n}\n\nfunction getPartofDay() {\n\tconst today = new Date()\n\tconst curHr = today.getHours()\n\n\tif (curHr < 12) {\n\t\treturn 'morning'\n\t} else if (curHr < 18) {\n\t\treturn 'afternoon'\n\t} else {\n\t\treturn 'evening'\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/header.tsx",
    "content": "import UmbrelLogo from '@/components/umbrel-logo'\nimport {cn} from '@/lib/utils'\nimport {greetingMessage} from '@/modules/desktop/greeting-message'\n\nexport function Header({userName}: {userName: string}) {\n\tconst name = userName\n\t// Always rendering the entire component to avoid layout thrashing\n\treturn (\n\t\t<div className={cn('relative z-10', name ? '' : 'invisible')}>\n\t\t\t<div className='flex flex-col items-center gap-3 px-4 md:gap-4'>\n\t\t\t\t<UmbrelLogo className='w-[73px] md:w-auto' />\n\t\t\t\t<h1 className='text-center text-19 font-bold md:text-5xl'>{greetingMessage(name)}</h1>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/install-first-app.tsx",
    "content": "import {ReactNode} from 'react'\nimport {Link, useLocation} from 'react-router-dom'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {ButtonLink} from '@/components/ui/button-link'\nimport UmbrelLogo from '@/components/umbrel-logo'\nimport {cn} from '@/lib/utils'\nimport {DockSpacer} from '@/modules/desktop/dock'\nimport {useAvailableApps} from '@/providers/available-apps'\nimport {RegistryApp} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nexport function InstallFirstApp() {\n\tconst {pathname} = useLocation()\n\tconst isHome = pathname === '/'\n\n\tif (!isHome) return null\n\n\tconst title = t('install-your-first-app')\n\n\treturn (\n\t\t<div className={cn('relative z-10 flex min-h-[100dvh] animate-in flex-col items-center duration-300 fade-in')}>\n\t\t\t<div className='pt-14' />\n\t\t\t<UmbrelLogo />\n\t\t\t<div className='pt-5' />\n\t\t\t<h1 className='-translate-y-2 text-center text-3xl leading-tight font-bold -tracking-2 md:text-48'>{title}</h1>\n\t\t\t<div className='pt-6' />\n\t\t\t<div className='flex-1' />\n\t\t\t<div className='flex w-full flex-col items-center justify-center'>\n\t\t\t\t<div className='grid w-full max-w-md gap-[30px] px-2 lg:max-w-[1200px] lg:grid-cols-3 lg:px-[30px]'>\n\t\t\t\t\t<Cards />\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<div className='pt-7' />\n\t\t\t<ButtonLink to='/app-store' className='h-[42px] px-5 py-4 text-14 backdrop-blur-md'>\n\t\t\t\t{t('desktop.install-first.link-to-app-store')}\n\t\t\t</ButtonLink>\n\t\t\t<div className='pt-[50px]' />\n\t\t\t<div className='flex-grow-[2]' />\n\t\t\t<DockSpacer />\n\t\t</div>\n\t)\n}\n\nfunction Cards() {\n\tconst {appsKeyed, isLoading} = useAvailableApps()\n\n\tif (isLoading) {\n\t\treturn <CardsSkeleton />\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<div className={cardClass}>\n\t\t\t\t<h2 className={cardHeadingClass}>{t('desktop.install-first.for-the-self-hoster')}</h2>\n\t\t\t\t<AppApp app={appsKeyed['nextcloud']} />\n\t\t\t\t<AppApp app={appsKeyed['immich']} />\n\t\t\t\t<AppApp app={appsKeyed['jellyfin']} />\n\t\t\t\t<AppApp app={appsKeyed['transmission']} />\n\t\t\t</div>\n\t\t\t<div className={cardClass}>\n\t\t\t\t<h2 className={cardHeadingClass}>{t('desktop.install-first.for-the-ai-enthusiast')}</h2>\n\t\t\t\t<AppApp app={appsKeyed['openclaw']} />\n\t\t\t\t<AppApp app={appsKeyed['ollama']} />\n\t\t\t\t<AppApp app={appsKeyed['open-webui']} />\n\t\t\t\t<AppApp app={appsKeyed['perplexica']} />\n\t\t\t</div>\n\t\t\t<div className={cardClass}>\n\t\t\t\t<h2 className={cardHeadingClass}>{t('desktop.install-first.for-the-bitcoiner')}</h2>\n\t\t\t\t<AppApp app={appsKeyed['bitcoin']} />\n\t\t\t\t<AppApp app={appsKeyed['public-pool']} />\n\t\t\t\t<AppApp app={appsKeyed['electrs']} />\n\t\t\t\t<AppApp app={appsKeyed['mempool']} />\n\t\t\t</div>\n\t\t</>\n\t)\n}\n\nfunction CardsSkeleton() {\n\treturn (\n\t\t<>\n\t\t\t<div className={cardClass}>\n\t\t\t\t<h2 className={cardHeadingClass}>{t('desktop.install-first.for-the-self-hoster')}</h2>\n\t\t\t\t<SkeletonApps />\n\t\t\t</div>\n\t\t\t<div className={cardClass}>\n\t\t\t\t<h2 className={cardHeadingClass}>{t('desktop.install-first.for-the-ai-enthusiast')}</h2>\n\t\t\t\t<SkeletonApps />\n\t\t\t</div>\n\t\t\t<div className={cardClass}>\n\t\t\t\t<h2 className={cardHeadingClass}>{t('desktop.install-first.for-the-bitcoiner')}</h2>\n\t\t\t\t<SkeletonApps />\n\t\t\t</div>\n\t\t</>\n\t)\n}\n\nfunction SkeletonApps() {\n\treturn (\n\t\t<>\n\t\t\t<SkeletonApp />\n\t\t\t<SkeletonApp />\n\t\t\t<SkeletonApp />\n\t\t\t<SkeletonApp />\n\t\t</>\n\t)\n}\n\nfunction SkeletonApp() {\n\treturn <App id='' icon='' appName='' appDescription='' />\n}\n\nfunction AppApp({app}: {app: RegistryApp}) {\n\tif (!app) return <SkeletonApp />\n\treturn <App id={app.id} icon={app.icon} appName={app.name} appDescription={app.tagline} />\n}\n\nfunction App({\n\tid,\n\ticon,\n\tappName,\n\tappDescription,\n}: {\n\tid?: string\n\ticon: string\n\tappName: ReactNode\n\tappDescription: ReactNode\n}) {\n\treturn (\n\t\t<Link\n\t\t\tto={`/app-store/${id}`}\n\t\t\tclassName='flex w-full items-center gap-2.5 rounded-15 p-2 duration-300 hover:bg-white/4'\n\t\t>\n\t\t\t<AppIcon src={icon} size={50} className='rounded-10' />\n\t\t\t<div className='flex min-w-0 flex-1 flex-col'>\n\t\t\t\t<h3 className='text-15 font-semibold -tracking-3'>{appName}</h3>\n\t\t\t\t<p className='w-full min-w-0 truncate text-13 opacity-50'>{appDescription}</p>\n\t\t\t</div>\n\t\t</Link>\n\t)\n}\n\nconst cardClass = tw`rounded-20 backdrop-blur-2xl contrast-more:backdrop-blur-none bg-blend-soft-light bg-linear-to-b from-black/50 via-black/50 to-black contrast-more:bg-neutral-800 px-4 py-8 shadow-dialog flex flex-col gap-2 min-w-0`\n\nconst cardHeadingClass = tw`text-center text-19 font-bold leading-tight -tracking-2 mb-2`\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/logout-dialog.tsx",
    "content": "import {RiLogoutCircleRLine} from 'react-icons/ri'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {useAuth} from '@/modules/auth/use-auth'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nexport function LogoutDialog() {\n\t// TODO: Enable hook below after this component is only injected as needed rather than all the time\n\t// useUmbrelTitle('Log out')\n\tconst dialogProps = useDialogOpenProps('logout')\n\tconst {logout} = useAuth()\n\n\treturn (\n\t\t<AlertDialog {...dialogProps}>\n\t\t\t<AlertDialogContent>\n\t\t\t\t<AlertDialogHeader icon={RiLogoutCircleRLine}>\n\t\t\t\t\t<AlertDialogTitle>{t('logout.confirm.title')}</AlertDialogTitle>\n\t\t\t\t</AlertDialogHeader>\n\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t<AlertDialogAction variant='destructive' className='px-6' onClick={logout}>\n\t\t\t\t\t\t{t('logout.confirm.submit')}\n\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t</AlertDialogFooter>\n\t\t\t</AlertDialogContent>\n\t\t</AlertDialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/uninstall-confirmation-dialog.tsx",
    "content": "import {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {useAllAvailableApps} from '@/providers/available-apps'\nimport {t} from '@/utils/i18n'\n\nexport function UninstallConfirmationDialog({\n\topen,\n\tonOpenChange,\n\tappId,\n\tonConfirm,\n}: {\n\tappId: string\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n\tonConfirm: () => void\n}) {\n\tconst {appsKeyed, isLoading} = useAllAvailableApps()\n\tconst app = appsKeyed?.[appId]\n\n\tif (isLoading) return null\n\t// The app may have been removed from the app store\n\t// so we just allow to continue uninstallation\n\t// TODO: refactor to check for the app against the\n\t// installed apps instead of apps in the app store\n\tif (!app) {\n\t\tconsole.error(`${appId} not found`)\n\t}\n\n\tconst appName = app?.name || t('app')\n\n\treturn (\n\t\t<AlertDialog open={open} onOpenChange={onOpenChange}>\n\t\t\t<AlertDialogContent>\n\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t<AlertDialogTitle>{t('app.uninstall.confirm.title', {app: appName})}</AlertDialogTitle>\n\t\t\t\t\t<AlertDialogDescription>{t('app.uninstall.confirm.description', {app: appName})}</AlertDialogDescription>\n\t\t\t\t</AlertDialogHeader>\n\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t<AlertDialogAction variant='destructive' onClick={onConfirm}>\n\t\t\t\t\t\t{t('app.uninstall.confirm.submit')}\n\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t</AlertDialogFooter>\n\t\t\t</AlertDialogContent>\n\t\t</AlertDialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/desktop/uninstall-these-first-dialog.tsx",
    "content": "import {Close} from '@radix-ui/react-dialog'\nimport {ReactNode} from 'react'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'\nimport {useAllAvailableApps} from '@/providers/available-apps'\nimport {t} from '@/utils/i18n'\n\nexport function UninstallTheseFirstDialog({\n\topen,\n\tonOpenChange,\n\tappId,\n\ttoUninstallFirstIds: toInstallFirstIds,\n}: {\n\tappId: string\n\ttoUninstallFirstIds: string[]\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n}) {\n\tconst {appsKeyed, isLoading} = useAllAvailableApps()\n\tconst app = appsKeyed?.[appId]\n\n\tif (isLoading) return null\n\tif (!app) throw new Error(t('app-not-found', {app: appId}))\n\n\tconst appName = app?.name\n\tconst toUninstallApps = toInstallFirstIds.map((id) => appsKeyed?.[id])\n\n\treturn (\n\t\t<Dialog open={open} onOpenChange={onOpenChange}>\n\t\t\t<DialogContent>\n\t\t\t\t<DialogHeader>\n\t\t\t\t\t<DialogTitle>{t('app.uninstall.deps.used-by.title', {app: appName})}</DialogTitle>\n\t\t\t\t</DialogHeader>\n\t\t\t\t<div className='space-y-3'>\n\t\t\t\t\t{toUninstallApps.map((app) => (\n\t\t\t\t\t\t<AppWithName key={app.id} icon={app.icon} appName={app.name} />\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t\t<DialogDescription>\n\t\t\t\t\t{/* i18n-ally-key-missing expected, but the key exists */}\n\t\t\t\t\t{t('app.uninstall.deps.used-by.description', {\n\t\t\t\t\t\tcount: toUninstallApps.length,\n\t\t\t\t\t\tapp: appName,\n\t\t\t\t\t\tfirstAppToUninstall: toUninstallApps[0].name,\n\t\t\t\t\t})}\n\t\t\t\t</DialogDescription>\n\t\t\t\t<DialogFooter>\n\t\t\t\t\t<Close asChild>\n\t\t\t\t\t\t<Button variant='primary' size='dialog'>\n\t\t\t\t\t\t\t{t('ok')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</Close>\n\t\t\t\t</DialogFooter>\n\t\t\t</DialogContent>\n\t\t</Dialog>\n\t)\n}\n\nfunction AppWithName({icon, appName}: {icon: string; appName: ReactNode}) {\n\treturn (\n\t\t<div className='flex w-full items-center gap-2.5'>\n\t\t\t<AppIcon src={icon} size={36} className='rounded-8' />\n\t\t\t<div className='flex min-w-0 flex-1 flex-col gap-0.5'>\n\t\t\t\t<h3 className='truncate text-14 leading-tight font-semibold -tracking-3'>{appName}</h3>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/floating-island/bare-island.tsx",
    "content": "import {motion, useWillChange} from 'motion/react'\nimport {Children, isValidElement, useEffect, useRef, useState} from 'react'\nimport {RiCloseLine} from 'react-icons/ri'\n\n// Animation configurations\nconst spring = {\n\ttype: 'spring' as const,\n\tstiffness: 400,\n\tdamping: 30,\n}\n\n// Size presets\nconst islandSizes = {\n\tminimized: {\n\t\twidth: 150,\n\t\theight: 40,\n\t\tborderRadius: 22,\n\t},\n\texpanded: {\n\t\twidth: 371,\n\t\theight: 180,\n\t\tborderRadius: 32,\n\t},\n}\n\ninterface IslandProps {\n\tid: string\n\tchildren: React.ReactNode\n\tonClose?: () => void\n\tnonDismissable?: boolean\n\t// When true, the island will expand and cannot be minimized. Useful for critical states like imminent reboots.\n\tforceExpanded?: boolean\n}\n\ninterface IslandChildProps {\n\tchildren: React.ReactNode\n}\n\nexport const IslandMinimized = ({children}: IslandChildProps) => {\n\treturn <>{children}</>\n}\n\nexport const IslandExpanded = ({children}: IslandChildProps) => {\n\treturn <>{children}</>\n}\n\nexport const Island = ({children, onClose, nonDismissable, forceExpanded}: IslandProps) => {\n\tconst [isExpanded, setIsExpanded] = useState(true)\n\tconst islandRef = useRef<HTMLDivElement>(null)\n\tconst willChange = useWillChange()\n\n\t// Force expansion when forceExpanded prop is true\n\tuseEffect(() => {\n\t\tif (forceExpanded) {\n\t\t\tsetIsExpanded(true)\n\t\t}\n\t}, [forceExpanded])\n\n\t// Stop propagation on both click and pointerdown to prevent Radix dialogs from\n\t// detecting this as an \"outside\" interaction and closing (Radix uses pointer events)\n\tconst handleIslandClick = (e: React.MouseEvent) => {\n\t\te.stopPropagation()\n\t\tif (!isExpanded) {\n\t\t\tsetIsExpanded(true)\n\t\t}\n\t}\n\n\tconst handlePointerDown = (e: React.PointerEvent) => {\n\t\te.stopPropagation()\n\t}\n\n\t// Use forceExpanded to prevent minimizing, or use internal state\n\tconst effectiveExpanded = forceExpanded || isExpanded\n\tconst size = effectiveExpanded ? islandSizes.expanded : islandSizes.minimized\n\n\t// Find and render the appropriate child component\n\tconst childArray = Children.toArray(children)\n\tconst minimizedChild = childArray.find((child) => isValidElement(child) && child.type === IslandMinimized)\n\tconst expandedChild = childArray.find((child) => isValidElement(child) && child.type === IslandExpanded)\n\n\t// Minimize island and stop propagation so dialogs below don't also close\n\tconst handleBackdropClick = (e: React.MouseEvent | React.TouchEvent | React.PointerEvent) => {\n\t\te.stopPropagation()\n\t\tif (!forceExpanded) {\n\t\t\tsetIsExpanded(false)\n\t\t}\n\t}\n\n\treturn (\n\t\t<div className='flex justify-center md:block'>\n\t\t\t{/* Full-screen backdrop when expanded: captures outside clicks to minimize island first,\n\t\t\t    stopping propagation so dialogs below stay open. Provides layered dismissal UX. */}\n\t\t\t{effectiveExpanded && !forceExpanded && (\n\t\t\t\t<div\n\t\t\t\t\tclassName='fixed inset-0'\n\t\t\t\t\tonClick={handleBackdropClick}\n\t\t\t\t\tonPointerDown={handleBackdropClick}\n\t\t\t\t\tonTouchStart={handleBackdropClick}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t<motion.div\n\t\t\t\tref={islandRef}\n\t\t\t\tclassName='relative bg-black text-white shadow-floating-island'\n\t\t\t\tstyle={{\n\t\t\t\t\t// TODO: debug using var in color-mix on macOS safari\n\t\t\t\t\t// backgroundColor: 'color-mix(in srgb, #000000 95%, rgb(var(--color-brand)) 5%)',\n\t\t\t\t\twillChange,\n\t\t\t\t}}\n\t\t\t\tanimate={{\n\t\t\t\t\twidth: size.width,\n\t\t\t\t\theight: size.height,\n\t\t\t\t\tborderRadius: size.borderRadius,\n\t\t\t\t}}\n\t\t\t\ttransition={spring}\n\t\t\t\tonClick={handleIslandClick}\n\t\t\t\tonPointerDown={handlePointerDown}\n\t\t\t>\n\t\t\t\t<div className='absolute inset-0'>\n\t\t\t\t\t{effectiveExpanded ? expandedChild : minimizedChild}\n\t\t\t\t\t{effectiveExpanded && onClose && !nonDismissable && (\n\t\t\t\t\t\t<motion.button\n\t\t\t\t\t\t\tclassName='absolute top-4 right-4 rounded-full bg-white/10 p-1 transition-colors hover:bg-white/20'\n\t\t\t\t\t\t\tinitial={{scale: 0}}\n\t\t\t\t\t\t\tanimate={{scale: 1}}\n\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\t\t\tonClose()\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<RiCloseLine className='h-4 w-4 text-white' />\n\t\t\t\t\t\t</motion.button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</motion.div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/floating-island/container.tsx",
    "content": "import {AnimatePresence, motion} from 'motion/react'\n\nimport {BackupsIsland} from '@/features/backups/components/floating-island'\nimport {useBackupProgress} from '@/features/backups/hooks/use-backups'\nimport {AudioIsland} from '@/features/files/components/floating-islands/audio-island'\nimport {FormattingIsland} from '@/features/files/components/floating-islands/formatting-island'\nimport {OperationsIsland} from '@/features/files/components/floating-islands/operations-island'\nimport {UploadingIsland} from '@/features/files/components/floating-islands/uploading-island'\nimport {useExternalStorage} from '@/features/files/hooks/use-external-storage'\nimport {RaidIsland} from '@/features/storage/components/floating-island'\nimport {useRaidProgress} from '@/features/storage/hooks/use-raid-progress'\nimport {usePendingRaidOperation} from '@/features/storage/providers/pending-operation-context'\nimport {useGlobalFiles} from '@/providers/global-files'\nimport {useImmersiveDialogOpen} from '@/providers/immersive-dialog'\n\nconst spring = {\n\ttype: 'spring' as const,\n\tstiffness: 400,\n\tdamping: 30,\n}\n\nexport function FloatingIslandContainer() {\n\t// When any ImmersiveDialog is open, bump z-index so islands appear above it\n\tconst isImmersiveDialogOpen = useImmersiveDialogOpen()\n\n\t// Grab global audio and uploading items state\n\tconst {audio, uploadingItems, operations} = useGlobalFiles()\n\t// Backups progress\n\tconst backupProgressQ = useBackupProgress(1000)\n\t// External storage\n\tconst {disks} = useExternalStorage()\n\t// RAID progress (real events + pending operation set by dialogs)\n\tconst raidProgress = useRaidProgress()\n\tconst {pendingOperation} = usePendingRaidOperation()\n\n\t// Show audio island if there's an audio file playing\n\tconst showAudio = audio.path && audio.name\n\n\t// Show uploading island if there are any uploads in progress\n\tconst showUploading = uploadingItems.length > 0\n\n\t// Show operations island if there are any operations in progress\n\tconst showOperations = operations.length > 0\n\t// Show backups island if any backups are running\n\tconst showBackups = (backupProgressQ.data?.length || 0) > 0\n\t// Show formatting island if any devices are being formatted\n\tconst showFormatting = (disks?.filter((disk) => disk.isFormatting).length || 0) > 0\n\t// Show RAID island if any RAID operation is in progress (real or pending)\n\tconst showRaid = raidProgress !== null || pendingOperation !== null\n\n\t// Common animation props\n\tconst commonProps = {\n\t\tinitial: {opacity: 0, scale: 0, transformOrigin: 'bottom center'},\n\t\tanimate: {opacity: 1, scale: 1, transformOrigin: 'bottom center'},\n\t\texit: {opacity: 0, scale: 0, transformOrigin: 'bottom center'},\n\t\ttransition: {layout: spring, opacity: {duration: 0.2}, scale: {duration: 0.2}},\n\t}\n\n\t// Positioned above dock. Normally z-50 (same as dock, but behind immersive dialogs).\n\t// When an ImmersiveDialog is open: z-60 + pointer-events-auto so island appears above dialog and is clickable.\n\treturn (\n\t\t<div\n\t\t\tclassName={`fixed bottom-[76px] left-1/2 flex w-full -translate-x-1/2 transform-gpu flex-col items-center justify-center gap-1 will-change-transform md:bottom-[90px] md:flex-row md:items-baseline md:gap-2 ${isImmersiveDialogOpen ? 'pointer-events-auto z-[60]' : 'z-50'}`}\n\t\t>\n\t\t\t<AnimatePresence>\n\t\t\t\t{showUploading && (\n\t\t\t\t\t<motion.div key='upload-island' layout {...commonProps}>\n\t\t\t\t\t\t<UploadingIsland />\n\t\t\t\t\t</motion.div>\n\t\t\t\t)}\n\t\t\t\t{showOperations && (\n\t\t\t\t\t<motion.div key='operations-island' layout {...commonProps}>\n\t\t\t\t\t\t<OperationsIsland />\n\t\t\t\t\t</motion.div>\n\t\t\t\t)}\n\t\t\t\t{showFormatting && (\n\t\t\t\t\t<motion.div key='formatting-island' layout {...commonProps}>\n\t\t\t\t\t\t<FormattingIsland />\n\t\t\t\t\t</motion.div>\n\t\t\t\t)}\n\t\t\t\t{showRaid && (\n\t\t\t\t\t<motion.div key='raid-island' layout {...commonProps}>\n\t\t\t\t\t\t<RaidIsland />\n\t\t\t\t\t</motion.div>\n\t\t\t\t)}\n\t\t\t\t{showBackups && (\n\t\t\t\t\t<motion.div key='backups-island' layout {...commonProps}>\n\t\t\t\t\t\t<BackupsIsland />\n\t\t\t\t\t</motion.div>\n\t\t\t\t)}\n\t\t\t\t{showAudio && (\n\t\t\t\t\t<motion.div key='audio-island' layout {...commonProps}>\n\t\t\t\t\t\t<AudioIsland />\n\t\t\t\t\t</motion.div>\n\t\t\t\t)}\n\t\t\t</AnimatePresence>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/immersive-picker/index.tsx",
    "content": "import {matchSorter} from 'match-sorter'\nimport {useEffect, useRef, useState} from 'react'\nimport {TbChevronLeft} from 'react-icons/tb'\nimport {Link} from 'react-router-dom'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {ChevronDown} from '@/components/chevron-down'\nimport {Button} from '@/components/ui/button'\nimport {DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'\nimport {ImmersiveDialogContent, immersiveDialogTitleClass} from '@/components/ui/immersive-dialog'\nimport {Input} from '@/components/ui/input'\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {LOADING_DASH} from '@/constants'\nimport {cn} from '@/lib/utils'\nimport {useApps} from '@/providers/apps'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nexport const radioButtonClass = tw`rounded-12 bg-white/5 p-5 text-left flex justify-between items-center gap-2 flex-wrap shadow-button-highlight-soft-hpx outline-hidden duration-300 hover:bg-white/6 transition-[background,color,box-shadow] focus-visible:ring-4 ring-white/5 focus-visible:ring-offset-1 ring-offset-white/20`\nexport const radioTitleClass = tw`text-15 font-medium -tracking-2`\nexport const radioDescriptionClass = tw`text-13 opacity-90 -tracking-2`\n\nexport const immersivePickerDialogTitleClass = cn(immersiveDialogTitleClass, '-mt-1 text-19')\n\nexport function ImmersivePickerDialogContentInit({title, children}: {title: string; children: React.ReactNode}) {\n\treturn (\n\t\t<ImmersiveDialogContent short>\n\t\t\t<h1 className={immersivePickerDialogTitleClass}>{title}</h1>\n\t\t\t<div className='flex flex-col gap-2.5'>{children}</div>\n\t\t</ImmersiveDialogContent>\n\t)\n}\n\nexport function ImmersivePickerItem({\n\ttitle,\n\tdescription,\n\tchildren,\n\tto,\n\tonClick,\n}: {\n\ttitle: string\n\tdescription: string\n\tto?: string\n\tchildren?: React.ReactNode\n\tonClick?: () => void\n}) {\n\tif (to) {\n\t\treturn (\n\t\t\t<Link to={to} className={radioButtonClass}>\n\t\t\t\t<div>\n\t\t\t\t\t<div className={radioTitleClass}>{title}</div>\n\t\t\t\t\t<div className={radioDescriptionClass}>{description}</div>\n\t\t\t\t</div>\n\t\t\t\t{children}\n\t\t\t</Link>\n\t\t)\n\t}\n\treturn (\n\t\t<div className={cn(radioButtonClass)} onClick={onClick}>\n\t\t\t<div>\n\t\t\t\t<div className={radioTitleClass}>{title}</div>\n\t\t\t\t<div className={radioDescriptionClass}>{description}</div>\n\t\t\t</div>\n\t\t\t{children}\n\t\t</div>\n\t)\n}\n\nexport function BackLink({to, children}: {to: string; children: React.ReactNode}) {\n\treturn (\n\t\t<Link\n\t\t\tto={to}\n\t\t\tclassName='flex items-center justify-center rounded-full pr-2 decoration-white/20 underline-offset-4 outline-hidden focus-visible:underline'\n\t\t>\n\t\t\t<TbChevronLeft className='size-6 opacity-50' />\n\t\t\t<h1 className={cn(immersiveDialogTitleClass, 'text-19')}>{children}</h1>\n\t\t</Link>\n\t)\n}\n\nexport function ImmersivePickerDialogContent({children}: {children: React.ReactNode}) {\n\treturn (\n\t\t<ImmersiveDialogContent size='xl'>\n\t\t\t<div className='flex max-h-full flex-1 flex-col items-start gap-4'>{children}</div>\n\t\t</ImmersiveDialogContent>\n\t)\n}\n\nexport function AppDropdown({\n\tappId,\n\tsetAppId,\n\topen,\n\tonOpenChange,\n}: {\n\tappId?: string\n\tsetAppId: (id: string) => void\n\topen: boolean\n\tonOpenChange: (o: boolean) => void\n}) {\n\tconst [query, setQuery] = useState('')\n\tconst apps = useApps()\n\t// const [open, setOpen] = useState(false)\n\tconst inputRef = useRef<HTMLInputElement>(null)\n\n\tuseEffect(() => {\n\t\tif (!open) return\n\t\tsetTimeout(() => {\n\t\t\tinputRef.current?.focus()\n\t\t\tinputRef.current?.select()\n\t\t}, 0)\n\t}, [open])\n\n\tif (apps.isLoading || !apps.userApps || !apps.userAppsKeyed) {\n\t\treturn (\n\t\t\t<Button className='h-[36px] min-w-36 px-3'>\n\t\t\t\t<AppIcon size={20} className='rounded-4' />\n\t\t\t\t{LOADING_DASH}\n\t\t\t\t<ChevronDown />\n\t\t\t</Button>\n\t\t)\n\t}\n\n\tconst selectedApp = appId\n\t\t? apps.userAppsKeyed[appId]\n\t\t: {\n\t\t\t\ticon: undefined,\n\t\t\t\tname: t('app-picker.select-app'),\n\t\t\t}\n\n\tconst appResults = matchSorter(apps.userApps, query, {\n\t\tkeys: ['name'],\n\t\tthreshold: matchSorter.rankings.WORD_STARTS_WITH,\n\t})\n\n\treturn (\n\t\t// TODO: convert to combobox: https://ui.shadcn.com/docs/components/combobox\n\t\t<>\n\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t<Button className='h-[36px] min-w-36 px-3'>\n\t\t\t\t\t<span className='flex flex-1 flex-row items-center gap-2'>\n\t\t\t\t\t\t{selectedApp.icon && <AppIcon size={20} src={selectedApp.icon} className='rounded-4' />}\n\t\t\t\t\t\t{selectedApp.name}\n\t\t\t\t\t</span>\n\t\t\t\t\t<ChevronDown />\n\t\t\t\t</Button>\n\t\t\t</DropdownMenuTrigger>\n\t\t\t<DropdownMenuContent className='flex max-h-72 min-w-64 flex-col gap-3' align='start'>\n\t\t\t\t<Input\n\t\t\t\t\tvalue={query}\n\t\t\t\t\tclassName='shrink-0'\n\t\t\t\t\tonChange={(e) => setQuery(e.target.value)}\n\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t// Prevent key presses from triggering stuff in the dropdown menu\n\t\t\t\t\t\te.stopPropagation()\n\t\t\t\t\t\tif (e.key === 'Enter') {\n\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\tsetAppId(appResults[0].id)\n\t\t\t\t\t\t\tsetQuery('')\n\t\t\t\t\t\t\tonOpenChange(false)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (e.key === 'Escape') {\n\t\t\t\t\t\t\tsetQuery('')\n\t\t\t\t\t\t\tonOpenChange(false)\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\tsizeVariant={'short-square'}\n\t\t\t\t\tplaceholder={t('app-picker.search')}\n\t\t\t\t\tref={inputRef}\n\t\t\t\t/>\n\t\t\t\t{appResults.length === 0 && <div className='text-14 text-white/50'>{t('no-results-found')}</div>}\n\t\t\t\t{appResults.length > 0 && (\n\t\t\t\t\t<ScrollArea className='relative -mx-2.5 flex h-full flex-col px-2.5'>\n\t\t\t\t\t\t{appResults.map((app, i) => (\n\t\t\t\t\t\t\t<DropdownMenuCheckboxItem\n\t\t\t\t\t\t\t\tkey={app.id}\n\t\t\t\t\t\t\t\tchecked={app.id === appId}\n\t\t\t\t\t\t\t\tonSelect={() => setAppId(app.id)}\n\t\t\t\t\t\t\t\tclassName='flex gap-2'\n\t\t\t\t\t\t\t\tdata-highlighted={i === 0 && query ? true : undefined}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<AppIcon size={20} src={app.icon} className='rounded-4' />\n\t\t\t\t\t\t\t\t{app.name}\n\t\t\t\t\t\t\t</DropdownMenuCheckboxItem>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</ScrollArea>\n\t\t\t\t)}\n\t\t\t</DropdownMenuContent>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/migrate/migrate-image.tsx",
    "content": "import {FadeInImg} from '@/components/ui/fade-in-img'\nimport {trpcReact} from '@/trpc/trpc'\n\n// TODO: Add Umbrel Pro image variant (e.g., migrate-raspberrypi-umbrel-pro.png)\n// and update logic to show correct image based on target device (Home vs Pro)\nconst FROM_RASPBERRY_PI_URL = '/assets/migrate-raspberrypi-umbrel-home.png'\nconst FROM_UMBREL_URL = '/assets/migrate-umbrel-home-umbrel-home.png'\n\nexport function MigrateImage() {\n\tconst isMigrationFromUmbrelQ = trpcReact.migration.isMigratingFromUmbrelHome.useQuery()\n\n\tconst url = isMigrationFromUmbrelQ.data ? FROM_UMBREL_URL : FROM_RASPBERRY_PI_URL\n\n\treturn <FadeInImg src={url} width={111} height={104} alt='' />\n}\n"
  },
  {
    "path": "packages/ui/src/modules/migrate/migrate-inner.tsx",
    "content": "import {motion} from 'motion/react'\n\nimport {Alert} from '@/modules/bare/alert'\nimport {Progress} from '@/modules/bare/progress'\nimport {bareContainerClass, BareLogoTitle, BareSpacer} from '@/modules/bare/shared'\nimport {t} from '@/utils/i18n'\n\nexport function MigrateInner({\n\t// onSuccess,\n\t// onFail,\n\tprogress,\n\tmessage,\n\t// isStarting,\n\tisRunning,\n}: {\n\t// onSuccess: () => void\n\t// onFail: () => void\n\tprogress?: number\n\tmessage: string\n\t// isStarting: boolean\n\tisRunning: boolean\n}) {\n\t// const progress = migrationStatusQ.data?.progress\n\t// const isRunning = migrationStatusQ.data?.running\n\tconst isStarting = !progress && !isRunning\n\t// const isRunning = true\n\t// const isStarting = false\n\n\t// const message = (migrationStatusQ.data?.description || 'Connecting') + '...'\n\n\t// if (migrationStatusQ.data?.error) {\n\t// \t// navigate('/migrate/failed')\n\t// \tonFail()\n\t// }\n\n\t// if (!isRunning && progress === 100) {\n\t// \tonSuccess()\n\t// \t// navigate('/migrate/success')\n\t// }\n\n\treturn (\n\t\t<motion.div\n\t\t\tclassName={bareContainerClass}\n\t\t\tinitial={{opacity: 0}}\n\t\t\tanimate={{opacity: 1}}\n\t\t\ttransition={{duration: 0.4, delay: 0.2}}\n\t\t>\n\t\t\t<BareLogoTitle>{t('migration-assistant')}</BareLogoTitle>\n\t\t\t<BareSpacer />\n\t\t\t{/* Show indeterminate value if not running */}\n\t\t\t<Progress value={isStarting ? undefined : progress}>{message}</Progress>\n\t\t\t<div className='flex-1 pt-4' />\n\t\t\t<Alert>{t('migrate.callout')}</Alert>\n\t\t</motion.div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/sheet-top-fixed.tsx",
    "content": "import {Portal} from '@radix-ui/react-portal'\nimport {ReactNode} from 'react'\n\nconst SHEET_FIXED_ID = 'sheet-fixed-id'\n\nexport function SheetFixedTarget() {\n\treturn <div id={SHEET_FIXED_ID} />\n}\nexport function SheetFixedContent({children}: {children: ReactNode}) {\n\treturn <Portal container={document.getElementById(SHEET_FIXED_ID)}>{children}</Portal>\n}\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/four-stats-widget.tsx",
    "content": "import {LOADING_DASH} from '@/constants'\nimport {cn} from '@/lib/utils'\nimport type {FourStatsItem, FourStatsWidget, FourStatsWidgetProps} from '@/modules/widgets/shared/constants'\n\nimport {WidgetContainer, widgetTextCva} from './shared/shared'\n\nexport function FourStatsWidget({\n\titems,\n\tlink,\n\tonClick,\n}: FourStatsWidgetProps & {\n\tonClick?: (link?: string) => void\n}) {\n\treturn (\n\t\t<WidgetContainer\n\t\t\tonClick={() => onClick?.(link)}\n\t\t\tclassName='grid grid-cols-2 grid-rows-2 gap-1 p-1.5 sm:gap-2 sm:p-2.5'\n\t\t>\n\t\t\t{items?.slice(0, 4)?.map((item) => (\n\t\t\t\t<Item key={item.title} title={item.title} text={item.text} subtext={item.subtext} />\n\t\t\t))}\n\t\t\t{!items && (\n\t\t\t\t<>\n\t\t\t\t\t<Item title={LOADING_DASH} text={LOADING_DASH} subtext={LOADING_DASH} />\n\t\t\t\t\t<Item title={LOADING_DASH} text={LOADING_DASH} subtext={LOADING_DASH} />\n\t\t\t\t\t<Item title={LOADING_DASH} text={LOADING_DASH} subtext={LOADING_DASH} />\n\t\t\t\t\t<Item title={LOADING_DASH} text={LOADING_DASH} subtext={LOADING_DASH} />\n\t\t\t\t</>\n\t\t\t)}\n\t\t</WidgetContainer>\n\t)\n}\n\nfunction Item(item?: FourStatsItem) {\n\treturn (\n\t\t<div className='flex h-full flex-col justify-center rounded-5 bg-white/5 px-1 leading-none sm:rounded-12 sm:px-5'>\n\t\t\t<p\n\t\t\t\tclassName={cn(\n\t\t\t\t\twidgetTextCva({\n\t\t\t\t\t\topacity: 'secondary',\n\t\t\t\t\t}),\n\t\t\t\t\t'text-[8px] sm:text-11',\n\t\t\t\t)}\n\t\t\t\ttitle={item?.text}\n\t\t\t>\n\t\t\t\t{item?.title}\n\t\t\t</p>\n\t\t\t<p className={widgetTextCva()}>\n\t\t\t\t{item?.text} <span className={widgetTextCva({opacity: 'tertiary'})}>{item?.subtext}</span>\n\t\t\t</p>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/index.tsx",
    "content": "import {ComponentPropsWithRef, useEffect, useState} from 'react'\nimport {useNavigate} from 'react-router-dom'\nimport {map} from 'remeda'\n\nimport {toast} from '@/components/ui/toast'\nimport {BASE_ROUTE_PATH, HOME_PATH} from '@/features/files/constants'\nimport {FilesGridWidget, FilesListWidget} from '@/features/files/widgets'\nimport {useLaunchApp} from '@/hooks/use-launch-app'\nimport {temperatureDescriptionsKeyed, useTemperatureUnit} from '@/hooks/use-temperature-unit'\nimport {\n\tDEFAULT_REFRESH_MS,\n\tExampleWidgetConfig,\n\tRegistryWidget,\n\tWidgetConfig,\n\tWidgetType,\n} from '@/modules/widgets/shared/constants'\nimport {useApps} from '@/providers/apps'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {celciusToFahrenheit} from '@/utils/temperature'\n\nimport {FourStatsWidget} from './four-stats-widget'\nimport {ListEmojiWidget} from './list-emoji-widget'\nimport {ListWidget} from './list-widget'\nimport {WidgetContainer} from './shared/shared'\nimport {TextWithButtonsWidget} from './text-with-buttons-widget'\nimport {TextWithProgressWidget} from './text-with-progress-widget'\nimport {ThreeStatsWidget} from './three-stats-widget'\nimport {TwoStatsWidget} from './two-stats-with-guage-widget'\n\nexport function Widget({appId, config: manifestConfig}: {appId: string; config: RegistryWidget}) {\n\t// TODO: find a way to use `useApp()` to be cleaner\n\tconst {userAppsKeyed, systemAppsKeyed, isLoading: isLoadingApps} = useApps()\n\tconst app = userAppsKeyed?.[appId]\n\t// const finalEndpointUrl = urlJoin(appToUrlWithAppPath(app), config.endpoint);\n\n\tconst [refetchInterval, setRefetchInterval] = useState(manifestConfig.refresh ?? DEFAULT_REFRESH_MS)\n\n\tconst widgetQ = trpcReact.widget.data.useQuery(\n\t\t{widgetId: manifestConfig.id},\n\t\t{\n\t\t\tretry: false,\n\t\t\t// We do want refetching to happen on a schedule though\n\t\t\trefetchInterval,\n\t\t},\n\t)\n\n\t// Update the refetch interval based on the widget config, not the manifest config\n\t// This makes the widget refresh interval dynamic\n\tconst widgetConfigRefresh = (widgetQ.data as WidgetConfig)?.refresh\n\tuseEffect(() => {\n\t\tif (!widgetConfigRefresh) return\n\t\tsetRefetchInterval(widgetConfigRefresh)\n\t}, [widgetConfigRefresh])\n\n\tconst navigate = useNavigate()\n\tconst launchApp = useLaunchApp()\n\n\tconst isLoading = isLoadingApps || widgetQ.isLoading\n\n\tconst handleClick = (link?: string) => {\n\t\t// Handle special system/features widgets\n\t\tif (appId === 'live-usage' && systemAppsKeyed['UMBREL_live-usage']) {\n\t\t\tnavigate(link || '?dialog=live-usage')\n\t\t\treturn\n\t\t}\n\t\tif (appId === 'files' && systemAppsKeyed['UMBREL_files']) {\n\t\t\tnavigate(link || `${BASE_ROUTE_PATH}${HOME_PATH}`)\n\t\t\treturn\n\t\t}\n\t\tif (app) {\n\t\t\t// Launching directly because it's weird to have credentials show up\n\t\t\t// Users will likely open the app by clicking the icon before adding a widget associated with the app\n\t\t\tlaunchApp(appId, {path: link, direct: true})\n\t\t} else {\n\t\t\ttoast.error(t('app-not-found', {app: appId}))\n\t\t}\n\t}\n\n\t// Render the widget component directly instead of swapping between <LoadingWidget>\n\t// and the real widget. This keeps the same WidgetContainer (which has backdrop-filter)\n\t// mounted across loading → loaded, avoiding a visual flash when the backdrop-filter\n\t// re-composites on remount. When data isn't available, widget components render\n\t// with placeholder dashes (same visual as LoadingWidget).\n\tconst widget = (!isLoading && !widgetQ.isError ? widgetQ.data : undefined) as WidgetConfig\n\n\tswitch (manifestConfig.type) {\n\t\tcase 'text-with-buttons':\n\t\t\treturn <TextWithButtonsWidget {...(widget as WidgetConfig<'text-with-buttons'>)} onClick={handleClick} />\n\t\tcase 'text-with-progress':\n\t\t\treturn <TextWithProgressWidget {...(widget as WidgetConfig<'text-with-progress'>)} onClick={handleClick} />\n\t\tcase 'two-stats-with-guage':\n\t\t\treturn <TwoStatsWidget {...(widget as WidgetConfig<'two-stats-with-guage'>)} onClick={handleClick} />\n\t\tcase 'three-stats':\n\t\t\t// TODO: figure out how to show the user's desired temp unit in a way that isn't brittle\n\t\t\tif (manifestConfig.id === 'umbrel:system-statss') {\n\t\t\t\treturn <SystemThreeUpWidget {...(widget as WidgetConfig<'three-stats'>)} onClick={handleClick} />\n\t\t\t}\n\t\t\treturn <ThreeStatsWidget {...(widget as WidgetConfig<'three-stats'>)} onClick={handleClick} />\n\t\tcase 'four-stats':\n\t\t\treturn <FourStatsWidget {...(widget as WidgetConfig<'four-stats'>)} onClick={handleClick} />\n\t\tcase 'list':\n\t\t\treturn <ListWidget {...(widget as WidgetConfig<'list'>)} onClick={handleClick} />\n\t\tcase 'list-emoji':\n\t\t\treturn <ListEmojiWidget {...(widget as WidgetConfig<'list-emoji'>)} onClick={handleClick} />\n\t\t// features/files widgets\n\t\tcase 'files-list':\n\t\t\treturn <FilesListWidget {...(widget as WidgetConfig<'files-list'>)} onClick={handleClick} />\n\t\tcase 'files-grid':\n\t\t\treturn <FilesGridWidget {...(widget as WidgetConfig<'files-grid'>)} onClick={handleClick} />\n\t}\n}\n\n// Hacky way to get the right temperature unit based on user preferences\nexport function SystemThreeUpWidget({items, ...props}: ComponentPropsWithRef<typeof ThreeStatsWidget>) {\n\tconst [temperatureUnit] = useTemperatureUnit()\n\n\t// Show loading dashes while data is being fetched (items is undefined during loading)\n\tif (!items) return <ThreeStatsWidget {...props} />\n\n\tconst modifiedItems = map.strict(items, (item) => {\n\t\tif (!item.text?.includes('℃')) return item\n\t\tconst celciusNumber = parseInt(item.text.replace('℃', ''))\n\t\tconst temperatureNumber = temperatureUnit === 'f' ? celciusToFahrenheit(celciusNumber) : celciusNumber\n\t\tconst temperatureUnitLabel = temperatureDescriptionsKeyed[temperatureUnit].label\n\t\tconst newValue = temperatureNumber + temperatureUnitLabel\n\t\treturn {...item, text: newValue}\n\t})\n\treturn <ThreeStatsWidget items={modifiedItems} {...props} />\n}\n\nexport function ExampleWidget<T extends WidgetType = WidgetType>({\n\ttype,\n\texample,\n}: {\n\ttype: T\n\texample?: ExampleWidgetConfig<T>\n}) {\n\tswitch (type) {\n\t\tcase 'text-with-buttons': {\n\t\t\tconst w = example as WidgetConfig<'text-with-buttons'>\n\t\t\tconst widgetWithButtonLinks = {\n\t\t\t\t...w,\n\t\t\t\t// Link to nowhere\n\t\t\t\tbuttons: w.buttons?.map((button) => ({...button, link: ''})),\n\t\t\t}\n\t\t\treturn <TextWithButtonsWidget {...widgetWithButtonLinks} />\n\t\t}\n\t\tcase 'text-with-progress': {\n\t\t\tconst w = example as WidgetConfig<'text-with-progress'>\n\t\t\treturn <TextWithProgressWidget {...w} />\n\t\t}\n\t\tcase 'two-stats-with-guage': {\n\t\t\tconst w = example as WidgetConfig<'two-stats-with-guage'>\n\t\t\treturn <TwoStatsWidget {...w} />\n\t\t}\n\t\tcase 'three-stats': {\n\t\t\tconst w = example as WidgetConfig<'three-stats'>\n\t\t\treturn <ThreeStatsWidget {...w} />\n\t\t}\n\t\tcase 'four-stats': {\n\t\t\tconst w = example as WidgetConfig<'four-stats'>\n\t\t\treturn <FourStatsWidget {...w} />\n\t\t}\n\t\tcase 'list': {\n\t\t\tconst w = example as WidgetConfig<'list'>\n\t\t\treturn <ListWidget {...w} />\n\t\t}\n\t\tcase 'list-emoji': {\n\t\t\tconst w = example as WidgetConfig<'list-emoji'>\n\t\t\treturn <ListEmojiWidget {...w} />\n\t\t}\n\t\t// features/files widgets\n\t\tcase 'files-list': {\n\t\t\tconst w = example as WidgetConfig<'files-list'>\n\t\t\treturn <FilesListWidget {...w} />\n\t\t}\n\t\tcase 'files-grid': {\n\t\t\tconst w = example as WidgetConfig<'files-grid'>\n\t\t\treturn <FilesGridWidget {...w} />\n\t\t}\n\t}\n}\n\nexport function LoadingWidget<T extends WidgetType = WidgetType>({type, onClick}: {type: T; onClick?: () => void}) {\n\tswitch (type) {\n\t\tcase 'text-with-buttons': {\n\t\t\treturn <TextWithButtonsWidget onClick={onClick} />\n\t\t}\n\t\tcase 'text-with-progress': {\n\t\t\treturn <TextWithProgressWidget onClick={onClick} />\n\t\t}\n\t\tcase 'two-stats-with-guage': {\n\t\t\treturn <TwoStatsWidget onClick={onClick} />\n\t\t}\n\t\tcase 'three-stats': {\n\t\t\treturn <ThreeStatsWidget onClick={onClick} />\n\t\t}\n\t\tcase 'four-stats': {\n\t\t\treturn <FourStatsWidget onClick={onClick} />\n\t\t}\n\t\tcase 'list': {\n\t\t\treturn <ListWidget onClick={onClick} />\n\t\t}\n\t\tcase 'list-emoji': {\n\t\t\treturn <ListEmojiWidget onClick={onClick} />\n\t\t}\n\t\t// features/files widgets\n\t\tcase 'files-list': {\n\t\t\treturn <FilesListWidget onClick={onClick} />\n\t\t}\n\t\tcase 'files-grid': {\n\t\t\treturn <FilesGridWidget onClick={onClick} />\n\t\t}\n\t}\n}\n\nexport function ErrorWidget({error}: {error: string}) {\n\treturn <WidgetContainer className='p-5 text-12 text-destructive2-lightest'>{error}</WidgetContainer>\n}\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/list-emoji-widget.tsx",
    "content": "import {LOADING_DASH} from '@/constants'\nimport type {ListEmojiItem, ListEmojiWidget, ListEmojiWidgetProps} from '@/modules/widgets/shared/constants'\n\nimport {WidgetContainer, widgetTextCva} from './shared/shared'\n\nexport function ListEmojiWidget({\n\titems,\n\tcount,\n\tlink,\n\tonClick,\n}: ListEmojiWidgetProps & {\n\tonClick?: (link?: string) => void\n}) {\n\treturn (\n\t\t<WidgetContainer onClick={() => onClick?.(link)} className='relative gap-0 p-2 pb-2.5 sm:gap-2 sm:p-5'>\n\t\t\t{!items && <ListEmojiItem emoji='' text={LOADING_DASH} />}\n\t\t\t{items?.[0] && <ListEmojiItem emoji={items?.[0].emoji} text={items?.[0].text} />}\n\t\t\t{items?.[1] && (\n\t\t\t\t<div className='origin-left scale-90 opacity-60'>\n\t\t\t\t\t<ListEmojiItem emoji={items?.[1].emoji} text={items?.[1].text} />\n\t\t\t\t</div>\n\t\t\t)}\n\t\t\t{items?.[2] && (\n\t\t\t\t<div className='origin-left scale-[.8] opacity-40'>\n\t\t\t\t\t<ListEmojiItem emoji={items?.[2].emoji} text={items?.[2].text} />\n\t\t\t\t</div>\n\t\t\t)}\n\t\t\t{items?.[3] && (\n\t\t\t\t<div className='origin-left scale-[.7] opacity-20'>\n\t\t\t\t\t<ListEmojiItem emoji={items?.[3].emoji} text={items?.[3].text} />\n\t\t\t\t</div>\n\t\t\t)}\n\t\t\t<div className='absolute right-3 bottom-3 w-1/2 truncate text-right text-[33px] leading-none font-semibold -tracking-3 opacity-10'>\n\t\t\t\t{count ?? LOADING_DASH}\n\t\t\t</div>\n\t\t</WidgetContainer>\n\t)\n}\n\nfunction ListEmojiItem(item?: ListEmojiItem) {\n\treturn (\n\t\t<div className='flex items-center gap-1.5'>\n\t\t\t<div className='flex h-5 w-5 items-center justify-center rounded-5 bg-white/5'>\n\t\t\t\t{limitToOneEmoji(item?.emoji ?? '')}\n\t\t\t</div>\n\t\t\t<p className={widgetTextCva()}>{item?.text}</p>\n\t\t</div>\n\t)\n}\n\nfunction limitToOneEmoji(str: string) {\n\tconst emojiRegex = /(\\p{Emoji_Presentation}|\\p{Extended_Pictographic})/gu\n\tconst matchedEmojis = str.match(emojiRegex)\n\treturn matchedEmojis ? matchedEmojis[0] : ''\n}\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/list-widget.tsx",
    "content": "import {Fragment} from 'react'\n\nimport {LOADING_DASH} from '@/constants'\nimport type {ListWidget, ListWidgetItem, ListWidgetProps} from '@/modules/widgets/shared/constants'\n\nimport {WidgetContainer} from './shared/shared'\n\nexport function ListWidget({\n\titems,\n\tlink,\n\tnoItemsText = 'Nothing to show.',\n\tonClick,\n}: ListWidgetProps & {\n\tonClick?: (link?: string) => void\n}) {\n\treturn (\n\t\t<WidgetContainer onClick={() => onClick?.(link)} className='overflow-hidden p-2 !pb-0 sm:p-4'>\n\t\t\t<div\n\t\t\t\tclassName='flex h-full w-full flex-col gap-2 max-sm:gap-0'\n\t\t\t\tstyle={{\n\t\t\t\t\tmaskImage: 'linear-gradient(to bottom, red 50px calc(100% - 80px), transparent)',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{!items && <ListItem subtext={undefined} text={LOADING_DASH} />}\n\t\t\t\t{items?.length === 0 && (\n\t\t\t\t\t<div className='grid h-full w-full place-items-center pb-2 text-center sm:pb-4'>{noItemsText}</div>\n\t\t\t\t)}\n\t\t\t\t{/* Slice just in case API sends down too much data */}\n\t\t\t\t{items &&\n\t\t\t\t\titems.length > 0 &&\n\t\t\t\t\titems.slice(0, 5).map((item, i) => (\n\t\t\t\t\t\t<Fragment key={i}>\n\t\t\t\t\t\t\t{i !== 0 && <hr className='border-white/5' />}\n\t\t\t\t\t\t\t<ListItem subtext={item.subtext} text={item.text} />\n\t\t\t\t\t\t</Fragment>\n\t\t\t\t\t))}\n\t\t\t</div>\n\t\t</WidgetContainer>\n\t)\n}\n\nfunction ListItem(item?: ListWidgetItem) {\n\treturn (\n\t\t<div className='text-12 leading-tight'>\n\t\t\t<div className='truncate opacity-20'>{item?.subtext ?? LOADING_DASH}</div>\n\t\t\t<p className='line-clamp-2 text-11 opacity-80 sm:text-12'>{item?.text}</p>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/shared/backdrop-blur-context.tsx",
    "content": "import {createContext} from 'react'\n\ntype Variant = 'with-backdrop-blur' | 'default'\nexport const BackdropBlurVariantContext = createContext<Variant>('with-backdrop-blur')\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/shared/constants.ts",
    "content": "// TODO: this should all probably be in umbreld\n\nimport {FilesGridWidget, FilesListWidget, filesWidgetTypes} from '@/features/files/widgets'\n\nexport const DEFAULT_REFRESH_MS = 1000 * 60 * 5\n\nexport type BaseWidget = {\n\trefresh?: number\n}\n\nexport const widgetTypes = [\n\t'text-with-buttons',\n\t'text-with-progress',\n\t'two-stats-with-guage',\n\t'three-stats',\n\t'four-stats',\n\t'list-emoji',\n\t'list',\n\n\t// features/files widgets\n\t...filesWidgetTypes,\n] as const\n\nexport type WidgetType = (typeof widgetTypes)[number]\n\n// ------------------------------\n\n/**\n * This link is relative to `RegistryApp['path']`\n * NOTE: type is created for this comment to appear in VSCode\n */\nexport type Link = string\n\nexport type FourStatsItem = BaseWidget & {\n\ttitle?: string\n\ttext?: string\n\tsubtext?: string\n}\nexport type FourStatsWidget = BaseWidget & {\n\ttype: 'four-stats'\n\tlink?: Link\n\titems?: [FourStatsItem, FourStatsItem, FourStatsItem, FourStatsItem]\n}\nexport type FourStatsWidgetProps = Omit<FourStatsWidget, 'type'>\n\nexport type ThreeStatsItem = {\n\ticon?: string\n\tsubtext?: string\n\ttext?: string\n}\nexport type ThreeStatsWidget = BaseWidget & {\n\ttype: 'three-stats'\n\tlink?: Link\n\titems?: [ThreeStatsItem, ThreeStatsItem, ThreeStatsItem]\n}\nexport type ThreeStatsWidgetProps = Omit<ThreeStatsWidget, 'type'>\n\n// The long name feels like it could be just be two-stats, but if we ever add one without a progress, what would we call it?\nexport type TwoStatsWithProgressItem = {\n\ttitle?: string\n\ttext?: string\n\tsubtext?: string\n\t/** Number from 0 to 1 */\n\tprogress?: number\n}\nexport type TwoStatsWithProgressWidget = BaseWidget & {\n\ttype: 'two-stats-with-guage'\n\tlink?: Link\n\titems?: [TwoStatsWithProgressItem, TwoStatsWithProgressItem]\n}\nexport type TwoStatsWithProgressWidgetProps = Omit<TwoStatsWithProgressWidget, 'type'>\n\nexport type TextWithProgressWidget = BaseWidget & {\n\ttype: 'text-with-progress'\n\tlink?: Link\n\ttitle?: string\n\ttext?: string\n\tsubtext?: string\n\tprogressLabel?: string\n\t/** Number from 0 to 1 */\n\tprogress?: number\n}\nexport type TextWithProgressWidgetProps = Omit<TextWithProgressWidget, 'type'>\n\nexport type TextWithButtonsWidget = BaseWidget & {\n\ttype: 'text-with-buttons'\n\ttitle?: string\n\ttext?: string\n\tsubtext?: string\n\tbuttons?: {\n\t\ttext?: string\n\t\ticon?: string\n\t\tlink: Link\n\t}[]\n}\nexport type TextWithButtonsWidgetProps = Omit<TextWithButtonsWidget, 'type'>\n\nexport type ListWidgetItem = {\n\ttext?: string\n\tsubtext?: string\n}\nexport type ListWidget = BaseWidget & {\n\ttype: 'list'\n\tlink?: Link\n\titems?: ListWidgetItem[]\n\tnoItemsText?: string\n}\nexport type ListWidgetProps = Omit<ListWidget, 'type'>\n\nexport type ListEmojiItem = {\n\temoji?: string\n\ttext?: string\n}\nexport type ListEmojiWidget = BaseWidget & {\n\ttype: 'list-emoji'\n\tlink?: Link\n\tcount?: string\n\titems?: ListEmojiItem[]\n}\nexport type ListEmojiWidgetProps = Omit<ListEmojiWidget, 'type'>\n\ntype AnyWidgetConfig =\n\t| FourStatsWidget\n\t| ThreeStatsWidget\n\t| TwoStatsWithProgressWidget\n\t| TextWithProgressWidget\n\t| TextWithButtonsWidget\n\t| ListWidget\n\t| ListEmojiWidget\n\t// features/files widgets\n\t| FilesListWidget\n\t| FilesGridWidget\n\n// Choose the widget AnyWidgetConfig based on the type `T` passed in, othwerwise `never`\nexport type WidgetConfig<T extends WidgetType = WidgetType> = Extract<AnyWidgetConfig, {type: T}>\n\n// ------------------------------\n\nexport type ExampleWidgetConfig<T extends WidgetType = WidgetType> = T extends 'text-with-buttons'\n\t? // Omit the `type` (and `link` from buttons) by omitting `buttons` and then adding it without the `link`\n\t\tOmit<TextWithButtonsWidget, 'type' | 'buttons'> & {buttons: Omit<TextWithButtonsWidget['buttons'], 'link'>}\n\t: // Otherwise, just omit the `type`\n\t\tOmit<WidgetConfig<T>, 'type'>\n\n// Adding `= WidgetType` to `T` makes it so that if `T` is not provided, it defaults to `WidgetType`. Prevents us from always having to write `RegistryWidget<WidgetType>` when referring to the type.\nexport type RegistryWidget<T extends WidgetType = WidgetType> = {\n\tid: string\n\ttype: T\n\trefresh?: number\n\t// Examples aren't interactive so no need to include `link` in example\n\texample?: ExampleWidgetConfig<T>\n}\n\n// ------------------------------\n\nexport const MAX_WIDGETS = 3\n\nexport const liveUsageWidgets: [\n\tRegistryWidget<'text-with-progress'>,\n\tRegistryWidget<'text-with-progress'>,\n\tRegistryWidget<'three-stats'>,\n] = [\n\t{\n\t\tid: 'umbrel:storage',\n\t\ttype: 'text-with-progress',\n\t\texample: {\n\t\t\ttitle: 'Storage',\n\t\t\ttext: '256 GB',\n\t\t\tprogressLabel: '1.75 TB left',\n\t\t\tprogress: 0.25,\n\t\t},\n\t},\n\t{\n\t\tid: 'umbrel:memory',\n\t\ttype: 'text-with-progress',\n\t\texample: {\n\t\t\ttitle: 'Memory',\n\t\t\ttext: '5.8 GB',\n\t\t\tsubtext: '/16GB',\n\t\t\tprogressLabel: '11.4 GB left',\n\t\t\tprogress: 0.36,\n\t\t},\n\t},\n\t{\n\t\tid: 'umbrel:system-stats',\n\t\ttype: 'three-stats',\n\t\texample: {\n\t\t\titems: [\n\t\t\t\t{\n\t\t\t\t\ticon: 'system-widget-cpu',\n\t\t\t\t\tsubtext: 'CPU',\n\t\t\t\t\ttext: '24%',\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ticon: 'system-widget-memory',\n\t\t\t\t\tsubtext: 'Memory',\n\t\t\t\t\ttext: '5.8 GB',\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ticon: 'system-widget-storage',\n\t\t\t\t\tsubtext: 'Storage',\n\t\t\t\t\ttext: '1.75 TB',\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t},\n]\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/shared/shared.tsx",
    "content": "import {cva} from 'class-variance-authority'\nimport {useContext} from 'react'\n\nimport {cn} from '@/lib/utils'\nimport {tw} from '@/utils/tw'\n\nimport {BackdropBlurVariantContext} from './backdrop-blur-context'\n\nexport const widgetContainerCva = cva(\n\tcn(\n\t\ttw`bg-neutral-800/60 rounded-12 sm:rounded-20 w-[var(--widget-w)] h-[var(--widget-h)] shrink-0 flex flex-col gap-2 cursor-default text-left`,\n\t\t// animations\n\t\ttw`transition-[scale,box-shadow] duration-300 hover:scale-105`,\n\t),\n\t// ^-- Using `tw` to force vscode to recognize the tailwind classes\n\t{\n\t\tvariants: {\n\t\t\tvariant: {\n\t\t\t\t'with-backdrop-blur':\n\t\t\t\t\t'bg-neutral-900/70 backdrop-blur-xl backdrop-saturate-150 backdrop-brightness-[1.25] contrast-more:backdrop-blur-none contrast-more:bg-neutral-900 backdrop-saturate-[300%] shadow-widget',\n\t\t\t\tdefault: 'bg-neutral-900/80 shadow-widget',\n\t\t\t},\n\t\t},\n\t\tdefaultVariants: {\n\t\t\tvariant: 'with-backdrop-blur',\n\t\t},\n\t},\n)\n\nexport const widgetTextCva = cva('text-11 sm:text-13 leading-snug font-semibold -tracking-2 truncate', {\n\tvariants: {\n\t\topacity: {\n\t\t\tprimary: 'opacity-80',\n\t\t\tsecondary: 'opacity-50',\n\t\t\ttertiary: 'opacity-25',\n\t\t},\n\t},\n})\n\ntype WidgetContainerButtonProps = React.ComponentPropsWithoutRef<'button'>\ntype WidgetContainerDivProps = React.ComponentPropsWithoutRef<'div'>\ntype WidgetContainerProps = WidgetContainerButtonProps | WidgetContainerDivProps\n\n/** Make the widget a button if we pass an `onClick` */\nexport const WidgetContainer: React.FC<WidgetContainerProps> = ({className, ...props}) => {\n\tconst variant = useContext(BackdropBlurVariantContext)\n\n\t// Forcing the correct types for `props`\n\t// Only allow `onClick` to do something if it's truthy\n\tif ('onClick' in props) {\n\t\tconst p = props as WidgetContainerButtonProps\n\t\treturn (\n\t\t\t<button\n\t\t\t\tclassName={cn(\n\t\t\t\t\twidgetContainerCva({variant}),\n\t\t\t\t\t'ring-white/25 focus:outline-hidden focus-visible:ring-6 active:scale-95',\n\t\t\t\t\tclassName,\n\t\t\t\t)}\n\t\t\t\t{...p}\n\t\t\t/>\n\t\t)\n\t} else {\n\t\tconst p = props as WidgetContainerDivProps\n\t\treturn <div className={cn(widgetContainerCva({variant}), className)} {...p} />\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/shared/stat-text.tsx",
    "content": "import {LOADING_DASH} from '@/constants'\n\nimport {widgetTextCva} from './shared'\n\nexport function StatText({title, value, valueSub}: {title?: string; value?: string; valueSub?: string}) {\n\treturn (\n\t\t// tabular-nums to prevent the numbers from jumping around, especially when showing live data\n\t\t<div className='flex flex-col gap-1 tabular-nums sm:gap-2'>\n\t\t\t{title && <div className={widgetTextCva({opacity: 'secondary'})}>{title}</div>}\n\t\t\t<div className='flex min-w-0 items-end gap-1 text-12 leading-none font-semibold -tracking-3 opacity-80 sm:text-24'>\n\t\t\t\t<span className='min-w-0 truncate'>{value ?? LOADING_DASH}</span>\n\t\t\t\t<span className='min-w-0 flex-1 truncate text-13 font-bold opacity-[45%]'>{valueSub}</span>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/shared/tabler-icon.tsx",
    "content": "import {HTMLProps, useEffect, useState} from 'react'\n\nimport {cn} from '@/lib/utils'\n\nfunction sanitizeIconName(input: string) {\n\treturn input.replace(/[^a-z0-9-]/g, '')\n}\n\nconst customIcons = ['system-widget-memory', 'system-widget-storage', 'system-widget-temperature', 'system-widget-cpu']\n\nexport function TablerIcon({iconName, className, ...props}: {iconName: string} & HTMLProps<HTMLDivElement>) {\n\tconst [icon, setIcon] = useState('')\n\n\tuseEffect(() => {\n\t\tconst url = customIcons.includes(iconName)\n\t\t\t? `/assets/${sanitizeIconName(iconName)}.svg`\n\t\t\t: `/generated-tabler-icons/${sanitizeIconName(iconName)}.svg`\n\t\tfetch(url)\n\t\t\t.then((res) => res.text())\n\t\t\t.then((res) => {\n\t\t\t\tif (res.startsWith('<svg')) return setIcon(res)\n\t\t\t\tconsole.error(`Icon: \"${iconName}.svg\" not found`)\n\t\t\t\treturn setIcon('')\n\t\t\t})\n\t}, [iconName])\n\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(className, !icon && 'rounded-5 bg-white/5', icon && 'animate-in duration-300 fade-in')}\n\t\t\tdangerouslySetInnerHTML={{__html: icon}}\n\t\t\t{...props}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/shared/widget-wrapper.tsx",
    "content": "import {ReactNode} from 'react'\n\nimport {cn} from '@/lib/utils'\n\nexport function WidgetWrapper({label, children}: {label: string; children?: ReactNode}) {\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t'flex w-[var(--widget-w)] flex-col items-center justify-between',\n\t\t\t\tlabel && 'h-[var(--widget-labeled-h)]',\n\t\t\t)}\n\t\t>\n\t\t\t{children}\n\t\t\t{label && (\n\t\t\t\t<div className='desktop relative z-0 max-w-full truncate text-center text-13 leading-normal drop-shadow-desktop-label contrast-more:bg-black contrast-more:px-1'>\n\t\t\t\t\t{label}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/text-with-buttons-widget.tsx",
    "content": "import {ReactNode} from 'react'\nimport {take} from 'remeda'\n\nimport {LOADING_DASH} from '@/constants'\nimport type {TextWithButtonsWidgetProps} from '@/modules/widgets/shared/constants'\n\nimport {WidgetContainer} from './shared/shared'\nimport {StatText} from './shared/stat-text'\nimport {TablerIcon} from './shared/tabler-icon'\n\nexport function TextWithButtonsWidget({\n\ttitle,\n\ttext,\n\tsubtext,\n\tbuttons,\n\tonClick,\n}: TextWithButtonsWidgetProps & {\n\tonClick?: (link: string) => void\n}) {\n\treturn (\n\t\t<WidgetContainer className='gap-0 p-2 sm:p-5'>\n\t\t\t<StatText title={title ?? LOADING_DASH} value={text} valueSub={subtext} />\n\t\t\t<div className='flex-1' />\n\t\t\t<div className='flex flex-col gap-1 sm:flex-row'>\n\t\t\t\t{buttons &&\n\t\t\t\t\ttake(buttons, 3).map((button) => (\n\t\t\t\t\t\t// Not using `link` for `key` in case user wants two buttons to link to the same `link` for some reason\n\t\t\t\t\t\t<WidgetButton key={button.text} onClick={() => onClick?.(button.link)}>\n\t\t\t\t\t\t\t{button.icon && (\n\t\t\t\t\t\t\t\t<TablerIcon\n\t\t\t\t\t\t\t\t\ticonName={button.icon}\n\t\t\t\t\t\t\t\t\tclassName='mr-1 h-3 w-3 sm:h-4 sm:w-4 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:sm:h-4 [&>svg]:sm:w-4'\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<span className='truncate py-1 text-xs'>{button.text}</span>\n\t\t\t\t\t\t</WidgetButton>\n\t\t\t\t\t))}\n\t\t\t</div>\n\t\t</WidgetContainer>\n\t)\n}\n\nfunction WidgetButton({onClick, children}: {onClick: () => void; children: ReactNode}) {\n\treturn (\n\t\t<button\n\t\t\tonClick={onClick}\n\t\t\tclassName='flex h-[24px] min-w-0 flex-1 items-center justify-center rounded-5 bg-white/5 px-2.5 text-12 font-medium transition-colors hover:bg-white/10 active:bg-white/5 sm:h-[30px] sm:rounded-full'\n\t\t>\n\t\t\t{children}\n\t\t</button>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/text-with-progress-widget.tsx",
    "content": "import {Progress} from '@/components/ui/progress'\nimport {LOADING_DASH} from '@/constants'\nimport {TextWithProgressWidgetProps} from '@/modules/widgets/shared/constants'\nimport {t} from '@/utils/i18n'\n\nimport {WidgetContainer, widgetTextCva} from './shared/shared'\nimport {StatText} from './shared/stat-text'\n\nexport function TextWithProgressWidget({\n\ttitle,\n\ttext,\n\tsubtext,\n\tprogressLabel,\n\tprogress = 0,\n\tlink,\n\tonClick,\n}: TextWithProgressWidgetProps & {\n\tonClick?: (link?: string) => void\n}) {\n\treturn (\n\t\t<WidgetContainer className='p-2 sm:p-5' onClick={() => onClick?.(link)}>\n\t\t\t<StatText title={title ?? LOADING_DASH} value={text} valueSub={subtext} />\n\t\t\t<div className='flex-1' />\n\t\t\t{/* TODO: use shadcn progress component */}\n\t\t\t{/* Show \"In progress\" if we don't have a progress label and there's some progress. Otherwise, just show a dash. */}\n\t\t\t<div className={widgetTextCva({opacity: 'secondary'})}>\n\t\t\t\t{progressLabel || (progress ? t('widget.progress.in-progress') : LOADING_DASH)}\n\t\t\t</div>\n\t\t\t<Progress value={progress * 100} />\n\t\t</WidgetContainer>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/three-stats-widget.tsx",
    "content": "import {LOADING_DASH} from '@/constants'\nimport {cn} from '@/lib/utils'\nimport type {ThreeStatsItem, ThreeStatsWidget, ThreeStatsWidgetProps} from '@/modules/widgets/shared/constants'\n\nimport {WidgetContainer, widgetTextCva} from './shared/shared'\nimport {TablerIcon} from './shared/tabler-icon'\n\nexport function ThreeStatsWidget({\n\titems,\n\tlink,\n\tonClick,\n}: ThreeStatsWidgetProps & {\n\tonClick?: (link?: string) => void\n}) {\n\treturn (\n\t\t<WidgetContainer\n\t\t\tonClick={() => onClick?.(link)}\n\t\t\tclassName='flex flex-col items-stretch justify-stretch gap-1.5 p-1.5 sm:flex-row sm:gap-2 sm:px-4 sm:py-3'\n\t\t>\n\t\t\t{items?.[0] && <Item icon={items[0].icon} subtext={items[0].subtext} text={items[0].text} />}\n\t\t\t{items?.[1] && <Item icon={items[1].icon} subtext={items[1].subtext} text={items[1].text} />}\n\t\t\t{items?.[2] && <Item icon={items[2].icon} subtext={items[2].subtext} text={items[2].text} />}\n\t\t\t{!items && (\n\t\t\t\t<>\n\t\t\t\t\t<Item icon='' subtext={LOADING_DASH} text={LOADING_DASH} />\n\t\t\t\t\t<Item icon='' subtext={LOADING_DASH} text={LOADING_DASH} />\n\t\t\t\t\t<Item icon='' subtext={LOADING_DASH} text={LOADING_DASH} />\n\t\t\t\t</>\n\t\t\t)}\n\t\t</WidgetContainer>\n\t)\n}\n\nfunction Item(item?: ThreeStatsItem) {\n\treturn (\n\t\t// NOTE: consider reducing rounding if we don't have 3 items\n\t\t<div className='flex min-w-0 flex-1 animate-in items-center overflow-hidden rounded-5 bg-white/5 px-1 duration-300 fade-in max-sm:gap-1 max-sm:px-1 sm:flex-col sm:justify-center sm:rounded-full'>\n\t\t\t{/* `[&>svg]` to select child svg */}\n\t\t\t{item?.icon && <TablerIcon iconName={item?.icon} className='h-5 w-5 sm:mb-4 [&>svg]:h-5 [&>svg]:w-5' />}\n\t\t\t<div className='flex w-full flex-row justify-between sm:flex-col sm:text-center'>\n\t\t\t\t<p className={cn(widgetTextCva({opacity: 'secondary'}), 'max-w-full truncate')}>{item?.subtext}</p>\n\t\t\t\t<p className={cn(widgetTextCva(), 'max-w-full truncate')}>{item?.text}</p>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/widgets/two-stats-with-guage-widget.tsx",
    "content": "import {Arc} from '@/components/ui/arc'\nimport {LOADING_DASH} from '@/constants'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport type {TwoStatsWithProgressItem, TwoStatsWithProgressWidgetProps} from '@/modules/widgets/shared/constants'\n\nimport {WidgetContainer} from './shared/shared'\n\nexport function TwoStatsWidget({\n\titems,\n\tlink,\n\tonClick,\n}: TwoStatsWithProgressWidgetProps & {onClick?: (link?: string) => void}) {\n\treturn (\n\t\t<WidgetContainer onClick={() => onClick?.(link)} className='flex-row items-center justify-center sm:gap-[30px]'>\n\t\t\t{items?.[0] && (\n\t\t\t\t<Item title={items[0].title} text={items[0].text} subtext={items[0].subtext} progress={items[0].progress} />\n\t\t\t)}\n\t\t\t{items?.[1] && (\n\t\t\t\t<Item title={items[1].title} text={items[1].text} subtext={items[1].subtext} progress={items[1].progress} />\n\t\t\t)}\n\t\t\t{!items && (\n\t\t\t\t<>\n\t\t\t\t\t<Item title={LOADING_DASH} text={LOADING_DASH} />\n\t\t\t\t\t<Item title={LOADING_DASH} text={LOADING_DASH} />\n\t\t\t\t</>\n\t\t\t)}\n\t\t</WidgetContainer>\n\t)\n}\n\nfunction Item(item?: TwoStatsWithProgressItem) {\n\tconst isMobile = useIsMobile()\n\tconst size = isMobile ? 65 : 94\n\tconst strokeWidth = isMobile ? 5 : 7\n\n\treturn (\n\t\t<div className='relative'>\n\t\t\t<Arc strokeWidth={strokeWidth} size={size} progress={item?.progress ?? 0} />\n\t\t\t<div\n\t\t\t\tclassName='absolute top-1/2 left-1/2 mt-[1px] -translate-x-1/2 -translate-y-1/2 px-1.5 text-center'\n\t\t\t\t// Set width so text fits inside the arc\n\t\t\t\tstyle={{width: size - strokeWidth * 2}}\n\t\t\t>\n\t\t\t\t<div className='truncate text-[9px] leading-tight font-semibold tracking-normal sm:text-13'>\n\t\t\t\t\t{item?.text}\n\t\t\t\t\t{item?.subtext && <span className='opacity-40'>{item?.subtext}</span>}\n\t\t\t\t</div>\n\t\t\t\t{item?.title && (\n\t\t\t\t\t<div className='truncate text-[9px] leading-tight font-medium -tracking-3 opacity-40 sm:text-12'>\n\t\t\t\t\t\t{item?.title}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/wifi/desktop-wifi-button-connected.tsx",
    "content": "import {Link} from 'react-router-dom'\n\nimport {cn} from '@/lib/utils'\nimport {WifiIcon2} from '@/modules/wifi/icon'\nimport {trpcReact} from '@/trpc/trpc'\nimport {signalToBars} from '@/utils/wifi'\n\nexport function DesktopWifiButtonConnected({className}: {className?: string}) {\n\tconst wifiQ = trpcReact.wifi.connected.useQuery()\n\n\tif (wifiQ.isLoading || wifiQ.data?.status !== 'connected') return null\n\n\treturn (\n\t\t<Link\n\t\t\tclassName={cn(\n\t\t\t\t'animate-in rounded-6 ring-white/20 outline-hidden transition-[background,shadow] fade-in focus-visible:bg-white/6 focus-visible:ring-2 focus-visible:backdrop-blur-xs',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\tto='/settings/wifi'\n\t\t>\n\t\t\t<WifiIcon2 bars={signalToBars(wifiQ.data?.signal ?? 0)} className='size-9' />\n\t\t</Link>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/wifi/icon.tsx",
    "content": "import {SVGProps, useId} from 'react'\nimport {TbWifi, TbWifi0, TbWifi1, TbWifi2, TbWifiOff} from 'react-icons/tb'\n\nimport {cn} from '@/lib/utils'\n\nexport function WifiIcon({bars = 4, className}: {bars: number; className?: string}) {\n\tconst components = [TbWifiOff, TbWifi0, TbWifi1, TbWifi2, TbWifi]\n\n\tconst Comp = components[bars]\n\n\tif (bars === 0) {\n\t\treturn (\n\t\t\t<div className={cn('relative bg-red-500/20', className)}>\n\t\t\t\t<TbWifiOff className={cn('absolute-center opacity-20', className)} />\n\t\t\t</div>\n\t\t)\n\t}\n\n\treturn (\n\t\t<div className={cn('relative bg-red-500/20', className)}>\n\t\t\t<TbWifi className={cn('absolute-center opacity-20', className)} />\n\t\t\t<Comp className={cn('absolute-center', className)} />\n\t\t</div>\n\t)\n}\n\nexport function WifiIcon2({bars = 4, ...props}: {bars: number} & SVGProps<SVGSVGElement>) {\n\tconst uniqueId = useId()\n\n\treturn (\n\t\t<svg xmlns='http://www.w3.org/2000/svg' width={24} height={24} viewBox='0 0 24 24' fill='currentColor' {...props}>\n\t\t\t<defs>\n\t\t\t\t<path\n\t\t\t\t\tid={`${uniqueId}-a`}\n\t\t\t\t\tfillRule='evenodd'\n\t\t\t\t\td='M11.002 17.177a1 1 0 0 1 1-1h.009a1 1 0 1 1 0 2h-.009a1 1 0 0 1-1-1Z'\n\t\t\t\t\tclipRule='evenodd'\n\t\t\t\t/>\n\t\t\t\t<path\n\t\t\t\t\tid={`${uniqueId}-b`}\n\t\t\t\t\tfillRule='evenodd'\n\t\t\t\t\td='M12.002 14.726a2.45 2.45 0 0 0-1.733.718 1 1 0 1 1-1.414-1.415 4.451 4.451 0 0 1 6.294 0 1 1 0 1 1-1.414 1.415 2.45 2.45 0 0 0-1.733-.718Z'\n\t\t\t\t\tclipRule='evenodd'\n\t\t\t\t/>\n\t\t\t\t<path\n\t\t\t\t\tid={`${uniqueId}-c`}\n\t\t\t\t\tfillRule='evenodd'\n\t\t\t\t\td='M12.002 11.274a5.9 5.9 0 0 0-4.174 1.729 1 1 0 1 1-1.414-1.414 7.901 7.901 0 0 1 11.176 0 1 1 0 1 1-1.415 1.414 5.9 5.9 0 0 0-4.173-1.729Z'\n\t\t\t\t\tclipRule='evenodd'\n\t\t\t\t/>\n\t\t\t\t<path\n\t\t\t\t\tid={`${uniqueId}-d`}\n\t\t\t\t\tfillRule='evenodd'\n\t\t\t\t\td='M18.643 10.565c-3.68-3.657-9.603-3.654-13.254-.002a1 1 0 1 1-1.415-1.414c4.435-4.436 11.622-4.432 16.08-.002a1 1 0 1 1-1.41 1.418Z'\n\t\t\t\t\tclipRule='evenodd'\n\t\t\t\t/>\n\t\t\t</defs>\n\t\t\t<use fillRule='evenodd' clipRule='evenodd' href={`#${uniqueId}-a`} className='opacity-20' />\n\t\t\t{bars >= 1 && <use fillRule='evenodd' clipRule='evenodd' href={`#${uniqueId}-a`} />}\n\t\t\t<use fillRule='evenodd' clipRule='evenodd' href={`#${uniqueId}-b`} className='opacity-20' />\n\t\t\t{bars >= 2 && <use fillRule='evenodd' clipRule='evenodd' href={`#${uniqueId}-b`} />}\n\t\t\t<use fillRule='evenodd' clipRule='evenodd' href={`#${uniqueId}-c`} className='opacity-20' />\n\t\t\t{bars >= 3 && <use fillRule='evenodd' clipRule='evenodd' href={`#${uniqueId}-c`} />}\n\t\t\t<use fillRule='evenodd' clipRule='evenodd' href={`#${uniqueId}-d`} className='opacity-20' />\n\t\t\t{bars >= 4 && <use fillRule='evenodd' clipRule='evenodd' href={`#${uniqueId}-d`} />}\n\t\t</svg>\n\t)\n}\n\nexport function WifiIcon2Circled({bars = 4, isConnected}: {bars: number; isConnected?: boolean}) {\n\treturn (\n\t\t<div\n\t\t\tclassName={cn(\n\t\t\t\t'grid size-6 shrink-0 place-items-center rounded-full border border-white/20 bg-white/6 bg-white/10',\n\t\t\t\tisConnected && 'bg-brand',\n\t\t\t)}\n\t\t>\n\t\t\t<WifiIcon2\n\t\t\t\tclassName='size-5'\n\t\t\t\tbars={bars}\n\t\t\t\tstyle={{\n\t\t\t\t\tfilter: 'drop-shadow(0px 0px 2px rgba(255,255,255,.5))',\n\t\t\t\t}}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n\nexport function LockIcon() {\n\treturn (\n\t\t<svg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg' className='shrink-0'>\n\t\t\t<path\n\t\t\t\tfill-rule='evenodd'\n\t\t\t\tclip-rule='evenodd'\n\t\t\t\td='M4.91724 2.38599C5.20441 2.09883 5.59389 1.9375 6 1.9375C6.40611 1.9375 6.79559 2.09883 7.08276 2.38599C7.36992 2.67316 7.53125 3.06264 7.53125 3.46875V4.96875H4.46875V3.46875C4.46875 3.06264 4.63008 2.67316 4.91724 2.38599ZM3.53125 4.96875V3.46875C3.53125 2.814 3.79135 2.18606 4.25433 1.72308C4.71731 1.2601 5.34525 1 6 1C6.65475 1 7.28269 1.2601 7.74567 1.72308C8.20865 2.18606 8.46875 2.814 8.46875 3.46875V4.96875H8.5C9.32843 4.96875 10 5.64032 10 6.46875V9.46875C10 10.2972 9.32843 10.9688 8.5 10.9688H3.5C2.67157 10.9688 2 10.2972 2 9.46875V6.46875C2 5.64032 2.67157 4.96875 3.5 4.96875H3.53125Z'\n\t\t\t\tfill='currentColor'\n\t\t\t/>\n\t\t</svg>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/wifi/wifi-drawer-or-dialog.tsx",
    "content": "import {AnimatePresence, motion} from 'motion/react'\nimport {ReactNode, useEffect, useRef, useState} from 'react'\nimport {TbAlertTriangle} from 'react-icons/tb'\nimport {Drawer as DrawerPrimitive} from 'vaul'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog'\nimport {Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle} from '@/components/ui/drawer'\nimport {PasswordInput} from '@/components/ui/input'\nimport {Loading} from '@/components/ui/loading'\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {Switch} from '@/components/ui/switch'\nimport {useAutoHeightAnimation} from '@/hooks/use-auto-height-animation'\nimport {useIsSmallMobile} from '@/hooks/use-is-mobile'\nimport {cn} from '@/lib/utils'\nimport {WifiListItemContent} from '@/modules/wifi/wifi-item-content'\nimport {RouterOutput, trpcReact, WifiNetwork, WifiStatus, WifiStatusUi} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\ntype NetworkStatus = RouterOutput['wifi']['connected']\n\nexport function WifiDrawerOrDialogContent() {\n\tconst utils = trpcReact.useUtils()\n\tconst statusQ = trpcReact.wifi.connected.useQuery()\n\tconst networkStatus = statusQ.data\n\n\tconst disconnectMut = trpcReact.wifi.disconnect.useMutation({\n\t\tonSettled: () => {\n\t\t\tutils.wifi.connected.invalidate()\n\t\t\tutils.system.getIpAddresses.invalidate()\n\t\t},\n\t})\n\n\tconst networksQ = trpcReact.wifi.networks.useQuery(undefined, {\n\t\t// If we come back to the tab after 2 seconds away, we want to refresh the networks\n\t\tstaleTime: 2000,\n\t})\n\tconst namedNetworks = networksQ.data?.filter((network) => !!network.ssid)\n\n\tconst [controls, ref] = useAutoHeightAnimation([networksQ.isFetching])\n\n\tconst [disableAlertOpen, setDisableAlertOpen] = useState(false)\n\n\treturn (\n\t\t<DrawerOrDialogContent\n\t\t\theader={\n\t\t\t\tstatusQ.data?.status === 'connected' && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\tclassName='mt-0 disabled:pointer-events-none'\n\t\t\t\t\t\t\tchecked={statusQ.data?.status === 'connected'}\n\t\t\t\t\t\t\tonCheckedChange={(toCheck) => !toCheck && setDisableAlertOpen(true)}\n\t\t\t\t\t\t\tdisabled={disconnectMut.isPending || statusQ.isFetching || statusQ.data?.status !== 'connected'}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<AlertDialog open={disableAlertOpen} onOpenChange={setDisableAlertOpen}>\n\t\t\t\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t\t\t\t\t<AlertDialogTitle>{t('wifi-dangerous-disable-confirmation-title')}</AlertDialogTitle>\n\t\t\t\t\t\t\t\t\t<AlertDialogDescription>\n\t\t\t\t\t\t\t\t\t\t{t('wifi-dangerous-disable-confirmation-description')}\n\t\t\t\t\t\t\t\t\t</AlertDialogDescription>\n\t\t\t\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t\t\t\t\t<AlertDialogAction\n\t\t\t\t\t\t\t\t\t\tvariant='destructive'\n\t\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\t\t// Invalidate queries so other parts of the interface can update\n\t\t\t\t\t\t\t\t\t\t\tutils.wifi.connected.invalidate()\n\t\t\t\t\t\t\t\t\t\t\tdisconnectMut.mutate()\n\t\t\t\t\t\t\t\t\t\t\tsetDisableAlertOpen(false)\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{t('disable')}\n\t\t\t\t\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t\t\t\t\t<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t\t\t\t</AlertDialogContent>\n\t\t\t\t\t\t</AlertDialog>\n\t\t\t\t\t</>\n\t\t\t\t)\n\t\t\t}\n\t\t>\n\t\t\t<motion.div\n\t\t\t\tclassName='overflow-hidden'\n\t\t\t\tanimate={controls}\n\t\t\t\ttransition={{\n\t\t\t\t\tduration: 0.3,\n\t\t\t\t\tease: 'easeInOut',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<div ref={ref}>\n\t\t\t\t\t{networksQ.isLoading ? (\n\t\t\t\t\t\t<Message>\n\t\t\t\t\t\t\t<Loading>{t('wifi-searching')}</Loading>\n\t\t\t\t\t\t</Message>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<EnabledContent\n\t\t\t\t\t\t\tnamedNetworks={namedNetworks}\n\t\t\t\t\t\t\tnetworkStatus={networkStatus}\n\t\t\t\t\t\t\tisLoading={networksQ.isFetching}\n\t\t\t\t\t\t\terrorMessage={networksQ.error?.message}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</motion.div>\n\t\t</DrawerOrDialogContent>\n\t)\n}\n\nexport function WifiDrawerOrDialog(props: React.ComponentProps<typeof DrawerPrimitive.Root>) {\n\tconst isMobile = useIsSmallMobile()\n\tconst Wrapper = isMobile ? Drawer : Dialog\n\n\treturn <Wrapper {...props} />\n}\n\nexport function DrawerOrDialogContent({header, children}: {header?: ReactNode; children: ReactNode}) {\n\tconst title = t('wifi')\n\n\tconst isMobile = useIsSmallMobile()\n\n\tconst Content = isMobile ? DrawerContent : DialogContent\n\tconst Header = isMobile ? DrawerHeader : DialogHeader\n\tconst Title = isMobile ? DrawerTitle : DialogTitle\n\tconst Description = isMobile ? DrawerDescription : DialogDescription\n\n\treturn (\n\t\t<Content className='mx-auto px-[20px] py-[30px] sm:max-w-[560px]'>\n\t\t\t<Header className='flex flex-row items-center justify-between gap-4'>\n\t\t\t\t<div className='space-y-0.5'>\n\t\t\t\t\t<Title>{title}</Title>\n\t\t\t\t\t<Description className='text-12 leading-tight'>{t('wifi-description-long')}</Description>\n\t\t\t\t</div>\n\t\t\t\t{header}\n\t\t\t</Header>\n\t\t\t{children}\n\t\t</Content>\n\t)\n}\n\nexport function EnabledContent({\n\tisLoading,\n\terrorMessage,\n\tnamedNetworks,\n\tnetworkStatus,\n}: {\n\tisLoading?: boolean\n\terrorMessage?: string\n\tnamedNetworks?: WifiNetwork[]\n\tnetworkStatus?: NetworkStatus\n}) {\n\tconst [openSsid, setOpenSsid] = useState<string | undefined>(undefined)\n\tconst currentSsid = networkStatus?.status === 'disconnected' ? undefined : networkStatus?.ssid\n\n\t// Scroll to top when connected network changes\n\t// But we don't wanna scroll to the top when a network is added or removed\n\tconst scrollRef = useRef<HTMLDivElement>(null)\n\tuseEffect(() => {\n\t\tif (scrollRef.current) {\n\t\t\tscrollRef.current.scrollTop = 0\n\t\t}\n\t}, [currentSsid])\n\n\tif (errorMessage) {\n\t\treturn <Message>{errorMessage}</Message>\n\t}\n\n\tconst currentStatus = networkStatus?.status\n\tconst connectedNetwork =\n\t\tcurrentStatus === 'connected' ? namedNetworks?.find((network) => network.ssid === currentSsid) : undefined\n\n\t// named networks except the one we're currently connected to\n\tconst availableNetworks = namedNetworks?.filter((network) => network.ssid !== connectedNetwork?.ssid)\n\n\tif (!namedNetworks || namedNetworks.length === 0) {\n\t\treturn <Message>{t('wifi-no-networks-message')}</Message>\n\t}\n\n\treturn (\n\t\t<ScrollArea\n\t\t\tscrollbarClass='my-3'\n\t\t\tclassName={cn('flex h-[380px] flex-col rounded-12 bg-white/5 transition-opacity', isLoading && 'opacity-50')}\n\t\t\tviewportRef={scrollRef}\n\t\t>\n\t\t\t{connectedNetwork && (\n\t\t\t\t<div className={cn(wifiListItemClass, 'pointer-events-none')}>\n\t\t\t\t\t<WifiListItemContent status='connected' network={connectedNetwork} />\n\t\t\t\t</div>\n\t\t\t)}\n\t\t\t{availableNetworks?.map((network) => (\n\t\t\t\t<Network\n\t\t\t\t\tkey={network.ssid}\n\t\t\t\t\tnetwork={network}\n\t\t\t\t\tisWifiActive={currentStatus === 'connected'}\n\t\t\t\t\tisOpen={openSsid === network.ssid}\n\t\t\t\t\tsetIsOpen={(o) => (o ? setOpenSsid(network.ssid) : setOpenSsid(undefined))}\n\t\t\t\t\tstatus={network.ssid === currentSsid ? currentStatus : undefined}\n\t\t\t\t/>\n\t\t\t))}\n\t\t</ScrollArea>\n\t)\n}\n\nfunction Network({\n\tnetwork,\n\tstatus,\n\tisOpen,\n\tsetIsOpen,\n\tisWifiActive,\n}: {\n\tnetwork: WifiNetwork\n\tstatus?: WifiStatus\n\tisOpen: boolean\n\tisWifiActive: boolean\n\tsetIsOpen: (open: boolean) => void\n}) {\n\tconst passwordInputRef = useRef<HTMLInputElement>(null)\n\n\tconst utils = trpcReact.useUtils()\n\tconst connectMut = trpcReact.wifi.connect.useMutation({\n\t\tonMutate: () => {\n\t\t\tutils.wifi.connected.cancel()\n\t\t},\n\t\tonSuccess: () => setIsOpen(false),\n\t\tonError: () => {\n\t\t\tsetTimeout(() => {\n\t\t\t\tpasswordInputRef.current?.select()\n\t\t\t\tpasswordInputRef.current?.focus()\n\t\t\t}, 200)\n\t\t},\n\t\tonSettled: () => {\n\t\t\tutils.wifi.connected.invalidate()\n\t\t\tutils.system.getIpAddresses.invalidate()\n\t\t},\n\t})\n\n\tuseEffect(() => {\n\t\t// Reset error\n\t\tif (!isOpen) {\n\t\t\tconnectMut.reset()\n\t\t}\n\t}, [isOpen, connectMut])\n\n\tconst connect = ({ssid, password}: {ssid: string; password?: string}) => {\n\t\tconnectMut.mutate({ssid, password})\n\t}\n\n\tconst statusUi = connectMut.isPending ? 'loading' : status\n\n\t// Show password error under the password input and other errors in a differnt place\n\tconst errorMessage = connectMut.error?.message\n\tconst isPasswordError = errorMessage === 'Incorrect password'\n\tconst passwordError = isPasswordError ? errorMessage : undefined\n\tconst otherError = isPasswordError ? undefined : errorMessage\n\n\tconst ConnectComponent = isWifiActive ? ConnectWithConfirmation : Connect\n\n\treturn (\n\t\t<motion.div\n\t\t\t// use position layout to avoid stretching\n\t\t\tlayout='position'\n\t\t\tkey={network.ssid}\n\t\t\tclassName={cn(wifiListItemClass, '!gap-0')}\n\t\t\tonClick={() => setIsOpen(true)}\n\t\t\trole={isOpen ? undefined : 'button'}\n\t\t\ttabIndex={0}\n\t\t>\n\t\t\t<WifiListItemContent network={network} status={statusUi} error={otherError} />\n\t\t\t<AnimatePresence>\n\t\t\t\t{isOpen && !connectMut.isPending && (\n\t\t\t\t\t<AnimateHeight>\n\t\t\t\t\t\t<ConnectComponent\n\t\t\t\t\t\t\tpasswordInputRef={passwordInputRef}\n\t\t\t\t\t\t\tnetwork={network}\n\t\t\t\t\t\t\tstatus={statusUi}\n\t\t\t\t\t\t\tonConnect={connect}\n\t\t\t\t\t\t\terror={passwordError}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</AnimateHeight>\n\t\t\t\t)}\n\t\t\t</AnimatePresence>\n\t\t</motion.div>\n\t)\n}\n\nfunction AnimateHeight({children}: {children: ReactNode}) {\n\treturn (\n\t\t<motion.div\n\t\t\tlayout='position'\n\t\t\tinitial={{opacity: 0, height: 0}}\n\t\t\tanimate={{\n\t\t\t\theight: 'auto',\n\t\t\t\topacity: 1,\n\t\t\t\tmarginTop: 8,\n\t\t\t\ttransition: {\n\t\t\t\t\theight: {\n\t\t\t\t\t\tduration: 0.15,\n\t\t\t\t\t},\n\t\t\t\t\topacity: {\n\t\t\t\t\t\tduration: 0.15,\n\t\t\t\t\t\tdelay: 0.15,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}}\n\t\t\texit={{\n\t\t\t\theight: 0,\n\t\t\t\topacity: 0,\n\t\t\t\tmarginTop: 0,\n\t\t\t\ttransition: {\n\t\t\t\t\theight: {\n\t\t\t\t\t\tduration: 0.15,\n\t\t\t\t\t},\n\t\t\t\t\topacity: {\n\t\t\t\t\t\tduration: 0.15,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}}\n\t\t>\n\t\t\t{children}\n\t\t</motion.div>\n\t)\n}\n\ntype ConnectData = {ssid: string; password?: string}\ntype ConnectProps = {\n\tnetwork: WifiNetwork\n\tstatus?: WifiStatusUi\n\tonConnect: ({ssid, password}: ConnectData) => void\n\terror?: string\n\tpasswordInputRef?: React.RefObject<HTMLInputElement | null>\n}\n\nfunction ConnectWithConfirmation({onConnect, ...rest}: ConnectProps) {\n\tconst [data, setData] = useState<ConnectData | undefined>(undefined)\n\tconst [open, setOpen] = useState(false)\n\n\treturn (\n\t\t<>\n\t\t\t<Connect\n\t\t\t\tonConnect={(data) => {\n\t\t\t\t\tsetData(data)\n\t\t\t\t\tsetOpen(true)\n\t\t\t\t}}\n\t\t\t\t{...rest}\n\t\t\t/>\n\t\t\t<AlertDialog open={open} onOpenChange={setOpen}>\n\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t\t<AlertDialogTitle>{t('wifi-dangerous-change-confirmation-title')}</AlertDialogTitle>\n\t\t\t\t\t\t<AlertDialogDescription>{t('wifi-dangerous-change-confirmation-description')}</AlertDialogDescription>\n\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t\t<AlertDialogAction\n\t\t\t\t\t\t\tvariant='destructive'\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tif (!data) return\n\t\t\t\t\t\t\t\tonConnect(data)\n\t\t\t\t\t\t\t\tsetOpen(false)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('change')}\n\t\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t\t<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t</AlertDialogContent>\n\t\t\t</AlertDialog>\n\t\t</>\n\t)\n}\n\nexport function Connect({network, status, onConnect, error, passwordInputRef}: ConnectProps) {\n\tconst [password, setPassword] = useState('')\n\n\tif (status === 'loading') {\n\t\treturn null\n\t}\n\n\tif (network.authenticated) {\n\t\treturn (\n\t\t\t<form\n\t\t\t\tonSubmit={(e) => {\n\t\t\t\t\te.preventDefault()\n\t\t\t\t\tonConnect({ssid: network.ssid, password})\n\t\t\t\t}}\n\t\t\t\tclassName='flex gap-2'\n\t\t\t>\n\t\t\t\t<PasswordInput\n\t\t\t\t\tinputRef={passwordInputRef}\n\t\t\t\t\tautoFocus\n\t\t\t\t\tlabel={t('password')}\n\t\t\t\t\tsizeVariant={'short'}\n\t\t\t\t\tclassName='flex-1'\n\t\t\t\t\tvalue={password}\n\t\t\t\t\tonValueChange={setPassword}\n\t\t\t\t\terror={error}\n\t\t\t\t/>\n\t\t\t\t<Button type='submit' variant='primary' size='input-short'>\n\t\t\t\t\t{t('connect')}\n\t\t\t\t</Button>\n\t\t\t</form>\n\t\t)\n\t} else {\n\t\treturn (\n\t\t\t<form\n\t\t\t\tonSubmit={(e) => {\n\t\t\t\t\te.preventDefault()\n\t\t\t\t\tonConnect({ssid: network.ssid})\n\t\t\t\t}}\n\t\t\t\tclassName='flex items-center gap-2'\n\t\t\t>\n\t\t\t\t<div className='flex flex-1 items-center gap-1 text-sm text-yellow-300'>\n\t\t\t\t\t<TbAlertTriangle className='size-4' />\n\t\t\t\t\t<span>{t('wifi-connect-insecure-message')}</span>\n\t\t\t\t</div>\n\t\t\t\t<Button type='submit' variant='primary' size='input-short' autoFocus>\n\t\t\t\t\tConnect\n\t\t\t\t</Button>\n\t\t\t</form>\n\t\t)\n\t}\n}\n\nexport function Message({children}: {children?: React.ReactNode}) {\n\treturn (\n\t\t<div className='grid h-32 place-items-center rounded-12 bg-white/6 p-4'>\n\t\t\t<div className='text-center text-14 font-medium -tracking-2 opacity-60'>{children}</div>\n\t\t</div>\n\t)\n}\n\nexport const wifiListItemClass = tw`w-full p-3 hover:bg-white/6 focus-within:bg-white/6 transition-colors border-b border-t first:border-t-0 last:border-b-0 mb-[-1px] border-white/6 outline-hidden flex flex-col gap-3`\n"
  },
  {
    "path": "packages/ui/src/modules/wifi/wifi-item-content.tsx",
    "content": "import {TbAlertCircle} from 'react-icons/tb'\nimport {isString} from 'remeda'\n\nimport {Spinner} from '@/components/ui/loading'\nimport {LockIcon, WifiIcon2Circled} from '@/modules/wifi/icon'\nimport {WifiNetwork, WifiStatusUi} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {signalToBars} from '@/utils/wifi'\n\nexport function WifiListItemContent({\n\tnetwork,\n\tstatus,\n\terror,\n}: {\n\tnetwork: WifiNetwork\n\tstatus?: WifiStatusUi\n\terror?: string\n}) {\n\treturn (\n\t\t<div className='flex w-full items-center gap-2.5'>\n\t\t\t<WifiIcon2Circled bars={signalToBars(network.signal)} isConnected={status === 'connected'} />\n\t\t\t<div className='flex flex-1 items-center gap-2 truncate'>\n\t\t\t\t<h3 className='truncate text-15 leading-none font-medium -tracking-2'>{network.ssid}</h3>\n\t\t\t\t{network.authenticated && <LockIcon />}\n\t\t\t</div>\n\t\t\t{status === 'loading' && <Spinner />}\n\t\t\t{error && (\n\t\t\t\t<div className='flex items-center gap-1 text-13 font-medium -tracking-2 text-destructive2-lightest'>\n\t\t\t\t\t<TbAlertCircle className='size-4' />\n\t\t\t\t\t{isString(error) ? error : t('wifi-connection-failed')}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/modules/wifi/wifi-list-row-connected-description.tsx",
    "content": "import {UNKNOWN} from '@/constants'\nimport {WifiNetwork} from '@/trpc/trpc'\nimport {signalToBars} from '@/utils/wifi'\n\nimport {LockIcon, WifiIcon2} from './icon'\n\nexport function WifiListRowConnectedDescription({network}: {network: Partial<WifiNetwork>}) {\n\treturn (\n\t\t// `h-3` prevents height from being different between the `disconnected` and `connected` states\n\t\t<span className='flex h-3 items-center gap-1'>\n\t\t\t<WifiIcon2 bars={signalToBars(network.signal ?? 0)} className='size-4' />\n\t\t\t{network.ssid ?? UNKNOWN()}\n\t\t\t{network.authenticated && <LockIcon />}\n\t\t</span>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/providers/apps.tsx",
    "content": "import {createContext, useContext} from 'react'\nimport {filter} from 'remeda'\n\nimport {trpcReact, UserApp} from '@/trpc/trpc'\nimport {keyBy} from '@/utils/misc'\n\nexport type AppT = {\n\tid: string\n\tname: string\n\ticon: string\n\tsystemApp?: boolean\n\tsystemAppTo?: string\n}\n\n// `UMBREL_` prefix to make extra clear the distinction between system app IDs and user installable ids.\n// In `umbreld`, system app widgets are prefixed with `umbrel:`.\nexport const systemApps = [\n\t{\n\t\tid: 'UMBREL_system',\n\t\tname: 'System',\n\t\ticon: '/assets/umbrel-app.svg',\n\t\tsystemApp: true,\n\t\tsystemAppTo: '/',\n\t},\n\t// For the dock...\n\t{\n\t\tid: 'UMBREL_home',\n\t\tname: 'Home',\n\t\ticon: '/assets/dock/dock-home.png',\n\t\tsystemApp: true,\n\t\tsystemAppTo: '/',\n\t},\n\t{\n\t\tid: 'UMBREL_app-store',\n\t\tname: 'App Store',\n\t\ticon: '/assets/dock/dock-app-store.png',\n\t\tsystemApp: true,\n\t\tsystemAppTo: '/app-store',\n\t},\n\t{\n\t\tid: 'UMBREL_files',\n\t\tname: 'Files',\n\t\ticon: '/assets/dock/dock-files.png',\n\t\tsystemApp: true,\n\t\tsystemAppTo: '/files/Home',\n\t},\n\t{\n\t\tid: 'UMBREL_settings',\n\t\tname: 'Settings',\n\t\ticon: '/assets/dock/dock-settings.png',\n\t\tsystemApp: true,\n\t\tsystemAppTo: '/settings',\n\t},\n\t{\n\t\tid: 'UMBREL_live-usage',\n\t\tname: 'Live Usage',\n\t\ticon: '/assets/dock/dock-live-usage.png',\n\t\tsystemApp: true,\n\t\t// NOTE: using this will clear existing search params\n\t\t// In practice, this means cmdk will clear params and clicking dock icon will not\n\t\tsystemAppTo: '?dialog=live-usage',\n\t},\n\t{\n\t\tid: 'UMBREL_widgets',\n\t\tname: 'Widgets',\n\t\ticon: '/assets/dock/dock-widgets.png',\n\t\tsystemApp: true,\n\t\tsystemAppTo: '/edit-widgets',\n\t},\n] as const satisfies readonly AppT[]\n\nexport const systemAppsKeyed = keyBy(systemApps, 'id')\n\ntype AppsContextT = {\n\tuserApps?: UserApp[]\n\tuserAppsKeyed?: Record<string, UserApp>\n\t// needs to be explicitly readonly so typescript doesn't complain, though all other props are technically readonly too\n\tsystemApps: readonly AppT[]\n\tsystemAppsKeyed: typeof systemAppsKeyed\n\tallApps: AppT[]\n\tallAppsKeyed: Record<string, AppT>\n\tisLoading: boolean\n}\nconst AppsContext = createContext<AppsContextT | null>(null)\n\nexport function AppsProvider({children}: {children: React.ReactNode}) {\n\tconst appsQ = trpcReact.apps.list.useQuery()\n\n\t// Remove apps that have an error\n\t// TODO: consider passing these down in some places (like the desktop)\n\tconst userApps = filter(appsQ.data ?? [], (app): app is UserApp => !('error' in app))\n\tconst userAppsKeyed = keyBy(userApps, 'id')\n\n\tconst allApps = [...userApps, ...systemApps]\n\tconst allAppsKeyed = keyBy(allApps, 'id')\n\n\treturn (\n\t\t<AppsContext\n\t\t\tvalue={{\n\t\t\t\tuserApps,\n\t\t\t\tuserAppsKeyed,\n\t\t\t\tsystemApps,\n\t\t\t\tsystemAppsKeyed,\n\t\t\t\tallApps,\n\t\t\t\tallAppsKeyed,\n\t\t\t\tisLoading: appsQ.isLoading,\n\t\t\t}}\n\t\t>\n\t\t\t{children}\n\t\t</AppsContext>\n\t)\n}\n\nexport function useApps() {\n\tconst ctx = useContext(AppsContext)\n\tif (!ctx) throw new Error('useApps must be used within AppsProvider')\n\n\treturn ctx\n}\n\nexport function useUserApp(id?: string | null) {\n\tconst ctx = useContext(AppsContext)\n\tif (!ctx) throw new Error('useUserApp must be used within AppsProvider')\n\n\tif (!id) return {isLoading: false, app: undefined} as const\n\tif (ctx.isLoading) return {isLoading: true} as const\n\n\treturn {\n\t\tisLoading: false,\n\t\tapp: ctx.userAppsKeyed?.[id],\n\t} as const\n}\n"
  },
  {
    "path": "packages/ui/src/providers/auth-bootstrap.tsx",
    "content": "import {useEffect} from 'react'\n\nimport {JWT_LOCAL_STORAGE_KEY} from '@/modules/auth/shared'\nimport {trpcReact} from '@/trpc/trpc'\n\n// Clear a stale JWT at page load if umbreld reports we're not logged in.\n// Without this, a stale JWT can cause WS auth failures and redirect loops\n// because we have a tRPC split-link that prefers WS when a token exists.\nexport function AuthBootstrap() {\n\tconst isLoggedInQ = trpcReact.user.isLoggedIn.useQuery(undefined)\n\n\tuseEffect(() => {\n\t\t// Wait until the server answers definitively\n\t\tif (!isLoggedInQ.isSuccess) return\n\n\t\t// If the server says we're NOT logged in but a JWT exists locally,\n\t\t// it's stale (e.g., after secret rotation, restore, migration, new install etc).\n\t\tconst isLoggedIn = Boolean(isLoggedInQ.data)\n\t\tconst hasJwt = Boolean(localStorage.getItem(JWT_LOCAL_STORAGE_KEY))\n\n\t\t// If we're already logged in or there is no JWT to clear, we do nothing\n\t\tif (isLoggedIn || !hasJwt) return\n\n\t\t// Clear the stale JWT and hard-navigate to login page so guards and split-link\n\t\t// recompute state without a token.\n\t\tlocalStorage.removeItem(JWT_LOCAL_STORAGE_KEY)\n\t\twindow.location.replace('/login')\n\t}, [isLoggedInQ.isSuccess, isLoggedInQ.data])\n\n\treturn null\n}\n"
  },
  {
    "path": "packages/ui/src/providers/available-apps.tsx",
    "content": "import {createContext, useContext} from 'react'\nimport {groupBy, indexBy, mapValues} from 'remeda'\n\nimport {Category, UMBREL_APP_STORE_ID} from '@/modules/app-store/constants'\nimport {RegistryApp, RouterOutput, trpcReact} from '@/trpc/trpc'\nimport {keyBy} from '@/utils/misc'\n\ntype AppsContextT =\n\t| {\n\t\t\tisLoading: true\n\t  }\n\t| {\n\t\t\tisLoading: false\n\t\t\trepos: RouterOutput['appStore']['registry']\n\t\t\trepoAppsKeyed: Record<string, Record<string, RegistryApp>>\n\t\t\trepoAppsGroupedByCategory: Record<string, Record<Category, RegistryApp[]>>\n\t  }\n\nconst AppsContext = createContext<AppsContextT | null>(null)\n\n// TODO: put all of this in a hook because trpc won't make multiple calls to the same query\nexport function AvailableAppsProvider({children}: {children: React.ReactNode}) {\n\tconst appsQ = trpcReact.appStore.registry.useQuery(undefined, {\n\t\tstaleTime: 10 * 1000 * 60, // 10 minutes\n\t})\n\tconst repos = appsQ.data ?? []\n\n\tif (appsQ.isLoading) return null\n\n\tif (appsQ.isError || !appsQ.data) {\n\t\tthrow new Error('Failed to fetch apps.')\n\t}\n\n\tconst reposKeyed = indexBy(repos, (repo) => repo?.meta.id)\n\tconst repoAppsKeyed = mapValues(reposKeyed, (repo) => keyBy(repo?.apps ?? [], 'id'))\n\tconst repoAppsGroupedByCategory = mapValues(reposKeyed, (repo) => groupBy(repo?.apps ?? [], (app) => app.category))\n\n\tconst providerProps: AppsContextT = appsQ.isLoading\n\t\t? {isLoading: true}\n\t\t: {repos, repoAppsKeyed, repoAppsGroupedByCategory, isLoading: false}\n\n\treturn <AppsContext value={providerProps}>{children}</AppsContext>\n}\n\nexport function useAvailableApps(registryId: string = UMBREL_APP_STORE_ID) {\n\tconst ctx = useContext(AppsContext)\n\tif (!ctx) throw new Error('useAvailableApps must be used within AvailableAppsProvider')\n\n\tif (ctx.isLoading) return {isLoading: true} as const\n\n\tconst appsKeyed = ctx.repoAppsKeyed[registryId]\n\tconst apps = ctx.repos.find((repo) => repo?.meta.id === registryId)?.apps ?? []\n\tconst appsGroupedByCategory = ctx.repoAppsGroupedByCategory[registryId]\n\n\treturn {\n\t\tisLoading: false,\n\t\tapps,\n\t\tappsKeyed,\n\t\tappsGroupedByCategory,\n\t} as const\n}\n\nexport function useAllAvailableApps() {\n\tconst ctx = useContext(AppsContext)\n\tif (!ctx) throw new Error('useAllAvailableApps must be used within AvailableAppsProvider')\n\n\tif (ctx.isLoading) return {isLoading: true} as const\n\n\tconst apps = ctx.repos.flatMap((repo) => repo?.apps ?? [])\n\tconst appsKeyed = keyBy(apps, 'id')\n\n\treturn {\n\t\tisLoading: false,\n\t\tapps,\n\t\tappsKeyed,\n\t} as const\n}\n\n// Allow querying for nullish app to allow the `id` to be dynamic\nexport function useAvailableApp(id?: string | null, registryId: string = UMBREL_APP_STORE_ID) {\n\tconst {appsKeyed, isLoading} = useAvailableApps(registryId)\n\n\tif (!id) return {isLoading: false, app: undefined} as const\n\tif (isLoading) return {isLoading: true} as const\n\tif (!appsKeyed) return {isLoading: false, app: undefined} as const\n\n\treturn {\n\t\tisLoading: false,\n\t\tapp: appsKeyed[id],\n\t} as const\n}\n"
  },
  {
    "path": "packages/ui/src/providers/confirmation/confirmation-context.tsx",
    "content": "import {createContext} from 'react'\n\nimport type {ConfirmationContextType} from '@/providers/confirmation/types'\n\n// Create the context with a default value\nexport const ConfirmationContext = createContext<ConfirmationContextType | undefined>(undefined)\n"
  },
  {
    "path": "packages/ui/src/providers/confirmation/confirmation-provider.tsx",
    "content": "import React, {useCallback, useRef, useState} from 'react'\n\nimport {ConfirmationContext} from '@/providers/confirmation/confirmation-context'\nimport {GenericConfirmationDialog} from '@/providers/confirmation/generic-confirmation-dialog'\nimport type {\n\tConfirmationOptions,\n\tConfirmationResult,\n\tRejectFunction,\n\tResolveFunction,\n} from '@/providers/confirmation/types'\n\ninterface ConfirmationProviderProps {\n\tchildren: React.ReactNode\n}\n\nexport const ConfirmationProvider: React.FC<ConfirmationProviderProps> = ({children}) => {\n\tconst [isOpen, setIsOpen] = useState(false)\n\tconst [options, setOptions] = useState<ConfirmationOptions | null>(null)\n\n\t// Use refs to store resolve/reject functions to avoid context re-renders on every confirmation request\n\tconst resolveRef = useRef<ResolveFunction | null>(null)\n\tconst rejectRef = useRef<RejectFunction | null>(null)\n\n\tconst requestConfirmation = useCallback(\n\t\t(opts: ConfirmationOptions, resolve: ResolveFunction, reject: RejectFunction) => {\n\t\t\tsetOptions(opts)\n\t\t\tresolveRef.current = resolve\n\t\t\trejectRef.current = reject\n\t\t\tsetIsOpen(true)\n\t\t},\n\t\t[],\n\t)\n\n\tconst handleResolve = useCallback((result: ConfirmationResult) => {\n\t\tif (resolveRef.current) {\n\t\t\tresolveRef.current(result)\n\t\t}\n\t\tsetIsOpen(false)\n\t}, [])\n\n\tconst handleReject = useCallback((reason?: any) => {\n\t\tif (rejectRef.current) {\n\t\t\trejectRef.current(reason)\n\t\t}\n\t\tsetIsOpen(false)\n\t}, [])\n\n\tconst contextValue = {\n\t\tisOpen,\n\t\toptions,\n\t\trequestConfirmation,\n\t}\n\n\treturn (\n\t\t<ConfirmationContext value={contextValue}>\n\t\t\t{children}\n\t\t\t<GenericConfirmationDialog isOpen={isOpen} options={options} onResolve={handleResolve} onReject={handleReject} />\n\t\t</ConfirmationContext>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/providers/confirmation/generic-confirmation-dialog.tsx",
    "content": "import React, {useEffect, useId, useState} from 'react'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {Checkbox, checkboxContainerClass, checkboxLabelClass} from '@/components/ui/checkbox'\nimport {cn} from '@/lib/utils'\nimport type {ConfirmationOptions, ConfirmationResult} from '@/providers/confirmation/types'\n\ninterface GenericConfirmationDialogProps {\n\tisOpen: boolean\n\toptions: ConfirmationOptions | null\n\tonResolve: (result: ConfirmationResult) => void\n\tonReject: (reason?: any) => void\n}\n\nexport const GenericConfirmationDialog: React.FC<GenericConfirmationDialogProps> = ({\n\tisOpen,\n\toptions,\n\tonResolve,\n\tonReject,\n}) => {\n\tconst [applyToAllChecked, setApplyToAllChecked] = useState(false)\n\tconst checkboxId = useId()\n\n\t// Reset checkbox state when dialog options change (i.e., a new confirmation opens)\n\tuseEffect(() => {\n\t\tif (isOpen) {\n\t\t\tsetApplyToAllChecked(false)\n\t\t}\n\t}, [isOpen, options])\n\n\tif (!options) {\n\t\t// Render nothing if options are null (e.g., during fade-out animation or initial state)\n\t\treturn null\n\t}\n\n\tconst {title, message, actions, icon: IconComponent, showApplyToAll} = options\n\n\t// If the action represents a user cancellation (with the value \"cancel\"),\n\t// propagate the promise rejection so callers can distinguish cancellation from\n\t// other confirmed actions. Otherwise, resolve with the chosen value.\n\tconst handleActionClick = (value: string | number) => {\n\t\tif (value === 'cancel') {\n\t\t\t// Treat this as an explicit cancellation\n\t\t\tonReject('cancel')\n\t\t\treturn\n\t\t}\n\t\tonResolve({actionValue: value, applyToAll: showApplyToAll ? applyToAllChecked : false})\n\t}\n\n\t// Use onOpenChange for dismissal (clicking outside, pressing Esc)\n\tconst handleOpenChange = (open: boolean) => {\n\t\tif (!open) {\n\t\t\t// Trigger reject only if the dialog was intentionally closed by the user\n\t\t\t// without choosing an action.\n\t\t\tonReject('Dialog dismissed by user')\n\t\t}\n\t}\n\n\treturn (\n\t\t<AlertDialog open={isOpen} onOpenChange={handleOpenChange}>\n\t\t\t<AlertDialogContent>\n\t\t\t\t<AlertDialogHeader icon={IconComponent}>\n\t\t\t\t\t<AlertDialogTitle>{title}</AlertDialogTitle>\n\t\t\t\t\t{message && <AlertDialogDescription>{message}</AlertDialogDescription>}\n\t\t\t\t</AlertDialogHeader>\n\n\t\t\t\t{/* Action Buttons */}\n\t\t\t\t<div className='flex flex-col justify-center gap-y-2 md:flex-row md:gap-x-2 md:gap-y-0'>\n\t\t\t\t\t{actions.map((action, index) => (\n\t\t\t\t\t\t<AlertDialogAction\n\t\t\t\t\t\t\tkey={action.label}\n\t\t\t\t\t\t\tvariant={action.variant || 'default'}\n\t\t\t\t\t\t\tclassName='px-6'\n\t\t\t\t\t\t\tonClick={() => handleActionClick(action.value)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{action.label}\n\t\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\n\t\t\t\t{/* \"Apply to all\" checkbox (only if enabled) */}\n\t\t\t\t{showApplyToAll && (\n\t\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t\t<div className={cn(checkboxContainerClass)}>\n\t\t\t\t\t\t\t<Checkbox\n\t\t\t\t\t\t\t\tid={checkboxId}\n\t\t\t\t\t\t\t\tchecked={applyToAllChecked}\n\t\t\t\t\t\t\t\tonCheckedChange={(checked) => setApplyToAllChecked(!!checked)}\n\t\t\t\t\t\t\t\tclassName='h-4 w-4 rounded-4'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<label htmlFor={checkboxId} className={cn(checkboxLabelClass, 'text-12 text-white/40')}>\n\t\t\t\t\t\t\t\tApply to all\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t)}\n\t\t\t</AlertDialogContent>\n\t\t</AlertDialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/providers/confirmation/index.ts",
    "content": "// TODO: Use this everywhere instead of repetitive dialogs for simple confirmations (eg. restart, logout, download file, etc)\n\nexport * from '@/providers/confirmation/types'\nexport * from '@/providers/confirmation/confirmation-context'\nexport * from '@/providers/confirmation/confirmation-provider'\nexport * from '@/providers/confirmation/use-confirmation'\nexport * from '@/providers/confirmation/generic-confirmation-dialog'\n"
  },
  {
    "path": "packages/ui/src/providers/confirmation/types.ts",
    "content": "import React from 'react'\nimport type {IconType} from 'react-icons'\n\nexport type ConfirmationAction = {\n\tlabel: string // The label of the action\n\tvalue: string | number // The value to resolve the promise with\n\t// Restrict variants to common dialog action types\n\tvariant?: 'primary' | 'destructive' | 'default'\n}\n\nexport type ConfirmationOptions = {\n\ttitle: string // Title of the dialog\n\tmessage: React.ReactNode // Message of the dialog\n\tactions: ConfirmationAction[] // Actions of the dialog\n\ticon?: IconType // IconType to enforce compatibility\n\tshowApplyToAll?: boolean // Whether to show the apply to all checkbox\n}\n\nexport type ConfirmationResult = {\n\tactionValue: string | number // The value from the chosen ConfirmationAction\n\tapplyToAll: boolean\n}\n\n// Type for the promise's resolve function stored in context/provider\nexport type ResolveFunction = (value: ConfirmationResult | PromiseLike<ConfirmationResult>) => void\n// Type for the promise's reject function stored in context/provider\nexport type RejectFunction = (reason?: any) => void\n\n// Shape of the context value\nexport type ConfirmationContextType = {\n\tisOpen: boolean\n\toptions: ConfirmationOptions | null\n\trequestConfirmation: (options: ConfirmationOptions, resolve: ResolveFunction, reject: RejectFunction) => void\n}\n"
  },
  {
    "path": "packages/ui/src/providers/confirmation/use-confirmation.ts",
    "content": "import {useContext} from 'react'\n\nimport {ConfirmationContext} from '@/providers/confirmation/confirmation-context'\nimport type {ConfirmationOptions, ConfirmationResult} from '@/providers/confirmation/types'\n\nexport const useConfirmation = () => {\n\tconst context = useContext(ConfirmationContext)\n\n\tif (!context) {\n\t\tthrow new Error('useConfirmation must be used within a ConfirmationProvider')\n\t}\n\n\tconst confirm = (options: ConfirmationOptions): Promise<ConfirmationResult> => {\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tcontext.requestConfirmation(options, resolve, reject)\n\t\t})\n\t}\n\n\treturn confirm\n}\n"
  },
  {
    "path": "packages/ui/src/providers/global-files.tsx",
    "content": "import React, {createContext, useCallback, useContext, useRef, useState} from 'react'\nimport {FileWithPath} from 'react-dropzone'\nimport {AiOutlineFileExclamation} from 'react-icons/ai'\n\nimport {toast} from '@/components/ui/toast'\nimport type {FileSystemItem} from '@/features/files/types'\nimport {getFilesErrorMessage} from '@/features/files/utils/error-messages'\nimport {splitFileName} from '@/features/files/utils/format-filesystem-name'\nimport {useConfirmation} from '@/providers/confirmation'\nimport type {RouterOutput} from '@/trpc/trpc'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {secondsToEta} from '@/utils/seconds-to-eta'\n\n// Types\ninterface AudioState {\n\tpath: string | null\n\tname: string | null\n}\n\ninterface UploadStats {\n\ttotalProgress: number\n\ttotalSpeed: number\n\ttotalUploaded: number\n\ttotalSize: number\n\teta: string\n}\n\n// Extend FileSystemItem for upload-specific states\ntype UploadStatus = 'uploading' | 'collided' | 'retrying' | 'error' | 'cancelled'\ninterface UploadingFileSystemItem extends FileSystemItem {\n\tstatus: UploadStatus\n\t// progress, speed, etc. are already optional in FileSystemItem\n}\n\n// ---------------- Long-running filesystem operations ----------------\n// Copy and move currently (could be extended to other operations, such as archive and unarchive)\n// Used for the operations floating island, and the rewind restore progress dialog\ntype OperationProgress = RouterOutput['files']['operationProgress'][number]\nexport type OperationsInProgress = OperationProgress[]\n\ninterface GlobalFilesContextValue {\n\t// Audio\n\taudio: AudioState\n\tsetAudio: React.Dispatch<React.SetStateAction<AudioState>>\n\n\t// Uploads\n\tuploadingItems: UploadingFileSystemItem[]\n\tuploadStats: UploadStats\n\tstartUpload: (files: File[] | FileList, destinationPath: string) => void\n\tcancelUpload: (tempId: string) => void\n\n\t// Long-running filesystem operations (copy, move, archive, etc.)\n\toperations: OperationsInProgress\n}\n\n// Create the context\nconst GlobalFilesContext = createContext<GlobalFilesContextValue | null>(null)\n\n// Utility function for upload calculations\nconst calculateUploadStats = (items: UploadingFileSystemItem[]): UploadStats => {\n\tif (items.length === 0) {\n\t\treturn {\n\t\t\ttotalProgress: 0,\n\t\t\ttotalSpeed: 0,\n\t\t\ttotalUploaded: 0,\n\t\t\ttotalSize: 0,\n\t\t\teta: '-',\n\t\t}\n\t}\n\n\tconst {totalSpeed, totalUploaded, totalSize} = items.reduce(\n\t\t(acc, item) => ({\n\t\t\ttotalSpeed: acc.totalSpeed + (item.speed || 0),\n\t\t\t// Only include non-skipped items in progress calculation\n\t\t\ttotalUploaded:\n\t\t\t\tacc.totalUploaded +\n\t\t\t\t(item.status !== 'cancelled' && item.size && item.progress ? (item.size * item.progress) / 100 : 0),\n\t\t\ttotalSize: acc.totalSize + (item.status !== 'cancelled' ? (item.size ?? 0) : 0),\n\t\t}),\n\t\t{totalSpeed: 0, totalUploaded: 0, totalSize: 0},\n\t)\n\n\t// Calculate total progress based on bytes uploaded vs total bytes\n\tconst totalProgress = totalSize > 0 ? (totalUploaded / totalSize) * 100 : 0\n\n\tlet eta = '-'\n\tif (totalSpeed > 0) {\n\t\tconst remaining = totalSize - totalUploaded\n\t\tconst secondsRemaining = Math.round(remaining / totalSpeed)\n\t\teta = secondsToEta(secondsRemaining)\n\t}\n\n\t// Handle case where totalSize becomes 0 due to cancellations\n\tif (totalSize === 0 && items.some((item) => item.status === 'cancelled')) {\n\t\treturn {totalProgress: 100, totalSpeed: 0, totalUploaded: 0, totalSize: 0, eta: '-'}\n\t}\n\n\treturn {\n\t\ttotalProgress,\n\t\ttotalSpeed,\n\t\ttotalUploaded,\n\t\ttotalSize,\n\t\teta,\n\t}\n}\n\n// The Provider\nexport function GlobalFilesProvider({children}: {children: React.ReactNode}) {\n\tconst utils = trpcReact.useUtils()\n\tconst confirm = useConfirmation()\n\n\t// Directory creation mutation\n\tconst createDirectory = trpcReact.files.createDirectory.useMutation({\n\t\tonError: (error) => {\n\t\t\ttoast.error(t('files-error.upload', {message: getFilesErrorMessage(error.message)}))\n\t\t\tthrow error // Re-throw to handle in the calling function\n\t\t},\n\t})\n\n\t// Utility to create all required directories\n\tconst createRequiredDirectories = async (files: FileList | FileWithPath[], destinationPath: string) => {\n\t\tconst fileArray = files instanceof FileList ? Array.from(files) : files\n\t\tconst directories = new Set<string>()\n\n\t\t// Collect all unique directory paths\n\t\tfor (const file of fileArray) {\n\t\t\tconst filePath = ('path' in file ? file.path : file.name) as string\n\t\t\tconst parts = filePath.split('/')\n\n\t\t\t// Remove the file name (last part)\n\t\t\tparts.pop()\n\n\t\t\t// Build directory paths\n\t\t\tlet currentPath = destinationPath\n\t\t\tfor (const part of parts) {\n\t\t\t\tif (part) {\n\t\t\t\t\tcurrentPath = `${currentPath}/${part}`\n\t\t\t\t\tdirectories.add(currentPath)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Sort directories by depth to create parent directories first\n\t\tconst sortedDirs = Array.from(directories).sort((a, b) => {\n\t\t\treturn a.split('/').length - b.split('/').length\n\t\t})\n\n\t\t// Create directories sequentially\n\t\tfor (const dir of sortedDirs) {\n\t\t\t// const name = dir.split('/').pop()! // Unused\n\t\t\t// const parentPath = dir.slice(0, -name.length - 1) // Unused\n\t\t\tawait createDirectory.mutateAsync({path: dir})\n\t\t}\n\t}\n\n\t// -- 1. Audio state\n\tconst [audio, setAudio] = useState<AudioState>({path: null, name: null})\n\n\t// -- 2. Uploads state\n\tconst [uploadingItems, setUploadingItems] = useState<UploadingFileSystemItem[]>([])\n\tconst [uploadStats, setUploadStats] = useState<UploadStats>(calculateUploadStats([]))\n\tconst activeXHRsRef = useRef<Map<string, XMLHttpRequest>>(new Map())\n\n\t// -- 3. Operations-in-progress state (copy and move currently)\n\tconst [operations, setOperations] = useState<OperationsInProgress>([])\n\n\t// Subscribe to \"files:operation-progress\" events that stream progress of copy/move operations\n\ttrpcReact.eventBus.listen.useSubscription(\n\t\t{event: 'files:operation-progress'},\n\t\t{\n\t\t\tonData(data) {\n\t\t\t\t// data is an array of operations currently in progress\n\t\t\t\tsetOperations(data as OperationsInProgress)\n\t\t\t},\n\t\t\tonError(err) {\n\t\t\t\tconsole.error('eventBus.listen(files:operation-progress) subscription error', err)\n\t\t\t},\n\t\t},\n\t)\n\tconst collisionQueueRef = useRef<Map<string, {item: UploadingFileSystemItem; file: File | FileWithPath}>>(new Map())\n\tconst isConfirmationActiveRef = useRef(false) // Prevent multiple simultaneous prompts\n\tconst applyDecisionToAllRef = useRef<'replace' | 'keep-both' | 'skip' | null>(null)\n\tconst batchIdRef = useRef<string | null>(null) // Identify items belonging to the same batch\n\n\tconst MAX_CONCURRENT = 2\n\tconst UPDATE_INTERVAL = 1000\n\n\t// --- Upload Helper Functions (Declare before use in callbacks) ---\n\n\t// Helper to update item state\n\tconst updateItemState = useCallback((tempId: string, updates: Partial<UploadingFileSystemItem>) => {\n\t\tsetUploadingItems((prev) => {\n\t\t\tconst updated = prev.map((item) => (item.tempId === tempId ? {...item, ...updates} : item))\n\t\t\t// Only recalculate stats if something actually changed that affects them\n\t\t\tif (\n\t\t\t\tupdates.progress !== undefined ||\n\t\t\t\tupdates.speed !== undefined ||\n\t\t\t\tupdates.status !== undefined ||\n\t\t\t\tupdates.size !== undefined\n\t\t\t) {\n\t\t\t\tsetUploadStats(calculateUploadStats(updated))\n\t\t\t}\n\t\t\treturn updated\n\t\t})\n\t}, [])\n\n\t// Helper to remove item\n\tconst removeItem = useCallback((tempId: string) => {\n\t\tsetUploadingItems((prev) => {\n\t\t\tconst updated = prev.filter((item) => item.tempId !== tempId)\n\t\t\tsetUploadStats(calculateUploadStats(updated))\n\t\t\treturn updated\n\t\t})\n\t\tactiveXHRsRef.current.delete(tempId)\n\t}, [])\n\n\t// Helper to finalize upload for an item\n\tconst finalizeUpload = useCallback(\n\t\tasync (tempId: string, destinationPath: string) => {\n\t\t\tremoveItem(tempId)\n\t\t\tawait utils.files.list.invalidate({path: destinationPath})\n\t\t},\n\t\t[removeItem, utils.files.list],\n\t)\n\n\t// Function to process the next item in the collision queue\n\t// Need to declare processNextCollision before uploadFile because uploadFile calls it.\n\t// Need to declare uploadFile before processNextCollision because processNextCollision calls it.\n\t// This mutual dependency requires forward declaration.\n\n\tconst uploadFileRef = useRef<typeof uploadFileInternal | undefined>(undefined)\n\tconst processNextCollisionRef = useRef<typeof processNextCollisionInternal | undefined>(undefined)\n\n\t// Define the internal functions\n\tconst uploadFileInternal = useCallback(\n\t\t(\n\t\t\titem: UploadingFileSystemItem,\n\t\t\tfile: File | FileWithPath,\n\t\t\tcollisionStrategy: 'error' | 'replace' | 'keep-both' = 'error',\n\t\t) => {\n\t\t\tconst tempId = item.tempId!\n\t\t\tconst destinationPath = item.path.substring(0, item.path.lastIndexOf('/'))\n\n\t\t\t// Use processNextCollisionRef.current inside\n\t\t\tconst currentProcessNextCollision = processNextCollisionRef.current\n\n\t\t\tupdateItemState(tempId, {\n\t\t\t\tstatus: collisionStrategy === 'error' ? 'uploading' : 'retrying',\n\t\t\t\tspeed: 0,\n\t\t\t\tprogress: collisionStrategy === 'error' ? 0 : undefined,\n\t\t\t})\n\n\t\t\tconst xhr = new XMLHttpRequest()\n\t\t\tactiveXHRsRef.current.set(tempId, xhr)\n\n\t\t\tconst uploadUrl = `/api/files/upload?path=${encodeURIComponent(item.path)}&collision=${collisionStrategy}`\n\t\t\txhr.open('POST', uploadUrl)\n\n\t\t\tlet lastLoaded = 0\n\t\t\tlet lastTime = Date.now()\n\t\t\tlet lastUpdate = Date.now()\n\n\t\t\txhr.upload.onprogress = (e) => {\n\t\t\t\tif (e.lengthComputable) {\n\t\t\t\t\tconst now = Date.now()\n\t\t\t\t\tconst timeSinceLastUpdate = now - lastUpdate\n\n\t\t\t\t\tif (timeSinceLastUpdate >= UPDATE_INTERVAL) {\n\t\t\t\t\t\tconst timeElapsed = now - lastTime\n\t\t\t\t\t\tconst bytesUploaded = e.loaded - lastLoaded\n\t\t\t\t\t\t// Prevent division by zero and ensure speed is non-negative\n\t\t\t\t\t\tconst speed = timeElapsed > 0 ? Math.max(0, (bytesUploaded / timeElapsed) * 1000) : 0\n\t\t\t\t\t\tconst progress = Math.round((e.loaded / e.total) * 100)\n\n\t\t\t\t\t\t// Only update if the item still exists and is uploading/retrying\n\t\t\t\t\t\tsetUploadingItems((prev) => {\n\t\t\t\t\t\t\tconst currentItem = prev.find((u) => u.tempId === tempId)\n\t\t\t\t\t\t\tif (!currentItem || (currentItem.status !== 'uploading' && currentItem.status !== 'retrying')) return prev\n\n\t\t\t\t\t\t\tconst updated = prev.map((u) =>\n\t\t\t\t\t\t\t\tu.tempId === tempId ? {...u, progress, speed, status: currentItem.status} : u,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\tsetUploadStats(calculateUploadStats(updated))\n\t\t\t\t\t\t\treturn updated\n\t\t\t\t\t\t})\n\n\t\t\t\t\t\tlastLoaded = e.loaded\n\t\t\t\t\t\tlastTime = now\n\t\t\t\t\t\tlastUpdate = now\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\txhr.onload = async () => {\n\t\t\t\tactiveXHRsRef.current.delete(tempId)\n\t\t\t\tif (xhr.status >= 200 && xhr.status < 300) {\n\t\t\t\t\t// Success, finalize (remove item from list, invalidate cache)\n\t\t\t\t\tfinalizeUpload(tempId, destinationPath)\n\t\t\t\t} else {\n\t\t\t\t\tconst isCollision = xhr.responseText?.includes('[destination-already-exists]')\n\n\t\t\t\t\tif (isCollision && collisionStrategy === 'error') {\n\t\t\t\t\t\t// Add to collision queue and mark state\n\t\t\t\t\t\tupdateItemState(tempId, {status: 'collided', progress: 0, speed: 0})\n\t\t\t\t\t\tcollisionQueueRef.current.set(tempId, {item: {...item, status: 'collided'}, file}) // Store the file too for retry\n\t\t\t\t\t\t// Use the batchId associated with this upload\n\t\t\t\t\t\tconst currentBatchId = batchIdRef.current\n\t\t\t\t\t\tif (currentBatchId && currentProcessNextCollision) {\n\t\t\t\t\t\t\tcurrentProcessNextCollision(currentBatchId)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\t\t'Collision detected but cannot process queue (missing batchId or processNextCollision ref).',\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// General error or failed retry\n\t\t\t\t\t\tupdateItemState(tempId, {status: 'error', isUploading: false, progress: 0, speed: 0})\n\t\t\t\t\t\ttoast.error(\n\t\t\t\t\t\t\tt('files-error.upload', {\n\t\t\t\t\t\t\t\tmessage: `${item.name}: ${xhr.statusText || t('files-backend-error.upload-failed')}`,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\txhr.onerror = () => {\n\t\t\t\tactiveXHRsRef.current.delete(tempId)\n\t\t\t\t// Network error or similar\n\t\t\t\tupdateItemState(tempId, {status: 'error', isUploading: false, progress: 0, speed: 0})\n\t\t\t\ttoast.error(t('files-error.upload-network-error', {name: item.name}))\n\t\t\t}\n\n\t\t\txhr.onabort = () => {\n\t\t\t\t// Only remove if manually aborted via cancelUpload, not internal retries/queueing\n\t\t\t\tif (!collisionQueueRef.current.has(tempId)) {\n\t\t\t\t\t// Check if it's not waiting for collision resolution\n\t\t\t\t\tactiveXHRsRef.current.delete(tempId)\n\t\t\t\t\tremoveItem(tempId) // Remove from UI on explicit cancel\n\t\t\t\t}\n\t\t\t}\n\n\t\t\txhr.send(file) // Send the raw file object\n\t\t},\n\t\t[updateItemState, finalizeUpload, removeItem],\n\t) // Removed processNextCollision from deps here\n\n\tconst processNextCollisionInternal = useCallback(\n\t\tasync (currentBatchId: string) => {\n\t\t\tif (isConfirmationActiveRef.current || collisionQueueRef.current.size === 0) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Use uploadFileRef.current inside\n\t\t\tconst currentUploadFile = uploadFileRef.current\n\t\t\tif (!currentUploadFile) {\n\t\t\t\treturn // Cannot proceed without uploadFile\n\t\t\t}\n\n\t\t\tconst nextCollisionEntry = Array.from(collisionQueueRef.current.entries())[0]\n\t\t\tconst [tempId, {item, file}] = nextCollisionEntry\n\n\t\t\t// If a batch decision exists, apply it\n\t\t\tif (applyDecisionToAllRef.current) {\n\t\t\t\tcollisionQueueRef.current.delete(tempId)\n\t\t\t\tconst decision = applyDecisionToAllRef.current\n\t\t\t\tif (decision === 'skip') {\n\t\t\t\t\tremoveItem(tempId)\n\t\t\t\t} else {\n\t\t\t\t\t// Retry upload with the chosen strategy\n\t\t\t\t\tcurrentUploadFile(item, file, decision)\n\t\t\t\t}\n\t\t\t\t// Process next immediately if applyToAll was used\n\t\t\t\trequestAnimationFrame(() => processNextCollisionRef.current?.(currentBatchId))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tisConfirmationActiveRef.current = true\n\t\t\tconst fromName = splitFileName(item.name).name\n\t\t\tconst destinationPath = item.path.split('/').slice(0, -1).join('/')\n\t\t\tconst destinationName = destinationPath.split('/').pop() || 'destination' // Extract destination folder name\n\n\t\t\ttry {\n\t\t\t\tconst result = await confirm({\n\t\t\t\t\ttitle: t('files-collision.title', {itemName: fromName, destinationName}),\n\t\t\t\t\tmessage: t('files-collision.message'),\n\t\t\t\t\tactions: [\n\t\t\t\t\t\t{label: t('files-collision.action.keep-both'), value: 'keep-both', variant: 'primary'},\n\t\t\t\t\t\t{label: t('files-collision.action.replace'), value: 'replace', variant: 'default'},\n\t\t\t\t\t\t{label: t('files-collision.action.skip'), value: 'skip', variant: 'default'},\n\t\t\t\t\t],\n\t\t\t\t\tshowApplyToAll: collisionQueueRef.current.size > 1, // Show if more than one item is waiting\n\t\t\t\t\ticon: AiOutlineFileExclamation,\n\t\t\t\t})\n\n\t\t\t\tcollisionQueueRef.current.delete(tempId) // Remove current item before processing decision\n\t\t\t\tisConfirmationActiveRef.current = false\n\n\t\t\t\tconst userDecision = result.actionValue as 'replace' | 'keep-both' | 'skip'\n\n\t\t\t\tif (result.applyToAll) {\n\t\t\t\t\tapplyDecisionToAllRef.current = userDecision\n\t\t\t\t}\n\n\t\t\t\tif (userDecision === 'skip') {\n\t\t\t\t\tremoveItem(tempId)\n\t\t\t\t} else {\n\t\t\t\t\t// Retry upload with the chosen strategy\n\t\t\t\t\tcurrentUploadFile(item, file, userDecision)\n\t\t\t\t}\n\n\t\t\t\t// Trigger processing next collision after a short delay\n\t\t\t\t// Use requestAnimationFrame to ensure state updates have likely propagated\n\t\t\t\t// Pass the current function ref to avoid stale closure issues if needed, though direct call should be fine\n\t\t\t\trequestAnimationFrame(() => processNextCollisionRef.current?.(currentBatchId))\n\t\t\t} catch {\n\t\t\t\t// User dismissed the dialog\n\t\t\t\tisConfirmationActiveRef.current = false\n\t\t\t\tcollisionQueueRef.current.delete(tempId) // Remove current item\n\n\t\t\t\t// Treat dismissal as 'skip' for the current item\n\t\t\t\tremoveItem(tempId)\n\n\t\t\t\t// If Apply to All was potentially checked, set the batch decision to skip\n\t\t\t\tif (collisionQueueRef.current.size > 0) {\n\t\t\t\t\t// Only set if others are waiting\n\t\t\t\t\tapplyDecisionToAllRef.current = 'skip'\n\t\t\t\t\tconst remainingItems = Array.from(collisionQueueRef.current.keys())\n\t\t\t\t\tremainingItems.forEach((id) => {\n\t\t\t\t\t\tremoveItem(id)\n\t\t\t\t\t\tcollisionQueueRef.current.delete(id)\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t[confirm, removeItem],\n\t) // Removed uploadFile and processNextCollision dependencies\n\n\t// Assign the refs after declaration\n\tuploadFileRef.current = uploadFileInternal\n\tprocessNextCollisionRef.current = processNextCollisionInternal\n\n\t// --- End Upload Helper Functions ---\n\n\t// Start one or more uploads\n\tconst startUpload = async (files: FileList | FileWithPath[], destinationPath: string) => {\n\t\tif (!files || (files instanceof FileList && files.length === 0) || (Array.isArray(files) && files.length === 0)) {\n\t\t\treturn\n\t\t}\n\n\t\t// Reset batch-specific state for this new upload operation\n\t\tconst currentBatchId = `batch-${Date.now()}-${Math.random()}`\n\t\tbatchIdRef.current = currentBatchId // Set the current batch ID\n\t\tapplyDecisionToAllRef.current = null // Reset apply-to-all decision\n\t\tcollisionQueueRef.current.clear() // Clear any leftovers from previous batches (shouldn't happen ideally)\n\t\tisConfirmationActiveRef.current = false // Ensure confirmation is not locked\n\n\t\ttry {\n\t\t\tconst fileArray = files instanceof FileList ? Array.from(files) : files\n\n\t\t\t// Add all uploading items at once\n\t\t\tconst newItems: UploadingFileSystemItem[] = fileArray.map((file: File | FileWithPath) => {\n\t\t\t\tconst name = file.name\n\t\t\t\t// Ensure path always starts with / and handles potential double slashes\n\t\t\t\tconst cleanDestinationPath = destinationPath.endsWith('/') ? destinationPath.slice(0, -1) : destinationPath\n\t\t\t\tconst filePathInDest =\n\t\t\t\t\t'path' in file && file.path ? (file.path.startsWith('/') ? file.path : `/${file.path}`) : `/${file.name}`\n\t\t\t\tconst path = `${cleanDestinationPath}${filePathInDest}`\n\n\t\t\t\tconst tempId = `upload-${path}-${Date.now()}` // Add timestamp for potential retries\n\t\t\t\tconst type = file.type || 'file'\n\t\t\t\tconst size = file.size\n\t\t\t\tconst modified = Date.now()\n\t\t\t\tconst thumbnail = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined\n\n\t\t\t\treturn {\n\t\t\t\t\ttempId,\n\t\t\t\t\tname,\n\t\t\t\t\tpath,\n\t\t\t\t\ttype,\n\t\t\t\t\tsize,\n\t\t\t\t\tthumbnail,\n\t\t\t\t\toperations: [],\n\t\t\t\t\tmodified,\n\t\t\t\t\tisUploading: true,\n\t\t\t\t\tstatus: 'uploading',\n\t\t\t\t\tprogress: 0,\n\t\t\t\t\tspeed: 0,\n\t\t\t\t}\n\t\t\t})\n\n\t\t\tsetUploadingItems((prev) => {\n\t\t\t\t// Filter out potential duplicates from previous unfinished batches if any? Or rely on tempId.\n\t\t\t\tconst updated = [...prev, ...newItems]\n\t\t\t\tsetUploadStats(calculateUploadStats(updated))\n\t\t\t\treturn updated\n\t\t\t})\n\n\t\t\t// First create all required directories\n\t\t\t// We do this so that the user can start browsing the directories\n\t\t\t// to see upload progress within them\n\t\t\t// TODO: This is a hack, and won't scale well for a large number of directories\n\t\t\t// or deeply nested directories. We should just let the backend create the directories\n\t\t\t// And as soon as an uploaded file with a directory has been uploaded, we can render the directory\n\t\t\tawait createRequiredDirectories(fileArray, destinationPath)\n\t\t\t// Invalidate the list query, and start the upload\n\t\t\tawait utils.files.list.invalidate({path: destinationPath})\n\n\t\t\t// Process uploads with concurrency control\n\t\t\tlet activeUploadCount = 0\n\t\t\tconst uploadQueue = [...newItems.map((item, index) => ({item, file: fileArray[index]}))] // Queue of {item, file} pairs\n\n\t\t\tconst processQueue = () => {\n\t\t\t\twhile (uploadQueue.length > 0 && activeUploadCount < MAX_CONCURRENT) {\n\t\t\t\t\tactiveUploadCount++\n\t\t\t\t\tconst {item, file} = uploadQueue.shift()!\n\n\t\t\t\t\t// Wrap the uploadFile call in a promise that resolves when the upload finishes (success, error, collision queue)\n\t\t\t\t\t// or is aborted (external cancellation).\n\t\t\t\t\t// We need this to manage concurrency correctly.\n\t\t\t\t\tnew Promise<void>((resolve) => {\n\t\t\t\t\t\t// Use .then/.catch/.finally on the uploadFile call\n\t\t\t\t\t\t// Use the ref to call the function\n\t\t\t\t\t\tPromise.resolve(uploadFileRef.current?.(item, file, 'error'))\n\t\t\t\t\t\t\t.catch((err) => {\n\t\t\t\t\t\t\t\tconsole.error('Error during uploadFile execution:', err)\n\t\t\t\t\t\t\t\t// Ensure state is updated even if uploadFile itself throws unexpectedly\n\t\t\t\t\t\t\t\tupdateItemState(item.tempId!, {status: 'error', progress: 0, speed: 0})\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t.finally(() => {\n\t\t\t\t\t\t\t\tactiveUploadCount--\n\t\t\t\t\t\t\t\tresolve() // Signal completion for concurrency management\n\t\t\t\t\t\t\t\tprocessQueue() // Attempt to process next in queue\n\t\t\t\t\t\t\t})\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tprocessQueue() // Start processing the queue\n\t\t} catch (error: any) {\n\t\t\t// Error during directory creation or initial setup\n\t\t\tsetUploadingItems([]) // Clear queue on setup failure\n\t\t\ttoast.error(t('files-error.upload', {message: getFilesErrorMessage(error.message)}))\n\t\t}\n\t}\n\n\tconst cancelUpload = (tempId: string) => {\n\t\tconst xhr = activeXHRsRef.current.get(tempId)\n\t\tif (xhr) {\n\t\t\txhr.abort() // This triggers the xhr.onabort handler in uploadFile\n\t\t\tactiveXHRsRef.current.delete(tempId)\n\t\t} else {\n\t\t\t// If not actively uploading (e.g., waiting in collision queue), just remove state\n\t\t\tsetUploadingItems((prev) => {\n\t\t\t\tconst updated = prev.filter((item) => item.tempId !== tempId)\n\t\t\t\tsetUploadStats(calculateUploadStats(updated))\n\t\t\t\treturn updated\n\t\t\t})\n\t\t\t// Also remove from collision queue if it's there\n\t\t\tcollisionQueueRef.current.delete(tempId)\n\t\t}\n\t}\n\n\t// Finally, compile the context value:\n\tconst value: GlobalFilesContextValue = {\n\t\t// audio\n\t\taudio,\n\t\tsetAudio,\n\n\t\t// uploads\n\t\tuploadingItems,\n\t\tuploadStats,\n\t\tstartUpload,\n\t\tcancelUpload,\n\n\t\t// operations progress\n\t\toperations,\n\t}\n\n\treturn <GlobalFilesContext value={value}>{children}</GlobalFilesContext>\n}\n\n// A simple custom hook to consume it\nexport function useGlobalFiles() {\n\tconst ctx = useContext(GlobalFilesContext)\n\tif (!ctx) {\n\t\tthrow new Error('useGlobalFiles must be used within <GlobalFilesProvider>')\n\t}\n\treturn ctx\n}\n"
  },
  {
    "path": "packages/ui/src/providers/global-system-state/index.tsx",
    "content": "import {useQueryClient} from '@tanstack/react-query'\nimport {createContext, ReactNode, useContext, useEffect, useState} from 'react'\nimport {JSONTree} from 'react-json-tree'\nimport {usePreviousDistinct} from 'react-use'\n\nimport {BareCoverMessage, CoverMessageParagraph} from '@/components/ui/cover-message'\nimport {DebugOnlyBare} from '@/components/ui/debug-only'\nimport {toast} from '@/components/ui/toast'\nimport {usePrefixedLocalStorage} from '@/hooks/use-prefixed-local-storage'\nimport {useJwt} from '@/modules/auth/use-auth'\nimport {MigratingCover, useMigrate} from '@/providers/global-system-state/migrate'\nimport {RestartingCover, useRestart} from '@/providers/global-system-state/restart'\nimport {ShuttingDownCover, useShutdown} from '@/providers/global-system-state/shutdown'\nimport {RouterError, RouterOutput, trpcReact} from '@/trpc/trpc'\nimport {MS_PER_SECOND} from '@/utils/date-time'\nimport {t} from '@/utils/i18n'\nimport {assertUnreachable, IS_DEV} from '@/utils/misc'\n\nimport {ResettingCover, useReset} from './reset'\nimport {RestoreCover} from './restore'\nimport {UpdatingCover, useUpdate} from './update'\n\ntype SystemStatus = RouterOutput['system']['status']\n\nconst GlobalSystemStateContext = createContext<{\n\tshutdown: () => void\n\trestart: () => void\n\tupdate: () => void\n\tmigrate: () => void\n\treset: (password: string) => void\n\tgetError(): RouterError | null\n\tclearError(): void\n\t// We call this before triggering a custom restart flow (e.g., RAID setup) to prevent error boundary from showing when requests fail.\n\t// Unlike the normal restart flow, this does NOT trigger logout-on-running behavior.\n\tsuppressErrors: () => void\n} | null>(null)\n\nexport function GlobalSystemStateProvider({children}: {children: ReactNode}) {\n\tconst jwt = useJwt()\n\tconst [triggered, setTriggered] = useState(false)\n\tconst [failure, setFailure] = useState(false)\n\tconst [restoreFailure, setRestoreFailure] = useState(false)\n\tconst [shouldLogoutOnRunning, setShouldLogoutOnRunning] = usePrefixedLocalStorage('should-logout-on-running', false)\n\tconst [startShutdownTimer, setStartShutdownTimer] = useState(false)\n\tconst [shutdownComplete, setShutdownComplete] = useState(false)\n\tconst [routerError, setRouterError] = useState<RouterError | null>(null)\n\t// Separate flag for suppressing errors without triggering logout-on-running (e.g., RAID setup)\n\tconst [errorsSuppressedOnly, setErrorsSuppressedOnly] = useState(false)\n\n\t// Start over fresh when any of the supported actions is triggered\n\tconst onMutate = async () => {\n\t\tsetTriggered(true)\n\t\tsetFailure(false)\n\t\tsetRestoreFailure(false)\n\t\tsetShouldLogoutOnRunning(false)\n\t\tsetStartShutdownTimer(false)\n\t\tsetShutdownComplete(false)\n\t\tsetRouterError(null)\n\t}\n\n\t// Intercept router errors so the triggering component can handle them.\n\t// Password errors (UNAUTHORIZED) are shown in the form field.\n\t// System errors (e.g., factory reset failed) are shown as toasts.\n\tconst onError = async (error: RouterError) => {\n\t\tif (error?.data?.code === 'UNAUTHORIZED') {\n\t\t\tsetRouterError(error)\n\t\t} else {\n\t\t\ttoast.error(t('factory-reset-failed', {message: error.message}))\n\t\t}\n\t\tsetTriggered(false)\n\n\t\t// Prevent logout/redirect when error occurs\n\t\tsetShouldLogoutOnRunning(false)\n\t}\n\tconst getError = () => routerError\n\tconst clearError = () => setRouterError(null)\n\t// Allow external code to suppress errors (e.g., RAID setup doing its own restart flow)\n\t// This sets a separate flag so it doesn't trigger logout-on-running behavior\n\tconst suppressErrors = () => setErrorsSuppressedOnly(true)\n\n\tconst queryClient = useQueryClient()\n\tconst utils = trpcReact.useUtils()\n\n\t// When the action completes, remember whether it was a success or a failure\n\t// and potentially clean up left-over state so the failed action can be\n\t// attempted again. We use `failure` below to trigger the error cover.\n\tconst onSuccess = (success: boolean) => {\n\t\tsetFailure(!success)\n\t\tutils.system.status.cancel() // avoid receiving an outdated status\n\t\tif (!success) {\n\t\t\tsetTriggered(false)\n\t\t\tsetShouldLogoutOnRunning(false)\n\t\t\tsetStartShutdownTimer(false)\n\t\t}\n\t}\n\n\t// TODO: handle `onError` for other actions than reset?\n\tconst restart = useRestart({onMutate, onSuccess})\n\tconst shutdown = useShutdown({onMutate, onSuccess})\n\tconst update = useUpdate({onMutate, onSuccess})\n\tconst migrate = useMigrate({onMutate, onSuccess})\n\tconst reset = useReset({onMutate, onError})\n\n\t// Force swift and fresh status updates when an action is in progress.\n\t// We disable retry to get consistent polling during device reboots - we know\n\t// the device is down, we just want to detect when it comes back ASAP rather\n\t// than waiting for exponential backoff between retries.\n\tconst systemStatusQ = trpcReact.system.status.useQuery(undefined, {\n\t\trefetchInterval: triggered ? 500 : 10 * MS_PER_SECOND,\n\t\tgcTime: 0,\n\t\tretry: 0,\n\t})\n\n\tif (!IS_DEV) {\n\t\tif (systemStatusQ.error && !triggered && !errorsSuppressedOnly) {\n\t\t\t// This error should get caught by a parent error boundary component\n\t\t\t// TODO: figure out what to do about network errors\n\t\t\t// TODO: Do we need this production-only case at all?\n\t\t\tthrow systemStatusQ.error\n\t\t}\n\t}\n\n\t// Status is `undefined` upon mount, then updating to the status reported by\n\t// the backend, plus when the system reboots, the first status query to fail\n\t// will report `undefined` again. Handle these cases explicitly below.\n\tconst status = systemStatusQ.data\n\tconst prevStatus: SystemStatus | undefined = usePreviousDistinct(status)\n\n\t// If status moves away from 'running' without onMutate (e.g., restore),\n\t// set `triggered` to enable fast polling and the post-restart redirect.\n\tuseEffect(() => {\n\t\tif (!triggered && status && status !== 'running') {\n\t\t\tsetTriggered(true)\n\t\t}\n\t}, [status, triggered])\n\n\t// When global system state is triggered and status switches to anything but\n\t// 'running', we know that the action is now in progress. So we'll now wait\n\t// until the system becomes 'running' again before logging the user out.\n\t// Here, `undefined` is a valid non-running status in that it indicates that\n\t// the system has stopped responding, so is likely rebooting.\n\tuseEffect(() => {\n\t\tif (status !== 'running' && triggered && !shouldLogoutOnRunning) {\n\t\t\tsetShouldLogoutOnRunning(true)\n\t\t}\n\t}, [setShouldLogoutOnRunning, shouldLogoutOnRunning, status, triggered])\n\n\t// When the system becomes running again after setting shouldLogoutOnRunning\n\t// above, log the user out and redirect them to the follow-up page, in turn\n\t// resetting global system state provider incl. its various state vars.\n\tuseEffect(() => {\n\t\tif (status === 'running' && shouldLogoutOnRunning) {\n\t\t\t// shouldLogoutOnRunning is stored in local storage for when the user\n\t\t\t// manually reloads the page even though they shouldn't. Hence we unset it\n\t\t\t// explicitly here and delay for a moment to be sure that local storage\n\t\t\t// has been updated.\n\t\t\tsetShouldLogoutOnRunning(false)\n\t\t\tsetTimeout(() => {\n\t\t\t\tqueryClient.cancelQueries() // prevent auth errors\n\t\t\t\tjwt.removeJwt()\n\t\t\t\tlocation.href = '/'\n\t\t\t}, 500)\n\t\t\treturn\n\t\t}\n\t}, [status, prevStatus, shouldLogoutOnRunning, jwt, setShouldLogoutOnRunning, queryClient, triggered])\n\n\t// Start shutdown timer when status endpoint starts failing, showing the\n\t// shutdown complete cover after a sensible delay.\n\tuseEffect(() => {\n\t\tif (\n\t\t\tstatus === 'shutting-down' &&\n\t\t\t!startShutdownTimer &&\n\t\t\t(systemStatusQ.isError || systemStatusQ.failureCount > 0)\n\t\t) {\n\t\t\tsetStartShutdownTimer(true)\n\t\t\tsetTimeout(() => setShutdownComplete(true), 30 * MS_PER_SECOND)\n\t\t}\n\t}, [startShutdownTimer, status, systemStatusQ.failureCount, systemStatusQ.isError, triggered])\n\n\t// We poll for restore errors only while the system is 'restoring' (not during other non-running states)\n\t// - After we just transitioned from 'restoring' -> 'running', we do one more fetch to catch an error reported at the boundary\n\t// - If a failure is already latched, keep enabled so the button remains available, but\n\t//   we won't poll (refetchInterval is 0 when not restoring)\n\tconst isRestoring = status === 'restoring'\n\tconst justFinishedRestoring = prevStatus === 'restoring' && status === 'running'\n\tconst shouldPollRestoreError = isRestoring || restoreFailure || (justFinishedRestoring && !restoreFailure)\n\n\tconst restoreErrorQ = trpcReact.backups.restoreStatus.useQuery(undefined, {\n\t\tenabled: shouldPollRestoreError,\n\t\trefetchInterval: isRestoring ? 500 : 0,\n\t\tselect: (d) => !!d?.error,\n\t})\n\n\tuseEffect(() => {\n\t\tif (restoreErrorQ.data) setRestoreFailure(true)\n\t}, [restoreErrorQ.data])\n\n\t// When we come back online, we should continue to show the previous state until we've logged out,\n\t// plus, when the action failed, we should show the failure cover until the user interacts with it.\n\tconst statusToShow =\n\t\t(triggered || failure || restoreFailure) && (!status || status === 'running') ? prevStatus : status\n\n\t// Debug info can be activated by adding the local storage key 'debug' with a value of `true`\n\tconst debugInfo = (\n\t\t<DebugOnlyBare>\n\t\t\t<div className='fixed right-0 bottom-0 origin-bottom-right scale-50' style={{zIndex: 1000}}>\n\t\t\t\t<JSONTree\n\t\t\t\t\tdata={{\n\t\t\t\t\t\tstatus,\n\t\t\t\t\t\tprevStatus,\n\t\t\t\t\t\tstatusToShow,\n\t\t\t\t\t\ttriggered,\n\t\t\t\t\t\tfailure,\n\t\t\t\t\t\trestoreFailure,\n\t\t\t\t\t\tshouldLogoutOnRunning,\n\t\t\t\t\t\tstartShutdownTimer,\n\t\t\t\t\t\tshutdownComplete,\n\t\t\t\t\t\tstatusIsError: systemStatusQ.isError,\n\t\t\t\t\t\tfailureCount: systemStatusQ.failureCount,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</DebugOnlyBare>\n\t)\n\n\t// Covers are shown based on system status; restore behaves like others now\n\n\tif (systemStatusQ.isLoading) {\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<BareCoverMessage delayed>{t('trpc.checking-backend')}</BareCoverMessage>\n\t\t\t\t{debugInfo}\n\t\t\t</>\n\t\t)\n\t}\n\n\tswitch (statusToShow) {\n\t\tcase undefined:\n\t\tcase 'running': {\n\t\t\treturn (\n\t\t\t\t<GlobalSystemStateContext\n\t\t\t\t\tvalue={{shutdown, restart, update, migrate, reset, getError, clearError, suppressErrors}}\n\t\t\t\t>\n\t\t\t\t\t{children}\n\t\t\t\t\t{debugInfo}\n\t\t\t\t</GlobalSystemStateContext>\n\t\t\t)\n\t\t}\n\t\tcase 'restoring': {\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t<RestoreCover />\n\t\t\t\t\t{debugInfo}\n\t\t\t\t</>\n\t\t\t)\n\t\t}\n\t\tcase 'shutting-down': {\n\t\t\tif (shutdownComplete) {\n\t\t\t\treturn (\n\t\t\t\t\t<BareCoverMessage>\n\t\t\t\t\t\t{t('shut-down.complete')}\n\t\t\t\t\t\t<CoverMessageParagraph>{t('shut-down.complete-text')}</CoverMessageParagraph>\n\t\t\t\t\t</BareCoverMessage>\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\treturn (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<ShuttingDownCover />\n\t\t\t\t\t\t{debugInfo}\n\t\t\t\t\t</>\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t\tcase 'restarting': {\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t<RestartingCover />\n\t\t\t\t\t{debugInfo}\n\t\t\t\t</>\n\t\t\t)\n\t\t}\n\t\tcase 'updating': {\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t<UpdatingCover onRetry={update} />\n\t\t\t\t\t{debugInfo}\n\t\t\t\t</>\n\t\t\t)\n\t\t}\n\t\tcase 'migrating': {\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t<MigratingCover onRetry={migrate} />\n\t\t\t\t\t{debugInfo}\n\t\t\t\t</>\n\t\t\t)\n\t\t}\n\t\tcase 'resetting': {\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t<ResettingCover />\n\t\t\t\t\t{debugInfo}\n\t\t\t\t</>\n\t\t\t)\n\t\t}\n\t}\n\tassertUnreachable(statusToShow)\n}\n\nexport function useGlobalSystemState() {\n\tconst ctx = useContext(GlobalSystemStateContext)\n\tif (!ctx) throw new Error('`useGlobalSystemState` must be used within `GlobalSystemStateProvider`')\n\n\treturn ctx\n}\n"
  },
  {
    "path": "packages/ui/src/providers/global-system-state/migrate.tsx",
    "content": "import {BarePage} from '@/layouts/bare/bare-page'\nimport FailedLayout from '@/modules/bare/failed-layout'\nimport {ProgressLayout} from '@/modules/bare/progress-layout'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function useMigrate({onMutate, onSuccess}: {onMutate?: () => void; onSuccess?: (didWork: boolean) => void}) {\n\tconst migrateMut = trpcReact.migration.migrate.useMutation({\n\t\tonMutate,\n\t\tonSuccess,\n\t})\n\n\tconst migrate = () => migrateMut.mutate()\n\n\treturn migrate\n}\n\nexport function MigratingCover({onRetry}: {onRetry: () => void}) {\n\tconst updateStatusQ = trpcReact.migration.migrationStatus.useQuery(undefined, {\n\t\trefetchInterval: 500,\n\t})\n\n\tconst {progress, description, running, error} = updateStatusQ.data ?? {}\n\tconst indeterminate = updateStatusQ.isLoading || !running\n\n\treturn (\n\t\t<BarePage>\n\t\t\t{!error && (\n\t\t\t\t<ProgressLayout\n\t\t\t\t\ttitle={t('migration-assistant')}\n\t\t\t\t\tcallout={t('migrate.callout')}\n\t\t\t\t\tprogress={indeterminate ? undefined : progress}\n\t\t\t\t\tmessage={description}\n\t\t\t\t\tisRunning={!!running}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{error && (\n\t\t\t\t<FailedLayout\n\t\t\t\t\ttitle={t('migrate.failed.title')}\n\t\t\t\t\tdescription={<>Error: {error}</>}\n\t\t\t\t\tbuttonText={t('migrate.failed.retry')}\n\t\t\t\t\tbuttonOnClick={onRetry}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{/* We go straight to rebooting */}\n\t\t\t{/* {progress === 100 && (\n\t\t\t\t<SuccessLayout\n\t\t\t\t\ttitle={t('migrate.success.title')}\n\t\t\t\t\tdescription={t('migrate.success.description')}\n\t\t\t\t\tbuttonText={t('continue-to-log-in')}\n\t\t\t\t/>\n\t\t\t)} */}\n\t\t</BarePage>\n\t)\n}\n\nexport function useSoftwareUpdate({\n\tonMutate,\n\tonSuccess,\n}: {\n\tonMutate?: () => void\n\tonSuccess?: (didWork: boolean) => void\n}) {\n\tconst updateVersionMut = trpcReact.system.update.useMutation({\n\t\tonMutate,\n\t\tonSuccess,\n\t})\n\n\tconst update = () => updateVersionMut.mutate()\n\n\treturn update\n}\n"
  },
  {
    "path": "packages/ui/src/providers/global-system-state/reset.tsx",
    "content": "import {BarePage} from '@/layouts/bare/bare-page'\nimport {ProgressLayout} from '@/modules/bare/progress-layout'\nimport {trpcReact, type RouterError} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function useReset({onMutate, onError}: {onMutate?: () => void; onError?: (err: RouterError) => void}) {\n\tconst resetMut = trpcReact.system.factoryReset.useMutation({\n\t\tonMutate,\n\t\tonError,\n\t})\n\n\tconst reset = (password: string) => resetMut.mutate({password})\n\n\treturn reset\n}\n\n// The device reboots immediately and we delete old state in the background on boot,\n// so there is no progress to show. We just show a loading indicator.\nexport function ResettingCover() {\n\treturn (\n\t\t<BarePage>\n\t\t\t<ProgressLayout\n\t\t\t\ttitle={t('factory-reset.rebooting.title')}\n\t\t\t\tcallout={t('factory-reset.rebooting.message')}\n\t\t\t\tmessage={t('factory-reset.rebooting.status')}\n\t\t\t\tisRunning\n\t\t\t/>\n\t\t</BarePage>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/providers/global-system-state/restart.tsx",
    "content": "import {CoverMessage, CoverMessageParagraph} from '@/components/ui/cover-message'\nimport {Loading} from '@/components/ui/loading'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function useRestart({onMutate, onSuccess}: {onMutate?: () => void; onSuccess?: (didWork: boolean) => void}) {\n\tconst restartMut = trpcReact.system.restart.useMutation({\n\t\tonMutate,\n\t\tonSuccess,\n\t})\n\tconst restart = restartMut.mutate\n\n\treturn restart\n}\n\nexport function RestartingCover() {\n\treturn (\n\t\t<CoverMessage>\n\t\t\t<Loading>{t('restart.restarting')}</Loading>\n\t\t\t<CoverMessageParagraph>{t('restart.restarting-message')}</CoverMessageParagraph>\n\t\t</CoverMessage>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/providers/global-system-state/restore.tsx",
    "content": "import {useRestoreStatus} from '@/features/backups/hooks/use-backups'\nimport {BarePage} from '@/layouts/bare/bare-page'\nimport FailedLayout from '@/modules/bare/failed-layout'\nimport {ProgressLayout} from '@/modules/bare/progress-layout'\nimport {t} from '@/utils/i18n'\nimport {secondsToEta} from '@/utils/seconds-to-eta'\n\nexport function RestoreCover() {\n\tconst restoreQ = useRestoreStatus()\n\tconst {progress, running, error, secondsRemaining} = restoreQ.data ?? {}\n\n\tconst p = typeof progress === 'number' ? progress : 0\n\tconst hasSample = restoreQ.data !== undefined\n\tconst isCompleting = !running && !error && p === 100\n\tconst indeterminate = hasSample && !running && p === 0\n\n\t// Use our own copy for messages; ignore backend-provided description\n\tlet message = isCompleting ? t('backups.restoring-completing') : t('backups.restoring-progress', {percent: p})\n\n\tif (running && secondsRemaining) {\n\t\tmessage += ` • ${t('backups.restoring-time-remaining', {time: secondsToEta(secondsRemaining)})}`\n\t}\n\n\treturn (\n\t\t<BarePage>\n\t\t\t{!error && (\n\t\t\t\t<ProgressLayout\n\t\t\t\t\ttitle={t('backups.restoring')}\n\t\t\t\t\tcallout={t('backups.restoring-warning')}\n\t\t\t\t\tprogress={indeterminate ? undefined : p}\n\t\t\t\t\tmessage={message}\n\t\t\t\t\tisRunning={!!running}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{error && (\n\t\t\t\t// Options to go to Home page or Restore wizard\n\t\t\t\t<FailedLayout\n\t\t\t\t\ttitle={t('backups.restore-failed.title')}\n\t\t\t\t\tdescription={t('backups.restore-failed.message')}\n\t\t\t\t\tbuttonText={t('backups.restore-failed.retry')}\n\t\t\t\t\tbuttonOnClick={() => (document.location.href = '/settings/backups/restore')}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</BarePage>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/providers/global-system-state/shutdown.tsx",
    "content": "import {CoverMessage, CoverMessageParagraph} from '@/components/ui/cover-message'\nimport {Loading} from '@/components/ui/loading'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function useShutdown({onMutate, onSuccess}: {onMutate?: () => void; onSuccess?: (didWork: boolean) => void}) {\n\tconst shutdownMut = trpcReact.system.shutdown.useMutation({\n\t\tonMutate,\n\t\tonSuccess,\n\t})\n\tconst shutdown = shutdownMut.mutate\n\n\treturn shutdown\n}\n\nexport function ShuttingDownCover() {\n\treturn (\n\t\t<CoverMessage>\n\t\t\t<Loading>{t('shut-down.shutting-down')}</Loading>\n\t\t\t<CoverMessageParagraph>{t('shut-down.shutting-down-message')}</CoverMessageParagraph>\n\t\t</CoverMessage>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/providers/global-system-state/update.tsx",
    "content": "import {BarePage} from '@/layouts/bare/bare-page'\nimport FailedLayout from '@/modules/bare/failed-layout'\nimport {ProgressLayout} from '@/modules/bare/progress-layout'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function useUpdate({onMutate, onSuccess}: {onMutate?: () => void; onSuccess?: (didWork: boolean) => void}) {\n\tconst updateVersionMut = trpcReact.system.update.useMutation({\n\t\tonMutate,\n\t\tonSuccess,\n\t})\n\n\tconst update = () => updateVersionMut.mutate()\n\n\treturn update\n}\n\nexport function UpdatingCover({onRetry}: {onRetry: () => void}) {\n\tconst latestVersionQ = trpcReact.system.checkUpdate.useQuery()\n\tconst updateStatusQ = trpcReact.system.updateStatus.useQuery(undefined, {\n\t\trefetchInterval: 500,\n\t})\n\tconst latestVersion = latestVersionQ.data\n\n\tif (!latestVersion) {\n\t\treturn null\n\t}\n\n\tconst {progress, description, running, error} = updateStatusQ.data ?? {}\n\tconst indeterminate = updateStatusQ.isLoading || !running\n\n\treturn (\n\t\t<BarePage>\n\t\t\t{!error && (\n\t\t\t\t<ProgressLayout\n\t\t\t\t\ttitle={t('software-update.updating-to', {name: latestVersion.name})}\n\t\t\t\t\tcallout={t('software-update.callout')}\n\t\t\t\t\tprogress={indeterminate ? undefined : progress}\n\t\t\t\t\tmessage={description}\n\t\t\t\t\tisRunning={!!running}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{error && (\n\t\t\t\t<FailedLayout\n\t\t\t\t\ttitle={t('software-update.failed')}\n\t\t\t\t\tdescription={<>Error: {error}</>}\n\t\t\t\t\tbuttonText={t('software-update.failed.retry')}\n\t\t\t\t\tbuttonOnClick={onRetry}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</BarePage>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/providers/immersive-dialog.tsx",
    "content": "// Tracks when any ImmersiveDialog is open, allowing other components (like floating islands)\n// to adjust their z-index to appear above the dialog.\n//\n// Uses a counter instead of boolean to handle edge cases robustly:\n// - Nested dialogs (e.g., storage manager has sub-dialogs)\n// - React StrictMode double-mounting\n// - Complex component timing issues\n// Only reports closed (isOpen=false) when ALL dialogs have closed.\n\nimport {createContext, ReactNode, useCallback, useContext, useState} from 'react'\n\ninterface ImmersiveDialogContextValue {\n\tisOpen: boolean\n\tincrement: () => void\n\tdecrement: () => void\n}\n\nconst ImmersiveDialogContext = createContext<ImmersiveDialogContextValue | null>(null)\n\nexport function ImmersiveDialogProvider({children}: {children: ReactNode}) {\n\tconst [count, setCount] = useState(0)\n\tconst increment = useCallback(() => setCount((c) => c + 1), [])\n\tconst decrement = useCallback(() => setCount((c) => Math.max(0, c - 1)), [])\n\tconst isOpen = count > 0\n\treturn <ImmersiveDialogContext value={{isOpen, increment, decrement}}>{children}</ImmersiveDialogContext>\n}\n\nexport function useImmersiveDialogOpen() {\n\tconst ctx = useContext(ImmersiveDialogContext)\n\tif (!ctx) throw new Error('useImmersiveDialogOpen must be used within ImmersiveDialogProvider')\n\treturn ctx.isOpen\n}\n\nexport function useImmersiveDialogCounter() {\n\tconst ctx = useContext(ImmersiveDialogContext)\n\tif (!ctx) throw new Error('useImmersiveDialogCounter must be used within ImmersiveDialogProvider')\n\treturn {increment: ctx.increment, decrement: ctx.decrement}\n}\n"
  },
  {
    "path": "packages/ui/src/providers/language.tsx",
    "content": "import i18next from 'i18next'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {trpcReact} from '@/trpc/trpc'\nimport {supportedLanguageCodes} from '@/utils/language'\n\nexport function RemoteLanguageInjector() {\n\tconst languageQ = trpcReact.user.language.useQuery()\n\n\tconst activeLanguage = i18next.language\n\tconst preferredLanguage = languageQ.data\n\n\tif (arrayIncludes(supportedLanguageCodes, preferredLanguage)) {\n\t\t// Reconfigure i18n and reload the page when the preferred language\n\t\t// changed on the backend and now differs from the active language\n\t\tlocalStorage.setItem('i18nextLng', preferredLanguage)\n\t\tif (preferredLanguage !== activeLanguage) {\n\t\t\twindow.location.reload()\n\t\t}\n\t}\n\n\treturn null\n}\n"
  },
  {
    "path": "packages/ui/src/providers/prefetch.tsx",
    "content": "import {useIsFetching, useQueryClient} from '@tanstack/react-query'\nimport {useEffect, useState} from 'react'\n\nimport {trpcReact} from '@/trpc/trpc'\n\nimport {getWallpaperThumbUrl, wallpapers} from './wallpaper'\n\nconst prefetchStableMs = 500\n\nexport function Prefetcher() {\n\tconst utils = trpcReact.useUtils()\n\tconst queryClient = useQueryClient()\n\tconst [triggered, setTriggered] = useState(false)\n\tconst isLoggedInQ = trpcReact.user.isLoggedIn.useQuery()\n\tconst isFetching = useIsFetching()\n\n\t// We want to prefetch all data used by major UI components like settings, so\n\t// that opening the component for the first time doesn't show placeholders or\n\t// inner UI components such as switches with their default option selected.\n\t// We don't need to prefetch everything, though. Most user preferences for\n\t// example, like language and units, are already fetched early otherwise, and\n\t// anything non-distracting can be skipped in favor of a quicker first load.\n\n\tfunction performPrefetch() {\n\t\tconst prefetchQueries = [\n\t\t\t// Settings header\n\t\t\tutils.system.device,\n\t\t\tutils.system.version,\n\t\t\tutils.system.getIpAddresses,\n\t\t\tutils.system.uptime,\n\t\t\tutils.user.get,\n\n\t\t\t// Settings backups\n\t\t\tutils.backups.getRepositories,\n\n\t\t\t// Settings raid (Pro devices — returns empty on non-Pro)\n\t\t\tutils.hardware.raid.getStatus,\n\t\t\tutils.hardware.internalStorage.getDevices,\n\n\t\t\t// Settings sidebar\n\t\t\tutils.system.systemDiskUsage,\n\t\t\tutils.system.systemMemoryUsage,\n\t\t\tutils.system.cpuUsage,\n\t\t\tutils.system.cpuTemperature,\n\n\t\t\t// Settings switches\n\t\t\tutils.wifi.supported,\n\t\t\tutils.wifi.connected,\n\t\t\tutils.user.is2faEnabled,\n\t\t\tutils.apps.getTorEnabled,\n\n\t\t\t// Advanced settings switches\n\t\t\tutils.system.getReleaseChannel,\n\t\t\tutils.system.isExternalDns,\n\n\t\t\t// Files\n\t\t\tutils.files.viewPreferences,\n\t\t\tutils.files.favorites,\n\t\t\tutils.files.shares,\n\n\t\t\t// App Store\n\t\t\tutils.appStore.registry,\n\t\t]\n\n\t\tPromise.allSettled(prefetchQueries.map((q) => q.prefetch()))\n\n\t\t// App Store discover page (external API, not tRPC)\n\t\tqueryClient.prefetchQuery({\n\t\t\tqueryKey: ['app-store', 'discover'],\n\t\t\tqueryFn: () => fetch('https://apps.umbrel.com/api/v2/umbrelos/app-store/discover').then((res) => res.json()),\n\t\t})\n\n\t\tconst prefetchThumbnails = wallpapers.map((wallpaper) => getWallpaperThumbUrl(wallpaper))\n\n\t\tprefetchThumbnails.forEach((url) => {\n\t\t\tconst link = document.createElement('link')\n\t\t\tlink.rel = 'prefetch'\n\t\t\tlink.href = url\n\t\t\tdocument.head.appendChild(link)\n\t\t})\n\t}\n\n\t// We want prefetching to happen exactly once\n\t// - only when the user is logged in\n\t// - when there are no more pending queries\n\t// - when conditions are stable for a while\n\tconst conditionsFulfilled = !triggered && !!isLoggedInQ.data && !isFetching\n\n\tuseEffect(() => {\n\t\tif (!conditionsFulfilled) return\n\n\t\t// Schedule prefetch, anticipating stable conditions. Once triggered, this\n\t\t// effect goes stale because conditionsFulfilled doesn't change anymore.\n\t\tconst timeout = setTimeout(() => {\n\t\t\tsetTriggered(true)\n\t\t\tperformPrefetch()\n\t\t}, prefetchStableMs)\n\n\t\t// If conditions are not stable, cancel and try again\n\t\treturn () => clearTimeout(timeout)\n\t}, [conditionsFulfilled])\n\n\treturn null\n}\n"
  },
  {
    "path": "packages/ui/src/providers/sheet-sticky-header.tsx",
    "content": "// NOTE: in the future, may want to use this for dialogs, but for now only works for sheets\n\nimport {Portal} from '@radix-ui/react-portal'\nimport {ComponentPropsWithoutRef, createContext, useContext, useEffect, useState} from 'react'\n\nimport {cn} from '@/lib/utils'\n\n// In the future, the child that sets the header content will should be responsible for this\nconst SCROLL_THRESHOLD = 110\nexport const SHEET_HEADER_ID = 'sheet-header-root-id'\n\ntype ContextT = {\n\tshowStickyHeader: boolean\n\thasStickyHeader: boolean\n\tsetHasStickyHeader: (has: boolean) => void\n}\n\nconst StickyContext = createContext<ContextT | null>(null)\n\nexport function SheetStickyHeaderProvider({\n\tchildren,\n\tscrollRef,\n}: {\n\tchildren: React.ReactNode\n\tscrollRef: React.RefObject<HTMLDivElement | null>\n}) {\n\tconst [hasStickyHeader, setHasStickyHeader] = useState(false)\n\tconst [showStickyHeader, setShowStickyHeader] = useState(false)\n\n\tuseEffect(() => {\n\t\tconst el = scrollRef.current\n\t\tconst scrollHandler = () => {\n\t\t\tconst scrollTop = scrollRef.current?.scrollTop ?? 0\n\t\t\t// console.log('scroll', scrollTop)\n\t\t\tif (scrollTop > SCROLL_THRESHOLD && hasStickyHeader) {\n\t\t\t\tsetShowStickyHeader(true)\n\t\t\t} else {\n\t\t\t\tsetShowStickyHeader(false)\n\t\t\t}\n\t\t}\n\n\t\tel?.addEventListener('scroll', scrollHandler, {passive: true})\n\n\t\treturn () => el?.removeEventListener('scroll', scrollHandler)\n\t}, [scrollRef, hasStickyHeader])\n\n\treturn <StickyContext value={{showStickyHeader, hasStickyHeader, setHasStickyHeader}}>{children}</StickyContext>\n}\n\nexport function useSheetStickyHeader() {\n\tconst ctx = useContext(StickyContext)\n\tif (!ctx) throw new Error('useSheetStickyHeader must be used within SheetStickyHeaderProvider')\n\n\treturn ctx\n}\n\n// ---\n\nexport function SheetStickyHeader(props: ComponentPropsWithoutRef<'div'>) {\n\tconst {setHasStickyHeader} = useSheetStickyHeader()\n\n\tuseEffect(() => {\n\t\tsetHasStickyHeader(true)\n\t\treturn () => setHasStickyHeader(false)\n\t}, [setHasStickyHeader])\n\n\treturn <Portal container={document.getElementById(SHEET_HEADER_ID)} {...props} />\n}\n\nexport function SheetStickyHeaderTarget() {\n\tconst {showStickyHeader} = useSheetStickyHeader()\n\n\treturn (\n\t\t<div\n\t\t\tid={SHEET_HEADER_ID}\n\t\t\tclassName={cn(\n\t\t\t\t'invisible absolute inset-x-0 top-0 z-50 h-[76px] rounded-t-20 border-b border-white/10 bg-black/50 px-5 backdrop-blur-xl empty:hidden',\n\t\t\t\tshowStickyHeader && 'visible',\n\t\t\t)}\n\t\t\tstyle={{\n\t\t\t\tboxShadow: '2px 2px 2px 0px #FFFFFF0D inset',\n\t\t\t}}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/providers/wallpaper.tsx",
    "content": "import {createContext, ReactNode, useCallback, useContext, useEffect, useLayoutEffect, useState} from 'react'\nimport {usePreviousDistinct} from 'react-use'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {FadeInImg} from '@/components/ui/fade-in-img'\nimport {cn} from '@/lib/utils'\nimport {trpcReact} from '@/trpc/trpc'\nimport {keyBy, preloadImage} from '@/utils/misc'\nimport {tw} from '@/utils/tw'\n\ntype WallpaperBase = {\n\tid: string | undefined\n\turl: string\n\tbrandColorHsl: string\n}\n\nexport const wallpapers = [\n\t{\n\t\tid: '1',\n\t\turl: '/assets/wallpapers/1.jpg',\n\t\tbrandColorHsl: '259 100% 59%',\n\t},\n\t{\n\t\tid: '2',\n\t\turl: '/assets/wallpapers/2.jpg',\n\t\tbrandColorHsl: '6 56% 54%',\n\t},\n\t{\n\t\tid: '3',\n\t\turl: '/assets/wallpapers/3.jpg',\n\t\tbrandColorHsl: '22 88% 40%',\n\t},\n\t{\n\t\tid: '4',\n\t\turl: '/assets/wallpapers/4.jpg',\n\t\tbrandColorHsl: '198 100% 31%',\n\t},\n\t{\n\t\tid: '5',\n\t\turl: '/assets/wallpapers/5.jpg',\n\t\tbrandColorHsl: '202 100% 33%',\n\t},\n\t{\n\t\tid: '6',\n\t\turl: '/assets/wallpapers/6.jpg',\n\t\tbrandColorHsl: '160 100% 27%',\n\t},\n\t{\n\t\tid: '7',\n\t\turl: '/assets/wallpapers/7.jpg',\n\t\tbrandColorHsl: '79 100% 25%',\n\t},\n\t{\n\t\tid: '8',\n\t\turl: '/assets/wallpapers/8.jpg',\n\t\tbrandColorHsl: '185 100% 29%',\n\t},\n\t{\n\t\tid: '9',\n\t\turl: '/assets/wallpapers/9.jpg',\n\t\tbrandColorHsl: '359 64% 62%',\n\t},\n\t{\n\t\tid: '10',\n\t\turl: '/assets/wallpapers/10.jpg',\n\t\tbrandColorHsl: '18 75% 52%',\n\t},\n\t{\n\t\tid: '11',\n\t\turl: '/assets/wallpapers/11.jpg',\n\t\tbrandColorHsl: '185 100% 29%',\n\t},\n\t{\n\t\tid: '12',\n\t\turl: '/assets/wallpapers/12.jpg',\n\t\tbrandColorHsl: '332 84% 47%',\n\t},\n\t{\n\t\tid: '13',\n\t\turl: '/assets/wallpapers/13.jpg',\n\t\tbrandColorHsl: '194 81% 39%',\n\t},\n\t{\n\t\tid: '14',\n\t\turl: '/assets/wallpapers/14.jpg',\n\t\tbrandColorHsl: '328 87% 49%',\n\t},\n\t{\n\t\tid: '15',\n\t\turl: '/assets/wallpapers/15.jpg',\n\t\tbrandColorHsl: '32 100% 36%',\n\t},\n\t{\n\t\tid: '16',\n\t\turl: '/assets/wallpapers/16.jpg',\n\t\tbrandColorHsl: '265 100% 42%',\n\t},\n\t{\n\t\tid: '17',\n\t\turl: '/assets/wallpapers/17.jpg',\n\t\tbrandColorHsl: '184 100% 25%',\n\t},\n\t{\n\t\tid: '18',\n\t\turl: '/assets/wallpapers/18.jpg',\n\t\tbrandColorHsl: '259 100% 59%',\n\t},\n\t{\n\t\tid: '19',\n\t\turl: '/assets/wallpapers/19.jpg',\n\t\tbrandColorHsl: '204 100% 41%',\n\t},\n\t{\n\t\tid: '20',\n\t\turl: '/assets/wallpapers/20.jpg',\n\t\tbrandColorHsl: '259 100% 59%',\n\t},\n\t{\n\t\tid: '21',\n\t\turl: '/assets/wallpapers/21.jpg',\n\t\tbrandColorHsl: '12 78% 50%',\n\t},\n\t{\n\t\tid: '22',\n\t\turl: '/assets/wallpapers/22.jpg',\n\t\tbrandColorHsl: '92 52% 41%',\n\t},\n] as const satisfies readonly WallpaperBase[]\n\nexport function getWallpaperThumbUrl(wallpaper: WallpaperBase) {\n\treturn `/assets/wallpapers/generated-thumbs/${wallpaper.id}.jpg`\n}\n\nexport type Wallpaper = (typeof wallpapers)[number]\nexport type WallpaperId = (typeof wallpapers)[number]['id']\nexport const wallpapersKeyed = keyBy(wallpapers, 'id')\nexport const wallpaperIds = wallpapers.map((w) => w.id)\n\n// ---\n\nconst nullWallpaper = {\n\tid: undefined,\n\turl: '',\n\tbrandColorHsl: '0 0% 50%',\n} as const satisfies WallpaperBase\n\ntype WallpaperType = {\n\twallpaper: Wallpaper | typeof nullWallpaper\n\tisLoading: boolean\n\tprevWallpaper: Wallpaper | undefined\n\tsetWallpaperId: (id: WallpaperId) => void\n\twallpaperFullyVisible: boolean\n\tsetWallpaperFullyVisible: () => void\n}\n\nconst WallPaperContext = createContext<WallpaperType>(null as any)\n\n/*\nScenarios:\n- First load, nothing in localStorage yet\n- Waiting for remote call\n\t* Always show either local or null wallpaper\n- Remote and local are different\n\t* Always \n- Logged out vs. logged in\n\t* When logged out, use the local storage value\n\t* After logged in, use remote value\n*/\n\nexport function WallpaperProviderConnected({children}: {children: ReactNode}) {\n\tconst remote = useRemoteWallpaper()\n\n\tconst remoteWallpaper = remote.wallpaper\n\n\t// We want to avoid showing a wallpaper and then changing it later, unless we already had one cached locally\n\t// since that's most likely going to be the right one. But after the remote call returns, we show the remote\n\t// one if it returns (usually when user is logged in), and otherwise we show either the local one.\n\t// The default one is loaded when nothing is in local storage yet.\n\tconst wallpaper = remote.isLoading ? nullWallpaper : remoteWallpaper || nullWallpaper\n\n\treturn (\n\t\t<WallpaperProvider wallpaper={wallpaper} onWallpaperChange={(w) => remote.setWallpaperId(w.id)}>\n\t\t\t{children}\n\t\t</WallpaperProvider>\n\t)\n}\n\nexport function WallpaperProvider({\n\twallpaper,\n\tonWallpaperChange,\n\tchildren,\n}: {\n\twallpaper: Wallpaper | typeof nullWallpaper\n\tonWallpaperChange: (wallpaper: Wallpaper) => void\n\tchildren: ReactNode\n}) {\n\tconst [isLoading, setIsLoading] = useState(true)\n\tconst [wallpaperFullyVisible, setWallpaperFullyVisible] = useState(false)\n\n\tconst prevId = usePreviousDistinct(wallpaper.id)\n\n\tuseWallpaperCssVars(wallpaper.id)\n\n\tuseLayoutEffect(() => {\n\t\tif (wallpaper.id === prevId) return\n\t\tsetWallpaperFullyVisible(false)\n\t\tsetIsLoading(true)\n\n\t\t// preload image\n\t\tpreloadImage(wallpaper.url).then(() => setIsLoading(false))\n\t}, [wallpaper.url, wallpaper.id, prevId])\n\n\treturn (\n\t\t<WallPaperContext\n\t\t\tvalue={{\n\t\t\t\twallpaper,\n\t\t\t\tisLoading,\n\t\t\t\tprevWallpaper: (prevId && wallpapersKeyed[prevId]) || undefined,\n\t\t\t\tsetWallpaperId: (id: WallpaperId) => {\n\t\t\t\t\tonWallpaperChange(wallpapersKeyed[id])\n\t\t\t\t},\n\t\t\t\twallpaperFullyVisible,\n\t\t\t\tsetWallpaperFullyVisible: () => setWallpaperFullyVisible(true),\n\t\t\t}}\n\t\t>\n\t\t\t{children}\n\t\t</WallPaperContext>\n\t)\n}\n\nexport function useWallpaperCssVars(wallpaperId?: WallpaperId) {\n\tconst {brandColorHsl} = wallpaperId ? wallpapersKeyed[wallpaperId] : nullWallpaper\n\n\tuseLayoutEffect(() => {\n\t\tconst el = document.documentElement\n\t\tel.style.setProperty('--color-brand', brandColorHsl)\n\t\tel.style.setProperty('--color-brand-lighter', brandHslLighter(brandColorHsl))\n\t\tel.style.setProperty('--color-brand-lightest', brandHslLightest(brandColorHsl))\n\t}, [brandColorHsl])\n}\n\n/**\n * Get the wallpaper from the user's settings. However, we want to preserve the wallpaper after logout locally so they see it when they log in again.\n */\nexport const useWallpaper = () => {\n\tconst ctx = useContext(WallPaperContext)\n\tif (!ctx) throw new Error('useWallpaper must be used within WallpaperProvider')\n\treturn ctx\n}\n\nexport function Wallpaper({\n\tclassName,\n\tstayBlurred,\n\tisPreview,\n}: {\n\tclassName?: string\n\tstayBlurred?: boolean\n\tisPreview?: boolean\n}) {\n\tconst {wallpaper, prevWallpaper, isLoading, wallpaperFullyVisible, setWallpaperFullyVisible} = useWallpaper()\n\n\tif (!wallpaper || !wallpaper.id) return null\n\n\treturn (\n\t\t<>\n\t\t\t<FadeInImg\n\t\t\t\tkey={wallpaper.url + '-loading'}\n\t\t\t\tsrc={getWallpaperThumbUrl(wallpaper)}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'pointer-events-none fixed inset-0 w-full scale-125 object-cover object-center blur-[var(--wallpaper-blur)] duration-700',\n\t\t\t\t\tisPreview && 'absolute h-full',\n\t\t\t\t\t!isPreview && 'h-lvh',\n\t\t\t\t\tclassName,\n\t\t\t\t)}\n\t\t\t/>\n\t\t\t{!isLoading && !stayBlurred && (\n\t\t\t\t<FadeInImg\n\t\t\t\t\tkey={wallpaper.url}\n\t\t\t\t\tsrc={wallpaper.url}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t// Using black bg by default because sometimes we want to show the wallpaper before it's loaded, and over other elements\n\t\t\t\t\t\ttw`pointer-events-none fixed inset-0 w-full animate-in bg-black object-cover object-center duration-700 fade-in`,\n\t\t\t\t\t\tisPreview && 'absolute h-full',\n\t\t\t\t\t\t!isPreview && 'h-lvh',\n\t\t\t\t\t\tclassName,\n\t\t\t\t\t)}\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tanimation: !stayBlurred && 'animate-unblur 0.7s',\n\t\t\t\t\t}}\n\t\t\t\t\tonAnimationEnd={setWallpaperFullyVisible}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{/* Put this last so that we can see it exiting over the new wallpaper */}\n\t\t\t{prevWallpaper && !wallpaperFullyVisible && (\n\t\t\t\t<div\n\t\t\t\t\tkey={prevWallpaper.url}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t'pointer-events-none fixed inset-0 animate-out bg-cover bg-center duration-700 fill-mode-both fade-out zoom-out-125',\n\t\t\t\t\t\tisPreview && 'absolute',\n\t\t\t\t\t\tclassName,\n\t\t\t\t\t)}\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tbackgroundImage: `url(${prevWallpaper.url})`,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{/* {isLoading && <div className='fixed left-0 top-0 '>Loading...</div>} */}\n\t\t</>\n\t)\n}\n\nfunction useRemoteWallpaper(onSuccess?: (id: WallpaperId) => void) {\n\t// Refetching causes lots of failed calls to the backend on bare pages before we're logged in.\n\tconst userQ = trpcReact.user.wallpaper.useQuery(undefined, {\n\t\tretry: false,\n\t})\n\tconst wallpaperQId = userQ.data\n\n\t// Handle the onSuccess side effect\n\tuseEffect(() => {\n\t\tif (userQ.isSuccess && wallpaperQId && arrayIncludes(wallpaperIds, wallpaperQId)) {\n\t\t\tonSuccess?.(wallpaperQId)\n\t\t}\n\t}, [userQ.isSuccess, wallpaperQId, onSuccess])\n\n\tconst utils = trpcReact.useUtils()\n\tconst userMut = trpcReact.user.set.useMutation({\n\t\tonSuccess: () => {\n\t\t\tutils.user.get.invalidate()\n\t\t\tutils.user.wallpaper.invalidate()\n\t\t},\n\t})\n\tconst setWallpaperId = useCallback((id: WallpaperId) => userMut.mutate({wallpaper: id}), [userMut])\n\n\treturn {\n\t\tisLoading: userQ.isLoading,\n\t\twallpaper: wallpaperQId && arrayIncludes(wallpaperIds, wallpaperQId) ? wallpapersKeyed[wallpaperQId] : undefined,\n\t\tsetWallpaperId,\n\t}\n}\n\n/**\n * Updates local storage with the wallpaper id from the backend.\n *\n * There's a little dance that needs to happen with wallpapers. When we first load the page, we don't have the TRPC context yet, and we determine the wallpaper from\n * local storage. Usually, this id will be correct. However, if the user changed the wallpaper on another browser,\n * the local storage value will be out of date. So we load the old wallpaper and wait until the TRPC context is available to load the correct one.\n */\nexport function RemoteWallpaperInjector() {\n\tconst remote = useRemoteWallpaper()\n\tconst {wallpaper, setWallpaperId} = useWallpaper()\n\n\tconst localId = wallpaper?.id\n\tconst remoteId = remote.wallpaper?.id\n\n\t// Chance of circular dependency here, so it's important to ensure that the dependencies do not invalidate unless absolutely necessary.\n\tuseEffect(() => {\n\t\tif (remoteId && remoteId !== localId) setWallpaperId(remoteId)\n\t}, [remoteId, localId, setWallpaperId])\n\n\treturn null\n}\n\nexport const LIGHTEN_AMOUNT = 8\nfunction brandHslLighterByAmount(hsl: string, amount: number) {\n\tconst tokens = hsl.split(' ')\n\tconst h = tokens[0]\n\tconst s = parseFloat(tokens[1])\n\tconst l = parseFloat(tokens[2].replace('%', ''))\n\tconst lLighter = l > 100 ? 100 : l + amount\n\treturn `${h} ${s}% ${lLighter}%`\n}\n\nexport function brandHslLighter(hsl: string) {\n\treturn brandHslLighterByAmount(hsl, LIGHTEN_AMOUNT)\n}\nexport function brandHslLightest(hsl: string) {\n\treturn brandHslLighterByAmount(hsl, LIGHTEN_AMOUNT * 2)\n}\n"
  },
  {
    "path": "packages/ui/src/router.tsx",
    "content": "// Route structure:\n// - routes/ contains page-level components mapped to URL paths\n// - modules/ contains reusable UI compositions shared across routes (app-store, desktop, auth, etc.)\n// - features/ may colocate their own route definitions (e.g., files/routes.tsx) when they own an entire route subtree\n\nimport React, {Suspense} from 'react'\nimport {createBrowserRouter, Outlet} from 'react-router-dom'\n\nimport {CmdkMenu, CmdkProvider} from '@/components/cmdk'\nimport {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'\nimport {filesRoutes} from '@/features/files/routes'\nimport {DesktopContextMenu} from '@/modules/desktop/desktop-context-menu'\n\nimport {ErrorBoundaryPageFallback} from './components/ui/error-boundary-page-fallback'\nimport {AppStoreLayout} from './layouts/app-store'\nimport {BareLayout} from './layouts/bare/bare'\nimport {OnboardingLayout} from './layouts/bare/onboarding'\nimport {Desktop} from './layouts/desktop'\nimport {SheetLayout} from './layouts/sheet'\nimport {EnsureLoggedIn, EnsureLoggedOut} from './modules/auth/ensure-logged-in'\nimport {EnsureNoRaidMountFailure} from './modules/auth/ensure-no-raid-mount-failure'\nimport {EnsureProDevice} from './modules/auth/ensure-pro-device'\nimport {EnsureUserDoesntExist, EnsureUserExists} from './modules/auth/ensure-user-exists'\nimport {Dock, DockBottomPositioner} from './modules/desktop/dock'\nimport {FloatingIslandContainer} from './modules/floating-island/container'\nimport {AppsProvider} from './providers/apps'\nimport {AvailableAppsProvider} from './providers/available-apps'\nimport {Wallpaper} from './providers/wallpaper'\nimport {NotFound} from './routes/not-found'\nimport {Notifications} from './routes/notifications'\nimport {Settings} from './routes/settings'\n\nconst AppPage = React.lazy(() => import('./routes/app-store/app-page'))\nconst CategoryPage = React.lazy(() => import('./routes/app-store/category-page'))\nconst Discover = React.lazy(() => import('./routes/app-store/discover'))\nconst CommunityAppStoreHome = React.lazy(() => import('./routes/community-app-store'))\nconst CommunityAppPage = React.lazy(() => import('./routes/community-app-store/app-page'))\nconst EditWidgetsPage = React.lazy(() => import('./routes/edit-widgets'))\nconst Login = React.lazy(() => import('./routes/login'))\nconst OnboardingStart = React.lazy(() => import('./routes/onboarding'))\nconst CreateAccount = React.lazy(() => import('./routes/onboarding/create-account'))\nconst AccountCreated = React.lazy(() => import('./routes/onboarding/account-created'))\nconst Raid = React.lazy(() => import('./routes/onboarding/raid'))\nconst RaidSetup = React.lazy(() => import('./routes/onboarding/raid/setup'))\nconst FactoryReset = React.lazy(() => import('./routes/factory-reset'))\nconst OnboardingRestore = React.lazy(() => import('./routes/onboarding/restore'))\nconst RaidError = React.lazy(() => import('./routes/raid-error'))\n\n// NOTE: consider extracting certain providers into react-router loaders\nexport const router = createBrowserRouter([\n\t// desktop\n\t{\n\t\tpath: '/',\n\t\telement: (\n\t\t\t<EnsureNoRaidMountFailure>\n\t\t\t\t<EnsureLoggedIn>\n\t\t\t\t\t<Wallpaper />\n\t\t\t\t\t{/* Get any notifications from umbreld and render them as alert dialogs */}\n\t\t\t\t\t<Notifications />\n\t\t\t\t\t<AvailableAppsProvider>\n\t\t\t\t\t\t<AppsProvider>\n\t\t\t\t\t\t\t<CmdkProvider>\n\t\t\t\t\t\t\t\t<DesktopContextMenu>\n\t\t\t\t\t\t\t\t\t<Desktop />\n\t\t\t\t\t\t\t\t</DesktopContextMenu>\n\t\t\t\t\t\t\t\t<CmdkMenu />\n\t\t\t\t\t\t\t</CmdkProvider>\n\t\t\t\t\t\t\t<Suspense>\n\t\t\t\t\t\t\t\t<Outlet />\n\t\t\t\t\t\t\t</Suspense>\n\t\t\t\t\t\t\t<FloatingIslandContainer />\n\t\t\t\t\t\t\t<DockBottomPositioner>\n\t\t\t\t\t\t\t\t<Dock />\n\t\t\t\t\t\t\t</DockBottomPositioner>\n\t\t\t\t\t\t</AppsProvider>\n\t\t\t\t\t</AvailableAppsProvider>\n\t\t\t\t</EnsureLoggedIn>\n\t\t\t</EnsureNoRaidMountFailure>\n\t\t),\n\t\tErrorBoundary: ErrorBoundaryPageFallback,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: 'edit-widgets',\n\t\t\t\tComponent: EditWidgetsPage,\n\t\t\t\tErrorBoundary: ErrorBoundaryCardFallback,\n\t\t\t},\n\t\t\t{\n\t\t\t\tComponent: SheetLayout,\n\t\t\t\tchildren: [\n\t\t\t\t\t...filesRoutes,\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: 'app-store',\n\t\t\t\t\t\telement: (\n\t\t\t\t\t\t\t<AvailableAppsProvider>\n\t\t\t\t\t\t\t\t<AppStoreLayout />\n\t\t\t\t\t\t\t</AvailableAppsProvider>\n\t\t\t\t\t\t),\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tindex: true,\n\t\t\t\t\t\t\t\tComponent: Discover,\n\t\t\t\t\t\t\t\tErrorBoundary: ErrorBoundaryCardFallback,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tpath: 'category/:categoryishId',\n\t\t\t\t\t\t\t\tComponent: CategoryPage,\n\t\t\t\t\t\t\t\tErrorBoundary: ErrorBoundaryCardFallback,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: 'app-store/:appId',\n\t\t\t\t\t\telement: (\n\t\t\t\t\t\t\t<AvailableAppsProvider>\n\t\t\t\t\t\t\t\t<AppPage />\n\t\t\t\t\t\t\t</AvailableAppsProvider>\n\t\t\t\t\t\t),\n\t\t\t\t\t\tErrorBoundary: ErrorBoundaryCardFallback,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: 'community-app-store/:appStoreId',\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tindex: true,\n\t\t\t\t\t\t\t\tComponent: CommunityAppStoreHome,\n\t\t\t\t\t\t\t\tErrorBoundary: ErrorBoundaryCardFallback,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tpath: ':appId',\n\t\t\t\t\t\t\t\tComponent: CommunityAppPage,\n\t\t\t\t\t\t\t\tErrorBoundary: ErrorBoundaryCardFallback,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tpath: 'settings/*',\n\t\t\t\t\t\tComponent: Settings,\n\t\t\t\t\t\tErrorBoundary: ErrorBoundaryCardFallback,\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tpath: ':settingsDialog',\n\t\t\t\t\t\t\t\tComponent: Settings,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t},\n\t\t],\n\t},\n\n\t// bare: layout with user's own wallpaper (blurred) and no card\n\t// Used for returning users (login) and system actions (factory reset)\n\t{\n\t\tpath: '/',\n\t\tComponent: BareLayout,\n\t\tErrorBoundary: ErrorBoundaryPageFallback,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tpath: 'login',\n\t\t\t\telement: (\n\t\t\t\t\t<EnsureNoRaidMountFailure>\n\t\t\t\t\t\t<EnsureUserExists>\n\t\t\t\t\t\t\t<EnsureLoggedOut>\n\t\t\t\t\t\t\t\t<Login />\n\t\t\t\t\t\t\t</EnsureLoggedOut>\n\t\t\t\t\t\t</EnsureUserExists>\n\t\t\t\t\t</EnsureNoRaidMountFailure>\n\t\t\t\t),\n\t\t\t},\n\t\t\t{\n\t\t\t\tpath: 'factory-reset/*',\n\t\t\t\telement: <FactoryReset />,\n\t\t\t},\n\t\t],\n\t},\n\n\t// raid-error: shown when RAID mount fails (storage system unavailable)\n\t{\n\t\tpath: '/raid-error',\n\t\telement: <RaidError />,\n\t\tErrorBoundary: ErrorBoundaryPageFallback,\n\t},\n\n\t// onboarding: branded first-time setup experience\n\t// Pro/Home: Video background, Other devices: Static wallpaper\n\t{\n\t\tpath: '/onboarding',\n\t\telement: (\n\t\t\t<EnsureNoRaidMountFailure>\n\t\t\t\t<OnboardingLayout />\n\t\t\t</EnsureNoRaidMountFailure>\n\t\t),\n\t\tErrorBoundary: ErrorBoundaryPageFallback,\n\t\tchildren: [\n\t\t\t{\n\t\t\t\tindex: true,\n\t\t\t\telement: (\n\t\t\t\t\t<EnsureUserDoesntExist>\n\t\t\t\t\t\t<OnboardingStart />\n\t\t\t\t\t</EnsureUserDoesntExist>\n\t\t\t\t),\n\t\t\t},\n\t\t\t{\n\t\t\t\tpath: 'restore',\n\t\t\t\telement: (\n\t\t\t\t\t<EnsureUserDoesntExist>\n\t\t\t\t\t\t<OnboardingRestore />\n\t\t\t\t\t</EnsureUserDoesntExist>\n\t\t\t\t),\n\t\t\t},\n\t\t\t{\n\t\t\t\tpath: 'create-account',\n\t\t\t\telement: (\n\t\t\t\t\t<EnsureUserDoesntExist>\n\t\t\t\t\t\t<CreateAccount />\n\t\t\t\t\t</EnsureUserDoesntExist>\n\t\t\t\t),\n\t\t\t},\n\t\t\t// RAID setup flow (Pro only)\n\t\t\t// TODO: will require changes once RAID is available on custom amd64 devices.\n\t\t\t{\n\t\t\t\tpath: 'raid',\n\t\t\t\telement: (\n\t\t\t\t\t<EnsureUserDoesntExist>\n\t\t\t\t\t\t<EnsureProDevice>\n\t\t\t\t\t\t\t<Raid />\n\t\t\t\t\t\t</EnsureProDevice>\n\t\t\t\t\t</EnsureUserDoesntExist>\n\t\t\t\t),\n\t\t\t},\n\t\t\t{\n\t\t\t\t// IMPORTANT: No EnsureUserDoesntExist guard here.\n\t\t\t\t// Unlike other onboarding routes, RAID setup spans a device reboot:\n\t\t\t\t// 1. User fills form (user doesn't exist yet)\n\t\t\t\t// 2. Backend sets up ZFS pool and reboots\n\t\t\t\t// 3. After reboot, backend creates user from saved credentials\n\t\t\t\t// 4. Frontend polls for user.exists, then shows success page\n\t\t\t\t// If we used EnsureUserDoesntExist, step 4 would redirect to /login\n\t\t\t\t// before showing the success page. The component protects itself by\n\t\t\t\t// checking for credentials in React Router's location.state and redirecting if missing.\n\t\t\t\tpath: 'raid/setup',\n\t\t\t\telement: (\n\t\t\t\t\t<EnsureProDevice>\n\t\t\t\t\t\t<RaidSetup />\n\t\t\t\t\t</EnsureProDevice>\n\t\t\t\t),\n\t\t\t},\n\t\t\t{\n\t\t\t\tpath: 'account-created',\n\t\t\t\telement: (\n\t\t\t\t\t<EnsureLoggedIn>\n\t\t\t\t\t\t<AccountCreated />\n\t\t\t\t\t</EnsureLoggedIn>\n\t\t\t\t),\n\t\t\t},\n\t\t],\n\t},\n\t{\n\t\tpath: '*',\n\t\tComponent: NotFound,\n\t},\n])\n"
  },
  {
    "path": "packages/ui/src/routes/README.md",
    "content": "Non-route files and folder should be prefixed with an underscore. This is to prevent confusion.\n"
  },
  {
    "path": "packages/ui/src/routes/app-store/app-page/index.tsx",
    "content": "import {useMemo, useRef} from 'react'\nimport {ErrorBoundary} from 'react-error-boundary'\nimport {useNavigate, useParams} from 'react-router-dom'\n\nimport {InstallButton} from '@/components/install-button'\nimport {InstallButtonConnected} from '@/components/install-button-connected'\nimport {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'\nimport {Loading} from '@/components/ui/loading'\nimport {AppContent} from '@/modules/app-store/app-page/app-content'\nimport {getRecommendationsFor} from '@/modules/app-store/app-page/get-recommendations'\nimport {appPageWrapperClass} from '@/modules/app-store/app-page/shared'\nimport {TopHeader} from '@/modules/app-store/app-page/top-header'\nimport {useApps} from '@/providers/apps'\nimport {useAvailableApp, useAvailableApps} from '@/providers/available-apps'\nimport {useLinkToDialog} from '@/utils/dialog'\n\nexport default function AppPage() {\n\tconst {appId} = useParams()\n\tconst {app, isLoading} = useAvailableApp(appId)\n\tconst linkToDialog = useLinkToDialog()\n\tconst navigate = useNavigate()\n\n\tconst {apps, isLoading: isLoadingApps} = useAvailableApps()\n\tconst {userAppsKeyed, isLoading: isLoadingUserApps} = useApps()\n\n\tconst installButtonRef = useRef<{triggerInstall: (highlightDependency?: string) => void}>(null)\n\n\tconst recommendedApps = useMemo(() => {\n\t\tif (!apps || !app) return []\n\t\treturn getRecommendationsFor(apps, app.id)\n\t}, [apps, app])\n\n\tif (isLoading || isLoadingApps || isLoadingUserApps) return <Loading />\n\n\tif (!app) throw new Error('App not found')\n\n\tconst userApp = userAppsKeyed?.[app.id]\n\n\tconst showDependencies = (dependencyId?: string) => {\n\t\tif (!app) return\n\t\tconst userApp = userAppsKeyed?.[app.id]\n\t\tif (userApp) {\n\t\t\t// Show app settings dialog when app is installed\n\t\t\tconst params = {for: app.id} as Record<string, string>\n\t\t\tif (dependencyId) params.dependency = dependencyId\n\t\t\tnavigate(linkToDialog('app-settings', params))\n\t\t} else if (installButtonRef.current) {\n\t\t\t// Otherwise show app install dialog\n\t\t\tinstallButtonRef.current.triggerInstall(dependencyId)\n\t\t}\n\t}\n\n\tif (isLoading || isLoadingApps || isLoadingUserApps) return <Loading />\n\tif (!app) throw new Error('App not found')\n\n\treturn (\n\t\t<div className={appPageWrapperClass}>\n\t\t\t<TopHeader\n\t\t\t\tapp={app}\n\t\t\t\tchildrenRight={\n\t\t\t\t\t<div className='flex items-center gap-5'>\n\t\t\t\t\t\t<ErrorBoundary\n\t\t\t\t\t\t\tfallback={\n\t\t\t\t\t\t\t\t<div className='pointer-events-none opacity-50'>\n\t\t\t\t\t\t\t\t\t<InstallButton state='not-installed' />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<InstallButtonConnected ref={installButtonRef} app={app} />\n\t\t\t\t\t\t</ErrorBoundary>\n\t\t\t\t\t</div>\n\t\t\t\t}\n\t\t\t/>\n\t\t\t<ErrorBoundary FallbackComponent={ErrorBoundaryCardFallback}>\n\t\t\t\t<AppContent app={app} userApp={userApp} recommendedApps={recommendedApps} showDependencies={showDependencies} />\n\t\t\t</ErrorBoundary>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/app-store/category-page.tsx",
    "content": "import {ErrorBoundary} from 'react-error-boundary'\nimport {Navigate, useParams} from 'react-router-dom'\n\nimport {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'\nimport {ConnectedAppStoreNav} from '@/modules/app-store/app-store-nav'\nimport {categories} from '@/modules/app-store/constants'\nimport {AppsGridFaintSection} from '@/modules/app-store/discover/apps-grid-section'\nimport {getCategoryLabel} from '@/modules/app-store/utils'\nimport {useAvailableApps} from '@/providers/available-apps'\n\nexport default function CategoryPage() {\n\treturn (\n\t\t<>\n\t\t\t<ConnectedAppStoreNav />\n\t\t\t<ErrorBoundary FallbackComponent={ErrorBoundaryCardFallback}>\n\t\t\t\t<CategoryContent />\n\t\t\t</ErrorBoundary>\n\t\t</>\n\t)\n}\n\nfunction CategoryContent() {\n\tconst {categoryishId} = useParams<{categoryishId: string}>()\n\tconst {appsGroupedByCategory, apps, isLoading} = useAvailableApps()\n\n\t// Probably invalid url param\n\tif (!categoryishId) return null\n\tif (isLoading) return null\n\n\tconst categoryId = categoryishId === 'discover' || categoryishId === 'all' ? null : categoryishId\n\n\t// Redirect if category is invalid OR if it's valid but has no apps\n\t// A category may have no apps if it is a hardcoded one in the OS and we have changed the app manifests to no longer include it.\n\tconst isPredefinedCategory = categoryId ? categories.includes(categoryId as any) : false\n\tconst existsInData = categoryId ? !!(appsGroupedByCategory as Record<string, any[]>)[categoryId] : false\n\tconst hasApps = categoryId ? (appsGroupedByCategory as Record<string, any[]>)[categoryId]?.length > 0 : true\n\n\tif (categoryId && ((!isPredefinedCategory && !existsInData) || !hasApps)) {\n\t\treturn <Navigate to='/app-store' replace />\n\t}\n\n\tconst filteredApps = categoryId ? (appsGroupedByCategory as Record<string, any[]>)[categoryId] || [] : apps\n\tconst title = getCategoryLabel(categoryishId)\n\n\treturn (\n\t\t<>\n\t\t\t<AppsGridFaintSection title={title} apps={filteredApps} />\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/app-store/discover.tsx",
    "content": "import {ErrorBoundary} from 'react-error-boundary'\n\nimport {ButtonLink} from '@/components/ui/button-link'\nimport {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'\nimport {cn} from '@/lib/utils'\nimport {ConnectedAppStoreNav} from '@/modules/app-store/app-store-nav'\nimport {AppsGridSection} from '@/modules/app-store/discover/apps-grid-section'\nimport {AppsRowSection} from '@/modules/app-store/discover/apps-row-section'\nimport {AppsThreeColumnSection} from '@/modules/app-store/discover/apps-three-column-section'\nimport {AppsGallerySection} from '@/modules/app-store/gallery-section'\nimport {cardFaintClass} from '@/modules/app-store/shared'\nimport {getCategoryLabel} from '@/modules/app-store/utils'\nimport {useAvailableApps} from '@/providers/available-apps'\nimport {RegistryApp} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nimport {useDiscoverQuery} from './use-discover-query'\n\nconst getAppById = (appId: string, apps: RegistryApp[]): RegistryApp | undefined => {\n\tconst app = apps.find((app) => app.id === appId)\n\t// Return undefined instead of throwing to allow graceful filtering of missing apps\n\t// This can happen if a new app is added to the app store and the discover endpoint, but umbrelOS hasn't pulled down the app store repo changes yet\n\tif (!app) return undefined\n\treturn app\n}\n\n// Fallback component when discover API fails\nfunction DiscoverUnavailable() {\n\treturn (\n\t\t<div className={cn(cardFaintClass, 'flex h-40 flex-col items-center justify-center p-8 text-center')}>\n\t\t\t<p className='text-15 font-medium text-white/80'>{t('app-store.discover.temporarily-unavailable-title')}</p>\n\t\t\t<p className='mt-2 text-12 text-white/50'>{t('app-store.discover.temporarily-unavailable-description')}</p>\n\t\t</div>\n\t)\n}\n\nexport default function Discover() {\n\treturn (\n\t\t<>\n\t\t\t<ConnectedAppStoreNav />\n\t\t\t<ErrorBoundary FallbackComponent={ErrorBoundaryCardFallback}>\n\t\t\t\t<DiscoverContent />\n\t\t\t</ErrorBoundary>\n\t\t</>\n\t)\n}\n\nfunction DiscoverContent() {\n\tconst availableApps = useAvailableApps()\n\tconst discoverQ = useDiscoverQuery()\n\n\t// Return null instead of a loading spinner — data is prefetched on idle so this\n\t// state is brief. An empty content area feels faster than a spinner flashing.\n\tif (availableApps.isLoading || discoverQ.isLoading) {\n\t\treturn null\n\t}\n\n\tconst {apps} = availableApps\n\n\t// Check for error state and show graceful fallback\n\tif (discoverQ.isError || !discoverQ.data) {\n\t\treturn <DiscoverUnavailable />\n\t}\n\n\tconst {banners, sections} = discoverQ.data\n\treturn (\n\t\t<>\n\t\t\t<AppsGallerySection banners={banners} />\n\t\t\t{sections.map((section) => {\n\t\t\t\tif (section.type === 'grid') {\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<AppsGridSection\n\t\t\t\t\t\t\tkey={section.heading + section.subheading}\n\t\t\t\t\t\t\ttitle={section.heading}\n\t\t\t\t\t\t\toverline={section.subheading}\n\t\t\t\t\t\t\tapps={section.apps.map((appId) => getAppById(appId, apps)).filter((app) => app !== undefined)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tif (section.type === 'horizontal') {\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<AppsRowSection\n\t\t\t\t\t\t\tkey={section.heading + section.subheading}\n\t\t\t\t\t\t\toverline={section.subheading}\n\t\t\t\t\t\t\ttitle={section.heading}\n\t\t\t\t\t\t\tapps={section.apps.map((appId) => getAppById(appId, apps)).filter((app) => app !== undefined)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\tif (section.type === 'three-column') {\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<AppsThreeColumnSection\n\t\t\t\t\t\t\tkey={section.heading + section.subheading}\n\t\t\t\t\t\t\tapps={section.apps.map((appId) => getAppById(appId, apps)).filter((app) => app !== undefined)}\n\t\t\t\t\t\t\toverline={section.subheading}\n\t\t\t\t\t\t\ttitle={section.heading}\n\t\t\t\t\t\t\ttextLocation={section.textLocation}\n\t\t\t\t\t\t\tdescription={section.description || ''}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{section.category && (\n\t\t\t\t\t\t\t\t<ButtonLink variant='primary' size='dialog' to={`/app-store/category/${section.category}`}>\n\t\t\t\t\t\t\t\t\t{t('app-store.browse-category-apps', {\n\t\t\t\t\t\t\t\t\t\tcategory: getCategoryLabel(section.category),\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t</ButtonLink>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</AppsThreeColumnSection>\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t})}\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/app-store/use-discover-query.tsx",
    "content": "import {useQuery} from '@tanstack/react-query'\n\nimport {Categoryish} from '@/modules/app-store/constants'\n\nexport type Banner = {\n\tid: string\n\timage: string\n}\n\nexport type Section = {\n\ttype: string\n\theading: string\n\tsubheading: string\n\tapps: string[]\n\ttextLocation?: 'left' | 'right' | undefined\n\tdescription?: string\n\tcategory?: Categoryish\n}\n\nexport type DiscoverData = {\n\tbanners: Banner[]\n\tsections: Section[]\n}\n\nexport function useDiscoverQuery() {\n\tconst discoverQ = useQuery<{data: DiscoverData}>({\n\t\tqueryKey: ['app-store', 'discover'],\n\t\tqueryFn: () => fetch('https://apps.umbrel.com/api/v2/umbrelos/app-store/discover').then((res) => res.json()),\n\t})\n\n\treturn {\n\t\tdata: discoverQ.data?.data,\n\t\tisLoading: discoverQ.isLoading,\n\t\tisError: discoverQ.isError,\n\t\terror: discoverQ.error,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/routes/community-app-store/app-page/index.tsx",
    "content": "import {ErrorBoundary} from 'react-error-boundary'\nimport {useParams} from 'react-router-dom'\n\nimport {InstallButton} from '@/components/install-button'\nimport {InstallButtonConnected} from '@/components/install-button-connected'\nimport {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'\nimport {Loading} from '@/components/ui/loading'\nimport {AppContent} from '@/modules/app-store/app-page/app-content'\nimport {appPageWrapperClass} from '@/modules/app-store/app-page/shared'\nimport {TopHeader} from '@/modules/app-store/app-page/top-header'\nimport {CommunityBadge} from '@/modules/community-app-store/community-badge'\nimport {trpcReact} from '@/trpc/trpc'\n\nexport default function CommunityAppPage() {\n\tconst {appStoreId, appId} = useParams<{appStoreId: string; appId: string}>()\n\n\tconst registryQ = trpcReact.appStore.registry.useQuery()\n\tconst appStore = registryQ.data?.find((appStore) => appStore?.meta.id === appStoreId)\n\n\tconst app = appStore?.apps.find((app) => app.id === appId)\n\n\tif (!appStoreId) throw new Error('App store id expected.') // Putting before isLoading because we don't want to show the is loading state\n\tif (registryQ.isLoading) return <Loading />\n\tif (!app) throw new Error('App not found. It may have been removed from the registry.')\n\n\treturn (\n\t\t<div className={appPageWrapperClass}>\n\t\t\t<CommunityBadge className='self-start' />\n\t\t\t<TopHeader\n\t\t\t\tapp={app}\n\t\t\t\tchildrenRight={\n\t\t\t\t\t<ErrorBoundary\n\t\t\t\t\t\tfallback={\n\t\t\t\t\t\t\t<div className='pointer-events-none opacity-50'>\n\t\t\t\t\t\t\t\t<InstallButton state='not-installed' />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t}\n\t\t\t\t\t>\n\t\t\t\t\t\t<InstallButtonConnected app={app} />\n\t\t\t\t\t</ErrorBoundary>\n\t\t\t\t}\n\t\t\t/>\n\t\t\t<ErrorBoundary FallbackComponent={ErrorBoundaryCardFallback}>\n\t\t\t\t<AppContent app={app} />\n\t\t\t</ErrorBoundary>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/community-app-store/index.tsx",
    "content": "import {TbArrowLeft} from 'react-icons/tb'\nimport {useNavigate, useParams} from 'react-router-dom'\nimport {groupBy} from 'remeda'\nimport {objectKeys} from 'ts-extras'\n\nimport {Loading} from '@/components/ui/loading'\nimport {cn} from '@/lib/utils'\nimport {AppWithDescription} from '@/modules/app-store/discover/apps-grid-section'\nimport {appsGridClass, AppStoreSheetInner, cardFaintClass, sectionOverlineClass} from '@/modules/app-store/shared'\nimport {CommunityBadge} from '@/modules/community-app-store/community-badge'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport default function CommunityAppStoreHome() {\n\tconst navigate = useNavigate()\n\tconst {appStoreId} = useParams<{appStoreId: string}>()\n\n\tconst registryQ = trpcReact.appStore.registry.useQuery()\n\n\tconst appStore = registryQ.data?.find((appStore) => appStore?.meta.id === appStoreId)\n\tconst appStoreName = appStore?.meta.name\n\n\tif (registryQ.isLoading) {\n\t\treturn <Loading />\n\t}\n\n\tif (registryQ.isError || !registryQ.data || !appStore) {\n\t\tthrow new Error('No data')\n\t}\n\n\tconst apps = appStore.apps\n\tconst appsGroupedByCategory = groupBy(apps, (a) => a.category)\n\n\treturn (\n\t\t<>\n\t\t\t<AppStoreSheetInner\n\t\t\t\ttitle={`${appStoreName} app store`}\n\t\t\t\tbeforeHeaderChildren={\n\t\t\t\t\t<>\n\t\t\t\t\t\t<CommunityBadge className='self-start' />\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => navigate('/app-store')}\n\t\t\t\t\t\t\tclassName='flex items-center gap-1 self-start underline-offset-2 outline-hidden focus-visible:underline'\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<TbArrowLeft className='h-5 w-5' />\n\t\t\t\t\t\t\t{t('community-app-store.back-to-umbrel-app-store')}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</>\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{objectKeys(appsGroupedByCategory).map((category) => (\n\t\t\t\t\t<div key={category} className={cardFaintClass}>\n\t\t\t\t\t\t<h3 className={cn(sectionOverlineClass, 'm-0 p-2.5')}>{category}</h3>\n\t\t\t\t\t\t<div className={appsGridClass}>\n\t\t\t\t\t\t\t{appsGroupedByCategory[category].map((app) => (\n\t\t\t\t\t\t\t\t<AppWithDescription key={app.id} app={app} to={`/community-app-store/${appStoreId}/${app.id}`} />\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t))}\n\t\t\t</AppStoreSheetInner>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/edit-widgets/index.tsx",
    "content": "import {useState} from 'react'\nimport {useNavigate} from 'react-router-dom'\n\nimport {useApps} from '@/providers/apps'\nimport {afterDelayedClose} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nimport {WidgetSelector} from './widget-selector'\n\nexport default function EditWidgetsPage() {\n\tconst navigate = useNavigate()\n\tconst [open, setOpen] = useState(true)\n\n\tconst {userApps, isLoading: isUserAppsLoading} = useApps()\n\tconst hasInstalledApps = !isUserAppsLoading && (userApps ?? []).length > 0\n\n\tif (!hasInstalledApps) {\n\t\treturn (\n\t\t\t<div className='absolute inset-0 grid h-full w-full place-items-center'>\n\t\t\t\t<div className='drop-shadow-desktop-label'>{t('widgets.install-an-app-before-using-widgets')}</div>\n\t\t\t</div>\n\t\t)\n\t}\n\n\treturn (\n\t\t<WidgetSelector\n\t\t\topen={open}\n\t\t\tonOpenChange={(open) => {\n\t\t\t\tsetOpen(open)\n\t\t\t\tafterDelayedClose(() => navigate('/'))(open)\n\t\t\t}}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/edit-widgets/widget-selector.tsx",
    "content": "import {Minus, Plus} from 'lucide-react'\nimport {AnimatePresence, motion} from 'motion/react'\nimport {ReactNode, useEffect, useState} from 'react'\nimport {ErrorBoundary} from 'react-error-boundary'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {DialogCloseButton} from '@/components/ui/dialog-close-button'\nimport {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'\nimport {Sheet, SheetContent, SheetHeader, SheetTitle} from '@/components/ui/sheet'\nimport {ScrollArea} from '@/components/ui/sheet-scroll-area'\nimport {WidgetCheckIcon} from '@/components/widget-check-icon'\nimport {useWidgets} from '@/hooks/use-widgets'\nimport {cn} from '@/lib/utils'\nimport {DockSpacer} from '@/modules/desktop/dock'\nimport {ExampleWidget, Widget} from '@/modules/widgets'\nimport {BackdropBlurVariantContext} from '@/modules/widgets/shared/backdrop-blur-context'\nimport {t} from '@/utils/i18n'\n\nexport function WidgetSelector({open, onOpenChange}: {open: boolean; onOpenChange: (open: boolean) => void}) {\n\t// Delay until after `usePager` has injected CSS vars\n\tconst [isReady, setIsReady] = useState(false)\n\tuseEffect(() => {\n\t\tconst id = setTimeout(() => setIsReady(true), 300)\n\t\treturn () => clearTimeout(id)\n\t}, [])\n\n\tconst {availableWidgets, toggleSelected, selected, selectedTooMany} = useWidgets()\n\n\tif (!isReady) return null\n\n\tconst selectedH = selected.length == 0 ? 'var(--sheet-top)' : `calc(var(--widget-h) + 8vh)`\n\n\treturn (\n\t\t<>\n\t\t\t{open && (\n\t\t\t\t// Don't make this take up full width because clicking outside should close the widget selector\n\t\t\t\t// `pointer-events-none` because we want clicking outside the sheet to close the sheet, not interact with the widget\n\t\t\t\t<div className='pointer-events-none absolute top-0 left-1/2 z-50 -translate-x-1/2 max-lg:scale-[85%] max-md:scale-[65%]'>\n\t\t\t\t\t{/* <div className='absoulte top-0 grid h-[var(--widget-h)] w-full place-items-center whitespace-nowrap'>\n\t\t\t\t\t\tNo widgets selected\n\t\t\t\t\t</div> */}\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tinitial={{\n\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t\ty: 40,\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tanimate={{\n\t\t\t\t\t\t\topacity: 1,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t}}\n\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\tduration: 0.2,\n\t\t\t\t\t\t\tease: 'easeOut',\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName={cn('flex items-center gap-[var(--app-x-gap)]', selectedTooMany && 'animate-shake')}\n\t\t\t\t\t\tstyle={{height: selectedH}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<AnimatePresence>\n\t\t\t\t\t\t\t{selected.map((widget) => {\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\tkey={widget.id}\n\t\t\t\t\t\t\t\t\t\tlayout\n\t\t\t\t\t\t\t\t\t\tinitial={{\n\t\t\t\t\t\t\t\t\t\t\topacity: 1,\n\t\t\t\t\t\t\t\t\t\t\ty: -20,\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\tanimate={{\n\t\t\t\t\t\t\t\t\t\t\topacity: 1,\n\t\t\t\t\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\texit={{\n\t\t\t\t\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t\t\t\t\t\ty: 20,\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\ttransition={{\n\t\t\t\t\t\t\t\t\t\t\ttype: 'spring',\n\t\t\t\t\t\t\t\t\t\t\tstiffness: 500,\n\t\t\t\t\t\t\t\t\t\t\tdamping: 30,\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<Widget appId={widget.app.id} config={widget} />\n\t\t\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t</AnimatePresence>\n\t\t\t\t\t</motion.div>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t\t<WidgetSheet open={open} onOpenChange={onOpenChange} selectedCssHeight={selectedH}>\n\t\t\t\t{availableWidgets.map(({appId, icon, name, widgets}) => {\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<WidgetSection key={appId} iconSrc={icon} title={name}>\n\t\t\t\t\t\t\t{widgets?.map((widget) => {\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<ErrorBoundary key={widget.id} fallback={null}>\n\t\t\t\t\t\t\t\t\t\t<WidgetChecker\n\t\t\t\t\t\t\t\t\t\t\tchecked={selected.map((w) => w.id).includes(widget.id)}\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={(checked) => toggleSelected(widget.id, checked)}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<ExampleWidget type={widget.type} example={widget.example} />\n\t\t\t\t\t\t\t\t\t\t</WidgetChecker>\n\t\t\t\t\t\t\t\t\t</ErrorBoundary>\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t</WidgetSection>\n\t\t\t\t\t)\n\t\t\t\t})}\n\t\t\t</WidgetSheet>\n\t\t</>\n\t)\n}\n\nfunction WidgetSheet({\n\topen,\n\tonOpenChange,\n\tchildren,\n\tselectedCssHeight,\n}: {\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n\tchildren: ReactNode\n\tselectedCssHeight: string\n}) {\n\treturn (\n\t\t<BackdropBlurVariantContext value='default'>\n\t\t\t<Sheet open={open} onOpenChange={onOpenChange} modal={false}>\n\t\t\t\t<SheetContent\n\t\t\t\t\tclassName='mx-auto max-w-[1040px] transition-[height]'\n\t\t\t\t\tonInteractOutside={(e) => e.preventDefault()}\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\theight: `calc(100dvh - ${selectedCssHeight})`,\n\t\t\t\t\t}}\n\t\t\t\t\tbackdrop={<div className='fixed inset-0 z-30' onClick={() => onOpenChange(false)} />}\n\t\t\t\t\tcloseButton={<DialogCloseButton className='absolute top-2.5 right-2.5 z-50' />}\n\t\t\t\t>\n\t\t\t\t\t<ScrollArea className='h-full rounded-t-20'>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t'flex h-full flex-col items-start gap-5 px-4 pt-6 opacity-0 md:gap-8 md:px-[80px] md:pt-12',\n\t\t\t\t\t\t\t\t'animate-in opacity-100 duration-100 fade-in',\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<SheetHeader>\n\t\t\t\t\t\t\t\t<SheetTitle>{t('widgets.edit.select-up-to-3-widgets')}</SheetTitle>\n\t\t\t\t\t\t\t</SheetHeader>\n\t\t\t\t\t\t\t<ErrorBoundary FallbackComponent={ErrorBoundaryCardFallback}>{children}</ErrorBoundary>\n\t\t\t\t\t\t\t<DockSpacer />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</ScrollArea>\n\t\t\t\t</SheetContent>\n\t\t\t</Sheet>\n\t\t</BackdropBlurVariantContext>\n\t)\n}\n\nfunction WidgetSection({iconSrc, title, children}: {iconSrc: string; title: string; children: ReactNode}) {\n\treturn (\n\t\t<>\n\t\t\t<div className='flex items-center gap-3'>\n\t\t\t\t<AppIcon src={iconSrc} size={36} className='rounded-8' />\n\t\t\t\t<h3 className='text-20 leading-tight font-semibold'>{title}</h3>\n\t\t\t</div>\n\t\t\t<div className='flex flex-row flex-wrap gap-[20px]'>{children}</div>\n\t\t\t<div className='h-1'></div>\n\t\t</>\n\t)\n}\n\nfunction PlusIcon({className}: {className?: string}) {\n\treturn (\n\t\t<div className={cn('flex h-[26px] w-[26px] items-center justify-center rounded-full bg-white/80', className)}>\n\t\t\t<Plus className='h-4 w-4 text-black' strokeWidth={2.5} />\n\t\t</div>\n\t)\n}\n\nfunction MinusIcon({className}: {className?: string}) {\n\treturn (\n\t\t<div className={cn('flex h-[26px] w-[26px] items-center justify-center rounded-full bg-white/80', className)}>\n\t\t\t<Minus className='h-4 w-4 text-black' strokeWidth={2.5} />\n\t\t</div>\n\t)\n}\n\nfunction WidgetChecker({\n\tchildren,\n\tchecked = false,\n\tonCheckedChange,\n}: {\n\tchildren: ReactNode\n\tchecked?: boolean\n\tonCheckedChange?: (checked: boolean) => void\n}) {\n\treturn (\n\t\t<div className='group relative'>\n\t\t\t{children}\n\t\t\t{/* Corner icon: check when selected, plus/minus on hover to hint add/remove */}\n\t\t\t<div className='absolute top-0 right-0 translate-x-1/3 -translate-y-1/3'>\n\t\t\t\t{checked ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t{/* Show check by default, swap to minus on hover */}\n\t\t\t\t\t\t<div className='text-brand group-hover:hidden'>\n\t\t\t\t\t\t\t<WidgetCheckIcon className='max-sm:scale-75' />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div className='hidden group-hover:block'>\n\t\t\t\t\t\t\t<MinusIcon className='max-sm:scale-75' />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</>\n\t\t\t\t) : (\n\t\t\t\t\t/* Fade in plus icon on hover */\n\t\t\t\t\t<div className='opacity-0 transition-opacity group-hover:opacity-100'>\n\t\t\t\t\t\t<PlusIcon className='max-sm:scale-75' />\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t\t{/* Invisible overlay button for the entire widget area */}\n\t\t\t<button\n\t\t\t\tclassName='absolute top-0 left-0 h-full w-full outline-hidden'\n\t\t\t\tonClick={() => onCheckedChange?.(!checked)}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/factory-reset/_components/confirm-with-password.tsx",
    "content": "import {useRef, useState} from 'react'\nimport {useMount} from 'react-use'\n\nimport {Button} from '@/components/ui/button'\nimport {ImmersiveDialogBody} from '@/components/ui/immersive-dialog'\nimport {PasswordInput} from '@/components/ui/input'\nimport {t} from '@/utils/i18n'\n\nimport {description, title} from './misc'\n\nexport function ConfirmWithPassword({\n\terror,\n\tonSubmit,\n\tclearError,\n}: {\n\terror: string\n\tonSubmit: (password: string) => void\n\tclearError: () => void\n}) {\n\tconst passwordRef = useRef<HTMLInputElement>(null)\n\tconst [password, setPassword] = useState('')\n\n\t// Clear password and errors so we don't see it when we come back to this page\n\tuseMount(() => {\n\t\tsetPassword('')\n\t\tclearError()\n\t})\n\n\tconst handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n\t\te.preventDefault()\n\t\tonSubmit(password)\n\t\tsetPassword('')\n\t}\n\n\treturn (\n\t\t<form onSubmit={handleSubmit} className='flex-1'>\n\t\t\t<ImmersiveDialogBody\n\t\t\t\ttitle={title()}\n\t\t\t\tdescription={description()}\n\t\t\t\tbodyText={t('factory-reset.confirm.body')}\n\t\t\t\tfooter={\n\t\t\t\t\t<>\n\t\t\t\t\t\t<Button type='submit' variant='destructive' size='dialog' className='min-w-0'>\n\t\t\t\t\t\t\t{t('factory-reset.confirm.submit')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t<div className='text-13 text-destructive2'>{t('factory-reset.confirm.submit-callout')}</div>\n\t\t\t\t\t</>\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t<label>\n\t\t\t\t\t<PasswordInput\n\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\tinputRef={passwordRef}\n\t\t\t\t\t\tsizeVariant='short'\n\t\t\t\t\t\tvalue={password}\n\t\t\t\t\t\tonValueChange={setPassword}\n\t\t\t\t\t\terror={error}\n\t\t\t\t\t/>\n\t\t\t\t</label>\n\t\t\t\t<div className='mt-5 rounded-8 bg-yellow-700/50 p-3 text-13 text-yellow-300/80'>\n\t\t\t\t\t⚠️ {t('factory-reset.confirm.ethernet-required-warning')}\n\t\t\t\t</div>\n\t\t\t</ImmersiveDialogBody>\n\t\t</form>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/factory-reset/_components/misc.tsx",
    "content": "import {t} from '@/utils/i18n'\n\n// In a function because otherwise translation won't always work\n// Could also put into a hook or component\nexport const title = () => t('factory-reset')\nexport const description = () => t('factory-reset-description')\n\nexport const backPath = '/settings'\n"
  },
  {
    "path": "packages/ui/src/routes/factory-reset/_components/review-data.tsx",
    "content": "import {TbServer, TbShoppingBag, TbUser} from 'react-icons/tb'\nimport {useNavigate} from 'react-router-dom'\n\nimport {Button} from '@/components/ui/button'\nimport {ButtonLink} from '@/components/ui/button-link'\nimport {ImmersiveDialogBody, ImmersiveDialogIconMessageKeyValue} from '@/components/ui/immersive-dialog'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {maybePrettyBytes} from '@/utils/pretty-bytes'\n\nimport {backPath, description, title} from './misc'\n\nexport function ReviewData() {\n\tconst navigate = useNavigate()\n\n\tconst userQ = trpcReact.user.get.useQuery()\n\tconst userAppsQ = trpcReact.apps.list.useQuery()\n\tconst diskQ = trpcReact.system.diskUsage.useQuery()\n\n\tconst installedAppCount = userAppsQ.data?.length\n\tconst used = maybePrettyBytes(diskQ.data?.totalUsed)\n\n\treturn (\n\t\t<>\n\t\t\t<ImmersiveDialogBody\n\t\t\t\ttitle={title()}\n\t\t\t\tdescription={description()}\n\t\t\t\tbodyText={t('factory-reset.review.following-will-be-removed')}\n\t\t\t\tfooter={\n\t\t\t\t\t<>\n\t\t\t\t\t\t<ButtonLink to='/factory-reset/confirm' variant='destructive' size='dialog' className='min-w-0'>\n\t\t\t\t\t\t\t{t('factory-reset.review.submit')}\n\t\t\t\t\t\t</ButtonLink>\n\t\t\t\t\t\t<Button size='dialog' className='min-w-0' onClick={() => navigate(backPath)}>\n\t\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</>\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t<ImmersiveDialogIconMessageKeyValue\n\t\t\t\t\ticon={TbUser}\n\t\t\t\t\tk={t('factory-reset.review.account-info')}\n\t\t\t\t\tv={\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<span>{userQ.data?.name}</span>\n\t\t\t\t\t\t\t<span className='max-sm:hidden'>\n\t\t\t\t\t\t\t\t<span className='mr-2 ml-2 opacity-50'>⋅</span>\n\t\t\t\t\t\t\t\t••••••••\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t<ImmersiveDialogIconMessageKeyValue\n\t\t\t\t\ticon={TbShoppingBag}\n\t\t\t\t\tk={t('factory-reset.review.apps')}\n\t\t\t\t\tv={t('factory-reset.review.installed-apps', {count: installedAppCount})}\n\t\t\t\t/>\n\t\t\t\t<ImmersiveDialogIconMessageKeyValue icon={TbServer} k={t('factory-reset.review.total-data')} v={used} />\n\t\t\t</ImmersiveDialogBody>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/factory-reset/index.tsx",
    "content": "import {TbRotate2} from 'react-icons/tb'\nimport {Route, Routes, useNavigate} from 'react-router-dom'\n\nimport {ImmersiveDialog, ImmersiveDialogSplitContent} from '@/components/ui/immersive-dialog'\nimport {EnsureLoggedIn} from '@/modules/auth/ensure-logged-in'\nimport {useGlobalSystemState} from '@/providers/global-system-state'\nimport {t} from '@/utils/i18n'\n\nimport {ConfirmWithPassword} from './_components/confirm-with-password'\nimport {backPath} from './_components/misc'\nimport {ReviewData} from './_components/review-data'\n\nexport default function FactoryReset() {\n\tconst {reset, getError, clearError} = useGlobalSystemState()\n\n\t// Handling routes in this weird way because:\n\t// - Standard router approach won't work because `<Outlet />` is generic and we want this parent to have state\n\t// - We do want to load all the sub-routes anyways\n\t// - Not using data routers anyways, which is why we'd be putting things in `router.tsx` anyways\n\t// - Initial routes require a user to be logged in, but other subroutes don't\n\t// - Wanna keep the trpc mutation that starts the factory reset in the same component so errors are handled properly\n\t// - If we wanna restart the mutation, we don't wanna have the user put the password in again\n\treturn (\n\t\t<>\n\t\t\t<Routes>\n\t\t\t\t<Route\n\t\t\t\t\tpath='/'\n\t\t\t\t\telement={\n\t\t\t\t\t\t<EnsureLoggedIn>\n\t\t\t\t\t\t\t<SplitDialog>\n\t\t\t\t\t\t\t\t<ReviewData />\n\t\t\t\t\t\t\t</SplitDialog>\n\t\t\t\t\t\t</EnsureLoggedIn>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t<Route\n\t\t\t\t\tpath='/confirm'\n\t\t\t\t\telement={\n\t\t\t\t\t\t<EnsureLoggedIn>\n\t\t\t\t\t\t\t<SplitDialog>\n\t\t\t\t\t\t\t\t{/* Only password errors come through getError() - system errors show as toasts */}\n\t\t\t\t\t\t\t\t<ConfirmWithPassword onSubmit={reset} error={getError()?.message ?? ''} clearError={clearError} />\n\t\t\t\t\t\t\t</SplitDialog>\n\t\t\t\t\t\t</EnsureLoggedIn>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t</Routes>\n\t\t</>\n\t)\n}\n\nfunction SplitDialog({children}: {children: React.ReactNode}) {\n\tconst navigate = useNavigate()\n\treturn (\n\t\t<ImmersiveDialog defaultOpen onOpenChange={(isOpen) => !isOpen && navigate(backPath, {preventScrollReset: true})}>\n\t\t\t<ImmersiveDialogSplitContent side={<SplitLeftContent />}>{children}</ImmersiveDialogSplitContent>\n\t\t</ImmersiveDialog>\n\t)\n}\n\nfunction SplitLeftContent() {\n\treturn (\n\t\t<div className='flex flex-col items-center'>\n\t\t\t<div\n\t\t\t\tclassName='grid h-[67px] w-[67px] place-items-center rounded-15 bg-destructive2'\n\t\t\t\tstyle={{\n\t\t\t\t\tboxShadow: '0 1px 1px #ffffff33 inset',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<TbRotate2 className='h-[40px] w-[40px]' />\n\t\t\t</div>\n\t\t\t<div className='mt-2.5 px-2 text-center text-15 font-medium'>{t('factory-reset')}</div>\n\t\t\t<div className='text-13 opacity-40'>{t('umbrel')}</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/live-usage.tsx",
    "content": "import {DialogPortal} from '@radix-ui/react-dialog'\nimport {motion} from 'motion/react'\nimport {ReactNode, useEffect, useState} from 'react'\nimport {ErrorBoundary} from 'react-error-boundary'\nimport {useLocation, useNavigate} from 'react-router-dom'\nimport {Area, AreaChart, ResponsiveContainer, XAxis, YAxis} from 'recharts'\n\nimport {AppIcon} from '@/components/app-icon'\nimport {Card} from '@/components/ui/card'\nimport {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'\nimport {\n\tImmersiveDialog,\n\tImmersiveDialogContent,\n\tImmersiveDialogOverlay,\n\timmersiveDialogTitleClass,\n} from '@/components/ui/immersive-dialog'\nimport {Progress} from '@/components/ui/progress'\nimport {SegmentedControl} from '@/components/ui/segmented-control'\nimport {LOADING_DASH} from '@/constants'\nimport {useCpuForUi} from '@/hooks/use-cpu'\nimport {useDiskForUi, useSystemDiskForUi} from '@/hooks/use-disk'\nimport {useMemoryForUi, useSystemMemoryForUi} from '@/hooks/use-memory'\nimport {cn} from '@/lib/utils'\nimport {AppT, systemAppsKeyed, useApps} from '@/providers/apps'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\nimport {formatNumberI18n} from '@/utils/number'\nimport {maybePrettyBytes} from '@/utils/pretty-bytes'\nimport {tw} from '@/utils/tw'\n\nexport default function LiveUsageDialog() {\n\tconst title = t('live-usage')\n\tconst dialogProps = useDialogOpenProps('live-usage')\n\n\treturn (\n\t\t<ImmersiveDialog {...dialogProps}>\n\t\t\t<DialogPortal>\n\t\t\t\t<ImmersiveDialogOverlay />\n\t\t\t\t<ImmersiveDialogContent size='md' showScroll>\n\t\t\t\t\t<h1 className={immersiveDialogTitleClass}>{title}</h1>\n\t\t\t\t\t<ErrorBoundary FallbackComponent={ErrorBoundaryCardFallback}>\n\t\t\t\t\t\t<LiveUsageContent />\n\t\t\t\t\t</ErrorBoundary>\n\t\t\t\t</ImmersiveDialogContent>\n\t\t\t</DialogPortal>\n\t\t</ImmersiveDialog>\n\t)\n}\n\ntype SelectedTab = 'storage' | 'memory' | 'cpu'\n\nfunction LiveUsageContent() {\n\tconst {search} = useLocation()\n\tconst navigate = useNavigate()\n\tconst queryParams = new URLSearchParams(search)\n\tconst selectedTab = (queryParams.get('tab') as SelectedTab) || 'cpu'\n\n\tconst setSelectedTab = (tab: SelectedTab) => {\n\t\tqueryParams.set('tab', tab)\n\t\tnavigate({search: queryParams.toString()})\n\t}\n\n\t// Poll for cpu and memory usage, but do not poll for disk usage\n\t// As disk-usage doesn't change much in real-time but the calculation causes\n\t// CPU spikes\n\tconst cpuUsage = useCpuForUi({poll: true})\n\tconst memoryUsage = useSystemMemoryForUi({poll: true})\n\tconst diskUsage = useSystemDiskForUi()\n\n\t// Initialize cpu and memory charts with 30 \"0\" values so there's a clean base line from where they start populating with\n\tconst [cpuChartData, setCpuChartData] = useState<Array<{value: number}>>(new Array(30).fill({value: 0}))\n\tconst [memoryChartData, setMemoryChartData] = useState<Array<{value: number}>>(new Array(30).fill({value: 0}))\n\n\t// Update cpu and memory charts whenever their progress values update\n\tuseEffect(() => {\n\t\tsetCpuChartData((prevData: Array<{value: number}>) => [...prevData.slice(1), {value: cpuUsage.progress * 100 || 0}])\n\t}, [cpuUsage.progress])\n\n\tuseEffect(() => {\n\t\tsetMemoryChartData((prevData: Array<{value: number}>) => [\n\t\t\t...prevData.slice(1),\n\t\t\t{value: memoryUsage.progress * 100 || 0},\n\t\t])\n\t}, [memoryUsage.progress])\n\n\treturn (\n\t\t<div className='grid gap-y-5'>\n\t\t\t{/* Hidden on mobile, as we show regular tabs */}\n\t\t\t<div className='hidden columns-3 sm:block'>\n\t\t\t\t<button className='block w-full text-left focus:outline-hidden' onClick={() => setSelectedTab('cpu')}>\n\t\t\t\t\t<UsageCard\n\t\t\t\t\t\ttitle={t('cpu')}\n\t\t\t\t\t\tvalue={cpuUsage.value}\n\t\t\t\t\t\tprogressLabel={cpuUsage.secondaryValue}\n\t\t\t\t\t\tprogress={cpuUsage.progress}\n\t\t\t\t\t\tactive={selectedTab === 'cpu'}\n\t\t\t\t\t\tchart={cpuChartData}\n\t\t\t\t\t/>\n\t\t\t\t</button>\n\t\t\t\t<button className='block w-full text-left focus:outline-hidden' onClick={() => setSelectedTab('memory')}>\n\t\t\t\t\t<UsageCard\n\t\t\t\t\t\ttitle={t('memory')}\n\t\t\t\t\t\tvalue={memoryUsage.value}\n\t\t\t\t\t\tvalueSub={memoryUsage.valueSub}\n\t\t\t\t\t\tprogressLabel={memoryUsage.secondaryValue}\n\t\t\t\t\t\tprogress={memoryUsage.progress}\n\t\t\t\t\t\trightChildren={memoryUsage.isMemoryLow && <ErrorMessage>{t('memory.low')}</ErrorMessage>}\n\t\t\t\t\t\tactive={selectedTab === 'memory'}\n\t\t\t\t\t\tchart={memoryChartData}\n\t\t\t\t\t/>\n\t\t\t\t</button>\n\t\t\t\t<button className='block w-full text-left focus:outline-hidden' onClick={() => setSelectedTab('storage')}>\n\t\t\t\t\t<UsageCard\n\t\t\t\t\t\ttitle={t('storage')}\n\t\t\t\t\t\tvalue={diskUsage.value}\n\t\t\t\t\t\tvalueSub={diskUsage.valueSub}\n\t\t\t\t\t\tprogressLabel={diskUsage.secondaryValue}\n\t\t\t\t\t\tprogress={diskUsage.progress}\n\t\t\t\t\t\trightChildren={\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{diskUsage.isDiskLow && <ErrorMessage>{t('storage.low')}</ErrorMessage>}\n\t\t\t\t\t\t\t\t{diskUsage.isDiskFull && <ErrorMessage>{t('storage.full')}</ErrorMessage>}\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t}\n\t\t\t\t\t\tactive={selectedTab === 'storage'}\n\t\t\t\t\t/>\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t{/* Shown only on mobile */}\n\t\t\t<div className='sm:hidden'>\n\t\t\t\t<SegmentedControl\n\t\t\t\t\tsize='lg'\n\t\t\t\t\ttabs={[\n\t\t\t\t\t\t{id: 'cpu', label: t('cpu')},\n\t\t\t\t\t\t{id: 'memory', label: t('memory')},\n\t\t\t\t\t\t{id: 'storage', label: t('storage')},\n\t\t\t\t\t]}\n\t\t\t\t\tvalue={selectedTab}\n\t\t\t\t\tonValueChange={setSelectedTab}\n\t\t\t\t/>\n\t\t\t</div>\n\n\t\t\t{/* Key to make sure we reset the error */}\n\t\t\t<ErrorBoundary key={selectedTab} FallbackComponent={ErrorBoundaryCardFallback}>\n\t\t\t\t{selectedTab === 'cpu' && <CpuSection />}\n\t\t\t\t{selectedTab === 'memory' && <MemorySection />}\n\t\t\t\t{selectedTab === 'storage' && <StorageSection />}\n\t\t\t</ErrorBoundary>\n\t\t</div>\n\t)\n}\n// ---\n\nfunction StorageSection() {\n\tconst {isLoading, value, valueSub, secondaryValue, progress, isDiskLow, isDiskFull, apps} = useDiskForUi({\n\t\tpoll: true,\n\t})\n\n\treturn (\n\t\t<>\n\t\t\t<div className='sm:hidden'>\n\t\t\t\t<UsageCard\n\t\t\t\t\tvalue={value}\n\t\t\t\t\tvalueSub={valueSub}\n\t\t\t\t\tprogressLabel={secondaryValue}\n\t\t\t\t\tprogress={progress}\n\t\t\t\t\trightChildren={\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t{isDiskLow && <ErrorMessage>{t('storage.low')}</ErrorMessage>}\n\t\t\t\t\t\t\t{isDiskFull && <ErrorMessage>{t('storage.full')}</ErrorMessage>}\n\t\t\t\t\t\t</>\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t\t{isLoading && <AppListSkeleton systemApps={[systemAppsKeyed.UMBREL_system, systemAppsKeyed.UMBREL_files]} />}\n\t\t\t<AppList apps={apps} formatValue={(v) => maybePrettyBytes(v)} />\n\t\t</>\n\t)\n}\n\nfunction MemorySection() {\n\tconst {isLoading, value, valueSub, secondaryValue, progress, isMemoryLow, apps} = useMemoryForUi({poll: true})\n\n\treturn (\n\t\t<>\n\t\t\t<div className='sm:hidden'>\n\t\t\t\t<UsageCard\n\t\t\t\t\tvalue={value}\n\t\t\t\t\tvalueSub={valueSub}\n\t\t\t\t\tprogressLabel={secondaryValue}\n\t\t\t\t\tprogress={progress}\n\t\t\t\t\trightChildren={isMemoryLow && <ErrorMessage>{t('memory.low')}</ErrorMessage>}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t\t{isLoading && <AppListSkeleton systemApps={[systemAppsKeyed.UMBREL_system]} />}\n\t\t\t<AppList apps={apps} formatValue={(v) => maybePrettyBytes(v)} />\n\t\t</>\n\t)\n}\n\nfunction CpuSection() {\n\tconst {isLoading, value, secondaryValue, progress, apps} = useCpuForUi({poll: true})\n\n\treturn (\n\t\t<>\n\t\t\t<div className='sm:hidden'>\n\t\t\t\t<UsageCard value={value} progressLabel={secondaryValue} progress={progress} />\n\t\t\t</div>\n\t\t\t{isLoading && <AppListSkeleton systemApps={[systemAppsKeyed.UMBREL_system]} />}\n\t\t\t<AppList apps={apps} formatValue={(n) => formatNumberI18n({n}) + '%'} />\n\t\t</>\n\t)\n}\n\nfunction UsageCard({\n\tactive,\n\ttitle,\n\tvalue,\n\tvalueSub,\n\tprogressLabel,\n\tprogress = 0,\n\trightChildren,\n\tchart,\n}: {\n\tactive?: boolean\n\ttitle?: string\n\tvalue?: string\n\tvalueSub?: string\n\tprogressLabel?: string\n\tprogress?: number\n\trightChildren?: ReactNode\n\tchart?: Array<any>\n}) {\n\treturn (\n\t\t<motion.div className='relative p-[2px]'>\n\t\t\t<motion.div\n\t\t\t\tclassName='absolute top-0 left-0 z-[-1] h-full w-full rounded-[12px] bg-linear-to-b from-brand/90 to-brand/15'\n\t\t\t\tinitial={{opacity: 0}}\n\t\t\t\tanimate={{opacity: active ? 1 : 0}}\n\t\t\t\ttransition={active ? {duration: 0.3, delay: 0.1} : {duration: 0.3}}\n\t\t\t/>\n\t\t\t<motion.div\n\t\t\t\tclassName='relative translate-x-0 translate-y-0 transform overflow-hidden rounded-12'\n\t\t\t\tinitial={{backgroundColor: 'rgba(30, 30, 30, 0)'}}\n\t\t\t\tanimate={{backgroundColor: active ? 'rgba(30, 30, 30, 1)' : 'rgba(30, 30, 30, 0)'}}\n\t\t\t\ttransition={active ? {duration: 0.3} : {duration: 0.3, delay: 0.1}}\n\t\t\t>\n\t\t\t\t<motion.div\n\t\t\t\t\tclassName='absolute top-0 left-0 z-[0] h-full w-full rounded-[12px] bg-linear-to-b from-brand/15 to-brand/0'\n\t\t\t\t\tinitial={{opacity: 0}}\n\t\t\t\t\tanimate={{opacity: active ? 1 : 0}}\n\t\t\t\t\ttransition={active ? {duration: 0.3, delay: 0.05} : {duration: 0.3}}\n\t\t\t\t/>\n\t\t\t\t{chart && (\n\t\t\t\t\t<ResponsiveContainer\n\t\t\t\t\t\tstyle={{position: 'absolute', bottom: -1, left: '-0.5%', zIndex: 0, borderRadius: 12}}\n\t\t\t\t\t\twidth='101%'\n\t\t\t\t\t\theight='100%'\n\t\t\t\t\t>\n\t\t\t\t\t\t<AreaChart data={chart} margin={{bottom: 0}}>\n\t\t\t\t\t\t\t<defs>\n\t\t\t\t\t\t\t\t<linearGradient id={`${title}GradientChartColor`} x1='0' y1='0' x2='0' y2='1'>\n\t\t\t\t\t\t\t\t\t<stop\n\t\t\t\t\t\t\t\t\t\toffset='5%'\n\t\t\t\t\t\t\t\t\t\tstyle={{stopColor: active ? 'hsl(var(--color-brand) / 0.3)' : 'rgba(255, 255, 255, 0.05)'}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<stop\n\t\t\t\t\t\t\t\t\t\toffset='95%'\n\t\t\t\t\t\t\t\t\t\tstyle={{stopColor: active ? 'hsl(var(--color-brand) / 0)' : 'rgba(255, 255, 255, 0)'}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</linearGradient>\n\t\t\t\t\t\t\t</defs>\n\t\t\t\t\t\t\t<YAxis domain={[0, 100]} hide={true} />\n\t\t\t\t\t\t\t<XAxis hide={true} />\n\t\t\t\t\t\t\t<Area\n\t\t\t\t\t\t\t\tisAnimationActive={false}\n\t\t\t\t\t\t\t\ttype='monotone'\n\t\t\t\t\t\t\t\tdataKey='value'\n\t\t\t\t\t\t\t\tstyle={{stroke: active ? 'hsl(var(--color-brand) / 0.2)' : 'rgba(255, 255, 255, 0.05)'}}\n\t\t\t\t\t\t\t\tfillOpacity={1}\n\t\t\t\t\t\t\t\tfill={`url(#${title}GradientChartColor)`}\n\t\t\t\t\t\t\t\tlegendType='none'\n\t\t\t\t\t\t\t\tdot={false}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</AreaChart>\n\t\t\t\t\t</ResponsiveContainer>\n\t\t\t\t)}\n\t\t\t\t<Card className={`flex flex-col gap-3`}>\n\t\t\t\t\t<div className='hidden items-center justify-between gap-2 sm:flex'>\n\t\t\t\t\t\t<span className='text-[0.8rem] font-bold opacity-40'>{title || ''}</span>\n\t\t\t\t\t\t{rightChildren}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className='flex min-w-0 items-end gap-1 text-24 leading-none font-semibold -tracking-3 opacity-80'>\n\t\t\t\t\t\t<span className='min-w-0 truncate'>{value ?? LOADING_DASH}</span>\n\t\t\t\t\t\t<span className='min-w-0 flex-1 truncate text-13 font-bold opacity-[45%]'>{valueSub}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className='flex flex-col gap-2'>\n\t\t\t\t\t\t<div className='text-13 font-semibold -tracking-2 opacity-40'>{progressLabel}</div>\n\t\t\t\t\t\t<Progress value={progress * 100} variant='primary' />\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t</motion.div>\n\t\t</motion.div>\n\t)\n}\n\nfunction ErrorMessage({children}: {children?: ReactNode}) {\n\treturn (\n\t\t<div className='flex items-center gap-2 text-[#F45A5A]'>\n\t\t\t<div className='h-[5px] w-[5px] animate-pulse rounded-full bg-current ring-3 ring-[#F45A5A]/20'></div>\n\t\t\t<div className={cn('text-13 font-medium -tracking-2', 'leading-inter-trimmed')}>{children}</div>\n\t\t</div>\n\t)\n}\n\n// --\n\nfunction AppList({apps, formatValue}: {apps?: {id: string; used: number}[]; formatValue: (value: number) => string}) {\n\tconst {userAppsKeyed} = useApps()\n\n\tif (userAppsKeyed === undefined) return null\n\tif (!apps || apps.length === 0) return null\n\n\treturn (\n\t\t<div className={appListClass}>\n\t\t\t{apps?.map(({id, used}) => {\n\t\t\t\tlet icon = userAppsKeyed[id]?.icon\n\t\t\t\tlet title = userAppsKeyed[id]?.name || t('unknown-app')\n\t\t\t\tif (id === 'umbreld-system') {\n\t\t\t\t\ticon = systemAppsKeyed.UMBREL_system.icon\n\t\t\t\t\ttitle = systemAppsKeyed.UMBREL_system.name\n\t\t\t\t}\n\t\t\t\tif (id === 'umbreld-files') {\n\t\t\t\t\ticon = systemAppsKeyed.UMBREL_files.icon\n\t\t\t\t\ttitle = systemAppsKeyed.UMBREL_files.name\n\t\t\t\t}\n\t\t\t\treturn <AppListRow key={id} icon={icon} title={title} value={formatValue(used)} />\n\t\t\t})}\n\t\t</div>\n\t)\n}\n\nexport function AppListSkeleton({systemApps}: {systemApps?: Array<AppT>}) {\n\tconst {userApps} = useApps()\n\t// Show a list of user-installed and system apps\n\t// with no values\n\treturn (\n\t\t<div className={appListClass}>\n\t\t\t{[...(systemApps || []), ...(userApps || [])].map((app) => {\n\t\t\t\treturn <AppListRow key={app.id} title={app.name} icon={app.icon} value='' />\n\t\t\t})}\n\t\t</div>\n\t)\n}\n\nconst appListClass = tw`divide-y divide-white/6 overflow-hidden rounded-12 bg-white/5`\n\nfunction AppListRow({icon, title, value, disabled}: {icon?: string; title: string; value: string; disabled?: boolean}) {\n\treturn (\n\t\t<div className={cn('flex min-w-0 items-center gap-2 p-3', disabled && 'opacity-50')}>\n\t\t\t<AppIcon src={icon} size={25} className={cn('rounded-5 shadow-md', disabled && 'grayscale')} />\n\t\t\t<span className='min-w-0 flex-1 truncate text-15 font-medium -tracking-4 opacity-90'>{title}</span>\n\t\t\t<span className='text-15 font-normal -tracking-3 uppercase tabular-nums'>{value}</span>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/login.tsx",
    "content": "import {useState} from 'react'\n\nimport {PasswordInput} from '@/components/ui/input'\nimport {PinInput} from '@/components/ui/pin-input'\nimport {formGroupClass, Layout, primaryButtonProps} from '@/layouts/bare/shared'\nimport {cn} from '@/lib/utils'\nimport {useAuth} from '@/modules/auth/use-auth'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\ntype Step = 'password' | '2fa'\n\nexport default function Login() {\n\tconst [password, setPassword] = useState('')\n\tconst [step, setStep] = useState<Step>('password')\n\n\tconst {loginWithJwt} = useAuth()\n\n\tconst loginMut = trpcReact.user.login.useMutation({\n\t\tonSuccess: loginWithJwt,\n\t\tonError: (error) => {\n\t\t\tif (error.message === 'Missing 2FA code') {\n\t\t\t\tsetStep('2fa')\n\t\t\t} else {\n\t\t\t\tsetPassword('')\n\t\t\t}\n\t\t},\n\t})\n\n\tconst handleSubmitPassword = async (e: React.FormEvent<HTMLFormElement>) => {\n\t\te.preventDefault()\n\t\tloginMut.mutate({password})\n\t}\n\n\tconst handleSubmit2fa = async (totpToken: string) => {\n\t\tconst res = await loginMut.mutateAsync({password, totpToken})\n\t\treturn !!res\n\t}\n\n\tswitch (step) {\n\t\tcase 'password': {\n\t\t\treturn (\n\t\t\t\t<Layout title={t('login.title')} subTitle={t('login.subtitle')}>\n\t\t\t\t\t<form className='flex w-full flex-col items-center gap-5 px-4 md:px-0' onSubmit={handleSubmitPassword}>\n\t\t\t\t\t\t<div className={cn(formGroupClass, 'max-w-[280px]')}>\n\t\t\t\t\t\t\t<PasswordInput\n\t\t\t\t\t\t\t\tlabel={t('login.password-label')}\n\t\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\t\tvalue={password}\n\t\t\t\t\t\t\t\tonValueChange={setPassword}\n\t\t\t\t\t\t\t\terror={loginMut.error?.message}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<button type='submit' {...primaryButtonProps}>\n\t\t\t\t\t\t\t{t('login.password.submit')}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</form>\n\t\t\t\t</Layout>\n\t\t\t)\n\t\t}\n\t\tcase '2fa': {\n\t\t\treturn (\n\t\t\t\t<Layout title={t('login-2fa.title')} subTitle={t('login-2fa.subtitle')}>\n\t\t\t\t\t<form className='flex w-full flex-col items-center gap-5 px-4 md:px-0' onSubmit={handleSubmitPassword}>\n\t\t\t\t\t\t<PinInput autoFocus length={6} onCodeCheck={handleSubmit2fa} />\n\t\t\t\t\t</form>\n\t\t\t\t</Layout>\n\t\t\t)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/routes/not-found.tsx",
    "content": "import {useState} from 'react'\nimport {useNavigate} from 'react-router-dom'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {Dock, DockBottomPositioner} from '@/modules/desktop/dock'\nimport {AppsProvider} from '@/providers/apps'\nimport {AvailableAppsProvider} from '@/providers/available-apps'\nimport {Wallpaper} from '@/providers/wallpaper'\nimport {t} from '@/utils/i18n'\n\nexport function NotFound() {\n\tconst navigate = useNavigate()\n\tconst [open, setOpen] = useState(true)\n\n\treturn (\n\t\t<>\n\t\t\t<Wallpaper />\n\t\t\t<AvailableAppsProvider>\n\t\t\t\t<AppsProvider>\n\t\t\t\t\t<DockBottomPositioner>\n\t\t\t\t\t\t<Dock />\n\t\t\t\t\t</DockBottomPositioner>\n\t\t\t\t</AppsProvider>\n\t\t\t</AvailableAppsProvider>\n\t\t\t<AlertDialog open={open} onOpenChange={setOpen}>\n\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t\t<AlertDialogTitle>{t('not-found-404')}</AlertDialogTitle>\n\t\t\t\t\t\t<AlertDialogDescription></AlertDialogDescription>\n\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t\t<AlertDialogAction onClick={() => navigate(-1)}>{t('not-found-404.back')}</AlertDialogAction>\n\t\t\t\t\t\t<AlertDialogCancel onClick={() => navigate('/')}>{t('not-found-404.home')}</AlertDialogCancel>\n\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t</AlertDialogContent>\n\t\t\t</AlertDialog>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/notifications.tsx",
    "content": "import {motion} from 'motion/react'\nimport {useEffect, useRef, useState} from 'react'\nimport {RiErrorWarningFill} from 'react-icons/ri'\nimport {useNavigate} from 'react-router-dom'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {Button} from '@/components/ui/button'\nimport {BackupDeviceIcon} from '@/features/backups/components/backup-device-icon'\nimport {getDeviceNameFromPath} from '@/features/backups/utils/backup-location-helpers'\nimport {useNotifications} from '@/hooks/use-notifications'\nimport {cn} from '@/lib/utils'\nimport {trpcReact} from '@/trpc/trpc'\nimport {useLinkToDialog} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nfunction NotificationContent({children}: {children: string}) {\n\tconst contentRef = useRef<HTMLDivElement>(null)\n\tconst [isExpanded, setIsExpanded] = useState(false)\n\tconst [showReadMore, setShowReadMore] = useState(false)\n\n\tuseEffect(() => {\n\t\tif (!contentRef.current) return\n\t\tconst el = contentRef.current\n\t\tconst WIGGLE_ROOM = 20\n\t\tsetShowReadMore(el.scrollHeight > el.clientHeight + WIGGLE_ROOM)\n\t}, [children])\n\n\treturn (\n\t\t<div className='flex flex-col gap-2'>\n\t\t\t<motion.div\n\t\t\t\tref={contentRef}\n\t\t\t\tinitial={false}\n\t\t\t\tanimate={{\n\t\t\t\t\theight: isExpanded ? 'auto' : '3em',\n\t\t\t\t}}\n\t\t\t\ttransition={{\n\t\t\t\t\tduration: 0.4,\n\t\t\t\t\tease: [0.32, 0.72, 0, 1],\n\t\t\t\t}}\n\t\t\t\tclassName='overflow-hidden'\n\t\t\t\tstyle={{\n\t\t\t\t\tWebkitMaskImage:\n\t\t\t\t\t\tisExpanded || !showReadMore ? undefined : 'linear-gradient(to bottom, black, black, transparent)',\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t<div className={cn('text-sm')}>\n\t\t\t\t\t{children.split('\\n').map((paragraph, index) => (\n\t\t\t\t\t\t<AlertDialogDescription key={index} className={`${index > 0 ? 'mt-4' : ''} text-white/70`}>\n\t\t\t\t\t\t\t{paragraph}\n\t\t\t\t\t\t</AlertDialogDescription>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t</motion.div>\n\t\t\t{showReadMore && (\n\t\t\t\t<button\n\t\t\t\t\tonClick={() => setIsExpanded(true)}\n\t\t\t\t\tclassName='self-center text-xs font-medium text-brand transition-opacity duration-300 hover:opacity-80'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\topacity: isExpanded ? 0 : 1,\n\t\t\t\t\t\tpointerEvents: isExpanded ? 'none' : 'auto',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{t('read-more')}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n\ntype NotificationContent = {\n\ttitle: string\n\tdescription: string\n\ticon?: React.ReactNode\n\taction?: React.ReactNode\n}\n\n/**\n * Parses backup notification ID to extract repository ID if present.\n * Format: \"backups-failing\" (legacy) or \"backups-failing:<repo-id>\" (new)\n * TODO: remove support for legacy \"backups-failing\" notification format\n * that was used in umbrelOS 1.5 beta 1 and beta 2 (with no repo ID).\n */\nfunction parseBackupNotificationId(notification: string): {repoId: string | null} {\n\tif (notification.startsWith('backups-failing:') && notification.includes(':')) {\n\t\treturn {repoId: notification.split(':')[1]}\n\t}\n\treturn {repoId: null}\n}\n\n/**\n * Handles backup-failing notifications by fetching repo details\n * and generating appropriate content with device-specific information.\n */\nfunction getBackupFailingContent(\n\tnotification: string,\n\tbackupRepositoriesQuery: {data?: Array<{id: string; path: string}>},\n\tonGoToBackups: () => void,\n\tonClearNotification: () => void,\n): NotificationContent {\n\tconst {repoId} = parseBackupNotificationId(notification)\n\n\t// Find repository details if we have a repo ID\n\tconst repository = repoId ? backupRepositoriesQuery.data?.find((r) => r.id === repoId) : null\n\n\t// Get device name from path if available\n\tconst deviceName = repository?.path ? getDeviceNameFromPath(repository.path) : null\n\n\tconst actionButtons = (\n\t\t<>\n\t\t\t<Button variant='default' size='dialog' onClick={onClearNotification} tabIndex={-1}>\n\t\t\t\t{t('ok')}\n\t\t\t</Button>\n\t\t\t<AlertDialogAction variant='primary' onClick={onGoToBackups} tabIndex={0}>\n\t\t\t\t{t('notifications.backups-failing.go-to-backups')}\n\t\t\t</AlertDialogAction>\n\t\t</>\n\t)\n\n\t// Use specific content when we have repository details\n\tif (repository && deviceName) {\n\t\treturn {\n\t\t\ttitle: t('notifications.backups-failing.title'),\n\t\t\tdescription: t('notifications.backups-failing-location.description', {location: deviceName}),\n\t\t\ticon: (\n\t\t\t\t<div className='relative'>\n\t\t\t\t\t<BackupDeviceIcon path={repository.path} className='size-14 opacity-90' />\n\t\t\t\t\t<div className='absolute -top-2 -right-2 flex size-7 items-center justify-center rounded-full bg-[#FF9500]'>\n\t\t\t\t\t\t<RiErrorWarningFill className='size-5 text-black' />\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t),\n\t\t\taction: actionButtons,\n\t\t}\n\t}\n\n\t// Fall back to generic message for legacy format or when repo not found\n\treturn {\n\t\ttitle: t('notifications.backups-failing.title'),\n\t\tdescription: t('notifications.backups-failing.description'),\n\t\taction: actionButtons,\n\t}\n}\n\n/**\n * Handles \"Back That Mac Up\" migration notification.\n */\nfunction getMigratedBackThatMacUpContent(): NotificationContent {\n\treturn {\n\t\ttitle: 'Back That Mac Up - Changes Required',\n\t\tdescription:\n\t\t\t'umbrelOS 1.4 introduces Shared Folders over your network, which can also serve as a Time Machine backup location.\\nYour current macOS backups using the Back That Mac Up app will no longer work.\\nYou can uninstall Back That Mac Up and instead create a new Shared Folder using Files for Time Machine.\\nIf you’d still prefer to continue using the Back That Mac Up app:\\n1. Go to Time Machine settings.\\n2. Remove the backup destination.\\n3. Go to Finder.\\n4. Press CMD+K and add smb://umbrel.local:1445.\\n5. Enter \"timemachine\" (without quotes) as the username and password.\\n6. Go back to Time Machine settings.\\n7. Add a new location.\\n8. Select Umbrel.\\nNote: If you previously used encryption, you will need to enter your encryption password. Time Machine will then resume backups with all your previous data intact.',\n\t}\n}\n\n/**\n * Fallback handler for unknown notification types.\n */\nfunction getDefaultNotificationContent(notification: string): NotificationContent {\n\treturn {\n\t\ttitle: 'Notification',\n\t\tdescription: notification,\n\t}\n}\n\nexport function Notifications() {\n\t// Hooks and state\n\tconst {notifications, clearNotification} = useNotifications()\n\tconst navigate = useNavigate()\n\tconst linkToDialog = useLinkToDialog()\n\n\t// Determine if we need to query backup repositories\n\t// TODO: remove support for legacy \"backups-failing\" notification format\n\t// that was used in umbrelOS 1.5 beta 1 and beta 2 (with no repo ID)\n\tconst hasBackupNotification = notifications.some((n) => n === 'backups-failing' || n.startsWith('backups-failing:'))\n\n\t// Query backup repositories (only when needed)\n\tconst backupRepositoriesQuery = trpcReact.backups.getRepositories.useQuery(undefined, {\n\t\tenabled: hasBackupNotification,\n\t})\n\n\t// Separate umbrelos-updated notification from others\n\tconst standardNotifications = notifications.filter((n) => n !== 'umbrelos-updated')\n\tconst showWhatsNew = notifications.includes('umbrelos-updated')\n\n\t// Navigate to whats-new dialog when the umbrelos-updated notification is present\n\t// Clear the notification immediately to prevent re-navigation\n\t// TODO: Re-enable when whats-new content is updated\n\tuseEffect(() => {\n\t\tif (showWhatsNew) {\n\t\t\tclearNotification('umbrelos-updated')\n\t\t\t// navigate(linkToDialog('whats-new'))\n\t\t}\n\t}, [showWhatsNew, navigate, linkToDialog, clearNotification])\n\n\t// Get notification content based on notification type\n\tconst getNotificationContent = (notification: string): NotificationContent => {\n\t\t// Handle backup-failing notifications (both legacy and new format with repo ID)\n\t\tif (notification === 'backups-failing' || notification.startsWith('backups-failing:')) {\n\t\t\tconst onGoToBackups = () => {\n\t\t\t\tclearNotification(notification)\n\t\t\t\tnavigate('/settings/backups/configure')\n\t\t\t}\n\t\t\tconst onClearNotification = () => {\n\t\t\t\tclearNotification(notification)\n\t\t\t}\n\t\t\treturn getBackupFailingContent(notification, backupRepositoriesQuery, onGoToBackups, onClearNotification)\n\t\t}\n\n\t\t// Handle specific notification types\n\t\tif (notification === 'migrated-back-that-mac-up') {\n\t\t\treturn getMigratedBackThatMacUpContent()\n\t\t}\n\n\t\t// Default fallback for unknown notifications\n\t\treturn getDefaultNotificationContent(notification)\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t{standardNotifications.map((notification) => {\n\t\t\t\tconst content = getNotificationContent(notification)\n\t\t\t\treturn (\n\t\t\t\t\t<AlertDialog key={notification} open={true}>\n\t\t\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t\t\t\t{content.icon && <div className='flex items-center justify-center py-2'>{content.icon}</div>}\n\t\t\t\t\t\t\t\t<AlertDialogTitle>{content.title}</AlertDialogTitle>\n\t\t\t\t\t\t\t\t<NotificationContent>{content.description}</NotificationContent>\n\t\t\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t\t\t\t{content.action || (\n\t\t\t\t\t\t\t\t\t<AlertDialogAction variant='primary' onClick={() => clearNotification(notification)} tabIndex={0}>\n\t\t\t\t\t\t\t\t\t\t{t('ok')}\n\t\t\t\t\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t\t\t</AlertDialogContent>\n\t\t\t\t\t</AlertDialog>\n\t\t\t\t)\n\t\t\t})}\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/onboarding/account-created.tsx",
    "content": "import {useEffect, useRef} from 'react'\nimport {Trans} from 'react-i18next/TransWithoutContext'\nimport {Link} from 'react-router-dom'\n\nimport {links} from '@/constants/links'\nimport {footerLinkClass, Layout, primaryButtonProps} from '@/layouts/bare/shared'\nimport {useOnboardingDevice} from '@/routes/onboarding/use-onboarding-device'\nimport {trpcReact} from '@/trpc/trpc'\nimport {linkClass} from '@/utils/element-classes'\nimport {t} from '@/utils/i18n'\n\nexport default function AccountCreated() {\n\tconst continueLinkRef = useRef<HTMLAnchorElement>(null)\n\tconst device = useOnboardingDevice()\n\n\tconst getQuery = trpcReact.user.get.useQuery()\n\n\t// Grab the first name\n\tconst name = getQuery.data?.name?.split(' ')[0]\n\n\tuseEffect(() => {\n\t\tcontinueLinkRef.current?.focus()\n\t}, [])\n\n\tif (!name) {\n\t\treturn null\n\t}\n\n\treturn (\n\t\t<Layout\n\t\t\ttitle={t('onboarding.account-created.youre-all-set-name', {name})}\n\t\t\tsubTitle={\n\t\t\t\t<Trans\n\t\t\t\t\ti18nKey='onboarding.account-created.by-clicking-button-you-agree'\n\t\t\t\t\tcomponents={{\n\t\t\t\t\t\tlinked: <Link to={links.legal.tos} className={linkClass} target='_blank' />,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t}\n\t\t\tsubTitleMaxWidth={630}\n\t\t\tsubTitleClassName='text-white/50'\n\t\t\tshowLogo={!device.showDevice}\n\t\t\tfooter={\n\t\t\t\t<div className='flex flex-col items-center gap-3'>\n\t\t\t\t\t<Link to={links.support} target='_blank' className={footerLinkClass}>\n\t\t\t\t\t\t{t('onboarding.contact-support')}\n\t\t\t\t\t</Link>\n\t\t\t\t</div>\n\t\t\t}\n\t\t>\n\t\t\t{device.showDevice && device.image && (\n\t\t\t\t<>\n\t\t\t\t\t<img src={device.image} alt='Umbrel device' className={device.imageClassName} />\n\t\t\t\t\t<p className='-mt-2 text-[20px] font-semibold text-white/85'>{device.name}</p>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t<Link\n\t\t\t\tdata-testid='to-desktop'\n\t\t\t\tto='/'\n\t\t\t\tviewTransition\n\t\t\t\tref={continueLinkRef}\n\t\t\t\tclassName={`mt-4 ${primaryButtonProps.className}`}\n\t\t\t\tstyle={primaryButtonProps.style}\n\t\t\t>\n\t\t\t\t{t('onboarding.launch-umbrelos')}\n\t\t\t</Link>\n\t\t</Layout>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/onboarding/create-account.tsx",
    "content": "import {useState} from 'react'\nimport {useNavigate} from 'react-router-dom'\n\nimport {AnimatedInputError, Input, PasswordInput} from '@/components/ui/input'\nimport {useDeviceInfo} from '@/hooks/use-device-info'\nimport {useLanguage} from '@/hooks/use-language'\nimport {formGroupClass, Layout, primaryButtonProps} from '@/layouts/bare/shared'\nimport {useAuth} from '@/modules/auth/use-auth'\nimport {OnboardingAction, OnboardingFooter} from '@/routes/onboarding/onboarding-footer'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\n// Credentials for Umbrel Pro RAID flow. Passed via React Router's location.state\n// through the RAID setup pages. Actual user.register call happens in setup.tsx\n// after RAID configuration. location.state survives page refresh but is lost on\n// direct URL navigation or new tab.\nexport type AccountCredentials = {\n\tname: string\n\tpassword: string\n\tlanguage: string\n}\n\nexport default function CreateAccount() {\n\tconst title = t('onboarding.create-account')\n\tconst navigate = useNavigate()\n\tconst auth = useAuth()\n\tconst [language] = useLanguage()\n\tconst {data: deviceInfo} = useDeviceInfo()\n\n\tconst [name, setName] = useState('')\n\tconst [password, setPassword] = useState('')\n\tconst [confirmPassword, setConfirmPassword] = useState('')\n\tconst [localError, setLocalError] = useState('')\n\tconst [isNavigating, setIsNavigating] = useState(false)\n\n\tconst isPro = deviceInfo?.umbrelHostEnvironment === 'umbrel-pro'\n\n\tconst loginMut = trpcReact.user.login.useMutation({\n\t\tonSuccess: async (jwt) => {\n\t\t\tsetIsNavigating(true)\n\t\t\tauth.signUpWithJwt(jwt, '/onboarding/account-created')\n\t\t},\n\t})\n\n\tconst registerMut = trpcReact.user.register.useMutation({\n\t\tonSuccess: async () => loginMut.mutate({password, totpToken: ''}),\n\t})\n\n\tconst onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n\t\te.preventDefault()\n\n\t\t// Reset errors\n\t\tregisterMut.reset()\n\t\tsetLocalError('')\n\n\t\tif (!name) {\n\t\t\tsetLocalError(t('onboarding.create-account.failed.name-required'))\n\t\t\treturn\n\t\t}\n\n\t\tif (password !== confirmPassword) {\n\t\t\tsetLocalError(t('onboarding.create-account.failed.passwords-dont-match'))\n\t\t\treturn\n\t\t}\n\n\t\tif (password.length < 6) {\n\t\t\tsetLocalError(t('change-password.failed.min-length', {characters: 6}))\n\t\t\treturn\n\t\t}\n\n\t\tif (isPro) {\n\t\t\t// For Umbrel Pro we navigate to RAID setup\n\t\t\tsetIsNavigating(true)\n\t\t\tconst credentials: AccountCredentials = {name, password, language}\n\n\t\t\t// Pass credentials to RAID setup page\n\t\t\tnavigate('/onboarding/raid', {state: {credentials}})\n\t\t} else {\n\t\t\t// For non-Pro devices we do standard registration flow\n\t\t\tregisterMut.mutate({name, password, language})\n\t\t}\n\t}\n\n\tconst remoteFormError = !registerMut.error?.data?.zodError && registerMut.error?.message\n\tconst formError = localError || remoteFormError\n\tconst isLoading = registerMut.isPending || loginMut.isPending || isNavigating\n\n\treturn (\n\t\t<Layout\n\t\t\ttitle={title}\n\t\t\tsubTitle={t('onboarding.create-account.subtitle')}\n\t\t\tsubTitleMaxWidth={630}\n\t\t\tfooter={<OnboardingFooter action={OnboardingAction.RESTORE} />}\n\t\t>\n\t\t\t<form onSubmit={onSubmit} className='w-full'>\n\t\t\t\t<fieldset disabled={isLoading} className='flex flex-col items-center gap-5'>\n\t\t\t\t\t<div className={formGroupClass}>\n\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\tplaceholder={t('onboarding.create-account.name.input-placeholder')}\n\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\tvalue={name}\n\t\t\t\t\t\t\tonValueChange={setName}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<PasswordInput\n\t\t\t\t\t\t\tlabel={t('onboarding.create-account.password.input-label')}\n\t\t\t\t\t\t\tvalue={password}\n\t\t\t\t\t\t\tonValueChange={setPassword}\n\t\t\t\t\t\t\terror={registerMut.error?.data?.zodError?.fieldErrors['password']?.join('. ')}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<PasswordInput\n\t\t\t\t\t\t\tlabel={t('onboarding.create-account.confirm-password.input-label')}\n\t\t\t\t\t\t\tvalue={confirmPassword}\n\t\t\t\t\t\t\tonValueChange={setConfirmPassword}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div className='-my-2.5'>\n\t\t\t\t\t\t<AnimatedInputError>{formError}</AnimatedInputError>\n\t\t\t\t\t</div>\n\t\t\t\t\t<button type='submit' {...primaryButtonProps}>\n\t\t\t\t\t\t{isLoading ? t('onboarding.create-account.submitting') : t('onboarding.create-account.submit')}\n\t\t\t\t\t</button>\n\t\t\t\t</fieldset>\n\t\t\t</form>\n\t\t</Layout>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/onboarding/index.tsx",
    "content": "import {useEffect, useRef} from 'react'\nimport {Link} from 'react-router-dom'\n\nimport {useLanguage} from '@/hooks/use-language'\nimport {Layout, primaryButtonProps} from '@/layouts/bare/shared'\nimport {OnboardingAction, OnboardingFooter} from '@/routes/onboarding/onboarding-footer'\nimport {useOnboardingDevice} from '@/routes/onboarding/use-onboarding-device'\nimport {t} from '@/utils/i18n'\nimport {supportedLanguageCodes} from '@/utils/language'\n\n// Attempt to auto-select a suitable language from the user's browser preferences\nfunction useAutoDetectLanguage() {\n\tconst [, setLang] = useLanguage()\n\n\tuseEffect(() => {\n\t\t// Only run once\n\t\tif (sessionStorage.getItem('temporary-language')) {\n\t\t\treturn\n\t\t}\n\n\t\t// Get the browser language codes (eg. ['en-US', 'jp'])\n\t\tconst {languages: browserLanguageCodes} = navigator\n\t\tif (!Array.isArray(browserLanguageCodes)) return\n\n\t\t// Try to find a supported language code\n\t\tfor (const languageCode of browserLanguageCodes) {\n\t\t\tconst baseCode = languageCode.split('-')[0] // eg. 'en'\n\n\t\t\t// If we support the language, set it\n\t\t\tif ((supportedLanguageCodes as readonly string[]).includes(baseCode)) {\n\t\t\t\tsetLang(baseCode as any)\n\t\t\t\tsessionStorage.setItem('temporary-language', baseCode)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}, [])\n}\n\nexport default function OnboardingStart() {\n\tconst title = t('onboarding.start.title')\n\tconst continueLinkRef = useRef<HTMLAnchorElement>(null)\n\tconst device = useOnboardingDevice()\n\n\t// Auto detect browser language once to set the default language\n\tuseAutoDetectLanguage()\n\n\tuseEffect(() => {\n\t\tcontinueLinkRef.current?.focus()\n\t}, [])\n\n\treturn (\n\t\t<Layout\n\t\t\ttitle={title}\n\t\t\tsubTitle={t('onboarding.start.subtitle')}\n\t\t\tsubTitleMaxWidth={500}\n\t\t\tfooter={<OnboardingFooter action={OnboardingAction.RESTORE} />}\n\t\t\tanimate\n\t\t\tshowLogo={!device.showDevice}\n\t\t>\n\t\t\t{device.showDevice && device.image && (\n\t\t\t\t<>\n\t\t\t\t\t<img src={device.image} alt='Umbrel device' className={device.imageClassName} />\n\t\t\t\t\t<p className='-mt-4 text-[13px] font-medium text-white/30'>{device.name}</p>\n\t\t\t\t</>\n\t\t\t)}\n\n\t\t\t<Link to='/onboarding/create-account' viewTransition ref={continueLinkRef} {...primaryButtonProps}>\n\t\t\t\t{t('onboarding.start.continue')}\n\t\t\t</Link>\n\t\t</Layout>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/onboarding/onboarding-footer.tsx",
    "content": "import {Globe} from 'lucide-react'\nimport {TbHistory, TbMessageCircle, TbUser} from 'react-icons/tb'\nimport {Link} from 'react-router-dom'\n\nimport {ChevronDown} from '@/components/chevron-down'\nimport {DropdownMenu, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'\nimport {IconButton} from '@/components/ui/icon-button'\nimport {links} from '@/constants/links'\nimport {useLanguage} from '@/hooks/use-language'\nimport {LanguageDropdownContent} from '@/routes/settings/_components/language-dropdown'\nimport {t} from '@/utils/i18n'\nimport {languages} from '@/utils/language'\n\nexport enum OnboardingAction {\n\tCREATE_ACCOUNT = 'create-account',\n\tRESTORE = 'restore',\n}\n\ninterface OnboardingFooterProps {\n\taction: OnboardingAction\n}\n\n// Custom footer button class for onboarding - override default border from buttonVariants\nconst footerButtonClass = 'bg-white/[0.06] border-0'\n\nexport function OnboardingFooter({action}: OnboardingFooterProps) {\n\tconst isCreateAccount = action === OnboardingAction.CREATE_ACCOUNT\n\tconst route = isCreateAccount ? '/onboarding/create-account' : '/onboarding/restore'\n\tconst Icon = isCreateAccount ? TbUser : TbHistory\n\n\treturn (\n\t\t<div className='flex flex-row flex-wrap items-center justify-center gap-3'>\n\t\t\t<Link to={route} viewTransition>\n\t\t\t\t{/* Small screens: with short text */}\n\t\t\t\t<IconButton icon={Icon} size='default' className={`sm:hidden ${footerButtonClass}`}>\n\t\t\t\t\t{/* Using explicit conditionals instead of dynamic keys so GitHub Action for translations can detect translation keys */}\n\t\t\t\t\t{isCreateAccount ? t('onboarding.create-instead-short') : t('onboarding.restore-short')}\n\t\t\t\t</IconButton>\n\t\t\t\t{/* Larger screens: with full text */}\n\t\t\t\t<IconButton icon={Icon} size='default' className={`hidden sm:flex ${footerButtonClass}`}>\n\t\t\t\t\t{/* Using explicit conditionals instead of dynamic keys so GitHub Action for translations can detect translation keys */}\n\t\t\t\t\t{isCreateAccount ? t('onboarding.create-instead-long') : t('onboarding.restore-long')}\n\t\t\t\t</IconButton>\n\t\t\t</Link>\n\t\t\t{/* TODO: consider adding drawer on mobile */}\n\t\t\t<DropdownMenu>\n\t\t\t\t<OnboardingLanguageDropdownTrigger />\n\t\t\t\t<LanguageDropdownContent />\n\t\t\t</DropdownMenu>\n\t\t\t<Link to={links.support} target='_blank'>\n\t\t\t\t<IconButton icon={TbMessageCircle} size='default' className={footerButtonClass}>\n\t\t\t\t\t{t('onboarding.contact-support')}\n\t\t\t\t</IconButton>\n\t\t\t</Link>\n\t\t</div>\n\t)\n}\n\n// Custom language dropdown trigger just for onboarding footer with custom styling\nfunction OnboardingLanguageDropdownTrigger() {\n\tconst [activeCode] = useLanguage()\n\n\treturn (\n\t\t<DropdownMenuTrigger asChild>\n\t\t\t<IconButton icon={Globe} className={footerButtonClass}>\n\t\t\t\t{languages.find(({code}) => code === activeCode)?.name}\n\t\t\t\t<ChevronDown />\n\t\t\t</IconButton>\n\t\t</DropdownMenuTrigger>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/onboarding/raid/index.tsx",
    "content": "import {motion} from 'motion/react'\nimport {useEffect, useState} from 'react'\nimport {useLocation, useNavigate} from 'react-router-dom'\n\nimport {AccountCredentials} from '@/routes/onboarding/create-account'\nimport {t} from '@/utils/i18n'\n\nimport {RaidError} from './raid-error'\nimport {useDetectStorageDevices} from './use-raid-setup'\n\n// Minimum time to show the scanning animation (in ms)\nconst MIN_SCAN_DISPLAY_TIME = 3000\n\n// Entry point for RAID onboarding flow. Detects SSDs and routes to /raid/setup if found.\n// Shows inline error states for detection errors or no SSDs found.\nexport default function Raid() {\n\tconst navigate = useNavigate()\n\tconst location = useLocation()\n\tconst {devices, isDetecting, error} = useDetectStorageDevices()\n\n\t// Get credentials passed from create-account page via React Router's location.state\n\tconst credentials = location.state?.credentials as AccountCredentials | undefined\n\n\t// Track minimum display time and detection complete state\n\tconst [minTimeElapsed, setMinTimeElapsed] = useState(false)\n\tconst [detectionComplete, setDetectionComplete] = useState(false)\n\n\t// Redirect to create-account if credentials are missing (e.g., direct URL navigation or new tab)\n\tuseEffect(() => {\n\t\tif (!credentials) {\n\t\t\tnavigate('/onboarding/create-account', {replace: true})\n\t\t}\n\t}, [credentials, navigate])\n\n\t// Start minimum display timer\n\tuseEffect(() => {\n\t\tif (!credentials) return\n\n\t\tconst timer = setTimeout(() => {\n\t\t\tsetMinTimeElapsed(true)\n\t\t}, MIN_SCAN_DISPLAY_TIME)\n\n\t\treturn () => clearTimeout(timer)\n\t}, [credentials])\n\n\t// Mark detection as complete once min time elapsed and not detecting\n\tuseEffect(() => {\n\t\tif (minTimeElapsed && !isDetecting) {\n\t\t\tsetDetectionComplete(true)\n\t\t}\n\t}, [minTimeElapsed, isDetecting])\n\n\t// Navigate to setup if SSDs found (after detection complete)\n\t// Note: We only pass credentials, not devices. Setup page fetches its own fresh device list\n\t// to avoid stale data issues after hardware changes (e.g., user shuts down to change an SSD, boots up, refreshes current page)\n\tuseEffect(() => {\n\t\tif (!credentials || error) return\n\t\tif (detectionComplete && devices.length > 0) {\n\t\t\tnavigate('/onboarding/raid/setup', {state: {credentials}})\n\t\t}\n\t}, [detectionComplete, devices.length, credentials, navigate, error])\n\n\t// Don't render while redirecting due to missing credentials\n\tif (!credentials) return null\n\n\t// Show error state if detection failed\n\tif (error) {\n\t\treturn <RaidError title={error} instructions={t('onboarding.raid.error.detection-instructions')} />\n\t}\n\n\t// Show no SSDs state if detection complete but no devices found\n\tif (detectionComplete && devices.length === 0) {\n\t\treturn (\n\t\t\t<RaidError\n\t\t\t\ttitle={t('onboarding.raid.error.no-ssds-detected')}\n\t\t\t\tinstructions={t('onboarding.raid.error.no-ssds-instructions')}\n\t\t\t\timage={{\n\t\t\t\t\tsrc: '/assets/onboarding/no-ssd-found.webp',\n\t\t\t\t\talt: t('onboarding.raid.no-ssds-alt'),\n\t\t\t\t}}\n\t\t\t/>\n\t\t)\n\t}\n\n\treturn (\n\t\t<div className='flex flex-1 flex-col items-center justify-center gap-10'>\n\t\t\t{/* Image with scanning line overlay - the animated line serves as the loading indicator */}\n\t\t\t<div className='relative flex items-center justify-center'>\n\t\t\t\t<img\n\t\t\t\t\tsrc='/assets/onboarding/ssd-scan.webp'\n\t\t\t\t\talt={t('onboarding.raid.scanning-alt')}\n\t\t\t\t\tclassName='aspect-square w-[300px]'\n\t\t\t\t/>\n\t\t\t\t<motion.div\n\t\t\t\t\tclassName='pointer-events-none absolute w-[340px]'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\theight: '2px',\n\t\t\t\t\t\tbackground: 'white',\n\t\t\t\t\t\tboxShadow: '0 0 10px 2px rgba(255, 255, 255, 0.6), 0 0 20px 4px rgba(255, 255, 255, 0.3)',\n\t\t\t\t\t}}\n\t\t\t\t\tanimate={{\n\t\t\t\t\t\ty: ['-120px', '120px', '-120px'],\n\t\t\t\t\t}}\n\t\t\t\t\ttransition={{\n\t\t\t\t\t\tduration: 5,\n\t\t\t\t\t\trepeat: Infinity,\n\t\t\t\t\t\tease: 'easeInOut',\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</div>\n\n\t\t\t<span\n\t\t\t\tclassName='text-[15px] text-white/85'\n\t\t\t\tstyle={{textShadow: '0 0 8px rgba(255, 255, 255, 0.2), 0 0 16px rgba(255, 255, 255, 0.15)'}}\n\t\t\t>\n\t\t\t\t{t('onboarding.raid.scanning')}\n\t\t\t</span>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/onboarding/raid/raid-error.tsx",
    "content": "import {TbAlertTriangleFilled} from 'react-icons/tb'\n\nimport {Button} from '@/components/ui/button'\nimport {toast} from '@/components/ui/toast'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\ntype RaidErrorProps = {\n\ttitle: string\n\tinstructions: string\n\timage?: {\n\t\tsrc: string\n\t\talt: string\n\t}\n}\n\n// Error component for both device detection errors and no SSDs found.\nexport function RaidError({title, instructions, image}: RaidErrorProps) {\n\tconst shutdownMut = trpcReact.system.shutdown.useMutation({\n\t\tonError: (error) => {\n\t\t\ttoast.error(t('shut-down.failed', {message: error.message}))\n\t\t},\n\t})\n\n\tconst handleShutdown = () => {\n\t\tshutdownMut.mutate()\n\t}\n\n\treturn (\n\t\t<div className={`flex flex-1 flex-col items-center justify-center ${image ? 'md:justify-between' : ''}`}>\n\t\t\t{/* Content */}\n\t\t\t<div className={`flex flex-col items-center gap-4 px-4 ${image ? 'md:pt-8' : ''}`}>\n\t\t\t\t<TbAlertTriangleFilled className='size-[22px] text-[#F5A623]' />\n\t\t\t\t<h1\n\t\t\t\t\tclassName='-mt-1 text-[18px] font-bold text-white/85 md:text-[20px]'\n\t\t\t\t\tstyle={{textShadow: '0 0 8px rgba(255, 255, 255, 0.2), 0 0 16px rgba(255, 255, 255, 0.15)'}}\n\t\t\t\t>\n\t\t\t\t\t{title}\n\t\t\t\t</h1>\n\t\t\t\t<p className='-mt-2 max-w-[300px] text-center text-[14px] text-white/70 md:text-[15px]'>{instructions}</p>\n\t\t\t\t<Button\n\t\t\t\t\tvariant='destructive'\n\t\t\t\t\tsize='lg'\n\t\t\t\t\tonClick={handleShutdown}\n\t\t\t\t\tdisabled={shutdownMut.isPending}\n\t\t\t\t\tstyle={{boxShadow: '0px 2px 4px 0px #FFFFFF3D inset'}}\n\t\t\t\t>\n\t\t\t\t\t{shutdownMut.isPending ? t('shut-down.shutting-down') : t('shut-down')}\n\t\t\t\t</Button>\n\t\t\t</div>\n\n\t\t\t{/* Bottom image (optional, hidden on mobile) */}\n\t\t\t{image && (\n\t\t\t\t<img\n\t\t\t\t\tsrc={image.src}\n\t\t\t\t\talt={image.alt}\n\t\t\t\t\tdraggable={false}\n\t\t\t\t\tclassName='hidden w-full max-w-[800px] translate-x-14 object-contain object-bottom md:-mb-6 md:block md:translate-x-20'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\taspectRatio: '1693 / 738',\n\t\t\t\t\t\tmaskImage: 'linear-gradient(to right, black 90%, transparent 100%)',\n\t\t\t\t\t\tWebkitMaskImage: 'linear-gradient(to right, black 90%, transparent 100%)',\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/onboarding/raid/setup.tsx",
    "content": "// RAID Setup Page - kept intentionally large because it's a cohesive page flow\n\nimport {useEffect, useState} from 'react'\nimport {Trans} from 'react-i18next/TransWithoutContext'\nimport {IoShieldHalf} from 'react-icons/io5'\nimport {TbActivityHeartbeat, TbAlertTriangle, TbAlertTriangleFilled, TbCircleCheckFilled} from 'react-icons/tb'\nimport {Link, useLocation, useNavigate} from 'react-router-dom'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {Switch} from '@/components/ui/switch'\nimport {links} from '@/constants/links'\nimport {footerLinkClass, Layout, primaryButtonProps} from '@/layouts/bare/shared'\nimport {useAuth} from '@/modules/auth/use-auth'\nimport {Progress} from '@/modules/bare/progress'\nimport {useGlobalSystemState} from '@/providers/global-system-state/index'\nimport {AccountCredentials} from '@/routes/onboarding/create-account'\nimport {trpcReact} from '@/trpc/trpc'\nimport {linkClass} from '@/utils/element-classes'\nimport {t} from '@/utils/i18n'\n\nimport {SsdHealthDialog, useSsdHealthDialog} from './ssd-health-dialog'\nimport {SsdSlot, SsdTray} from './ssd-tray'\nimport {\n\tFAILSAFE_COLOR,\n\tformatSize,\n\tgetDeviceHealth,\n\tRaidType,\n\tStorageDevice,\n\tuseDetectStorageDevices,\n\tWASTED_COLOR,\n} from './use-raid-setup'\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\n// Get warning message for a device (generic message - details shown in health dialog)\nfunction getHealthWarningMessage(device: StorageDevice): string | null {\n\tif (getDeviceHealth(device).hasWarning) return t('onboarding.raid.health-warning')\n\treturn null\n}\n\n// Format bytes, but if 0, use the same unit as the reference value (e.g., \"0TB\" instead of \"0B\")\nconst formatSizeWithUnit = (bytes: number, referenceBytes: number) => {\n\tif (bytes === 0 && referenceBytes > 0) {\n\t\tconst unit = formatSize(referenceBytes).replace(/[\\d.]/g, '')\n\t\treturn `0${unit}`\n\t}\n\treturn formatSize(bytes)\n}\n\n// ============================================================================\n// Sub-components\n// ============================================================================\n\n/** Helper component to show failsafe info text */\nfunction FailSafeInfo({\n\tfailsafeSize,\n\tunusedSize,\n\tdeviceCount,\n\tsmallestSize,\n}: {\n\tfailsafeSize: number\n\tunusedSize: number\n\tdeviceCount: number\n\tsmallestSize: number\n}) {\n\tconst protectionStr = formatSize(failsafeSize)\n\tconst unusedStr = formatSize(unusedSize)\n\tconst smallestStr = formatSize(smallestSize)\n\n\t// Mixed-size drives - show explanation and tip\n\tif (unusedSize > 0) {\n\t\treturn (\n\t\t\t<div className='flex flex-col gap-2 text-[13px] text-white/50'>\n\t\t\t\t<p>{t('onboarding.raid.failsafe.mixed-sizes', {smallest: smallestStr, wasted: unusedStr})}</p>\n\t\t\t\t<p className='text-yellow-500'>💡 {t('onboarding.raid.failsafe.tip')}</p>\n\t\t\t</div>\n\t\t)\n\t}\n\n\t// Same-sized drives - show explanation and expansion hint\n\tif (deviceCount === 2) {\n\t\tconst futureWith3 = formatSize(smallestSize * 2)\n\t\tconst futureWith4 = formatSize(smallestSize * 3)\n\t\treturn (\n\t\t\t<span className='text-[13px] text-white/50'>\n\t\t\t\t{t('onboarding.raid.failsafe.protection-info-2ssds', {\n\t\t\t\t\tprotection: protectionStr,\n\t\t\t\t\tsmallest: smallestStr,\n\t\t\t\t\tfutureWith3,\n\t\t\t\t\tfutureWith4,\n\t\t\t\t})}\n\t\t\t</span>\n\t\t)\n\t}\n\n\tif (deviceCount === 3) {\n\t\tconst futureWith4 = formatSize(smallestSize * 3)\n\t\treturn (\n\t\t\t<span className='text-[13px] text-white/50'>\n\t\t\t\t{t('onboarding.raid.failsafe.protection-info-3ssds', {\n\t\t\t\t\tprotection: protectionStr,\n\t\t\t\t\tsmallest: smallestStr,\n\t\t\t\t\tfutureWith4,\n\t\t\t\t})}\n\t\t\t</span>\n\t\t)\n\t}\n\n\t// 4 SSDs - fully expanded, no additional text needed\n\treturn null\n}\n\n// ============================================================================\n// Main Component\n// ============================================================================\n\nexport default function RaidSetup() {\n\tconst navigate = useNavigate()\n\tconst location = useLocation()\n\n\t// Get credentials from React Router's location.state (passed from create-account page)\n\t// location.state survives page refresh (browser History API), lost only on direct URL navigation or new tab.\n\t// If lost, we redirect to create-account.\n\t//\n\t// IMPORTANT: If user refreshes page while device is rebooting, they'll see network errors until the device comes\n\t// back online, then be redirected to login (since user now exists). They won't see the success page, but the setup still completes successfully.\n\tconst credentials = location.state?.credentials as AccountCredentials | undefined\n\n\t// Always fetch fresh devices from server in case user shut down to change an SSD and refreshes current url\n\tconst {devices, isDetecting} = useDetectStorageDevices()\n\n\t// FailSafe rendering logic:\n\t// ┌─────────────────────────┬─────────────┬─────────────┬─────────────────┐\n\t// │ Configuration           │ Can Enable  │ Recommended │ Default State   │\n\t// ├─────────────────────────┼─────────────┼─────────────┼─────────────────┤\n\t// │ 1 SSD                   │ No          │ —           │ OFF (disabled)  │\n\t// │ 2+ SSDs, same size      │ Yes         │ Yes         │ ON              │\n\t// │ 2+ SSDs, mixed sizes    │ Yes         │ No          │ OFF             │\n\t// └─────────────────────────┴─────────────┴─────────────┴─────────────────┘\n\n\tconst canEnableFailSafe = devices.length >= 2\n\t// Check if all drives have the same roundedSize (backend rounds to nearest 250GB for ≥1TB drives)\n\tconst roundedSizes = devices.map((d) => d.roundedSize)\n\tconst smallestRounded = roundedSizes.length > 0 ? Math.min(...roundedSizes) : 0\n\tconst allSameSize = roundedSizes.length > 0 && roundedSizes.every((s) => s === smallestRounded)\n\tconst defaultFailSafe = canEnableFailSafe && allSameSize\n\tconst [failSafeEnabled, setFailSafeEnabled] = useState(defaultFailSafe)\n\n\t// Shutdown confirmation dialog state\n\tconst [showShutdownDialog, setShowShutdownDialog] = useState(false)\n\n\t// Setup phase: null | 'setting-up' | 'restarting' | 'complete' | 'error'\n\tconst [setupPhase, setSetupPhase] = useState<null | 'setting-up' | 'restarting' | 'complete' | 'error'>(null)\n\n\t// Track if we're launching (stays true through navigation to prevent button flash)\n\tconst [isLaunching, setIsLaunching] = useState(false)\n\n\t// SSD Health dialog state\n\tconst healthDialog = useSsdHealthDialog()\n\n\t// Poll for RAID setup completion after reboot\n\t// This endpoint returns: true (complete), false (in progress), or throws (failed)\n\t// We disable retry to avoid exponential backoff - we just want to detect when it completes ASAP\n\tconst raidStatusQ = trpcReact.hardware.raid.checkInitialRaidSetupStatus.useQuery(undefined, {\n\t\tenabled: setupPhase === 'restarting',\n\t\trefetchInterval: setupPhase === 'restarting' ? 2000 : false,\n\t\tretry: false,\n\t})\n\n\t// Handle RAID setup completion or failure\n\tuseEffect(() => {\n\t\tif (setupPhase !== 'restarting') return\n\n\t\t// Setup complete - pool exists, user created, app store synced\n\t\tif (raidStatusQ.data === true) {\n\t\t\tsetSetupPhase('complete')\n\t\t}\n\n\t\t// Check for actual server errors (not network errors during reboot)\n\t\t// Network errors like \"fetch failed\" are expected while device is rebooting - just keep polling\n\t\t// Server errors (e.g., initialRaidSetupError) indicate actual setup failure\n\t\tif (raidStatusQ.isError) {\n\t\t\tconst errorMessage = raidStatusQ.error?.message ?? ''\n\t\t\tconst isNetworkError = errorMessage.includes('fetch failed') || errorMessage.includes('Failed to fetch')\n\t\t\tif (!isNetworkError) {\n\t\t\t\t// Actual server error - setup failed\n\t\t\t\tsetSetupPhase('error')\n\t\t\t}\n\t\t\t// Network error - ignore, keep polling (device is probably still rebooting)\n\t\t}\n\t}, [setupPhase, raidStatusQ.data, raidStatusQ.isError, raidStatusQ.error])\n\n\t// Auth for auto-login on success\n\tconst auth = useAuth()\n\n\t// Get global system state to suppress errors during our custom restart flow\n\tconst {suppressErrors, shutdown} = useGlobalSystemState()\n\n\t// Login mutation for auto-login after setup complete\n\tconst loginMut = trpcReact.user.login.useMutation({\n\t\tonSuccess: (jwt) => {\n\t\t\tauth.signUpWithJwt(jwt, '/')\n\t\t},\n\t\tonError: () => {\n\t\t\t// If login fails, just redirect to login page\n\t\t\twindow.location.href = '/'\n\t\t},\n\t})\n\n\t// Register mutation - this will set up RAID, save credentials, and trigger reboot\n\tconst registerMut = trpcReact.user.register.useMutation({\n\t\tonSuccess: () => {\n\t\t\t// Registration succeeded - device will reboot\n\t\t\t// Transition to restarting phase\n\t\t\tsetSetupPhase('restarting')\n\t\t},\n\t\tonError: () => {\n\t\t\tsetSetupPhase(null)\n\t\t},\n\t})\n\n\t// Redirect to create-account if credentials are missing (e.g., direct URL navigation or new tab)\n\tuseEffect(() => {\n\t\tif (!credentials) {\n\t\t\tnavigate('/onboarding/create-account', {replace: true})\n\t\t}\n\t}, [credentials, navigate])\n\n\t// Redirect to detect page if still detecting or no devices found\n\tuseEffect(() => {\n\t\tif (!credentials) return\n\t\tif (isDetecting || devices.length === 0) {\n\t\t\tnavigate('/onboarding/raid', {state: {credentials}, replace: true})\n\t\t}\n\t}, [isDetecting, devices.length, credentials, navigate])\n\n\t// Don't render while redirecting\n\tif (!credentials || isDetecting || devices.length === 0) {\n\t\treturn null\n\t}\n\n\t// --- Event Handlers ---\n\n\t// Handle continue button - register with RAID config\n\tconst handleContinue = () => {\n\t\tif (!credentials) {\n\t\t\t// No credentials - shouldn't happen, but navigate back to create account\n\t\t\tnavigate('/onboarding/create-account', {replace: true})\n\t\t\treturn\n\t\t}\n\n\t\t// Suppress global system state errors before triggering reboot\n\t\t// This prevents the error boundary from showing \"Something went wrong\" during the expected network downtime\n\t\tsuppressErrors()\n\n\t\tsetSetupPhase('setting-up')\n\n\t\t// Get device IDs for RAID setup\n\t\tconst raidDevices = devices.map((d) => d.id).filter((id): id is string => id !== undefined)\n\t\tconst raidType: RaidType = failSafeEnabled ? 'failsafe' : 'storage'\n\n\t\t// Call register with credentials and RAID config\n\t\t// Backend will: set up ZFS pool, save credentials to config, trigger reboot\n\t\tregisterMut.mutate({\n\t\t\tname: credentials.name,\n\t\t\tpassword: credentials.password,\n\t\t\tlanguage: credentials.language,\n\t\t\traidDevices,\n\t\t\traidType,\n\t\t})\n\t}\n\n\t// Handle shutdown\n\tconst handleShutdown = () => {\n\t\tshutdown()\n\t}\n\n\t// --- Derived State & Calculations ---\n\n\t// We show the smallest drive as \"failsafe\" because it determines the usable capacity per drive.\n\t// Get the last slot device with the smallest roundedSize.\n\tconst smallestSize = devices.length > 0 ? Math.min(...devices.map((d) => d.roundedSize)) : 0\n\tconst smallestDevices = devices.filter((d) => d.roundedSize === smallestSize)\n\tconst smallestDeviceSlot = smallestDevices.length > 0 ? (smallestDevices[smallestDevices.length - 1].slot ?? -1) : -1\n\n\t// Convert devices to slots array for SsdTray visualization\n\t// Uses the slot property from each device (1-4)\n\tconst slots: (SsdSlot | null)[] = [null, null, null, null]\n\tdevices.forEach((device) => {\n\t\tconst slotIndex = (device.slot ?? 0) - 1 // slot is 1-indexed\n\t\tif (slotIndex >= 0 && slotIndex < 4) {\n\t\t\tslots[slotIndex] = {\n\t\t\t\tsize: formatSize(device.roundedSize),\n\t\t\t\thasWarning: getDeviceHealth(device).hasWarning,\n\t\t\t}\n\t\t}\n\t})\n\n\t// The failsafe slot is the smallest drive (when failsafe is enabled)\n\tconst failsafeSlot = failSafeEnabled && canEnableFailSafe && smallestDeviceSlot > 0 ? smallestDeviceSlot - 1 : -1\n\n\t// Calculate storage values based on failsafe config\n\t// FailSafe uses RAIDZ1: one drive's worth of capacity for parity (based on smallest drive)\n\t// Formula: available = (n-1) × smallest, failsafe = smallest, wasted = total - available - failsafe\n\t//\n\t// Examples:\n\t// ┌────────────────────────┬───────────┬──────────┬────────┐\n\t// │ Configuration          │ Available │ FailSafe │ Wasted │\n\t// ├────────────────────────┼───────────┼──────────┼────────┤\n\t// │ 1×2TB (no failsafe)    │ 2TB       │ —        │ —      │\n\t// │ 2×2TB (same size)      │ 2TB       │ 2TB      │ 0      │\n\t// │ 3×2TB (same size)      │ 4TB       │ 2TB      │ 0      │\n\t// │ 4×2TB (same size)      │ 6TB       │ 2TB      │ 0      │\n\t// │ 2TB + 4TB (mixed)      │ 2TB       │ 2TB      │ 2TB    │\n\t// │ 2TB + 2TB + 4TB        │ 4TB       │ 2TB      │ 2TB    │\n\t// └────────────────────────┴───────────┴──────────┴────────┘\n\n\tconst totalRoundedBytes = devices.reduce((sum, d) => sum + d.roundedSize, 0)\n\n\tlet availableBytes: number\n\tlet failsafeBytes: number\n\tlet unusedBytes: number\n\n\tif (failSafeEnabled && canEnableFailSafe) {\n\t\tfailsafeBytes = smallestSize\n\t\tavailableBytes = (devices.length - 1) * smallestSize\n\t\tunusedBytes = Math.max(0, totalRoundedBytes - availableBytes - failsafeBytes)\n\t} else {\n\t\tfailsafeBytes = 0\n\t\tavailableBytes = totalRoundedBytes\n\t\tunusedBytes = 0\n\t}\n\n\tconst availableStorage = formatSize(availableBytes)\n\tconst failsafeStorage = formatSizeWithUnit(failsafeBytes, availableBytes)\n\tconst unusedStorage = formatSizeWithUnit(unusedBytes, availableBytes)\n\n\t// --- Render: Error State ---\n\n\t// Show error state if registration failed (pre-reboot) or RAID setup failed (post-reboot)\n\tconst errorMessage = registerMut.error?.message || raidStatusQ.error?.message\n\tif (registerMut.error || setupPhase === 'error') {\n\t\tconst canRetry = !!registerMut.error // Can only retry pre-reboot errors\n\t\treturn (\n\t\t\t<div className='flex flex-1 flex-col items-center justify-center gap-4'>\n\t\t\t\t<TbAlertTriangleFilled className='size-[22px] text-[#F5A623]' />\n\t\t\t\t<h1\n\t\t\t\t\tclassName='text-[20px] font-bold text-white/85'\n\t\t\t\t\tstyle={{textShadow: '0 0 8px rgba(255, 255, 255, 0.2), 0 0 16px rgba(255, 255, 255, 0.15)'}}\n\t\t\t\t>\n\t\t\t\t\t{t('onboarding.raid.setup-failed.title')}\n\t\t\t\t</h1>\n\t\t\t\t<p className='max-w-[300px] text-center text-[15px] text-white/70'>{errorMessage}</p>\n\t\t\t\t<p className='max-w-[300px] text-center text-[13px] text-white/50'>\n\t\t\t\t\t{canRetry\n\t\t\t\t\t\t? t('onboarding.raid.setup-failed.description-retry')\n\t\t\t\t\t\t: t('onboarding.raid.setup-failed.description-no-retry')}\n\t\t\t\t</p>\n\t\t\t\t<div className='mt-0 flex gap-3'>\n\t\t\t\t\t{canRetry && (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\tregisterMut.reset()\n\t\t\t\t\t\t\t\tsetSetupPhase(null)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName={primaryButtonProps.className}\n\t\t\t\t\t\t\tstyle={primaryButtonProps.style}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('onboarding.raid.try-again')}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={handleShutdown}\n\t\t\t\t\t\tclassName='flex h-[42px] min-w-[112px] items-center justify-center rounded-full bg-destructive2 px-4 text-14 font-medium text-white ring-destructive2/40 transition-all duration-300 hover:bg-destructive2-lighter focus:outline-hidden focus-visible:ring-3 active:scale-100 active:bg-destructive2 disabled:pointer-events-none disabled:opacity-50'\n\t\t\t\t\t\tstyle={{boxShadow: '0px 2px 4px 0px rgba(255, 255, 255, 0.25) inset'}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('shut-down')}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t)\n\t}\n\n\t// --- Render: Progress State ---\n\n\t// Show setup progress state (covers both ZFS pool creation and post-reboot user setup)\n\tif (setupPhase === 'setting-up' || setupPhase === 'restarting') {\n\t\treturn (\n\t\t\t<Layout\n\t\t\t\ttitle={t('onboarding.raid.configuring.title')}\n\t\t\t\tsubTitle={t('onboarding.raid.configuring.subtitle')}\n\t\t\t\tsubTitleMaxWidth={400}\n\t\t\t\tshowLogo={false}\n\t\t\t\tfooter={\n\t\t\t\t\t<div className='w-full max-w-sm'>\n\t\t\t\t\t\t<p className='text-center text-sm text-white/60'>{t('onboarding.raid.configuring.warning')}</p>\n\t\t\t\t\t</div>\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t<img\n\t\t\t\t\tsrc='/assets/onboarding/pro-front.webp'\n\t\t\t\t\talt={t('storage-manager.umbrel-pro')}\n\t\t\t\t\tdraggable={false}\n\t\t\t\t\tclassName='w-64 md:w-96'\n\t\t\t\t/>\n\t\t\t\t<p className='-mt-4 text-[13px] font-medium text-white/30'>{t('storage-manager.umbrel-pro')}</p>\n\t\t\t\t{/* Progress bar */}\n\t\t\t\t<div className='mt-4 w-full max-w-sm'>\n\t\t\t\t\t<Progress />\n\t\t\t\t</div>\n\t\t\t</Layout>\n\t\t)\n\t}\n\n\t// --- Render: Success State ---\n\n\t// Show success page after setup is complete\n\t// Note: Pro uses this inline success page (not /onboarding/account-created) because we need to\n\t// display storage/failsafe details and handle auto-login after reboot\n\tif (setupPhase === 'complete') {\n\t\t// Get first name from credentials\n\t\tconst firstName = credentials?.name?.split(' ')[0] || ''\n\t\treturn (\n\t\t\t<Layout\n\t\t\t\ttitle={t('onboarding.account-created.youre-all-set-name', {name: firstName})}\n\t\t\t\tsubTitle={\n\t\t\t\t\t<Trans\n\t\t\t\t\t\ti18nKey='onboarding.account-created.by-clicking-button-you-agree'\n\t\t\t\t\t\tcomponents={{\n\t\t\t\t\t\t\tlinked: <Link to={links.legal.tos} className={linkClass} target='_blank' />,\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t}\n\t\t\t\tsubTitleMaxWidth={630}\n\t\t\t\tsubTitleClassName='text-white/50'\n\t\t\t\tshowLogo={false}\n\t\t\t\tfooter={\n\t\t\t\t\t<div className='flex flex-col items-center gap-3'>\n\t\t\t\t\t\t<Link to={links.support} target='_blank' className={footerLinkClass}>\n\t\t\t\t\t\t\t{t('onboarding.contact-support')}\n\t\t\t\t\t\t</Link>\n\t\t\t\t\t</div>\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t<img\n\t\t\t\t\tsrc='/assets/onboarding/pro-front.webp'\n\t\t\t\t\talt={t('storage-manager.umbrel-pro')}\n\t\t\t\t\tdraggable={false}\n\t\t\t\t\tclassName='w-64 md:w-96'\n\t\t\t\t/>\n\t\t\t\t<p className='-mt-2 text-[20px] font-semibold text-white/85'>{t('storage-manager.umbrel-pro')}</p>\n\t\t\t\t<p className='-mt-5 text-[14px] font-medium text-white/50'>\n\t\t\t\t\t{failSafeEnabled\n\t\t\t\t\t\t? t('onboarding.raid.success.storage-info-failsafe', {\n\t\t\t\t\t\t\t\tavailable: availableStorage,\n\t\t\t\t\t\t\t\tfailsafe: failsafeStorage,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t: t('onboarding.raid.success.storage-info', {available: availableStorage})}\n\t\t\t\t</p>\n\n\t\t\t\t<button\n\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\tsetIsLaunching(true)\n\t\t\t\t\t\tif (credentials?.password) {\n\t\t\t\t\t\t\t// Try to auto-login with the credentials we have\n\t\t\t\t\t\t\tloginMut.mutate({password: credentials.password, totpToken: ''})\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// No credentials, just redirect to login\n\t\t\t\t\t\t\twindow.location.href = '/'\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\tdisabled={isLaunching}\n\t\t\t\t\tclassName={`mt-4 ${primaryButtonProps.className}`}\n\t\t\t\t\tstyle={primaryButtonProps.style}\n\t\t\t\t>\n\t\t\t\t\t{isLaunching ? t('onboarding.raid.launching') : t('onboarding.launch-umbrelos')}\n\t\t\t\t</button>\n\t\t\t</Layout>\n\t\t)\n\t}\n\n\t// --- Render: Main Setup Form ---\n\n\treturn (\n\t\t<div className='flex flex-1 flex-col md:flex-row'>\n\t\t\t{/* Left side - content (full width on mobile) */}\n\t\t\t<div className='flex flex-1 flex-col justify-start gap-4 px-4 py-6 md:pt-10 md:pr-0 md:pb-0 md:pl-6'>\n\t\t\t\t<div className='flex flex-col gap-1 md:gap-2'>\n\t\t\t\t\t<h1\n\t\t\t\t\t\tclassName='text-[20px] font-bold text-white/85 md:text-[24px]'\n\t\t\t\t\t\tstyle={{textShadow: '0 0 8px rgba(255, 255, 255, 0.2), 0 0 16px rgba(255, 255, 255, 0.15)'}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('onboarding.raid.storage')}\n\t\t\t\t\t</h1>\n\t\t\t\t\t<p className='text-[14px] text-white/50 md:text-[16px]'>{t('onboarding.raid.ssds-found')}</p>\n\t\t\t\t</div>\n\n\t\t\t\t{/* SSD list card */}\n\t\t\t\t<div className='flex flex-col rounded-xl bg-white/5 p-3'>\n\t\t\t\t\t{devices.map((device) => {\n\t\t\t\t\t\tconst warning = getHealthWarningMessage(device)\n\t\t\t\t\t\tconst hasWarning = getDeviceHealth(device).hasWarning\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={device.id}\n\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\tonClick={() => healthDialog.openDialog(device, device.slot ?? 0)}\n\t\t\t\t\t\t\t\tclassName='-mx-1 flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-white/5'\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<div className='flex flex-col gap-0.5'>\n\t\t\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t\t\t{warning ? (\n\t\t\t\t\t\t\t\t\t\t\t<TbAlertTriangle className='size-5 text-[#F5A623]' />\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<TbCircleCheckFilled className='size-5 text-brand' />\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t<span className='text-[14px] font-medium text-white/60 md:text-[15px]'>\n\t\t\t\t\t\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\t\t\t\t\t\ti18nKey='onboarding.raid.ssd-in-slot'\n\t\t\t\t\t\t\t\t\t\t\t\tvalues={{size: formatSize(device.roundedSize), slot: device.slot}}\n\t\t\t\t\t\t\t\t\t\t\t\tcomponents={{highlight: <span className='text-white' />}}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t{warning && <p className='ml-7 text-[12px] text-[#F5A623]/80 md:text-[13px]'>{warning}</p>}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{/* Health pill - mobile only (desktop has device visualization) */}\n\t\t\t\t\t\t\t\t<div className='relative flex items-center justify-center rounded-full border border-white/[0.16] bg-white/[0.08] px-3 py-0.5 md:hidden'>\n\t\t\t\t\t\t\t\t\t<TbActivityHeartbeat className='size-4 text-white/60' />\n\t\t\t\t\t\t\t\t\t{/* Warning dot with ping - positioned to intersect pill edge */}\n\t\t\t\t\t\t\t\t\t{hasWarning && (\n\t\t\t\t\t\t\t\t\t\t<span className='absolute -top-0.5 right-1.5 translate-x-1/3 -translate-y-1/3'>\n\t\t\t\t\t\t\t\t\t\t\t<span className='absolute inset-0 size-2.5 rounded-full bg-[#F5A623]' />\n\t\t\t\t\t\t\t\t\t\t\t<span className='absolute inset-0 size-2.5 animate-ping rounded-full bg-[#F5A623] opacity-75' />\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</div>\n\n\t\t\t\t{/* Shut down link */}\n\t\t\t\t<button\n\t\t\t\t\tonClick={() => setShowShutdownDialog(true)}\n\t\t\t\t\tclassName='w-fit text-[13px] text-white/50 underline-offset-2 transition-colors hover:text-white/70 hover:underline'\n\t\t\t\t>\n\t\t\t\t\t{t('onboarding.raid.change-drives-link')}\n\t\t\t\t</button>\n\n\t\t\t\t{/* Divider */}\n\t\t\t\t<div className='border-t border-white/10' />\n\n\t\t\t\t{/* FailSafe section */}\n\t\t\t\t<div className='flex flex-col gap-3 md:gap-4'>\n\t\t\t\t\t<div className='flex flex-col gap-1 md:gap-2'>\n\t\t\t\t\t\t<h2\n\t\t\t\t\t\t\tclassName='text-[18px] font-semibold text-white/85 md:text-[20px]'\n\t\t\t\t\t\t\tstyle={{textShadow: '0 0 8px rgba(255, 255, 255, 0.2), 0 0 16px rgba(255, 255, 255, 0.15)'}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('onboarding.raid.failsafe')}\n\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t<p className='text-[14px] text-white/50 md:text-[16px]'>{t('onboarding.raid.failsafe.subtitle')}</p>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{canEnableFailSafe ? (\n\t\t\t\t\t\t/* Toggle card - shown when 2+ SSDs */\n\t\t\t\t\t\t<div className='flex flex-col gap-4 rounded-xl bg-white/5 p-4'>\n\t\t\t\t\t\t\t<div className='flex items-center justify-between'>\n\t\t\t\t\t\t\t\t<div className='flex items-center gap-3'>\n\t\t\t\t\t\t\t\t\t<Switch checked={failSafeEnabled} onCheckedChange={setFailSafeEnabled} />\n\t\t\t\t\t\t\t\t\t<span className='text-[15px] text-white/85'>{t('onboarding.raid.failsafe.enable')}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{allSameSize && (\n\t\t\t\t\t\t\t\t\t<div className='flex items-center gap-1.5 rounded-full bg-white/10 px-3 py-1'>\n\t\t\t\t\t\t\t\t\t\t<IoShieldHalf className='size-4 text-brand' />\n\t\t\t\t\t\t\t\t\t\t<span className='text-[13px] text-brand'>{t('onboarding.raid.recommended')}</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t{/* Storage breakdown bar - only shown when enabled */}\n\t\t\t\t\t\t\t{failSafeEnabled && (\n\t\t\t\t\t\t\t\t<div className='flex flex-col gap-2'>\n\t\t\t\t\t\t\t\t\t<div className='flex text-[14px]'>\n\t\t\t\t\t\t\t\t\t\t<span style={{width: `${(availableBytes / totalRoundedBytes) * 100}%`}}>\n\t\t\t\t\t\t\t\t\t\t\t<span className='text-brand'>{t('onboarding.raid.storage-label')}</span>{' '}\n\t\t\t\t\t\t\t\t\t\t\t<span className='font-medium text-brand opacity-60'>{availableStorage}</span>\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span style={{width: `${(failsafeBytes / totalRoundedBytes) * 100}%`}}>\n\t\t\t\t\t\t\t\t\t\t\t<span style={{color: FAILSAFE_COLOR}}>{t('onboarding.raid.failsafe')}</span>{' '}\n\t\t\t\t\t\t\t\t\t\t\t<span className='font-medium opacity-60' style={{color: FAILSAFE_COLOR}}>\n\t\t\t\t\t\t\t\t\t\t\t\t{failsafeStorage}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t{unusedBytes > 0 && (\n\t\t\t\t\t\t\t\t\t\t\t<span style={{width: `${(unusedBytes / totalRoundedBytes) * 100}%`}}>\n\t\t\t\t\t\t\t\t\t\t\t\t<span style={{color: WASTED_COLOR}}>{t('onboarding.raid.wasted')}</span>{' '}\n\t\t\t\t\t\t\t\t\t\t\t\t<span className='font-medium opacity-60' style={{color: WASTED_COLOR}}>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{unusedStorage}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t{/* Progress bar */}\n\t\t\t\t\t\t\t\t\t<div className='flex h-2 w-full overflow-hidden rounded-full'>\n\t\t\t\t\t\t\t\t\t\t{/* Storage */}\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName='h-full bg-brand'\n\t\t\t\t\t\t\t\t\t\t\tstyle={{width: `${(availableBytes / totalRoundedBytes) * 100}%`}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t{/* Failsafe */}\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName='h-full'\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\twidth: `${(failsafeBytes / totalRoundedBytes) * 100}%`,\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: FAILSAFE_COLOR,\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t{/* Wasted - only if there's unused storage */}\n\t\t\t\t\t\t\t\t\t\t{unusedBytes > 0 && (\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName='h-full'\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={{width: `${(unusedBytes / totalRoundedBytes) * 100}%`, backgroundColor: WASTED_COLOR}}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{failSafeEnabled ? (\n\t\t\t\t\t\t\t\t<FailSafeInfo\n\t\t\t\t\t\t\t\t\tfailsafeSize={failsafeBytes}\n\t\t\t\t\t\t\t\t\tunusedSize={unusedBytes}\n\t\t\t\t\t\t\t\t\tdeviceCount={devices.length}\n\t\t\t\t\t\t\t\t\tsmallestSize={smallestSize}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<p className='text-[13px] text-yellow-500'>\n\t\t\t\t\t\t\t\t\t<TbAlertTriangle className='mr-1 mb-0.5 inline size-4 align-middle' />\n\t\t\t\t\t\t\t\t\t{t('onboarding.raid.failsafe.warning-now-only')}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t/* Info card - shown when only 1 SSD */\n\t\t\t\t\t\t<div className='flex flex-col items-center rounded-xl bg-white/5 p-6 text-center'>\n\t\t\t\t\t\t\t<TbAlertTriangle\n\t\t\t\t\t\t\t\tclassName='size-5 text-[#D7BF44]'\n\t\t\t\t\t\t\t\tstyle={{filter: 'drop-shadow(0 0 8px rgba(215, 191, 68, 0.46))'}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<span className='mt-3 text-[15px] font-medium text-white/85'>\n\t\t\t\t\t\t\t\t{t('onboarding.raid.failsafe.cant-enable')}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span className='mt-1 text-[14px] text-white/50'>\n\t\t\t\t\t\t\t\t{t('onboarding.raid.failsafe.single-ssd-info', {size: devices[0] ? formatSize(devices[0].size) : ''})}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* Continue button */}\n\t\t\t\t\t<button\n\t\t\t\t\t\tonClick={handleContinue}\n\t\t\t\t\t\t{...primaryButtonProps}\n\t\t\t\t\t\tclassName={`${primaryButtonProps.className} w-full md:w-fit`}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('onboarding.raid.continue')}\n\t\t\t\t\t</button>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* Right side - device visualization (hidden on mobile) */}\n\t\t\t<div className='hidden flex-1 flex-col items-end justify-center md:-mr-6 md:flex'>\n\t\t\t\t<div\n\t\t\t\t\tclassName='w-[95%]'\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tmaskImage: 'linear-gradient(to bottom, black 80%, transparent 100%)',\n\t\t\t\t\t\tWebkitMaskImage: 'linear-gradient(to bottom, black 80%, transparent 100%)',\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<SsdTray\n\t\t\t\t\t\tslots={slots}\n\t\t\t\t\t\tfailsafeSlot={failsafeSlot}\n\t\t\t\t\t\tonHealthClick={(slotIndex) => {\n\t\t\t\t\t\t\t// Find the device for this slot (slots are 0-indexed, device.slot is 1-indexed)\n\t\t\t\t\t\t\tconst device = devices.find((d) => d.slot === slotIndex + 1)\n\t\t\t\t\t\t\tif (device) {\n\t\t\t\t\t\t\t\thealthDialog.openDialog(device, slotIndex + 1)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<div className='-mt-20 flex w-[95%] translate-x-4 flex-col items-center gap-1'>\n\t\t\t\t\t<p className='text-[20px] font-semibold text-white/50'>\n\t\t\t\t\t\t{t('onboarding.raid.available-storage')} <span className='text-brand'>{availableStorage}</span>\n\t\t\t\t\t</p>\n\t\t\t\t\t<p className='text-[14px] text-white/50'>\n\t\t\t\t\t\t{t('onboarding.raid.failsafe')} <span style={{color: FAILSAFE_COLOR}}>{failsafeStorage}</span>\n\t\t\t\t\t\t{unusedBytes > 0 && (\n\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t{` · ${t('onboarding.raid.wasted')} `}\n\t\t\t\t\t\t\t\t<span style={{color: WASTED_COLOR}}>{unusedStorage}</span>\n\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* Shutdown confirmation dialog */}\n\t\t\t<AlertDialog open={showShutdownDialog} onOpenChange={setShowShutdownDialog}>\n\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t\t<AlertDialogTitle>{t('onboarding.raid.shutdown-dialog.title')}</AlertDialogTitle>\n\t\t\t\t\t\t<AlertDialogDescription>{t('onboarding.raid.shutdown-dialog.description')}</AlertDialogDescription>\n\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t\t<AlertDialogAction variant='destructive' onClick={() => shutdown()}>\n\t\t\t\t\t\t\t{t('shut-down')}\n\t\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t\t<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t</AlertDialogContent>\n\t\t\t</AlertDialog>\n\n\t\t\t{/* SSD Health dialog */}\n\t\t\t{healthDialog.selectedDevice && (\n\t\t\t\t<SsdHealthDialog\n\t\t\t\t\tdevice={healthDialog.selectedDevice.device}\n\t\t\t\t\tslotNumber={healthDialog.selectedDevice.slotNumber}\n\t\t\t\t\topen={healthDialog.open}\n\t\t\t\t\tonOpenChange={healthDialog.onOpenChange}\n\t\t\t\t/>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/onboarding/raid/ssd-health-dialog.tsx",
    "content": "// TODO: Consider moving to shared location (e.g., @/features/storage/) when implementing RAID settings in dashboard\n\nimport {useState} from 'react'\nimport {TbActivityHeartbeat, TbAlertTriangle} from 'react-icons/tb'\n\nimport {FadeScroller} from '@/components/fade-scroller'\nimport {Dialog, DialogHeader, DialogScrollableContent, DialogTitle} from '@/components/ui/dialog'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nimport {formatSize, getDeviceHealth, StorageDevice} from './use-raid-setup'\n\ntype Warning = {\n\tmessage: string\n\tadvice: string\n}\n\ntype SsdHealthDialogProps = {\n\tdevice: StorageDevice\n\tslotNumber: number\n\topen: boolean\n\tonOpenChange: (open: boolean) => void\n}\n\nexport function SsdHealthDialog({device, slotNumber, open, onOpenChange}: SsdHealthDialogProps) {\n\t// Get health status from shared helper\n\tconst {smartUnhealthy, lifeRemaining, lifeWarning, tempWarning, tempCritical} = getDeviceHealth(device)\n\n\t// Determine health status display label\n\tconst healthStatus =\n\t\tdevice.smartStatus === 'healthy'\n\t\t\t? t('storage-manager.health.status-healthy')\n\t\t\t: device.smartStatus === 'unhealthy'\n\t\t\t\t? t('storage-manager.health.status-unhealthy')\n\t\t\t\t: t('storage-manager.health.status-unknown')\n\n\t// Collect all warnings for this device\n\tconst warnings: Warning[] = []\n\n\tif (smartUnhealthy) {\n\t\twarnings.push({\n\t\t\tmessage: t('storage-manager.health.warning-unhealthy-message'),\n\t\t\tadvice: t('storage-manager.health.warning-unhealthy-advice'),\n\t\t})\n\t}\n\n\tif (lifeWarning) {\n\t\twarnings.push({\n\t\t\tmessage: t('storage-manager.health.warning-life-message', {percent: lifeRemaining}),\n\t\t\tadvice: t('storage-manager.health.warning-life-advice'),\n\t\t})\n\t}\n\n\tif (tempCritical) {\n\t\twarnings.push({\n\t\t\tmessage: t('storage-manager.health.warning-temp-critical', {temperature: `${device.temperature}°C`}),\n\t\t\tadvice: t('storage-manager.health.warning-temp-advice'),\n\t\t})\n\t} else if (tempWarning) {\n\t\twarnings.push({\n\t\t\tmessage: t('storage-manager.health.warning-temp-overheating', {temperature: `${device.temperature}°C`}),\n\t\t\tadvice: t('storage-manager.health.warning-temp-advice'),\n\t\t})\n\t}\n\n\treturn (\n\t\t<Dialog open={open} onOpenChange={onOpenChange}>\n\t\t\t<DialogScrollableContent showClose>\n\t\t\t\t<div className='space-y-5 px-5 py-6'>\n\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t<TbActivityHeartbeat className='size-5' />\n\t\t\t\t\t\t\t<DialogTitle>{t('storage-manager.health.title')}</DialogTitle>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</DialogHeader>\n\n\t\t\t\t\t{/* SSD Depiction */}\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName='relative -mr-5'\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tmaskImage: 'linear-gradient(to right, black 60%, transparent 100%)',\n\t\t\t\t\t\t\tWebkitMaskImage: 'linear-gradient(to right, black 60%, transparent 100%)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<img\n\t\t\t\t\t\t\tsrc='/assets/onboarding/ssd-info.webp'\n\t\t\t\t\t\t\talt={t('onboarding.raid.ssd-label', {number: slotNumber})}\n\t\t\t\t\t\t\tdraggable={false}\n\t\t\t\t\t\t\tclassName='ml-auto w-[95%]'\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t{/* Overlay text */}\n\t\t\t\t\t\t{/* Left side - Size and Slot */}\n\t\t\t\t\t\t<div className='absolute flex flex-col' style={{left: '20%', top: '50%', transform: 'translateY(-50%)'}}>\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName='leading-tight font-bold'\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tfontSize: 'clamp(20px, 5vw, 30px)',\n\t\t\t\t\t\t\t\t\ttextShadow: '0 0 8px rgba(255, 255, 255, 0.2), 0 0 16px rgba(255, 255, 255, 0.15)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{formatSize(device.size)}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span className='text-white/50' style={{fontSize: 'clamp(12px, 2.5vw, 14px)'}}>\n\t\t\t\t\t\t\t\t{t('onboarding.raid.ssd-label', {number: slotNumber})}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/* Right side - Model */}\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tclassName='absolute font-medium text-white/90'\n\t\t\t\t\t\t\tstyle={{right: '5%', top: '70%', transform: 'translateY(-50%)', fontSize: 'clamp(12px, 2.5vw, 15px)'}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{device.model}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Warnings Section - only shown if there are warnings */}\n\t\t\t\t\t{warnings.length > 0 && (\n\t\t\t\t\t\t<div className='rounded-12 border border-[#F5A623]/30 bg-[#F5A623]/10 p-4'>\n\t\t\t\t\t\t\t<div className='mb-3 flex items-center gap-2 text-[#F5A623]'>\n\t\t\t\t\t\t\t\t<TbAlertTriangle className='size-5' />\n\t\t\t\t\t\t\t\t<span className='font-semibold'>{t('storage-manager.health.warnings')}</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className='divide-y divide-[#F5A623]/20'>\n\t\t\t\t\t\t\t\t{warnings.map((warning, index) => (\n\t\t\t\t\t\t\t\t\t<div key={index} className='py-2 text-sm first:pt-0 last:pb-0'>\n\t\t\t\t\t\t\t\t\t\t<p className='font-medium text-white/90'>{warning.message}</p>\n\t\t\t\t\t\t\t\t\t\t<p className='mt-0.5 text-white/50'>{warning.advice}</p>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t{/* General Section */}\n\t\t\t\t\t<div className='space-y-2'>\n\t\t\t\t\t\t<span className='text-xs font-medium tracking-wider text-white/40 uppercase'>\n\t\t\t\t\t\t\t{t('storage-manager.health.general')}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{/* Model and serial use select-all for easy copying */}\n\t\t\t\t\t\t<div className={listClass}>\n\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t<span className='shrink-0'>{t('storage-manager.health.model-and-capacity')}</span>\n\t\t\t\t\t\t\t\t<FadeScroller direction='x' className='umbrel-hide-scrollbar min-w-0 overflow-x-auto font-normal'>\n\t\t\t\t\t\t\t\t\t<span className='whitespace-nowrap select-all'>{device.model}</span>\n\t\t\t\t\t\t\t\t\t<span className='whitespace-nowrap'> · {formatSize(device.size)}</span>\n\t\t\t\t\t\t\t\t</FadeScroller>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t<span className='shrink-0'>{t('storage-manager.health.serial-number')}</span>\n\t\t\t\t\t\t\t\t<FadeScroller direction='x' className='umbrel-hide-scrollbar min-w-0 overflow-x-auto font-normal'>\n\t\t\t\t\t\t\t\t\t<span className='whitespace-nowrap select-all'>{device.serial}</span>\n\t\t\t\t\t\t\t\t</FadeScroller>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Wear Section */}\n\t\t\t\t\t<div className='space-y-2'>\n\t\t\t\t\t\t<span className='text-xs font-medium tracking-wider text-white/40 uppercase'>\n\t\t\t\t\t\t\t{t('storage-manager.health.wear')}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<div className={listClass}>\n\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t<span>{t('storage-manager.health.health-status')}</span>\n\t\t\t\t\t\t\t\t<span className='flex items-center gap-2 font-normal'>\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclassName='size-[5px] rounded-full ring-3'\n\t\t\t\t\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor:\n\t\t\t\t\t\t\t\t\t\t\t\t\tdevice.smartStatus === 'healthy'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? '#00D084'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: device.smartStatus === 'unhealthy'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? '#F5A623'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'rgba(255,255,255,0.5)',\n\t\t\t\t\t\t\t\t\t\t\t\t'--tw-ring-color':\n\t\t\t\t\t\t\t\t\t\t\t\t\tdevice.smartStatus === 'healthy'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'rgba(0, 208, 132, 0.3)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: device.smartStatus === 'unhealthy'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'rgba(245, 166, 35, 0.3)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'rgba(255, 255, 255, 0.15)',\n\t\t\t\t\t\t\t\t\t\t\t} as React.CSSProperties\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t{healthStatus}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{lifeRemaining !== undefined && (\n\t\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t\t<span>{t('storage-manager.health.estimated-life')}</span>\n\t\t\t\t\t\t\t\t\t<span className='flex items-center gap-2 font-normal'>\n\t\t\t\t\t\t\t\t\t\t{lifeWarning && (\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclassName='size-[5px] rounded-full ring-3'\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: '#F5A623',\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t'--tw-ring-color': 'rgba(245, 166, 35, 0.3)',\n\t\t\t\t\t\t\t\t\t\t\t\t\t} as React.CSSProperties\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t{lifeRemaining}%{lifeWarning && ` · ${t('storage-manager.health.low')}`}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Temperature Section */}\n\t\t\t\t\t{device.temperature !== undefined && (\n\t\t\t\t\t\t<div className='space-y-2'>\n\t\t\t\t\t\t\t<span className='text-xs font-medium tracking-wider text-white/40 uppercase'>\n\t\t\t\t\t\t\t\t{t('storage-manager.health.temperature')}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<div className={listClass}>\n\t\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t\t<span>{t('storage-manager.health.current-temperature')}</span>\n\t\t\t\t\t\t\t\t\t<span className='flex items-center gap-2 font-normal'>\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tclassName='size-[5px] rounded-full ring-3'\n\t\t\t\t\t\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor: tempCritical ? '#FF2F63' : tempWarning ? '#F5A623' : '#00D084',\n\t\t\t\t\t\t\t\t\t\t\t\t\t'--tw-ring-color': tempCritical\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'rgba(255, 47, 99, 0.3)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: tempWarning\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'rgba(245, 166, 35, 0.3)'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'rgba(0, 208, 132, 0.3)',\n\t\t\t\t\t\t\t\t\t\t\t\t} as React.CSSProperties\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t{device.temperature}°C\n\t\t\t\t\t\t\t\t\t\t{tempCritical && ` · ${t('storage-manager.health.critical')}`}\n\t\t\t\t\t\t\t\t\t\t{tempWarning && ` · ${t('storage-manager.health.overheating')}`}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{device.temperatureWarning !== undefined && (\n\t\t\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t\t\t<span>{t('storage-manager.health.warning-threshold')}</span>\n\t\t\t\t\t\t\t\t\t\t<span className='font-normal'>{device.temperatureWarning}°C</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{device.temperatureCritical !== undefined && (\n\t\t\t\t\t\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t\t\t\t\t\t<span>{t('storage-manager.health.critical-threshold')}</span>\n\t\t\t\t\t\t\t\t\t\t<span className='font-normal'>{device.temperatureCritical}°C</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</DialogScrollableContent>\n\t\t</Dialog>\n\t)\n}\n\nconst listClass = tw`divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6`\nconst listItemClass = tw`flex items-center gap-3 px-3 h-[42px] text-14 font-medium -tracking-3 justify-between text-white/90`\n\n// Hook to manage SSD health dialog state\nexport function useSsdHealthDialog() {\n\tconst [selectedDevice, setSelectedDevice] = useState<{device: StorageDevice; slotNumber: number} | null>(null)\n\n\treturn {\n\t\tselectedDevice,\n\t\topen: selectedDevice !== null,\n\t\tonOpenChange: (open: boolean) => {\n\t\t\tif (!open) setSelectedDevice(null)\n\t\t},\n\t\topenDialog: (device: StorageDevice, slotNumber: number) => {\n\t\t\tsetSelectedDevice({device, slotNumber})\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/routes/onboarding/raid/ssd-tray.tsx",
    "content": "import {IoShieldHalf} from 'react-icons/io5'\nimport {TbActivityHeartbeat} from 'react-icons/tb'\n\nimport {t} from '@/utils/i18n'\n\nimport {FAILSAFE_COLOR} from './use-raid-setup'\n\nexport type SsdSlot = {\n\tsize: string // e.g., \"2TB\"\n\thasWarning?: boolean // true if SSD has health issues\n}\n\ntype SsdTrayProps = {\n\t/** Array of occupied slots (index 0-3). null/undefined means empty slot */\n\tslots: (SsdSlot | null | undefined)[]\n\t/** Slot index (0-3) that is used for failsafe. -1 or undefined means no failsafe */\n\tfailsafeSlot?: number\n\t/** Callback when health pill is clicked. Receives slot index (0-3) */\n\tonHealthClick?: (slotIndex: number) => void\n}\n\n/**\n * Renders the SSD tray with conditional SSD visibility.\n *\n * Layer order (bottom to top):\n * 1. Empty tray PNG\n * 2. Slot labels (SSD 1, SSD 2, etc.)\n * 3. Individual SSD PNGs (conditionally rendered)\n * 4. Brand color overlays (border + transparent background)\n * 5. Shield icon (failsafe slot only)\n */\nexport function SsdTray({slots, failsafeSlot = -1, onHealthClick}: SsdTrayProps) {\n\treturn (\n\t\t<div className='relative w-full' style={{aspectRatio: '511 / 686', containerType: 'inline-size'}}>\n\t\t\t{/* Layer 1: Empty tray */}\n\t\t\t<img\n\t\t\t\tsrc='/assets/onboarding/ssd-tray.webp'\n\t\t\t\talt={t('onboarding.raid.ssd-tray-alt')}\n\t\t\t\tdraggable={false}\n\t\t\t\tclassName='absolute inset-0 size-full'\n\t\t\t/>\n\n\t\t\t{/* Layer 2: Slot labels - white with glow when SSD present */}\n\t\t\t{[0, 1, 2, 3].map((i) => (\n\t\t\t\t<div\n\t\t\t\t\tkey={`label-${i}`}\n\t\t\t\t\tclassName={`absolute text-center font-medium ${slots[i] ? 'text-white' : 'text-[#656565]'}`}\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tleft: `${18 + i * 19}%`,\n\t\t\t\t\t\ttop: '14%',\n\t\t\t\t\t\twidth: '17%',\n\t\t\t\t\t\tfontSize: 'clamp(6px, 2.5cqi, 12px)',\n\t\t\t\t\t\t...(slots[i] && {textShadow: '0px 0px 6px rgba(255, 255, 255, 0.25)'}),\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{t('onboarding.raid.ssd-label', {number: i + 1})}\n\t\t\t\t</div>\n\t\t\t))}\n\n\t\t\t{/* Layers 3-5: SSDs with overlays (conditional) */}\n\t\t\t{slots.map((slot, i) => {\n\t\t\t\tif (!slot) return null\n\n\t\t\t\tconst isFailsafe = i === failsafeSlot\n\n\t\t\t\treturn (\n\t\t\t\t\t<div key={i}>\n\t\t\t\t\t\t{/* Layer 3: SSD image */}\n\t\t\t\t\t\t<img\n\t\t\t\t\t\t\tsrc='/assets/onboarding/ssd.webp'\n\t\t\t\t\t\t\talt={t('onboarding.raid.ssd-label', {number: i + 1})}\n\t\t\t\t\t\t\tdraggable={false}\n\t\t\t\t\t\t\tclassName='absolute'\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tleft: `${17 + i * 19}%`,\n\t\t\t\t\t\t\t\ttop: '17%',\n\t\t\t\t\t\t\t\twidth: '27.2%',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t{/* Layer 4: Size text - vertical gradient, underneath overlay */}\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName='absolute'\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tleft: `${17 + i * 19 - 4}%`,\n\t\t\t\t\t\t\t\ttop: '-2%',\n\t\t\t\t\t\t\t\twidth: '27.2%',\n\t\t\t\t\t\t\t\theight: '60%',\n\t\t\t\t\t\t\t\tdisplay: 'grid',\n\t\t\t\t\t\t\t\tplaceItems: 'center',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName='font-bold'\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tfontSize: 'clamp(12px, 6cqw, 24px)',\n\t\t\t\t\t\t\t\t\twritingMode: 'vertical-rl',\n\t\t\t\t\t\t\t\t\ttextOrientation: 'mixed',\n\t\t\t\t\t\t\t\t\ttransform: 'rotate(180deg)',\n\t\t\t\t\t\t\t\t\tbackground: 'linear-gradient(180deg, #FFFFFF 0%, #999999 100%)',\n\t\t\t\t\t\t\t\t\tWebkitBackgroundClip: 'text',\n\t\t\t\t\t\t\t\t\tbackgroundClip: 'text',\n\t\t\t\t\t\t\t\t\tWebkitTextFillColor: 'transparent',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{slot.size}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* Layer 5: Overlay (border + transparent bg) - brand color for storage, white for failsafe */}\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName='absolute rounded-lg'\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tleft: `${17.5 + i * 19}%`,\n\t\t\t\t\t\t\t\ttop: '13%',\n\t\t\t\t\t\t\t\twidth: '18%',\n\t\t\t\t\t\t\t\theight: '53%',\n\t\t\t\t\t\t\t\tborder: isFailsafe ? `1px solid ${FAILSAFE_COLOR}` : '1px solid hsl(var(--color-brand))',\n\t\t\t\t\t\t\t\tbackgroundColor: isFailsafe ? 'rgba(0, 132, 255, 0.1)' : 'hsl(var(--color-brand) / 0.1)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\n\t\t\t\t\t\t{/* Layer 6: Shield icon - only shown on failsafe slot */}\n\t\t\t\t\t\t{isFailsafe && (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclassName='absolute flex items-center justify-center'\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tleft: `${17.5 + i * 19}%`,\n\t\t\t\t\t\t\t\t\ttop: '11%',\n\t\t\t\t\t\t\t\t\twidth: '18%',\n\t\t\t\t\t\t\t\t\theight: '56%',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<IoShieldHalf className='size-6' style={{color: FAILSAFE_COLOR}} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t{/* Layer 7: Health status pill - shown at bottom of each SSD */}\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName='absolute flex items-center justify-center'\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tleft: `${17.5 + i * 19}%`,\n\t\t\t\t\t\t\t\ttop: '56%',\n\t\t\t\t\t\t\t\twidth: '18%',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\tonClick={() => onHealthClick?.(i)}\n\t\t\t\t\t\t\t\tclassName='relative flex items-center justify-center rounded-full border border-white/[0.16] bg-white/[0.08] transition-colors hover:bg-white/[0.12]'\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tpaddingLeft: 'clamp(8px, 3cqi, 14px)',\n\t\t\t\t\t\t\t\t\tpaddingRight: 'clamp(8px, 3cqi, 14px)',\n\t\t\t\t\t\t\t\t\tpaddingTop: 'clamp(1px, 0.5cqi, 3px)',\n\t\t\t\t\t\t\t\t\tpaddingBottom: 'clamp(1px, 0.5cqi, 3px)',\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<TbActivityHeartbeat\n\t\t\t\t\t\t\t\t\tclassName='text-white/60'\n\t\t\t\t\t\t\t\t\tstyle={{width: 'clamp(12px, 4cqi, 20px)', height: 'clamp(12px, 4cqi, 20px)'}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t{/* Radar ping warning dot - upper right of pill */}\n\t\t\t\t\t\t\t\t{slot.hasWarning && (\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclassName='absolute'\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\ttop: '-2px',\n\t\t\t\t\t\t\t\t\t\t\tright: '-2px',\n\t\t\t\t\t\t\t\t\t\t\twidth: 'clamp(6px, 2cqi, 10px)',\n\t\t\t\t\t\t\t\t\t\t\theight: 'clamp(6px, 2cqi, 10px)',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{/* Solid center dot */}\n\t\t\t\t\t\t\t\t\t\t<span className='absolute inset-0 rounded-full bg-[#F5A623]' />\n\t\t\t\t\t\t\t\t\t\t{/* Expanding ping ring */}\n\t\t\t\t\t\t\t\t\t\t<span className='absolute inset-0 animate-ping rounded-full bg-[#F5A623] opacity-75' />\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)\n\t\t\t})}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/onboarding/raid/use-raid-setup.ts",
    "content": "import prettyBytes from 'pretty-bytes'\n\nimport {RouterOutput, trpcReact} from '@/trpc/trpc'\n\n// Type from backend hardware.internalStorage.getDevices\nexport type StorageDevice = RouterOutput['hardware']['internalStorage']['getDevices'][number]\n\n// Matches backend z.enum(['storage', 'failsafe']) in user.register\nexport type RaidType = 'storage' | 'failsafe'\n\n// Format bytes without space, rounding to integer only for 3+ digit values (>=100) to avoid overflow\n// e.g., \"4.5TB\", \"45.2GB\", \"256GB\" - only 256.1GB gets rounded because 256 >= 100\nexport const formatSize = (bytes: number) => {\n\t// First format with 1 decimal to determine the numeric value\n\tconst formatted = prettyBytes(bytes, {maximumFractionDigits: 1})\n\tconst numericValue = parseFloat(formatted)\n\n\t// If 3+ digits (>=100), round to integer to keep string short\n\tconst fractionDigits = numericValue >= 100 ? 0 : 1\n\n\treturn prettyBytes(bytes, {maximumFractionDigits: fractionDigits}).replace(' ', '')\n}\n\n// Threshold % for lifetime usage warning (100 = the rated endurance being fully used)\n// Used by both ssd-tray.tsx (indicator dots) and ssd-health-dialog.tsx (warning display)\nexport const LIFETIME_WARNING_THRESHOLD = 80\n\n// Color for FailSafe storage visualization (white to contrast with brand color)\nexport const FAILSAFE_COLOR = '#FFFFFF'\n\n// Color for wasted storage visualization (red to indicate inefficiency)\nexport const WASTED_COLOR = '#FF2F63'\n\n// Get device health status - single source of truth for all health checks\n// Used by setup.tsx (warning indicators) and ssd-health-dialog.tsx (detailed display)\nexport function getDeviceHealth(device: StorageDevice) {\n\t// SMART status\n\tconst smartUnhealthy = device.smartStatus === 'unhealthy'\n\n\t// Lifetime remaining (inverse of lifetimeUsed)\n\tconst lifeRemaining = device.lifetimeUsed !== undefined ? Math.max(0, 100 - device.lifetimeUsed) : undefined\n\tconst lifeWarning = device.lifetimeUsed !== undefined && device.lifetimeUsed >= LIFETIME_WARNING_THRESHOLD\n\n\t// Temperature checks (critical takes precedence over warning)\n\tconst tempCritical =\n\t\tdevice.temperature !== undefined &&\n\t\tdevice.temperatureCritical !== undefined &&\n\t\tdevice.temperature >= device.temperatureCritical\n\n\tconst tempWarning =\n\t\t!tempCritical &&\n\t\tdevice.temperature !== undefined &&\n\t\tdevice.temperatureWarning !== undefined &&\n\t\tdevice.temperature >= device.temperatureWarning\n\n\t// Any warning present\n\tconst hasWarning = smartUnhealthy || lifeWarning || tempWarning || tempCritical\n\n\treturn {\n\t\thasWarning,\n\t\tsmartUnhealthy,\n\t\tlifeRemaining,\n\t\tlifeWarning,\n\t\ttempWarning,\n\t\ttempCritical,\n\t}\n}\n\n// Hook to detect storage devices\nexport function useDetectStorageDevices() {\n\tconst query = trpcReact.hardware.internalStorage.getDevices.useQuery(undefined, {\n\t\t// Poll every 10 seconds to keep temperature and health status up to date\n\t\trefetchInterval: 10_000,\n\t})\n\n\treturn {\n\t\tdevices: query.data ?? [],\n\t\t// We only use isLoading (not isFetching) so polling doesn't trigger the loading scanner\n\t\tisDetecting: query.isLoading,\n\t\terror: query.error?.message ?? null,\n\t\trefetch: query.refetch,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/routes/onboarding/restore.tsx",
    "content": "import {ChevronLeft, Loader2} from 'lucide-react'\nimport {useMemo, useState} from 'react'\nimport {Trans} from 'react-i18next/TransWithoutContext'\nimport {Link} from 'react-router-dom'\n\nimport {FadeScroller} from '@/components/fade-scroller'\nimport {Button} from '@/components/ui/button'\nimport {Input, PasswordInput} from '@/components/ui/input'\nimport {RestoreLocationDropdown} from '@/features/backups/components/restore-location-dropdown'\nimport {\n\tuseConnectToRepository,\n\tuseRepositoryBackups,\n\tuseRestoreBackup,\n\ttype Backup,\n} from '@/features/backups/hooks/use-backups'\nimport {BACKUP_FILE_NAME, getRepositoryPathFromBackupFile} from '@/features/backups/utils/filepath-helpers'\nimport {sortBackupsByTimeDesc} from '@/features/backups/utils/sort'\nimport AddNetworkShareDialog from '@/features/files/components/dialogs/add-network-share-dialog'\nimport {MiniBrowser} from '@/features/files/components/mini-browser'\nimport {useExternalStorage} from '@/features/files/hooks/use-external-storage'\nimport {formatFilesystemDate} from '@/features/files/utils/format-filesystem-date'\nimport {formatFilesystemSize} from '@/features/files/utils/format-filesystem-size'\nimport {useDeviceInfo} from '@/hooks/use-device-info'\nimport {useLanguage} from '@/hooks/use-language'\nimport {formGroupClass, Layout, primaryButtonProps} from '@/layouts/bare/shared'\nimport {cn} from '@/lib/utils'\nimport {OnboardingAction, OnboardingFooter} from '@/routes/onboarding/onboarding-footer'\nimport {t} from '@/utils/i18n'\n\n// Routes to Umbrel Pro instructions or regular restore flow\nexport default function BackupsRestoreOnboarding() {\n\tconst {isLoading: isLoadingDeviceCheck, data: deviceInfo} = useDeviceInfo()\n\tconst isUmbrelPro = deviceInfo?.umbrelHostEnvironment === 'umbrel-pro'\n\n\t// Show loading state while checking device type\n\tif (isLoadingDeviceCheck) {\n\t\treturn (\n\t\t\t<Layout\n\t\t\t\ttitle={t('backups-restore-header')}\n\t\t\t\tsubTitle=''\n\t\t\t\tfooter={<OnboardingFooter action={OnboardingAction.CREATE_ACCOUNT} />}\n\t\t\t>\n\t\t\t\t<div className='flex items-center justify-center py-12'>\n\t\t\t\t\t<Loader2 className='size-6 animate-spin text-white/50' />\n\t\t\t\t</div>\n\t\t\t</Layout>\n\t\t)\n\t}\n\n\t// Show Umbrel Pro specific instructions\n\tif (isUmbrelPro) {\n\t\treturn <UmbrelProRestoreInstructions />\n\t}\n\n\t// Show regular restore flow for non-Pro devices\n\treturn <RegularRestoreFlow />\n}\n\n// Umbrel Pro restore instructions component\n// Umbrel Pro requires completing onboarding first, then restoring via Settings\nfunction UmbrelProRestoreInstructions() {\n\tconst steps = [\n\t\tt('backups-restore-pro.step1'),\n\t\t<Trans\n\t\t\tkey='step2'\n\t\t\ti18nKey='backups-restore-pro.step2'\n\t\t\tcomponents={[<span key='path' className='font-medium text-white' />]}\n\t\t/>,\n\t\tt('backups-restore-pro.step3'),\n\t]\n\n\treturn (\n\t\t<Layout\n\t\t\ttitle={t('backups-restore-header')}\n\t\t\tsubTitle={t('backups-restore-pro.subtitle')}\n\t\t\tsubTitleMaxWidth={630}\n\t\t\tfooter={<OnboardingFooter action={OnboardingAction.CREATE_ACCOUNT} />}\n\t\t>\n\t\t\t<div className='mx-auto mt-2 mb-6 w-full max-w-[560px]'>\n\t\t\t\t{/* Steps */}\n\t\t\t\t<div className='divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>\n\t\t\t\t\t{steps.map((text, i) => (\n\t\t\t\t\t\t<div key={i} className='flex items-center gap-3 p-3 text-13 font-medium -tracking-3'>\n\t\t\t\t\t\t\t<div className='flex size-6 shrink-0 items-center justify-center rounded-full bg-white/10 text-11 text-white/60'>\n\t\t\t\t\t\t\t\t{i + 1}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div className='flex-1 text-white/70'>{text}</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\n\t\t\t\t<p className='mt-4 text-center text-13 text-white/50'>{t('backups-restore-pro.after-restore')}</p>\n\n\t\t\t\t{/* Action button */}\n\t\t\t\t<div className='flex justify-center pt-6'>\n\t\t\t\t\t<Link to='/onboarding/create-account' viewTransition {...primaryButtonProps}>\n\t\t\t\t\t\t{t('onboarding.start.continue')}\n\t\t\t\t\t</Link>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</Layout>\n\t)\n}\n\n// Regular restore flow for non-Pro devices\nfunction RegularRestoreFlow() {\n\tconst title = t('backups-restore-header')\n\tconst [lang] = useLanguage()\n\n\t// Steps\n\tenum Step {\n\t\tChooseLocation = 0,\n\t\tPassword = 1,\n\t\tBackups = 2,\n\t\tReview = 3,\n\t}\n\tconst [step, setStep] = useState<Step>(Step.ChooseLocation)\n\n\t// Repository connection state\n\tconst [repositoryPath, setRepositoryPath] = useState('')\n\tconst [encryptionPassword, setEncryptionPassword] = useState('')\n\tconst [connectedRepositoryId, setConnectedRepositoryId] = useState('')\n\tconst [selectedBackupId, setSelectedBackupId] = useState('')\n\n\t// Backups hooks\n\tconst {connectToRepository, isPending: isConnecting} = useConnectToRepository()\n\tconst {restoreBackup, isPending: isRestoring} = useRestoreBackup()\n\tconst {isExternalStorageSupported} = useExternalStorage()\n\n\t// Fetch backups when repository connected\n\tconst {data: backupsUnsorted, isLoading: isLoadingBackups} = useRepositoryBackups(connectedRepositoryId, {\n\t\tenabled: !!connectedRepositoryId,\n\t\tstaleTime: 15_000,\n\t})\n\n\tconst backups = useMemo(\n\t\t(): Backup[] => sortBackupsByTimeDesc(backupsUnsorted as Backup[] | undefined),\n\t\t[backupsUnsorted],\n\t)\n\n\t// Reuse MiniBrowser for browsing, with AddNetworkShareDialog for discovery\n\tconst [isBrowserOpen, setBrowserOpen] = useState(false)\n\tconst [browserRoot, setBrowserRoot] = useState<string | undefined>(undefined)\n\tconst [isAddNasOpen, setAddNasOpen] = useState(false)\n\n\t// Subtitles per-step\n\tconst stepSubtitle =\n\t\tstep === Step.ChooseLocation\n\t\t\t? t('backups-restore.restore-from-nas-or-external')\n\t\t\t: step === Step.Password\n\t\t\t\t? t('backups-restore.encryption-password-description')\n\t\t\t\t: step === Step.Backups\n\t\t\t\t\t? t('backups-restore.select-backup-description')\n\t\t\t\t\t: t('backups-restore.review-description')\n\n\tconst canContinueFromAddDevice = repositoryPath.trim().length > 0 && repositoryPath.endsWith(BACKUP_FILE_NAME)\n\n\t// Validation per-step\n\tconst canNext =\n\t\tstep === Step.ChooseLocation\n\t\t\t? canContinueFromAddDevice\n\t\t\t: step === Step.Password\n\t\t\t\t? !!encryptionPassword.trim()\n\t\t\t\t: step === Step.Backups\n\t\t\t\t\t? !!selectedBackupId\n\t\t\t\t\t: true\n\n\tasync function handleNext() {\n\t\tif (step === Step.ChooseLocation) {\n\t\t\tsetStep(Step.Password)\n\t\t\treturn\n\t\t}\n\t\tif (step === Step.Password) {\n\t\t\ttry {\n\t\t\t\t// Extract parent directory from backup file path\n\t\t\t\tconst parentPath = getRepositoryPathFromBackupFile(repositoryPath)\n\t\t\t\tconst id = await connectToRepository({path: parentPath, password: encryptionPassword})\n\t\t\t\tsetConnectedRepositoryId(id)\n\t\t\t\tsetStep(Step.Backups)\n\t\t\t} catch {\n\t\t\t\t// Error toasts are handled in the hook\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tif (step === Step.Backups) {\n\t\t\tsetStep(Step.Review)\n\t\t\treturn\n\t\t}\n\t}\n\n\treturn (\n\t\t<Layout\n\t\t\ttitle={title}\n\t\t\tsubTitle={stepSubtitle}\n\t\t\tsubTitleMaxWidth={630}\n\t\t\tfooter={<OnboardingFooter action={OnboardingAction.CREATE_ACCOUNT} />}\n\t\t>\n\t\t\t<div className='mx-auto mt-2 mb-6 w-full max-w-[720px]'>\n\t\t\t\t{step === 0 && (\n\t\t\t\t\t<div className='space-y-4'>\n\t\t\t\t\t\t<div className={formGroupClass + ' mx-auto w-full max-w-[560px] text-center'}>\n\t\t\t\t\t\t\t<div className='relative text-left'>\n\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\ttype='text'\n\t\t\t\t\t\t\t\t\tvalue={repositoryPath}\n\t\t\t\t\t\t\t\t\treadOnly\n\t\t\t\t\t\t\t\t\tclassName='pr-28'\n\t\t\t\t\t\t\t\t\ttitle={repositoryPath || ''}\n\t\t\t\t\t\t\t\t\taria-disabled={!repositoryPath}\n\t\t\t\t\t\t\t\t\ttabIndex={repositoryPath ? 0 : -1}\n\t\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\t\tif (!repositoryPath) return\n\t\t\t\t\t\t\t\t\t\tconst root = repositoryPath.startsWith('/Network') ? '/Network' : '/External'\n\t\t\t\t\t\t\t\t\t\tsetBrowserRoot(root)\n\t\t\t\t\t\t\t\t\t\tsetBrowserOpen(true)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<RestoreLocationDropdown\n\t\t\t\t\t\t\t\t\tonSelect={(root) => {\n\t\t\t\t\t\t\t\t\t\tsetBrowserRoot(root)\n\t\t\t\t\t\t\t\t\t\tsetBrowserOpen(true)\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tisExternalStorageSupported={isExternalStorageSupported}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{step === Step.Password && (\n\t\t\t\t\t<div className='space-y-4'>\n\t\t\t\t\t\t<div className={formGroupClass + ' mx-auto w-full max-w-[560px] text-center'}>\n\t\t\t\t\t\t\t<div className='mx-auto w-full max-w-[560px]'>\n\t\t\t\t\t\t\t\t<PasswordInput\n\t\t\t\t\t\t\t\t\tvalue={encryptionPassword}\n\t\t\t\t\t\t\t\t\tonValueChange={setEncryptionPassword}\n\t\t\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\t\t\tlabel={t('backups-restore.encryption-password')}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{step === Step.Backups && (\n\t\t\t\t\t<div className='space-y-4'>\n\t\t\t\t\t\t<div className='mx-auto w-full max-w-[560px]'>\n\t\t\t\t\t\t\t{isLoadingBackups ? (\n\t\t\t\t\t\t\t\t<div className='flex items-center justify-center gap-2 py-6 text-white/70'>\n\t\t\t\t\t\t\t\t\t<Loader2 className='size-4 animate-spin' aria-hidden='true' />\n\t\t\t\t\t\t\t\t\t<span>{t('files-listing.loading')}</span>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : backups.length === 0 ? (\n\t\t\t\t\t\t\t\t<div className='text-center text-xs opacity-60'>{t('backups-restore.no-backups-found')}</div>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<FadeScroller direction='y' className='max-h-[45vh] overflow-y-auto pr-1'>\n\t\t\t\t\t\t\t\t\t<div className='space-y-2'>\n\t\t\t\t\t\t\t\t\t\t{backups.map((backup, i) => {\n\t\t\t\t\t\t\t\t\t\t\tconst selected = backup.id === selectedBackupId\n\t\t\t\t\t\t\t\t\t\t\tconst isLatest = i === 0\n\t\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t\t<BackupSnapshot\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={backup.id}\n\t\t\t\t\t\t\t\t\t\t\t\t\tbackup={backup}\n\t\t\t\t\t\t\t\t\t\t\t\t\tselected={selected}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={() => setSelectedBackupId(backup.id)}\n\t\t\t\t\t\t\t\t\t\t\t\t\tlang={lang}\n\t\t\t\t\t\t\t\t\t\t\t\t\tisLatest={isLatest}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</FadeScroller>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t{step === Step.Review && (\n\t\t\t\t\t<div className='space-y-4 text-center'>\n\t\t\t\t\t\t<span className='text-center text-sm'>{t('backups-restore.restoring-from')}</span>\n\n\t\t\t\t\t\t{/* Backup snapshot */}\n\t\t\t\t\t\t<div className='mx-auto w-full max-w-[560px] text-left'>\n\t\t\t\t\t\t\t{(() => {\n\t\t\t\t\t\t\t\tconst backup = backups.find((x) => x.id === selectedBackupId)\n\t\t\t\t\t\t\t\tif (!backup) return null\n\n\t\t\t\t\t\t\t\tconst backupIndex = backups.findIndex((x) => x.id === selectedBackupId)\n\t\t\t\t\t\t\t\tconst isLatest = backupIndex === 0\n\n\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t<BackupSnapshot backup={backup} selected={false} lang={lang} noHover={true} isLatest={isLatest} />\n\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t})()}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\n\t\t\t\t<div className='mt-6 flex items-center justify-center gap-2 pt-4'>\n\t\t\t\t\t{step > Step.ChooseLocation && (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\tclassName='flex size-10 items-center justify-center rounded-full border border-white/10 bg-white/5 transition-colors duration-300 hover:bg-white/10 focus-visible:border-white/50 focus-visible:bg-white/10 focus-visible:outline-hidden'\n\t\t\t\t\t\t\tonClick={() => setStep((s) => (s === 0 ? 0 : ((s - 1) as 0 | 1 | 2)))}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ChevronLeft className='size-5' />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t\t{step < Step.Review ? (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\tprimaryButtonProps.className,\n\t\t\t\t\t\t\t\t!canNext || (isConnecting && 'pointer-events-none opacity-50'),\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\tonClick={handleNext}\n\t\t\t\t\t\t\tdisabled={!canNext || isConnecting}\n\t\t\t\t\t\t\tstyle={primaryButtonProps.style}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className={isConnecting ? 'opacity-0' : 'opacity-100'}>{t('continue')}</span>\n\t\t\t\t\t\t\t{isConnecting && <Loader2 className='absolute size-4 animate-spin' />}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\tclassName={cn(primaryButtonProps.className, isRestoring && 'pointer-events-none opacity-50')}\n\t\t\t\t\t\t\tonClick={async () => {\n\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\tawait restoreBackup(selectedBackupId)\n\t\t\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t\t\t// Error toasts are handled in the hook\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tdisabled={!selectedBackupId || isRestoring}\n\t\t\t\t\t\t\tstyle={primaryButtonProps.style}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className={isRestoring ? 'opacity-0' : 'opacity-100'}>{t('backups-restore')}</span>\n\t\t\t\t\t\t\t{isRestoring && <Loader2 className='absolute size-4 animate-spin' />}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t{/* MiniBrowser for repository path selection */}\n\t\t\t\t<MiniBrowser\n\t\t\t\t\topen={isBrowserOpen}\n\t\t\t\t\tonOpenChange={setBrowserOpen}\n\t\t\t\t\trootPath={browserRoot || '/'}\n\t\t\t\t\tonOpenPath={repositoryPath || browserRoot || '/'}\n\t\t\t\t\tpreselectOnOpen={true}\n\t\t\t\t\tselectionMode='folders'\n\t\t\t\t\ttitle={t('backups-restore.select-backup-file')}\n\t\t\t\t\tsubtitle={\n\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\ti18nKey='backups-restore.select-backup-file-only'\n\t\t\t\t\t\t\tvalues={{backupFileName: BACKUP_FILE_NAME}}\n\t\t\t\t\t\t\tcomponents={{\n\t\t\t\t\t\t\t\tbold: <span className='text-brand-lightest' />,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t}\n\t\t\t\t\tselectableFilter={(entry) => entry.name === BACKUP_FILE_NAME}\n\t\t\t\t\tonSelect={(p) => {\n\t\t\t\t\t\tsetRepositoryPath(p)\n\t\t\t\t\t\tsetBrowserOpen(false)\n\t\t\t\t\t}}\n\t\t\t\t\tactions={\n\t\t\t\t\t\tbrowserRoot === '/Network' ? (\n\t\t\t\t\t\t\t<Button size='sm' variant='default' onClick={() => setAddNasOpen(true)}>\n\t\t\t\t\t\t\t\t{t('backups.add-umbrel-or-nas')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t) : null\n\t\t\t\t\t}\n\t\t\t\t/>\n\n\t\t\t\t{/* NAS discovery dialog; reopens MiniBrowser on success */}\n\t\t\t\t<AddNetworkShareDialog\n\t\t\t\t\topen={isAddNasOpen}\n\t\t\t\t\tonOpenChange={(v) => setAddNasOpen(v)}\n\t\t\t\t\tsuppressNavigateOnAdd\n\t\t\t\t\tonAdded={() => {\n\t\t\t\t\t\tsetBrowserRoot('/Network')\n\t\t\t\t\t\tsetBrowserOpen(true)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</Layout>\n\t)\n}\n\nfunction BackupSnapshot({\n\tbackup,\n\tselected = false,\n\tonClick,\n\tlang,\n\tnoHover = false,\n\tisLatest = false,\n}: {\n\tbackup: Backup\n\tselected?: boolean\n\tonClick?: () => void\n\tlang: string\n\tnoHover?: boolean\n\tisLatest?: boolean\n}) {\n\tconst when = backup.time\n\tconst label = when ? formatFilesystemDate(when, lang as any) : t('backups-restore.unknown-date')\n\tconst size = backup.size\n\tconst sizeTxt = typeof size === 'number' ? formatFilesystemSize(size) : t('unknown')\n\n\treturn (\n\t\t<div className='relative'>\n\t\t\t<div\n\t\t\t\tclassName={[\n\t\t\t\t\t'flex w-full items-center justify-between rounded-8 border px-4 py-3',\n\t\t\t\t\tselected ? 'border-brand bg-brand/15' : 'border-white/10',\n\t\t\t\t\t!noHover && !selected ? 'hover:bg-white/5' : '',\n\t\t\t\t\t'',\n\t\t\t\t].join(' ')}\n\t\t\t\tonClick={onClick}\n\t\t\t\ttitle={backup.id}\n\t\t\t>\n\t\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t\t<div className='truncate text-sm'>{label}</div>\n\t\t\t\t</div>\n\t\t\t\t{isLatest && (\n\t\t\t\t\t<div className='mr-2 shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-[10px] tracking-wide uppercase opacity-80'>\n\t\t\t\t\t\t{t('backups-restore.latest')}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t{sizeTxt && (\n\t\t\t\t\t<div className='shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-[10px] tracking-wide uppercase opacity-80'>\n\t\t\t\t\t\t{sizeTxt}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/onboarding/use-onboarding-device.ts",
    "content": "import {UmbrelHostEnvironment} from '@/constants'\nimport {useDeviceInfo} from '@/hooks/use-device-info'\n\ntype OnboardingDevice = {\n\tshowDevice: boolean\n\timage: string | null\n\timageClassName: string\n\tname: string | null\n}\n\nconst deviceConfig: Partial<\n\tRecord<\n\t\tUmbrelHostEnvironment,\n\t\t{\n\t\t\timage: string\n\t\t\timageClassName: string\n\t\t\tname: string\n\t\t}\n\t>\n> = {\n\t'umbrel-pro': {\n\t\timage: '/assets/onboarding/pro-front.webp',\n\t\timageClassName: 'w-64 md:w-96',\n\t\tname: 'Umbrel Pro',\n\t},\n\t'umbrel-home': {\n\t\timage: '/assets/onboarding/home-front.webp',\n\t\timageClassName: 'w-48 md:w-64',\n\t\tname: 'Umbrel Home',\n\t},\n}\n\nconst DEFAULT: OnboardingDevice = {\n\tshowDevice: false,\n\timage: null,\n\timageClassName: '',\n\tname: null,\n}\n\nexport function useOnboardingDevice(): OnboardingDevice {\n\tconst {isLoading, data} = useDeviceInfo()\n\n\tif (isLoading || !data?.umbrelHostEnvironment) return DEFAULT\n\n\tconst config = deviceConfig[data.umbrelHostEnvironment]\n\tif (!config) return DEFAULT\n\n\treturn {\n\t\tshowDevice: true,\n\t\timage: config.image,\n\t\timageClassName: config.imageClassName,\n\t\tname: config.name,\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/routes/raid-error/index.tsx",
    "content": "import {useState} from 'react'\nimport {Trans} from 'react-i18next/TransWithoutContext'\nimport {TbActivityHeartbeat, TbAlertTriangle, TbAlertTriangleFilled, TbCircleCheckFilled} from 'react-icons/tb'\nimport {Navigate} from 'react-router-dom'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {Button} from '@/components/ui/button'\nimport {BareCoverMessage} from '@/components/ui/cover-message'\nimport {Loading} from '@/components/ui/loading'\nimport {toast} from '@/components/ui/toast'\nimport {OnboardingPage} from '@/layouts/bare/onboarding-page'\nimport {useGlobalSystemState} from '@/providers/global-system-state'\nimport {SsdHealthDialog, useSsdHealthDialog} from '@/routes/onboarding/raid/ssd-health-dialog'\nimport {SsdSlot, SsdTray} from '@/routes/onboarding/raid/ssd-tray'\nimport {formatSize, getDeviceHealth, useDetectStorageDevices} from '@/routes/onboarding/raid/use-raid-setup'\nimport {LanguageDropdown} from '@/routes/settings/_components/language-dropdown'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nconst Highlight = ({children}: {children?: React.ReactNode}) => <span className='text-white'>{children}</span>\n\nfunction TroubleshootingStep({\n\tnumber,\n\ttitle,\n\tdescription,\n\tbuttonText,\n\tonClick,\n\tdisabled,\n}: {\n\tnumber: number\n\ttitle: string\n\tdescription: string\n\tbuttonText: string\n\tonClick: () => void\n\tdisabled?: boolean\n}) {\n\treturn (\n\t\t<div className='flex items-center gap-3 p-3'>\n\t\t\t<span className='flex size-5 shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold'>\n\t\t\t\t{number}\n\t\t\t</span>\n\t\t\t<div className='flex flex-1 flex-col gap-0.5'>\n\t\t\t\t<span className='text-12 font-semibold text-white'>{title}</span>\n\t\t\t\t<span className='text-12 text-white/50'>{description}</span>\n\t\t\t</div>\n\t\t\t<Button size='sm' variant='default' onClick={onClick} disabled={disabled} className='h-7 px-3 text-11'>\n\t\t\t\t{buttonText}\n\t\t\t</Button>\n\t\t</div>\n\t)\n}\n\nexport default function RaidErrorScreen() {\n\t// Dialog states\n\tconst [showShutdownDialog, setShowShutdownDialog] = useState(false)\n\tconst [showFactoryResetDialog, setShowFactoryResetDialog] = useState(false)\n\n\t// Action triggered states - for immediate UI feedback before overlay appears\n\tconst [restartTriggered, setRestartTriggered] = useState(false)\n\tconst [shutdownTriggered, setShutdownTriggered] = useState(false)\n\n\t// Check if there's actually a RAID mount failure - we redirect away if not\n\tconst mountFailureQ = trpcReact.hardware.raid.checkRaidMountFailure.useQuery(undefined, {\n\t\tretry: false,\n\t})\n\n\t// Get RAID status for each device (which drives were in RAID and their status)\n\tconst raidDevicesQ = trpcReact.hardware.raid.checkRaidMountFailureDevices.useQuery()\n\n\t// Get detailed device info (for drives that ARE detected)\n\tconst {devices: availableDevices} = useDetectStorageDevices()\n\n\t// SSD Health dialog state\n\tconst healthDialog = useSsdHealthDialog()\n\n\t// System actions - we use global state here for proper overlay covers\n\tconst {restart, shutdown} = useGlobalSystemState()\n\n\t// In recovery mode (RAID mount failure), factory reset doesn't require a password\n\t// We call the mutation directly - global state will show ResettingCover based on status\n\tconst factoryResetMut = trpcReact.system.factoryReset.useMutation({\n\t\tonError: (error) => {\n\t\t\ttoast.error(t('raid-error.factory-reset-failed'), {\n\t\t\t\tdescription: error.message,\n\t\t\t})\n\t\t},\n\t})\n\n\t// If no mount failure, we redirect to home\n\tif (mountFailureQ.isLoading) {\n\t\treturn (\n\t\t\t<BareCoverMessage>\n\t\t\t\t<Loading />\n\t\t\t</BareCoverMessage>\n\t\t)\n\t}\n\n\tif (mountFailureQ.data === false || mountFailureQ.isError) {\n\t\treturn <Navigate to='/' replace />\n\t}\n\n\tconst raidDevices = raidDevicesQ.data\n\n\t// Build list of DETECTED drives only (with accurate slot info)\n\t// Filter out any drives without a known slot number\n\tconst detectedDrives = availableDevices\n\t\t.filter((device) => device.slot !== undefined)\n\t\t.map((device) => {\n\t\t\tconst hasHealthWarning = getDeviceHealth(device).hasWarning\n\t\t\treturn {\n\t\t\t\tslotNum: device.slot as number,\n\t\t\t\tdevice,\n\t\t\t\thasHealthWarning,\n\t\t\t}\n\t\t})\n\n\t// Count missing RAID drives (configured but not detected)\n\tconst missingDriveCount = raidDevices?.filter((rd) => !rd.isOk).length ?? 0\n\n\t// Convert to SsdTray format - only show detected drives in their actual slots\n\tconst traySlots: (SsdSlot | null)[] = [1, 2, 3, 4].map((slotNum) => {\n\t\tconst detected = detectedDrives.find((d) => d.slotNum === slotNum)\n\t\tif (!detected) return null\n\t\treturn {\n\t\t\tsize: formatSize(detected.device.size),\n\t\t\thasWarning: detected.hasHealthWarning,\n\t\t}\n\t})\n\n\treturn (\n\t\t<OnboardingPage>\n\t\t\t<div className='flex flex-1 flex-col md:flex-row'>\n\t\t\t\t{/* Left side - content */}\n\t\t\t\t<div className='flex flex-1 flex-col items-center justify-center gap-4 px-4 py-6 md:items-start md:justify-start md:py-8 md:pr-0 md:pl-6'>\n\t\t\t\t\t{/* Header */}\n\t\t\t\t\t<div className='flex flex-col items-center gap-2 md:items-start'>\n\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t<TbAlertTriangleFilled className='size-[22px] text-[#F5A623]' />\n\t\t\t\t\t\t\t<h1\n\t\t\t\t\t\t\t\tclassName='text-[18px] font-bold text-white/85 md:text-[20px]'\n\t\t\t\t\t\t\t\tstyle={{textShadow: '0 0 8px rgba(255, 255, 255, 0.2), 0 0 16px rgba(255, 255, 255, 0.15)'}}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{t('raid-error.title')}\n\t\t\t\t\t\t\t</h1>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<p className='max-w-[500px] text-center text-[14px] text-white/50 md:text-left md:text-[15px]'>\n\t\t\t\t\t\t\t{t('raid-error.description')}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Drive status */}\n\t\t\t\t\t<div className='flex w-full max-w-[420px] flex-col rounded-xl bg-white/5 p-3 md:max-w-none'>\n\t\t\t\t\t\t{/* Detected drives */}\n\t\t\t\t\t\t{detectedDrives.map((drive) => {\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tkey={drive.slotNum}\n\t\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\t\tonClick={() => healthDialog.openDialog(drive.device, drive.slotNum)}\n\t\t\t\t\t\t\t\t\tclassName='-mx-1 flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-white/5'\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<div className='flex flex-col gap-0.5'>\n\t\t\t\t\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t\t\t\t\t{drive.hasHealthWarning ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<TbAlertTriangle className='size-5 text-[#F5A623]' />\n\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t<TbCircleCheckFilled className='size-5 text-brand' />\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t<span className='text-[14px] font-medium text-white/60 md:text-[15px]'>\n\t\t\t\t\t\t\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\t\t\t\t\t\t\ti18nKey='raid-error.ssd-in-slot'\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalues={{size: formatSize(drive.device.size), slot: drive.slotNum}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tcomponents={{highlight: <Highlight />}}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t{drive.hasHealthWarning && (\n\t\t\t\t\t\t\t\t\t\t\t<p className='ml-7 text-[12px] text-[#F5A623]/80 md:text-[13px]'>\n\t\t\t\t\t\t\t\t\t\t\t\t{t('raid-error.health-warning')}\n\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t{/* Health pill */}\n\t\t\t\t\t\t\t\t\t<div className='relative flex items-center justify-center rounded-full border border-white/[0.16] bg-white/[0.08] px-3 py-0.5'>\n\t\t\t\t\t\t\t\t\t\t<TbActivityHeartbeat className='size-4 text-white/60' />\n\t\t\t\t\t\t\t\t\t\t{drive.hasHealthWarning && (\n\t\t\t\t\t\t\t\t\t\t\t<span className='absolute -top-0.5 -right-0.5'>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className='absolute inset-0 size-2.5 rounded-full bg-[#F5A623]' />\n\t\t\t\t\t\t\t\t\t\t\t\t<span className='absolute inset-0 size-2.5 animate-ping rounded-full bg-[#F5A623] opacity-75' />\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t})}\n\n\t\t\t\t\t\t{/* Missing SSDs warning - at bottom of card */}\n\t\t\t\t\t\t{missingDriveCount > 0 && (\n\t\t\t\t\t\t\t<div className='flex items-center gap-2 px-1 pt-2'>\n\t\t\t\t\t\t\t\t<TbAlertTriangleFilled className='size-5 shrink-0 text-[#FF3434]' />\n\t\t\t\t\t\t\t\t<span className='text-[14px] text-white/50 md:text-[15px]'>\n\t\t\t\t\t\t\t\t\t{missingDriveCount === 1\n\t\t\t\t\t\t\t\t\t\t? t('raid-error.missing-ssd-one')\n\t\t\t\t\t\t\t\t\t\t: t('raid-error.missing-ssd-multiple', {count: missingDriveCount})}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Troubleshooting steps */}\n\t\t\t\t\t<div className='w-full max-w-[420px] md:max-w-none'>\n\t\t\t\t\t\t<div className='divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>\n\t\t\t\t\t\t\t<TroubleshootingStep\n\t\t\t\t\t\t\t\tnumber={1}\n\t\t\t\t\t\t\t\ttitle={t('raid-error.step-restart.title')}\n\t\t\t\t\t\t\t\tdescription={t('raid-error.step-restart.description')}\n\t\t\t\t\t\t\t\tbuttonText={t('raid-error.step-restart.button')}\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetRestartTriggered(true)\n\t\t\t\t\t\t\t\t\trestart()\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tdisabled={restartTriggered}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<TroubleshootingStep\n\t\t\t\t\t\t\t\tnumber={2}\n\t\t\t\t\t\t\t\ttitle={t('raid-error.step-check-connections.title')}\n\t\t\t\t\t\t\t\tdescription={t('raid-error.step-check-connections.description')}\n\t\t\t\t\t\t\t\tbuttonText={t('raid-error.step-check-connections.button')}\n\t\t\t\t\t\t\t\tonClick={() => setShowShutdownDialog(true)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<TroubleshootingStep\n\t\t\t\t\t\t\t\tnumber={3}\n\t\t\t\t\t\t\t\ttitle={t('raid-error.step-factory-reset.title')}\n\t\t\t\t\t\t\t\tdescription={t('raid-error.step-factory-reset.description')}\n\t\t\t\t\t\t\t\tbuttonText={t('raid-error.step-factory-reset.button')}\n\t\t\t\t\t\t\t\tonClick={() => setShowFactoryResetDialog(true)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t{/* Right side - SSD tray visualization (hidden on mobile) */}\n\t\t\t\t<div className='hidden flex-1 flex-col items-end justify-center md:-mr-6 md:flex'>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName='w-[95%]'\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tmaskImage: 'linear-gradient(to bottom, black 80%, transparent 100%)',\n\t\t\t\t\t\t\tWebkitMaskImage: 'linear-gradient(to bottom, black 80%, transparent 100%)',\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t<SsdTray\n\t\t\t\t\t\t\tslots={traySlots}\n\t\t\t\t\t\t\tfailsafeSlot={-1}\n\t\t\t\t\t\t\tonHealthClick={(slotIndex) => {\n\t\t\t\t\t\t\t\tconst slotNum = slotIndex + 1\n\t\t\t\t\t\t\t\tconst drive = detectedDrives.find((d) => d.slotNum === slotNum)\n\t\t\t\t\t\t\t\tif (drive) {\n\t\t\t\t\t\t\t\t\thealthDialog.openDialog(drive.device, slotNum)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* Language selector - needed for edge case of fresh browser + RAID failure (defaults to English) */}\n\t\t\t<div className='flex items-center justify-center pb-2'>\n\t\t\t\t<LanguageDropdown />\n\t\t\t</div>\n\n\t\t\t{/* SSD Health dialog */}\n\t\t\t{healthDialog.selectedDevice && (\n\t\t\t\t<SsdHealthDialog\n\t\t\t\t\tdevice={healthDialog.selectedDevice.device}\n\t\t\t\t\tslotNumber={healthDialog.selectedDevice.slotNumber}\n\t\t\t\t\topen={healthDialog.open}\n\t\t\t\t\tonOpenChange={healthDialog.onOpenChange}\n\t\t\t\t/>\n\t\t\t)}\n\n\t\t\t{/* Shutdown confirmation dialog */}\n\t\t\t<AlertDialog open={showShutdownDialog} onOpenChange={setShowShutdownDialog}>\n\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t\t<AlertDialogTitle>{t('raid-error.shutdown-dialog.title')}</AlertDialogTitle>\n\t\t\t\t\t\t<AlertDialogDescription>{t('raid-error.shutdown-dialog.description')}</AlertDialogDescription>\n\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t\t<AlertDialogAction\n\t\t\t\t\t\t\tvariant='destructive'\n\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\t\tsetShutdownTriggered(true)\n\t\t\t\t\t\t\t\tshutdown()\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tdisabled={shutdownTriggered}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('shut-down')}\n\t\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t\t<AlertDialogCancel disabled={shutdownTriggered}>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t</AlertDialogContent>\n\t\t\t</AlertDialog>\n\n\t\t\t{/* Factory reset confirmation dialog */}\n\t\t\t<AlertDialog open={showFactoryResetDialog} onOpenChange={setShowFactoryResetDialog}>\n\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t\t<AlertDialogTitle>{t('raid-error.factory-reset-dialog.title')}</AlertDialogTitle>\n\t\t\t\t\t\t<AlertDialogDescription>{t('raid-error.factory-reset-dialog.description')}</AlertDialogDescription>\n\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t\t<AlertDialogAction\n\t\t\t\t\t\t\tvariant='destructive'\n\t\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\t\tfactoryResetMut.mutate({})\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tdisabled={factoryResetMut.isPending}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t('factory-reset')}\n\t\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t\t<AlertDialogCancel disabled={factoryResetMut.isPending}>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t</AlertDialogContent>\n\t\t\t</AlertDialog>\n\t\t</OnboardingPage>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/2fa-disable.tsx",
    "content": "import {Dialog, DialogContent, DialogHeader, DialogTitle} from '@/components/ui/dialog'\nimport {Drawer, DrawerContent, DrawerHeader, DrawerTitle} from '@/components/ui/drawer'\nimport {PinInput} from '@/components/ui/pin-input'\nimport {Separator} from '@/components/ui/separator'\nimport {use2fa} from '@/hooks/use-2fa'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {t} from '@/utils/i18n'\n\nexport default function TwoFactorDisableDialog() {\n\tconst title = t('2fa.disable.title')\n\n\tconst isMobile = useIsMobile()\n\tconst dialogProps = useSettingsDialogProps()\n\n\tconst {disable} = use2fa(() => dialogProps.onOpenChange(false))\n\n\tif (isMobile) {\n\t\treturn (\n\t\t\t<Drawer {...dialogProps}>\n\t\t\t\t<DrawerContent fullHeight>\n\t\t\t\t\t<DrawerHeader>\n\t\t\t\t\t\t<DrawerTitle>{title}</DrawerTitle>\n\t\t\t\t\t</DrawerHeader>\n\t\t\t\t\t<Inner onCodeCheck={disable} />\n\t\t\t\t</DrawerContent>\n\t\t\t</Drawer>\n\t\t)\n\t}\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogContent className='flex flex-col items-center gap-5'>\n\t\t\t\t<DialogHeader>\n\t\t\t\t\t<DialogTitle>{title}</DialogTitle>\n\t\t\t\t</DialogHeader>\n\t\t\t\t<Inner onCodeCheck={disable} />\n\t\t\t</DialogContent>\n\t\t</Dialog>\n\t)\n}\n\nfunction Inner({onCodeCheck}: {onCodeCheck: (code: string) => Promise<boolean>}) {\n\treturn (\n\t\t<>\n\t\t\t<Separator />\n\t\t\t<p className='text-17 leading-tight font-normal -tracking-2'>{t('2fa.enter-code')}</p>\n\t\t\t<PinInput autoFocus length={6} onCodeCheck={onCodeCheck} />\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/2fa-enable.tsx",
    "content": "import {motion} from 'motion/react'\nimport {ReactNode, useEffect} from 'react'\nimport QRCode from 'react-qr-code'\n\nimport {CopyableField} from '@/components/ui/copyable-field'\nimport {Dialog, DialogDescription, DialogHeader, DialogScrollableContent, DialogTitle} from '@/components/ui/dialog'\nimport {\n\tDrawer,\n\tDrawerContent,\n\tDrawerDescription,\n\tDrawerHeader,\n\tDrawerScroller,\n\tDrawerTitle,\n} from '@/components/ui/drawer'\nimport {Loading} from '@/components/ui/loading'\nimport {PinInput} from '@/components/ui/pin-input'\nimport {Separator} from '@/components/ui/separator'\nimport {use2fa} from '@/hooks/use-2fa'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nexport default function TwoFactorEnableDialog() {\n\tconst title = t('2fa.enable.title')\n\tconst scanThisMessage = t('2fa.enable.scan-this')\n\n\tconst isMobile = useIsMobile()\n\tconst dialogProps = useSettingsDialogProps()\n\n\t// const dialogProps = useDialogOpenProps('2fa-enable')\n\tconst {enable, totpUri, generateTotpUri} = use2fa(() => dialogProps.onOpenChange(false))\n\tuseEffect(generateTotpUri, [generateTotpUri])\n\n\tif (isMobile) {\n\t\treturn (\n\t\t\t<Drawer {...dialogProps}>\n\t\t\t\t<DrawerContent fullHeight>\n\t\t\t\t\t<DrawerHeader>\n\t\t\t\t\t\t<DrawerTitle>{title}</DrawerTitle>\n\t\t\t\t\t\t<DrawerDescription>{t('2fa-description')}</DrawerDescription>\n\t\t\t\t\t</DrawerHeader>\n\t\t\t\t\t<DrawerScroller>\n\t\t\t\t\t\t<p className={paragraphClass}>{scanThisMessage}</p>\n\t\t\t\t\t\t<div className='flex flex-col items-center gap-5'>\n\t\t\t\t\t\t\t{/* NOTE: keep this small so that the pin input is visible within the viewport */}\n\t\t\t\t\t\t\t<Inner qrCodeSize={150} totpUri={totpUri} onCodeCheck={enable} />\n\t\t\t\t\t\t\t<div className='mb-4' />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</DrawerScroller>\n\t\t\t\t</DrawerContent>\n\t\t\t</Drawer>\n\t\t)\n\t}\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogScrollableContent>\n\t\t\t\t<div className='flex flex-col items-center gap-5 p-8'>\n\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t<DialogTitle>{title}</DialogTitle>\n\t\t\t\t\t\t<DialogDescription>{scanThisMessage}</DialogDescription>\n\t\t\t\t\t</DialogHeader>\n\t\t\t\t\t<Inner totpUri={totpUri} onCodeCheck={enable} />\n\t\t\t\t</div>\n\t\t\t</DialogScrollableContent>\n\t\t</Dialog>\n\t)\n}\n\nconst paragraphClass = tw`text-left text-13 font-normal leading-tight -tracking-2 text-white/60`\n\nfunction Inner({\n\tqrCodeSize = 240,\n\ttotpUri,\n\tonCodeCheck,\n}: {\n\tqrCodeSize?: number\n\ttotpUri: string\n\tonCodeCheck: (code: string) => Promise<boolean>\n}) {\n\treturn (\n\t\t<>\n\t\t\t<AnimateInQr size={qrCodeSize} animateIn={!!totpUri}>\n\t\t\t\t<QRCode\n\t\t\t\t\tsize={256}\n\t\t\t\t\tstyle={{height: 'auto', maxWidth: '100%', width: '100%', opacity: totpUri ? 1 : 0}}\n\t\t\t\t\tvalue={totpUri}\n\t\t\t\t\tviewBox={`0 0 256 256`}\n\t\t\t\t/>\n\t\t\t</AnimateInQr>\n\t\t\t<div className='w-full space-y-2 text-center'>\n\t\t\t\t<p className='text-13 leading-tight font-normal -tracking-2 text-white/60'>{t('2fa.enable.or-paste')}</p>\n\t\t\t\t<CopyableField value={totpUri} />\n\t\t\t</div>\n\t\t\t<Separator />\n\t\t\t<p className='text-center text-sm leading-tight font-normal -tracking-2'>{t('2fa.enter-code')}</p>\n\t\t\t<PinInput length={6} onCodeCheck={onCodeCheck} />\n\t\t</>\n\t)\n}\n\nconst AnimateInQr = ({children, size, animateIn}: {children: ReactNode; size: number; animateIn?: boolean}) => (\n\t<div\n\t\tclassName='relative mx-auto'\n\t\tstyle={{\n\t\t\tperspective: '300px',\n\t\t\twidth: size + 'px',\n\t\t}}\n\t>\n\t\t<motion.div\n\t\t\tclassName='rounded-8 bg-white p-3'\n\t\t\tinitial={{\n\t\t\t\topacity: 0,\n\t\t\t\trotateX: 20,\n\t\t\t\trotateY: 10,\n\t\t\t\trotateZ: 0,\n\t\t\t\tscale: 0.5,\n\t\t\t}}\n\t\t\tanimate={\n\t\t\t\tanimateIn && {\n\t\t\t\t\topacity: 1,\n\t\t\t\t\trotateX: 0,\n\t\t\t\t\trotateY: 0,\n\t\t\t\t\trotateZ: 0,\n\t\t\t\t\tscale: 1,\n\t\t\t\t}\n\t\t\t}\n\t\t\ttransition={{duration: 0.15, ease: 'easeOut'}}\n\t\t>\n\t\t\t{children}\n\t\t</motion.div>\n\t\t{!animateIn && (\n\t\t\t<div className='absolute inset-0 grid place-items-center'>\n\t\t\t\t<Loading />\n\t\t\t</div>\n\t\t)}\n\t</div>\n)\n"
  },
  {
    "path": "packages/ui/src/routes/settings/2fa.tsx",
    "content": "import {useState} from 'react'\n\nimport {use2fa} from '@/hooks/use-2fa'\nimport TwoFactorDisableDialog from '@/routes/settings/2fa-disable'\nimport TwoFactorEnableDialog from '@/routes/settings/2fa-enable'\n\nexport function TwoFactorDialog() {\n\tconst {isEnabled} = use2fa()\n\n\t// Need to do this because when the child component `isEnabled` changes, the other dialog will appear for a split second before the dialog closes\n\tconst [mountEnabled] = useState(isEnabled)\n\n\tif (mountEnabled) {\n\t\treturn <TwoFactorDisableDialog />\n\t} else {\n\t\treturn <TwoFactorEnableDialog />\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/app-store-preferences-content.tsx",
    "content": "import {useState} from 'react'\nimport {TbChevronRight} from 'react-icons/tb'\n\nimport {ChevronDown} from '@/components/chevron-down'\nimport {Button} from '@/components/ui/button'\nimport {\n\tDropdownMenu,\n\tDropdownMenuCheckboxItem,\n\tDropdownMenuContent,\n\tDropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport {listClass, listItemClass} from '@/components/ui/list'\nimport {SegmentedControl} from '@/components/ui/segmented-control'\nimport {Switch} from '@/components/ui/switch'\n\nexport function AppStorePreferencesContent() {\n\tconst tabs = [\n\t\t{id: 'auto-update', label: 'Auto-update'},\n\t\t{id: 'notifications', label: 'Notifications'},\n\t\t{id: 'uninstall', label: 'Uninstall'},\n\t]\n\tconst [activeTab, setActiveTab] = useState(tabs[0].id)\n\n\treturn (\n\t\t<>\n\t\t\t<div className={listClass}>\n\t\t\t\t<label className={listItemClass}>\n\t\t\t\t\t<span>Allow a specific function</span>\n\t\t\t\t\t<Switch />\n\t\t\t\t</label>\n\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\tSingle value selector\n\t\t\t\t\t<DropdownMenu>\n\t\t\t\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t\t\t\t<Button size='sm'>\n\t\t\t\t\t\t\t\tValue label\n\t\t\t\t\t\t\t\t<ChevronDown />\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t\t\t<DropdownMenuContent>\n\t\t\t\t\t\t\t<DropdownMenuCheckboxItem checked>English</DropdownMenuCheckboxItem>\n\t\t\t\t\t\t\t<DropdownMenuCheckboxItem>French</DropdownMenuCheckboxItem>\n\t\t\t\t\t\t</DropdownMenuContent>\n\t\t\t\t\t</DropdownMenu>\n\t\t\t\t</div>\n\t\t\t\t<label className={listItemClass}>\n\t\t\t\t\tMulti-level setting\n\t\t\t\t\t<TbChevronRight />\n\t\t\t\t</label>\n\t\t\t</div>\n\t\t\t<SegmentedControl size='lg' tabs={tabs} value={activeTab} onValueChange={setActiveTab} />\n\t\t\t<div className={listClass}>\n\t\t\t\t<label className={listItemClass}>Auto-update all apps</label>\n\t\t\t</div>\n\t\t\t<div className={listClass}>\n\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t<span>Lighting node</span>\n\t\t\t\t\t<Button size='sm' className='text-destructive2-lightest'>\n\t\t\t\t\t\tUninstall\n\t\t\t\t\t</Button>\n\t\t\t\t</div>\n\t\t\t\t<div className={listItemClass}>\n\t\t\t\t\t<span>Lighting node</span>\n\t\t\t\t\t<Button size='sm' className='text-destructive2-lightest'>\n\t\t\t\t\t\tUninstall\n\t\t\t\t\t</Button>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/cpu-card-content.tsx",
    "content": "import {useCpuForUi} from '@/hooks/use-cpu'\nimport {t} from '@/utils/i18n'\n\nimport {ProgressStatCardContent} from './progress-card-content'\n\nexport function CpuCardContent() {\n\tconst {value, secondaryValue, progress} = useCpuForUi()\n\n\treturn <ProgressStatCardContent title={t('cpu')} value={value} secondaryValue={secondaryValue} progress={progress} />\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/cpu-temperature-card-content.tsx",
    "content": "import {AnimatedNumber} from '@/components/ui/animated-number'\nimport {SegmentedControl} from '@/components/ui/segmented-control'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {\n\ttemperatureDescriptions,\n\ttemperatureDescriptionsKeyed,\n\tTemperatureUnit,\n\tuseTemperatureUnit,\n} from '@/hooks/use-temperature-unit'\nimport {cn} from '@/lib/utils'\nimport {t} from '@/utils/i18n'\nimport {isCpuTooHot} from '@/utils/system'\nimport {celciusToFahrenheit, temperatureWarningToColor, temperatureWarningToMessage} from '@/utils/temperature'\n\nimport {cardErrorClass, cardSecondaryValueClass, cardTitleClass, cardValueClass} from './shared'\n\nexport function CpuTemperatureCardContent({\n\ttemperatureInCelcius,\n\tdefaultUnit,\n\twarning,\n}: {\n\ttemperatureInCelcius?: number\n\tdefaultUnit?: TemperatureUnit\n\twarning?: string\n}) {\n\tconst [unit, setUnit] = useTemperatureUnit(defaultUnit)\n\n\tconst temperatureNumber = unit === 'c' ? temperatureInCelcius : celciusToFahrenheit(temperatureInCelcius)\n\tconst temperatureUnitLabel = temperatureDescriptionsKeyed[unit].label\n\tconst temperatureMessage = temperatureNumber === 69 ? t('temperature.nice') : temperatureWarningToMessage(warning)\n\n\t// 60% opacity to base 16\n\tconst opacity = (60).toString(16)\n\tconst isUnknown = temperatureNumber === undefined\n\n\tconst isMobile = useIsMobile()\n\n\treturn (\n\t\t<div className='flex flex-col gap-4'>\n\t\t\t<div className={cardTitleClass}>{t('temperature')}</div>\n\t\t\t<div className='flex flex-wrap-reverse items-center justify-between gap-2'>\n\t\t\t\t<div className='flex shrink-0 flex-col gap-2.5'>\n\t\t\t\t\t<div className={cardValueClass}>\n\t\t\t\t\t\t{isUnknown ? '--' : <AnimatedNumber to={temperatureNumber} />} {temperatureUnitLabel}\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName={cn('h-[5px] w-[5px] rounded-full ring-3', !isUnknown && 'animate-pulse')}\n\t\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tbackgroundColor: temperatureWarningToColor(warning),\n\t\t\t\t\t\t\t\t\t'--tw-ring-color': temperatureWarningToColor(warning) + opacity,\n\t\t\t\t\t\t\t\t} as React.CSSProperties // forcing because of `--tw-ring-color`\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div className={cn(cardSecondaryValueClass, 'leading-inter-trimmed')}>{temperatureMessage}</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<SegmentedControl\n\t\t\t\t\tsize={isMobile ? 'sm' : 'default'}\n\t\t\t\t\tvariant='primary'\n\t\t\t\t\ttabs={temperatureDescriptions}\n\t\t\t\t\tvalue={unit}\n\t\t\t\t\tonValueChange={setUnit}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t\t{isCpuTooHot(warning) && <span className={cardErrorClass}>{t('temperature.too-hot-suggestion')}</span>}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/device-info-content.tsx",
    "content": "import {TbQuestionMark} from 'react-icons/tb'\n\nimport {CopyButton} from '@/components/ui/copy-button'\nimport {FadeInImg} from '@/components/ui/fade-in-img'\nimport {hostEnvironmentMap, UmbrelHostEnvironment} from '@/constants'\nimport {cn} from '@/lib/utils'\nimport {maybeT, t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nimport AnimatedUmbrelHomeIcon from './device-info-umbrel-home'\nimport AnimatedUmbrelProIcon from './device-info-umbrel-pro'\n\nexport function DeviceInfoContent({\n\tumbrelHostEnvironment,\n\tdevice,\n\tmodelNumber,\n\tserialNumber,\n}: {\n\tumbrelHostEnvironment?: UmbrelHostEnvironment\n\tdevice?: string\n\tmodelNumber?: string\n\tserialNumber?: string\n}) {\n\treturn (\n\t\t<div className='space-y-6'>\n\t\t\t<div className={cn('flex justify-center', umbrelHostEnvironment !== 'umbrel-pro' && 'py-2')}>\n\t\t\t\t<HostEnvironmentIcon\n\t\t\t\t\tenvironment={umbrelHostEnvironment}\n\t\t\t\t\tmodelNumber={modelNumber}\n\t\t\t\t\tserialNumber={serialNumber}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t\t<div className={listClass}>\n\t\t\t\t<div className={listItemClassNarrow}>\n\t\t\t\t\t<span>{t('device-info.device')}</span>\n\t\t\t\t\t<span className={cn('font-normal', (modelNumber || serialNumber) && 'pr-6')}>{maybeT(device)}</span>\n\t\t\t\t</div>\n\t\t\t\t{modelNumber && (\n\t\t\t\t\t<div className={listItemClassNarrow}>\n\t\t\t\t\t\t<span>{t('device-info.model-number')}</span>\n\t\t\t\t\t\t<span className='flex items-center gap-2 font-normal'>\n\t\t\t\t\t\t\t{modelNumber} <CopyButton value={modelNumber} />\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t{serialNumber && (\n\t\t\t\t\t<div className={listItemClassNarrow}>\n\t\t\t\t\t\t<span>{t('device-info.serial-number')}</span>\n\t\t\t\t\t\t<span className='flex items-center gap-2 font-normal'>\n\t\t\t\t\t\t\t{serialNumber} <CopyButton value={serialNumber} />\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t</div>\n\t)\n}\nconst listClass = tw`divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6`\nconst listItemClass = tw`flex items-center gap-3 px-3 h-[50px] text-15 font-medium -tracking-3 justify-between`\nconst listItemClassNarrow = cn(listItemClass, tw`h-[42px]`)\n\nexport const HostEnvironmentIcon = ({\n\tenvironment,\n\tmodelNumber,\n\tserialNumber,\n}: {\n\tenvironment?: UmbrelHostEnvironment\n\tmodelNumber?: string\n\tserialNumber?: string\n}) => {\n\tconst iconDimensions = {\n\t\t'umbrel-pro': 200,\n\t\t'umbrel-home': 128,\n\t\t'raspberry-pi': 64,\n\t\t'docker-container': 72,\n\t\tunknown: 128,\n\t}\n\n\tif (environment === 'umbrel-home') {\n\t\treturn <AnimatedUmbrelHomeIcon modelNumber={modelNumber} serialNumber={serialNumber} />\n\t}\n\n\tif (environment === 'umbrel-pro') {\n\t\treturn <AnimatedUmbrelProIcon serialNumber={serialNumber} />\n\t}\n\n\tconst icon =\n\t\tenvironment && hostEnvironmentMap[environment]?.icon ? (\n\t\t\t<FadeInImg\n\t\t\t\tsrc={hostEnvironmentMap[environment].icon}\n\t\t\t\twidth={iconDimensions[environment]}\n\t\t\t\theight={iconDimensions[environment]}\n\t\t\t/>\n\t\t) : (\n\t\t\t<TbQuestionMark className='h-12 w-12 text-white/50' />\n\t\t)\n\n\t// Only wrap in IconContainer for raspberry-pi and docker-container\n\tif (environment === 'raspberry-pi' || environment === 'docker-container') {\n\t\treturn <IconContainer>{icon}</IconContainer>\n\t}\n\n\treturn icon\n}\n\nconst IconContainer = ({children}: {children: React.ReactNode}) => (\n\t<div\n\t\tclassName='grid h-32 w-32 place-items-center rounded-[27px] bg-[#52525252]'\n\t\tstyle={{\n\t\t\tboxShadow: '0 1px 2px #ffffff55 inset',\n\t\t}}\n\t>\n\t\t{children}\n\t</div>\n)\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/device-info-umbrel-home.tsx",
    "content": "import {motion} from 'motion/react'\nimport {memo, useCallback, useState} from 'react'\n\nimport {cn} from '@/lib/utils'\n\nimport {LaserEngraving} from './laser-engraving'\n\nconst AnimatedUmbrelHomeIcon = memo(\n\t({modelNumber = '', serialNumber = ''}: {modelNumber?: string; serialNumber?: string}) => {\n\t\t// Update transforms to return 0 when flipped\n\t\tconst [isFlipped, setIsFlipped] = useState(false)\n\n\t\t// Track number of clicks\n\t\tconst [clicks, setClicks] = useState(0)\n\n\t\tconst handleClick = useCallback(() => {\n\t\t\tconst totalClicks = clicks + 1\n\t\t\tsetClicks(totalClicks)\n\t\t\tif (totalClicks === 3) {\n\t\t\t\tsetIsFlipped(true)\n\t\t\t}\n\t\t}, [clicks])\n\n\t\tconst footVariants = {\n\t\t\thidden: {rotate: 180, opacity: 0},\n\t\t\tvisible: {rotate: 0, opacity: 1, transition: {duration: 0.5}},\n\t\t}\n\n\t\treturn (\n\t\t\t<motion.div\n\t\t\t\tstyle={{\n\t\t\t\t\tdisplay: 'flex',\n\t\t\t\t\tjustifyContent: 'center',\n\t\t\t\t\talignItems: 'center',\n\t\t\t\t\toutline: 'none',\n\t\t\t\t\theight: '128px',\n\t\t\t\t\twidth: '128px',\n\t\t\t\t}}\n\t\t\t\tanimate={{\n\t\t\t\t\theight: isFlipped ? '335px' : '128px',\n\t\t\t\t\twidth: isFlipped ? '335px' : '128px',\n\t\t\t\t}}\n\t\t\t\ttabIndex={-1}\n\t\t\t\twhileTap={{\n\t\t\t\t\tscale: isFlipped ? 1 : 0.97,\n\t\t\t\t}}\n\t\t\t\twhileHover={{\n\t\t\t\t\tscale: isFlipped ? 1 : 1.03,\n\t\t\t\t}}\n\t\t\t\tonClick={handleClick}\n\t\t\t>\n\t\t\t\t<div className='flex h-full w-full items-center justify-center'>\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t'relative h-full w-full overflow-hidden bg-linear-to-tr from-neutral-800 via-neutral-900 to-neutral-800',\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t'rounded-[3.5rem]': isFlipped,\n\t\t\t\t\t\t\t\t'rounded-[1.3rem]': !isFlipped,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div className='absolute top-[-25%] left-[-25%] h-[150%] w-[150%] animate-spin bg-[conic-gradient(from_0deg,transparent_0deg,rgba(255,255,255,0.3)_40deg,rgba(255,255,255,0.25)_80deg,transparent_120deg)] [animation-duration:_20s]'></div>\n\t\t\t\t\t\t<div className='absolute top-[-25%] left-[-25%] h-[150%] w-[150%] animate-spin bg-[conic-gradient(from_180deg,transparent_0deg,rgba(255,255,255,0.3)_40deg,rgba(255,255,255,0.25)_80deg,transparent_120deg)] [animation-duration:_20s]'></div>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t'absolute top-[1.5px] left-[1.5px] flex h-[calc(100%-3px)] w-[calc(100%-3px)] items-center justify-center bg-linear-to-tr from-neutral-900 via-neutral-950 to-neutral-800 text-xs',\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t'rounded-[3.5rem]': isFlipped,\n\t\t\t\t\t\t\t\t\t'rounded-[1.3rem]': !isFlipped,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{isFlipped ? (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\tclassName='absolute top-0 left-0 h-full w-full opacity-10'\n\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\tbackgroundImage: isFlipped ? 'url(/assets/umbrel-home-device-info-grain.png)' : 'none',\n\t\t\t\t\t\t\t\t\t\t\tbackgroundBlendMode: 'overlay',\n\t\t\t\t\t\t\t\t\t\t\tbackgroundSize: 'cover',\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t></div>\n\t\t\t\t\t\t\t\t\t<div className='pointer-events-none absolute flex h-full w-full'>\n\t\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\t\tinitial='hidden'\n\t\t\t\t\t\t\t\t\t\t\tanimate='visible'\n\t\t\t\t\t\t\t\t\t\t\tvariants={{\n\t\t\t\t\t\t\t\t\t\t\t\thidden: {},\n\t\t\t\t\t\t\t\t\t\t\t\tvisible: {\n\t\t\t\t\t\t\t\t\t\t\t\t\ttransition: {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstaggerChildren: 0,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdelayChildren: 0.2,\n\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\t\t\tvariants={footVariants}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName='absolute top-[8%] left-[8%] h-[44px] w-[44px] rounded-full border border-black/50 bg-black/40 shadow-[0_2px_4px_rgba(0,0,0,0.4),inset_0_1px_2px_rgba(255,255,255,0.15)]'\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\t\t\tvariants={footVariants}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName='absolute top-[8%] right-[8%] h-[44px] w-[44px] rounded-full border border-black/50 bg-black/40 shadow-[0_2px_4px_rgba(0,0,0,0.4),inset_0_1px_2px_rgba(255,255,255,0.15)]'\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\t\t\tvariants={footVariants}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName='absolute right-[8%] bottom-[8%] h-[44px] w-[44px] rounded-full border border-black/50 bg-black/40 shadow-[0_2px_4px_rgba(0,0,0,0.4),inset_0_1px_2px_rgba(255,255,255,0.15)]'\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\t\t\tvariants={footVariants}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName='absolute bottom-[8%] left-[8%] h-[44px] w-[44px] rounded-full border border-black/50 bg-black/40 shadow-[0_2px_4px_rgba(0,0,0,0.4),inset_0_1px_2px_rgba(255,255,255,0.15)]'\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<div className='pointer-events-none relative h-full w-full'>\n\t\t\t\t\t\t\t\t\t\t<div className='absolute top-1/2 left-1/2 w-full -translate-x-1/2 -translate-y-1/2 text-center text-[11px] text-white/60'>\n\t\t\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\t\t\tinitial={{opacity: 0}}\n\t\t\t\t\t\t\t\t\t\t\t\tanimate={{opacity: 0.6}}\n\t\t\t\t\t\t\t\t\t\t\t\ttransition={{delay: 0.2, duration: 0.5, ease: [0.22, 1, 0.36, 1]}}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName='flex flex-wrap justify-center'\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className='inline whitespace-pre'>Designed by Umbrel. Assembled in China.</span>\n\t\t\t\t\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t<div className='absolute bottom-[20%] left-1/2 flex -translate-x-1/2 flex-col items-center gap-2'>\n\t\t\t\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\t\t\t\tinitial={{opacity: 0}}\n\t\t\t\t\t\t\t\t\t\t\t\tanimate={{opacity: 1}}\n\t\t\t\t\t\t\t\t\t\t\t\ttransition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName='text-[9px] text-white/40'\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\tModel {modelNumber}&nbsp;&nbsp;&nbsp;Rated 12V ⎓ 2.5A\n\t\t\t\t\t\t\t\t\t\t\t</motion.div>\n\t\t\t\t\t\t\t\t\t\t\t<motion.img\n\t\t\t\t\t\t\t\t\t\t\t\tinitial={{opacity: 0}}\n\t\t\t\t\t\t\t\t\t\t\t\tanimate={{opacity: 0.35}}\n\t\t\t\t\t\t\t\t\t\t\t\ttransition={{delay: 0.2, duration: 0.5, ease: [0.22, 1, 0.36, 1]}}\n\t\t\t\t\t\t\t\t\t\t\t\tsrc='/assets/umbrel-home-certifications.svg'\n\t\t\t\t\t\t\t\t\t\t\t\tclassName='w-[80px]'\n\t\t\t\t\t\t\t\t\t\t\t\tdraggable='false'\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t<LaserEngraving\n\t\t\t\t\t\t\t\t\t\t\t\ttext={`Serial ${serialNumber}`}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName='absolute top-[-6px] left-0'\n\t\t\t\t\t\t\t\t\t\t\t\tbackgroundColor='transparent'\n\t\t\t\t\t\t\t\t\t\t\t\tengravingColor='#3F3F3F'\n\t\t\t\t\t\t\t\t\t\t\t\tspeed={10}\n\t\t\t\t\t\t\t\t\t\t\t\tdelay={0.5}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<svg width='51' height='25' viewBox='0 0 569 280' fill='none' xmlns='http://www.w3.org/2000/svg'>\n\t\t\t\t\t\t\t\t\t<mask\n\t\t\t\t\t\t\t\t\t\tid='mask0_26_11'\n\t\t\t\t\t\t\t\t\t\tstyle={{maskType: 'alpha'}}\n\t\t\t\t\t\t\t\t\t\tmaskUnits='userSpaceOnUse'\n\t\t\t\t\t\t\t\t\t\tx='0'\n\t\t\t\t\t\t\t\t\t\ty='0'\n\t\t\t\t\t\t\t\t\t\twidth='569'\n\t\t\t\t\t\t\t\t\t\theight='280'\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<path\n\t\t\t\t\t\t\t\t\t\t\tfillRule='evenodd'\n\t\t\t\t\t\t\t\t\t\t\tclipRule='evenodd'\n\t\t\t\t\t\t\t\t\t\t\td='M281.001 52.1822C343.327 50.9851 392.381 67.7244 430.42 100.664C458.068 124.59 481.196 158.188 499.077 202.567C485.449 199.214 471.046 197.569 455.967 197.569C424.084 197.569 395.459 204.931 371.612 220.952C344.886 204.692 316.162 196.133 285.718 196.133C254.595 196.133 224.711 205.091 196.347 221.85C168.961 204.672 138.118 196.153 104.456 196.153C92.303 196.153 80.676 197.276 69.708 199.627C85.7836 158.809 107.05 127.343 132.881 104.276C169.781 71.3356 218.394 53.3793 281.001 52.1822ZM4.88818 268.469C8.57466 273.602 14.0193 277.241 20.2214 278.672C26.9648 280.227 34.0513 279.046 39.9217 275.386C42.4384 273.818 44.6431 271.849 46.4638 269.581C57.8781 256.512 75.6752 248.226 104.456 248.226C131.761 248.226 155.169 255.788 175.658 270.751L176.457 271.35C181.883 275.38 188.429 277.627 195.19 277.781C201.951 277.934 208.593 275.987 214.197 272.208C238.664 255.688 262.371 248.226 285.718 248.226C308.666 248.226 330.914 255.408 352.962 271.05L353.422 271.37C365.675 280.108 382.326 279.35 393.72 269.534C408.292 256.985 428.242 249.662 455.967 249.662C485.331 249.662 508.078 257.882 525.989 273.125C528.597 275.343 531.617 277.027 534.877 278.08C538.136 279.133 541.572 279.534 544.987 279.262C548.403 278.99 551.731 278.049 554.782 276.492C557.833 274.936 560.547 272.796 562.769 270.193C564.991 267.59 566.678 264.575 567.732 261.322C568.787 258.068 569.19 254.639 568.917 251.23C568.784 249.569 568.492 247.929 568.047 246.332C547.348 166.285 513.502 103.616 464.622 61.2801C415.208 18.504 352.922 -1.32776 279.981 0.0688505C207.38 1.46546 145.973 22.6739 98.0793 65.45C50.533 107.904 18.7264 169.413 0.726395 247.192C0.0814305 249.862 -0.142033 252.642 0.0880995 255.431C0.450913 259.828 1.92223 264.018 4.3136 267.635C4.50003 267.917 4.6916 268.195 4.88818 268.469Z'\n\t\t\t\t\t\t\t\t\t\t\tfill='white'\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</mask>\n\t\t\t\t\t\t\t\t\t<g mask='url(#mask0_26_11)'>\n\t\t\t\t\t\t\t\t\t\t<rect\n\t\t\t\t\t\t\t\t\t\t\tclassName='origin-center animate-spin [animation-duration:20s]'\n\t\t\t\t\t\t\t\t\t\t\tx='-61'\n\t\t\t\t\t\t\t\t\t\t\ty='-186'\n\t\t\t\t\t\t\t\t\t\t\twidth='700'\n\t\t\t\t\t\t\t\t\t\t\theight='700'\n\t\t\t\t\t\t\t\t\t\t\tfill='url(#paint0_linear_26_11)'\n\t\t\t\t\t\t\t\t\t\t\tstyle={{willChange: 'transform', transform: 'translateZ(0)', overflow: 'hidden'}} // Fix for flickering in Chrome around edges\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</g>\n\t\t\t\t\t\t\t\t\t<defs>\n\t\t\t\t\t\t\t\t\t\t<linearGradient\n\t\t\t\t\t\t\t\t\t\t\tid='paint0_linear_26_11'\n\t\t\t\t\t\t\t\t\t\t\tx1='142'\n\t\t\t\t\t\t\t\t\t\t\ty1='-171'\n\t\t\t\t\t\t\t\t\t\t\tx2='436'\n\t\t\t\t\t\t\t\t\t\t\ty2='501'\n\t\t\t\t\t\t\t\t\t\t\tgradientUnits='userSpaceOnUse'\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<stop stopColor='#202020' />\n\t\t\t\t\t\t\t\t\t\t\t<stop offset='0.365' stopColor='#3E3E3E' />\n\t\t\t\t\t\t\t\t\t\t\t<stop offset='0.494166' stopColor='#858585' />\n\t\t\t\t\t\t\t\t\t\t\t<stop offset='0.650946' stopColor='#333333' />\n\t\t\t\t\t\t\t\t\t\t\t<stop offset='0.998941' stopColor='#3F3F3F' />\n\t\t\t\t\t\t\t\t\t\t</linearGradient>\n\t\t\t\t\t\t\t\t\t</defs>\n\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</motion.div>\n\t\t)\n\t},\n)\n\nexport default AnimatedUmbrelHomeIcon\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/device-info-umbrel-pro.tsx",
    "content": "import {motion} from 'motion/react'\nimport {memo, useEffect, useState} from 'react'\n\nimport {LaserEngraving} from './laser-engraving'\n\n// Render canvas at 2x for retina sharpness, CSS scales it down to display size\nconst CANVAS_SCALE = 2\n// Canvas is tall so smoke particles have room to rise and fade naturally\nconst ENGRAVE_DISPLAY_HEIGHT = 150\nconst ENGRAVE_CANVAS_WIDTH = 400 * CANVAS_SCALE\nconst ENGRAVE_CANVAS_HEIGHT = ENGRAVE_DISPLAY_HEIGHT * CANVAS_SCALE\nconst COVER_SLIDE_DELAY = 1.0 // seconds to wait after scale-up before cover slides\n\nconst AnimatedUmbrelProIcon = memo(({serialNumber = ''}: {serialNumber?: string}) => {\n\tconst [scaleComplete, setScaleComplete] = useState(false)\n\tconst [coverLifted, setCoverLifted] = useState(false)\n\tconst [engraveReady, setEngraveReady] = useState(false)\n\n\t// Add a delay between scale-up completing and cover sliding\n\tuseEffect(() => {\n\t\tif (!scaleComplete) return\n\t\tconst timer = setTimeout(() => setCoverLifted(true), COVER_SLIDE_DELAY * 1000)\n\t\treturn () => clearTimeout(timer)\n\t}, [scaleComplete])\n\n\treturn (\n\t\t<motion.div\n\t\t\tclassName='relative mb-3 w-full'\n\t\t\tstyle={{aspectRatio: '1 / 1', maxWidth: 400}}\n\t\t\tinitial={{scale: 1, opacity: 1}}\n\t\t\tanimate={{scale: 1, opacity: 1}}\n\t\t\ttransition={{duration: 0.6, ease: [0.22, 1, 0.36, 1]}}\n\t\t\tonAnimationComplete={() => setScaleComplete(true)}\n\t\t>\n\t\t\t{/* Clipped container for the device images and cover slide */}\n\t\t\t<div className='absolute inset-0 mt-8'>\n\t\t\t\t{/* Layer 1: Bottom plate (revealed surface where serial gets engraved) */}\n\t\t\t\t<img\n\t\t\t\t\tsrc='/assets/umbrel-pro-bottom.webp'\n\t\t\t\t\tclassName='pointer-events-none absolute inset-0 h-full w-full object-contain'\n\t\t\t\t\tstyle={{filter: 'drop-shadow(-8px 12px 20px rgba(0, 0, 0, 0.55)) brightness(0.85)'}}\n\t\t\t\t\tdraggable={false}\n\t\t\t\t/>\n\n\t\t\t\t{/* Layer 3: Cover plate that slides up to reveal bottom */}\n\t\t\t\t<motion.img\n\t\t\t\t\tsrc='/assets/umbrel-pro-bottom-cover.webp'\n\t\t\t\t\tclassName='pointer-events-none absolute inset-0 m-auto h-[97%] w-[97%] object-contain'\n\t\t\t\t\tstyle={{filter: 'drop-shadow(-8px 12px 20px rgba(0, 0, 0, 0.55))'}}\n\t\t\t\t\tdraggable={false}\n\t\t\t\t\tinitial={{y: 0}}\n\t\t\t\t\tanimate={{y: coverLifted ? '-25%' : 0}}\n\t\t\t\t\ttransition={{duration: 0.8, ease: [0.22, 1, 0.36, 1]}}\n\t\t\t\t\tonAnimationComplete={() => {\n\t\t\t\t\t\tif (coverLifted) setEngraveReady(true)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</div>\n\n\t\t\t{/* Layer 2: Laser engraving — outside the clipped container so smoke can overflow */}\n\t\t\t{serialNumber && engraveReady && (\n\t\t\t\t<div\n\t\t\t\t\tclassName='pointer-events-none absolute left-0 flex w-full items-end justify-center'\n\t\t\t\t\tstyle={{bottom: '-12%', height: ENGRAVE_DISPLAY_HEIGHT}}\n\t\t\t\t>\n\t\t\t\t\t<LaserEngraving\n\t\t\t\t\t\ttext={`Serial ${serialNumber}`}\n\t\t\t\t\t\twidth={ENGRAVE_CANVAS_WIDTH}\n\t\t\t\t\t\theight={ENGRAVE_CANVAS_HEIGHT}\n\t\t\t\t\t\tfontSize={22}\n\t\t\t\t\t\tbackgroundColor='transparent'\n\t\t\t\t\t\tengravingColor='#555555'\n\t\t\t\t\t\tspeed={16}\n\t\t\t\t\t\tdelay={0.3}\n\t\t\t\t\t\tclassName='w-full'\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</motion.div>\n\t)\n})\n\nexport default AnimatedUmbrelProIcon\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/language-dropdown.tsx",
    "content": "import {Globe} from 'lucide-react'\nimport {useState} from 'react'\n\nimport {ChevronDown} from '@/components/chevron-down'\nimport {\n\tDropdownMenu,\n\tDropdownMenuCheckboxItem,\n\tDropdownMenuContent,\n\tDropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport {IconButton} from '@/components/ui/icon-button'\nimport {useLanguage} from '@/hooks/use-language'\nimport {languages, SupportedLanguageCode} from '@/utils/language'\n\nexport function LanguageDropdown() {\n\treturn (\n\t\t<DropdownMenu>\n\t\t\t<LanguageDropdownTrigger />\n\t\t\t<LanguageDropdownContent />\n\t\t</DropdownMenu>\n\t)\n}\n\nexport function LanguageDropdownTrigger() {\n\tconst [activeCode] = useLanguage()\n\n\treturn (\n\t\t<DropdownMenuTrigger asChild>\n\t\t\t<IconButton icon={Globe}>\n\t\t\t\t{languages.find(({code}) => code === activeCode)?.name}\n\t\t\t\t<ChevronDown />\n\t\t\t</IconButton>\n\t\t</DropdownMenuTrigger>\n\t)\n}\n\nexport function LanguageDropdownContent() {\n\tconst [activeCode, setActiveCode] = useLanguage()\n\tconst [temporaryCode, setTemporaryCode] = useState(activeCode)\n\n\tconst changeLanguage = async (code: SupportedLanguageCode) => {\n\t\tsetTemporaryCode(code)\n\t\t// Delay so user can see the checkmark\n\t\tsetTimeout(() => setActiveCode(code), 200)\n\t}\n\n\treturn (\n\t\t<DropdownMenuContent align='end'>\n\t\t\t{languages.map(({code, name}) => (\n\t\t\t\t<DropdownMenuCheckboxItem\n\t\t\t\t\tkey={code}\n\t\t\t\t\tchecked={temporaryCode === code}\n\t\t\t\t\tonSelect={() => changeLanguage(code)}\n\t\t\t\t\tdisabled={temporaryCode !== activeCode}\n\t\t\t\t>\n\t\t\t\t\t{name}\n\t\t\t\t</DropdownMenuCheckboxItem>\n\t\t\t))}\n\t\t</DropdownMenuContent>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/laser-engraving.tsx",
    "content": "import {memo, useCallback, useEffect, useMemo, useRef} from 'react'\n\ninterface LaserEngravingProps {\n\ttext?: string\n\tfontSize?: number\n\twidth?: number\n\theight?: number\n\tbackgroundColor?: string\n\tengravingColor?: string\n\tspeed?: number\n\tdelay?: number\n\tclassName?: string\n}\n\ninterface Point {\n\tx: number\n\ty: number\n}\n\ntype AnimationPhase = 'scanning' | 'engraving' | 'touching-up' | 'final-scanning'\n\nclass SmokeParticle {\n\tprivate x: number\n\tprivate y: number\n\tprivate age: number\n\tprivate maxAge: number\n\tprivate velocityY: number\n\tprivate velocityX: number\n\tprivate size: number\n\tprivate turbulence: number\n\tprivate alpha: number\n\n\tconstructor(x: number, y: number) {\n\t\tthis.x = x\n\t\tthis.y = y\n\t\tthis.age = 0\n\t\tthis.maxAge = 100 + Math.random() * 20\n\t\tthis.velocityY = -0.8 - Math.random() * 0.3\n\t\tthis.velocityX = (Math.random() - 0.5) * 0.6\n\t\tthis.size = 2 + Math.random() * 2\n\t\tthis.turbulence = Math.random() * 0.08\n\t\tthis.alpha = 0.15 + Math.random() * 0.1\n\t}\n\n\tupdate(): boolean {\n\t\tthis.age++\n\t\tthis.velocityX += (Math.random() - 0.5) * this.turbulence\n\t\tthis.velocityY *= 0.99\n\t\tthis.x += this.velocityX\n\t\tthis.y += this.velocityY\n\t\tthis.size += 0.05\n\t\treturn this.age < this.maxAge\n\t}\n\n\tdraw(ctx: CanvasRenderingContext2D): void {\n\t\tconst lifeProgress = this.age / this.maxAge\n\t\tconst alpha = (1 - lifeProgress) * this.alpha\n\t\tconst gradient = ctx.createRadialGradient(this.x, this.y, 0, this.x, this.y, this.size)\n\t\tgradient.addColorStop(0, `rgba(175, 175, 175, ${alpha})`)\n\t\tgradient.addColorStop(0.5, `rgba(150, 150, 150, ${alpha * 0.5})`)\n\t\tgradient.addColorStop(1, 'rgba(100, 100, 100, 0)')\n\t\tctx.fillStyle = gradient\n\t\tctx.beginPath()\n\t\tctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)\n\t\tctx.fill()\n\t}\n}\n\nexport const LaserEngraving: React.FC<LaserEngravingProps> = memo(\n\t({\n\t\ttext = '',\n\t\tfontSize = 24,\n\t\twidth = 335,\n\t\theight = 335,\n\t\tbackgroundColor = '#111',\n\t\tengravingColor = '#222',\n\t\tspeed = 12,\n\t\tdelay = 0,\n\t\tclassName = '',\n\t}) => {\n\t\tconst canvasRef = useRef<HTMLCanvasElement | null>(null)\n\n\t\t// Memoize canvas style and font string\n\t\tconst canvasStyle = useMemo(\n\t\t\t() => ({\n\t\t\t\tbackground: backgroundColor,\n\t\t\t\tmaxWidth: '100%',\n\t\t\t}),\n\t\t\t[backgroundColor],\n\t\t)\n\n\t\tconst fontString = useMemo(\n\t\t\t() =>\n\t\t\t\t`${fontSize}px \"Inter var\", ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"`,\n\t\t\t[fontSize],\n\t\t)\n\n\t\t// Memoize points calculation\n\t\tconst points = useMemo(() => {\n\t\t\tconst tempCanvas = document.createElement('canvas')\n\t\t\tconst tempCtx = tempCanvas.getContext('2d')\n\t\t\tif (!tempCtx || !text) return [] // Early return if no context or text\n\n\t\t\ttempCanvas.width = width\n\t\t\ttempCanvas.height = height\n\n\t\t\ttempCtx.font = fontString // Use memoized font string\n\t\t\tconst textMetrics = tempCtx.measureText(text)\n\t\t\tconst textX = (width - textMetrics.width) / 2\n\t\t\tconst textY = height / 2\n\n\t\t\ttempCtx.fillStyle = 'white'\n\t\t\ttempCtx.fillText(text, textX, textY)\n\t\t\tconst imageData = tempCtx.getImageData(0, 0, width, height)\n\t\t\tconst pixels = imageData.data\n\n\t\t\tconst textPoints: Point[] = []\n\t\t\tfor (let y = 0; y < height; y++) {\n\t\t\t\tfor (let x = 0; x < width; x++) {\n\t\t\t\t\tconst i = (y * width + x) * 4\n\t\t\t\t\tif (pixels[i] > 0) {\n\t\t\t\t\t\ttextPoints.push({x, y})\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn textPoints.sort((a, b) => {\n\t\t\t\tif (a.x === b.x) return a.y - b.y\n\t\t\t\treturn a.x - b.x\n\t\t\t})\n\t\t}, [text, fontString, width, height])\n\n\t\t// Memoize drawing functions\n\t\tconst drawFunctions = useMemo(\n\t\t\t() => ({\n\t\t\t\tdrawLaser(ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, x: number, y: number): void {\n\t\t\t\t\t// Draw outer red glow (increased from 6 to 8)\n\t\t\t\t\tctx.beginPath()\n\t\t\t\t\tconst outerGradient = ctx.createRadialGradient(x, y, 0, x, y, 10)\n\t\t\t\t\touterGradient.addColorStop(0, 'rgba(255, 0, 0, 0.8)')\n\t\t\t\t\touterGradient.addColorStop(0.5, 'rgba(255, 0, 0, 0.4)')\n\t\t\t\t\touterGradient.addColorStop(1, 'rgba(255, 0, 0, 0)')\n\t\t\t\t\tctx.fillStyle = outerGradient\n\t\t\t\t\tctx.arc(x, y, 10, 0, Math.PI * 2)\n\t\t\t\t\tctx.fill()\n\n\t\t\t\t\t// Draw bright white center (increased from 2 to 3)\n\t\t\t\t\tctx.beginPath()\n\t\t\t\t\tconst innerGradient = ctx.createRadialGradient(x, y, 0, x, y, 3)\n\t\t\t\t\tinnerGradient.addColorStop(0, 'rgba(255, 255, 255, 1)')\n\t\t\t\t\tinnerGradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.8)')\n\t\t\t\t\tinnerGradient.addColorStop(1, 'rgba(255, 200, 200, 0)')\n\t\t\t\t\tctx.fillStyle = innerGradient\n\t\t\t\t\tctx.arc(x, y, 3, 0, Math.PI * 2)\n\t\t\t\t\tctx.fill()\n\t\t\t\t},\n\n\t\t\t\tcreateSmoke(x: number, y: number): SmokeParticle[] {\n\t\t\t\t\tconst particles: SmokeParticle[] = []\n\t\t\t\t\tfor (let i = 0; i < 3; i++) {\n\t\t\t\t\t\tconst offsetX = (Math.random() - 0.5) * 3\n\t\t\t\t\t\tconst offsetY = (Math.random() - 0.5) * 3\n\t\t\t\t\t\tparticles.push(new SmokeParticle(x + offsetX, y + offsetY))\n\t\t\t\t\t}\n\t\t\t\t\treturn particles\n\t\t\t\t},\n\n\t\t\t\t// Add new function for drawing laser line\n\t\t\t\tdrawLaserLine(\n\t\t\t\t\tctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,\n\t\t\t\t\tstartX: number,\n\t\t\t\t\tstartY: number,\n\t\t\t\t\tendX: number,\n\t\t\t\t\tendY: number,\n\t\t\t\t): void {\n\t\t\t\t\t// Draw the entire line segment with bright red\n\t\t\t\t\tctx.beginPath()\n\t\t\t\t\tctx.strokeStyle = 'rgb(255, 0, 0)'\n\t\t\t\t\tctx.lineWidth = 2\n\t\t\t\t\tctx.shadowColor = 'rgba(255, 0, 0, 0.8)'\n\t\t\t\t\tctx.shadowBlur = 4\n\t\t\t\t\tctx.moveTo(startX, startY)\n\t\t\t\t\tctx.lineTo(endX, endY)\n\t\t\t\t\tctx.stroke()\n\n\t\t\t\t\t// Add a brighter core to the line\n\t\t\t\t\tctx.beginPath()\n\t\t\t\t\tctx.strokeStyle = 'rgba(255, 100, 100, 0.9)'\n\t\t\t\t\tctx.lineWidth = 1\n\t\t\t\t\tctx.moveTo(startX, startY)\n\t\t\t\t\tctx.lineTo(endX, endY)\n\t\t\t\t\tctx.stroke()\n\n\t\t\t\t\tctx.shadowBlur = 0\n\t\t\t\t},\n\t\t\t}),\n\t\t\t[],\n\t\t)\n\n\t\t// Add new memoized function for bounding box coordinates\n\t\tconst boundingBox = useMemo(() => {\n\t\t\tconst tempCanvas = document.createElement('canvas')\n\t\t\tconst tempCtx = tempCanvas.getContext('2d')\n\t\t\tif (!tempCtx) return {x: 0, y: 0, width: 0, height: 0}\n\n\t\t\ttempCtx.font = `${fontSize}px \"Inter var\", ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\"`\n\t\t\tconst metrics = tempCtx.measureText(text)\n\t\t\tconst textX = (width - metrics.width) / 2\n\t\t\tconst textY = height / 2\n\n\t\t\treturn {\n\t\t\t\tx: textX - 2,\n\t\t\t\ty: textY - fontSize + 1,\n\t\t\t\twidth: metrics.width + 4,\n\t\t\t\theight: fontSize + 4,\n\t\t\t}\n\t\t}, [text, fontSize, width, height])\n\n\t\t// Optimize particle batch processing with TypedArrays\n\t\tconst updateAndDrawParticles = useCallback(\n\t\t\t(\n\t\t\t\tparticles: SmokeParticle[],\n\t\t\t\tctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,\n\t\t\t): SmokeParticle[] => {\n\t\t\t\tconst aliveParticles: SmokeParticle[] = []\n\n\t\t\t\t// Process all particles\n\t\t\t\tparticles.forEach((particle) => {\n\t\t\t\t\tif (particle.update()) {\n\t\t\t\t\t\tparticle.draw(ctx as CanvasRenderingContext2D)\n\t\t\t\t\t\taliveParticles.push(particle)\n\t\t\t\t\t}\n\t\t\t\t})\n\n\t\t\t\treturn aliveParticles\n\t\t\t},\n\t\t\t[],\n\t\t)\n\n\t\t// Optimize engraved points handling with Set and TypedArray\n\t\tconst drawEngravedPoints = useCallback(\n\t\t\t(\n\t\t\t\tctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,\n\t\t\t\tpoints: Set<string>,\n\t\t\t\tcolor1: string,\n\t\t\t\tcolor2: string,\n\t\t\t) => {\n\t\t\t\tconst pointsArray = Array.from(points)\n\t\t\t\tconst buffer = new Float32Array(pointsArray.length * 3) // x, y, pass\n\n\t\t\t\t// Batch process points into TypedArray\n\t\t\t\tpointsArray.forEach((point, index) => {\n\t\t\t\t\tconst [x, y, pass] = point.split(',').map(Number)\n\t\t\t\t\tconst offset = index * 3\n\t\t\t\t\tbuffer[offset] = x\n\t\t\t\t\tbuffer[offset + 1] = y\n\t\t\t\t\tbuffer[offset + 2] = pass\n\t\t\t\t})\n\n\t\t\t\t// Draw points in batches\n\t\t\t\tctx.save()\n\t\t\t\tfor (let i = 0; i < buffer.length; i += 3) {\n\t\t\t\t\tctx.fillStyle = buffer[i + 2] === 2 ? color1 : color2\n\t\t\t\t\tctx.fillRect(buffer[i], buffer[i + 1], 1, 1)\n\t\t\t\t}\n\t\t\t\tctx.restore()\n\t\t\t},\n\t\t\t[],\n\t\t)\n\n\t\tuseEffect(() => {\n\t\t\tconst canvas = canvasRef.current\n\t\t\tif (!canvas || points.length === 0) return\n\n\t\t\tconst mainCtx = canvas.getContext('2d')\n\t\t\tif (!mainCtx) return\n\n\t\t\tconst offscreen = new OffscreenCanvas(width, height)\n\t\t\tconst offscreenCtx = offscreen.getContext('2d', {\n\t\t\t\talpha: true,\n\t\t\t\tdesynchronized: true,\n\t\t\t})\n\n\t\t\tif (!offscreenCtx) return\n\n\t\t\tlet smokeParticles: SmokeParticle[] = []\n\t\t\tconst engravedPoints = new Set<string>()\n\t\t\tlet lastFrameTime = performance.now()\n\t\t\tlet animationFrameId: number\n\n\t\t\t// Move generateScanPoints inside useEffect\n\t\t\tconst generateScanPoints = (box: typeof boundingBox): Point[] => {\n\t\t\t\tconst points: Point[] = []\n\t\t\t\tconst numPointsPerSide = 20\n\n\t\t\t\t// Left edge (top to bottom)\n\t\t\t\tfor (let i = 0; i <= numPointsPerSide; i++) {\n\t\t\t\t\tpoints.push({\n\t\t\t\t\t\tx: box.x,\n\t\t\t\t\t\ty: box.y + (box.height * i) / numPointsPerSide,\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\t// Bottom edge (left to right)\n\t\t\t\tfor (let i = 0; i <= numPointsPerSide; i++) {\n\t\t\t\t\tpoints.push({\n\t\t\t\t\t\tx: box.x + (box.width * i) / numPointsPerSide,\n\t\t\t\t\t\ty: box.y + box.height,\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\t// Right edge (bottom to top)\n\t\t\t\tfor (let i = 0; i <= numPointsPerSide; i++) {\n\t\t\t\t\tpoints.push({\n\t\t\t\t\t\tx: box.x + box.width,\n\t\t\t\t\t\ty: box.y + box.height - (box.height * i) / numPointsPerSide,\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\t// Top edge (right to left)\n\t\t\t\tfor (let i = 0; i <= numPointsPerSide; i++) {\n\t\t\t\t\tpoints.push({\n\t\t\t\t\t\tx: box.x + box.width - (box.width * i) / numPointsPerSide,\n\t\t\t\t\t\ty: box.y,\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\treturn points\n\t\t\t}\n\n\t\t\tconst scanPoints = generateScanPoints(boundingBox)\n\n\t\t\tlet phase: AnimationPhase = 'scanning'\n\t\t\tlet scanCount = 0\n\t\t\tlet scanProgress = 0\n\t\t\tlet currentPoint = 0\n\t\t\tlet direction: 'ltr' | 'rtl' = 'ltr'\n\t\t\tlet passes = 0\n\t\t\tconst maxPasses = 2\n\t\t\tlet finalScanCount = 0\n\t\t\tlet touchUpStartTime = 0\n\t\t\tlet touchUpPoints: Point[] = []\n\t\t\tconst TOUCH_UP_DURATION = 1500 // 1.5 seconds in milliseconds\n\n\t\t\tconst generateTouchUpPoints = () => {\n\t\t\t\t// Get all half-engraved points\n\t\t\t\tconst halfEngravedPoints = Array.from(engravedPoints)\n\t\t\t\t\t.filter((point) => point.split(',')[2] === '1')\n\t\t\t\t\t.map((point) => {\n\t\t\t\t\t\tconst [x, y] = point.split(',').map(Number)\n\t\t\t\t\t\treturn {x, y}\n\t\t\t\t\t})\n\n\t\t\t\t// Randomly select 30 points for touch-up\n\t\t\t\tconst selectedPoints: Point[] = []\n\t\t\t\twhile (selectedPoints.length < 30 && halfEngravedPoints.length > 0) {\n\t\t\t\t\tconst randomIndex = Math.floor(Math.random() * halfEngravedPoints.length)\n\t\t\t\t\tselectedPoints.push(halfEngravedPoints[randomIndex])\n\t\t\t\t\thalfEngravedPoints.splice(randomIndex, 1)\n\t\t\t\t}\n\n\t\t\t\treturn selectedPoints\n\t\t\t}\n\n\t\t\tfunction animate(currentTime: number): void {\n\t\t\t\tif (currentTime - lastFrameTime < 16.67) {\n\t\t\t\t\tanimationFrameId = requestAnimationFrame(animate)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tlastFrameTime = currentTime\n\n\t\t\t\toffscreenCtx!.clearRect(0, 0, width, height)\n\n\t\t\t\tif (phase === 'scanning') {\n\t\t\t\t\tscanProgress += 0.1\n\n\t\t\t\t\t// Clear the canvas for this frame\n\t\t\t\t\toffscreenCtx!.clearRect(0, 0, width, height)\n\n\t\t\t\t\t// Draw engraved text if it exists\n\t\t\t\t\tif (engravedPoints.size > 0) {\n\t\t\t\t\t\tdrawEngravedPoints(\n\t\t\t\t\t\t\toffscreenCtx!,\n\t\t\t\t\t\t\tengravedPoints,\n\t\t\t\t\t\t\tengravingColor,\n\t\t\t\t\t\t\tpasses === maxPasses ? engravingColor : `${engravingColor}80`,\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\n\t\t\t\t\t// Update and draw smoke particles\n\t\t\t\t\tsmokeParticles = updateAndDrawParticles(smokeParticles, offscreenCtx as any)\n\n\t\t\t\t\t// Only draw scanning animation if not complete\n\t\t\t\t\tif (!(passes === maxPasses && finalScanCount >= 4)) {\n\t\t\t\t\t\t// Calculate start and end points for the visible line segment\n\t\t\t\t\t\tconst totalPoints = scanPoints.length\n\t\t\t\t\t\tconst wrappedProgress = (scanCount + scanProgress) % 5\n\t\t\t\t\t\tconst currentPoint = Math.floor(wrappedProgress * totalPoints) % totalPoints\n\t\t\t\t\t\tconst lineLength = totalPoints / 4\n\n\t\t\t\t\t\t// Calculate start point with wrapping\n\t\t\t\t\t\tconst startPoint = (currentPoint - lineLength + totalPoints) % totalPoints\n\t\t\t\t\t\tlet endPoint = currentPoint\n\n\t\t\t\t\t\t// Handle case where line wraps around the end\n\t\t\t\t\t\tif (startPoint > endPoint) {\n\t\t\t\t\t\t\tendPoint += totalPoints\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Draw the laser line\n\t\t\t\t\t\toffscreenCtx!.beginPath()\n\t\t\t\t\t\toffscreenCtx!.strokeStyle = 'rgb(255, 0, 0)'\n\t\t\t\t\t\toffscreenCtx!.lineWidth = 2\n\t\t\t\t\t\toffscreenCtx!.shadowColor = 'rgba(255, 0, 0, 0.8)'\n\t\t\t\t\t\toffscreenCtx!.shadowBlur = 4\n\n\t\t\t\t\t\t// Draw the path (handling wrap-around)\n\t\t\t\t\t\toffscreenCtx!.moveTo(scanPoints[startPoint % totalPoints].x, scanPoints[startPoint % totalPoints].y)\n\t\t\t\t\t\tfor (let i = startPoint + 1; i <= endPoint; i++) {\n\t\t\t\t\t\t\tconst idx = i % totalPoints\n\t\t\t\t\t\t\toffscreenCtx!.lineTo(scanPoints[idx].x, scanPoints[idx].y)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\toffscreenCtx!.stroke()\n\n\t\t\t\t\t\t// Add a brighter core line\n\t\t\t\t\t\toffscreenCtx!.beginPath()\n\t\t\t\t\t\toffscreenCtx!.strokeStyle = 'rgba(255, 100, 100, 0.9)'\n\t\t\t\t\t\toffscreenCtx!.lineWidth = 1\n\t\t\t\t\t\toffscreenCtx!.shadowBlur = 0\n\n\t\t\t\t\t\toffscreenCtx!.moveTo(scanPoints[startPoint % totalPoints].x, scanPoints[startPoint % totalPoints].y)\n\t\t\t\t\t\tfor (let i = startPoint + 1; i <= endPoint; i++) {\n\t\t\t\t\t\t\tconst idx = i % totalPoints\n\t\t\t\t\t\t\toffscreenCtx!.lineTo(scanPoints[idx].x, scanPoints[idx].y)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\toffscreenCtx!.stroke()\n\t\t\t\t\t}\n\n\t\t\t\t\t// Update scan count after drawing\n\t\t\t\t\tif (scanProgress >= 1) {\n\t\t\t\t\t\tscanCount++\n\t\t\t\t\t\tscanProgress = 0\n\n\t\t\t\t\t\tif (scanCount >= 8) {\n\t\t\t\t\t\t\tif (passes === maxPasses && finalScanCount >= 4) {\n\t\t\t\t\t\t\t\t// Draw final state and stop animation\n\t\t\t\t\t\t\t\tmainCtx!.clearRect(0, 0, width, height)\n\t\t\t\t\t\t\t\tdrawEngravedPoints(mainCtx!, engravedPoints, engravingColor, engravingColor)\n\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t} else if (passes === maxPasses) {\n\t\t\t\t\t\t\t\tfinalScanCount++\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tphase = 'engraving'\n\t\t\t\t\t\t\t\tcurrentPoint = 0\n\t\t\t\t\t\t\t\tdirection = 'ltr'\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else if (phase === 'engraving') {\n\t\t\t\t\t// Draw existing engraved points with appropriate opacity\n\t\t\t\t\t// Points from first pass should be half opacity, points from second pass should be full opacity\n\t\t\t\t\tconst halfOpacityPoints = new Set(\n\t\t\t\t\t\tArray.from(engravedPoints)\n\t\t\t\t\t\t\t.filter((point) => point.split(',')[2] === '1')\n\t\t\t\t\t\t\t.map((point) => point),\n\t\t\t\t\t)\n\t\t\t\t\tconst fullOpacityPoints = new Set(\n\t\t\t\t\t\tArray.from(engravedPoints)\n\t\t\t\t\t\t\t.filter((point) => point.split(',')[2] === '2')\n\t\t\t\t\t\t\t.map((point) => point),\n\t\t\t\t\t)\n\n\t\t\t\t\t// Draw half opacity points first\n\t\t\t\t\tif (halfOpacityPoints.size > 0) {\n\t\t\t\t\t\tdrawEngravedPoints(offscreenCtx!, halfOpacityPoints, engravingColor, `${engravingColor}80`)\n\t\t\t\t\t}\n\t\t\t\t\t// Draw full opacity points on top\n\t\t\t\t\tif (fullOpacityPoints.size > 0) {\n\t\t\t\t\t\tdrawEngravedPoints(offscreenCtx!, fullOpacityPoints, engravingColor, engravingColor)\n\t\t\t\t\t}\n\n\t\t\t\t\t// Update smoke particles\n\t\t\t\t\tsmokeParticles = updateAndDrawParticles(smokeParticles, offscreenCtx as any)\n\n\t\t\t\t\t// Engrave new points with pass number\n\t\t\t\t\tfor (let i = 0; i < speed; i++) {\n\t\t\t\t\t\tif (direction === 'ltr' && currentPoint < points.length) {\n\t\t\t\t\t\t\tconst point = points[currentPoint]\n\t\t\t\t\t\t\tengravedPoints.add(`${point.x},${point.y},1`) // First pass\n\t\t\t\t\t\t\tcurrentPoint++\n\t\t\t\t\t\t} else if (direction === 'rtl' && currentPoint >= 0) {\n\t\t\t\t\t\t\tconst point = points[currentPoint]\n\t\t\t\t\t\t\tengravedPoints.add(`${point.x},${point.y},2`) // Second pass\n\t\t\t\t\t\t\tcurrentPoint--\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Draw laser and smoke at current position\n\t\t\t\t\tif ((direction === 'ltr' && currentPoint < points.length) || (direction === 'rtl' && currentPoint >= 0)) {\n\t\t\t\t\t\tconst currentPos = points[currentPoint]\n\t\t\t\t\t\tdrawFunctions.drawLaser(offscreenCtx!, currentPos.x, currentPos.y)\n\t\t\t\t\t\tsmokeParticles.push(...drawFunctions.createSmoke(currentPos.x, currentPos.y))\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// When first pass is complete (LTR), transition to touch-up\n\t\t\t\t\t\tif (direction === 'ltr') {\n\t\t\t\t\t\t\tphase = 'touching-up'\n\t\t\t\t\t\t\ttouchUpStartTime = currentTime\n\t\t\t\t\t\t\ttouchUpPoints = generateTouchUpPoints()\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Finished second pass (RTL)\n\t\t\t\t\t\t\tpasses = maxPasses\n\t\t\t\t\t\t\tphase = 'scanning'\n\t\t\t\t\t\t\tscanCount = 0\n\t\t\t\t\t\t\tscanProgress = 0\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else if (phase === 'touching-up') {\n\t\t\t\t\t// During touch-up, maintain half opacity\n\t\t\t\t\tdrawEngravedPoints(offscreenCtx!, engravedPoints, engravingColor, `${engravingColor}80`)\n\n\t\t\t\t\t// Update smoke particles\n\t\t\t\t\tsmokeParticles = updateAndDrawParticles(smokeParticles, offscreenCtx as any)\n\n\t\t\t\t\t// Calculate touch-up progress\n\t\t\t\t\tconst touchUpElapsed = currentTime - touchUpStartTime\n\n\t\t\t\t\tif (touchUpElapsed < TOUCH_UP_DURATION) {\n\t\t\t\t\t\t// Randomly move between touch-up points\n\t\t\t\t\t\tconst randomIndex = Math.floor(Math.random() * touchUpPoints.length)\n\t\t\t\t\t\tconst targetPoint = touchUpPoints[randomIndex]\n\n\t\t\t\t\t\t// Draw laser and smoke effects\n\t\t\t\t\t\tdrawFunctions.drawLaser(offscreenCtx!, targetPoint.x, targetPoint.y)\n\t\t\t\t\t\tsmokeParticles.push(...drawFunctions.createSmoke(targetPoint.x, targetPoint.y))\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Transition to second pass\n\t\t\t\t\t\tphase = 'engraving'\n\t\t\t\t\t\tdirection = 'rtl'\n\t\t\t\t\t\tcurrentPoint = points.length - 1\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Copy offscreen canvas to main canvas\n\t\t\t\tmainCtx!.clearRect(0, 0, width, height)\n\n\t\t\t\tmainCtx!.drawImage(offscreen, 0, 0)\n\n\t\t\t\tif (phase === 'scanning' || currentPoint < points.length || smokeParticles.length > 0) {\n\t\t\t\t\tanimationFrameId = requestAnimationFrame(animate)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Start animation after delay\n\t\t\tconst timeoutId = setTimeout(() => {\n\t\t\t\tanimate(performance.now())\n\t\t\t}, delay * 1000)\n\n\t\t\treturn () => {\n\t\t\t\tif (animationFrameId) cancelAnimationFrame(animationFrameId)\n\t\t\t\tclearTimeout(timeoutId) // Clean up timeout\n\t\t\t\toffscreen.width = 0\n\t\t\t\toffscreen.height = 0\n\t\t\t\tsmokeParticles = []\n\t\t\t\tengravedPoints.clear()\n\t\t\t}\n\t\t}, [\n\t\t\tpoints,\n\t\t\twidth,\n\t\t\theight,\n\t\t\tbackgroundColor,\n\t\t\tengravingColor,\n\t\t\tspeed,\n\t\t\tdelay,\n\t\t\tdrawFunctions,\n\t\t\tboundingBox,\n\t\t\tdrawEngravedPoints,\n\t\t\tupdateAndDrawParticles,\n\t\t])\n\n\t\treturn <canvas ref={canvasRef} width={width} height={height} style={canvasStyle} className={className} />\n\t},\n)\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/list-row.tsx",
    "content": "import React, {MouseEventHandler} from 'react'\nimport {IconType} from 'react-icons'\nimport {useMount} from 'react-use'\n\nimport {cn} from '@/lib/utils'\n\nexport function ListRow({\n\ttitle,\n\tdescription,\n\tchildren,\n\tisActive = false,\n\tisLabel = false,\n\tdisabled,\n\tonClick,\n}: {\n\ttitle: string\n\tdescription: React.ReactNode\n\tchildren?: React.ReactNode\n\tisActive?: boolean\n\tisLabel?: boolean\n\tdisabled?: boolean\n\tonClick?: MouseEventHandler\n}) {\n\tconst El = isLabel ? 'label' : 'div'\n\tconst ref = React.useRef<any>(null)\n\n\tuseMount(() => {\n\t\tif (!isActive) return\n\t\t// ref.current?.scrollIntoView({behavior: 'smooth'})\n\t\tref.current?.focus()\n\t})\n\n\treturn (\n\t\t<El\n\t\t\t// Allow being focused if active\n\t\t\ttabIndex={isActive ? 0 : -1}\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t'flex items-center justify-between gap-x-4 gap-y-2.5 py-4 outline-hidden',\n\t\t\t\t// Show hover effect by default\n\t\t\t\t'bg-linear-to-r from-transparent to-transparent hover:via-white/4',\n\t\t\t\t// Make it clickable if it's a label\n\t\t\t\tisLabel && 'active:via-white/3',\n\t\t\t\t// TODO: also scroll into view if active\n\t\t\t\tisActive && 'umbrel-pulse-a-few-times',\n\t\t\t\tdisabled && 'pointer-events-none opacity-50',\n\t\t\t)}\n\t\t\tonClick={onClick}\n\t\t>\n\t\t\t<div className='flex min-w-0 flex-1 flex-col gap-1'>\n\t\t\t\t<h3 className='text-14 leading-none font-medium -tracking-2'>{title}</h3>\n\t\t\t\t<p className='text-12 leading-tight -tracking-2 text-white/40'>{description}</p>\n\t\t\t</div>\n\t\t\t{children}\n\t\t</El>\n\t)\n}\n\nexport function ListRowMobile({\n\ticon,\n\ttitle,\n\tdescription,\n\tchildren,\n\tonClick,\n}: {\n\ticon: IconType\n\ttitle: React.ReactNode\n\tdescription: React.ReactNode\n\tchildren?: React.ReactNode\n\tonClick?: () => void\n}) {\n\tconst Icon = icon\n\n\treturn (\n\t\t<button className={cn('flex w-full items-center gap-x-4 gap-y-2.5 px-2.5 py-3 text-left')} onClick={onClick}>\n\t\t\t<div className='flex h-8 w-8 shrink-0 items-center justify-center rounded-6 bg-white/6'>\n\t\t\t\t{Icon && <Icon className={cn('h-5 w-5 text-brand [&>*]:stroke-2')} />}\n\t\t\t</div>\n\t\t\t<div className='flex min-w-0 flex-col gap-1'>\n\t\t\t\t<h3 className='text-13 leading-none font-medium -tracking-2'>{title}</h3>\n\t\t\t\t<p className='truncate text-12 leading-none -tracking-2 text-white/40'>{description}</p>\n\t\t\t</div>\n\t\t\t{children}\n\t\t</button>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/memory-card-content.tsx",
    "content": "import {useSystemMemoryForUi} from '@/hooks/use-memory'\nimport {t} from '@/utils/i18n'\n\nimport {ProgressStatCardContent} from './progress-card-content'\nimport {cardErrorClass} from './shared'\n\nexport function MemoryCardContent() {\n\tconst {value, valueSub, secondaryValue, progress, isMemoryLow} = useSystemMemoryForUi()\n\n\treturn (\n\t\t<ProgressStatCardContent\n\t\t\ttitle={t('memory')}\n\t\t\tvalue={value}\n\t\t\tvalueSub={valueSub}\n\t\t\tsecondaryValue={secondaryValue}\n\t\t\tprogress={progress}\n\t\t\tafterChildren={isMemoryLow && <span className={cardErrorClass}>{t('memory.low')}</span>}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/no-forgot-password-message.tsx",
    "content": "export function NoForgotPasswordMessage() {\n\treturn (\n\t\t<p className='text-12 leading-tight font-normal -tracking-2 text-white/40'>\n\t\t\tThere is no ‘Forgot password’ option, so we recommend you write down your password physically somewhere, in case\n\t\t\tyou forget.\n\t\t\t{/* Add this back when we do password strength checking */}\n\t\t\t{/* https://surajmahraj.notion.site/umbrelOS-1-0-UI-Polish-a75c2f43893d49f4ae1e572e1455c33e#:~:text=My%20idea%20for%20%E2%80%98-,super%20strong,-%E2%80%99%20is%20that%20this */}\n\t\t\t{/* <Trans i18nKey='no-forgot-password-message' components={{em: <em className='not-italic text-success-light' />}} /> */}\n\t\t</p>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/progress-card-content.tsx",
    "content": "import {Progress} from '@/components/ui/progress'\nimport {cn} from '@/lib/utils'\n\nimport {cardSecondaryValueClass, cardTitleClass, cardValueClass, cardValueSubClass} from './shared'\n\nexport function ProgressStatCardContent({\n\ttitle,\n\tvalue,\n\tvalueSub,\n\tsecondaryValue,\n\tprogress,\n\tafterChildren,\n}: {\n\ttitle?: string\n\tvalue?: string\n\tvalueSub?: string\n\tsecondaryValue?: string\n\tprogress: number\n\tafterChildren?: React.ReactNode\n}) {\n\treturn (\n\t\t<div className='flex flex-col gap-4'>\n\t\t\t<div className={cardTitleClass}>{title}</div>\n\t\t\t<div className='flex items-baseline justify-between gap-4 truncate text-17 leading-tight'>\n\t\t\t\t<div className='flex items-baseline gap-1 truncate'>\n\t\t\t\t\t<span className={cardValueClass}>{value}</span>{' '}\n\t\t\t\t\t<span className={cn(cardValueSubClass, 'hidden sm:block')}>{valueSub}</span>\n\t\t\t\t</div>\n\t\t\t\t<span className={cn(cardSecondaryValueClass, 'text-xs')}>{secondaryValue}</span>\n\t\t\t</div>\n\t\t\t<Progress value={progress * 100} size='thicker' variant='primary' />\n\t\t\t{afterChildren}\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/settings-content-mobile.tsx",
    "content": "import {\n\tTb2Fa,\n\tTbArrowBigRightLines,\n\tTbCircleArrowUp,\n\tTbColumns3,\n\tTbHistory,\n\tTbLanguage,\n\tTbPhoto,\n\tTbServer,\n\tTbSettingsMinus,\n\tTbShare,\n\tTbTool,\n\tTbUser,\n\tTbWifi,\n} from 'react-icons/tb'\nimport {Link, useNavigate} from 'react-router-dom'\n\n// import {useNavigate} from 'react-router-dom'\n\nimport {ButtonLink} from '@/components/ui/button-link'\nimport {Card} from '@/components/ui/card'\nimport {SETTINGS_SYSTEM_CARDS_ID, UNKNOWN} from '@/constants'\nimport {getDeviceHealth} from '@/features/storage/hooks/use-storage'\nimport {useCpuTemperature} from '@/hooks/use-cpu-temperature'\nimport {useDeviceInfo} from '@/hooks/use-device-info'\nimport {useIsHomeOrPro} from '@/hooks/use-is-home-or-pro'\nimport {useIsUmbrelPro} from '@/hooks/use-is-umbrel-pro'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {DesktopPreviewConnected, DesktopPreviewFrame} from '@/modules/desktop/desktop-preview'\nimport {WifiListRowConnectedDescription} from '@/modules/wifi/wifi-list-row-connected-description'\nimport {SettingsSummary} from '@/routes/settings/_components/settings-summary'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {firstNameFromFullName} from '@/utils/misc'\n\nimport {CpuCardContent} from './cpu-card-content'\nimport {CpuTemperatureCardContent} from './cpu-temperature-card-content'\nimport {ListRowMobile} from './list-row'\nimport {MemoryCardContent} from './memory-card-content'\nimport {ContactSupportLink} from './shared'\nimport {StorageCardContent} from './storage-card-content'\n\nexport function SettingsContentMobile() {\n\tconst {addLinkSearchParams} = useQueryParams()\n\tconst navigate = useNavigate()\n\tconst userQ = trpcReact.user.get.useQuery()\n\tconst cpuTemperature = useCpuTemperature()\n\tconst deviceInfo = useDeviceInfo()\n\tconst wifiQ = trpcReact.wifi.connected.useQuery()\n\tconst {isUmbrelPro} = useIsUmbrelPro()\n\tconst {deviceName} = useIsHomeOrPro()\n\t// Storage queries only run on Umbrel Pro to avoid unnecessary API calls on other devices\n\tconst raidStatusQ = trpcReact.hardware.raid.getStatus.useQuery(undefined, {enabled: isUmbrelPro})\n\tconst devicesQ = trpcReact.hardware.internalStorage.getDevices.useQuery(undefined, {enabled: isUmbrelPro})\n\t// const isUmbrelHomeQ = trpcReact.migration.isUmbrelHome.useQuery()\n\t// const isUmbrelHome = !!isUmbrelHomeQ.data\n\n\t// Check if there's a RAID issue that needs attention\n\tconst hasRaidIssue = raidStatusQ.data?.exists && raidStatusQ.data?.status && raidStatusQ.data?.status !== 'ONLINE'\n\n\t// Check if any SSD has health issues\n\tconst hasHealthIssue = devicesQ.data?.some((device) => getDeviceHealth(device).hasWarning)\n\n\t// Show indicator if any storage issue exists\n\t// Note: Storage Manager row only renders on Umbrel Pro, so this indicator is Pro-only\n\tconst hasStorageIssue = hasRaidIssue || hasHealthIssue\n\n\tif (!userQ.data) {\n\t\treturn null\n\t}\n\n\treturn (\n\t\t<div className='flex animate-in flex-col gap-5 fade-in'>\n\t\t\t<div className='flex items-center justify-center'>\n\t\t\t\t<DesktopPreviewFrame>\n\t\t\t\t\t<DesktopPreviewConnected />\n\t\t\t\t</DesktopPreviewFrame>\n\t\t\t</div>\n\n\t\t\t<div className='grid max-md:gap-5 md:grid-cols-2'>\n\t\t\t\t<div className='flex items-center gap-[5px] px-2.5 md:order-last'>\n\t\t\t\t\t<ButtonLink to={{search: addLinkSearchParams({dialog: 'logout'})}} size='md-squared' className='flex-grow'>\n\t\t\t\t\t\t{t('logout')}\n\t\t\t\t\t</ButtonLink>\n\t\t\t\t\t<ButtonLink to={{search: addLinkSearchParams({dialog: 'restart'})}} size='md-squared' className='flex-grow'>\n\t\t\t\t\t\t{t('restart')}\n\t\t\t\t\t</ButtonLink>\n\t\t\t\t\t<ButtonLink\n\t\t\t\t\t\tto={{\n\t\t\t\t\t\t\tsearch: addLinkSearchParams({dialog: 'shutdown'}),\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tsize='md-squared'\n\t\t\t\t\t\ttext='destructive'\n\t\t\t\t\t\tclassName='flex-grow'\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('shut-down')}\n\t\t\t\t\t</ButtonLink>\n\t\t\t\t</div>\n\n\t\t\t\t<div className='mx-2.5'>\n\t\t\t\t\t<h2 className='text-24 leading-none font-bold -tracking-4'>\n\t\t\t\t\t\t{userQ.data?.name && `${firstNameFromFullName(userQ.data?.name)}’s`}{' '}\n\t\t\t\t\t\t<span className='opacity-40'>{t('umbrel')}</span>\n\t\t\t\t\t</h2>\n\t\t\t\t\t<div className='pt-5' />\n\t\t\t\t\t<SettingsSummary />\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* --- */}\n\t\t\t<div className='grid grid-cols-2 gap-2'>\n\t\t\t\t<Link\n\t\t\t\t\tto={{\n\t\t\t\t\t\tsearch: addLinkSearchParams({dialog: 'live-usage', tab: 'storage'}),\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Card>\n\t\t\t\t\t\t<StorageCardContent />\n\t\t\t\t\t</Card>\n\t\t\t\t</Link>\n\n\t\t\t\t<Link\n\t\t\t\t\tto={{\n\t\t\t\t\t\tsearch: addLinkSearchParams({dialog: 'live-usage', tab: 'memory'}),\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t{/* Set id on the second card because we wanna scroll to see them all */}\n\t\t\t\t\t<Card id={SETTINGS_SYSTEM_CARDS_ID}>\n\t\t\t\t\t\t<MemoryCardContent />\n\t\t\t\t\t</Card>\n\t\t\t\t</Link>\n\n\t\t\t\t<Link\n\t\t\t\t\tto={{\n\t\t\t\t\t\tsearch: addLinkSearchParams({dialog: 'live-usage', tab: 'cpu'}),\n\t\t\t\t\t}}\n\t\t\t\t>\n\t\t\t\t\t<Card>\n\t\t\t\t\t\t<CpuCardContent />\n\t\t\t\t\t</Card>\n\t\t\t\t</Link>\n\n\t\t\t\t<Card>\n\t\t\t\t\t<CpuTemperatureCardContent\n\t\t\t\t\t\twarning={cpuTemperature.warning}\n\t\t\t\t\t\ttemperatureInCelcius={cpuTemperature.temperature}\n\t\t\t\t\t/>\n\t\t\t\t</Card>\n\t\t\t</div>\n\n\t\t\t<div className='umbrel-divide-y rounded-12 bg-white/5 p-1'>\n\t\t\t\t<ListRowMobile\n\t\t\t\t\ticon={TbUser}\n\t\t\t\t\ttitle={t('account')}\n\t\t\t\t\tdescription={t('account-description')}\n\t\t\t\t\tonClick={() => navigate('account/change-name')}\n\t\t\t\t/>\n\t\t\t\t<ListRowMobile\n\t\t\t\t\ticon={TbPhoto}\n\t\t\t\t\ttitle={t('wallpaper')}\n\t\t\t\t\tdescription={t('wallpaper-description')}\n\t\t\t\t\tonClick={() => navigate('wallpaper')}\n\t\t\t\t/>\n\t\t\t\t<ListRowMobile\n\t\t\t\t\ticon={TbWifi}\n\t\t\t\t\ttitle={t('wifi')}\n\t\t\t\t\tdescription={\n\t\t\t\t\t\twifiQ.data?.status === 'connected' ? (\n\t\t\t\t\t\t\t<WifiListRowConnectedDescription network={wifiQ.data} />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\tt('wifi-description')\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\tonClick={() => navigate('wifi')}\n\t\t\t\t/>\n\t\t\t\t<ListRowMobile\n\t\t\t\t\ticon={Tb2Fa}\n\t\t\t\t\ttitle={t('2fa')}\n\t\t\t\t\tdescription={t('2fa-description')}\n\t\t\t\t\tonClick={() => navigate('2fa')}\n\t\t\t\t/>\n\t\t\t\t<ListRowMobile\n\t\t\t\t\ticon={TbShare}\n\t\t\t\t\ttitle={t('settings.file-sharing')}\n\t\t\t\t\tdescription={t('settings.file-sharing.description')}\n\t\t\t\t\tonClick={() => navigate('file-sharing')}\n\t\t\t\t/>\n\t\t\t\t{isUmbrelPro && (\n\t\t\t\t\t<ListRowMobile\n\t\t\t\t\t\ticon={TbColumns3}\n\t\t\t\t\t\ttitle={\n\t\t\t\t\t\t\t<span className='flex items-center gap-1.5'>\n\t\t\t\t\t\t\t\t{t('storage-manager')}\n\t\t\t\t\t\t\t\t{hasStorageIssue && (\n\t\t\t\t\t\t\t\t\t<div className='relative h-2 w-2'>\n\t\t\t\t\t\t\t\t\t\t<span className='absolute inset-0 rounded-full bg-[#FF3434]' />\n\t\t\t\t\t\t\t\t\t\t<span className='absolute inset-0 animate-ping rounded-full bg-[#FF3434] opacity-75' />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t}\n\t\t\t\t\t\tdescription={t('storage-manager.description')}\n\t\t\t\t\t\tonClick={() => navigate('storage')}\n\t\t\t\t\t/>\n\t\t\t\t)}\n\t\t\t\t<ListRowMobile\n\t\t\t\t\ticon={TbHistory}\n\t\t\t\t\ttitle={t('backups')}\n\t\t\t\t\tdescription={t('backups-description')}\n\t\t\t\t\tonClick={() => navigate('backups')}\n\t\t\t\t/>\n\t\t\t\t{/* <ListRowMobile\n\t\t\t\t\ticon={TbShoppingBag}\n\t\t\t\t\ttitle={t('app-store.title')}\n\t\t\t\t\tdescription={t('app-store.description')}\n\t\t\t\t\tonClick={() => navigate(linkToDialog('app-store-preferences'))}\n\t\t\t\t/> */}\n\t\t\t\t<ListRowMobile\n\t\t\t\t\ticon={TbTool}\n\t\t\t\t\ttitle={t('troubleshoot')}\n\t\t\t\t\tdescription={t('troubleshoot-description')}\n\t\t\t\t\tonClick={() => navigate('troubleshoot')}\n\t\t\t\t/>\n\t\t\t\t<ListRowMobile\n\t\t\t\t\ticon={TbServer}\n\t\t\t\t\ttitle={t('device-info')}\n\t\t\t\t\tdescription={t('device-info-description', {\n\t\t\t\t\t\tmodel: deviceInfo.data?.modelNumber ?? UNKNOWN(),\n\t\t\t\t\t\tserial: deviceInfo.data?.serialNumber ?? UNKNOWN(),\n\t\t\t\t\t})}\n\t\t\t\t\tonClick={() => navigate('device-info')}\n\t\t\t\t/>\n\t\t\t\t<ListRowMobile\n\t\t\t\t\ticon={TbArrowBigRightLines}\n\t\t\t\t\ttitle={t('migration-assistant')}\n\t\t\t\t\tdescription={t('migration-assistant-description', {deviceName})}\n\t\t\t\t\tonClick={() => navigate('migration-assistant')}\n\t\t\t\t/>\n\t\t\t\t<ListRowMobile\n\t\t\t\t\ticon={TbLanguage}\n\t\t\t\t\ttitle={t('language')}\n\t\t\t\t\tdescription={t('language-description')}\n\t\t\t\t\tonClick={() => navigate('language')}\n\t\t\t\t/>\n\t\t\t\t<ListRowMobile\n\t\t\t\t\ticon={TbSettingsMinus}\n\t\t\t\t\ttitle={t('advanced-settings')}\n\t\t\t\t\tdescription={t('advanced-settings-description')}\n\t\t\t\t\tonClick={() => navigate('advanced')}\n\t\t\t\t/>\n\t\t\t\t<ListRowMobile\n\t\t\t\t\ticon={TbCircleArrowUp}\n\t\t\t\t\ttitle={t('software-update.title')}\n\t\t\t\t\tdescription={t('check-for-latest-version')}\n\t\t\t\t\tonClick={() => navigate('software-update')}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t\t<ContactSupportLink />\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/settings-content.tsx",
    "content": "import {Loader2} from 'lucide-react'\nimport {useEffect, useState} from 'react'\nimport {FaRegSave} from 'react-icons/fa'\nimport {\n\tRiExpandRightFill,\n\tRiKeyLine,\n\tRiLogoutCircleRLine,\n\tRiPulseLine,\n\tRiRestartLine,\n\tRiShutDownLine,\n\tRiUserLine,\n} from 'react-icons/ri'\nimport {TbColumns3, TbHistory, TbServer, TbSettings, TbSettingsMinus, TbShare, TbTool, TbWifi} from 'react-icons/tb'\nimport {useNavigate, useParams} from 'react-router-dom'\n\nimport {ChevronDown} from '@/components/chevron-down'\nimport {Card} from '@/components/ui/card'\nimport {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'\nimport {IconButton} from '@/components/ui/icon-button'\nimport {IconButtonLink} from '@/components/ui/icon-button-link'\nimport {Switch} from '@/components/ui/switch'\nimport {SETTINGS_SYSTEM_CARDS_ID} from '@/constants'\nimport {useBackups} from '@/features/backups/hooks/use-backups'\nimport {getDeviceHealth} from '@/features/storage/hooks/use-storage'\nimport {useCpuTemperature} from '@/hooks/use-cpu-temperature'\nimport {useIsHomeOrPro} from '@/hooks/use-is-home-or-pro'\nimport {useIsUmbrelPro} from '@/hooks/use-is-umbrel-pro'\nimport {DesktopPreviewConnected, DesktopPreviewFrame} from '@/modules/desktop/desktop-preview'\nimport {WifiListRowConnectedDescription} from '@/modules/wifi/wifi-list-row-connected-description'\nimport {LanguageDropdownContent, LanguageDropdownTrigger} from '@/routes/settings/_components/language-dropdown'\nimport {SettingsSummary} from '@/routes/settings/_components/settings-summary'\nimport {trpcReact} from '@/trpc/trpc'\nimport {useLinkToDialog} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\nimport {firstNameFromFullName} from '@/utils/misc'\n\nimport {CpuCardContent} from './cpu-card-content'\nimport {CpuTemperatureCardContent} from './cpu-temperature-card-content'\nimport {ListRow} from './list-row'\nimport {MemoryCardContent} from './memory-card-content'\nimport {ContactSupportLink} from './shared'\nimport {SoftwareUpdateListRow} from './software-update-list-row'\nimport {StorageCardContent} from './storage-card-content'\nimport {WallpaperPicker} from './wallpaper-picker'\n\nexport function SettingsContent() {\n\tconst navigate = useNavigate()\n\tconst linkToDialog = useLinkToDialog()\n\tconst [languageOpen, setLanguageOpen] = useState(false)\n\n\tconst cpuTemp = useCpuTemperature()\n\tconst {isUmbrelPro} = useIsUmbrelPro()\n\tconst {deviceName} = useIsHomeOrPro()\n\n\tconst [userQ, wifiSupportedQ, is2faEnabledQ, raidStatusQ, devicesQ] = trpcReact.useQueries((t) => [\n\t\tt.user.get(),\n\t\tt.wifi.supported(),\n\t\tt.user.is2faEnabled(),\n\t\t// Storage queries only run on Umbrel Pro to avoid unnecessary API calls on other devices\n\t\tt.hardware.raid.getStatus(undefined, {enabled: isUmbrelPro}),\n\t\tt.hardware.internalStorage.getDevices(undefined, {enabled: isUmbrelPro}),\n\t])\n\n\tconst {repositories: backupRepositories, isLoadingRepositories: isLoadingBackups} = useBackups()\n\n\t// Check if there's a RAID issue that needs attention\n\tconst hasRaidIssue = raidStatusQ.data?.exists && raidStatusQ.data?.status && raidStatusQ.data?.status !== 'ONLINE'\n\n\t// Check if any SSD has health issues\n\tconst hasHealthIssue = devicesQ.data?.some((device) => getDeviceHealth(device).hasWarning)\n\n\t// Show indicator if any storage issue exists\n\t// Note: Storage Manager row only renders on Umbrel Pro, so this indicator is Pro-only\n\tconst hasStorageIssue = hasRaidIssue || hasHealthIssue\n\n\tconst {settingsDialog} = useParams<{settingsDialog: 'wallpaper' | 'language' | 'software-update'}>()\n\n\t// Scroll to hash\n\tuseEffect(() => {\n\t\tif (location.hash) {\n\t\t\tconst el = document.querySelector(location.hash)\n\t\t\tif (el) {\n\t\t\t\tel.scrollIntoView({behavior: 'instant', block: 'center'})\n\t\t\t}\n\t\t}\n\t}, [])\n\n\treturn (\n\t\t<div className='animate-in fade-in'>\n\t\t\t<div className='grid w-full gap-x-[30px] gap-y-[20px] lg:grid-cols-[280px_auto]'>\n\t\t\t\t<div className='flex items-center justify-center'>\n\t\t\t\t\t<DesktopPreviewFrame>\n\t\t\t\t\t\t<DesktopPreviewConnected />\n\t\t\t\t\t</DesktopPreviewFrame>\n\t\t\t\t</div>\n\t\t\t\t<Card className='flex flex-wrap items-center justify-between gap-5'>\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<h2 className='text-24 leading-none font-bold -tracking-4'>\n\t\t\t\t\t\t\t{userQ.data?.name && `${firstNameFromFullName(userQ.data?.name)}’s`}{' '}\n\t\t\t\t\t\t\t<span className='opacity-40'>{t('umbrel')}</span>\n\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t<div className='pt-5' />\n\t\t\t\t\t\t<SettingsSummary />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className='flex w-full flex-col items-stretch gap-2.5 md:w-auto md:flex-row'>\n\t\t\t\t\t\t<IconButtonLink to={linkToDialog('logout')} size='xl' icon={RiLogoutCircleRLine}>\n\t\t\t\t\t\t\t{t('logout')}\n\t\t\t\t\t\t</IconButtonLink>\n\t\t\t\t\t\t<IconButtonLink to={linkToDialog('restart')} size='xl' icon={RiRestartLine}>\n\t\t\t\t\t\t\t{t('restart')}\n\t\t\t\t\t\t</IconButtonLink>\n\t\t\t\t\t\t<IconButtonLink to={linkToDialog('shutdown')} size='xl' text='destructive' icon={RiShutDownLine}>\n\t\t\t\t\t\t\t{t('shut-down')}\n\t\t\t\t\t\t</IconButtonLink>\n\t\t\t\t\t</div>\n\t\t\t\t</Card>\n\t\t\t\t<div className='flex flex-col gap-3'>\n\t\t\t\t\t<Card>\n\t\t\t\t\t\t<StorageCardContent />\n\t\t\t\t\t</Card>\n\t\t\t\t\t{/* Choosing middle card because we wanna scroll to center to likely see them all */}\n\t\t\t\t\t<Card id={SETTINGS_SYSTEM_CARDS_ID}>\n\t\t\t\t\t\t<MemoryCardContent />\n\t\t\t\t\t</Card>\n\t\t\t\t\t<Card>\n\t\t\t\t\t\t<CpuCardContent />\n\t\t\t\t\t</Card>\n\t\t\t\t\t<Card>\n\t\t\t\t\t\t<CpuTemperatureCardContent warning={cpuTemp.warning} temperatureInCelcius={cpuTemp.temperature} />\n\t\t\t\t\t</Card>\n\t\t\t\t\t<div className='mx-auto'>\n\t\t\t\t\t\t<IconButtonLink icon={RiPulseLine} to={linkToDialog('live-usage')}>\n\t\t\t\t\t\t\t{t('open-live-usage')}\n\t\t\t\t\t\t</IconButtonLink>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className='flex-1' />\n\t\t\t\t\t<ContactSupportLink className='max-lg:hidden' />\n\t\t\t\t</div>\n\t\t\t\t<Card className='umbrel-divide-y overflow-hidden !py-0'>\n\t\t\t\t\t<ListRow title={t('account')} description={t('account-description')}>\n\t\t\t\t\t\t<div className='flex flex-wrap gap-2 pt-3'>\n\t\t\t\t\t\t\t<IconButtonLink to={'account/change-name'} icon={RiUserLine}>\n\t\t\t\t\t\t\t\t{t('change-name')}\n\t\t\t\t\t\t\t</IconButtonLink>\n\t\t\t\t\t\t\t<IconButtonLink to={'account/change-password'} icon={RiKeyLine}>\n\t\t\t\t\t\t\t\t{t('change-password')}\n\t\t\t\t\t\t\t</IconButtonLink>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</ListRow>\n\t\t\t\t\t<ListRow\n\t\t\t\t\t\ttitle={t('wallpaper')}\n\t\t\t\t\t\tdescription={t('wallpaper-description')}\n\t\t\t\t\t\tisActive={settingsDialog === 'wallpaper'}\n\t\t\t\t\t>\n\t\t\t\t\t\t{/* -mx-2 so that when last item is active, it right aligns with other list row buttons, and first item aligns on mobile when picker wrapped down */}\n\t\t\t\t\t\t{/* w-full to prevent overflow issues */}\n\t\t\t\t\t\t<div className='-mx-2 max-w-full'>\n\t\t\t\t\t\t\t<WallpaperPicker />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</ListRow>\n\t\t\t\t\t{wifiSupportedQ.data ? (\n\t\t\t\t\t\t<WifiSupportedListRow />\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<ListRow title={t('wifi')} description={t('wifi-description')}>\n\t\t\t\t\t\t\t<Switch checked={false} onCheckedChange={() => navigate('wifi-unsupported')} />\n\t\t\t\t\t\t</ListRow>\n\t\t\t\t\t)}\n\t\t\t\t\t<ListRow title={t('2fa')} description={t('2fa-description')} disabled={is2faEnabledQ.isLoading}>\n\t\t\t\t\t\t<Switch checked={is2faEnabledQ.data} onCheckedChange={() => navigate('2fa')} />\n\t\t\t\t\t</ListRow>\n\t\t\t\t\t{/* Storage Manager - Umbrel Pro only */}\n\t\t\t\t\t{isUmbrelPro && (\n\t\t\t\t\t\t<ListRow title={t('storage-manager')} description={t('storage-manager.description')}>\n\t\t\t\t\t\t\t<div className='relative'>\n\t\t\t\t\t\t\t\t{hasStorageIssue && (\n\t\t\t\t\t\t\t\t\t<div className='absolute top-0 -right-0.5 h-2.5 w-2.5'>\n\t\t\t\t\t\t\t\t\t\t<span className='absolute inset-0 rounded-full bg-[#FF3434]' />\n\t\t\t\t\t\t\t\t\t\t<span className='absolute inset-0 animate-ping rounded-full bg-[#FF3434] opacity-75' />\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t<IconButton icon={TbColumns3} onClick={() => navigate('storage')}>\n\t\t\t\t\t\t\t\t\t{t('storage-manager.manage')}\n\t\t\t\t\t\t\t\t</IconButton>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</ListRow>\n\t\t\t\t\t)}\n\t\t\t\t\t<ListRow title={t('settings.file-sharing')} description={t('settings.file-sharing.description')}>\n\t\t\t\t\t\t<IconButton icon={TbShare} onClick={() => navigate('file-sharing')}>\n\t\t\t\t\t\t\t{t('settings.file-sharing.configure')}\n\t\t\t\t\t\t</IconButton>\n\t\t\t\t\t</ListRow>\n\t\t\t\t\t{/* Backups */}\n\t\t\t\t\t<ListRow title={t('backups')} description={t('backups-description')}>\n\t\t\t\t\t\t<div className='flex flex-wrap gap-2 pt-3'>\n\t\t\t\t\t\t\t{/* There are 2 buttons/dropdowns (Set up/Configure dropdown, Restore dropdown) */}\n\t\t\t\t\t\t\t{/* We always render the \"Restore\" dropdown with Full Restore and Rewind options */}\n\t\t\t\t\t\t\t{/* We render the \"Set up\" dropdown if the user has no backup repo yet, or the \"Configure\" button if they do*/}\n\t\t\t\t\t\t\t{/* If we're still checking for existing backup repos we just show a load spinner in place of the Set up or Configure button */}\n\t\t\t\t\t\t\t{isLoadingBackups ? (\n\t\t\t\t\t\t\t\t<div className='flex h-[30px] items-center'>\n\t\t\t\t\t\t\t\t\t<Loader2 className='size-4 animate-spin text-white/60' aria-label={t('loading')} />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : (backupRepositories?.length ?? 0) === 0 ? (\n\t\t\t\t\t\t\t\t<DropdownMenu>\n\t\t\t\t\t\t\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t\t\t\t\t\t\t<IconButton icon={FaRegSave}>\n\t\t\t\t\t\t\t\t\t\t\t{t('backups-setup')}\n\t\t\t\t\t\t\t\t\t\t\t<ChevronDown />\n\t\t\t\t\t\t\t\t\t\t</IconButton>\n\t\t\t\t\t\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t\t\t\t\t\t<DropdownMenuContent align='end' className='min-w-[280px]'>\n\t\t\t\t\t\t\t\t\t\t<DropdownMenuItem onSelect={() => navigate('backups/setup?backups-setup-tab=nas')}>\n\t\t\t\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='text-14 font-medium'>{t('backups-setup-umbrel-or-nas')}</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='text-12 text-white/40'>{t('backups-setup-nas-or-umbrel-description')}</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t\t\t\t<DropdownMenuItem onSelect={() => navigate('backups/setup?backups-setup-tab=external')}>\n\t\t\t\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='text-14 font-medium'>{t('external-drive')}</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='text-12 text-white/40'>{t('backups-setup-external-description')}</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t\t\t\t<DropdownMenuItem onSelect={() => navigate('backups/setup?backups-setup-tab=umbrel-private-cloud')}>\n\t\t\t\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='text-14 font-medium'>{t('backups-setup-umbrel-private-cloud')}</div>\n\t\t\t\t\t\t\t\t\t\t\t\t<div className='text-12 text-white/40'>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{t('backups-setup-umbrel-private-cloud-description')}\n\t\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t\t\t</DropdownMenuContent>\n\t\t\t\t\t\t\t\t</DropdownMenu>\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<IconButtonLink to={'backups/configure'} icon={TbSettings}>\n\t\t\t\t\t\t\t\t\t{t('backups-configure')}\n\t\t\t\t\t\t\t\t</IconButtonLink>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t<DropdownMenu>\n\t\t\t\t\t\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t\t\t\t\t\t<IconButton icon={TbHistory}>\n\t\t\t\t\t\t\t\t\t\t{t('backups-restore')}\n\t\t\t\t\t\t\t\t\t\t<ChevronDown />\n\t\t\t\t\t\t\t\t\t</IconButton>\n\t\t\t\t\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t\t\t\t\t<DropdownMenuContent align='end' className='min-w-[280px]'>\n\t\t\t\t\t\t\t\t\t<DropdownMenuItem onSelect={() => navigate('backups/restore')}>\n\t\t\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t\t\t<div className='text-14 font-medium'>{t('backups-restore-full')}</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className='text-12 text-white/40'>{t('backups-restore-full-description')}</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t\t\t<DropdownMenuItem onSelect={() => navigate('/files/Home?rewind=open')}>\n\t\t\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t\t\t<div className='text-14 font-medium'>{t('backups-rewind')}</div>\n\t\t\t\t\t\t\t\t\t\t\t<div className='text-12 text-white/40'>{t('backups-rewind-description')}</div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t\t</DropdownMenuContent>\n\t\t\t\t\t\t\t</DropdownMenu>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</ListRow>\n\t\t\t\t\t{/* <ListRow title={t('app-store.title')} description={t('app-store.description')}>\n\t\t\t\t\t\t<IconButton icon={RiEqualizerLine} onClick={() => navigate(linkToDialog('app-store-preferences'))}>\n\t\t\t\t\t\t\t{t('preferences')}\n\t\t\t\t\t\t</IconButton>\n\t\t\t\t\t</ListRow> */}\n\t\t\t\t\t<ListRow title={t('troubleshoot')} description={t('troubleshoot-description')}>\n\t\t\t\t\t\t<IconButton icon={TbTool} onClick={() => navigate('troubleshoot')}>\n\t\t\t\t\t\t\t{t('troubleshoot')}\n\t\t\t\t\t\t</IconButton>\n\t\t\t\t\t</ListRow>\n\t\t\t\t\t<ListRow title={t('device-info')} description={t('device-info-description')}>\n\t\t\t\t\t\t<IconButton icon={TbServer} onClick={() => navigate('device-info')}>\n\t\t\t\t\t\t\t{t('device-info.view-info')}\n\t\t\t\t\t\t</IconButton>\n\t\t\t\t\t</ListRow>\n\t\t\t\t\t<ListRow title={t('migration-assistant')} description={t('migration-assistant-description', {deviceName})}>\n\t\t\t\t\t\t{/* We could use an IconButtonLink but then the ` from `ListRow` wouldn't work */}\n\t\t\t\t\t\t<IconButton icon={RiExpandRightFill} onClick={() => navigate('migration-assistant')}>\n\t\t\t\t\t\t\t{t('migrate')}\n\t\t\t\t\t\t</IconButton>\n\t\t\t\t\t</ListRow>\n\t\t\t\t\t{/* TODO: Uncomment and enable after fixing translations  */}\n\t\t\t\t\t<ListRow\n\t\t\t\t\t\ttitle={t('language')}\n\t\t\t\t\t\tdescription={t('language-description')}\n\t\t\t\t\t\tonClick={() => setLanguageOpen(true)}\n\t\t\t\t\t\tisActive={settingsDialog === 'language'}\n\t\t\t\t\t>\n\t\t\t\t\t\t<DropdownMenu open={languageOpen} onOpenChange={setLanguageOpen}>\n\t\t\t\t\t\t\t<LanguageDropdownTrigger />\n\t\t\t\t\t\t\t<LanguageDropdownContent />\n\t\t\t\t\t\t</DropdownMenu>\n\t\t\t\t\t</ListRow>\n\t\t\t\t\t<ListRow title={t('advanced-settings')} description={t('advanced-settings-description')}>\n\t\t\t\t\t\t<IconButtonLink icon={TbSettingsMinus} to='/settings/advanced'>\n\t\t\t\t\t\t\t{t('open')}\n\t\t\t\t\t\t</IconButtonLink>\n\t\t\t\t\t</ListRow>\n\t\t\t\t\t<SoftwareUpdateListRow isActive={settingsDialog === 'software-update'} />\n\t\t\t\t</Card>\n\t\t\t\t<ContactSupportLink className='lg:hidden' />\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\nfunction WifiSupportedListRow() {\n\tconst navigate = useNavigate()\n\tconst wifiQ = trpcReact.wifi.connected.useQuery()\n\n\treturn wifiQ.data?.status === 'connected' ? (\n\t\t<ListRow\n\t\t\ttitle={t('wifi')}\n\t\t\tdescription={<WifiListRowConnectedDescription network={wifiQ.data} />}\n\t\t\tdisabled={wifiQ.isLoading}\n\t\t>\n\t\t\t<IconButtonLink to={'wifi'} icon={TbWifi}>\n\t\t\t\t{t('wifi-view-networks')}\n\t\t\t</IconButtonLink>\n\t\t</ListRow>\n\t) : (\n\t\t<ListRow title={t('wifi')} description={t('wifi-description')} disabled={wifiQ.isLoading}>\n\t\t\t<Switch checked={false} onCheckedChange={() => navigate('wifi')} />\n\t\t</ListRow>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/settings-summary.tsx",
    "content": "import {Fragment} from 'react'\n\nimport {LOADING_DASH, UNKNOWN} from '@/constants'\nimport {useDeviceInfo} from '@/hooks/use-device-info'\nimport {useLanguage} from '@/hooks/use-language'\nimport {trpcReact} from '@/trpc/trpc'\nimport {duration} from '@/utils/date-time'\nimport {t} from '@/utils/i18n'\n\nexport function SettingsSummary() {\n\tconst [languageCode] = useLanguage()\n\tconst deviceInfo = useDeviceInfo()\n\tconst osVersionQ = trpcReact.system.version.useQuery()\n\tconst uptimeQ = trpcReact.system.uptime.useQuery()\n\tconst ipAddresses = trpcReact.system.getIpAddresses.useQuery()\n\n\treturn (\n\t\t<dl\n\t\t\tclassName='grid w-max grid-cols-2 items-center gap-x-5 gap-y-2 text-14 leading-none -tracking-2'\n\t\t\tstyle={{\n\t\t\t\t// Makes columns not all the same width\n\t\t\t\tgridTemplateColumns: 'auto auto',\n\t\t\t}}\n\t\t>\n\t\t\t<dt className='opacity-40'>{t('device')}</dt>\n\t\t\t<dd>{deviceInfo.data?.device || LOADING_DASH}</dd>\n\t\t\t<dt className='opacity-40'>{t('umbrelos')}</dt>\n\t\t\t<dd>{osVersionQ.isLoading ? LOADING_DASH : (osVersionQ.data?.name ?? UNKNOWN())}</dd>\n\t\t\t<dt className='opacity-40'>{t('local-ip')}</dt>\n\t\t\t<dd>\n\t\t\t\t{ipAddresses.data?.length\n\t\t\t\t\t? ipAddresses.data.map((ip: string, index: number) => (\n\t\t\t\t\t\t\t<Fragment key={ip}>\n\t\t\t\t\t\t\t\t{/* Allow text selection for copying IP address */}\n\t\t\t\t\t\t\t\t<span className='select-text'>{ip}</span>\n\t\t\t\t\t\t\t\t{index < ipAddresses.data.length - 1 && ', '}\n\t\t\t\t\t\t\t</Fragment>\n\t\t\t\t\t\t))\n\t\t\t\t\t: LOADING_DASH}\n\t\t\t</dd>\n\t\t\t<dt className='opacity-40'>{t('uptime')}</dt>\n\t\t\t<dd>{uptimeQ.isLoading ? LOADING_DASH : duration(uptimeQ.data, languageCode)}</dd>\n\t\t</dl>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/shared.tsx",
    "content": "import {useState} from 'react'\nimport {Trans} from 'react-i18next/TransWithoutContext'\nimport {RiAlarmWarningFill} from 'react-icons/ri'\nimport {Link, useNavigate} from 'react-router-dom'\n\nimport {ErrorAlert} from '@/components/ui/alert'\nimport {links} from '@/constants/links'\nimport {cn} from '@/lib/utils'\nimport {afterDelayedClose} from '@/utils/dialog'\nimport {linkClass} from '@/utils/element-classes'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nexport const cardTitleClass = tw`text-12 font-semibold leading-tight truncate -tracking-2 text-white/40`\nexport const cardValueClass = tw`font-bold -tracking-4 truncate text-17 leading-inter-trimmed`\nexport const cardValueSubClass = tw`text-14 font-bold truncate leading-inter-trimmed -tracking-3 text-white/40`\nexport const cardSecondaryValueBaseClass = tw`text-14 font-medium -tracking-3 text-white/40 leading-inter-trimmed`\nexport const cardSecondaryValueClass = cn(cardSecondaryValueBaseClass, tw`truncate flex-shrink-full`)\nexport const cardErrorClass = cn(cardSecondaryValueBaseClass, tw`animate-pulse leading-snug text-destructive2-lightest`)\n\nexport function ContactSupportLink({className}: {className?: string}) {\n\treturn (\n\t\t<p className={cn('mx-auto text-12 font-normal text-white/70', className)}>\n\t\t\t<Trans\n\t\t\t\ti18nKey='settings.contact-support'\n\t\t\t\tcomponents={{\n\t\t\t\t\tlinked: <Link to={links.support} className={linkClass} target='_blank' />,\n\t\t\t\t}}\n\t\t\t/>\n\t\t</p>\n\t)\n}\n\nexport function ChangePasswordWarning() {\n\treturn <ErrorAlert icon={RiAlarmWarningFill} description={t('change-password.callout')} />\n}\n\nexport function useSettingsDialogProps() {\n\tconst navigate = useNavigate()\n\n\tconst [open, setOpen] = useState(true)\n\n\treturn {\n\t\topen,\n\t\tonOpenChange: (open: boolean) => {\n\t\t\tsetOpen(open)\n\t\t\tafterDelayedClose(() => navigate('/settings'))(open)\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/software-update-list-row.tsx",
    "content": "// TODO: Re-enable Trans and Link when whats-new content is updated\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nimport {Trans} from 'react-i18next/TransWithoutContext'\nimport {RiArrowUpCircleFill, RiCheckboxCircleFill, RiInformationLine, RiRefreshLine} from 'react-icons/ri'\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nimport {Link} from 'react-router-dom'\n\nimport {Button} from '@/components/ui/button'\nimport {Icon} from '@/components/ui/icon'\nimport {IconButtonLink} from '@/components/ui/icon-button-link'\nimport {LOADING_DASH} from '@/constants'\nimport {useSoftwareUpdate} from '@/hooks/use-software-update'\nimport {useLinkToDialog} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nimport {ListRow} from './list-row'\n\nexport function SoftwareUpdateListRow({isActive}: {isActive: boolean}) {\n\tconst {state, currentVersion, latestVersion, checkLatest} = useSoftwareUpdate()\n\t// eslint-disable-next-line @typescript-eslint/no-unused-vars\n\tconst linkToDialog = useLinkToDialog()\n\n\tif (state === 'update-available') {\n\t\treturn (\n\t\t\t<ListRow\n\t\t\t\tisActive={isActive}\n\t\t\t\ttitle={currentVersion?.name || `umbrelOS ${LOADING_DASH}`}\n\t\t\t\tdescription={\n\t\t\t\t\t<span className='flex items-center gap-1 pb-3'>\n\t\t\t\t\t\t<Icon component={RiArrowUpCircleFill} className='text-brand' />\n\t\t\t\t\t\t{t('software-update.new-version', {name: latestVersion?.name || LOADING_DASH})}\n\t\t\t\t\t</span>\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t<IconButtonLink icon={RiInformationLine} variant='primary' to='/settings/software-update/confirm'>\n\t\t\t\t\t{t('software-update.view')}\n\t\t\t\t</IconButtonLink>\n\t\t\t</ListRow>\n\t\t)\n\t}\n\n\treturn (\n\t\t<ListRow\n\t\t\tisActive={isActive}\n\t\t\ttitle={currentVersion?.name || `umbrelOS ${LOADING_DASH}`}\n\t\t\tdescription={\n\t\t\t\t<span className='flex items-center gap-1 pb-3'>\n\t\t\t\t\t{state === 'at-latest' || state === 'checking' ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<Icon component={RiCheckboxCircleFill} className='text-success' />\n\t\t\t\t\t\t\t{t('software-update.on-latest')}\n\t\t\t\t\t\t\t{/* TODO: Re-enable when whats-new content is updated */}\n\t\t\t\t\t\t\t{/* {' · '}\n\t\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\t\ti18nKey='software-update.see-whats-new'\n\t\t\t\t\t\t\t\tcomponents={{\n\t\t\t\t\t\t\t\t\tlinked: <Link to={linkToDialog('whats-new')} className='underline' />,\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/> */}\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t{/* Invisible icon to prevent layout shift */}\n\t\t\t\t\t\t\t{t('check-for-latest-version')}\n\t\t\t\t\t\t\t<Icon component={RiArrowUpCircleFill} className='invisible' />\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t</span>\n\t\t\t}\n\t\t>\n\t\t\t<Button onClick={checkLatest}>\n\t\t\t\t<Icon component={RiRefreshLine} className={state === 'checking' ? 'animate-spin' : undefined} />\n\t\t\t\t{state === 'checking' ? t('software-update.checking') : t('software-update.check')}\n\t\t\t</Button>\n\t\t</ListRow>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/storage-card-content.tsx",
    "content": "import {useSystemDiskForUi} from '@/hooks/use-disk'\nimport {t} from '@/utils/i18n'\n\nimport {ProgressStatCardContent} from './progress-card-content'\nimport {cardErrorClass} from './shared'\n\nexport function StorageCardContent() {\n\tconst {value, valueSub, secondaryValue, progress, isDiskLow, isDiskFull} = useSystemDiskForUi()\n\n\treturn (\n\t\t<ProgressStatCardContent\n\t\t\ttitle={t('storage')}\n\t\t\tvalue={value}\n\t\t\tvalueSub={valueSub}\n\t\t\tsecondaryValue={secondaryValue}\n\t\t\tprogress={progress}\n\t\t\tafterChildren={\n\t\t\t\t<>\n\t\t\t\t\t{isDiskLow && <span className={cardErrorClass}>{t('storage.low')}</span>}\n\t\t\t\t\t{isDiskFull && <span className={cardErrorClass}>{t('storage.full')}</span>}\n\t\t\t\t</>\n\t\t\t}\n\t\t/>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/_components/wallpaper-picker.tsx",
    "content": "import {useEffect, useRef} from 'react'\n\nimport {cn} from '@/lib/utils'\nimport {useWallpaper, wallpapers} from '@/providers/wallpaper'\n\nconst ITEM_W = 40\nconst GAP = 4\nconst ACTIVE_SCALE = 1.4\n\nfunction WallpaperItem({\n\tactive,\n\tbg,\n\tonSelect,\n\tclassName,\n\tref,\n}: {\n\tactive?: boolean\n\tbg: string\n\tonSelect: () => void\n\tclassName?: string\n\tref?: React.Ref<HTMLButtonElement>\n}) {\n\treturn (\n\t\t<button\n\t\t\tref={ref}\n\t\t\tonClick={onSelect}\n\t\t\tclassName={cn(\n\t\t\t\t'h-6 shrink-0 bg-white/10 bg-cover bg-center ring-white/50 outline-hidden transition-all duration-200 hover:brightness-125 focus-visible:ring-1',\n\t\t\t\tactive\n\t\t\t\t\t? // NOTE: `mx-3` or whatever horizontal marging needs to be big enough to not cause the ring to get clipped from scrolling container\n\t\t\t\t\t\t'mx-3 rounded-5 ring-2 ring-white/50'\n\t\t\t\t\t: 'rounded-3',\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t\tstyle={{\n\t\t\t\twidth: ITEM_W,\n\t\t\t\ttransform: `scale(${active ? ACTIVE_SCALE : 1})`,\n\t\t\t\tbackgroundImage: `url(${bg})`,\n\t\t\t\t// transformOrigin: \"left center\",\n\t\t\t}}\n\t\t/>\n\t)\n}\n\n// TODO: delay mounting for performance\nexport function WallpaperPicker({maxW}: {maxW?: number}) {\n\tconst {wallpaper, setWallpaperId} = useWallpaper()\n\tconst containerRef = useRef<HTMLDivElement>(null)\n\tconst scrollerRef = useRef<HTMLDivElement>(null)\n\tconst itemsRef = useRef<HTMLDivElement>(null)\n\tconst selectedItemRef = useRef<HTMLButtonElement>(null)\n\n\tuseEffect(() => {\n\t\tif (!containerRef.current || !selectedItemRef.current || !itemsRef.current || !scrollerRef.current) {\n\t\t\treturn\n\t\t}\n\n\t\tconst containerW = containerRef.current.clientWidth\n\t\tconst index = wallpapers.findIndex((w) => w.id === wallpaper.id)\n\n\t\tscrollerRef.current.scrollTo({\n\t\t\tbehavior: 'smooth',\n\t\t\tleft: index * (ITEM_W + GAP) - containerW / 2 + (ITEM_W * ACTIVE_SCALE) / 2,\n\t\t})\n\t}, [wallpaper.id])\n\n\treturn (\n\t\t// h-7 so we don't affect height of parent, but make gap work when wrapping\n\t\t<div ref={containerRef} className='flex h-7 max-w-full flex-grow-1 animate-in items-center fade-in'>\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'umbrel-hide-scrollbar umbrel-wallpaper-fade-scroller w-full items-center overflow-x-auto bg-red-500/0 py-3',\n\t\t\t\t\t!maxW && 'md:max-w-[350px]',\n\t\t\t\t)}\n\t\t\t\tref={scrollerRef}\n\t\t\t\tstyle={{\n\t\t\t\t\tmaxWidth: maxW,\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{/* NOTE: doing `items-center` here would cause the spacer items collapse because of a flex bug */}\n\t\t\t\t<div ref={itemsRef} className='flex' style={{gap: GAP}}>\n\t\t\t\t\t<div className='w-1 shrink-0' />\n\t\t\t\t\t{wallpapers.map((w) => (\n\t\t\t\t\t\t<WallpaperItem\n\t\t\t\t\t\t\tref={w.id === wallpaper.id ? selectedItemRef : undefined}\n\t\t\t\t\t\t\tkey={w.id}\n\t\t\t\t\t\t\tactive={w.id === wallpaper.id}\n\t\t\t\t\t\t\tonSelect={() => {\n\t\t\t\t\t\t\t\tsetWallpaperId(w.id)\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tbg={`/assets/wallpapers/generated-thumbs/${w.id}.jpg`}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t\t<div className='w-1 shrink-0' />\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/advanced.tsx",
    "content": "import React from 'react'\nimport {PiFlaskFill} from 'react-icons/pi'\nimport {useParams} from 'react-router-dom'\n\nimport {CopyableField} from '@/components/ui/copyable-field'\nimport {CoverMessage, CoverMessageParagraph} from '@/components/ui/cover-message'\nimport {Dialog, DialogHeader, DialogScrollableContent, DialogTitle} from '@/components/ui/dialog'\nimport {Drawer, DrawerContent, DrawerHeader, DrawerScroller, DrawerTitle} from '@/components/ui/drawer'\nimport {Icon, IconTypes} from '@/components/ui/icon'\nimport {IconButtonLink} from '@/components/ui/icon-button-link'\nimport {Loading} from '@/components/ui/loading'\nimport {Switch} from '@/components/ui/switch'\nimport {useIsExternalDns} from '@/hooks/use-is-externaldns'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {useSoftwareUpdate} from '@/hooks/use-software-update'\nimport {useTorEnabled} from '@/hooks/use-tor-enabled'\nimport {cn} from '@/lib/utils'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nexport default function AdvancedSettingsDrawerOrDialog() {\n\tconst title = t('advanced-settings')\n\tconst dialogProps = useSettingsDialogProps()\n\n\tconst isBetaChannel = useIsBetaChannel()\n\n\tconst isExternalDns = useIsExternalDns()\n\n\tconst isMobile = useIsMobile()\n\n\tconst tor = useTorEnabled()\n\tconst hiddenServiceQ = trpcReact.system.hiddenService.useQuery(undefined, {\n\t\tenabled: tor.enabled,\n\t})\n\n\t// Track the last action (enable/disable) to show appropriate cover message\n\tconst [torEnabling, setTorEnabling] = React.useState(false)\n\n\tconst handleTorToggle = (checked: boolean) => {\n\t\tsetTorEnabling(checked)\n\t\ttor.setEnabled(checked)\n\t}\n\n\tconst {advancedSelection} = useParams<{\n\t\tadvancedSelection?: 'beta-program' | 'external-dns' | 'tor'\n\t}>()\n\n\tconst remoteTorAccessSettingRow = (\n\t\t<div className={cn('flex flex-col gap-2', cardClass, advancedSelection === 'tor' && 'umbrel-pulse-a-few-times')}>\n\t\t\t<label className='flex w-full items-center justify-between gap-x-2'>\n\t\t\t\t<CardText\n\t\t\t\t\ttitle={t('remote-tor-access')}\n\t\t\t\t\tdescription={tor.enabled ? t('tor-enabled-description') : t('tor-description')}\n\t\t\t\t/>\n\t\t\t\t<Switch\n\t\t\t\t\tclassName={cn('pointer-events-auto', tor.isMutLoading && 'umbrel-pulse')}\n\t\t\t\t\tchecked={!!tor.enabled}\n\t\t\t\t\tonCheckedChange={handleTorToggle}\n\t\t\t\t\tdisabled={tor.isLoading}\n\t\t\t\t/>\n\t\t\t</label>\n\t\t\t{tor.enabled && hiddenServiceQ.data && (\n\t\t\t\t<CopyableField narrow className='pointer-events-auto w-full' value={hiddenServiceQ.data} />\n\t\t\t)}\n\t\t</div>\n\t)\n\n\t// Show loading cover state while enabling/disabling Tor\n\tif (tor.isMutLoading) {\n\t\treturn (\n\t\t\t<CoverMessage>\n\t\t\t\t<Loading>{torEnabling ? t('enabling-tor') : t('tor.disable.progress')}</Loading>\n\t\t\t\t<CoverMessageParagraph>\n\t\t\t\t\t{torEnabling ? t('tor.enable.description') : t('tor.disable.description')}\n\t\t\t\t</CoverMessageParagraph>\n\t\t\t</CoverMessage>\n\t\t)\n\t}\n\n\tif (isMobile) {\n\t\treturn (\n\t\t\t<Drawer {...dialogProps}>\n\t\t\t\t<DrawerContent fullHeight>\n\t\t\t\t\t<DrawerHeader>\n\t\t\t\t\t\t<DrawerTitle>{title}</DrawerTitle>\n\t\t\t\t\t</DrawerHeader>\n\t\t\t\t\t<DrawerScroller>\n\t\t\t\t\t\t<div className='flex flex-col gap-y-3'>\n\t\t\t\t\t\t\t<label className={cardClass}>\n\t\t\t\t\t\t\t\t<CardText title={t('terminal')} description={t('terminal-description')} />\n\t\t\t\t\t\t\t\t<IconButtonLink className='pointer-events-auto self-center' to={'/settings/terminal'}>\n\t\t\t\t\t\t\t\t\t{t('open')}\n\t\t\t\t\t\t\t\t</IconButtonLink>\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<label className={cn(cardClass, advancedSelection === 'beta-program' && 'umbrel-pulse-a-few-times')}>\n\t\t\t\t\t\t\t\t<CardText\n\t\t\t\t\t\t\t\t\ttitle={t('beta-program')}\n\t\t\t\t\t\t\t\t\tdescription={t('beta-program-description')}\n\t\t\t\t\t\t\t\t\ttrailingIcon={PiFlaskFill}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\tclassName={cn('pointer-events-auto', isBetaChannel.isLoading && 'umbrel-pulse')}\n\t\t\t\t\t\t\t\t\tchecked={isBetaChannel.isChecked}\n\t\t\t\t\t\t\t\t\tonCheckedChange={isBetaChannel.change}\n\t\t\t\t\t\t\t\t\tdisabled={isBetaChannel.isLoading}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<label className={cn(cardClass, advancedSelection === 'external-dns' && 'umbrel-pulse-a-few-times')}>\n\t\t\t\t\t\t\t\t<CardText title={t('external-dns')} description={t('external-dns-description')} />\n\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\tclassName={cn('pointer-events-auto', isExternalDns.isLoading && 'umbrel-pulse')}\n\t\t\t\t\t\t\t\t\tchecked={isExternalDns.isChecked}\n\t\t\t\t\t\t\t\t\tonCheckedChange={isExternalDns.change}\n\t\t\t\t\t\t\t\t\tdisabled={isExternalDns.isLoading}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t{remoteTorAccessSettingRow}\n\t\t\t\t\t\t\t<label className={cardClass}>\n\t\t\t\t\t\t\t\t<CardText title={t('factory-reset')} description={t('factory-reset-description')} />\n\t\t\t\t\t\t\t\t<IconButtonLink className='pointer-events-auto self-center' to={'/factory-reset'} variant='destructive'>\n\t\t\t\t\t\t\t\t\t{t('reset')}\n\t\t\t\t\t\t\t\t</IconButtonLink>\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</DrawerScroller>\n\t\t\t\t</DrawerContent>\n\t\t\t</Drawer>\n\t\t)\n\t}\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogScrollableContent>\n\t\t\t\t<div className='space-y-6 px-5 py-6'>\n\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t<DialogTitle>{title}</DialogTitle>\n\t\t\t\t\t</DialogHeader>\n\t\t\t\t\t<div className='flex flex-col gap-y-3'>\n\t\t\t\t\t\t<label className={cardClass}>\n\t\t\t\t\t\t\t<CardText title={t('terminal')} description={t('terminal-description')} />\n\t\t\t\t\t\t\t<IconButtonLink className='pointer-events-auto self-center' to={'/settings/terminal'}>\n\t\t\t\t\t\t\t\t{t('open')}\n\t\t\t\t\t\t\t</IconButtonLink>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label className={cn(cardClass, advancedSelection === 'beta-program' && 'umbrel-pulse-a-few-times')}>\n\t\t\t\t\t\t\t<CardText\n\t\t\t\t\t\t\t\ttitle={t('beta-program')}\n\t\t\t\t\t\t\t\tdescription={t('beta-program-description')}\n\t\t\t\t\t\t\t\ttrailingIcon={PiFlaskFill}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\tclassName={cn('pointer-events-auto', isBetaChannel.isLoading && 'umbrel-pulse')}\n\t\t\t\t\t\t\t\tchecked={isBetaChannel.isChecked}\n\t\t\t\t\t\t\t\tonCheckedChange={isBetaChannel.change}\n\t\t\t\t\t\t\t\tdisabled={isBetaChannel.isLoading}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label className={cn(cardClass, advancedSelection === 'external-dns' && 'umbrel-pulse-a-few-times')}>\n\t\t\t\t\t\t\t<CardText title={t('external-dns')} description={t('external-dns-description')} />\n\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\tclassName={cn('pointer-events-auto', isExternalDns.isLoading && 'umbrel-pulse')}\n\t\t\t\t\t\t\t\tchecked={isExternalDns.isChecked}\n\t\t\t\t\t\t\t\tonCheckedChange={isExternalDns.change}\n\t\t\t\t\t\t\t\tdisabled={isExternalDns.isLoading}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t{remoteTorAccessSettingRow}\n\t\t\t\t\t\t<label className={cardClass}>\n\t\t\t\t\t\t\t<CardText title={t('factory-reset')} description={t('factory-reset-description')} />\n\t\t\t\t\t\t\t<IconButtonLink className='pointer-events-auto self-center' to={'/factory-reset'} variant='destructive'>\n\t\t\t\t\t\t\t\t{t('reset')}\n\t\t\t\t\t\t\t</IconButtonLink>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</DialogScrollableContent>\n\t\t</Dialog>\n\t)\n}\n\nfunction useIsBetaChannel() {\n\tconst releaseChannelQ = trpcReact.system.getReleaseChannel.useQuery()\n\tconst isChecked = releaseChannelQ.data === 'beta'\n\tconst {checkLatest} = useSoftwareUpdate()\n\n\tconst releaseChannelMut = trpcReact.system.setReleaseChannel.useMutation({\n\t\tonSuccess: () => {\n\t\t\treleaseChannelQ.refetch()\n\t\t\tcheckLatest()\n\t\t},\n\t})\n\n\tconst change = (checked: boolean) => {\n\t\tif (checked) {\n\t\t\treleaseChannelMut.mutate({channel: 'beta'})\n\t\t} else {\n\t\t\treleaseChannelMut.mutate({channel: 'stable'})\n\t\t}\n\t}\n\n\tconst isLoading = releaseChannelMut.isPending || releaseChannelQ.isLoading\n\n\treturn {isChecked, change, isLoading}\n}\n\nfunction CardText({title, description, trailingIcon}: {title: string; description: string; trailingIcon?: IconTypes}) {\n\treturn (\n\t\t<div className='flex-1 space-y-1'>\n\t\t\t<h3 className='text-14 leading-tight font-medium'>\n\t\t\t\t{title}\n\t\t\t\t{trailingIcon && <Icon component={trailingIcon} className='ml-2 inline-block opacity-50' />}\n\t\t\t</h3>\n\t\t\t<p className='text-13 leading-tight opacity-45'>{description}</p>\n\t\t</div>\n\t)\n}\n\nconst cardClass = tw`flex items-start gap-x-2 rounded-12 bg-white/6 p-4 pointer-events-none`\n"
  },
  {
    "path": "packages/ui/src/routes/settings/app-store-preferences.tsx",
    "content": "import {Dialog, DialogContent, DialogHeader, DialogPortal, DialogTitle} from '@/components/ui/dialog'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nimport {AppStorePreferencesContent} from './_components/app-store-preferences-content'\n\nexport default function AppStorePreferencesDialog() {\n\tconst dialogProps = useDialogOpenProps('app-store-preferences')\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogPortal>\n\t\t\t\t<DialogContent className='p-0'>\n\t\t\t\t\t<div className='umbrel-dialog-fade-scroller space-y-6 overflow-y-auto px-5 py-6'>\n\t\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t\t<DialogTitle>{t('app-store.title')}</DialogTitle>\n\t\t\t\t\t\t</DialogHeader>\n\t\t\t\t\t\t<AppStorePreferencesContent />\n\t\t\t\t\t</div>\n\t\t\t\t</DialogContent>\n\t\t\t</DialogPortal>\n\t\t</Dialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/change-name.tsx",
    "content": "import {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogFooter, DialogHeader, DialogPortal, DialogTitle} from '@/components/ui/dialog'\nimport {AnimatedInputError, Input} from '@/components/ui/input'\nimport {useUserName} from '@/hooks/use-user-name'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {t} from '@/utils/i18n'\n\nexport default function ChangeNameDialog() {\n\tconst title = t('change-name')\n\tconst dialogProps = useSettingsDialogProps()\n\n\tconst {name, setName, handleSubmit, formError, isLoading} = useUserName({\n\t\tonSuccess: () => dialogProps.onOpenChange(false),\n\t})\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogPortal>\n\t\t\t\t<DialogContent asChild>\n\t\t\t\t\t<form onSubmit={handleSubmit}>\n\t\t\t\t\t\t<fieldset disabled={isLoading} className='flex flex-col gap-5'>\n\t\t\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t\t\t<DialogTitle>{title}</DialogTitle>\n\t\t\t\t\t\t\t</DialogHeader>\n\t\t\t\t\t\t\t<Input placeholder={t('change-name.input-placeholder')} value={name} onValueChange={setName} />\n\t\t\t\t\t\t\t<div className='-my-2.5'>\n\t\t\t\t\t\t\t\t<AnimatedInputError>{formError}</AnimatedInputError>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t\t\t<Button type='submit' size='dialog' variant='primary'>\n\t\t\t\t\t\t\t\t\t{t('confirm')}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<Button type='button' size='dialog' onClick={() => dialogProps.onOpenChange(false)}>\n\t\t\t\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</DialogFooter>\n\t\t\t\t\t\t</fieldset>\n\t\t\t\t\t</form>\n\t\t\t\t</DialogContent>\n\t\t\t</DialogPortal>\n\t\t</Dialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/change-password.tsx",
    "content": "import {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogFooter, DialogHeader, DialogPortal, DialogTitle} from '@/components/ui/dialog'\nimport {AnimatedInputError, PasswordInput} from '@/components/ui/input'\nimport {usePassword} from '@/hooks/use-password'\nimport {ChangePasswordWarning, useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {t} from '@/utils/i18n'\n\nexport default function ChangePasswordDialog() {\n\tconst title = t('change-password')\n\n\tconst dialogProps = useSettingsDialogProps()\n\n\tconst {\n\t\tpassword,\n\t\tsetPassword,\n\t\tnewPassword,\n\t\tsetNewPassword,\n\t\tnewPasswordRepeat,\n\t\tsetNewPasswordRepeat,\n\t\thandleSubmit,\n\t\tfieldErrors,\n\t\tformError,\n\t\tisLoading,\n\t} = usePassword({\n\t\tonSuccess: () => dialogProps.onOpenChange(false),\n\t})\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogPortal>\n\t\t\t\t<DialogContent asChild>\n\t\t\t\t\t<form onSubmit={handleSubmit}>\n\t\t\t\t\t\t<fieldset disabled={isLoading} className='flex flex-col gap-5'>\n\t\t\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t\t\t<DialogTitle>{title}</DialogTitle>\n\t\t\t\t\t\t\t</DialogHeader>\n\t\t\t\t\t\t\t<ChangePasswordWarning />\n\t\t\t\t\t\t\t<PasswordInput\n\t\t\t\t\t\t\t\tlabel={t('change-password.current-password')}\n\t\t\t\t\t\t\t\tvalue={password}\n\t\t\t\t\t\t\t\tonValueChange={setPassword}\n\t\t\t\t\t\t\t\terror={fieldErrors.oldPassword}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<PasswordInput\n\t\t\t\t\t\t\t\tlabel={t('change-password.new-password')}\n\t\t\t\t\t\t\t\tvalue={newPassword}\n\t\t\t\t\t\t\t\tonValueChange={setNewPassword}\n\t\t\t\t\t\t\t\terror={fieldErrors.newPassword}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<PasswordInput\n\t\t\t\t\t\t\t\tlabel={t('change-password.repeat-password')}\n\t\t\t\t\t\t\t\tvalue={newPasswordRepeat}\n\t\t\t\t\t\t\t\tonValueChange={setNewPasswordRepeat}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<div className='-my-2.5'>\n\t\t\t\t\t\t\t\t<AnimatedInputError>{formError}</AnimatedInputError>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<DialogFooter>\n\t\t\t\t\t\t\t\t<Button type='submit' size='dialog' variant='primary'>\n\t\t\t\t\t\t\t\t\t{t('confirm')}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t\t<Button type='button' size='dialog' onClick={() => dialogProps.onOpenChange(false)}>\n\t\t\t\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</DialogFooter>\n\t\t\t\t\t\t</fieldset>\n\t\t\t\t\t</form>\n\t\t\t\t</DialogContent>\n\t\t\t</DialogPortal>\n\t\t</Dialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/device-info.tsx",
    "content": "import {Dialog, DialogHeader, DialogScrollableContent, DialogTitle} from '@/components/ui/dialog'\nimport {useDeviceInfo} from '@/hooks/use-device-info'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {t} from '@/utils/i18n'\n\nimport {DeviceInfoContent} from './_components/device-info-content'\n\nexport default function DeviceInfoDialog() {\n\tconst title = t('device-info')\n\tconst dialogProps = useSettingsDialogProps()\n\n\tconst {isLoading, data} = useDeviceInfo()\n\n\t// Don't show dialog because we don't know how big it will be until the content is loaded\n\tif (isLoading) {\n\t\treturn null\n\t}\n\n\tconst isUmbrelPro = data.umbrelHostEnvironment === 'umbrel-pro'\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogScrollableContent showClose={!isUmbrelPro}>\n\t\t\t\t<div className={isUmbrelPro ? 'space-y-6 px-5 pb-6' : 'space-y-6 px-5 py-6'}>\n\t\t\t\t\t{!isUmbrelPro && (\n\t\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t\t<DialogTitle>{title}</DialogTitle>\n\t\t\t\t\t\t</DialogHeader>\n\t\t\t\t\t)}\n\t\t\t\t\t<DeviceInfoContent\n\t\t\t\t\t\tumbrelHostEnvironment={data.umbrelHostEnvironment}\n\t\t\t\t\t\tdevice={data.device}\n\t\t\t\t\t\tmodelNumber={data.modelNumber}\n\t\t\t\t\t\tserialNumber={data.serialNumber}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</DialogScrollableContent>\n\t\t</Dialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/file-sharing.tsx",
    "content": "import {PlusCircle} from 'lucide-react'\nimport {AnimatePresence, motion} from 'motion/react'\nimport {useEffect, useRef, useState} from 'react'\n\nimport {Button} from '@/components/ui/button'\nimport {Dialog, DialogHeader, DialogScrollableContent, DialogTitle} from '@/components/ui/dialog'\nimport {Drawer, DrawerContent, DrawerHeader, DrawerScroller, DrawerTitle} from '@/components/ui/drawer'\nimport {listClass} from '@/components/ui/list'\nimport {Switch} from '@/components/ui/switch'\nimport {HomeIcon} from '@/features/files/assets/home-icon'\nimport {PlatformInstructions} from '@/features/files/components/dialogs/share-info-dialog/platform-instructions'\nimport {\n\tPlatform,\n\tplatforms,\n\tPlatformSelector,\n} from '@/features/files/components/dialogs/share-info-dialog/platform-selector'\nimport {MiniBrowser} from '@/features/files/components/mini-browser'\nimport {FileItemIcon} from '@/features/files/components/shared/file-item-icon'\nimport {FolderIcon} from '@/features/files/components/shared/file-item-icon/folder-icon'\nimport {HOME_PATH} from '@/features/files/constants'\nimport {useHomeDirectoryName} from '@/features/files/hooks/use-home-directory-name'\nimport {useShares} from '@/features/files/hooks/use-shares'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nexport default function FileSharingDrawerOrDialog() {\n\tconst title = t('settings.file-sharing')\n\tconst dialogProps = useSettingsDialogProps()\n\tconst isMobile = useIsMobile()\n\tconst homeDirectoryName = useHomeDirectoryName()\n\n\tconst {\n\t\tshares,\n\t\tisLoadingShares,\n\t\tisHomeShared,\n\t\tsharePassword,\n\t\tisLoadingSharesPassword,\n\t\taddShare,\n\t\tremoveShare,\n\t\tisAddingShare,\n\t\tisRemovingShare,\n\t} = useShares()\n\n\tconst isEnabled = (shares?.length ?? 0) > 0\n\tconst isBusy = isAddingShare || isRemovingShare\n\tconst isLoading = isLoadingShares || isBusy\n\n\tconst [selectedPlatform, setSelectedPlatform] = useState<Platform>(platforms[0])\n\tconst [isAddFolderOpen, setAddFolderOpen] = useState(false)\n\n\t// Stable-ordered list of all folders seen during this dialog session.\n\t// Seeded with initial shares on first load, then updated on toggle-off and add.\n\t// Items stay in their original position even when toggled off.\n\tconst [seenFolders, setSeenFolders] = useState<{name: string; path: string}[]>([])\n\tconst seededRef = useRef(false)\n\n\tconst homeShared = isHomeShared() ?? false\n\n\t// Individual shares (non-Home)\n\tconst activeIndividualShares = shares?.filter((share) => share.path !== HOME_PATH) ?? []\n\tconst activePaths = new Set(activeIndividualShares.map((s) => s.path))\n\n\t// Seed seenFolders with initial active shares once data loads\n\tuseEffect(() => {\n\t\tif (!seededRef.current && !isLoadingShares && activeIndividualShares.length > 0) {\n\t\t\tseededRef.current = true\n\t\t\tsetSeenFolders(activeIndividualShares.map((s) => ({name: s.name, path: s.path})))\n\t\t}\n\t}, [isLoadingShares, activeIndividualShares])\n\n\tconst seenPaths = new Set(seenFolders.map((f) => f.path))\n\n\t// Build a stable-ordered list: seen folders first (preserves order),\n\t// then append any new active shares not yet tracked (e.g. just added via MiniBrowser).\n\tconst individualShares = [\n\t\t...seenFolders.map((f) => ({name: f.name, path: f.path, isShared: activePaths.has(f.path)})),\n\t\t...activeIndividualShares\n\t\t\t.filter((s) => !seenPaths.has(s.path))\n\t\t\t.map((s) => ({name: s.name, path: s.path, isShared: true})),\n\t]\n\n\t// Whether any folders were toggled off during this session\n\tconst hasRecentlyRemoved = seenFolders.some((f) => !activePaths.has(f.path))\n\n\t// Show the first-run choice screen when nothing is shared and nothing was recently removed\n\tconst showChoiceScreen = !isEnabled && !hasRecentlyRemoved && !isLoadingShares\n\n\t// Derive name/sharename for platform instructions from the primary share\n\tconst primaryShare = shares?.find((s) => s.path === HOME_PATH) ?? shares?.[0]\n\tconst primaryName = primaryShare?.name ?? homeDirectoryName\n\tconst primarySharename = primaryShare?.sharename\n\n\t// Home toggle handler\n\tconst handleHomeToggle = async (checked: boolean) => {\n\t\tif (checked) {\n\t\t\tawait addShare({path: HOME_PATH})\n\t\t} else {\n\t\t\tawait removeShare({path: HOME_PATH})\n\t\t}\n\t}\n\n\t// Individual share toggle handler\n\tconst handleShareToggle = async (path: string, name: string, checked: boolean) => {\n\t\tif (checked) {\n\t\t\tawait addShare({path})\n\t\t} else {\n\t\t\t// Track in seenFolders so it stays visible at the same position\n\t\t\tsetSeenFolders((prev) => (prev.some((f) => f.path === path) ? prev : [...prev, {name, path}]))\n\t\t\tawait removeShare({path})\n\t\t}\n\t}\n\n\tconst smbUrl =\n\t\tselectedPlatform.id === 'windows' ? `\\\\\\\\${window.location.hostname}` : `smb://${window.location.hostname}/`\n\tconst username = 'umbrel'\n\tconst password = isLoadingSharesPassword ? '...' : sharePassword || ''\n\n\t// --- First-run choice screen ---\n\tconst choiceScreen = (\n\t\t<div className='flex flex-col gap-y-4'>\n\t\t\t<p className='px-1 text-13 -tracking-2 text-white/50'>{t('settings.file-sharing.choice-subtitle')}</p>\n\t\t\t<h4 className='px-1 text-14 font-semibold -tracking-2'>{t('settings.file-sharing.choice-heading')}</h4>\n\t\t\t<div className='grid grid-cols-2 gap-3'>\n\t\t\t\t<button\n\t\t\t\t\tclassName='flex flex-col items-center gap-3 rounded-12 bg-white/6 px-4 py-6 text-center transition-colors hover:bg-white/10 active:bg-white/8'\n\t\t\t\t\tonClick={() => addShare({path: HOME_PATH})}\n\t\t\t\t\tdisabled={isBusy}\n\t\t\t\t>\n\t\t\t\t\t<HomeIcon className='h-10 w-10' />\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<h4 className='text-13 leading-tight font-semibold md:text-14'>\n\t\t\t\t\t\t\t{t('settings.file-sharing.choice-entire-title')}\n\t\t\t\t\t\t</h4>\n\t\t\t\t\t\t<p className='mt-1 text-12 leading-tight text-white/40'>\n\t\t\t\t\t\t\t{t('settings.file-sharing.choice-entire-description')}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t</button>\n\t\t\t\t<button\n\t\t\t\t\tclassName='flex flex-col items-center gap-3 rounded-12 bg-white/6 px-4 py-6 text-center transition-colors hover:bg-white/10 active:bg-white/8'\n\t\t\t\t\tonClick={() => setAddFolderOpen(true)}\n\t\t\t\t\tdisabled={isBusy}\n\t\t\t\t>\n\t\t\t\t\t<FolderIcon className='h-10 w-10' />\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<h4 className='text-13 leading-tight font-semibold md:text-14'>\n\t\t\t\t\t\t\t{t('settings.file-sharing.choice-specific-title')}\n\t\t\t\t\t\t</h4>\n\t\t\t\t\t\t<p className='mt-1 text-12 leading-tight text-white/40'>\n\t\t\t\t\t\t\t{t('settings.file-sharing.choice-specific-description')}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t</button>\n\t\t\t</div>\n\n\t\t\t{/* MiniBrowser (shared with the active state below) */}\n\t\t\t<MiniBrowser\n\t\t\t\topen={isAddFolderOpen}\n\t\t\t\tonOpenChange={setAddFolderOpen}\n\t\t\t\trootPath={HOME_PATH}\n\t\t\t\tonOpenPath={HOME_PATH}\n\t\t\t\tpreselectOnOpen={false}\n\t\t\t\ttitle={t('settings.file-sharing.add-folder-title')}\n\t\t\t\tselectionMode='folders'\n\t\t\t\tdisabledPaths={shares?.map((s) => s.path)}\n\t\t\t\tonSelect={(path) => {\n\t\t\t\t\taddShare({path})\n\t\t\t\t\tsetAddFolderOpen(false)\n\t\t\t\t}}\n\t\t\t/>\n\t\t</div>\n\t)\n\n\t// --- Active sharing screen ---\n\tconst activeScreen = (\n\t\t<div className='flex flex-col gap-y-6'>\n\t\t\t{/* Share entire Umbrel */}\n\t\t\t<div className={listClass}>\n\t\t\t\t<label className={listItemClass}>\n\t\t\t\t\t<FileItemIcon\n\t\t\t\t\t\titem={{name: 'Home', path: HOME_PATH, type: 'directory', modified: 0, size: 0, operations: []}}\n\t\t\t\t\t\tclassName='h-8 w-8 shrink-0'\n\t\t\t\t\t/>\n\t\t\t\t\t<div className='min-w-0 flex-1'>\n\t\t\t\t\t\t<h4 className='text-14 leading-tight font-medium'>{t('settings.file-sharing.share-entire-home-dir')}</h4>\n\t\t\t\t\t\t<p className='mt-[1px] text-12 leading-tight text-white/40'>\n\t\t\t\t\t\t\t{t('settings.file-sharing.share-entire-home-dir-description', {homeDirectoryName})}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t</div>\n\t\t\t\t\t<Switch\n\t\t\t\t\t\tchecked={homeShared}\n\t\t\t\t\t\tonCheckedChange={handleHomeToggle}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\tclassName={isBusy ? 'umbrel-pulse' : undefined}\n\t\t\t\t\t/>\n\t\t\t\t</label>\n\t\t\t</div>\n\n\t\t\t{/* Individual shared folders — hidden when home is shared and no individual shares */}\n\t\t\t{!(homeShared && activeIndividualShares.length === 0 && !hasRecentlyRemoved) && (\n\t\t\t\t<div className='flex flex-col gap-y-2'>\n\t\t\t\t\t<div className='flex items-center justify-between px-1'>\n\t\t\t\t\t\t<h4 className='text-13 font-medium -tracking-2 text-white/60'>\n\t\t\t\t\t\t\t{t('settings.file-sharing.shared-folders')}\n\t\t\t\t\t\t</h4>\n\t\t\t\t\t\t<Button size='sm' onClick={() => setAddFolderOpen(true)}>\n\t\t\t\t\t\t\t{t('settings.file-sharing.add-folder')}\n\t\t\t\t\t\t\t<PlusCircle className='h-3 w-3' />\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</div>\n\t\t\t\t\t{homeShared && (\n\t\t\t\t\t\t<p className='px-1 text-11 leading-tight -tracking-2 text-white/30'>\n\t\t\t\t\t\t\t{t('settings.file-sharing.home-shared-note', {homeDirectoryName})}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t)}\n\t\t\t\t\t{individualShares.length > 0 && (\n\t\t\t\t\t\t<div className={listClass}>\n\t\t\t\t\t\t\t<AnimatePresence initial={false}>\n\t\t\t\t\t\t\t\t{individualShares.map((share) => (\n\t\t\t\t\t\t\t\t\t<motion.label\n\t\t\t\t\t\t\t\t\t\tkey={share.path}\n\t\t\t\t\t\t\t\t\t\tclassName={listItemClass}\n\t\t\t\t\t\t\t\t\t\tinitial={{opacity: 0, height: 0}}\n\t\t\t\t\t\t\t\t\t\tanimate={{opacity: 1, height: 'auto'}}\n\t\t\t\t\t\t\t\t\t\texit={{opacity: 0, height: 0}}\n\t\t\t\t\t\t\t\t\t\ttransition={{duration: 0.2}}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<FileItemIcon\n\t\t\t\t\t\t\t\t\t\t\titem={{\n\t\t\t\t\t\t\t\t\t\t\t\tname: share.name,\n\t\t\t\t\t\t\t\t\t\t\t\tpath: share.path,\n\t\t\t\t\t\t\t\t\t\t\t\ttype: 'directory',\n\t\t\t\t\t\t\t\t\t\t\t\tmodified: 0,\n\t\t\t\t\t\t\t\t\t\t\t\tsize: 0,\n\t\t\t\t\t\t\t\t\t\t\t\toperations: [],\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\tclassName='h-7 w-7 shrink-0'\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t<span className='min-w-0 flex-1 truncate text-14 font-medium'>{share.name}</span>\n\t\t\t\t\t\t\t\t\t\t<Switch\n\t\t\t\t\t\t\t\t\t\t\tchecked={share.isShared}\n\t\t\t\t\t\t\t\t\t\t\tonCheckedChange={(checked) => handleShareToggle(share.path, share.name, checked)}\n\t\t\t\t\t\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t</motion.label>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</AnimatePresence>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t{/* MiniBrowser for adding shared folders */}\n\t\t\t<MiniBrowser\n\t\t\t\topen={isAddFolderOpen}\n\t\t\t\tonOpenChange={setAddFolderOpen}\n\t\t\t\trootPath={HOME_PATH}\n\t\t\t\tonOpenPath={HOME_PATH}\n\t\t\t\tpreselectOnOpen={false}\n\t\t\t\ttitle={t('settings.file-sharing.add-folder-title')}\n\t\t\t\tselectionMode='folders'\n\t\t\t\tdisabledPaths={shares?.map((s) => s.path)}\n\t\t\t\tonSelect={(path) => {\n\t\t\t\t\taddShare({path})\n\t\t\t\t\tsetAddFolderOpen(false)\n\t\t\t\t}}\n\t\t\t/>\n\n\t\t\t{/* Connection instructions */}\n\t\t\t<AnimatePresence>\n\t\t\t\t{isEnabled && (\n\t\t\t\t\t<motion.div\n\t\t\t\t\t\tinitial={{height: 0, opacity: 0}}\n\t\t\t\t\t\tanimate={{height: 'auto', opacity: 1}}\n\t\t\t\t\t\texit={{height: 0, opacity: 0}}\n\t\t\t\t\t\ttransition={{duration: 0.3}}\n\t\t\t\t\t\tclassName='overflow-hidden'\n\t\t\t\t\t>\n\t\t\t\t\t\t{/* Gradient divider */}\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName='my-2 h-[1px] w-full'\n\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\tbackground:\n\t\t\t\t\t\t\t\t\t'radial-gradient(50% 50% at 50% 50%, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 100%)',\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<div className='flex flex-col gap-4 pt-2'>\n\t\t\t\t\t\t\t<PlatformSelector selectedPlatform={selectedPlatform} onPlatformChange={setSelectedPlatform} />\n\t\t\t\t\t\t\t<PlatformInstructions\n\t\t\t\t\t\t\t\tplatform={selectedPlatform}\n\t\t\t\t\t\t\t\tsmbUrl={smbUrl}\n\t\t\t\t\t\t\t\tusername={username}\n\t\t\t\t\t\t\t\tpassword={password}\n\t\t\t\t\t\t\t\tname={primaryName}\n\t\t\t\t\t\t\t\tsharename={primarySharename}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</motion.div>\n\t\t\t\t)}\n\t\t\t</AnimatePresence>\n\t\t</div>\n\t)\n\n\tconst content = showChoiceScreen ? choiceScreen : activeScreen\n\n\tif (isMobile) {\n\t\treturn (\n\t\t\t<Drawer {...dialogProps}>\n\t\t\t\t<DrawerContent fullHeight>\n\t\t\t\t\t<DrawerHeader>\n\t\t\t\t\t\t<DrawerTitle>{title}</DrawerTitle>\n\t\t\t\t\t</DrawerHeader>\n\t\t\t\t\t<DrawerScroller>{content}</DrawerScroller>\n\t\t\t\t</DrawerContent>\n\t\t\t</Drawer>\n\t\t)\n\t}\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogScrollableContent>\n\t\t\t\t<div className='space-y-3 px-5 py-6'>\n\t\t\t\t\t<DialogHeader>\n\t\t\t\t\t\t<DialogTitle>{title}</DialogTitle>\n\t\t\t\t\t</DialogHeader>\n\t\t\t\t\t{content}\n\t\t\t\t</div>\n\t\t\t</DialogScrollableContent>\n\t\t</Dialog>\n\t)\n}\n\nconst listItemClass = tw`flex items-center gap-3 px-3 py-2.5 text-14 font-medium -tracking-3`\n"
  },
  {
    "path": "packages/ui/src/routes/settings/index.tsx",
    "content": "import React, {Suspense, useState} from 'react'\nimport {ErrorBoundary} from 'react-error-boundary'\nimport {Route, Routes, useLocation} from 'react-router-dom'\nimport {keys} from 'remeda'\nimport {arrayIncludes} from 'ts-extras'\n\nimport {Button} from '@/components/ui/button'\nimport {CoverMessage, CoverMessageParagraph} from '@/components/ui/cover-message'\nimport {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback'\nimport {Loading} from '@/components/ui/loading'\nimport {SheetHeader, SheetTitle} from '@/components/ui/sheet'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {TwoFactorDialog} from '@/routes/settings/2fa'\nimport AdvancedSettingsDrawerOrDialog from '@/routes/settings/advanced'\nimport {SoftwareUpdateConfirmDialog} from '@/routes/settings/software-update-confirm'\nimport {t} from '@/utils/i18n'\nimport {IS_ANDROID} from '@/utils/misc'\n\n// Routes that should bypass the Sheet and render fullscreen with their own backdrop\n// Add paths here to have them render outside the Settings sheet\nexport const SETTINGS_FULLSCREEN_PATHS = ['/settings/storage'] as const\n// export const SETTINGS_FULLSCREEN_PATHS = [] as const\n\nexport function isFullscreenSettingsPath(pathname: string) {\n\treturn SETTINGS_FULLSCREEN_PATHS.some((path) => pathname.includes(path))\n}\n\n// import {SettingsContent} from './_components/settings-content'\nconst SettingsContent = React.lazy(() =>\n\timport('./_components/settings-content').then((m) => ({default: m.SettingsContent})),\n)\nconst SettingsContentMobile = React.lazy(() =>\n\timport('./_components/settings-content-mobile').then((m) => ({default: m.SettingsContentMobile})),\n)\n\nconst FileSharingDrawerOrDialog = React.lazy(() => import('@/routes/settings/file-sharing'))\nconst AppStorePreferencesDialog = React.lazy(() => import('@/routes/settings/app-store-preferences'))\nconst ChangeNameDialog = React.lazy(() => import('@/routes/settings/change-name'))\nconst ChangePasswordDialog = React.lazy(() => import('@/routes/settings/change-password'))\nconst RestartDialog = React.lazy(() => import('@/routes/settings/restart'))\nconst ShutdownDialog = React.lazy(() => import('@/routes/settings/shutdown'))\nconst TroubleshootDialog = React.lazy(() => import('@/routes/settings/troubleshoot/index'))\nconst TerminalDialog = React.lazy(() => import('@/routes/settings/terminal/index'))\nconst DeviceInfoDialog = React.lazy(() => import('@/routes/settings/device-info'))\nconst BackupsRestoreDialog = React.lazy(() => import('@/features/backups/index'))\n\n// drawers\nconst StartMigrationDrawerOrDialog = React.lazy(() =>\n\timport('@/routes/settings/mobile/start-migration-drawer-or-dialog').then((m) => ({\n\t\tdefault: m.StartMigrationDrawerOrDialog,\n\t})),\n)\nconst Wifi = React.lazy(() => import('@/routes/settings/wifi'))\nconst WifiUnsupported = React.lazy(() => import('@/routes/settings/wifi-unsupported'))\nconst AccountDrawer = React.lazy(() =>\n\timport('@/routes/settings/mobile/account').then((m) => ({default: m.AccountDrawer})),\n)\nconst WallpaperDrawer = React.lazy(() =>\n\timport('@/routes/settings/mobile/wallpaper').then((m) => ({default: m.WallpaperDrawer})),\n)\nconst LanguageDrawer = React.lazy(() =>\n\timport('@/routes/settings/mobile/language').then((m) => ({default: m.LanguageDrawer})),\n)\nconst AppStorePreferencesDrawer = React.lazy(() =>\n\timport('@/routes/settings/mobile/app-store-preferences').then((m) => ({\n\t\tdefault: m.AppStorePreferencesDrawer,\n\t})),\n)\nconst DeviceInfoDrawer = React.lazy(() =>\n\timport('@/routes/settings/mobile/device-info').then((m) => ({default: m.DeviceInfoDrawer})),\n)\nconst BackupsMobileDrawer = React.lazy(() =>\n\timport('@/routes/settings/mobile/backups-mobile-drawer').then((m) => ({default: m.BackupsMobileDrawer})),\n)\nconst SoftwareUpdateDrawer = React.lazy(() =>\n\timport('@/routes/settings/mobile/software-update').then((m) => ({default: m.SoftwareUpdateDrawer})),\n)\nconst StorageManagerDialog = React.lazy(() => import('@/features/storage/index'))\n\nconst routeToDialogDesktop = {\n\t'app-store-preferences': AppStorePreferencesDialog,\n\trestart: RestartDialog,\n\tshutdown: ShutdownDialog,\n\t// Allow drawers in desktop in case someone opens a link to a drawer\n} as const satisfies Record<string, React.ComponentType>\n\nconst dialogKeys = keys.strict(routeToDialogDesktop)\n\nexport type SettingsDialogKey = keyof typeof routeToDialogDesktop\n\nconst routeToDialogMobile: Record<string, React.ComponentType> = {\n\t'app-store-preferences': AppStorePreferencesDrawer,\n\trestart: RestartDialog,\n\tshutdown: ShutdownDialog,\n} as const satisfies Record<SettingsDialogKey, React.ComponentType>\n\nfunction QueryStringDialog() {\n\tconst isMobile = useIsMobile() && !IS_ANDROID\n\tconst routeToDialog = isMobile ? routeToDialogMobile : routeToDialogDesktop\n\n\tconst {params} = useQueryParams()\n\tconst dialog = params.get('dialog')\n\n\t// Prevent breaking if there's a dialog that is rendered somewhere else and not in this map (\"logout\", for example)\n\tconst has = dialog && arrayIncludes(dialogKeys, dialog)\n\tconst Component = has && dialog ? routeToDialog[dialog] : () => null\n\n\treturn <Component />\n}\n\nexport function Settings() {\n\tconst title = t('settings')\n\tconst location = useLocation()\n\tconst isMobile = useIsMobile() && !IS_ANDROID\n\n\t// When on a fullscreen route, Sheet is bypassed so we can't use Sheet components\n\tif (isFullscreenSettingsPath(location.pathname)) {\n\t\treturn (\n\t\t\t<Suspense fallback={<div className='fixed inset-0 z-50 bg-black/30 backdrop-blur-xl' />}>\n\t\t\t\t<Routes>\n\t\t\t\t\t<Route path='/storage/*' Component={StorageManagerDialog} />\n\t\t\t\t</Routes>\n\t\t\t</Suspense>\n\t\t)\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t<SheetHeader className='px-2.5'>\n\t\t\t\t<SheetTitle className='leading-none'>{title}</SheetTitle>\n\t\t\t</SheetHeader>\n\t\t\t<ErrorBoundary FallbackComponent={ErrorBoundaryCardFallback}>\n\t\t\t\t{isMobile && <SettingsContentMobile />}\n\t\t\t\t{!isMobile && <SettingsContent />}\n\t\t\t\t<Suspense>\n\t\t\t\t\t<Routes>\n\t\t\t\t\t\t<Route path='/2fa' Component={TwoFactorDialog} />\n\t\t\t\t\t\t<Route path='/device-info' Component={isMobile ? DeviceInfoDrawer : DeviceInfoDialog} />\n\t\t\t\t\t\t{!isMobile && <Route path='/account/change-name' Component={ChangeNameDialog} />}\n\t\t\t\t\t\t{!isMobile && <Route path='/account/change-password' Component={ChangePasswordDialog} />}\n\t\t\t\t\t\t{/* Fall-through `/account` to here. If going to account, always show drawer, even if on desktop */}\n\t\t\t\t\t\t{<Route path='/account/:accountTab' Component={AccountDrawer} />}\n\t\t\t\t\t\t{isMobile && <Route path='/wallpaper' Component={WallpaperDrawer} />}\n\t\t\t\t\t\t<Route path='/wifi' Component={Wifi} />\n\t\t\t\t\t\t<Route path='/wifi-unsupported' Component={WifiUnsupported} />\n\t\t\t\t\t\t{/* Backup: mobile drawer (/backups) opens first on mobile to give same options as desktop */}\n\t\t\t\t\t\t{isMobile && <Route path='/backups' Component={BackupsMobileDrawer} />}\n\t\t\t\t\t\t<Route path='/backups/*' Component={BackupsRestoreDialog} />\n\t\t\t\t\t\t{/* Not choosing based on `isMobile` because we don't want the dialog state to get reset if you resize the browser window. But also we want the same `/settings/migration-assistant` path for the first dialog/drawer you see */}\n\t\t\t\t\t\t<Route path='/migration-assistant' Component={StartMigrationDrawerOrDialog} />\n\t\t\t\t\t\t{isMobile && <Route path='/language' Component={LanguageDrawer} />}\n\t\t\t\t\t\t<Route path='/troubleshoot/*' Component={TroubleshootDialog} />\n\t\t\t\t\t\t<Route path='/terminal/*' Component={TerminalDialog} />\n\t\t\t\t\t\t{isMobile && <Route path='/software-update' Component={SoftwareUpdateDrawer} />}\n\t\t\t\t\t\t<Route path='/software-update/confirm' Component={SoftwareUpdateConfirmDialog} />\n\t\t\t\t\t\t<Route path='/file-sharing' Component={FileSharingDrawerOrDialog} />\n\t\t\t\t\t\t<Route path='/advanced/:advancedSelection?' Component={AdvancedSettingsDrawerOrDialog} />\n\t\t\t\t\t\t<Route path='/storage/*' Component={StorageManagerDialog} />\n\t\t\t\t\t</Routes>\n\t\t\t\t\t<QueryStringDialog />\n\t\t\t\t</Suspense>\n\t\t\t</ErrorBoundary>\n\t\t</>\n\t)\n}\n\nexport function CoverTest() {\n\tconst [showCover, setShowCover] = useState(false)\n\n\treturn (\n\t\t<>\n\t\t\t<Button onClick={() => setShowCover(true)}>Show cover</Button>\n\t\t\t{showCover && (\n\t\t\t\t<CoverMessage onClick={() => setShowCover(false)}>\n\t\t\t\t\t<Loading>{t('shut-down.shutting-down')}</Loading>\n\t\t\t\t\t<CoverMessageParagraph>{t('shut-down.shutting-down-message')}</CoverMessageParagraph>\n\t\t\t\t</CoverMessage>\n\t\t\t)}\n\t\t</>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/migration-assistant.tsx",
    "content": "import {useEffect, useState} from 'react'\nimport {RiAlertFill} from 'react-icons/ri'\nimport {TbAlertTriangleFilled, TbArrowBadgeRight, TbLock, TbPower, TbUsb} from 'react-icons/tb'\n\nimport {ErrorAlert} from '@/components/ui/alert'\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {Button} from '@/components/ui/button'\nimport {\n\tImmersiveDialog,\n\tImmersiveDialogBody,\n\tImmersiveDialogIconMessage,\n\tImmersiveDialogSplitContent,\n} from '@/components/ui/immersive-dialog'\nimport {Loading} from '@/components/ui/loading'\nimport {useIsHomeOrPro} from '@/hooks/use-is-home-or-pro'\nimport {MigrateImage} from '@/modules/migrate/migrate-image'\nimport {useGlobalSystemState} from '@/providers/global-system-state/index'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nconst title = t('migration-assistant')\n\nexport default function MigrationAssistantDialog() {\n\tconst dialogProps = useSettingsDialogProps()\n\tconst {isHomeOrPro, isLoading, deviceName} = useIsHomeOrPro()\n\n\t// Don't show anything while loading\n\tif (isLoading) return null\n\n\tif (!isHomeOrPro) {\n\t\treturn (\n\t\t\t<AlertDialog {...dialogProps}>\n\t\t\t\t<AlertDialogContent>\n\t\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t\t<AlertDialogTitle>{t('migration-assistant')}</AlertDialogTitle>\n\t\t\t\t\t</AlertDialogHeader>\n\t\t\t\t\t<div className='mt-2 flex justify-center'>\n\t\t\t\t\t\t<MigrateImage />\n\t\t\t\t\t</div>\n\t\t\t\t\t<AlertDialogDescription className='text-center'>\n\t\t\t\t\t\t{t('migration-assistant-unsupported-device-description')}\n\t\t\t\t\t</AlertDialogDescription>\n\t\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t\t<AlertDialogAction onClick={() => dialogProps.onOpenChange(false)}>{t('ok')}</AlertDialogAction>\n\t\t\t\t\t</AlertDialogFooter>\n\t\t\t\t</AlertDialogContent>\n\t\t\t</AlertDialog>\n\t\t)\n\t}\n\n\treturn (\n\t\t<ImmersiveDialog {...dialogProps}>\n\t\t\t<ImmersiveDialogSplitContent side={<MigrateImage />}>\n\t\t\t\t<MigrateContent deviceName={deviceName} />\n\t\t\t</ImmersiveDialogSplitContent>\n\t\t</ImmersiveDialog>\n\t)\n}\n\ntype MigrationState = 'prep' | 'check' | 'error' | 'ready'\n\nfunction MigrateContent({deviceName}: {deviceName: string}) {\n\tconst {migrate} = useGlobalSystemState()\n\n\tconst [state, setState] = useState<MigrationState>('prep')\n\n\tconst canMigrateQ = trpcReact.migration.canMigrate.useQuery(undefined, {\n\t\trefetchOnWindowFocus: false,\n\t\tenabled: state === 'check',\n\t})\n\n\t// Handle state update based on query result\n\tuseEffect(() => {\n\t\tif (state !== 'check') return // Only run when checking\n\n\t\tif (canMigrateQ.isSuccess) {\n\t\t\tif (canMigrateQ.data) {\n\t\t\t\tsetState('ready')\n\t\t\t} else {\n\t\t\t\tsetState('error')\n\t\t\t}\n\t\t} else if (canMigrateQ.isError) {\n\t\t\tsetState('error')\n\t\t}\n\t}, [canMigrateQ.isSuccess, canMigrateQ.isError, canMigrateQ.data, state])\n\n\tconst retry = () => {\n\t\tsetState('check')\n\t\tcanMigrateQ.refetch()\n\t}\n\tconst {isFetching, error} = canMigrateQ\n\n\t// return (\n\t// \t<div>\n\t// \t\t<Button disabled={canMigrateQ.isFetching} onClick={retry}>\n\t// \t\t\t{isFetching ? <Spinner /> : null}\n\t// \t\t\t{error ? 'Try again' : 'Continue'}\n\t// \t\t</Button>\n\t// \t\t<JSONTree\n\t// \t\t\tdata={{\n\t// \t\t\t\tstate,\n\t// \t\t\t\tcanMigrateQ,\n\t// \t\t\t\tisLoading: canMigrateQ.isLoading,\n\t// \t\t\t\tisFetching: canMigrateQ.isFetching,\n\t// \t\t\t\tisRefetching: canMigrateQ.isRefetching,\n\t// \t\t\t\terror: canMigrateQ.error?.message,\n\t// \t\t\t}}\n\t// \t\t/>\n\t// \t\t{canMigrateQ.error && <WarningMessage title={canMigrateQ.error.message} />}\n\t// \t</div>\n\t// )\n\n\tswitch (state) {\n\t\tcase 'prep':\n\t\tcase 'check':\n\t\t\treturn <MigrationAssistantPrep isLoading={isFetching} onNext={() => setState('check')} deviceName={deviceName} />\n\t\tcase 'error':\n\t\t\treturn (\n\t\t\t\t<MigrationAssistantError\n\t\t\t\t\tisLoading={isFetching}\n\t\t\t\t\terrors={error ? [error.message] : []}\n\t\t\t\t\tonCheckAgain={retry}\n\t\t\t\t\tonNext={() => setState('ready')}\n\t\t\t\t\tdeviceName={deviceName}\n\t\t\t\t/>\n\t\t\t)\n\t\tcase 'ready':\n\t\t\treturn <MigrationAssistantReady onNext={migrate} deviceName={deviceName} />\n\t}\n}\n\n// ----\n\nfunction MigrationAssistantPrep({\n\tisLoading,\n\tonNext,\n\tdeviceName,\n}: {\n\tisLoading: boolean\n\tonNext: () => void\n\tdeviceName: string\n}) {\n\tconst buttonContinueText = t('migration-assistant.prep.button-continue')\n\n\treturn (\n\t\t<ImmersiveDialogBody\n\t\t\ttitle={title}\n\t\t\tdescription={t('migration-assistant-description', {deviceName})}\n\t\t\tbodyText={t('migration-assistant.prep.body')}\n\t\t\tfooter={\n\t\t\t\t<>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\tclassName='w-full shrink-0 md:w-auto'\n\t\t\t\t\t\tonClick={() => onNext()}\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t>\n\t\t\t\t\t\t{isLoading ? <Loading /> : buttonContinueText}\n\t\t\t\t\t</Button>\n\t\t\t\t\t{/* TODO: consider not extending this component and instead hardcode the alert here */}\n\t\t\t\t</>\n\t\t\t}\n\t\t>\n\t\t\t<ImmersiveDialogIconMessage icon={TbPower} title={t('migration-assistant.prep.shut-down-rpi')} />\n\t\t\t<ImmersiveDialogIconMessage\n\t\t\t\ticon={TbUsb}\n\t\t\t\ttitle={t('migration-assistant.prep.connect-disk-to-home', {deviceName})}\n\t\t\t/>\n\t\t\t<ImmersiveDialogIconMessage\n\t\t\t\ticon={TbArrowBadgeRight}\n\t\t\t\ttitle={t('migration-assistant.prep.prep-done-continue-message', {\n\t\t\t\t\tbutton: buttonContinueText,\n\t\t\t\t})}\n\t\t\t/>\n\t\t</ImmersiveDialogBody>\n\t)\n}\n\n// ----\n\nexport function MigrationAssistantError({\n\tisLoading,\n\terrors,\n\tonCheckAgain,\n\tonNext,\n\tdeviceName,\n}: {\n\tisLoading: boolean\n\terrors: string[]\n\tonCheckAgain: () => void\n\tonNext: () => void\n\tdeviceName: string\n}) {\n\tconst hasErrors = errors && errors.length > 0\n\n\treturn (\n\t\t<ImmersiveDialogBody\n\t\t\ttitle={title}\n\t\t\tdescription={t('migration-assistant-description', {deviceName})}\n\t\t\tbodyText={\n\t\t\t\t<>\n\t\t\t\t\t{hasErrors && t('migration-assistant.failed')}\n\t\t\t\t\t{isLoading && <Loading>{t('migration-assistant.failed.retrying-message')}</Loading>}\n\t\t\t\t</>\n\t\t\t}\n\t\t\tfooter={\n\t\t\t\t<>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\tclassName='w-full md:w-auto'\n\t\t\t\t\t\tdisabled={isLoading}\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tif (errors && errors.length > 0) {\n\t\t\t\t\t\t\t\tonCheckAgain()\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tonNext()\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('try-again')}\n\t\t\t\t\t\t{/* {isLoading ? <Loading /> : t('try-again')} */}\n\t\t\t\t\t</Button>\n\t\t\t\t</>\n\t\t\t}\n\t\t>\n\t\t\t{!errors || (errors.length === 0 && <WarningMessage title={t('unknown-error')} />)}\n\t\t\t{errors.map((error) => (\n\t\t\t\t<WarningMessage key={error} title={error} />\n\t\t\t))}\n\t\t</ImmersiveDialogBody>\n\t)\n}\n\nfunction WarningMessage({title, description}: {title: string; description?: string}) {\n\treturn (\n\t\t<ImmersiveDialogIconMessage\n\t\t\ticon={TbAlertTriangleFilled}\n\t\t\ticonClassName='text-[#FFC107] [&>*]:stroke-none'\n\t\t\ttitle={title}\n\t\t\tdescription={description}\n\t\t/>\n\t)\n}\n\n// ----\n\nexport function MigrationAssistantReady({onNext, deviceName}: {onNext: () => void; deviceName: string}) {\n\treturn (\n\t\t<ImmersiveDialogBody\n\t\t\ttitle={t('migration-assistant.ready.title')}\n\t\t\tdescription={t('migration-assistant.ready.description', {deviceName})}\n\t\t\tbodyText={t('migration-assistant.ready.hint-header')}\n\t\t\tfooter={\n\t\t\t\t<>\n\t\t\t\t\t<Button variant='primary' size='dialog' className='w-full md:w-auto' onClick={() => onNext()}>\n\t\t\t\t\t\t{t('migration-assistant.continue-migration.ready.submit')}\n\t\t\t\t\t</Button>\n\t\t\t\t\t<ErrorAlert\n\t\t\t\t\t\t// -mr-2 to adjust the width so the alert doesn't wrap\n\t\t\t\t\t\tclassName='-mr-2'\n\t\t\t\t\t\tdescription={\n\t\t\t\t\t\t\t<div className='-my-1 flex items-baseline items-center gap-1'>\n\t\t\t\t\t\t\t\t<RiAlertFill className='h-3 w-3 shrink-0 translate-y-[1.5px]' />\n\t\t\t\t\t\t\t\t{t('migration-assistant.prep.callout', {deviceName})}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t}\n\t\t\t\t\t/>\n\t\t\t\t</>\n\t\t\t}\n\t\t>\n\t\t\t<ImmersiveDialogIconMessage\n\t\t\t\ticon={TbLock}\n\t\t\t\ttitle={t('migration-assistant.ready.hint-use-same-password.title')}\n\t\t\t\tdescription={t('migration-assistant.ready.hint-use-same-password.description', {deviceName})}\n\t\t\t/>\n\t\t\t<ImmersiveDialogIconMessage\n\t\t\t\ticon={TbPower}\n\t\t\t\ttitle={t('migration-assistant.ready.hint-keep-pi-off.title')}\n\t\t\t\tdescription={t('migration-assistant.ready.hint-keep-pi-off.description')}\n\t\t\t/>\n\t\t</ImmersiveDialogBody>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/mobile/account.tsx",
    "content": "import {useState} from 'react'\nimport {useParams} from 'react-router-dom'\n\nimport {Button} from '@/components/ui/button'\nimport {\n\tDrawer,\n\tDrawerContent,\n\tDrawerDescription,\n\tDrawerFooter,\n\tDrawerHeader,\n\tDrawerScroller,\n\tDrawerTitle,\n} from '@/components/ui/drawer'\nimport {AnimatedInputError, Input, Labeled, PasswordInput} from '@/components/ui/input'\nimport {SegmentedControl} from '@/components/ui/segmented-control'\nimport {usePassword} from '@/hooks/use-password'\nimport {useUserName} from '@/hooks/use-user-name'\nimport {ChangePasswordWarning, useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {t} from '@/utils/i18n'\n\nexport function AccountDrawer() {\n\tconst title = t('account')\n\n\tconst dialogProps = useSettingsDialogProps()\n\tconst closeDialog = () => dialogProps.onOpenChange(false)\n\n\tconst tabs = [\n\t\t{id: 'change-name', label: t('name')},\n\t\t{id: 'change-password', label: t('password')},\n\t] as const\n\ttype TabId = (typeof tabs)[number]['id']\n\n\tconst {accountTab} = useParams<{accountTab: TabId}>()\n\tconst [activeTab, setActiveTab] = useState(accountTab ?? tabs[0].id)\n\n\treturn (\n\t\t<Drawer {...dialogProps}>\n\t\t\t<DrawerContent fullHeight>\n\t\t\t\t<DrawerHeader>\n\t\t\t\t\t<DrawerTitle>{title}</DrawerTitle>\n\t\t\t\t\t<DrawerDescription>{t('account-description')}</DrawerDescription>\n\t\t\t\t</DrawerHeader>\n\t\t\t\t<DrawerScroller>\n\t\t\t\t\t<SegmentedControl size='lg' tabs={tabs} value={activeTab} onValueChange={setActiveTab} />\n\t\t\t\t\t{activeTab === 'change-name' && <ChangeName closeDialog={closeDialog} />}\n\t\t\t\t\t{activeTab === 'change-password' && <ChangePassword closeDialog={closeDialog} />}\n\t\t\t\t</DrawerScroller>\n\t\t\t</DrawerContent>\n\t\t</Drawer>\n\t)\n}\n\nfunction ChangeName({closeDialog}: {closeDialog: () => void}) {\n\tconst {name, setName, handleSubmit, formError, isLoading} = useUserName({onSuccess: closeDialog})\n\n\treturn (\n\t\t<form onSubmit={handleSubmit} className='flex flex-1 flex-col'>\n\t\t\t<fieldset disabled={isLoading} className='flex flex-1 flex-col gap-5'>\n\t\t\t\t<Labeled label={t('change-name.input-placeholder')}>\n\t\t\t\t\t<Input value={name} onValueChange={setName} />\n\t\t\t\t</Labeled>\n\t\t\t\t<div className='-my-2.5'>\n\t\t\t\t\t<AnimatedInputError>{formError}</AnimatedInputError>\n\t\t\t\t</div>\n\t\t\t\t<div className='flex-1' />\n\t\t\t\t<DrawerFooter>\n\t\t\t\t\t<Button type='button' size='dialog' onClick={closeDialog}>\n\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button type='submit' size='dialog' variant='primary'>\n\t\t\t\t\t\t{t('confirm')}\n\t\t\t\t\t</Button>\n\t\t\t\t</DrawerFooter>\n\t\t\t\t<div className='' />\n\t\t\t</fieldset>\n\t\t</form>\n\t)\n}\n\nfunction ChangePassword({closeDialog}: {closeDialog: () => void}) {\n\tconst {\n\t\tpassword,\n\t\tsetPassword,\n\t\tnewPassword,\n\t\tsetNewPassword,\n\t\tnewPasswordRepeat,\n\t\tsetNewPasswordRepeat,\n\t\thandleSubmit,\n\t\tfieldErrors,\n\t\tformError,\n\t\tisLoading,\n\t} = usePassword({onSuccess: closeDialog})\n\n\treturn (\n\t\t<form onSubmit={handleSubmit} className='flex flex-1 flex-col'>\n\t\t\t<fieldset disabled={isLoading} className='flex flex-1 flex-col gap-5'>\n\t\t\t\t<ChangePasswordWarning />\n\t\t\t\t<Labeled label={t('change-password.current-password')}>\n\t\t\t\t\t<PasswordInput value={password} onValueChange={setPassword} />\n\t\t\t\t</Labeled>\n\t\t\t\t<Labeled label={t('change-password.new-password')}>\n\t\t\t\t\t<PasswordInput value={newPassword} onValueChange={setNewPassword} error={fieldErrors.oldPassword} />\n\t\t\t\t</Labeled>\n\t\t\t\t<Labeled label={t('change-password.repeat-password')}>\n\t\t\t\t\t<PasswordInput\n\t\t\t\t\t\tvalue={newPasswordRepeat}\n\t\t\t\t\t\tonValueChange={setNewPasswordRepeat}\n\t\t\t\t\t\terror={fieldErrors.newPassword}\n\t\t\t\t\t/>\n\t\t\t\t</Labeled>\n\t\t\t\t<div className='flex-1' />\n\t\t\t\t<div className='-my-2.5'>\n\t\t\t\t\t<AnimatedInputError>{formError}</AnimatedInputError>\n\t\t\t\t</div>\n\n\t\t\t\t<DrawerFooter>\n\t\t\t\t\t<Button type='button' size='dialog' onClick={closeDialog}>\n\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button type='submit' size='dialog' variant='primary'>\n\t\t\t\t\t\t{t('confirm')}\n\t\t\t\t\t</Button>\n\t\t\t\t</DrawerFooter>\n\t\t\t</fieldset>\n\t\t</form>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/mobile/app-store-preferences.tsx",
    "content": "import {\n\tDrawer,\n\tDrawerContent,\n\tDrawerDescription,\n\tDrawerHeader,\n\tDrawerScroller,\n\tDrawerTitle,\n} from '@/components/ui/drawer'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nimport {AppStorePreferencesContent} from '../_components/app-store-preferences-content'\n\nexport function AppStorePreferencesDrawer() {\n\tconst title = t('settings.app-store-preferences.title')\n\tconst dialogProps = useDialogOpenProps('app-store-preferences')\n\n\treturn (\n\t\t<Drawer {...dialogProps}>\n\t\t\t<DrawerContent fullHeight withScroll>\n\t\t\t\t<DrawerHeader>\n\t\t\t\t\t<DrawerTitle>{title}</DrawerTitle>\n\t\t\t\t\t<DrawerDescription>{t('app-store.description')}</DrawerDescription>\n\t\t\t\t</DrawerHeader>\n\t\t\t\t<DrawerScroller>\n\t\t\t\t\t<div className='flex flex-col gap-5'>\n\t\t\t\t\t\t<AppStorePreferencesContent />\n\t\t\t\t\t</div>\n\t\t\t\t</DrawerScroller>\n\t\t\t</DrawerContent>\n\t\t</Drawer>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/mobile/backups-mobile-drawer.tsx",
    "content": "import {ChevronDown, Loader2} from 'lucide-react'\nimport {useCallback} from 'react'\nimport {FaRegSave} from 'react-icons/fa'\nimport {TbHistory, TbSettings} from 'react-icons/tb'\nimport {useNavigate} from 'react-router-dom'\n\nimport {Button} from '@/components/ui/button'\nimport {Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle} from '@/components/ui/drawer'\nimport {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu'\nimport {FadeInImg} from '@/components/ui/fade-in-img'\nimport backupsIcon from '@/features/backups/assets/backups-icon.png'\nimport {useBackups} from '@/features/backups/hooks/use-backups'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {t} from '@/utils/i18n'\n\nexport function BackupsMobileDrawer() {\n\tconst dialogProps = useSettingsDialogProps()\n\tconst navigate = useNavigate()\n\tconst {repositories: backupRepositories, isLoadingRepositories: isLoadingBackups} = useBackups()\n\n\tconst goToSetup = useCallback(() => {\n\t\tnavigate('/settings/backups/setup', {preventScrollReset: true})\n\t}, [navigate])\n\n\tconst goToConfigure = useCallback(() => {\n\t\tnavigate('/settings/backups/configure', {preventScrollReset: true})\n\t}, [navigate])\n\n\treturn (\n\t\t<Drawer {...dialogProps}>\n\t\t\t<DrawerContent>\n\t\t\t\t<DrawerHeader className='flex flex-col items-center text-center'>\n\t\t\t\t\t<div className='py-5'>\n\t\t\t\t\t\t<FadeInImg src={backupsIcon} width={67} height={67} alt='' />\n\t\t\t\t\t</div>\n\t\t\t\t\t<DrawerTitle>{t('backups')}</DrawerTitle>\n\t\t\t\t\t<DrawerDescription>{t('backups-description')}</DrawerDescription>\n\t\t\t\t</DrawerHeader>\n\t\t\t\t<DrawerFooter>\n\t\t\t\t\t{/* There are 2 buttons (Set up/Configure, Restore) */}\n\t\t\t\t\t{/* We always render the \"Restore\" dropdown with Full Restore and Rewind options */}\n\t\t\t\t\t{/* We render the \"Set up\" button if the user has no backup repo yet, or the \"Configure\" button if they do*/}\n\t\t\t\t\t{/* If we're still checking for existing backup repos we just show a load spinner in place of the Set up or Configure button */}\n\t\t\t\t\t{isLoadingBackups ? (\n\t\t\t\t\t\t<Button size='dialog' disabled aria-busy='true'>\n\t\t\t\t\t\t\t<Loader2 className='size-4 animate-spin' aria-hidden='true' />\n\t\t\t\t\t\t\t<span className='sr-only'>{t('loading')}</span>\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t) : (backupRepositories?.length ?? 0) === 0 ? (\n\t\t\t\t\t\t<Button onClick={goToSetup} size='dialog' variant='primary'>\n\t\t\t\t\t\t\t<FaRegSave className='size-4' />\n\t\t\t\t\t\t\t{t('backups-setup')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<Button onClick={goToConfigure} size='dialog'>\n\t\t\t\t\t\t\t<TbSettings className='size-4' />\n\t\t\t\t\t\t\t{t('backups-configure')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t)}\n\t\t\t\t\t<DropdownMenu>\n\t\t\t\t\t\t<DropdownMenuTrigger asChild>\n\t\t\t\t\t\t\t<Button size='dialog' className='flex items-center justify-center gap-2'>\n\t\t\t\t\t\t\t\t<TbHistory className='size-4' />\n\t\t\t\t\t\t\t\t{t('backups-restore')}\n\t\t\t\t\t\t\t\t<ChevronDown className='size-4' />\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</DropdownMenuTrigger>\n\t\t\t\t\t\t<DropdownMenuContent align='center' className='min-w-[280px]'>\n\t\t\t\t\t\t\t<DropdownMenuItem onSelect={() => navigate('/settings/backups/restore', {preventScrollReset: true})}>\n\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t<div className='text-14 font-medium'>{t('backups-restore-full')}</div>\n\t\t\t\t\t\t\t\t\t<div className='text-12 text-white/40'>{t('backups-restore-full-description')}</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t\t<DropdownMenuItem onSelect={() => navigate('/files/Home?rewind=open', {preventScrollReset: true})}>\n\t\t\t\t\t\t\t\t<div className='flex flex-col'>\n\t\t\t\t\t\t\t\t\t<div className='text-14 font-medium'>{t('backups-rewind')}</div>\n\t\t\t\t\t\t\t\t\t<div className='text-12 text-white/40'>{t('backups-rewind-description')}</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</DropdownMenuItem>\n\t\t\t\t\t\t</DropdownMenuContent>\n\t\t\t\t\t</DropdownMenu>\n\t\t\t\t</DrawerFooter>\n\t\t\t</DrawerContent>\n\t\t</Drawer>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/mobile/device-info.tsx",
    "content": "import {\n\tDrawer,\n\tDrawerContent,\n\tDrawerDescription,\n\tDrawerHeader,\n\tDrawerScroller,\n\tDrawerTitle,\n} from '@/components/ui/drawer'\nimport {useDeviceInfo} from '@/hooks/use-device-info'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {t} from '@/utils/i18n'\n\nimport {DeviceInfoContent} from '../_components/device-info-content'\n\nexport function DeviceInfoDrawer() {\n\tconst title = t('device-info')\n\tconst dialogProps = useSettingsDialogProps()\n\n\tconst {data, isLoading} = useDeviceInfo()\n\n\tif (isLoading) {\n\t\treturn null\n\t}\n\n\treturn (\n\t\t<Drawer {...dialogProps}>\n\t\t\t<DrawerContent fullHeight>\n\t\t\t\t<DrawerHeader>\n\t\t\t\t\t<DrawerTitle>{title}</DrawerTitle>\n\t\t\t\t\t<DrawerDescription>{t('device-info-description')}</DrawerDescription>\n\t\t\t\t</DrawerHeader>\n\t\t\t\t<DrawerScroller>\n\t\t\t\t\t<DeviceInfoContent\n\t\t\t\t\t\tumbrelHostEnvironment={data.umbrelHostEnvironment}\n\t\t\t\t\t\tdevice={data.device}\n\t\t\t\t\t\tmodelNumber={data.modelNumber}\n\t\t\t\t\t\tserialNumber={data.serialNumber}\n\t\t\t\t\t/>\n\t\t\t\t</DrawerScroller>\n\t\t\t</DrawerContent>\n\t\t</Drawer>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/mobile/language.tsx",
    "content": "import {useId, useState} from 'react'\n\nimport {\n\tDrawer,\n\tDrawerContent,\n\tDrawerDescription,\n\tDrawerHeader,\n\tDrawerScroller,\n\tDrawerTitle,\n} from '@/components/ui/drawer'\nimport {ListRadioItem} from '@/components/ui/list'\nimport {useLanguage} from '@/hooks/use-language'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {t} from '@/utils/i18n'\nimport {languages, SupportedLanguageCode} from '@/utils/language'\nimport {sleep} from '@/utils/misc'\n\nexport function LanguageDrawer() {\n\tconst title = t('language')\n\tconst dialogProps = useSettingsDialogProps()\n\tconst [activeCode, setActiveCode] = useLanguage()\n\tconst [temporaryCode, setTemporaryCode] = useState(activeCode)\n\n\tconst changeLanguage = async (code: SupportedLanguageCode) => {\n\t\t// Using this janky approach with a temporary code because we want to show feedback right away\n\t\t// and also close the dialog (which updates the page URL), so the timeout causes the page refresh to happen\n\t\t// at the desired url\n\t\tsetTemporaryCode(code)\n\t\t// Delay so user can see the checkmark\n\t\tawait sleep(200)\n\t\tdialogProps.onOpenChange(false)\n\t\tsetTimeout(() => setActiveCode(code), 200)\n\t}\n\n\tconst radioName = useId()\n\n\treturn (\n\t\t<Drawer {...dialogProps}>\n\t\t\t<DrawerContent fullHeight>\n\t\t\t\t<DrawerHeader>\n\t\t\t\t\t<DrawerTitle>{title}</DrawerTitle>\n\t\t\t\t\t<DrawerDescription>{t('language.select-description')}</DrawerDescription>\n\t\t\t\t</DrawerHeader>\n\n\t\t\t\t<DrawerScroller>\n\t\t\t\t\t<div className='divide-y divide-white/6 rounded-12 bg-white/6'>\n\t\t\t\t\t\t{languages.map(({code, name}) => (\n\t\t\t\t\t\t\t<ListRadioItem\n\t\t\t\t\t\t\t\tkey={code}\n\t\t\t\t\t\t\t\tname={radioName}\n\t\t\t\t\t\t\t\tchecked={temporaryCode === code}\n\t\t\t\t\t\t\t\tonSelect={() => changeLanguage(code)}\n\t\t\t\t\t\t\t\tdisabled={temporaryCode !== activeCode}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{name}\n\t\t\t\t\t\t\t</ListRadioItem>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</DrawerScroller>\n\t\t\t</DrawerContent>\n\t\t</Drawer>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/mobile/software-update.tsx",
    "content": "// TODO: Re-enable Trans and Link when whats-new content is updated\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nimport {Trans} from 'react-i18next/TransWithoutContext'\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nimport {Link} from 'react-router-dom'\n\nimport {Button} from '@/components/ui/button'\nimport {ButtonLink} from '@/components/ui/button-link'\nimport {Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle} from '@/components/ui/drawer'\nimport {FadeInImg} from '@/components/ui/fade-in-img'\nimport {LOADING_DASH} from '@/constants'\nimport {useSoftwareUpdate} from '@/hooks/use-software-update'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {useLinkToDialog} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\nimport {tw} from '@/utils/tw'\n\nexport function SoftwareUpdateDrawer() {\n\tconst title = t('software-update.title')\n\tconst dialogProps = useSettingsDialogProps()\n\n\tconst {state, currentVersion, latestVersion, checkLatest} = useSoftwareUpdate()\n\t// eslint-disable-next-line @typescript-eslint/no-unused-vars\n\tconst linkToDialog = useLinkToDialog()\n\n\treturn (\n\t\t<Drawer {...dialogProps}>\n\t\t\t<DrawerContent>\n\t\t\t\t<DrawerHeader>\n\t\t\t\t\t<DrawerTitle>{title}</DrawerTitle>\n\t\t\t\t\t<DrawerDescription>{t('check-for-latest-version')}</DrawerDescription>\n\t\t\t\t</DrawerHeader>\n\t\t\t\t<div className='flex flex-col items-center py-8'>\n\t\t\t\t\t<FadeInImg src='/assets/umbrel-ios.png' className='h-[96px] w-[96px]' />\n\t\t\t\t\t<div className='mb-4' />\n\t\t\t\t\t<p className='text-12 -tracking-2 opacity-50'>{t('software-update.current-running')}</p>\n\t\t\t\t\t<p className='text-15 -tracking-4'>{currentVersion?.name || `umbrelOS ${LOADING_DASH}`}</p>\n\t\t\t\t\t{/* TODO: Re-enable when whats-new content is updated */}\n\t\t\t\t\t{/* <p className='text-12 -tracking-2 opacity-50'>\n\t\t\t\t\t\t<Trans\n\t\t\t\t\t\t\ti18nKey='software-update.see-whats-new'\n\t\t\t\t\t\t\tcomponents={{\n\t\t\t\t\t\t\t\tlinked: <Link to={linkToDialog('whats-new')} className='underline' />,\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t/>\n\t\t\t\t\t</p> */}\n\t\t\t\t\t{/* Make it look like a button, but non-interactive */}\n\t\t\t\t</div>\n\t\t\t\t<DrawerFooter>\n\t\t\t\t\t{state === 'at-latest' && (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<div className={versionMessageClass}>{t('software-update.on-latest')}</div>\n\t\t\t\t\t\t\t<Button variant='primary' size='dialog' onClick={checkLatest}>\n\t\t\t\t\t\t\t\t{t('software-update.check')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t\t{(state === 'initial' || state === 'checking') && (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<div className={versionMessageClass}>&nbsp;{/* Spacer */}</div>\n\t\t\t\t\t\t\t<Button variant='primary' size='dialog' onClick={checkLatest} disabled={state === 'checking'}>\n\t\t\t\t\t\t\t\t{state === 'checking' ? t('software-update.checking') : t('software-update.check')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t\t{state === 'update-available' && (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<div className={versionMessageClass}>\n\t\t\t\t\t\t\t\t<div className='mr-2 inline-block h-1.5 w-1.5 -translate-y-px rounded-full bg-brand align-middle' />\n\t\t\t\t\t\t\t\t{t('software-update.new-version', {name: latestVersion?.name})}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<ButtonLink variant='primary' size='dialog' to='/settings/software-update/confirm'>\n\t\t\t\t\t\t\t\t{t('software-update.view')}\n\t\t\t\t\t\t\t</ButtonLink>\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t</DrawerFooter>\n\t\t\t</DrawerContent>\n\t\t</Drawer>\n\t)\n}\n\nconst versionMessageClass = tw`text-center text-14 font-semibold -tracking-2 py-4 leading-inter-trimmed`\n"
  },
  {
    "path": "packages/ui/src/routes/settings/mobile/start-migration-drawer-or-dialog.tsx",
    "content": "import {useState} from 'react'\n\nimport {Button} from '@/components/ui/button'\nimport {Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle} from '@/components/ui/drawer'\nimport {useIsHomeOrPro} from '@/hooks/use-is-home-or-pro'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {MigrateImage} from '@/modules/migrate/migrate-image'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport MigrationAssistantDialog from '@/routes/settings/migration-assistant'\nimport {t} from '@/utils/i18n'\n\nexport function StartMigrationDrawerOrDialog() {\n\tconst title = t('migration-assistant')\n\tconst dialogProps = useSettingsDialogProps()\n\tconst {deviceName} = useIsHomeOrPro()\n\n\tconst isMobile = useIsMobile()\n\tconst [startMigration, setStartMigration] = useState(isMobile ? false : true)\n\n\tif (startMigration) {\n\t\treturn <MigrationAssistantDialog />\n\t}\n\n\treturn (\n\t\t<Drawer {...dialogProps}>\n\t\t\t<DrawerContent>\n\t\t\t\t<DrawerHeader className='flex flex-col items-center text-center'>\n\t\t\t\t\t<div className='py-5'>\n\t\t\t\t\t\t<MigrateImage />\n\t\t\t\t\t</div>\n\t\t\t\t\t<DrawerTitle>{title}</DrawerTitle>\n\t\t\t\t\t<DrawerDescription>{t('migration-assistant-description', {deviceName})}</DrawerDescription>\n\t\t\t\t</DrawerHeader>\n\t\t\t\t<DrawerFooter>\n\t\t\t\t\t<Button onClick={() => setStartMigration(true)} variant='primary' size='dialog'>\n\t\t\t\t\t\t{t('migration-assistant.mobile.start-button')}\n\t\t\t\t\t</Button>\n\t\t\t\t</DrawerFooter>\n\t\t\t</DrawerContent>\n\t\t</Drawer>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/mobile/tor.tsx",
    "content": "import {CopyableField} from '@/components/ui/copyable-field'\nimport {Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle} from '@/components/ui/drawer'\nimport {listClass, listItemClass} from '@/components/ui/list'\nimport {Spinner} from '@/components/ui/loading'\nimport {Switch} from '@/components/ui/switch'\nimport {useTorEnabled} from '@/hooks/use-tor-enabled'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function TorDrawer() {\n\tconst title = t('remote-tor-access')\n\tconst dialogProps = useSettingsDialogProps()\n\n\tconst {enabled, setEnabled, isMutLoading, isError} = useTorEnabled({\n\t\tonSuccess: () => {\n\t\t\tdialogProps.onOpenChange(false)\n\t\t},\n\t})\n\n\tif (isError) {\n\t\tdialogProps.onOpenChange(false)\n\t}\n\n\tconst hiddenServiceQ = trpcReact.system.hiddenService.useQuery(undefined, {enabled})\n\n\treturn (\n\t\t<Drawer {...dialogProps}>\n\t\t\t<DrawerContent fullHeight>\n\t\t\t\t<DrawerHeader>\n\t\t\t\t\t<DrawerTitle>{title}</DrawerTitle>\n\t\t\t\t\t<DrawerDescription>{t('tor-description')}</DrawerDescription>\n\t\t\t\t</DrawerHeader>\n\t\t\t\t<div className={listClass}>\n\t\t\t\t\t<label className={listItemClass}>\n\t\t\t\t\t\t{t('tor.enable.mobile.switch-label')}\n\t\t\t\t\t\t<div className='flex items-center gap-2'>\n\t\t\t\t\t\t\t{isMutLoading && <Spinner />}\n\t\t\t\t\t\t\t<Switch checked={enabled} onCheckedChange={setEnabled} disabled={isMutLoading} />\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</label>\n\t\t\t\t</div>\n\t\t\t\t<div className='text-12 leading-tight font-normal -tracking-2 text-white/60'>{t('tor.enable.description')}</div>\n\t\t\t\t{enabled && (\n\t\t\t\t\t<div className='space-y-2'>\n\t\t\t\t\t\t<span className='text-15 font-medium -tracking-4'>{t('tor.hidden-service')}</span>\n\t\t\t\t\t\t<CopyableField value={hiddenServiceQ.data ?? ''} />\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</DrawerContent>\n\t\t</Drawer>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/mobile/wallpaper.tsx",
    "content": "import {useRef} from 'react'\nimport {useMount} from 'react-use'\n\nimport {\n\tDrawer,\n\tDrawerContent,\n\tDrawerDescription,\n\tDrawerHeader,\n\tDrawerScroller,\n\tDrawerTitle,\n} from '@/components/ui/drawer'\nimport {FadeInImg} from '@/components/ui/fade-in-img'\nimport {cn} from '@/lib/utils'\nimport {useWallpaper, WallpaperId, wallpapers} from '@/providers/wallpaper'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {t} from '@/utils/i18n'\n\nexport function WallpaperDrawer() {\n\tconst title = t('wallpaper')\n\tconst dialogProps = useSettingsDialogProps()\n\n\tconst {wallpaper, setWallpaperId} = useWallpaper()\n\n\tconst selectWallpaper = (id: WallpaperId) => {\n\t\tsetWallpaperId(id)\n\t}\n\n\treturn (\n\t\t<Drawer {...dialogProps}>\n\t\t\t<DrawerContent fullHeight>\n\t\t\t\t<DrawerHeader>\n\t\t\t\t\t<DrawerTitle>{title}</DrawerTitle>\n\t\t\t\t\t<DrawerDescription>{t('wallpaper-description')}</DrawerDescription>\n\t\t\t\t</DrawerHeader>\n\t\t\t\t<DrawerScroller>\n\t\t\t\t\t<div className='grid grid-cols-2 gap-2.5'>\n\t\t\t\t\t\t{wallpapers.map((w, i) => (\n\t\t\t\t\t\t\t<WallpaperItem\n\t\t\t\t\t\t\t\tkey={w.id}\n\t\t\t\t\t\t\t\tbg={`/assets/wallpapers/generated-small/${w.id}.jpg`}\n\t\t\t\t\t\t\t\tactive={w.id === wallpaper.id}\n\t\t\t\t\t\t\t\tonSelect={() => selectWallpaper(w.id)}\n\t\t\t\t\t\t\t\tclassName='animate-in fill-mode-both fade-in'\n\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\tanimationDelay: `${i * 20}ms`,\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</DrawerScroller>\n\t\t\t</DrawerContent>\n\t\t</Drawer>\n\t)\n}\n\nfunction WallpaperItem({\n\tactive,\n\tbg,\n\tonSelect,\n\tclassName,\n\tstyle,\n}: {\n\tactive?: boolean\n\tbg: string\n\tonSelect: () => void\n\tclassName?: string\n\tstyle: React.CSSProperties\n}) {\n\tconst ref = useRef<HTMLButtonElement>(null)\n\n\tuseMount(() => {\n\t\tif (!active) return\n\t\tref.current?.scrollIntoView({block: 'center'})\n\t})\n\n\treturn (\n\t\t<button\n\t\t\tref={ref}\n\t\t\tclassName={cn('relative aspect-1.9 overflow-hidden rounded-10 bg-white/10', className)}\n\t\t\tstyle={{\n\t\t\t\t...style,\n\t\t\t}}\n\t\t\tonClick={onSelect}\n\t\t>\n\t\t\t<FadeInImg src={bg} className='absolute inset-0 h-full w-full rounded-10 object-cover object-center' />\n\t\t\t{/* Border */}\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'absolute inset-0 rounded-10 border-4 transition-colors',\n\t\t\t\t\tactive ? 'border-white' : 'border-transparent',\n\t\t\t\t)}\n\t\t\t/>\n\t\t</button>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/restart.tsx",
    "content": "import {useState} from 'react'\nimport {RiRestartLine} from 'react-icons/ri'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {useGlobalSystemState} from '@/providers/global-system-state/index'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nexport default function RestartDialog() {\n\tconst dialogProps = useDialogOpenProps('restart')\n\n\tconst {restart} = useGlobalSystemState()\n\tconst [triggered, setTriggered] = useState(false)\n\n\treturn (\n\t\t<AlertDialog {...dialogProps}>\n\t\t\t<AlertDialogContent>\n\t\t\t\t<AlertDialogHeader icon={RiRestartLine}>\n\t\t\t\t\t<AlertDialogTitle>{t('restart.confirm.title')}</AlertDialogTitle>\n\t\t\t\t</AlertDialogHeader>\n\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t<AlertDialogAction\n\t\t\t\t\t\tvariant='destructive'\n\t\t\t\t\t\tclassName='px-6'\n\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t// Prevent closing by default\n\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\tsetTriggered(true)\n\t\t\t\t\t\t\trestart()\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tdisabled={triggered}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('restart.confirm.submit')}\n\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t</AlertDialogFooter>\n\t\t\t</AlertDialogContent>\n\t\t</AlertDialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/shutdown.tsx",
    "content": "import {RiShutDownLine} from 'react-icons/ri'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {useGlobalSystemState} from '@/providers/global-system-state/index'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\nexport default function ShutdownDialog() {\n\tconst dialogProps = useDialogOpenProps('shutdown')\n\n\tconst {shutdown} = useGlobalSystemState()\n\n\treturn (\n\t\t<AlertDialog {...dialogProps}>\n\t\t\t<AlertDialogContent>\n\t\t\t\t<AlertDialogHeader icon={RiShutDownLine}>\n\t\t\t\t\t<AlertDialogTitle>{t('shut-down.confirm.title')}</AlertDialogTitle>\n\t\t\t\t</AlertDialogHeader>\n\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t<AlertDialogAction\n\t\t\t\t\t\tvariant='destructive'\n\t\t\t\t\t\tonClick={(e) => {\n\t\t\t\t\t\t\t// Prevent closing by default\n\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\tshutdown()\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('shut-down.confirm.submit')}\n\t\t\t\t\t</AlertDialogAction>\n\t\t\t\t\t<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n\t\t\t\t</AlertDialogFooter>\n\t\t\t</AlertDialogContent>\n\t\t</AlertDialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/software-update-confirm.tsx",
    "content": "import {Markdown} from '@/components/markdown'\nimport {Button} from '@/components/ui/button'\nimport {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'\nimport {ScrollArea} from '@/components/ui/scroll-area'\nimport {useGlobalSystemState} from '@/providers/global-system-state/index'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function SoftwareUpdateConfirmDialog() {\n\tconst {update} = useGlobalSystemState()\n\tconst latestVersionQ = trpcReact.system.checkUpdate.useQuery()\n\tconst dialogProps = useSettingsDialogProps()\n\n\tif (latestVersionQ.isLoading) {\n\t\treturn null\n\t}\n\n\treturn (\n\t\t<Dialog {...dialogProps}>\n\t\t\t<DialogContent className='px-0'>\n\t\t\t\t<DialogHeader className='px-4 sm:px-8'>\n\t\t\t\t\t<DialogTitle>{latestVersionQ.data?.name}</DialogTitle>\n\t\t\t\t</DialogHeader>\n\t\t\t\t<ScrollArea className='flex max-h-[500px] flex-col gap-5 px-4 sm:px-8'>\n\t\t\t\t\t<Markdown>{latestVersionQ.data?.releaseNotes}</Markdown>\n\t\t\t\t</ScrollArea>\n\t\t\t\t<DialogFooter className='px-4 sm:px-8'>\n\t\t\t\t\t<Button\n\t\t\t\t\t\tvariant='primary'\n\t\t\t\t\t\tsize='dialog'\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tdialogProps.onOpenChange(false)\n\t\t\t\t\t\t\tupdate()\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{t('software-update.install-now')}\n\t\t\t\t\t</Button>\n\t\t\t\t\t<Button size='dialog' onClick={() => dialogProps.onOpenChange(false)}>\n\t\t\t\t\t\t{t('cancel')}\n\t\t\t\t\t</Button>\n\t\t\t\t\t{/* <DialogAction variant='destructive' className='px-6' onClick={logout}>\n\t\t\t\t\t\t{t('logout.confirm.submit')}\n\t\t\t\t\t</DialogAction>\n\t\t\t\t\t<DialogCancel>{t('cancel')}</DialogCancel> */}\n\t\t\t\t</DialogFooter>\n\t\t\t</DialogContent>\n\t\t</Dialog>\n\t)\n}\n\n// const sampleMarkdownReleaseNotes = `\n// # What's new\n\n// Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quam. Quam, quisquam. Quisquam, quam. Quam,\n// quisquam. Quisquam, quam. Quam, quisquam. Quisquam, quam. Quam, quisquam. Quisquam, quam. Quam, quisquam. Quisquam,\n\n// ## New features\n\n// ### More support:\n\n// - Added support for the Raspberry Pi 4 and 400\n// - Added support for the Raspberry Pi 5 and 500\n// - Added support for the Raspberry Pi 6 and 600\n// - Added support for the Raspberry Pi 7 and 700\n// - Added support for the Raspberry Pi 8 and 800\n\n// ### Improvements\n\n// [Lorem ipsum dolor](https://umbrel.com) sit amet consectetur adipisicing elit. Quisquam, quam. Quam, quisquam. Quisquam, quam. Quam,\n// quisquam. Quisquam, quam. Quam, quisquam. Quisquam, quam. Quam, quisquam. Quisquam, quam. Quam, quisquam. Quisquam,\n\n// ### Fixes\n\n// Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quam. Quam, quisquam. Quisquam, quam. Quam,\n// `\n"
  },
  {
    "path": "packages/ui/src/routes/settings/terminal/_shared.tsx",
    "content": "import {FitAddon} from '@xterm/addon-fit'\nimport {Terminal} from '@xterm/xterm'\nimport {useEffect, useRef, useState} from 'react'\nimport {TbArrowRight, TbClipboard, TbX} from 'react-icons/tb'\nimport {useMeasure} from 'react-use'\n\nimport {Button} from '@/components/ui/button'\nimport {useIsTouchDevice} from '@/features/files/hooks/use-is-touch-device'\nimport {useIsMobile} from '@/hooks/use-is-mobile'\nimport {BackLink} from '@/modules/immersive-picker'\nimport {t} from '@/utils/i18n'\n\nimport '@xterm/xterm/css/xterm.css'\n\nexport function TerminalTitleBackLink() {\n\treturn <BackLink to='/settings/terminal'>{t('terminal')}</BackLink>\n}\n\n// Minimum columns for MOTD display (warning box is 79 chars wide)\nconst MIN_COLS = 80\n\nexport const XTermTerminal = ({appId}: {appId?: string}) => {\n\tconst terminalRef = useRef<Terminal | null>(null)\n\tconst ws = useRef<WebSocket | null>(null)\n\tconst containerRef = useRef<HTMLDivElement | null>(null)\n\tconst scrollContainerRef = useRef<HTMLDivElement | null>(null)\n\tconst pasteInputRef = useRef<HTMLInputElement | null>(null)\n\n\tconst isMobile = useIsMobile()\n\tconst isTouchDevice = useIsTouchDevice()\n\tconst fontSize = isMobile ? 11 : 13\n\t// TODO: link this to the theme\n\tconst fontFamily = 'SF Mono, SFMono-Regular, ui-monospace, DejaVu Sans Mono, Menlo, Consolas, monospace'\n\n\tconst [parentContainerRef, {width: containerWidth, height: containerHeight}] = useMeasure()\n\tconst [charMeasureRef, {width: charWidth}] = useMeasure()\n\n\t// Paste UI state for touch devices\n\tconst [showPasteInput, setShowPasteInput] = useState(false)\n\n\t// Submit pasted command to terminal\n\tconst submitPasteInput = () => {\n\t\tconst text = pasteInputRef.current?.value || ''\n\t\tif (text && ws.current?.readyState === WebSocket.OPEN) {\n\t\t\tws.current.send(text + '\\r')\n\t\t}\n\t\tsetShowPasteInput(false)\n\t\tterminalRef.current?.focus()\n\t}\n\n\t// On narrow screens (e.g., mobile), terminal may be wider than container (due to MIN_COLS).\n\t// We auto-scroll horizontally to keep cursor visible as the user types, otherwise they can't see what they're typing.\n\tconst scrollToCursor = () => {\n\t\tif (!scrollContainerRef.current || !terminalRef.current || charWidth === 0) return\n\t\tconst cursorX = terminalRef.current.buffer.active.cursorX\n\t\tconst cursorPixelX = cursorX * charWidth + 16 // 16px left padding\n\t\tconst container = scrollContainerRef.current\n\t\tconst {clientWidth, scrollLeft} = container\n\t\tconst buffer = 40\n\n\t\tif (cursorPixelX < scrollLeft + buffer) {\n\t\t\tcontainer.scrollLeft = Math.max(0, cursorPixelX - buffer)\n\t\t} else if (cursorPixelX > scrollLeft + clientWidth - buffer) {\n\t\t\tcontainer.scrollLeft = cursorPixelX - clientWidth + buffer\n\t\t}\n\t}\n\n\tuseEffect(() => {\n\t\tif (containerWidth === 0 || containerHeight === 0) return\n\n\t\t// Clean up previous instances if they exist\n\t\tterminalRef.current?.dispose()\n\t\tws.current?.close()\n\n\t\tconst terminal = new Terminal({fontSize, fontFamily})\n\t\tconst fitAddon = new FitAddon()\n\t\tterminalRef.current = terminal\n\n\t\tif (containerRef.current) {\n\t\t\tterminal.loadAddon(fitAddon)\n\t\t\tterminal.open(containerRef.current)\n\t\t\tterminal.focus()\n\t\t\tfitAddon.fit()\n\n\t\t\t// Enforce minimum cols for MOTD display on narrow screens\n\t\t\tif (terminal.cols < MIN_COLS) {\n\t\t\t\tterminal.resize(MIN_COLS, terminal.rows)\n\t\t\t}\n\n\t\t\t// We read dimensions AFTER fit/resize so server PTY matches xterm exactly.\n\t\t\t// If mismatched, the server thinks lines wrap at a different column than xterm,\n\t\t\t// causing text to overwrite itself when typing past the (server's) line boundary.\n\t\t\tconst cols = terminal.cols\n\t\t\tconst rows = terminal.rows\n\n\t\t\t// Build ws url\n\t\t\tconst path = `/terminal?appId=${appId ?? ''}&rows=${rows}&cols=${cols}&token=${localStorage.getItem('jwt')}`\n\t\t\tconst wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'\n\t\t\tconst port = window.location.port ? `:${window.location.port}` : ''\n\t\t\tws.current = new WebSocket(`${wsProtocol}${window.location.hostname}${port}${path}`)\n\n\t\t\tws.current.onmessage = (event) => {\n\t\t\t\tterminal.write(event.data)\n\t\t\t\tscrollToCursor()\n\t\t\t}\n\t\t\tterminal.onData((data) => ws.current?.send(data))\n\t\t}\n\n\t\treturn () => {\n\t\t\tterminal.dispose()\n\t\t\tws.current?.close()\n\t\t}\n\t}, [appId, containerWidth, containerHeight])\n\n\treturn (\n\t\t<div\n\t\t\tref={parentContainerRef as React.LegacyRef<HTMLDivElement>}\n\t\t\tclassName='relative h-full w-full overflow-hidden rounded-12 bg-black/50'\n\t\t>\n\t\t\t{/* Hidden character to measure monospace character width */}\n\t\t\t<div\n\t\t\t\tref={charMeasureRef as React.LegacyRef<HTMLDivElement>}\n\t\t\t\tstyle={{fontFamily, fontSize, visibility: 'hidden', position: 'absolute', whiteSpace: 'nowrap'}}\n\t\t\t>\n\t\t\t\tW\n\t\t\t</div>\n\n\t\t\t{/* Paste button ONLY for touch devices. Without this, touch device users have no way to paste commands into the terminal. */}\n\t\t\t{/* xterm renders to a canvas which doesn't receive native paste gestures, so we allow users to paste via an input */}\n\t\t\t{isTouchDevice && (\n\t\t\t\t<>\n\t\t\t\t\t{showPasteInput ? (\n\t\t\t\t\t\t<form\n\t\t\t\t\t\t\tclassName='absolute inset-x-2 top-2 z-10 flex items-center gap-2 rounded-8 bg-neutral-900 p-2 shadow-lg'\n\t\t\t\t\t\t\tonSubmit={(e) => {\n\t\t\t\t\t\t\t\te.preventDefault()\n\t\t\t\t\t\t\t\tsubmitPasteInput()\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\tref={pasteInputRef}\n\t\t\t\t\t\t\t\ttype='text'\n\t\t\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\t\t\tplaceholder={t('terminal.paste-placeholder', 'Paste command here')}\n\t\t\t\t\t\t\t\tclassName='min-w-0 flex-1 rounded-4 bg-white/10 px-2 py-2 text-13 text-white placeholder:text-white/40 focus:outline-hidden'\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype='submit'\n\t\t\t\t\t\t\t\tclassName='flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20'\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<TbArrowRight className='h-5 w-5' />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\ttype='button'\n\t\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\t\tsetShowPasteInput(false)\n\t\t\t\t\t\t\t\t\tterminalRef.current?.focus()\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tclassName='shrink-0 rounded-full p-1 text-white/60 hover:bg-white/10 hover:text-white'\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<TbX className='h-4 w-4' />\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t</form>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<div className='absolute top-2 right-2 z-10'>\n\t\t\t\t\t\t\t<Button size='sm' className='bg-neutral-800 hover:bg-neutral-700' onClick={() => setShowPasteInput(true)}>\n\t\t\t\t\t\t\t\t<TbClipboard className='h-4 w-4' />\n\t\t\t\t\t\t\t\t{t('paste')}\n\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\t\t\t\t</>\n\t\t\t)}\n\t\t\t{/* Scroll container for horizontal scrolling on narrow screens */}\n\t\t\t<div ref={scrollContainerRef} className='h-full w-full overflow-x-auto overflow-y-hidden'>\n\t\t\t\t{/* 980px min width (for mobile) cause side scrolling is better than wrapping */}\n\t\t\t\t{/* Using `tracking-normal` and `text-rendering: unset` to prevent cursor text selection from not selecting the correct text */}\n\t\t\t\t{/* Note: xterm.js handles text selection internally, so no `select-text` class needed */}\n\t\t\t\t<div\n\t\t\t\t\tref={containerRef}\n\t\t\t\t\tclassName='h-full w-full min-w-[980px] px-4 py-3 tracking-normal'\n\t\t\t\t\tstyle={{textRendering: 'unset'}}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/terminal/app.tsx",
    "content": "import {useState} from 'react'\nimport {useNavigate, useParams} from 'react-router-dom'\n\nimport {DropdownMenu} from '@/components/ui/dropdown-menu'\nimport {useIsTouchDevice} from '@/features/files/hooks/use-is-touch-device'\nimport {AppDropdown, ImmersivePickerDialogContent} from '@/modules/immersive-picker'\nimport {TerminalTitleBackLink, XTermTerminal} from '@/routes/settings/terminal/_shared'\n\nexport function App() {\n\tconst navigate = useNavigate()\n\tconst {appId} = useParams<{appId: string}>()\n\tif (!appId) throw new Error('No app provided')\n\tconst setAppId = (id: string) => navigate(`/settings/terminal/app/${id}`)\n\n\tconst [open, setOpen] = useState(false)\n\tconst isTouchDevice = useIsTouchDevice()\n\n\treturn (\n\t\t<ImmersivePickerDialogContent>\n\t\t\t<div className='flex w-full items-center justify-between'>\n\t\t\t\t<TerminalTitleBackLink />\n\t\t\t\t<DropdownMenu open={open} onOpenChange={setOpen}>\n\t\t\t\t\t<AppDropdown appId={appId} setAppId={setAppId} open={open} onOpenChange={setOpen} />\n\t\t\t\t</DropdownMenu>\n\t\t\t</div>\n\t\t\t{/* On touch devices, add padding to leave room for on-screen keyboard.\n\t\t\t40vh is a rough approximation. Dynamically detecting keyboard height\n\t\t\ttriggers re-renders which would reset the terminal. */}\n\t\t\t{isTouchDevice ? (\n\t\t\t\t<div className='w-full flex-1 pb-[40vh]'>\n\t\t\t\t\t<XTermTerminal appId={appId} />\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<XTermTerminal appId={appId} />\n\t\t\t)}\n\t\t</ImmersivePickerDialogContent>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/terminal/index.tsx",
    "content": "import {DialogPortal} from '@radix-ui/react-dialog'\nimport {DropdownMenu} from '@radix-ui/react-dropdown-menu'\nimport {t} from 'i18next'\nimport {Suspense, useState} from 'react'\nimport {Route, Routes, useNavigate, useParams} from 'react-router-dom'\n\nimport {ImmersiveDialog, ImmersiveDialogOverlay} from '@/components/ui/immersive-dialog'\nimport {AppDropdown, ImmersivePickerDialogContentInit, ImmersivePickerItem} from '@/modules/immersive-picker'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\n\nimport {App} from './app'\nimport UmbrelOs from './umbrelos'\n\nexport default function TerminalDialog() {\n\tconst dialogProps = useSettingsDialogProps()\n\n\treturn (\n\t\t<ImmersiveDialog {...dialogProps}>\n\t\t\t<DialogPortal>\n\t\t\t\t<ImmersiveDialogOverlay />\n\t\t\t\t<Suspense>\n\t\t\t\t\t<Routes>\n\t\t\t\t\t\t<Route index path='/' Component={PickerDialogContent} />\n\t\t\t\t\t\t<Route path='/umbrelos' Component={UmbrelOs} />\n\t\t\t\t\t\t<Route path='/app/:appId' Component={App} />\n\t\t\t\t\t</Routes>\n\t\t\t\t</Suspense>\n\t\t\t</DialogPortal>\n\t\t</ImmersiveDialog>\n\t)\n}\n\nfunction PickerDialogContent() {\n\tconst navigate = useNavigate()\n\tconst [appDialogOpen, setAppDialogOpen] = useState(false)\n\tconst params = useParams<{appId: string}>()\n\n\treturn (\n\t\t<ImmersivePickerDialogContentInit title={t('terminal')}>\n\t\t\t<ImmersivePickerItem\n\t\t\t\ttitle={t('umbrelos')}\n\t\t\t\tdescription={t('terminal.umbrelos-description')}\n\t\t\t\tto='/settings/terminal/umbrelos'\n\t\t\t/>\n\t\t\t<ImmersivePickerItem\n\t\t\t\ttitle={t('terminal.app')}\n\t\t\t\tdescription={t('terminal.app-description')}\n\t\t\t\tonClick={() => setAppDialogOpen(true)}\n\t\t\t>\n\t\t\t\t<DropdownMenu open={appDialogOpen} onOpenChange={setAppDialogOpen}>\n\t\t\t\t\t<AppDropdown\n\t\t\t\t\t\topen={appDialogOpen}\n\t\t\t\t\t\tonOpenChange={setAppDialogOpen}\n\t\t\t\t\t\tappId={params.appId}\n\t\t\t\t\t\tsetAppId={(appId) => navigate(`/settings/terminal/app/${appId}`)}\n\t\t\t\t\t/>\n\t\t\t\t</DropdownMenu>\n\t\t\t</ImmersivePickerItem>\n\t\t</ImmersivePickerDialogContentInit>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/terminal/umbrelos.tsx",
    "content": "import {useIsTouchDevice} from '@/features/files/hooks/use-is-touch-device'\nimport {ImmersivePickerDialogContent} from '@/modules/immersive-picker'\nimport {TerminalTitleBackLink, XTermTerminal} from '@/routes/settings/terminal/_shared'\n\nexport default function UmbrelOs() {\n\tconst isTouchDevice = useIsTouchDevice()\n\n\treturn (\n\t\t<ImmersivePickerDialogContent>\n\t\t\t<div className='flex w-full flex-wrap items-center justify-between'>\n\t\t\t\t<TerminalTitleBackLink />\n\t\t\t</div>\n\t\t\t{/* On touch devices, add padding to leave room for on-screen keyboard.\n\t\t\t40vh is a rough approximation. Dynamically detecting keyboard height\n\t\t\ttriggers re-renders which would reset the terminal. */}\n\t\t\t{isTouchDevice ? (\n\t\t\t\t<div className='w-full flex-1 pb-[40vh]'>\n\t\t\t\t\t<XTermTerminal />\n\t\t\t\t</div>\n\t\t\t) : (\n\t\t\t\t<XTermTerminal />\n\t\t\t)}\n\t\t</ImmersivePickerDialogContent>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/troubleshoot/_shared.tsx",
    "content": "import {format} from 'date-fns'\nimport {saveAs} from 'file-saver'\nimport filenamify from 'filenamify/browser'\nimport {useEffect, useRef} from 'react'\n\nimport {cn} from '@/lib/utils'\nimport {BackLink} from '@/modules/immersive-picker'\nimport {RouterInput} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport type SystemLogType = RouterInput['system']['logs']['type']\n\nexport function TroubleshootTitleBackLink() {\n\treturn <BackLink to='/settings/troubleshoot'>{t('troubleshoot')}</BackLink>\n}\n\nexport const downloadUtf8Logs = (contents: string, fileNameString?: string) => {\n\tconst blob = new Blob([contents], {type: 'text/plain;charset=utf-8'})\n\n\t// Separating sections with `_` so easier to machine-parse in the future\n\tconst name = ['umbrel', filenamify(fileNameString ?? 'logs'), format(new Date(), 'yyyy-MM-dd_HH-mm')].join('_')\n\n\t// Final pass: replacing strings and doing lowercase so good for urls too?\n\tconst finalName = name.replace(/\\s+/g, '-').toLocaleLowerCase()\n\n\tsaveAs(blob, finalName + '.log')\n}\n\nexport function useScrollToBottom(ref: React.RefObject<HTMLDivElement | null>, deps: any[]) {\n\tuseEffect(() => {\n\t\tsetTimeout(() => {\n\t\t\tif (!ref.current) return\n\t\t\tref.current.scrollTop = ref.current.scrollHeight + 100\n\t\t}, 300)\n\t}, [ref, ...deps])\n}\n\nexport function LogResults({children}: {children: string}) {\n\tconst ref = useRef<HTMLDivElement>(null)\n\tuseScrollToBottom(ref, [children])\n\n\treturn (\n\t\t<div ref={ref} className='w-full flex-1 overflow-auto rounded-10 bg-black px-5 py-4'>\n\t\t\t{/* Allow text selection for copying logs/errors */}\n\t\t\t<div\n\t\t\t\tkey={children}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t'font-mono text-xs whitespace-pre text-white/50 select-text',\n\t\t\t\t\tchildren && 'animate-in delay-500 fill-mode-both fade-in',\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</div>\n\t\t\t{/* Keeps scroll pinned to bottom */}\n\t\t\t<div style={{overflowAnchor: 'auto'}} />\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/troubleshoot/app.tsx",
    "content": "import {useState} from 'react'\nimport {useNavigate, useParams} from 'react-router-dom'\n\nimport {Button} from '@/components/ui/button'\nimport {DropdownMenu} from '@/components/ui/dropdown-menu'\nimport {ImmersiveDialogFooter} from '@/components/ui/immersive-dialog'\nimport {LOADING_DASH} from '@/constants'\nimport {AppDropdown, ImmersivePickerDialogContent} from '@/modules/immersive-picker'\nimport {useUserApp} from '@/providers/apps'\nimport {downloadUtf8Logs, LogResults, TroubleshootTitleBackLink} from '@/routes/settings/troubleshoot/_shared'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport function TroubleshootApp() {\n\tconst navigate = useNavigate()\n\tconst {appId} = useParams<{appId: string}>()\n\tif (!appId) throw new Error('No app provided')\n\tconst setAppId = (id: string) => navigate(`/settings/troubleshoot/app/${id}`)\n\n\tconst {app} = useUserApp(appId)\n\tconst [open, setOpen] = useState(false)\n\n\tconst appLogs = useAppLogs(appId)\n\n\treturn (\n\t\t<ImmersivePickerDialogContent>\n\t\t\t<div className='flex w-full items-center justify-between'>\n\t\t\t\t<TroubleshootTitleBackLink />\n\t\t\t\t<DropdownMenu open={open} onOpenChange={setOpen}>\n\t\t\t\t\t<AppDropdown appId={appId} setAppId={setAppId} open={open} onOpenChange={setOpen} />\n\t\t\t\t</DropdownMenu>\n\t\t\t</div>\n\t\t\t{appLogs && <LogResults>{appLogs}</LogResults>}\n\t\t\t<ImmersiveDialogFooter className='justify-center'>\n\t\t\t\t<Button variant='primary' size='dialog' disabled={!appId} onClick={() => downloadUtf8Logs(appLogs, appId)}>\n\t\t\t\t\t{t('troubleshoot.app-download', {app: app?.name || LOADING_DASH})}\n\t\t\t\t</Button>\n\t\t\t\t{/* <Button size='dialog'>{t('troubleshoot.share-with-umbrel-support')}</Button> */}\n\t\t\t</ImmersiveDialogFooter>\n\t\t</ImmersivePickerDialogContent>\n\t)\n}\n\nfunction useAppLogs(appId: string) {\n\tconst troubleshootQ = trpcReact.apps.logs.useQuery({appId})\n\n\tif (troubleshootQ.isLoading) return t('loading') + '...'\n\tif (troubleshootQ.isError) return troubleshootQ.error.message\n\n\treturn troubleshootQ.data || t('troubleshoot-no-logs-yet')\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/troubleshoot/index.tsx",
    "content": "import {DialogPortal} from '@radix-ui/react-dialog'\nimport {DropdownMenu} from '@radix-ui/react-dropdown-menu'\nimport {t} from 'i18next'\nimport {Suspense, useState} from 'react'\nimport {Route, Routes, useNavigate, useParams} from 'react-router-dom'\n\nimport {ImmersiveDialog, ImmersiveDialogOverlay} from '@/components/ui/immersive-dialog'\nimport {AppDropdown, ImmersivePickerDialogContentInit, ImmersivePickerItem} from '@/modules/immersive-picker'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {TroubleshootApp} from '@/routes/settings/troubleshoot/app'\nimport TroubleshootUmbrelOs from '@/routes/settings/troubleshoot/umbrelos'\n\nexport default function TroubleshootDialog() {\n\tconst dialogProps = useSettingsDialogProps()\n\n\treturn (\n\t\t<ImmersiveDialog {...dialogProps}>\n\t\t\t<DialogPortal>\n\t\t\t\t<ImmersiveDialogOverlay />\n\t\t\t\t<Suspense>\n\t\t\t\t\t<Routes>\n\t\t\t\t\t\t<Route index path='/' Component={PickerDialogContent} />\n\t\t\t\t\t\t<Route path='/umbrelos/:systemTab?' Component={TroubleshootUmbrelOs} />\n\t\t\t\t\t\t<Route path='/app/:appId' Component={TroubleshootApp} />\n\t\t\t\t\t</Routes>\n\t\t\t\t</Suspense>\n\t\t\t</DialogPortal>\n\t\t</ImmersiveDialog>\n\t)\n}\n\nfunction PickerDialogContent() {\n\tconst navigate = useNavigate()\n\tconst [appDialogOpen, setAppDialogOpen] = useState(false)\n\tconst params = useParams<{appId: string}>()\n\n\treturn (\n\t\t<ImmersivePickerDialogContentInit title={t('troubleshoot-pick-title')}>\n\t\t\t<ImmersivePickerItem\n\t\t\t\ttitle={t('umbrelos')}\n\t\t\t\tdescription={t('troubleshoot.umbrelos-description')}\n\t\t\t\tto='/settings/troubleshoot/umbrelos/umbrelos'\n\t\t\t/>\n\t\t\t<ImmersivePickerItem\n\t\t\t\ttitle={t('troubleshoot.app')}\n\t\t\t\tdescription={t('troubleshoot.app-description')}\n\t\t\t\tonClick={() => setAppDialogOpen(true)}\n\t\t\t>\n\t\t\t\t<DropdownMenu open={appDialogOpen} onOpenChange={setAppDialogOpen}>\n\t\t\t\t\t<AppDropdown\n\t\t\t\t\t\topen={appDialogOpen}\n\t\t\t\t\t\tonOpenChange={setAppDialogOpen}\n\t\t\t\t\t\tappId={params.appId}\n\t\t\t\t\t\tsetAppId={(appId) => navigate(`/settings/troubleshoot/app/${appId}`)}\n\t\t\t\t\t/>\n\t\t\t\t</DropdownMenu>\n\t\t\t</ImmersivePickerItem>\n\t\t</ImmersivePickerDialogContentInit>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/troubleshoot/umbrelos.tsx",
    "content": "import {Button} from '@/components/ui/button'\nimport {ImmersiveDialogFooter} from '@/components/ui/immersive-dialog'\nimport {ImmersivePickerDialogContent} from '@/modules/immersive-picker'\nimport {LogResults, SystemLogType, TroubleshootTitleBackLink} from '@/routes/settings/troubleshoot/_shared'\nimport {trpcReact} from '@/trpc/trpc'\nimport {t} from '@/utils/i18n'\n\nexport default function TroubleshootUmbrelOs() {\n\tconst logs = useSystemLogs('system')\n\n\treturn (\n\t\t<ImmersivePickerDialogContent>\n\t\t\t<div className='flex w-full items-center justify-between'>\n\t\t\t\t<TroubleshootTitleBackLink />\n\t\t\t</div>\n\t\t\t<LogResults>{logs}</LogResults>\n\t\t\t<ImmersiveDialogFooter className='justify-center'>\n\t\t\t\t<Button variant='primary' size='dialog' onClick={() => (window.location.href = '/logs')}>\n\t\t\t\t\t{t('troubleshoot.system-download', {label: t('troubleshoot.umbrelos-logs')})}\n\t\t\t\t</Button>\n\t\t\t</ImmersiveDialogFooter>\n\t\t</ImmersivePickerDialogContent>\n\t)\n}\n\nexport function useSystemLogs(type: SystemLogType) {\n\tconst troubleshootQ = trpcReact.system.logs.useQuery({type})\n\n\tif (troubleshootQ.isLoading) return t('loading') + '...'\n\tif (troubleshootQ.isError) return troubleshootQ.error.message\n\n\treturn troubleshootQ.data || t('troubleshoot-no-logs-yet')\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/wifi-unsupported.tsx",
    "content": "import {TbWifi} from 'react-icons/tb'\n\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\nimport {t} from '@/utils/i18n'\n\nexport default function WifiUnsupported() {\n\tconst dialogProps = useSettingsDialogProps()\n\n\treturn (\n\t\t<AlertDialog {...dialogProps}>\n\t\t\t<AlertDialogContent>\n\t\t\t\t<AlertDialogHeader>\n\t\t\t\t\t<AlertDialogTitle>{t('wifi')}</AlertDialogTitle>\n\t\t\t\t</AlertDialogHeader>\n\t\t\t\t<div className='mt-2 flex justify-center'>\n\t\t\t\t\t<Icon />\n\t\t\t\t</div>\n\t\t\t\t<AlertDialogDescription className='text-center'>\n\t\t\t\t\t{t('wifi-unsupported-device-description')}\n\t\t\t\t</AlertDialogDescription>\n\t\t\t\t<AlertDialogFooter>\n\t\t\t\t\t<AlertDialogAction onClick={() => dialogProps.onOpenChange(false)}>{t('ok')}</AlertDialogAction>\n\t\t\t\t</AlertDialogFooter>\n\t\t\t</AlertDialogContent>\n\t\t</AlertDialog>\n\t)\n}\n\nfunction Icon() {\n\treturn (\n\t\t// Stolen from factory reset sidebar icon\n\t\t<div\n\t\t\tclassName='grid h-[67px] w-[67px] place-items-center rounded-15 bg-white/6'\n\t\t\tstyle={{boxShadow: '0 1px 1px #ffffff33 inset'}}\n\t\t>\n\t\t\t<TbWifi className='h-[40px] w-[40px]' />\n\t\t</div>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/settings/wifi.tsx",
    "content": "import {WifiDrawerOrDialog, WifiDrawerOrDialogContent} from '@/modules/wifi/wifi-drawer-or-dialog'\nimport {useSettingsDialogProps} from '@/routes/settings/_components/shared'\n\nexport default function Wifi() {\n\tconst dialogProps = useSettingsDialogProps()\n\n\treturn (\n\t\t<WifiDrawerOrDialog {...dialogProps}>\n\t\t\t<WifiDrawerOrDialogContent />\n\t\t</WifiDrawerOrDialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/routes/whats-new-modal.tsx",
    "content": "import {DialogPortal} from '@radix-ui/react-dialog'\nimport {useEffect, useRef, useState} from 'react'\nimport {TbChevronLeft, TbChevronRight} from 'react-icons/tb'\n\nimport {Button} from '@/components/ui/button'\nimport {Carousel, CarouselContent, CarouselItem, type CarouselApi} from '@/components/ui/carousel'\nimport {\n\tImmersiveDialog,\n\tImmersiveDialogContent,\n\tImmersiveDialogFooter,\n\tImmersiveDialogOverlay,\n\timmersiveDialogTitleClass,\n} from '@/components/ui/immersive-dialog'\nimport {cn} from '@/lib/utils'\nimport {useDialogOpenProps} from '@/utils/dialog'\nimport {t} from '@/utils/i18n'\n\n// Versions and features are hardcoded and we should update them on every release\n\nconst VERSION = 'umbrelOS 1.5'\n\nconst FEATURES = [\n\t{\n\t\tid: 1,\n\t\tvideo: '/assets/whats-new/backups.webm',\n\t\ttitleTKey: 'backups',\n\t\tdescriptionTKey: 'whats-new.feature-1.description',\n\t},\n\t{\n\t\tid: 2,\n\t\tvideo: '/assets/whats-new/rewind.webm',\n\t\ttitleTKey: 'rewind',\n\t\tdescriptionTKey: 'whats-new.feature-2.description',\n\t},\n\t{\n\t\tid: 3,\n\t\tvideo: '/assets/whats-new/restore.webm',\n\t\ttitleTKey: 'backups-restore',\n\t\tdescriptionTKey: 'whats-new.feature-3.description',\n\t},\n\t{\n\t\tid: 4,\n\t\tvideo: '/assets/whats-new/network-devices.webm',\n\t\ttitleTKey: 'whats-new.feature-4.title',\n\t\tdescriptionTKey: 'whats-new.feature-4.description',\n\t},\n\t{\n\t\tid: 5,\n\t\tvideo: '/assets/whats-new/external-storage.webm',\n\t\ttitleTKey: 'whats-new.feature-5.title',\n\t\tdescriptionTKey: 'whats-new.feature-5.description',\n\t\thelperTextTKey: 'whats-new.feature-5.helper-text',\n\t},\n]\n\nfunction DotIndicators({\n\tcurrentIndex,\n\ttotal,\n\tonDotClick,\n\tprogress,\n}: {\n\tcurrentIndex: number\n\ttotal: number\n\tonDotClick: (index: number) => void\n\tprogress: number\n}) {\n\treturn (\n\t\t<div className='flex items-center justify-center gap-1'>\n\t\t\t{Array.from({length: total}).map((_, index) => {\n\t\t\t\tconst isActive = index === currentIndex\n\n\t\t\t\treturn (\n\t\t\t\t\t<button\n\t\t\t\t\t\tkey={index}\n\t\t\t\t\t\tonClick={() => onDotClick(index)}\n\t\t\t\t\t\tclassName='group p-1 transition-opacity hover:opacity-80'\n\t\t\t\t\t\taria-label={`Go to slide ${index + 1}`}\n\t\t\t\t\t\ttabIndex={-1}\n\t\t\t\t\t>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t'relative h-1.5 overflow-hidden rounded-full transition-all duration-300',\n\t\t\t\t\t\t\t\tisActive ? 'w-10 bg-white/40 group-hover:bg-white/60' : 'w-1.5 bg-white/40 group-hover:bg-white/60',\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{isActive && (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tclassName='absolute inset-y-0 left-0 rounded-full bg-white transition-all duration-300 ease-linear'\n\t\t\t\t\t\t\t\t\tstyle={{width: `${progress}%`}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</button>\n\t\t\t\t)\n\t\t\t})}\n\t\t</div>\n\t)\n}\n\nexport function WhatsNewModal() {\n\tconst dialogProps = useDialogOpenProps('whats-new')\n\n\tconst [api, setApi] = useState<CarouselApi>()\n\tconst [currentIndex, setCurrentIndex] = useState(0)\n\tconst [progress, setProgress] = useState(0)\n\tconst videoRefs = useRef<(HTMLVideoElement | null)[]>([])\n\n\tuseEffect(() => {\n\t\tif (!api) return\n\n\t\tsetCurrentIndex(api.selectedScrollSnap())\n\n\t\tapi.on('select', () => {\n\t\t\tsetCurrentIndex(api.selectedScrollSnap())\n\t\t})\n\t}, [api])\n\n\t// Wire up video events for auto-advance and track progress\n\tuseEffect(() => {\n\t\tconst video = videoRefs.current[currentIndex]\n\t\tif (!video) return\n\n\t\t// Pause all other videos first\n\t\tvideoRefs.current.forEach((v, i) => {\n\t\t\tif (v && i !== currentIndex) {\n\t\t\t\tv.pause()\n\t\t\t\tv.currentTime = 0\n\t\t\t}\n\t\t})\n\n\t\t// Reset progress and restart video\n\t\tsetProgress(0)\n\t\tvideo.currentTime = 0\n\t\tvideo.play().catch(() => {\n\t\t\t// Ignore play errors\n\t\t})\n\n\t\tconst handleTimeUpdate = () => {\n\t\t\tif (video.duration > 0) {\n\t\t\t\tsetProgress((video.currentTime / video.duration) * 100)\n\t\t\t}\n\t\t}\n\n\t\tconst handleEnded = () => {\n\t\t\tsetProgress(100)\n\t\t\t// If last slide, loop to first; otherwise advance\n\t\t\tif (currentIndex === FEATURES.length - 1) {\n\t\t\t\tapi?.scrollTo(0)\n\t\t\t} else {\n\t\t\t\tapi?.scrollNext()\n\t\t\t}\n\t\t}\n\n\t\tvideo.addEventListener('timeupdate', handleTimeUpdate)\n\t\tvideo.addEventListener('ended', handleEnded)\n\n\t\treturn () => {\n\t\t\tvideo.removeEventListener('timeupdate', handleTimeUpdate)\n\t\t\tvideo.removeEventListener('ended', handleEnded)\n\t\t}\n\t}, [currentIndex, api])\n\n\t// Pause all videos when dialog closes\n\tuseEffect(() => {\n\t\tif (!dialogProps.open) {\n\t\t\tvideoRefs.current.forEach((v) => {\n\t\t\t\tif (v) {\n\t\t\t\t\tv.pause()\n\t\t\t\t\tv.currentTime = 0\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t}, [dialogProps.open])\n\n\tconst handleNext = () => {\n\t\tif (currentIndex < FEATURES.length - 1) {\n\t\t\tapi?.scrollNext()\n\t\t} else {\n\t\t\tdialogProps.onOpenChange(false)\n\t\t}\n\t}\n\n\tconst handlePrevious = () => {\n\t\tapi?.scrollPrev()\n\t}\n\n\tconst handleDotClick = (index: number) => {\n\t\tapi?.scrollTo(index)\n\t}\n\n\tconst isLastSlide = currentIndex === FEATURES.length - 1\n\tconst canScrollPrev = api?.canScrollPrev() ?? false\n\tconst canScrollNext = api?.canScrollNext() ?? false\n\n\treturn (\n\t\t<ImmersiveDialog {...dialogProps}>\n\t\t\t<DialogPortal>\n\t\t\t\t<ImmersiveDialogOverlay />\n\t\t\t\t<ImmersiveDialogContent size='sm' onInteractOutside={(e) => e.preventDefault()}>\n\t\t\t\t\t{/* Header */}\n\t\t\t\t\t<div className='mb-1 max-md:mt-2 max-md:mb-0'>\n\t\t\t\t\t\t<h1 className={cn(immersiveDialogTitleClass, 'max-md:text-xl')}>\n\t\t\t\t\t\t\t{t('whats-new.title', {version: VERSION})}\n\t\t\t\t\t\t</h1>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Carousel Container */}\n\t\t\t\t\t<div className='relative -mx-4 flex flex-1 flex-col overflow-hidden md:-mx-8'>\n\t\t\t\t\t\t{/* Video Carousel */}\n\t\t\t\t\t\t<Carousel setApi={setApi} className='w-full'>\n\t\t\t\t\t\t\t<CarouselContent className='-ml-0'>\n\t\t\t\t\t\t\t\t{FEATURES.map((feature, index) => (\n\t\t\t\t\t\t\t\t\t<CarouselItem key={feature.id} className='pl-0'>\n\t\t\t\t\t\t\t\t\t\t<div className='relative aspect-[4/3] max-h-[calc(100dvh-440px)] min-h-[200px] w-full overflow-hidden bg-neutral-900'>\n\t\t\t\t\t\t\t\t\t\t\t<video\n\t\t\t\t\t\t\t\t\t\t\t\tref={(el) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\tvideoRefs.current[index] = el\n\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\tsrc={feature.video}\n\t\t\t\t\t\t\t\t\t\t\t\tmuted\n\t\t\t\t\t\t\t\t\t\t\t\tplaysInline\n\t\t\t\t\t\t\t\t\t\t\t\tclassName='size-full object-cover'\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</CarouselItem>\n\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t</CarouselContent>\n\n\t\t\t\t\t\t\t{/* Custom Navigation Arrows */}\n\t\t\t\t\t\t\t{canScrollPrev && (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={handlePrevious}\n\t\t\t\t\t\t\t\t\tclassName='absolute top-1/2 left-4 z-10 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white backdrop-blur-xs transition-all hover:scale-110 hover:bg-black/60 max-sm:hidden md:left-6'\n\t\t\t\t\t\t\t\t\taria-label='Previous slide'\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<TbChevronLeft className='size-6' />\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t)}\n\n\t\t\t\t\t\t\t{canScrollNext && (\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\tonClick={handleNext}\n\t\t\t\t\t\t\t\t\tclassName='absolute top-1/2 right-4 z-10 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white backdrop-blur-xs transition-all hover:scale-110 hover:bg-black/60 max-sm:hidden md:right-6'\n\t\t\t\t\t\t\t\t\taria-label='Next slide'\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<TbChevronRight className='size-6' />\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</Carousel>\n\n\t\t\t\t\t\t{/* Dot Indicators */}\n\t\t\t\t\t\t<div className='mt-5 px-4 md:px-8'>\n\t\t\t\t\t\t\t<DotIndicators\n\t\t\t\t\t\t\t\tcurrentIndex={currentIndex}\n\t\t\t\t\t\t\t\ttotal={FEATURES.length}\n\t\t\t\t\t\t\t\tonDotClick={handleDotClick}\n\t\t\t\t\t\t\t\tprogress={progress}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t{/* Feature Content - updates based on currentIndex */}\n\t\t\t\t\t\t<div className='flex-1 space-y-4 px-4 py-6 md:px-8'>\n\t\t\t\t\t\t\t<div className='space-y-3'>\n\t\t\t\t\t\t\t\t<h3 className='text-2xl font-semibold -tracking-3 md:text-3xl'>\n\t\t\t\t\t\t\t\t\t{t(FEATURES[currentIndex].titleTKey)}\n\t\t\t\t\t\t\t\t</h3>\n\t\t\t\t\t\t\t\t<p className='text-base leading-tight text-white/70'>{t(FEATURES[currentIndex].descriptionTKey)}</p>\n\t\t\t\t\t\t\t\t{FEATURES[currentIndex].helperTextTKey && (\n\t\t\t\t\t\t\t\t\t<p className='text-xs leading-tight text-white/70'>{t(FEATURES[currentIndex].helperTextTKey)}</p>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t{/* Footer */}\n\t\t\t\t\t<ImmersiveDialogFooter className='justify-end'>\n\t\t\t\t\t\t<Button variant='secondary' size='dialog' onClick={handleNext}>\n\t\t\t\t\t\t\t{isLastSlide ? t('whats-new.continue') : t('whats-new.next')}\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t</ImmersiveDialogFooter>\n\t\t\t\t</ImmersiveDialogContent>\n\t\t\t</DialogPortal>\n\t\t</ImmersiveDialog>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/trpc/loading-indicator.tsx",
    "content": "import {Portal} from '@radix-ui/react-portal'\nimport {useIsFetching, useIsMutating} from '@tanstack/react-query'\nimport {TbLoader} from 'react-icons/tb'\n\nexport function LoadingIndicator() {\n\tconst isFetching = useIsFetching()\n\tconst isMutating = useIsMutating()\n\n\tif (!isFetching && !isMutating) {\n\t\treturn null\n\t}\n\n\treturn (\n\t\t<Portal>\n\t\t\t<div className='fixed bottom-1.5 left-1.5 z-50'>\n\t\t\t\t<TbLoader className='white h-3 w-3 animate-spin opacity-50 shadow-xs' />\n\t\t\t</div>\n\t\t</Portal>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/trpc/trpc-provider.tsx",
    "content": "import {QueryClient, QueryClientProvider} from '@tanstack/react-query'\nimport {useState} from 'react'\n\nimport {MS_PER_MINUTE} from '@/utils/date-time'\nimport {IS_DEV} from '@/utils/misc'\n\nimport {LoadingIndicator} from './loading-indicator'\nimport {links, trpcReact} from './trpc'\n\nexport const TrpcProvider: React.FC<{children: React.ReactNode}> = ({children}) => {\n\tconst [queryClient] = useState(\n\t\t() =>\n\t\t\tnew QueryClient({\n\t\t\t\tdefaultOptions: {queries: {staleTime: MS_PER_MINUTE}},\n\t\t\t}),\n\t)\n\n\tconst [trpcClient] = useState(() => trpcReact.createClient({links}))\n\n\treturn (\n\t\t<trpcReact.Provider client={trpcClient} queryClient={queryClient}>\n\t\t\t<QueryClientProvider client={queryClient}>\n\t\t\t\t{children}\n\t\t\t\t{IS_DEV && <LoadingIndicator />}\n\t\t\t</QueryClientProvider>\n\t\t</trpcReact.Provider>\n\t)\n}\n"
  },
  {
    "path": "packages/ui/src/trpc/trpc.ts",
    "content": "import {\n\tcreateTRPCClient,\n\tcreateTRPCReact,\n\tcreateWSClient,\n\thttpLink,\n\tloggerLink,\n\tsplitLink,\n\tTRPCClientErrorLike,\n\twsLink,\n} from '@trpc/react-query'\nimport {inferRouterInputs, inferRouterOutputs} from '@trpc/server'\n\nimport {JWT_LOCAL_STORAGE_KEY} from '@/modules/auth/shared'\nimport {IS_DEV} from '@/utils/misc'\n\nimport {httpOnlyPaths, type AppRouter} from '../../../umbreld/source/modules/server/trpc/common'\n\nconst {protocol, hostname, port} = location\n\n// do not pass colon when port is empty\nconst portPart = port ? `:${port}` : ''\nconst httpOrigin = `${protocol}//${hostname}${portPart}`\n\n// Some browsers now allow http(s):// schemes to be used in the websocket constructor and will silently rewrite to ws(s)://\n// but there are still some browsers, and older versions of now-compatible browsers, that will not do this:\n// https://caniuse.com/mdn-api_websocket_websocket_url_parameter_http_https_relative\n// So we explicitly build the websocket url with the correct scheme to maintain compatibility\nexport const trpcHttpUrl = `${httpOrigin}/trpc`\nconst wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:'\nconst trpcWsUrl = `${wsProtocol}//${hostname}${portPart}/trpc`\n\n// TODO: Getting jwt from `localStorage` like this means auth flow require a page refresh\nconst getJwt = () => localStorage.getItem(JWT_LOCAL_STORAGE_KEY)\n\nconst wsClient = createWSClient({\n\turl: () => {\n\t\tconst token = getJwt()\n\t\treturn token ? `${trpcWsUrl}?token=${token}` : `${trpcWsUrl}`\n\t},\n})\n\nexport const links = [\n\tloggerLink({\n\t\tenabled: () => IS_DEV,\n\t}),\n\t// Split 1: subscriptions vs everything else\n\t// httpLink is request/response only and cannot carry subscriptions, so we\n\t// route ALL subscription operations to WebSocket unconditionally (e.g. `eventBus.listen(files:operation-progress)`)\n\tsplitLink({\n\t\tcondition: (op) => op.type === 'subscription',\n\t\ttrue: wsLink({client: wsClient}),\n\t\t// Split 2: HTTP vs WebSocket for queries/mutations\n\t\t// We route over HTTP when there is no JWT (public/onboarding, no WS auth yet) or the procedure is in `httpOnlyPaths` (needs request/response semantics like cookies/headers)\n\t\t// Otherwise we use WebSocket\n\t\tfalse: splitLink({\n\t\t\tcondition: (operation) => {\n\t\t\t\tconst noToken = !getJwt()\n\t\t\t\tconst isHttpOnlyPath = httpOnlyPaths.includes(operation.path as (typeof httpOnlyPaths)[number])\n\t\t\t\treturn noToken || isHttpOnlyPath\n\t\t\t},\n\t\t\ttrue: httpLink({\n\t\t\t\turl: trpcHttpUrl,\n\t\t\t\theaders: () => {\n\t\t\t\t\tconst token = getJwt()\n\t\t\t\t\treturn token ? {Authorization: `Bearer ${token}`} : {}\n\t\t\t\t},\n\t\t\t}),\n\t\t\tfalse: wsLink({client: wsClient}),\n\t\t}),\n\t}),\n]\n\n// React client\nexport const trpcReact = createTRPCReact<AppRouter>()\n\n// Vanilla client for imperative (non-hook) tRPC calls. HTTP-only so its operation IDs\n// can never collide with the React client's active WebSocket subscriptions.\n/** Use sparingly — prefer trpcReact.useUtils() in React components */\nexport const trpcClient = createTRPCClient<AppRouter>({\n\tlinks: [\n\t\tloggerLink({enabled: () => IS_DEV}),\n\t\thttpLink({\n\t\t\turl: trpcHttpUrl,\n\t\t\theaders: () => {\n\t\t\t\tconst token = getJwt()\n\t\t\t\treturn token ? {Authorization: `Bearer ${token}`} : {}\n\t\t\t},\n\t\t}),\n\t],\n})\n\n// Types ----------------------------\n\nexport type RouterInput = inferRouterInputs<AppRouter>\nexport type RouterOutput = inferRouterOutputs<AppRouter>\nexport type RouterError = TRPCClientErrorLike<AppRouter>\n\n// ---\n\nexport type AppState = RouterOutput['apps']['state']['state']\nexport const appStates = [\n\t'unknown',\n\t'installing',\n\t'starting',\n\t'running',\n\t'stopping',\n\t'stopped',\n\t'restarting',\n\t'uninstalling',\n\t'updating',\n\t'ready',\n] satisfies AppState[]\n\nexport const installStates = ['installing', 'uninstalling', 'updating'] satisfies AppState[]\nexport type InstallState = (typeof installStates)[number]\nexport const installedStates = ['running', 'stopped', 'ready', 'restarting', 'starting'] satisfies AppState[]\nexport type InstalledState = (typeof installedStates)[number]\n\nexport const progressStates = [\n\t// 'not-installed',\n\t'installing',\n\t'starting',\n\t'running',\n\t'stopping',\n\t'restarting',\n\t'uninstalling',\n\t'updating',\n] satisfies AppState[]\n\nexport const progressBarStates = ['installing', 'updating'] satisfies AppState[]\n\n// `loading` means the frontend is currently fetching the state from the backend\nexport type AppStateOrLoading = 'loading' | AppState\n\n// Omitting `active` because we get the connection status from `WifiStatus` since it's more detailed and\n// don't wanna get confused on the frontend with two different ways of getting the connection status\nexport type WifiNetwork = Omit<RouterOutput['wifi']['networks'][number], 'active'>\nexport type WifiStatus = Exclude<RouterOutput['wifi']['connected'], undefined>['status']\n// `loading` is not returned by the backend, but is used in the frontend\nexport type WifiStatusUi = WifiStatus | 'loading'\n\n// ---\n\n/**\n * App in the registry as returned by the backend.\n */\nexport type RegistryApp = RouterOutput['appStore']['registry'][number]['apps'][number]\n\n/**\n * Installed app as returned by the backend, no error.\n */\nexport type UserApp = Exclude<RouterOutput['apps']['list'][number], {error: string}>\n\n/**\n * Installed app as returned by the backend, with error.\n */\nexport type UserAppError = Extract<RouterOutput['apps']['list'][number], {error: string}>\n"
  },
  {
    "path": "packages/ui/src/types.d.ts",
    "content": "// Add types for `import.meta.env`\n/// <reference types=\"vite/client\" />\n\n// https://github.com/lokesh/color-thief/issues/188#issuecomment-1166887824\ndeclare module 'colorthief' {\n\texport type RGBColor = [number, number, number]\n\texport default class ColorThief {\n\t\tgetColor: (\n\t\t\t/** The HTML image element. */\n\t\t\timg: HTMLImageElement | null,\n\t\t\t/** The quality level (default: 10). */\n\t\t\tquality?: number,\n\t\t) => RGBColor | null\n\t\tgetPalette: (\n\t\t\t/** The HTML image element. */\n\t\t\timg: HTMLImageElement | null,\n\t\t\t/** The number of colors in the palette (default: 10). */\n\t\t\tcolorCount?: number,\n\t\t\t/** The quality level (default: 10). */\n\t\t\tquality?: number,\n\t\t) => RGBColor[] | null\n\t}\n}\n\n// Since we import transformed images like ./ai.svg?w=120&format=webp&imagetools\n// this declaration allows TypeScript to correctly recognize them as modules providing a\n// string source URL.\ndeclare module '*&imagetools' {\n\t/**\n\t * actual types\n\t * - code https://github.com/JonasKruckenberg/imagetools/blob/main/packages/core/src/output-formats.ts\n\t * - docs https://github.com/JonasKruckenberg/imagetools/blob/main/docs/guide/getting-started.md#metadata\n\t */\n\tconst out\n\texport default out\n}\n"
  },
  {
    "path": "packages/ui/src/utils/call-every-interval.ts",
    "content": "import {MS_PER_HOUR} from '@/utils/date-time'\n\nconst REFRESH_INTERVAL: number = MS_PER_HOUR\n\n// Mostly generated with ChatGPT\nexport function callEveryInterval(\n\tlocalStorageKey: string,\n\tfuncToCall: () => void,\n\tinterval: number = REFRESH_INTERVAL,\n) {\n\tfunction shouldCallFunction() {\n\t\tconst lastCalledTimeStr: string | null = localStorage.getItem(localStorageKey)\n\t\tconst now = new Date().getTime()\n\n\t\t// Parse the lastCalledTime to number. If null, lastCalledTime will be 0\n\t\tconst lastCalledTime: number = lastCalledTimeStr ? parseInt(lastCalledTimeStr, 10) : 0\n\n\t\t// Check if more than an hour has passed since the last call\n\t\tif (!lastCalledTimeStr || now - lastCalledTime > interval) {\n\t\t\t// Update the last called time\n\t\t\tlocalStorage.setItem(localStorageKey, now.toString())\n\t\t\t// Call your function\n\t\t\tfuncToCall()\n\t\t}\n\t}\n\n\t// Call this function when the page loads\n\tdocument.addEventListener('DOMContentLoaded', shouldCallFunction)\n\n\t// Optional: Call `myFunction` every hour, regardless of user interaction\n\tsetInterval(() => {\n\t\tshouldCallFunction()\n\t}, interval)\n}\n"
  },
  {
    "path": "packages/ui/src/utils/date-time.ts",
    "content": "import {formatDistanceStrict, Locale} from 'date-fns'\nimport {de, enUS, es, fr, hu, it, ja, ko, nl, pt, tr, uk} from 'date-fns/locale'\n\nimport {UNKNOWN} from '@/constants'\nimport {SupportedLanguageCode} from '@/utils/language'\n\nexport const MS_PER_SECOND: number = 1000\nexport const MS_PER_MINUTE: number = MS_PER_SECOND * 60\nexport const MS_PER_HOUR: number = MS_PER_MINUTE * 60\n\nexport const languageCodeToDateLocale: Record<SupportedLanguageCode, Locale> = {\n\ten: enUS,\n\tde: de,\n\tes: es,\n\tfr: fr,\n\tit: it,\n\thu: hu,\n\tnl: nl,\n\tpt: pt,\n\tuk: uk,\n\ttr: tr,\n\tja: ja,\n\tko: ko,\n}\n\nexport function duration(seconds: number | undefined, languageCode: SupportedLanguageCode) {\n\tif (seconds === undefined) return UNKNOWN()\n\n\t// if duration is more than 7 days, then force\n\t// to show duration in days instead of months\n\tif (seconds > 7 * 24 * 60 * 60) {\n\t\treturn formatDistanceStrict(0, seconds * 1000, {unit: 'day', locale: languageCodeToDateLocale[languageCode]})\n\t}\n\treturn formatDistanceStrict(0, seconds * 1000, {locale: languageCodeToDateLocale[languageCode]})\n}\n"
  },
  {
    "path": "packages/ui/src/utils/dialog.ts",
    "content": "// TODO: move to misc.ts\nimport {useEffect, useState} from 'react'\nimport {type To} from 'react-router-dom'\n\nimport {useQueryParams} from '@/hooks/use-query-params'\nimport {SettingsDialogKey} from '@/routes/settings'\nimport {sleep} from '@/utils/misc'\n\nexport const EXIT_DURATION_MS = 100\n\nexport type GlobalDialogKey = 'logout' | 'live-usage' | 'whats-new'\nexport type AppStoreDialogKey = 'updates' | 'add-community-store' | 'default-credentials' | 'app-settings'\nexport type FilesDialogKey =\n\t| 'files-share-info'\n\t| 'files-empty-trash-confirmation'\n\t| 'files-extension-change-confirmation'\n\t| 'files-permanently-delete-confirmation'\n\t| 'files-external-storage-unsupported'\n\t| 'files-add-network-share'\n\t| 'files-format-drive'\nexport type DialogKey = GlobalDialogKey | AppStoreDialogKey | SettingsDialogKey | FilesDialogKey\n\n// TODO: make dialog query params typesafe\n\n/**\n * For use with dialogs and other Radix elements with an `onOpenChange` prop.\n */\nexport function afterDelayedClose(cb?: () => void) {\n\treturn (open: boolean) => !open && sleep(EXIT_DURATION_MS).then(cb)\n}\n\nexport function useAfterDelayedClose(open: boolean, cb: () => void) {\n\tuseEffect(() => {\n\t\tconst id = setTimeout(() => {\n\t\t\tif (!open) cb()\n\t\t}, EXIT_DURATION_MS)\n\n\t\t// Cancel the timeout if the component unmounts or the `open` prop changes.\n\t\treturn () => clearTimeout(id)\n\t}, [open, cb])\n}\n\n/** Allow controlling dialog from query params */\nexport function useDialogOpenProps(dialogKey: DialogKey) {\n\tconst {params, add, filter} = useQueryParams()\n\tconst [open, setOpen] = useState(false)\n\n\t// Update open state when url is changed from the outside\n\tuseEffect(() => {\n\t\tsetOpen(params.get('dialog') === dialogKey)\n\t}, [params, dialogKey])\n\n\tconst addQueryParam = () => {\n\t\tadd('dialog', dialogKey)\n\t}\n\n\tconst removeQueryParam = async () => {\n\t\tawait sleep(EXIT_DURATION_MS)\n\t\t// Remove `dialog` and all `dialogKey` prefixed search params\n\t\tfilter(([key]) => {\n\t\t\tconst isDialog = key === 'dialog'\n\t\t\tconst dialogParams = key.startsWith(dialogKey)\n\t\t\treturn !(isDialog || dialogParams)\n\t\t})\n\t}\n\n\tconst onOpenChange = (open: boolean) => {\n\t\t// Keeping this here despite `useEffect` to change open state immediately\n\t\tsetOpen(open)\n\t\tif (open) {\n\t\t\taddQueryParam()\n\t\t} else {\n\t\t\tremoveQueryParam()\n\t\t}\n\t}\n\n\treturn {open, onOpenChange}\n}\n\n/** For react router  */\nexport function useLinkToDialog() {\n\tconst {addLinkSearchParams} = useQueryParams()\n\treturn (\n\t\tdialogKey: DialogKey,\n\t\totherParams?: {\n\t\t\t[key: string]: string\n\t\t},\n\t): To => {\n\t\tconst otherParamsModified: {[key: string]: string} = {}\n\t\tif (otherParams) {\n\t\t\tObject.keys(otherParams).forEach((key) => {\n\t\t\t\totherParamsModified[`${dialogKey}-${key}`] = otherParams[key]\n\t\t\t})\n\t\t}\n\t\treturn {\n\t\t\tsearch: addLinkSearchParams({dialog: dialogKey, ...otherParamsModified}),\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/utils/element-classes.ts",
    "content": "import {tw} from '@/utils/tw'\n\nexport const linkClass = tw`transition-colors text-brand-lighter hover:text-brand hover:underline underline-offset-4 decoration-brand/30 outline-hidden focus:underline focus:text-brand`\n\nexport const dialogHeaderCircleButtonClass = tw`rounded-full outline-hidden opacity-30 hover:opacity-40`\n"
  },
  {
    "path": "packages/ui/src/utils/i18n.ts",
    "content": "import i18next from 'i18next'\nimport LanguageDetector from 'i18next-browser-languagedetector'\nimport Backend from 'i18next-http-backend'\nimport {initReactI18next} from 'react-i18next'\n\ni18next\n\t.use(initReactI18next) // passes i18n down to react-i18next\n\t.use(Backend)\n\t.use(LanguageDetector)\n\t.init({\n\t\t// debug: true,\n\t\t// initImmediate: true,\n\t\tbackend: {\n\t\t\tloadPath: '/locales/{{lng}}.json',\n\t\t},\n\t\t// lng: \"fr\",\n\t\t// lng: \"en\", // if you're using a language detector, do not define the lng option\n\t\tfallbackLng: 'en',\n\n\t\tinterpolation: {\n\t\t\tescapeValue: false, // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape\n\t\t},\n\n\t\tdetection: {\n\t\t\torder: ['localStorage'],\n\t\t\tcaches: ['localStorage'],\n\t\t},\n\t\treact: {\n\t\t\t// Prevents basic elements like `<em>`from being rendered through `dangerouslySetInnerHTML`\n\t\t\t// Not just for security, but also want to be able to use markup to swap out even basic elements like `<em>` for `<span>`.\n\t\t\ttransSupportBasicHtmlNodes: false,\n\t\t},\n\t})\n\n// Instead of using `useTranslation` hook, we can use `i18n.t` directly\n// The reason for `useTranslation` is to keep the component updated when the language changes, but we just reload the page anyhow.\nexport const t = i18next.t.bind(i18next)\nexport const maybeT = (key: string | undefined) => (key ? t(key) : t('unknown'))\n"
  },
  {
    "path": "packages/ui/src/utils/language.ts",
    "content": "import {map} from 'remeda'\n\nexport const languages = [\n\t{name: 'English', code: 'en'},\n\t{name: 'Deutsch', code: 'de'},\n\t{name: 'Español', code: 'es'},\n\t{name: 'Français', code: 'fr'},\n\t{name: 'Italiano', code: 'it'},\n\t{name: '한국어', code: 'ko'},\n\t{name: 'Magyar', code: 'hu'},\n\t{name: 'Nederlands', code: 'nl'},\n\t{name: 'Português', code: 'pt'},\n\t{name: 'Українська', code: 'uk'},\n\t{name: 'Türkçe', code: 'tr'},\n\t{name: '日本語', code: 'ja'},\n] as const\n\nexport const supportedLanguageCodes = map(languages, (entry) => entry.code)\n\nexport type SupportedLanguageCode = (typeof supportedLanguageCodes)[number]\n"
  },
  {
    "path": "packages/ui/src/utils/logs.ts",
    "content": "/* eslint-disable */\n// @ts-nocheck\n\nconsole.allLogs = []\n\nfunction pushLog(log: any) {\n\ttry {\n\t\tconsole.allLogs.push(`${new Date().toUTCString()} LOG: `, Array.from(log))\n\t} catch (e) {\n\t\t// ignore\n\t}\n}\n\n/**\n * Redirect all logs to our own thing so they can be downloaded\n * https://stackoverflow.com/a/52142526\n */\nexport function monkeyPatchConsoleLog() {\n\t// default\n\tconsole.defaultLog = console.log.bind(console)\n\tconsole.log = function () {\n\t\t// default &  console.log()\n\t\tconsole.defaultLog.apply(console, arguments)\n\n\t\tpushLog(arguments)\n\t}\n\n\t// error\n\tconsole.defaultError = console.error.bind(console)\n\n\tconsole.error = function () {\n\t\t// default &  console.error()\n\t\tconsole.defaultError.apply(console, arguments)\n\t\tpushLog(arguments)\n\t}\n\n\t// warn\n\t// Not doing ths for now\n\n\t// debug\n\tconsole.defaultDebug = console.debug.bind(console)\n\n\tconsole.debug = function () {\n\t\t// default &  console.debug()\n\t\tconsole.defaultDebug.apply(console, arguments)\n\t\tpushLog(arguments)\n\t}\n}\n\n// https://stackoverflow.com/a/19818659\nexport function downloadLogs() {\n\tlet data = console.allLogs\n\n\tif (!data) {\n\t\tconsole.error('Console.save: No data')\n\t\treturn\n\t}\n\n\tconst filename = 'console.json'\n\n\tif (typeof data === 'object') {\n\t\tdata = JSON.stringify(data)\n\t}\n\n\tconst blob = new Blob([data], {type: 'text/json'}),\n\t\te = document.createEvent('MouseEvents'),\n\t\ta = document.createElement('a')\n\n\ta.download = filename\n\ta.href = window.URL.createObjectURL(blob)\n\ta.dataset.downloadurl = ['text/json', a.download, a.href].join(':')\n\te.initMouseEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null)\n\ta.dispatchEvent(e)\n}\n"
  },
  {
    "path": "packages/ui/src/utils/misc.ts",
    "content": "import {indexBy} from 'remeda'\n\nimport {UserApp} from '@/trpc/trpc'\n\nexport function firstNameFromFullName(name: string) {\n\treturn name.split(' ')[0]\n}\n\nexport function sleep(milliseconds: number) {\n\treturn new Promise((resolve) => setTimeout(resolve, milliseconds))\n}\n\nexport function isNormalNumber(value: number | null | undefined): value is number {\n\tif (value === undefined || value === null) return false\n\treturn value !== Infinity && value !== -Infinity && !isNaN(value)\n}\n\n// https://stackoverflow.com/a/39419171\nexport function assertUnreachable(x: never): never {\n\tthrow new Error(\"Didn't expect to get here, got \" + x)\n}\n\n/**\n * Does what lodash's keyBy does, but returns with better types\n */\nexport function keyBy<T, U extends keyof T>(array: ReadonlyArray<T>, key: U): Record<T[U] & string, T> {\n\treturn indexBy(array, (el) => el[key])\n}\n\n// Not using `url-join` or others because they remove desired slashes after joining. `new URL('?bla=1', 'http://localhost:3001/a/').href` preserves trailing slash to return 'http://localhost:3001/a/?bla=1'\n// The `transmission` app depends on this behavior because the app's full path is `http://localhost:9091/transmission/web/` but when joining a query string, we want it to be `http://localhost:9091/transmission/web/?bla=1`\nexport function urlJoin(base: string, path: string) {\n\treturn new URL(path, base).href\n}\n\n/** `urlJoin` doesn't work when used like so: `urlJoin('foo', 'bar')`, and sometimes we just want basic behavior */\nexport function pathJoin(base: string, path: string) {\n\t// Remove trailing slash from base and leading slash from path\n\treturn base.replace(/\\/$/, '') + '/' + path.replace(/^\\//, '')\n}\n\nexport function appToUrl(app: UserApp) {\n\treturn isOnionPage()\n\t\t? `${location.protocol}//${app.hiddenService}`\n\t\t: `${location.protocol}//${location.hostname}:${app.port}`\n}\n\nexport function appToUrlWithAppPath(app: UserApp) {\n\treturn urlJoin(appToUrl(app), app.path ?? '')\n}\n\nexport function isOnionPage() {\n\treturn window.location.origin.indexOf('.onion') !== -1\n}\n\nexport function preloadImage(url: string): Promise<void> {\n\treturn new Promise((resolve) => {\n\t\tconst img = new Image()\n\t\tconst handleLoad = () => {\n\t\t\timg.removeEventListener('load', handleLoad)\n\t\t\tresolve()\n\t\t}\n\t\timg.addEventListener('load', handleLoad)\n\t\timg.src = url\n\t})\n}\n\n// ---\n\nexport function isWindows() {\n\treturn /Win/i.test(navigator.userAgent)\n}\n\nexport function isLinux() {\n\treturn /Linux/i.test(navigator.userAgent)\n}\n\nexport function isMac() {\n\treturn /Mac/i.test(navigator.userAgent)\n}\n\nexport function platform() {\n\tif (isWindows()) return 'windows'\n\tif (isLinux()) return 'linux'\n\tif (isMac()) return 'mac'\n\treturn 'other'\n}\n\n// NOTE: in Chrome, this can be `true` when emulating a touch device\nexport const IS_ANDROID = /Android/i.test(navigator.userAgent)\n\nexport const IS_DEV = localStorage.getItem('debug') === 'true'\n\nexport function cmdOrCtrl() {\n\treturn isMac() ? '⌘' : 'Ctrl+'\n}\n"
  },
  {
    "path": "packages/ui/src/utils/number.ts",
    "content": "import i18next from 'i18next'\n\nexport function formatNumberI18n({n, showDecimals = true}: {n: number; showDecimals?: boolean}) {\n\treturn new Intl.NumberFormat(i18next.language || 'en-US', {\n\t\tminimumFractionDigits: showDecimals ? 2 : 0,\n\t\tmaximumFractionDigits: showDecimals ? 2 : 0,\n\t}).format(n)\n}\n"
  },
  {
    "path": "packages/ui/src/utils/pretty-bytes.ts",
    "content": "import i18next from 'i18next'\nimport prettyBytes from 'pretty-bytes'\n\nimport {LOADING_DASH} from '@/constants'\n\nexport function maybePrettyBytes(n: number | undefined | null) {\n\tif (n === null) return LOADING_DASH\n\tif (n === undefined) return LOADING_DASH\n\t// TODO: pass in locale\n\treturn prettyBytes(n, {locale: i18next.language})\n}\n"
  },
  {
    "path": "packages/ui/src/utils/search.ts",
    "content": "import Fuse from 'fuse.js'\n\nconst fuseOptions = {\n\t// https://www.fusejs.io/api/options.html\n\tisCaseSensitive: false,\n\tincludeScore: false,\n\tincludeMatches: false,\n\tminMatchCharLength: 2,\n\tshouldSort: true,\n\tfindAllMatches: false,\n\tlocation: 0,\n\tthreshold: 0.3,\n\tdistance: 100,\n\tignoreLocation: false,\n\tuseExtendedSearch: false,\n\tignoreFieldNorm: false,\n\tfieldNormWeight: 1,\n}\n\nexport type SearchKey = {\n\tname: string\n\tweight: number\n}\n\nexport function createSearch<T>(items: T[], keys: SearchKey[]) {\n\tconst fuse = new Fuse<T>(items, {\n\t\t...fuseOptions,\n\t\tkeys,\n\t})\n\treturn (pattern: string, limit = 60) => {\n\t\tconst normalizedPattern = pattern.trim().replace(/\\s+/g, ' ')\n\t\tconst results = fuse.search(normalizedPattern, {limit})\n\t\treturn results.map((result) => result.item)\n\t}\n}\n"
  },
  {
    "path": "packages/ui/src/utils/seconds-to-eta.ts",
    "content": "export function secondsToEta(seconds: number | null | undefined): string {\n\tif (seconds == null || seconds <= 0 || !Number.isFinite(seconds)) {\n\t\treturn '-'\n\t}\n\n\tif (seconds < 60) {\n\t\treturn `${Math.round(seconds)}s`\n\t}\n\n\tif (seconds < 3600) {\n\t\treturn `${Math.round(seconds / 60)}m`\n\t}\n\n\tconst hours = Math.floor(seconds / 3600)\n\tconst minutes = Math.round((seconds % 3600) / 60)\n\treturn `${hours}hr ${minutes}m`\n}\n"
  },
  {
    "path": "packages/ui/src/utils/system.ts",
    "content": "import {RouterOutput} from '@/trpc/trpc'\n\nexport function trpcDiskToLocal(\n\tdata?: RouterOutput['system']['diskUsage'] | RouterOutput['system']['systemDiskUsage'],\n) {\n\tif (data === undefined) return undefined\n\n\tconst used = data?.totalUsed\n\tconst size = data?.size\n\tconst available = !size || !used ? undefined : size - used\n\n\treturn {\n\t\tused,\n\t\tsize,\n\t\tavailable,\n\t}\n}\n\nexport function trpcMemoryToLocal(\n\tdata?: RouterOutput['system']['memoryUsage'] | RouterOutput['system']['systemMemoryUsage'],\n) {\n\tif (data === undefined) return undefined\n\n\tconst used = data?.totalUsed\n\tconst size = data?.size\n\tconst available = !size || !used ? undefined : size - used\n\treturn {\n\t\tused,\n\t\tsize,\n\t\tavailable,\n\t}\n}\n\nexport function isTrpcDiskFull(data?: RouterOutput['system']['systemDiskUsage']) {\n\treturn isDiskFull(trpcDiskToLocal(data)?.available)\n}\n\nexport function isTrpcDiskLow(data?: RouterOutput['system']['systemDiskUsage']) {\n\treturn isDiskLow(trpcDiskToLocal(data)?.available)\n}\n\nexport function isTrpcMemoryLow(data?: RouterOutput['system']['systemMemoryUsage']) {\n\treturn isMemoryLow({size: data?.size, used: data?.totalUsed})\n}\n\n// ---\n\nexport function isDiskLow(remaining?: number) {\n\tif (remaining === undefined) return false\n\t// Return false because we don't want to show the warning if the disk is full\n\tif (isDiskFull(remaining)) return false\n\t// less than 1GB remaining\n\treturn remaining < 1000000000\n}\n\nexport function isDiskFull(remaining?: number) {\n\tif (remaining === undefined) return false\n\t// Less than 100mb remaining\n\treturn remaining < 100000000\n}\n\nexport function isCpuTooHot(warning?: string) {\n\tif (warning === undefined) return false\n\treturn warning === 'hot'\n}\n\nexport function isMemoryLow({size, used}: {size?: number; used?: number}) {\n\tif (size === undefined || used === undefined) return false\n\treturn used / size > 0.95\n}\n"
  },
  {
    "path": "packages/ui/src/utils/temperature.ts",
    "content": "import {LOADING_DASH} from '@/constants'\nimport {t} from '@/utils/i18n'\n\nexport function celciusToFahrenheit(temperatureInCelcius?: number) {\n\tif (temperatureInCelcius === undefined) return undefined\n\treturn Math.round((temperatureInCelcius * 9) / 5 + 32)\n}\n\n/** Format temperature with unit label (e.g., \"45°C\" or \"113°F\") */\nexport function formatTemperature(tempCelcius: number | undefined, unit: 'c' | 'f'): string {\n\tif (tempCelcius === undefined) return '--'\n\tconst temp = unit === 'f' ? celciusToFahrenheit(tempCelcius) : tempCelcius\n\tconst label = unit === 'c' ? '°C' : '°F'\n\treturn `${temp}${label}`\n}\n\nexport function temperatureWarningToColor(warning?: string) {\n\tif (warning === undefined) return '#CCCCCC'\n\n\tif (warning === 'warm') {\n\t\treturn '#E6E953'\n\t}\n\tif (warning === 'hot') {\n\t\treturn '#F45252'\n\t}\n\treturn '#96F16B'\n}\n\nexport function temperatureWarningToMessage(warning?: string) {\n\tif (warning === undefined) return LOADING_DASH\n\n\tif (warning === 'normal') {\n\t\treturn t('temperature.normal')\n\t}\n\tif (warning === 'warm') {\n\t\treturn t('temperature.warm')\n\t}\n\tif (warning === 'hot') {\n\t\treturn t('temperature.dangerously-hot')\n\t}\n\treturn t('temperature.normal')\n}\n"
  },
  {
    "path": "packages/ui/src/utils/tw.ts",
    "content": "import {createBreakpoint} from 'react-use'\n\n/** Pairs with `.vscode/settings.json` to provide intellisense for tailwind classes:\n * ```json\n * \"tailwindCSS.experimental.classRegex\": [\n *     \"tw`([^`]*)`\"\n * ]\n * ```\n */\nexport const tw = (strings: TemplateStringsArray) => strings.join('')\n\nexport const screens = {\n\tsm: 640,\n\tmd: 768,\n\tlg: 1024,\n\txl: 1280,\n\t'2xl': 1400,\n}\n\nexport const useBreakpoint = createBreakpoint(screens)\n"
  },
  {
    "path": "packages/ui/src/utils/wifi.ts",
    "content": "export function signalToBars(signal: number) {\n\tconst bars = Math.ceil(signal / 25)\n\treturn bars\n}\n"
  },
  {
    "path": "packages/ui/tailwind.config.ts",
    "content": "import tailwindTypography from '@tailwindcss/typography'\nimport {mapValues} from 'remeda'\n\nimport {screens} from './src/utils/tw'\n\n// Default system font stacks (previously from tailwindcss/defaultTheme, which no longer exists in v4)\nconst defaultSansFonts = [\n\t'ui-sans-serif',\n\t'system-ui',\n\t'sans-serif',\n\t'\"Apple Color Emoji\"',\n\t'\"Segoe UI Emoji\"',\n\t'\"Segoe UI Symbol\"',\n\t'\"Noto Color Emoji\"',\n]\nconst defaultMonoFonts = [\n\t'ui-monospace',\n\t'SFMono-Regular',\n\t'Menlo',\n\t'Monaco',\n\t'Consolas',\n\t'\"Liberation Mono\"',\n\t'\"Courier New\"',\n\t'monospace',\n]\n\nexport default {\n\ttheme: {\n\t\tfontFamily: {\n\t\t\tsans: ['var(--font-inter)', ...defaultSansFonts],\n\t\t\tmono: ['Roboto', ...defaultMonoFonts],\n\t\t\tsticker: ['\"Permanent Marker\"', 'cursive'],\n\t\t},\n\t\tcontainer: {\n\t\t\tcenter: true,\n\t\t\tpadding: '2rem',\n\t\t},\n\t\tscreens: mapValues(screens, (value) => value + 'px'),\n\t\textend: {\n\t\t\tflexShrink: {\n\t\t\t\t// Used if you want to shrink the item totally if no room\n\t\t\t\tfull: '9999',\n\t\t\t},\n\t\t\tborderRadius: {\n\t\t\t\t// Using numbers instead of sm, md, lg because easier to add radii in between later\n\t\t\t\t3: '3px',\n\t\t\t\t4: '4px',\n\t\t\t\t5: '5px',\n\t\t\t\t6: '6px',\n\t\t\t\t8: '8px',\n\t\t\t\t10: '10px',\n\t\t\t\t12: '12px',\n\t\t\t\t15: '15px',\n\t\t\t\t17: '17px',\n\t\t\t\t20: '20px',\n\t\t\t\t24: '24px',\n\t\t\t},\n\t\t\tcolors: {\n\t\t\t\t// Extracted from background\n\t\t\t\tbrand: 'hsl(var(--color-brand) / <alpha-value>)',\n\t\t\t\t'brand-lighter': 'hsl(var(--color-brand-lighter) / <alpha-value>)',\n\t\t\t\t'brand-lightest': 'hsl(var(--color-brand-lightest) / <alpha-value>)',\n\t\t\t\t//\n\t\t\t\tdestructive: '#E03E3E',\n\t\t\t\tdestructive2: '#E22C2C',\n\t\t\t\t'destructive2-lighter': '#F53737',\n\t\t\t\t'destructive2-lightest': '#F45A5A',\n\t\t\t\tsuccess: '#299E16',\n\t\t\t\t'success-light': '#51CB41',\n\t\t\t\t'dialog-content': '#1E1E1E',\n\t\t\t},\n\t\t\tborderWidth: {\n\t\t\t\tpx: '1px',\n\t\t\t\thpx: '0.5px',\n\t\t\t},\n\t\t\tboxShadow: {\n\t\t\t\tdock: '1.06058px 0px 0px 0px rgba(255, 255, 255, 0.04) inset, -1.06058px 0px 0px 0px rgba(255, 255, 255, 0.04) inset, 0px 1.06058px 0px 0px rgba(255, 255, 255, 0.20) inset, 0px 0.53029px 0px 0px rgba(255, 255, 255, 0.10) inset, 0px 4.04029px 24.24174px 0px rgba(0, 0, 0, 0.56)',\n\t\t\t\t'floating-island':\n\t\t\t\t\t'1.06058px 0px 0px 0px rgba(255, 255, 255, 0.04) inset, -1.06058px 0px 0px 0px rgba(255, 255, 255, 0.04) inset, 0px 1.06058px 0px 0px rgba(255, 255, 255, 0.14) inset, 0px 0.53029px 0px 0px rgba(255, 255, 255, 0.07) inset, 0px 4.04029px 24.24174px 0px rgba(0, 0, 0, 0.56)',\n\t\t\t\t'glass-button':\n\t\t\t\t\t'1px 0px 0px 0px rgba(255, 255, 255, 0.04) inset, -1px 0px 0px 0px rgba(255, 255, 255, 0.04) inset, 0px 1px 0px 0px rgba(255, 255, 255, 0.20) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.10) inset',\n\t\t\t\twidget:\n\t\t\t\t\t'0px 20px 30px 0px rgba(0, 0, 0, 0.30), 0 1px 0 0 rgba(255, 255, 255, 0.2) inset, 1px 0 0 0 rgba(255, 255, 255, 0.04) inset, -1px 0 0 0 rgba(255, 255, 255, 0.04) inset',\n\t\t\t\t'context-menu':\n\t\t\t\t\t'1.05px 0px 0px 0px rgba(255, 255, 255, 0.04) inset, -1.05px 0px 0px 0px rgba(255, 255, 255, 0.04) inset, 0px 0.525px 0px 0px rgba(255, 255, 255, 0.10) inset, 0px 24px 36px 0px rgba(0, 0, 0, 0.50)',\n\t\t\t\t'sheet-shadow': '2px 2px 2px 0px rgba(255, 255, 255, 0.05) inset',\n\t\t\t\tdropdown:\n\t\t\t\t\t'0px 60px 24px -40px rgba(0, 0, 0, 0.25), 1px 1px 0px 0px rgba(255, 255, 255, 0.08) inset, -1px -1px 1px 0px rgba(0, 0, 0, 0.20) inset',\n\t\t\t\tdialog: '0px 20px 36px 0px rgba(0, 0, 0, 0.25), 0px 1px 1px 0px rgba(255, 255, 255, 0.1) inset',\n\t\t\t\t'button-highlight': '0px 1px 0px 0px rgba(255, 255, 255, 0.3) inset',\n\t\t\t\t'button-highlight-hpx': '0px 0.5px 0px 0px rgba(255, 255, 255, 0.3) inset',\n\t\t\t\t'button-highlight-soft': '0px 1px 0px 0px rgba(255, 255, 255, 0.1) inset',\n\t\t\t\t'button-highlight-soft-hpx': '0px 0.5px 0px 0px rgba(255, 255, 255, 0.1) inset',\n\t\t\t\t'immersive-dialog-close':\n\t\t\t\t\t'0px 32px 32px 0px rgba(0, 0, 0, 0.32), 1px 1px 1px 0px rgba(255, 255, 255, 0.08) inset',\n\t\t\t\t'radio-outline': '0 0 0 1px rgba(255, 255, 255, 0.2) inset',\n\t\t\t},\n\t\t\tdropShadow: {\n\t\t\t\t'desktop-label': '0px 2px 4px rgba(0, 0, 0, 0.60)',\n\t\t\t},\n\t\t\topacity: {\n\t\t\t\t3: '0.03',\n\t\t\t\t4: '0.04',\n\t\t\t\t6: '0.06',\n\t\t\t},\n\t\t\tfontSize: {\n\t\t\t\t9: '9px',\n\t\t\t\t11: '11px',\n\t\t\t\t12: '12px',\n\t\t\t\t13: '13px',\n\t\t\t\t14: '14px',\n\t\t\t\t15: '15px',\n\t\t\t\t16: '16px',\n\t\t\t\t17: '17px',\n\t\t\t\t19: '19px',\n\t\t\t\t24: '24px',\n\t\t\t\t32: '32px',\n\t\t\t\t36: '36px',\n\t\t\t\t48: '48px',\n\t\t\t\t56: '56px',\n\t\t\t},\n\t\t\tbackdropBlur: {\n\t\t\t\t'4xl': '180px',\n\t\t\t},\n\t\t\tlineHeight: {\n\t\t\t\t'inter-trimmed': '0.73',\n\t\t\t},\n\t\t\tlineClamp: {\n\t\t\t\t10: '10',\n\t\t\t},\n\t\t\tletterSpacing: {\n\t\t\t\t'1': '0.01em',\n\t\t\t\t'2': '0.02em',\n\t\t\t\t'3': '0.03em',\n\t\t\t\t'4': '0.04em',\n\t\t\t},\n\t\t\tringWidth: {\n\t\t\t\t3: '3px',\n\t\t\t\t6: '6px',\n\t\t\t},\n\t\t\taspectRatio: {\n\t\t\t\t'2.25': '225 / 100',\n\t\t\t\t'1.6': '160 / 100',\n\t\t\t\t'1.9': '190 / 100',\n\t\t\t},\n\t\t\tkeyframes: {\n\t\t\t\t'accordion-down': {\n\t\t\t\t\tfrom: {height: '0px'},\n\t\t\t\t\tto: {height: 'var(--radix-accordion-content-height)'},\n\t\t\t\t},\n\t\t\t\t'accordion-up': {\n\t\t\t\t\tfrom: {height: 'var(--radix-accordion-content-height)'},\n\t\t\t\t\tto: {height: '0px'},\n\t\t\t\t},\n\t\t\t\t// Keyframes from:\n\t\t\t\t// https://css-tricks.com/snippets/css/shake-css-keyframe-animation/\n\t\t\t\tshake: {\n\t\t\t\t\t'10%, 90%': {\n\t\t\t\t\t\ttransform: 'translate3d(-1px, 0, 0)',\n\t\t\t\t\t},\n\t\t\t\t\t'20%, 80%': {\n\t\t\t\t\t\ttransform: 'translate3d(2px, 0, 0)',\n\t\t\t\t\t},\n\t\t\t\t\t'30%, 50%, 70%': {\n\t\t\t\t\t\ttransform: 'translate3d(-4px, 0, 0)',\n\t\t\t\t\t},\n\t\t\t\t\t'40%, 60%': {\n\t\t\t\t\t\ttransform: 'translate3d(4px, 0, 0)',\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t'sliding-loader': {\n\t\t\t\t\t'0%, 100%': {\n\t\t\t\t\t\tleft: '-30%',\n\t\t\t\t\t},\n\t\t\t\t\t'50%': {\n\t\t\t\t\t\tleft: '100%',\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t'files-drop-zone-ripple': {\n\t\t\t\t\t'0%, 100%': {\n\t\t\t\t\t\ttransform: 'translate(-50%, -50%) scale(1)',\n\t\t\t\t\t},\n\t\t\t\t\t'50%': {\n\t\t\t\t\t\ttransform: 'translate(-50%, -50%) scale(0.9)',\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t'files-folder-blink-on-drag-hover': {\n\t\t\t\t\t'0%, 100%': {backgroundColor: 'hsl(var(--color-brand))'},\n\t\t\t\t\t'25%, 75%': {backgroundColor: 'transparent'},\n\t\t\t\t\t'50%': {backgroundColor: 'hsl(var(--color-brand))'},\n\t\t\t\t},\n\t\t\t},\n\t\t\tanimation: {\n\t\t\t\t'files-drop-zone-ripple': 'files-drop-zone-ripple var(--duration,2s) ease calc(var(--i, 0)*.2s) infinite',\n\t\t\t\t'accordion-down': 'accordion-down 0.2s ease-out',\n\t\t\t\t'accordion-up': 'accordion-up 0.2s ease-out',\n\t\t\t\tshake: 'shake 0.7s ease-out both',\n\t\t\t\t'sliding-loader': 'sliding-loader 1s ease infinite',\n\t\t\t\t'files-folder-blink-on-drag-hover': 'files-folder-blink-on-drag-hover 0.4s linear',\n\t\t\t},\n\t\t\ttypography: () => ({\n\t\t\t\tneutral: {\n\t\t\t\t\tcss: {\n\t\t\t\t\t\t'--tw-prose-invert-bullets': 'rgb(255 255 255 / 50%)',\n\t\t\t\t\t\t'--tw-prose-invert-pre-bg': 'rgb(255 255 255 / 10%)',\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}),\n\t\t},\n\t},\n\tplugins: [\n\t\ttailwindTypography,\n\t\tfunction utilPlugin({addUtilities}: {addUtilities: (utilities: Record<string, Record<string, string>>) => void}) {\n\t\t\taddUtilities({\n\t\t\t\t'.absolute-center': {\n\t\t\t\t\tposition: 'absolute',\n\t\t\t\t\tleft: '50%',\n\t\t\t\t\ttop: '50%',\n\t\t\t\t\ttransform: 'translate(-50%, -50%)',\n\t\t\t\t},\n\t\t\t})\n\t\t},\n\t],\n}\n"
  },
  {
    "path": "packages/ui/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"strict\": true,\n\t\t\"target\": \"ES2020\",\n\t\t\"useDefineForClassFields\": true,\n\t\t\"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n\t\t\"module\": \"ESNext\",\n\t\t\"skipLibCheck\": true,\n\n\t\t/* Bundler mode */\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"allowImportingTsExtensions\": true,\n\t\t\"resolveJsonModule\": true,\n\t\t\"isolatedModules\": true,\n\t\t\"noEmit\": true,\n\t\t\"jsx\": \"react-jsx\",\n\n\t\t/* Import from root */\n\t\t\"baseUrl\": \".\",\n\t\t\"paths\": {\n\t\t\t\"@/*\": [\"src/*\"],\n\t\t\t// TODO: Remove once monorepo uses npm workspaces. Without workspaces, npm installs\n\t\t\t// separate @trpc/server copies in ui/ and umbreld/, causing TypeScript to treat\n\t\t\t// identical types as incompatible. This forces resolution to ui's copy.\n\t\t\t\"@trpc/server\": [\"./node_modules/@trpc/server\"],\n\t\t\t\"@trpc/server/*\": [\"./node_modules/@trpc/server/*\"]\n\t\t},\n\t\t\"plugins\": [\n\t\t\t{\n\t\t\t\t\"name\": \"ts-plugin-sort-import-suggestions\",\n\t\t\t\t// Matches `@/`, `../` and `./`, move them up in the suggestions (This is the default config if you leave it empty)\n\t\t\t\t\"moveUpPatterns\": [\"@/\", \"\\\\.{1,2}/\"],\n\t\t\t\t// Move `dist` down in the suggestions, by deafult it's `[]`\n\t\t\t\t\"moveDownPatterns\": [\"dist\"]\n\t\t\t}\n\t\t]\n\t},\n\t\"include\": [\"src\", \"app-auth\", \"tailwind.config.ts\"],\n\t/* Exclude parent */\n\t// \"exclude\": [\"../\"],\n\t\"references\": [{\"path\": \"./tsconfig.node.json\"}]\n}\n"
  },
  {
    "path": "packages/ui/tsconfig.node.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"composite\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"module\": \"ESNext\",\n\t\t\"moduleResolution\": \"bundler\",\n\t\t\"allowSyntheticDefaultImports\": true\n\t},\n\t\"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "packages/ui/update-translations.js",
    "content": "import {execSync} from 'child_process'\nimport fs from 'fs'\nimport path from 'path'\nimport process from 'process'\nimport fg from 'fast-glob'\nimport OpenAI from 'openai'\n\nconst exampleTranslationKeys = [\n\t'account-description',\n\t'wallpaper-description',\n\t'2fa-description',\n\t'tor-description',\n\t'migration-assistant',\n\t'migration-assistant-description',\n\t'language',\n\t'language-description',\n\t'app-store.description',\n\t'migrate',\n\t'change-name',\n\t'change-password',\n\t'onboarding.account-created.youre-all-set-name',\n]\n\nconst localesDirectory = path.join(process.cwd(), 'public', 'locales')\nconst englishReferenceFilePath = path.join(localesDirectory, 'en.json')\n\nconst languageMapping = {\n\ten: 'English',\n\tde: 'German',\n\tes: 'Spanish',\n\tfr: 'French',\n\tit: 'Italian',\n\tja: 'Japanese',\n\tnl: 'Dutch',\n\tpt: 'Portuguese',\n\ttr: 'Turkish',\n\tuk: 'Ukrainian',\n\thu: 'Hungarian',\n\tko: 'Korean',\n}\n\n// Get en.json content from the base branch\nfunction getBaseEnglishContent(baseBranch) {\n\ttry {\n\t\t// We're running from packages/ui directory, but git show needs path from repo root\n\t\tconst baseEnJsonPath = 'packages/ui/public/locales/en.json'\n\n\t\tconst result = execSync(`git show ${baseBranch}:${baseEnJsonPath}`, {encoding: 'utf8'})\n\t\treturn JSON.parse(result)\n\t} catch (error) {\n\t\tconsole.log(`Could not get base en.json from branch ${baseBranch}: ${error.message}`)\n\t\treturn null\n\t}\n}\n\n// Get modified keys that were actually changed in this PR\nfunction getModifiedKeys(currentContent, baseBranch) {\n\tconst modifiedKeys = {}\n\n\ttry {\n\t\t// Get the merge base between HEAD and the base branch\n\t\t// This is the common ancestor, which represents where the PR branch diverged\n\t\tconst mergeBase = execSync(`git merge-base HEAD origin/${baseBranch}`, {encoding: 'utf8'}).trim()\n\n\t\t// Get the en.json content from the merge base\n\t\tconst baseEnJsonPath = 'packages/ui/public/locales/en.json'\n\t\tconst result = execSync(`git show ${mergeBase}:${baseEnJsonPath}`, {encoding: 'utf8'})\n\t\tconst mergeBaseContent = JSON.parse(result)\n\n\t\tconsole.log(`Comparing against merge base: ${mergeBase}`)\n\n\t\t// Find added or modified keys compared to the merge base\n\t\tfor (const [key, value] of Object.entries(currentContent)) {\n\t\t\tif (!(key in mergeBaseContent) || mergeBaseContent[key] !== value) {\n\t\t\t\tmodifiedKeys[key] = value\n\t\t\t}\n\t\t}\n\t} catch (error) {\n\t\tconsole.log(`Error getting merge base content: ${error.message}`)\n\t\tconsole.log('Falling back to comparing against current base branch')\n\n\t\t// Fallback: get current base branch content\n\t\tconst baseContent = getBaseEnglishContent(baseBranch)\n\t\tfor (const [key, value] of Object.entries(currentContent)) {\n\t\t\tif (!baseContent || !(key in baseContent) || baseContent[key] !== value) {\n\t\t\t\tmodifiedKeys[key] = value\n\t\t\t}\n\t\t}\n\t}\n\n\treturn modifiedKeys\n}\n\n// Get the last commit that modified a specific key in a file within the PR\n// Returns commit hash or null\nfunction getLastCommitForKey(filePath, key, baseBranch) {\n\ttry {\n\t\t// Escape special regex characters in the key\n\t\tconst escapedKey = key.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n\n\t\t// filePath is already relative to current directory (packages/ui)\n\t\t// Git needs it relative to current directory, not repo root\n\t\tconst gitFilePath = filePath\n\n\t\t// Get all commits in the PR that modified this key\n\t\t// Use three dots (...) to include commits from the PR branch, not just direct ancestry\n\t\t// This is important because GitHub Actions creates merge commits\n\t\tconst gitCommand = `git log --pretty=format:'%H' -G'\"${escapedKey}\"' ${baseBranch}...HEAD -- ${gitFilePath}`\n\t\tconst result = execSync(gitCommand, {encoding: 'utf8'}).trim()\n\n\t\tif (!result) {\n\t\t\treturn null\n\t\t}\n\n\t\t// Return the most recent commit (first in the list)\n\t\tconst commits = result.split('\\n')\n\t\treturn commits[0]\n\t} catch {\n\t\treturn null\n\t}\n}\n\n// Check if a key needs regeneration\n// Returns true if the key should be regenerated\nfunction shouldRegenerateKey(key, localeFile, baseBranch) {\n\tconst enFilePath = 'public/locales/en.json'\n\tconst localeFilePath = path.join('public/locales', localeFile)\n\n\t// Find when the key was last modified in en.json in this PR\n\tconst enCommit = getLastCommitForKey(enFilePath, key, baseBranch)\n\n\tif (!enCommit) {\n\t\t// Key wasn't modified in en.json in this PR, but it's in modifiedKeys\n\t\t// This means it exists in current but not in base (new key)\n\t\t// Check if it was already translated in this PR\n\t\tconst localeCommit = getLastCommitForKey(localeFilePath, key, baseBranch)\n\t\treturn !localeCommit // Regenerate if not translated\n\t}\n\n\t// Find when the key was last modified in the locale file in this PR\n\tconst localeCommit = getLastCommitForKey(localeFilePath, key, baseBranch)\n\n\tif (!localeCommit) {\n\t\t// Key was modified in en.json but not in locale file - needs regeneration\n\t\treturn true\n\t}\n\n\t// Both were modified - check which commit came first\n\t// If locale commit came after en commit, skip regeneration\n\ttry {\n\t\t// Check if en commit is an ancestor of locale commit\n\t\t// If yes, locale came after en\n\t\texecSync(`git merge-base --is-ancestor ${enCommit} ${localeCommit}`, {encoding: 'utf8'})\n\t\t// Locale came after en - skip regeneration\n\t\treturn false\n\t} catch {\n\t\t// En is not ancestor of locale, so en came after - needs regeneration\n\t\treturn true\n\t}\n}\n\n// Get keys that need regeneration for a specific locale\nfunction getKeysNeedingRegeneration(modifiedEnKeys, localeFile, baseBranch) {\n\tconst keysNeedingRegeneration = {}\n\n\tfor (const [key, value] of Object.entries(modifiedEnKeys)) {\n\t\tif (shouldRegenerateKey(key, localeFile, baseBranch)) {\n\t\t\tkeysNeedingRegeneration[key] = value\n\t\t}\n\t}\n\n\treturn keysNeedingRegeneration\n}\n\n// Generates translations\nasync function generateTranslation(englishReferenceContent, textToTranslate, targetLanguage, existingLanguageContent) {\n\tconst openai = new OpenAI({apiKey: process.env.TRANSLATIONS_OPENAI_API_KEY})\n\tconst model = process.env.TRANSLATIONS_OPENAI_MODEL\n\tconst systemPromptTemplate = process.env.TRANSLATIONS_SYSTEM_PROMPT\n\tconst userPromptTemplate = process.env.TRANSLATIONS_USER_PROMPT\n\n\tconst inputExample = exampleTranslationKeys.map((key) => `'${key}': '${englishReferenceContent[key]}'`).join(', ')\n\tconst outputExample = exampleTranslationKeys.map((key) => `'${key}': '${existingLanguageContent[key]}'`).join(', ')\n\tconst systemPrompt = systemPromptTemplate\n\t\t.replace('replace_input_example', inputExample)\n\t\t.replace('replace_output_example', outputExample)\n\t\t.replace('replace_target_language', languageMapping[targetLanguage])\n\tconst userPrompt = userPromptTemplate\n\t\t.replace('replace_target_language', languageMapping[targetLanguage])\n\t\t.replace('replace_target_language', languageMapping[targetLanguage])\n\t\t.replace('replace_text_to_translate', JSON.stringify(textToTranslate))\n\n\tconst completion = await openai.chat.completions.create({\n\t\tmessages: [\n\t\t\t{role: 'system', content: systemPrompt},\n\t\t\t{role: 'user', content: userPrompt},\n\t\t],\n\t\tmodel,\n\t\tresponse_format: {type: 'json_object'},\n\t})\n\n\treturn completion.choices[0].message.content\n}\n\nasync function removeUnusedTranslations(englishReferenceContent) {\n\tconst tsxFiles = await fg(['src/**/*.tsx', 'src/**/*.ts', 'app-auth/src/**/*.tsx', 'app-auth/src/**/*.ts'])\n\tconst unusedKeys = []\n\n\tfor (const key in englishReferenceContent) {\n\t\tlet isKeyUsed = false\n\t\tfor (const file of tsxFiles) {\n\t\t\tconst content = fs.readFileSync(file, 'utf8')\n\t\t\t// Checks for t('key'), i18nKey='key', t(\"key\"), i18nKey=\"key\"\n\t\t\tlet keyToTest = key\n\n\t\t\t// https://www.i18next.com/translation-function/plurals\n\t\t\tif (key.endsWith('_one')) {\n\t\t\t\tkeyToTest = key.slice(0, -4)\n\t\t\t} else if (key.endsWith('_other')) {\n\t\t\t\tkeyToTest = key.slice(0, -6)\n\t\t\t}\n\t\t\tconst keyPatterns = [\n\t\t\t\t`t('${keyToTest}'`,\n\t\t\t\t`i18nKey='${keyToTest}'`,\n\t\t\t\t`t(\"${keyToTest}\"`,\n\t\t\t\t`i18nKey=\"${keyToTest}\"`,\n\t\t\t\t`TKey: '${keyToTest}'`,\n\t\t\t]\n\t\t\tif (keyPatterns.some((pattern) => content.includes(pattern))) {\n\t\t\t\tisKeyUsed = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif (!isKeyUsed) {\n\t\t\tunusedKeys.push(key)\n\t\t}\n\t}\n\t// Delete unused keys from englishReferenceContent\n\tfor (const key of unusedKeys) {\n\t\tconsole.log(`Removing unused translation key: '${key}'`)\n\t\tdelete englishReferenceContent[key]\n\t}\n\t// Save the updated englishReferenceContent to file\n\tfs.writeFileSync(englishReferenceFilePath, JSON.stringify(englishReferenceContent, null, 2), 'utf8')\n}\n\n// Check for missing or redundant translations in locale files and generates them if needed\nasync function generateAndWriteTranslations(\n\tenglishReferenceContent,\n\tlocaleFile,\n\tmodifiedEnKeys = null,\n\tbaseBranch = null,\n) {\n\tconst localeFilePath = path.join(localesDirectory, localeFile)\n\tlet localeFileContent = JSON.parse(fs.readFileSync(localeFilePath, 'utf8'))\n\tconst targetLanguage = localeFile.split('.')[0]\n\n\t// Remove keys in which no longer exist in en.json\n\tlocaleFileContent = Object.fromEntries(\n\t\tObject.entries(localeFileContent).filter(([key]) => key in englishReferenceContent),\n\t)\n\n\t// Check for variable mismatches between English and translated content for each key-value pair\n\tfor (const [key, value] of Object.entries(englishReferenceContent)) {\n\t\tconst variableRegex = /{{\\w+}}/g\n\t\tconst englishVariables = value.match(variableRegex)\n\t\tconst translatedValue = localeFileContent[key]\n\t\tif (translatedValue) {\n\t\t\tconst translatedVariables = translatedValue.match(variableRegex)\n\t\t\tif (englishVariables && (!translatedVariables || englishVariables.length !== translatedVariables.length)) {\n\t\t\t\tconsole.log(`'${key}' in file '${localeFile}' is missing variable(s), deleting it to regenerate...`)\n\t\t\t\tdelete localeFileContent[key]\n\t\t\t}\n\t\t}\n\t}\n\n\t// Only regenerate keys that were modified in en.json and not yet updated\n\tif (modifiedEnKeys !== null) {\n\t\tconst keysToRegenerate = getKeysNeedingRegeneration(modifiedEnKeys, localeFile, baseBranch)\n\n\t\t// Only delete existing translations for keys that need regeneration\n\t\tfor (const key of Object.keys(keysToRegenerate)) {\n\t\t\tif (key in localeFileContent) {\n\t\t\t\tdelete localeFileContent[key]\n\t\t\t}\n\t\t}\n\t}\n\n\t// Get missing translations\n\tconst missingTranslations = Object.keys(englishReferenceContent).reduce((missing, key) => {\n\t\tif (!Object.prototype.hasOwnProperty.call(localeFileContent, key)) {\n\t\t\t// console.log(`Missing translation for key '${key}' in file '${localeFile}'`)\n\t\t\tmissing[key] = englishReferenceContent[key]\n\t\t}\n\t\treturn missing\n\t}, {})\n\n\t// Generate translations for missing keys\n\tif (Object.keys(missingTranslations).length > 0) {\n\t\tconst generatedTranslation = await generateTranslation(\n\t\t\tenglishReferenceContent,\n\t\t\tmissingTranslations,\n\t\t\ttargetLanguage,\n\t\t\tlocaleFileContent,\n\t\t)\n\t\tconst generatedTranslationJson = JSON.parse(generatedTranslation)\n\t\tlocaleFileContent = {...localeFileContent, ...generatedTranslationJson}\n\t}\n\n\t// Sort keys\n\tconst sortedLocaleContent = Object.keys(localeFileContent)\n\t\t.sort()\n\t\t.reduce((result, key) => {\n\t\t\tresult[key] = localeFileContent[key]\n\t\t\treturn result\n\t\t}, {})\n\n\tfs.writeFileSync(localeFilePath, JSON.stringify(sortedLocaleContent, null, 2), 'utf8')\n}\n\nasync function checkAndGenerateTranslations(englishReferenceContent, modifiedEnKeys = null, baseBranch = null) {\n\tconst localeFiles = fs.readdirSync(localesDirectory)\n\tconst translationPromises = localeFiles.map((localeFile) =>\n\t\tgenerateAndWriteTranslations(englishReferenceContent, localeFile, modifiedEnKeys, baseBranch),\n\t)\n\tawait Promise.all(translationPromises)\n}\n\nasync function start() {\n\tlet englishReferenceContent = JSON.parse(fs.readFileSync(englishReferenceFilePath, 'utf8'))\n\tawait removeUnusedTranslations(englishReferenceContent)\n\t// Reload content as ununsed keys might have been removed\n\tenglishReferenceContent = JSON.parse(fs.readFileSync(englishReferenceFilePath, 'utf8'))\n\n\t// Check if we're in CI with a base branch for regeneration\n\tconst baseBranch = process.env.GITHUB_BASE_REF\n\tconst isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true'\n\n\tif (isCI && baseBranch) {\n\t\tconsole.log(`Running in CI mode with base branch: ${baseBranch}`)\n\n\t\t// Find modified keys by comparing against merge base\n\t\tconst modifiedKeys = getModifiedKeys(englishReferenceContent, baseBranch)\n\t\tconst modifiedKeysCount = Object.keys(modifiedKeys).length\n\n\t\tif (modifiedKeysCount > 0) {\n\t\t\tconsole.log(`Found ${modifiedKeysCount} modified/added keys in en.json`)\n\t\t\t// Only regenerate keys that were modified and not yet translated\n\t\t\tawait checkAndGenerateTranslations(englishReferenceContent, modifiedKeys, baseBranch)\n\t\t} else {\n\t\t\tconsole.log('No keys modified in en.json compared to merge base')\n\t\t}\n\t} else {\n\t\tconsole.log('Running in local/manual mode - regenerating all missing translations')\n\t\tawait checkAndGenerateTranslations(englishReferenceContent)\n\t}\n}\n\nstart()\n"
  },
  {
    "path": "packages/ui/vite.config.ts",
    "content": "import path from 'node:path'\nimport tailwindcss from '@tailwindcss/vite'\nimport react from '@vitejs/plugin-react'\nimport {defineConfig} from 'vite'\nimport {imagetools} from 'vite-imagetools'\n\n// https://vitejs.dev/config/\n\nexport default defineConfig({\n\tplugins: [\n\t\ttailwindcss(),\n\t\t// React Compiler automatically memoizes components, hooks, and expressions\n\t\t// at build time. No need to manually add useMemo/useCallback/React.memo.\n\t\t// useMemo/useCallback can still be used as escape hatches for precise control.\n\t\t// If a component behaves unexpectedly, add \"use no memo\" directive to opt it out.\n\t\treact({\n\t\t\tbabel: {\n\t\t\t\tplugins: ['babel-plugin-react-compiler'],\n\t\t\t},\n\t\t}),\n\t\timagetools({\n\t\t\t// Currently we only convert SVGs in features/files/assets/file-items-thumbnails\n\t\t\tinclude: /src\\/features\\/files\\/assets\\/file-items-thumbnails\\/[^?]+\\.svg(\\?.*)?$/,\n\t\t}),\n\t],\n\t// Vite 4.4.8+ blocks requests from unrecognized hosts to prevent DNS rebinding attacks.\n\t// Allow all hosts since the dev server runs inside a local Docker container and is\n\t// accessed via dynamic *.local hostnames (e.g. umbrel-dev.local, umbrel-dev-apps.local).\n\t// This only affects the dev server, not production builds.\n\tserver: {\n\t\tallowedHosts: true,\n\t},\n\tresolve: {\n\t\talias: {\n\t\t\t'@/': `${path.resolve(__dirname, 'src')}/`,\n\t\t},\n\t},\n\tbuild: {\n\t\trollupOptions: {\n\t\t\toutput: {\n\t\t\t\tminifyInternalExports: true,\n\t\t\t\tmanualChunks: {\n\t\t\t\t\t// remeda: ['remeda'],\n\t\t\t\t\t// motion: ['framer-motion'],\n\t\t\t\t\t// bignumber: ['bignumber.js'],\n\t\t\t\t\t// other: ['react-helmet-async', 'react-error-boundary'],\n\t\t\t\t\t// toaster: ['sonner'],\n\t\t\t\t\treact: ['react', 'react-dom'],\n\t\t\t\t\ti18n: ['i18next', 'react-i18next', 'i18next-browser-languagedetector', 'i18next-http-backend'],\n\t\t\t\t\tfetch: ['@tanstack/react-query', '@trpc/react-query', '@trpc/client'],\n\t\t\t\t\tcss: ['tailwind-merge', 'clsx'],\n\t\t\t\t\treactRouter: ['react-router-dom'],\n\t\t\t\t\tdev: ['@tanstack/react-query-devtools', 'react-json-tree'],\n\t\t\t\t\t// sorter: ['match-sorter'],\n\t\t\t\t\t// icons: ['react-icons', 'lucide-react'],\n\t\t\t\t\t// qr: ['react-qr-code'],\n\t\t\t\t\t// pin: ['rci'],\n\t\t\t\t\tcolorThief: ['colorthief'],\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n})\n"
  },
  {
    "path": "packages/umbreld/.gitignore",
    "content": "node_modules\ncoverage\n.DS_Store\ndata\nui"
  },
  {
    "path": "packages/umbreld/.prettierignore",
    "content": "# .gitignore contents\nnode_modules\ncoverage\n.DS_Store\ndata\n\n# Prevent prettier erroring on intentionally invalid YAML test case\nsource/modules/test-utilities/fixtures/community-repo/app-with-invalid-manifest/umbrel-app.yml"
  },
  {
    "path": "packages/umbreld/package.json",
    "content": "{\n\t\"name\": \"umbreld\",\n\t\"type\": \"module\",\n\t\"version\": \"1.6.1\",\n\t\"versionName\": \"umbrelOS 1.6.1\",\n\t\"main\": \"./source/index.ts\",\n\t\"bin\": \"./umbreld\",\n\t\"license\": \"PolyForm Noncommercial License 1.0.0\",\n\t\"private\": true,\n\t\"scripts\": {\n\t\t\"start\": \"./source/cli.ts\",\n\t\t\"client\": \"UMBREL_DATA_DIR=./data UMBREL_TRPC_ENDPOINT=http://localhost:3001/trpc npm run start -- client\",\n\t\t\"format\": \"prettier --write .\",\n\t\t\"format:check\": \"prettier --check .\",\n\t\t\"typecheck\": \"tsc --noEmit\",\n\t\t\"test\": \"vitest --reporter verbose --run --testTimeout 600000 --hookTimeout 600000\",\n\t\t\"test:unit\": \"npm run test -- unit.test\",\n\t\t\"test:integration\": \"npm run test -- integration.test\",\n\t\t\"test:coverage\": \"open ./coverage/index.html\",\n\t\t\"watch\": \"NODE_ENV=development nodemon --legacy-watch --ext js,json,ts --watch source --exec npm run\",\n\t\t\"dev\": \"FORCE_COLOR=1 UMBREL_UI_PROXY=http://localhost:3000 npm run watch -- start -- -- --data-directory /home/umbrel/umbrel --log-level verbose\",\n\t\t\"dev:production-mode\": \"FORCE_COLOR=1 npm run start -- --data-directory /home/umbrel/umbrel\",\n\t\t\"todo\": \"git grep --line-number TODO -- ':!package.json'\",\n\t\t\"test:vm\": \"npm run test -- vm.test\",\n\t\t\"test:umbrel-dev:prepare\": \"cd ../../; npm run dev exec:noninteractive -- bash -c 'systemctl stop umbrel; npm --prefix /umbrel-dev/packages/umbreld install'\",\n\t\t\"test:umbrel-dev\": \"cd ../../; npm run dev exec:noninteractive -- npm --prefix /umbrel-dev/packages/umbreld test\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@types/ssh2\": \"^1.15.1\",\n\t\t\"@tryjsky/v9u-smb2\": \"^1.1.0\",\n\t\t\"@types/adm-zip\": \"^0.5.7\",\n\t\t\"@types/archiver\": \"^6.0.3\",\n\t\t\"@types/bcryptjs\": \"^2.4.2\",\n\t\t\"@types/bytes\": \"^3.1.5\",\n\t\t\"@types/compressible\": \"^2.0.3\",\n\t\t\"@types/cors\": \"^2.8.13\",\n\t\t\"@types/dockerode\": \"^3.3.26\",\n\t\t\"@types/express\": \"^4.17.17\",\n\t\t\"@types/fs-extra\": \"^11.0.1\",\n\t\t\"@types/js-yaml\": \"^4.0.5\",\n\t\t\"@types/mime-types\": \"^2.1.4\",\n\t\t\"@types/ms\": \"^0.7.31\",\n\t\t\"@types/notp\": \"^2.0.2\",\n\t\t\"@types/semver\": \"^7.5.8\",\n\t\t\"@types/tcp-port-used\": \"^1.0.4\",\n\t\t\"@types/ws\": \"^8.5.10\",\n\t\t\"@vitest/coverage-v8\": \"^2.1.2\",\n\t\t\"adm-zip\": \"^0.5.16\",\n\t\t\"get-port\": \"^7.0.0\",\n\t\t\"got\": \"^14.4.6\",\n\t\t\"node-git-server\": \"^1.0.0\",\n\t\t\"nodemon\": \"^3.1.9\",\n\t\t\"ssh2\": \"^1.16.0\",\n\t\t\"prettier\": \"3.3.3\",\n\t\t\"tcp-port-used\": \"^1.0.2\",\n\t\t\"tough-cookie\": \"^5.1.2\",\n\t\t\"typescript\": \"^5.8.3\",\n\t\t\"vitest\": \"^2.1.2\",\n\t\t\"wait-port\": \"^1.0.4\"\n\t},\n\t\"dependencies\": {\n\t\t\"@homebridge/dbus-native\": \"github:getumbrel/dbus-native#types\",\n\t\t\"@parcel/watcher\": \"^2.5.1\",\n\t\t\"@trpc/client\": \"^11.1.1\",\n\t\t\"@trpc/server\": \"^11.1.1\",\n\t\t\"@tsconfig/node22\": \"^22.0.0\",\n\t\t\"@types/cookie-parser\": \"^1.4.7\",\n\t\t\"archiver\": \"^7.0.1\",\n\t\t\"arg\": \"^5.0.2\",\n\t\t\"bcryptjs\": \"^2.4.3\",\n\t\t\"bytes\": \"^3.1.2\",\n\t\t\"camelcase-keys\": \"^8.0.2\",\n\t\t\"chalk-template\": \"^0.5.0\",\n\t\t\"check-disk-space\": \"^3.4.0\",\n\t\t\"compose-spec-schema\": \"^1.0.0\",\n\t\t\"compressible\": \"^2.0.18\",\n\t\t\"cookie-parser\": \"^1.4.6\",\n\t\t\"cors\": \"2.8.5\",\n\t\t\"dockerode\": \"^4.0.2\",\n\t\t\"dot-prop\": \"^7.2.0\",\n\t\t\"drivelist\": \"^12.0.2\",\n\t\t\"emittery\": \"^1.1.0\",\n\t\t\"es-toolkit\": \"^1.33.0\",\n\t\t\"execa\": \"^7.1.1\",\n\t\t\"express\": \"^4.18.2\",\n\t\t\"express-jwt\": \"^8.4.1\",\n\t\t\"express-session\": \"^1.17.3\",\n\t\t\"fast-fuzzy\": \"^1.12.0\",\n\t\t\"fs-extra\": \"^11.1.1\",\n\t\t\"globby\": \"^13.2.1\",\n\t\t\"helmet\": \"^7.1.0\",\n\t\t\"http-proxy-middleware\": \"^2.0.6\",\n\t\t\"isomorphic-git\": \"^1.24.5\",\n\t\t\"js-yaml\": \"^4.1.0\",\n\t\t\"jsonwebtoken\": \"^9.0.1\",\n\t\t\"ky\": \"^1.10.0\",\n\t\t\"mime-types\": \"^2.1.35\",\n\t\t\"minimatch\": \"^10.0.1\",\n\t\t\"ms\": \"^2.1.3\",\n\t\t\"node-fetch\": \"^3.3.2\",\n\t\t\"node-pty\": \"^1.0.0\",\n\t\t\"notp\": \"^2.0.3\",\n\t\t\"ow\": \"^1.1.1\",\n\t\t\"p-queue\": \"^7.3.0\",\n\t\t\"p-retry\": \"^6.2.1\",\n\t\t\"p-wait-for\": \"^5.0.2\",\n\t\t\"pify\": \"^6.1.0\",\n\t\t\"pretty-bytes\": \"^6.1.1\",\n\t\t\"read-pkg-up\": \"^9.1.0\",\n\t\t\"semver\": \"^7.6.3\",\n\t\t\"session-file-store\": \"^1.5.0\",\n\t\t\"strip-ansi\": \"^7.1.0\",\n\t\t\"systeminformation\": \"github:getumbrel/systeminformation#a98cf825f52775cf4b8f3c7d4a00d0c96434ff46\",\n\t\t\"thirty-two\": \"^1.0.2\",\n\t\t\"tsx\": \"^4.7.1\",\n\t\t\"valid-filename\": \"^4.0.0\",\n\t\t\"ws\": \"^8.16.0\",\n\t\t\"zod\": \"^3.21.4\"\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/scripts/validate-manifests.ts",
    "content": "import fs from 'fs/promises'\nimport path from 'path'\nimport yaml from 'js-yaml'\nimport {validateManifest} from '../source/modules/apps/schema.js'\n\n// TODO: Integrate into umbrel-apps CI?\n\nconst rootFolder = process.env.UMBREL_ROOT ?? '/home/umbrel/umbrel'\nconst appStoresFolder = path.join(rootFolder, 'app-stores')\n\nlet total = 0\nlet valid = 0\n\nconst appStoreFolders = await fs.readdir(appStoresFolder)\nfor (const relativeAppStoreFolder of appStoreFolders) {\n\tif (relativeAppStoreFolder.startsWith('.')) continue\n\n\tconst appStoreFolder = path.join(appStoresFolder, relativeAppStoreFolder)\n\tconst stats = await fs.stat(appStoreFolder)\n\tif (!stats.isDirectory()) continue\n\n\tconst appFolders = await fs.readdir(appStoreFolder)\n\tfor (const relativeAppFolder of appFolders) {\n\t\tif (relativeAppFolder.startsWith('.')) continue\n\n\t\tconst appFolder = path.join(appStoreFolder, relativeAppFolder)\n\t\tconst stats = await fs.stat(appFolder)\n\t\tif (!stats.isDirectory()) continue\n\n\t\tconst manifestFile = path.join(appFolder, 'umbrel-app.yml')\n\t\ttry {\n\t\t\tconst rawManifest = await fs.readFile(manifestFile, 'utf8')\n\t\t\tconst parsedManifest = yaml.load(rawManifest)\n\t\t\tvalidateManifest(parsedManifest)\n\t\t\tvalid++\n\t\t} catch (error) {\n\t\t\tconsole.error(`Error in ${manifestFile}: ${(error as Error).message}`)\n\t\t}\n\t\ttotal++\n\t}\n}\n\nconsole.log(`${valid} of ${total} manifests are valid`)\n"
  },
  {
    "path": "packages/umbreld/source/cli.ts",
    "content": "#!/usr/bin/env tsx\nimport process from 'node:process'\n\nimport arg from 'arg'\nimport camelcaseKeys from 'camelcase-keys'\n\nimport {cliClient} from './modules/cli-client.js'\nimport blacklistUas from './modules/blacklist-uas/blacklist-uas.js'\n\nimport Umbreld, {type UmbreldOptions} from './index.js'\n\n// Blacklists uas drivers early in the boot process\nif (process.argv.includes('blacklist-uas')) {\n\tawait blacklistUas()\n\tprocess.exit(0)\n}\n\n// Quick trpc client for testing\nif (process.argv.includes('client')) {\n\tconst clientIndex = process.argv.indexOf('client')\n\tconst query = process.argv[clientIndex + 1]\n\tconst args = process.argv.slice(clientIndex + 2)\n\n\tawait cliClient({query, args})\n\tprocess.exit(0)\n}\n\nconst showHelp = () =>\n\tconsole.log(`\n    Usage\n        $ umbreld\n\n    Options\n        --help                    Shows this help message\n        --data-directory          Your Umbrel data directory\n        --port                    The port to listen on\n        --log-level               The logging intensity: silent|normal|verbose\n\t\t--default-app-store-repo  The default app store repository\n\n    Examples\n        $ umbreld --data-directory ~/umbrel\n`)\n\nconst args = camelcaseKeys(\n\targ({\n\t\t'--help': Boolean,\n\t\t'--data-directory': String,\n\t\t'--port': Number,\n\t\t'--log-level': String,\n\t\t'--default-app-store-repo': String,\n\t}),\n)\n\nif (args.help) {\n\tshowHelp()\n\tprocess.exit(0)\n}\n\n// TODO: Validate these args are valid\nconst umbreld = new Umbreld(args as UmbreldOptions)\n\n// Shutdown cleanly on SIGINT and SIGTERM\nlet isShuttingDown = false\nasync function cleanShutdown(signal: string) {\n\tif (isShuttingDown) return\n\tisShuttingDown = true\n\n\tumbreld.logger.log(`Received ${signal}, shutting down cleanly...`)\n\tawait umbreld.stop()\n\tprocess.exit(0)\n}\nprocess.on('SIGINT', cleanShutdown.bind(null, 'SIGINT'))\nprocess.on('SIGTERM', cleanShutdown.bind(null, 'SIGTERM'))\n\ntry {\n\tawait umbreld.start()\n} catch (error) {\n\tconsole.error(process.env.NODE_ENV === 'production' ? (error as Error).message : error)\n\tprocess.exit(1)\n}\n"
  },
  {
    "path": "packages/umbreld/source/constants.ts",
    "content": "/** Official app repository of the Umbrel App Store */\nexport const UMBREL_APP_STORE_REPO = 'https://github.com/getumbrel/umbrel-apps.git'\n\n// Marker file indicating first start after a backup restore\nexport const BACKUP_RESTORE_FIRST_START_FLAG = '.is-backups-restore-first-start'\n"
  },
  {
    "path": "packages/umbreld/source/index.ts",
    "content": "import path from 'node:path'\nimport fse from 'fs-extra'\n\n// TODO: import packageJson from '../package.json' assert {type: 'json'}\nconst packageJson = (await import('../package.json', {assert: {type: 'json'}})).default\n\nimport {UMBREL_APP_STORE_REPO, BACKUP_RESTORE_FIRST_START_FLAG} from './constants.js'\nimport createLogger, {type LogLevel} from './modules/utilities/logger.js'\nimport FileStore from './modules/utilities/file-store.js'\nimport Migration from './modules/startup-migrations/index.js'\nimport Server from './modules/server/index.js'\nimport User from './modules/user/user.js'\nimport AppStore from './modules/apps/app-store.js'\nimport Apps from './modules/apps/apps.js'\nimport Files from './modules/files/files.js'\nimport Hardware from './modules/hardware/hardware.js'\nimport Notifications from './modules/notifications/notifications.js'\nimport EventBus from './modules/event-bus/event-bus.js'\nimport Dbus from './modules/dbus/dbus.js'\nimport Backups from './modules/backups/backups.js'\n\nimport {commitOsPartition, setupPiCpuGovernor, restoreWiFi, waitForSystemTime, reboot} from './modules/system/system.js'\nimport {cleanupFactoryResetBackups} from './modules/system/factory-reset.js'\nimport {overrideDevelopmentHostname} from './modules/development.js'\n\ntype StoreSchema = {\n\tversion: string\n\tapps: string[]\n\tappRepositories: string[]\n\twidgets: string[]\n\ttorEnabled?: boolean\n\tuser: {\n\t\tname: string\n\t\thashedPassword: string\n\t\ttotpUri?: string\n\t\twallpaper?: string\n\t\tlanguage?: string\n\t\ttemperatureUnit?: string\n\t}\n\tsettings: {\n\t\treleaseChannel: 'stable' | 'beta'\n\t\twifi?: {\n\t\t\tssid: string\n\t\t\tpassword?: string\n\t\t}\n\t\texternalDns?: boolean\n\t}\n\tdevelopment: {\n\t\thostname?: string\n\t}\n\trecentlyOpenedApps: string[]\n\tfiles: {\n\t\tpreferences: {\n\t\t\tview: 'icons' | 'list'\n\t\t\tsortBy: 'name' | 'type' | 'modified' | 'size'\n\t\t\tsortOrder: 'ascending' | 'descending'\n\t\t}\n\t\tfavorites: string[]\n\t\trecents: string[]\n\t\tshares: {\n\t\t\tname: string\n\t\t\tpath: string\n\t\t}[]\n\t\tnetworkStorage: {\n\t\t\thost: string\n\t\t\tshare: string\n\t\t\tusername: string\n\t\t\tpassword: string\n\t\t\tmountPath: string\n\t\t}[]\n\t}\n\tnotifications: string[]\n\tbackups: {\n\t\trepositories: {\n\t\t\tid: string\n\t\t\tpath: string\n\t\t\tpassword: string\n\t\t\tlastBackup?: number\n\t\t}[]\n\t\tignore: string[]\n\t}\n\tmigration: {\n\t\tmenderToRugixAttempt?: number\n\t}\n}\n\nexport type UmbreldOptions = {\n\tdataDirectory: string\n\tport?: number\n\tlogLevel?: LogLevel\n\tdefaultAppStoreRepo?: string\n}\n\nexport default class Umbreld {\n\tversion: string = packageJson.version\n\tversionName: string = packageJson.versionName\n\tdevelopmentMode: boolean\n\tdataDirectory: string\n\tport: number\n\tlogLevel: LogLevel\n\tlogger: ReturnType<typeof createLogger>\n\tstore: FileStore<StoreSchema>\n\tmigration: Migration\n\tserver: Server\n\tuser: User\n\tappStore: AppStore\n\tapps: Apps\n\tfiles: Files\n\thardware: Hardware\n\tnotifications: Notifications\n\teventBus: EventBus\n\tdbus: Dbus\n\tbackups: Backups\n\tisBackupRestoreFirstStart = false\n\n\tconstructor({\n\t\tdataDirectory,\n\t\tport = 80,\n\t\tlogLevel = 'normal',\n\t\tdefaultAppStoreRepo = UMBREL_APP_STORE_REPO,\n\t}: UmbreldOptions) {\n\t\tthis.developmentMode = process?.env?.NODE_ENV === 'development'\n\t\tthis.dataDirectory = path.resolve(dataDirectory)\n\t\tthis.port = port\n\t\tthis.logLevel = logLevel\n\t\tthis.logger = createLogger('umbreld', this.logLevel)\n\t\tthis.store = new FileStore<StoreSchema>({filePath: `${dataDirectory}/umbrel.yaml`})\n\t\tthis.migration = new Migration(this)\n\t\tthis.server = new Server({umbreld: this})\n\t\tthis.user = new User(this)\n\t\tthis.appStore = new AppStore(this, {defaultAppStoreRepo})\n\t\tthis.apps = new Apps(this)\n\t\tthis.files = new Files(this)\n\t\tthis.hardware = new Hardware(this)\n\t\tthis.notifications = new Notifications(this)\n\t\tthis.eventBus = new EventBus(this)\n\t\tthis.dbus = new Dbus(this)\n\t\tthis.backups = new Backups(this)\n\t}\n\n\tasync start() {\n\t\tthis.logger.log(`☂️  Starting Umbrel v${this.version}`)\n\t\tthis.logger.log()\n\t\tthis.logger.log(`dataDirectory: ${this.dataDirectory}`)\n\t\tthis.logger.log(`port:          ${this.port}`)\n\t\tthis.logger.log(`logLevel:      ${this.logLevel}`)\n\t\tthis.logger.log()\n\n\t\t// If we've successfully booted then commit to the current OS partition\n\t\tawait commitOsPartition(this)\n\n\t\t// Set ondemand cpu governor for Raspberry Pi (non-blocking)\n\t\tsetupPiCpuGovernor(this)\n\n\t\t// Cleanup old factory reset state backups early to free up disk space ASAP (non-blocking)\n\t\tcleanupFactoryResetBackups(this)\n\n\t\t// Run migration module before anything else\n\t\t// TODO: think through if we want to allow the server module to run before migration.\n\t\t// It might be useful if we add more complicated migrations so we can signal progress.\n\t\tconst migrationResult = await this.migration.start()\n\t\t// If the migration module requests a reboot, halt umbreld startup and reboot the system immediately\n\t\tif (migrationResult.reboot) {\n\t\t\tthis.logger.log('Rebooting to complete migrations...')\n\t\t\tawait reboot()\n\t\t\treturn\n\t\t}\n\n\t\t// Detect first boot after a backup restore (we run after migrations move 'import' into dataDirectory)\n\t\tawait this.setBackupRestoreFirstStartFlag()\n\n\t\t// Override hostname in development when set\n\t\tconst developmentHostname = await this.store.get('development.hostname')\n\t\tif (developmentHostname) await overrideDevelopmentHostname(this, developmentHostname)\n\n\t\t// Synchronize the system password after OTA update (non-blocking)\n\t\tthis.user.syncSystemPassword()\n\n\t\t// Restore WiFi connection after OTA update (non-blocking)\n\t\trestoreWiFi(this)\n\n\t\t// Wait for system time to be synced for up to 10 seconds before proceeding\n\t\t// We need this on Raspberry Pi since it doesn't have a persistent real time clock.\n\t\t// It avoids race conditions where umbrelOS starts making network requests before\n\t\t// the local time is set which then fail with SSL cert errors.\n\t\tawait waitForSystemTime(this, 10)\n\n\t\t// We need to forcefully clean Docker state before being able to safely continue\n\t\t// If an existing container is listening on port 80 we'll crash, if an old version\n\t\t// of Umbrel wasn't shutdown properly, bringing containers up can fail.\n\t\t// Skip this in dev mode otherwise we get very slow reloads since this cleans\n\t\t// up app containers on every source code change.\n\t\tif (!this.developmentMode) {\n\t\t\tawait this.apps.cleanDockerState().catch((error) => this.logger.error(`Failed to clean Docker state`, error))\n\t\t}\n\n\t\t// Initialise modules\n\t\tawait Promise.all([\n\t\t\tthis.user.start(),\n\t\t\tthis.files.start(),\n\t\t\tthis.hardware.start(),\n\t\t\tthis.apps.start(),\n\t\t\tthis.appStore.start(),\n\t\t\tthis.dbus.start(),\n\t\t\tthis.server.start(),\n\t\t])\n\n\t\t// Start backups last because it depends on files\n\t\tthis.backups.start()\n\t}\n\n\tprivate async setBackupRestoreFirstStartFlag() {\n\t\ttry {\n\t\t\tconst restoreFlagPath = `${this.dataDirectory}/${BACKUP_RESTORE_FIRST_START_FLAG}`\n\t\t\tif (await fse.pathExists(restoreFlagPath)) {\n\t\t\t\tthis.logger.log('Detected first start after backup restore')\n\t\t\t\tthis.isBackupRestoreFirstStart = true\n\t\t\t\tawait fse.remove(restoreFlagPath).catch(() => {})\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tthis.logger.error('Failed checking backup restore first-start flag', error)\n\t\t}\n\t}\n\n\tasync stop() {\n\t\ttry {\n\t\t\t// Stop backups first because it depends on files\n\t\t\tawait this.backups.stop()\n\n\t\t\t// Stop modules\n\t\t\tawait Promise.all([\n\t\t\t\tthis.user.stop(),\n\t\t\t\tthis.files.stop(),\n\t\t\t\tthis.hardware.stop(),\n\t\t\t\tthis.apps.stop(),\n\t\t\t\tthis.appStore.stop(),\n\t\t\t\tthis.dbus.stop(),\n\t\t\t])\n\t\t\treturn true\n\t\t} catch (error) {\n\t\t\t// If we fail to stop gracefully there's not really much we can do, just log the error and return false\n\t\t\t// so it can be handled elsewhere if needed\n\t\t\tthis.logger.error(`Failed to stop umbreld`, error)\n\t\t\treturn false\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/app-repository.integration.test.ts",
    "content": "import {fileURLToPath} from 'node:url'\nimport path from 'node:path'\n\nimport {describe, beforeAll, afterAll, expect, test} from 'vitest'\nimport fse from 'fs-extra'\n\nimport runGitServer from '../test-utilities/run-git-server.js'\nimport temporaryDirectory from '../utilities/temporary-directory.js'\n\nimport AppRepository from './app-repository.js'\nimport Umbreld from '../../index.js'\n\nconst currentDirectory = path.dirname(fileURLToPath(import.meta.url))\n\nconst directory = temporaryDirectory()\n\nlet gitServer: Awaited<ReturnType<typeof runGitServer>>\n\nbeforeAll(async () => {\n\tawait directory.createRoot()\n\tgitServer = await runGitServer()\n})\nafterAll(async () => {\n\tawait directory.destroyRoot()\n\tawait gitServer.close()\n})\n\ndescribe('AppRepository', async () => {\n\ttest('is a class', () => {\n\t\texpect(AppRepository).toBeTypeOf('function')\n\t\texpect(AppRepository.toString().startsWith('class ')).toBe(true)\n\t})\n\n\ttest('return an instance on valid URL', async () => {\n\t\tconst umbreld = new Umbreld({dataDirectory: '/tmp'})\n\t\tconst url = 'http://github.com/getumbrel/umbrel-apps.git'\n\t\tconst appRepo = new AppRepository(umbreld, url)\n\t\texpect(appRepo.url).toBe(url)\n\t})\n\n\ttest('throws error on invalid URL', async () => {\n\t\tconst umbreld = new Umbreld({dataDirectory: '/tmp'})\n\t\texpect(() => new AppRepository(umbreld, 'invalid-url')).toThrow('Invalid URL')\n\t})\n})\n\ndescribe('appRepository.cleanUrl()', () => {\n\ttest('cleans HTTP URLs', async () => {\n\t\tconst umbreld = new Umbreld({dataDirectory: '/tmp'})\n\t\tconst appRepo = new AppRepository(umbreld, 'http://github.com/getumbrel/umbrel-apps.git')\n\t\texpect(appRepo.cleanUrl()).toBe('getumbrel-umbrel-apps-github-98f08343')\n\t})\n\n\ttest('cleans HTTPS URLs', async () => {\n\t\tconst umbreld = new Umbreld({dataDirectory: '/tmp'})\n\t\tconst appRepo = new AppRepository(umbreld, 'https://github.com/getumbrel/umbrel-apps.git')\n\t\texpect(appRepo.cleanUrl()).toBe('getumbrel-umbrel-apps-github-53f74447')\n\t})\n\n\ttest('cleans token URLs', async () => {\n\t\tconst umbreld = new Umbreld({dataDirectory: '/tmp'})\n\t\tconst appRepo = new AppRepository(umbreld, 'https://somerandomtoken@github.com/getumbrel/umbrel-apps.git')\n\t\texpect(appRepo.cleanUrl()).toBe('getumbrel-umbrel-apps-github-5db4a3e5')\n\t})\n\n\ttest('cleans GitLab URL', async () => {\n\t\tconst umbreld = new Umbreld({dataDirectory: '/tmp'})\n\t\tconst appRepo = new AppRepository(umbreld, 'https://gitlab.com/getumbrel/umbrel-apps.git')\n\t\texpect(appRepo.cleanUrl()).toBe('getumbrel-umbrel-apps-gitlab-8895504e')\n\t})\n\n\ttest('cleans non user/repo urls', async () => {\n\t\tconst umbreld = new Umbreld({dataDirectory: '/tmp'})\n\t\tconst appRepo = new AppRepository(umbreld, 'https://example.com')\n\t\texpect(appRepo.cleanUrl()).toBe('example-100680ad')\n\t})\n\n\ttest('removes dangerous characters', async () => {\n\t\tconst umbreld = new Umbreld({dataDirectory: '/tmp'})\n\t\tconst appRepo = new AppRepository(umbreld, `https://example.com/-+_)(*&^%$!~\\`,<>?;:'\"[{]}\\\\|=/`)\n\t\texpect(appRepo.cleanUrl()).toBe('example-fcd4912b')\n\t})\n})\n\ndescribe('appRepository.update()', () => {\n\ttest(\"does initial install from URL if there's no local repo\", async () => {\n\t\tconst dataDirectory = await directory.create()\n\t\tconst umbreld = new Umbreld({dataDirectory})\n\t\tconst appRepository = new AppRepository(umbreld, gitServer.url)\n\t\texpect(await fse.exists(`${appRepository.path}/.git`)).toBe(false)\n\t\texpect(await fse.exists(`${appRepository.path}/umbrel-app-store.yml`)).toBe(false)\n\t\tawait appRepository.update()\n\t\texpect(await fse.exists(`${appRepository.path}/.git`)).toBe(true)\n\t\texpect(await fse.exists(`${appRepository.path}/umbrel-app-store.yml`)).toBe(true)\n\t})\n\n\ttest('updates when the remote repo has changed', async () => {\n\t\tconst dataDirectory = await directory.create()\n\t\tconst umbreld = new Umbreld({dataDirectory})\n\t\tconst appRepository = new AppRepository(umbreld, gitServer.url)\n\n\t\t// Initial install\n\t\tawait appRepository.update()\n\t\tconst originalCommit = await appRepository.getCurrentCommit()\n\t\texpect(originalCommit).toBeTruthy()\n\n\t\t// Check we are updated\n\t\texpect(await appRepository.isUpdated()).toBe(true)\n\n\t\t// Add new commit to remote repo\n\t\tawait gitServer.addNewCommit()\n\n\t\t// Check we are not updated\n\t\texpect(await appRepository.isUpdated()).toBe(false)\n\n\t\t// Update again\n\t\tawait appRepository.update()\n\t\tconst postUpdateCommit = await appRepository.getCurrentCommit()\n\n\t\t// Check we're on the new commit\n\t\texpect(originalCommit).not.toBe(postUpdateCommit)\n\t})\n\n\ttest('does not update when both repos are the same', async () => {\n\t\tconst dataDirectory = await directory.create()\n\t\tconst umbreld = new Umbreld({dataDirectory})\n\t\tconst appRepository = new AppRepository(umbreld, gitServer.url)\n\n\t\t// Initial install\n\t\tawait appRepository.update()\n\t\tconst originalCommit = await appRepository.getCurrentCommit()\n\t\texpect(originalCommit).toBeTruthy()\n\n\t\t// Check we are updated\n\t\texpect(await appRepository.isUpdated()).toBe(true)\n\n\t\t// Update again\n\t\tawait appRepository.update()\n\t\tconst postUpdateCommit = await appRepository.getCurrentCommit()\n\n\t\t// Check we're on the same commit\n\t\texpect(originalCommit).toBe(postUpdateCommit)\n\t})\n})\n\ndescribe('appRepository.readRegistry()', () => {\n\ttest('reads community registry', async () => {\n\t\tconst umbreld = new Umbreld({dataDirectory: '/tmp'})\n\t\tconst appRepo = new AppRepository(umbreld, 'http://github.com/getumbrel/umbrel-apps.git')\n\n\t\t// Forcefully set app repo path to the community repo fixture\n\t\tappRepo.path = `${currentDirectory}/../test-utilities/fixtures/community-repo`\n\t\tumbreld.appStore.defaultAppStoreRepo = appRepo.path\n\n\t\t// Read registry\n\t\tconst registry = await appRepo.readRegistry()\n\t\tconst expectedRegistry = {\n\t\t\turl: 'http://github.com/getumbrel/umbrel-apps.git',\n\t\t\tmeta: {\n\t\t\t\tid: 'sparkles',\n\t\t\t\tname: 'Sparkles',\n\t\t\t},\n\t\t\tapps: [\n\t\t\t\t{\n\t\t\t\t\tappStoreId: 'sparkles',\n\t\t\t\t\tmanifestVersion: '1.0.0',\n\t\t\t\t\tid: 'sparkles-hello-world',\n\t\t\t\t\tname: 'Hello World',\n\t\t\t\t\ttagline: \"Replace this tagline with your app's tagline\",\n\t\t\t\t\ticon: 'https://svgur.com/i/mvA.svg',\n\t\t\t\t\tcategory: 'Development',\n\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\tport: 4000,\n\t\t\t\t\tdescription: \"Add your app's description here.\\n\\nYou can also add newlines!\",\n\t\t\t\t\tdeveloper: 'Umbrel',\n\t\t\t\t\twebsite: 'https://umbrel.com',\n\t\t\t\t\tsubmitter: 'Umbrel',\n\t\t\t\t\tsubmission: 'https://github.com/getumbrel/umbrel-hello-world-app',\n\t\t\t\t\trepo: 'https://github.com/getumbrel/umbrel-hello-world-app',\n\t\t\t\t\tsupport: 'https://github.com/getumbrel/umbrel-hello-world-app/issues',\n\t\t\t\t\tgallery: [\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t],\n\t\t\t\t\treleaseNotes: \"Add what's new in the latest version of your app here.\",\n\t\t\t\t\tdependencies: [],\n\t\t\t\t\tpath: '',\n\t\t\t\t\tdefaultUsername: '',\n\t\t\t\t\tdefaultPassword: '',\n\t\t\t\t\tbackupIgnore: ['data', 'logs', 'cache'],\n\t\t\t\t},\n\t\t\t],\n\t\t}\n\t\texpect(registry).toStrictEqual(expectedRegistry)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/app-repository.ts",
    "content": "import {URL} from 'node:url'\nimport crypto from 'node:crypto'\n\nimport fse from 'fs-extra'\nimport * as git from 'isomorphic-git'\nimport http from 'isomorphic-git/http/node/index.js'\nimport yaml from 'js-yaml'\nimport {globby} from 'globby'\nimport {$} from 'execa'\n\nimport type Umbreld from '../../index.js'\nimport randomToken from '../utilities/random-token.js'\nimport {type AppRepositoryMeta, type AppManifest, validateManifest} from './schema.js'\nimport {UMBREL_APP_STORE_REPO} from '../../constants.js'\n\nasync function readYaml(path: string) {\n\treturn yaml.load(await fse.readFile(path, 'utf8'))\n}\n\n// TODO: Refactor some of this logic out into utilities\n\n// Validate URL\nfunction isValidUrl(url: string) {\n\ttry {\n\t\tvoid new URL(url)\n\t\treturn true\n\t} catch {\n\t\treturn false\n\t}\n}\n\nexport default class AppRepository {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\turl: string\n\tpath: string\n\n\tconstructor(umbreld: Umbreld, url: string) {\n\t\tif (!isValidUrl(url)) throw new Error('Invalid URL')\n\t\tthis.#umbreld = umbreld\n\t\tthis.url = url\n\t\tthis.path = `${umbreld.dataDirectory}/app-stores/${this.cleanUrl()}`\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(name.toLowerCase())\n\t}\n\n\t// Clean URL so it's safe to use as a directory name\n\tcleanUrl() {\n\t\tconst {hostname, pathname} = new URL(this.url)\n\t\tconst basename = hostname.split('.')[0]\n\t\tconst username = pathname.split('/')[1]\n\t\tconst repository = pathname.split('/')[2]?.replace(/\\.git$/, '')\n\t\tconst hash = crypto.createHash('sha256').update(this.url).digest('hex').slice(0, 8)\n\n\t\tlet cleanUrl = ''\n\t\tif (username && repository) cleanUrl += `${username}-${repository}-`\n\t\tcleanUrl += `${basename}-${hash}`\n\n\t\treturn (\n\t\t\tcleanUrl\n\t\t\t\t// Convert the URL to lowercase\n\t\t\t\t.toLowerCase()\n\t\t\t\t// Remove all characters that are not alphanumeric, dot, or hyphen\n\t\t\t\t.replace(/[^a-zA-Z0-9.-]/g, '')\n\t\t)\n\t}\n\n\t// Atomically clones the repository. This ensures that the repository is fully cloned\n\t// or not cloned at all, it will never be in a partial state while the clone is in progress.\n\t// Can also be used to atomically update instead of a pull.\n\tasync atomicClone() {\n\t\tconst temporaryPath = `${this.#umbreld.dataDirectory}/app-stores/.tmp/${randomToken(64)}`\n\n\t\tawait git.clone({\n\t\t\tfs: fse,\n\t\t\thttp,\n\t\t\turl: this.url,\n\t\t\tdir: temporaryPath,\n\t\t\tdepth: 1,\n\t\t\tsingleBranch: true,\n\t\t})\n\n\t\t// We're running as root so we need to relax file permissions so container can access them\n\t\tawait $`chown -R 1000:1000 ${temporaryPath}`\n\n\t\t// We also need to strip out all .gitkeep files since some apps cannot be initialised with\n\t\t// a non-empty volume directory\n\t\tawait $`find ${temporaryPath} -name .gitkeep -delete`\n\n\t\tawait fse.move(temporaryPath, this.path, {overwrite: true})\n\t}\n\n\t// Get the current local commit\n\tasync getCurrentCommit() {\n\t\tconst localBranch = await git.currentBranch({fs: fse, dir: this.path, fullname: true})\n\t\treturn git.resolveRef({fs: fse, dir: this.path, ref: localBranch as string})\n\t}\n\n\t// Get the latest remote commit from the default branch\n\tasync checkLatestCommit() {\n\t\tconst remoteRefs = await git.listServerRefs({http, url: this.url})\n\t\tconst latestCommitInDefaultRemoteBranch = remoteRefs.find((ref) => ref.ref === 'HEAD')!.oid\n\t\treturn latestCommitInDefaultRemoteBranch\n\t}\n\n\t// Check if the app repo is behind the remote repo\n\tasync isUpdated() {\n\t\ttry {\n\t\t\tconst currentCommit = await this.getCurrentCommit()\n\t\t\tconst latestCommit = await this.checkLatestCommit()\n\t\t\treturn currentCommit === latestCommit\n\t\t} catch {\n\t\t\t// No matter what goes wrong just return false\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Update (or install) the repo\n\tasync update() {\n\t\tthis.logger.log(`Checking for update for ${this.url}`)\n\t\tconst isUpdated = await this.isUpdated()\n\t\tif (isUpdated) {\n\t\t\tthis.logger.log(`${this.url} is already up to date`)\n\t\t} else {\n\t\t\tthis.logger.log(`Newer version of ${this.url} available, updating`)\n\t\t\tawait this.atomicClone()\n\t\t\tthis.logger.log(`Updated ${this.url}!`)\n\t\t}\n\n\t\treturn this.isUpdated()\n\t}\n\n\t// Read registry\n\tasync readRegistry() {\n\t\t// Get repo metadata\n\n\t\tlet meta: AppRepositoryMeta\n\n\t\t// Handle official repo which does not have meta\n\t\t// TODO: Instead of this hack we can probably just add this to the official repo\n\t\t// before we ship this code.\n\t\tif (this.url === UMBREL_APP_STORE_REPO) {\n\t\t\tmeta = {\n\t\t\t\tid: 'umbrel-app-store',\n\t\t\t\tname: 'Umbrel App Store',\n\t\t\t}\n\t\t} else {\n\t\t\tmeta = (await readYaml(`${this.path}/umbrel-app-store.yml`)) as AppRepositoryMeta\n\t\t}\n\n\t\t// Read app manifests\n\t\tconst appManifests = await globby(`${this.path}/*/umbrel-app.yml`)\n\n\t\tconst parsedManifestsPromises = appManifests.map((manifest) =>\n\t\t\treadYaml(manifest)\n\t\t\t\t.catch((error) => {\n\t\t\t\t\tthis.logger.error(`Manifest parsing of ${manifest} failed`, error)\n\t\t\t\t})\n\t\t\t\t.then(validateManifest)\n\t\t\t\t.catch((error) => {\n\t\t\t\t\tthis.logger.error(`Manifest validation of ${manifest} failed`, error)\n\t\t\t\t}),\n\t\t)\n\n\t\t// Wait for all reads to finish\n\t\tconst manifests = await Promise.all(parsedManifestsPromises)\n\n\t\t// Process results and add mandatory properties\n\t\tconst apps = manifests\n\t\t\t// Filter out invalid manifests\n\t\t\t.filter((app): app is AppManifest => app !== undefined)\n\t\t\t// Filter out disabled apps\n\t\t\t.filter((app) => app.disabled !== true)\n\t\t\t// Filter out invalid IDs\n\t\t\t.filter((app) => meta.id === 'umbrel-app-store' || app.id.startsWith(meta.id))\n\t\t\t// Add icons and hydrate app store id\n\t\t\t.map((app) => ({\n\t\t\t\t...app,\n\t\t\t\tappStoreId: meta.id,\n\t\t\t\tgallery:\n\t\t\t\t\tmeta.id === 'umbrel-app-store'\n\t\t\t\t\t\t? app.gallery.map((file) => `https://getumbrel.github.io/umbrel-apps-gallery/${app.id}/${file}`)\n\t\t\t\t\t\t: app.gallery,\n\t\t\t\t// TODO: make this work for custom repos\n\t\t\t\ticon: app.icon ?? `https://getumbrel.github.io/umbrel-apps-gallery/${app.id}/icon.svg`,\n\t\t\t}))\n\t\t\t// Sort apps alphabetically\n\t\t\t.sort((a: any, b: any) => a.id.localeCompare(b.id))\n\n\t\treturn {\n\t\t\turl: this.url,\n\t\t\tmeta,\n\t\t\tapps,\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/app-store.integration.test.ts",
    "content": "import {expect, beforeAll, afterAll, test} from 'vitest'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\nimport runGitServer from '../test-utilities/run-git-server.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\nlet communityAppStoreGitServer: Awaited<ReturnType<typeof runGitServer>>\n\nbeforeAll(async () => {\n\t;[umbreld, communityAppStoreGitServer] = await Promise.all([createTestUmbreld(), runGitServer()])\n})\n\nafterAll(async () => {\n\tawait Promise.all([communityAppStoreGitServer.close(), umbreld.cleanup()])\n})\n\n// The following tests are stateful and must be run in order\n\ntest.sequential('registry() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.appStore.registry.query()).rejects.toThrow('Invalid token')\n})\n\ntest.sequential('addRepository() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.appStore.addRepository.mutate({url: communityAppStoreGitServer.url})).rejects.toThrow(\n\t\t'Invalid token',\n\t)\n})\n\ntest.sequential('removeRepository() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.appStore.removeRepository.mutate({url: communityAppStoreGitServer.url})).rejects.toThrow(\n\t\t'Invalid token',\n\t)\n})\n\ntest.sequential('login', async () => {\n\tawait expect(umbreld.registerAndLogin()).resolves.toBe(true)\n})\n\ntest.sequential('registry() returns app registry', async () => {\n\tawait expect(umbreld.client.appStore.registry.query()).resolves.toStrictEqual([\n\t\t{\n\t\t\turl: umbreld.instance.appStore.defaultAppStoreRepo,\n\t\t\tmeta: {\n\t\t\t\tid: 'sparkles',\n\t\t\t\tname: 'Sparkles',\n\t\t\t},\n\t\t\tapps: [\n\t\t\t\t{\n\t\t\t\t\tappStoreId: 'sparkles',\n\t\t\t\t\tmanifestVersion: '1.0.0',\n\t\t\t\t\tid: 'sparkles-hello-world',\n\t\t\t\t\tname: 'Hello World',\n\t\t\t\t\ttagline: \"Replace this tagline with your app's tagline\",\n\t\t\t\t\ticon: 'https://svgur.com/i/mvA.svg',\n\t\t\t\t\tcategory: 'Development',\n\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\tport: 4000,\n\t\t\t\t\tdescription: \"Add your app's description here.\\n\\nYou can also add newlines!\",\n\t\t\t\t\tdeveloper: 'Umbrel',\n\t\t\t\t\twebsite: 'https://umbrel.com',\n\t\t\t\t\tsubmitter: 'Umbrel',\n\t\t\t\t\tsubmission: 'https://github.com/getumbrel/umbrel-hello-world-app',\n\t\t\t\t\trepo: 'https://github.com/getumbrel/umbrel-hello-world-app',\n\t\t\t\t\tsupport: 'https://github.com/getumbrel/umbrel-hello-world-app/issues',\n\t\t\t\t\tgallery: [\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t],\n\t\t\t\t\treleaseNotes: \"Add what's new in the latest version of your app here.\",\n\t\t\t\t\tdependencies: [],\n\t\t\t\t\tpath: '',\n\t\t\t\t\tdefaultUsername: '',\n\t\t\t\t\tdefaultPassword: '',\n\t\t\t\t\tbackupIgnore: ['data', 'logs', 'cache'],\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t])\n})\n\ntest.sequential('addRepository() adds a second repository', async () => {\n\tawait expect(umbreld.client.appStore.addRepository.mutate({url: communityAppStoreGitServer.url})).resolves.toBe(true)\n})\n\ntest.sequential('registry() returns both app repositories in registry', async () => {\n\tawait expect(umbreld.client.appStore.registry.query()).resolves.toStrictEqual([\n\t\t{\n\t\t\turl: umbreld.instance.appStore.defaultAppStoreRepo,\n\t\t\tmeta: {\n\t\t\t\tid: 'sparkles',\n\t\t\t\tname: 'Sparkles',\n\t\t\t},\n\t\t\tapps: [\n\t\t\t\t{\n\t\t\t\t\tappStoreId: 'sparkles',\n\t\t\t\t\tmanifestVersion: '1.0.0',\n\t\t\t\t\tid: 'sparkles-hello-world',\n\t\t\t\t\tname: 'Hello World',\n\t\t\t\t\ttagline: \"Replace this tagline with your app's tagline\",\n\t\t\t\t\ticon: 'https://svgur.com/i/mvA.svg',\n\t\t\t\t\tcategory: 'Development',\n\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\tport: 4000,\n\t\t\t\t\tdescription: \"Add your app's description here.\\n\\nYou can also add newlines!\",\n\t\t\t\t\tdeveloper: 'Umbrel',\n\t\t\t\t\twebsite: 'https://umbrel.com',\n\t\t\t\t\tsubmitter: 'Umbrel',\n\t\t\t\t\tsubmission: 'https://github.com/getumbrel/umbrel-hello-world-app',\n\t\t\t\t\trepo: 'https://github.com/getumbrel/umbrel-hello-world-app',\n\t\t\t\t\tsupport: 'https://github.com/getumbrel/umbrel-hello-world-app/issues',\n\t\t\t\t\tgallery: [\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t],\n\t\t\t\t\treleaseNotes: \"Add what's new in the latest version of your app here.\",\n\t\t\t\t\tdependencies: [],\n\t\t\t\t\tpath: '',\n\t\t\t\t\tdefaultUsername: '',\n\t\t\t\t\tdefaultPassword: '',\n\t\t\t\t\tbackupIgnore: ['data', 'logs', 'cache'],\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t\t{\n\t\t\turl: communityAppStoreGitServer.url,\n\t\t\tmeta: {\n\t\t\t\tid: 'sparkles',\n\t\t\t\tname: 'Sparkles',\n\t\t\t},\n\t\t\tapps: [\n\t\t\t\t{\n\t\t\t\t\tappStoreId: 'sparkles',\n\t\t\t\t\tmanifestVersion: '1.0.0',\n\t\t\t\t\tid: 'sparkles-hello-world',\n\t\t\t\t\tname: 'Hello World',\n\t\t\t\t\ttagline: \"Replace this tagline with your app's tagline\",\n\t\t\t\t\ticon: 'https://svgur.com/i/mvA.svg',\n\t\t\t\t\tcategory: 'Development',\n\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\tport: 4000,\n\t\t\t\t\tdescription: \"Add your app's description here.\\n\\nYou can also add newlines!\",\n\t\t\t\t\tdeveloper: 'Umbrel',\n\t\t\t\t\twebsite: 'https://umbrel.com',\n\t\t\t\t\tsubmitter: 'Umbrel',\n\t\t\t\t\tsubmission: 'https://github.com/getumbrel/umbrel-hello-world-app',\n\t\t\t\t\trepo: 'https://github.com/getumbrel/umbrel-hello-world-app',\n\t\t\t\t\tsupport: 'https://github.com/getumbrel/umbrel-hello-world-app/issues',\n\t\t\t\t\tgallery: [\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t],\n\t\t\t\t\treleaseNotes: \"Add what's new in the latest version of your app here.\",\n\t\t\t\t\tdependencies: [],\n\t\t\t\t\tpath: '',\n\t\t\t\t\tdefaultUsername: '',\n\t\t\t\t\tdefaultPassword: '',\n\t\t\t\t\tbackupIgnore: ['data', 'logs', 'cache'],\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t])\n})\n\ntest.sequential('addRepository() throws adding a repository that has already been added', async () => {\n\tawait expect(umbreld.client.appStore.addRepository.mutate({url: communityAppStoreGitServer.url})).rejects.toThrow(\n\t\t'already exists',\n\t)\n})\n\ntest.sequential('removeRepository() removes a reposoitory', async () => {\n\tawait expect(umbreld.client.appStore.removeRepository.mutate({url: communityAppStoreGitServer.url})).resolves.toBe(\n\t\ttrue,\n\t)\n})\n\ntest.sequential('registry() no longer returns an app repository that has been removed', async () => {\n\tawait expect(umbreld.client.appStore.registry.query()).resolves.toStrictEqual([\n\t\t{\n\t\t\turl: umbreld.instance.appStore.defaultAppStoreRepo,\n\t\t\tmeta: {\n\t\t\t\tid: 'sparkles',\n\t\t\t\tname: 'Sparkles',\n\t\t\t},\n\t\t\tapps: [\n\t\t\t\t{\n\t\t\t\t\tappStoreId: 'sparkles',\n\t\t\t\t\tmanifestVersion: '1.0.0',\n\t\t\t\t\tid: 'sparkles-hello-world',\n\t\t\t\t\tname: 'Hello World',\n\t\t\t\t\ttagline: \"Replace this tagline with your app's tagline\",\n\t\t\t\t\ticon: 'https://svgur.com/i/mvA.svg',\n\t\t\t\t\tcategory: 'Development',\n\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t\tport: 4000,\n\t\t\t\t\tdescription: \"Add your app's description here.\\n\\nYou can also add newlines!\",\n\t\t\t\t\tdeveloper: 'Umbrel',\n\t\t\t\t\twebsite: 'https://umbrel.com',\n\t\t\t\t\tsubmitter: 'Umbrel',\n\t\t\t\t\tsubmission: 'https://github.com/getumbrel/umbrel-hello-world-app',\n\t\t\t\t\trepo: 'https://github.com/getumbrel/umbrel-hello-world-app',\n\t\t\t\t\tsupport: 'https://github.com/getumbrel/umbrel-hello-world-app/issues',\n\t\t\t\t\tgallery: [\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t\t'https://i.imgur.com/yyVG0Jb.jpeg',\n\t\t\t\t\t],\n\t\t\t\t\treleaseNotes: \"Add what's new in the latest version of your app here.\",\n\t\t\t\t\tdependencies: [],\n\t\t\t\t\tpath: '',\n\t\t\t\t\tdefaultUsername: '',\n\t\t\t\t\tdefaultPassword: '',\n\t\t\t\t\tbackupIgnore: ['data', 'logs', 'cache'],\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t])\n})\n\ntest.sequential('removeRepository() throws removing a reposoitory that does not exist', async () => {\n\tawait expect(umbreld.client.appStore.removeRepository.mutate({url: communityAppStoreGitServer.url})).rejects.toThrow(\n\t\t'does not exist',\n\t)\n})\n\ntest.sequential('removeRepository() throws removing the default reposoitory', async () => {\n\tawait expect(\n\t\tumbreld.client.appStore.removeRepository.mutate({url: umbreld.instance.appStore.defaultAppStoreRepo}),\n\t).rejects.toThrow('Cannot remove default repository')\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/app-store.ts",
    "content": "import pRetry from 'p-retry'\n\nimport type Umbreld from '../../index.js'\nimport runEvery from '../utilities/run-every.js'\nimport AppRepository from './app-repository.js'\n\nexport default class AppStore {\n\t#umbreld: Umbreld\n\t#stopUpdating?: () => void\n\tlogger: Umbreld['logger']\n\tupdateInterval = '5m'\n\tdefaultAppStoreRepo: string\n\tattemptedInitialAppStoreUpdate = false\n\n\tconstructor(umbreld: Umbreld, {defaultAppStoreRepo}: {defaultAppStoreRepo: string}) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(name.toLowerCase())\n\t\tthis.defaultAppStoreRepo = defaultAppStoreRepo\n\t}\n\n\tasync start() {\n\t\tthis.logger.log('Initialising app store')\n\n\t\t// Set default app repository on first start\n\t\tif ((await this.#umbreld.store.get('appRepositories')) === undefined) {\n\t\t\tawait this.#umbreld.store.set('appRepositories', [this.defaultAppStoreRepo])\n\t\t}\n\n\t\t// Initialise repositories\n\t\tthis.logger.log(`Initialising default repository...`)\n\t\tthis.attemptedInitialAppStoreUpdate = false\n\t\ttry {\n\t\t\tconst defaultRepository = await this.getDefaultRepository()\n\t\t\tif (!defaultRepository) throw new Error(`Default repository ${this.defaultAppStoreRepo} not found`)\n\t\t\tawait pRetry(\n\t\t\t\tasync () => {\n\t\t\t\t\tawait defaultRepository.update().finally(() => (this.attemptedInitialAppStoreUpdate = true))\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tonFailedAttempt: (error) => {\n\t\t\t\t\t\tthis.logger.error(\n\t\t\t\t\t\t\t`Failed to initialise default repository ${defaultRepository.url}, will retry ${error.retriesLeft} more times.`,\n\t\t\t\t\t\t\terror,\n\t\t\t\t\t\t)\n\t\t\t\t\t},\n\t\t\t\t\tretries: 5, // This will do exponential backoff for 1s, 2s, 4s, 8s, 16s\n\t\t\t\t},\n\t\t\t)\n\t\t\tthis.logger.log(`Default repository initialised!`)\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to initialise default repository`, error)\n\t\t}\n\n\t\t// Kick off update loop\n\t\tthis.logger.log(`Checking repositories for updates every ${this.updateInterval}`)\n\t\tthis.#stopUpdating = runEvery(this.updateInterval, () => this.update(), {runInstantly: true})\n\t}\n\n\tasync stop() {\n\t\tif (this.#stopUpdating) this.#stopUpdating()\n\t}\n\n\tasync getRepositories() {\n\t\tconst repositoryUrls = await this.#umbreld.store.get('appRepositories')\n\t\tconst repositories = repositoryUrls.map((url) => new AppRepository(this.#umbreld, url))\n\n\t\treturn repositories\n\t}\n\n\tasync getDefaultRepository() {\n\t\tconst repositories = await this.getRepositories()\n\t\treturn repositories.find((repository) => repository.url === this.defaultAppStoreRepo)\n\t}\n\n\tasync update() {\n\t\tconst repositories = await this.getRepositories()\n\t\tif (!repositories) throw new Error('App store not initialised')\n\t\tfor (const repository of repositories) {\n\t\t\ttry {\n\t\t\t\tawait repository.update()\n\t\t\t} catch (error) {\n\t\t\t\tthis.logger.error(`Failed to update ${repository.url}`, error)\n\t\t\t}\n\t\t}\n\t}\n\n\tasync registry() {\n\t\tconst repositories = await this.getRepositories()\n\t\tif (!repositories) throw new Error('App store not initialised')\n\t\tconst registryPromises = repositories.map((repository) =>\n\t\t\trepository.readRegistry().catch((error) => {\n\t\t\t\tthis.logger.error(`Failed to read registry from ${repository.url}`, error)\n\t\t\t\treturn null\n\t\t\t}),\n\t\t)\n\t\tconst registry = await Promise.all(registryPromises)\n\n\t\t// Remove failed reads and fix type definition to not be maybe null\n\t\treturn registry.filter(Boolean) as Array<Awaited<ReturnType<typeof AppRepository.prototype.readRegistry>>>\n\t}\n\n\tasync addRepository(url: string) {\n\t\t// Check if repo already exists\n\t\tconst existingRepositories = await this.getRepositories()\n\t\tif (existingRepositories.some((existingRepo) => existingRepo.url === url)) {\n\t\t\tthrow new Error(`Repository ${url} already exists`)\n\t\t}\n\n\t\tthis.logger.log(`Adding new repository: ${url}`)\n\n\t\t// Create repository instance and initialise it\n\t\tconst repository = new AppRepository(this.#umbreld, url)\n\t\tawait repository.update()\n\n\t\t// Save the repository URL\n\t\tawait this.#umbreld.store.getWriteLock(async ({get, set}) => {\n\t\t\tconst repositoryUrls = await get('appRepositories')\n\t\t\trepositoryUrls.push(url)\n\t\t\tawait set('appRepositories', [...new Set(repositoryUrls)])\n\t\t})\n\n\t\tthis.logger.log(`Added new repository: ${url}`)\n\t\treturn true\n\t}\n\n\tasync removeRepository(url: string) {\n\t\tif (this.defaultAppStoreRepo === url) {\n\t\t\tthrow new Error(`Cannot remove default repository`)\n\t\t}\n\n\t\t// Check if repo exists\n\t\tconst existingRepositories = await this.getRepositories()\n\t\tif (!existingRepositories.some((existingRepo) => existingRepo.url === url)) {\n\t\t\tthrow new Error(`Repository ${url} does not exist`)\n\t\t}\n\n\t\tthis.logger.log(`Removing repository: ${url}`)\n\n\t\t// Remove the repository URL\n\t\tawait this.#umbreld.store.getWriteLock(async ({get, set}) => {\n\t\t\tconst repositoryUrls = await get('appRepositories')\n\t\t\tconst updatedRepositoryUrls = repositoryUrls.filter((repoUrl) => repoUrl !== url)\n\t\t\tawait set('appRepositories', updatedRepositoryUrls)\n\t\t})\n\n\t\tthis.logger.log(`Removed repository: ${url}`)\n\t\treturn true\n\t}\n\n\tasync getAppTemplateFilePath(appId: string) {\n\t\t// Throw on invalid appId\n\t\tif (!/^[a-zA-Z0-9-_]+$/.test(appId)) throw new Error(`Invalid app ID: ${appId}`)\n\n\t\tconst registry = await this.registry()\n\n\t\t// Find the app in the registry\n\t\tfor (const repo of registry) {\n\t\t\tconst app = repo.apps.find((app) => app.id === appId)\n\t\t\tif (app) {\n\t\t\t\t// Find the repository path\n\t\t\t\tconst repositories = await this.getRepositories()\n\t\t\t\tconst repoPath = repositories.find((repository) => repository.url === repo.url)!.path\n\n\t\t\t\tif (!repoPath) throw new Error(`Repository path not found for ${repo.url}`)\n\n\t\t\t\treturn `${repoPath}/${appId}`\n\t\t\t}\n\t\t}\n\n\t\tthrow new Error(`App with ID ${appId} not found in any repository`)\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/app.ts",
    "content": "import crypto from 'node:crypto'\nimport nodePath from 'node:path'\n\nimport fse from 'fs-extra'\nimport yaml from 'js-yaml'\nimport {type Compose} from 'compose-spec-schema'\nimport {$} from 'execa'\nimport fetch from 'node-fetch'\nimport stripAnsi from 'strip-ansi'\nimport pRetry from 'p-retry'\n\nimport getDirectorySize from '../utilities/get-directory-size.js'\nimport {pullAll} from '../utilities/docker-pull.js'\nimport FileStore from '../utilities/file-store.js'\nimport {fillSelectedDependencies} from '../utilities/dependencies.js'\nimport type Umbreld from '../../index.js'\nimport {validateManifest, type AppSettings} from './schema.js'\nimport appScript from './legacy-compat/app-script.js'\n\nasync function readYaml(path: string) {\n\treturn yaml.load(await fse.readFile(path, 'utf8'))\n}\n\nasync function writeYaml(path: string, data: any) {\n\treturn fse.writeFile(path, yaml.dump(data))\n}\n\nexport async function readManifestInDirectory(dataDirectory: string) {\n\tconst parseYaml = readYaml(`${dataDirectory}/umbrel-app.yml`)\n\treturn parseYaml.then(validateManifest)\n}\n\ntype AppState =\n\t| 'unknown'\n\t| 'installing'\n\t| 'starting'\n\t| 'running'\n\t| 'stopping'\n\t| 'stopped'\n\t| 'restarting'\n\t| 'uninstalling'\n\t| 'updating'\n\t| 'ready'\n// TODO: Change ready to running.\n// Also note that we don't currently handle failing events to update the app state into a failed state.\n// That should be ok for now since apps rarely fail, but there will be the potential for state bugs here\n// where the app instance state gets out of sync with the actual state of the app.\n// We can handle this much more robustly in the future.\n\nexport default class App {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\tid: string\n\tdataDirectory: string\n\tstate: AppState = 'unknown'\n\tstateProgress = 0\n\tstore: FileStore<AppSettings>\n\n\tconstructor(umbreld: Umbreld, appId: string) {\n\t\t// Throw on invalid appId\n\t\tif (!/^[a-zA-Z0-9-_]+$/.test(appId)) throw new Error(`Invalid app ID: ${appId}`)\n\n\t\tthis.#umbreld = umbreld\n\t\tthis.id = appId\n\t\tthis.dataDirectory = `${umbreld.dataDirectory}/app-data/${this.id}`\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(name.toLowerCase())\n\t\tthis.store = new FileStore({filePath: `${this.dataDirectory}/settings.yml`})\n\t}\n\n\treadManifest() {\n\t\treturn readManifestInDirectory(this.dataDirectory)\n\t}\n\n\treadCompose() {\n\t\treturn readYaml(`${this.dataDirectory}/docker-compose.yml`) as Promise<Compose>\n\t}\n\n\tasync readHiddenService() {\n\t\ttry {\n\t\t\treturn await fse.readFile(`${this.#umbreld.dataDirectory}/tor/data/app-${this.id}/hostname`, 'utf-8')\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to read hidden service for app ${this.id}`, error)\n\t\t\treturn ''\n\t\t}\n\t}\n\n\tasync deriveDeterministicPassword() {\n\t\tconst umbrelSeed = await fse.readFile(`${this.#umbreld.dataDirectory}/db/umbrel-seed/seed`)\n\t\tconst identifier = `app-${this.id}-seed-APP_PASSWORD`\n\t\tconst deterministicPassword = crypto.createHmac('sha256', umbrelSeed).update(identifier).digest('hex')\n\n\t\treturn deterministicPassword\n\t}\n\n\twriteCompose(compose: Compose) {\n\t\treturn writeYaml(`${this.dataDirectory}/docker-compose.yml`, compose)\n\t}\n\n\tasync patchComposeFile() {\n\t\tconst manifest = await this.readManifest()\n\t\tconst appRequestsGpuAccess = manifest.permissions?.includes('GPU')\n\t\tconst DRI_DEVICE_PATH = '/dev/dri'\n\t\tconst deviceHasGpu = await fse.exists(DRI_DEVICE_PATH).catch(() => false)\n\n\t\tconst compose = await this.readCompose()\n\t\tfor (const serviceName of Object.keys(compose.services!)) {\n\t\t\t// Temporary patch to fix contianer names for modern docker-compose installs.\n\t\t\t// The contianer name scheme used to be <project-name>_<service-name>_1 but\n\t\t\t// recent versions of docker-compose use <project-name>-<service-name>-1\n\t\t\t// swapping underscores for dashes. This breaks Umbrel in places where the\n\t\t\t// containers are referenced via name and it also breaks referring to other\n\t\t\t// containers via DNS since the hostnames are derived with the same method.\n\t\t\t// We manually force all container names to the old scheme to maintain compatibility.\n\t\t\tif (!compose.services![serviceName].container_name) {\n\t\t\t\tcompose.services![serviceName].container_name = `${this.id}_${serviceName}_1`\n\t\t\t}\n\n\t\t\t// Migrate downloads volume from old `${UMBREL_ROOT}/data/storage/downloads` path to new\n\t\t\t// `${UMBREL_ROOT}/home/Downloads` path. Also handle raw data directory migration from\n\t\t\t// `${UMBREL_ROOT}/data/storage` to `${UMBREL_ROOT}/home`.\n\t\t\t// We need to do this here to handle any future app updates.\n\t\t\tcompose.services![serviceName].volumes = compose.services![serviceName].volumes?.map((volume) => {\n\t\t\t\treturn (volume as string)\n\t\t\t\t\t?.replace('/data/storage/downloads', `/home/Downloads`)\n\t\t\t\t\t?.replace('/data/storage', `/home`)\n\t\t\t})\n\n\t\t\t// Pass through host DRI device to all app containers if the app requests it\n\t\t\tconst shouldEnableGpuPassthrough = appRequestsGpuAccess && deviceHasGpu\n\t\t\tif (shouldEnableGpuPassthrough) {\n\t\t\t\tcompose.services![serviceName].devices = compose.services![serviceName].devices || []\n\t\t\t\tcompose.services![serviceName].devices.push(DRI_DEVICE_PATH)\n\t\t\t}\n\t\t}\n\n\t\tawait this.writeCompose(compose)\n\t}\n\n\tasync pull() {\n\t\tconst defaultImages = [\n\t\t\t'getumbrel/app-proxy:1.0.0@sha256:49eb600c4667c4b948055e33171b42a509b7e0894a77e0ca40df8284c77b52fb',\n\t\t\t'getumbrel/tor:0.4.7.8@sha256:2ace83f22501f58857fa9b403009f595137fa2e7986c4fda79d82a8119072b6a',\n\t\t]\n\t\tconst compose = await this.readCompose()\n\t\tconst images = Object.values(compose.services!)\n\t\t\t.map((service) => service.image)\n\t\t\t.filter(Boolean) as string[]\n\t\tawait pullAll([...defaultImages, ...images], (progress) => {\n\t\t\tthis.stateProgress = Math.max(1, progress * 99)\n\t\t\tthis.logger.log(`Downloaded ${this.stateProgress}% of app ${this.id}`)\n\t\t})\n\t}\n\n\tasync install() {\n\t\tthis.state = 'installing'\n\t\tthis.stateProgress = 1\n\n\t\tawait this.patchComposeFile()\n\t\tawait this.pull()\n\n\t\tawait pRetry(() => appScript(this.#umbreld, 'install', this.id), {\n\t\t\tonFailedAttempt: (error) => {\n\t\t\t\tthis.logger.error(\n\t\t\t\t\t`Attempt ${error.attemptNumber} installing app ${this.id} failed. There are ${error.retriesLeft} retries left.`,\n\t\t\t\t\terror,\n\t\t\t\t)\n\t\t\t},\n\t\t\tretries: 2,\n\t\t})\n\t\tthis.state = 'ready'\n\t\tthis.stateProgress = 0\n\n\t\treturn true\n\t}\n\n\tasync update() {\n\t\tthis.state = 'updating'\n\t\tthis.stateProgress = 1\n\n\t\t// TODO: Pull images here before the install script and calculate live progress for\n\t\t// this.stateProgress so button animations work\n\n\t\tthis.logger.log(`Updating app ${this.id}`)\n\n\t\t// Get a reference to the old images\n\t\tconst compose = await this.readCompose()\n\t\tconst oldImages = Object.values(compose.services!)\n\t\t\t.map((service) => service.image)\n\t\t\t.filter(Boolean) as string[]\n\n\t\t// Update the app, patching the compose file half way through\n\t\tawait appScript(this.#umbreld, 'pre-patch-update', this.id)\n\t\tawait this.patchComposeFile()\n\t\tawait this.pull()\n\t\tawait appScript(this.#umbreld, 'post-patch-update', this.id)\n\n\t\t// Delete the old images if we can. Silently fail on error cos docker\n\t\t// will return an error even if only one image is still needed.\n\t\ttry {\n\t\t\tawait $({stdio: 'inherit'})`docker rmi ${oldImages}`\n\t\t} catch {}\n\n\t\tthis.state = 'ready'\n\t\tthis.stateProgress = 0\n\n\t\t// Enable auto-start on boot\n\t\tawait this.setAutoStart(true)\n\n\t\treturn true\n\t}\n\n\tasync start() {\n\t\tthis.logger.log(`Starting app ${this.id}`)\n\t\tthis.state = 'starting'\n\t\t// We re-run the patch here to fix an edge case where 0.5.x imported apps\n\t\t// wont run because they haven't been patched.\n\t\tawait this.patchComposeFile()\n\t\tawait pRetry(() => appScript(this.#umbreld, 'start', this.id), {\n\t\t\tonFailedAttempt: (error) => {\n\t\t\t\tthis.logger.error(\n\t\t\t\t\t`Attempt ${error.attemptNumber} starting app ${this.id} failed. There are ${error.retriesLeft} retries left.`,\n\t\t\t\t\terror,\n\t\t\t\t)\n\t\t\t},\n\t\t\tretries: 2,\n\t\t})\n\t\tthis.state = 'ready'\n\n\t\t// Enable auto-start on boot\n\t\tawait this.setAutoStart(true)\n\n\t\treturn true\n\t}\n\n\tasync stop({persistState = false}: {persistState?: boolean} = {}) {\n\t\tthis.state = 'stopping'\n\t\tawait pRetry(() => appScript(this.#umbreld, 'stop', this.id), {\n\t\t\tonFailedAttempt: (error) => {\n\t\t\t\tthis.logger.error(\n\t\t\t\t\t`Attempt ${error.attemptNumber} stopping app ${this.id} failed. There are ${error.retriesLeft} retries left.`,\n\t\t\t\t\terror,\n\t\t\t\t)\n\t\t\t},\n\t\t\tretries: 2,\n\t\t})\n\t\tthis.state = 'stopped'\n\n\t\t// Disable auto-start on boot\n\t\tif (persistState) {\n\t\t\tawait this.setAutoStart(false)\n\t\t}\n\n\t\treturn true\n\t}\n\n\tasync restart() {\n\t\tthis.state = 'restarting'\n\t\tawait appScript(this.#umbreld, 'stop', this.id)\n\t\tawait appScript(this.#umbreld, 'start', this.id)\n\t\tthis.state = 'ready'\n\n\t\t// Enable auto-start on boot\n\t\tawait this.setAutoStart(true)\n\n\t\treturn true\n\t}\n\n\tasync uninstall() {\n\t\tthis.state = 'uninstalling'\n\t\tawait pRetry(() => appScript(this.#umbreld, 'stop', this.id), {\n\t\t\tonFailedAttempt: (error) => {\n\t\t\t\tthis.logger.error(\n\t\t\t\t\t`Attempt ${error.attemptNumber} stopping app ${this.id} failed. There are ${error.retriesLeft} retries left.`,\n\t\t\t\t\terror,\n\t\t\t\t)\n\t\t\t},\n\t\t\tretries: 2,\n\t\t})\n\t\tawait appScript(this.#umbreld, 'nuke-images', this.id)\n\t\tawait fse.remove(this.dataDirectory)\n\n\t\tawait this.#umbreld.store.getWriteLock(async ({get, set}) => {\n\t\t\tlet apps = (await get('apps')) || []\n\t\t\tapps = apps.filter((appId) => appId !== this.id)\n\t\t\tawait set('apps', apps)\n\n\t\t\t// Remove app from recentlyOpenedApps\n\t\t\tlet recentlyOpenedApps = (await get('recentlyOpenedApps')) || []\n\t\t\trecentlyOpenedApps = recentlyOpenedApps.filter((appId) => appId !== this.id)\n\t\t\tawait set('recentlyOpenedApps', recentlyOpenedApps)\n\n\t\t\t// Disable any associated widgets\n\t\t\tlet widgets = (await get('widgets')) || []\n\t\t\twidgets = widgets.filter((widget) => !widget.startsWith(`${this.id}:`))\n\t\t\tawait set('widgets', widgets)\n\t\t})\n\n\t\treturn true\n\t}\n\n\tasync getPids() {\n\t\tconst compose = await this.readCompose()\n\t\tconst containers = Object.values(compose.services!).map((service) => service.container_name) as string[]\n\t\tcontainers.push(`${this.id}_app_proxy_1`)\n\t\tcontainers.push(`${this.id}_tor_server_1`)\n\t\ttry {\n\t\t\t// If we fail to get the PIDs of one container, skip it and continue for\n\t\t\t// the other containers. We'll expect to get it on some misses for the app\n\t\t\t// proxy and tor server containers.\n\t\t\tconst cmd = containers.map((container) => `docker top ${container} -o pid 2>/dev/null || true`).join('\\n')\n\t\t\tconst {stdout} = await $({shell: true})`${cmd}`\n\t\t\treturn stdout\n\t\t\t\t.split('\\n') // Split on newline\n\t\t\t\t.map((line) => line.trim()) // Trim whitespace\n\t\t\t\t.filter((line) => /^([1-9][0-9]*|0)$/.test(line)) // Keep only integers\n\t\t\t\t.map((line) => parseInt(line, 10)) // And convert\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to get pids for app ${this.id}`, error)\n\t\t\treturn []\n\t\t}\n\t}\n\n\tasync getDiskUsage() {\n\t\ttry {\n\t\t\t// Disk usage calculations can fail if the app is rapidly moving files around\n\t\t\t// since files in directories will be listed and then iterated over to have\n\t\t\t// their size summed up. If a file is moved between these two operations it\n\t\t\t// will fail. It happens rarely so simply retrying will catch most cases.\n\t\t\treturn await pRetry(() => getDirectorySize(this.dataDirectory), {retries: 2})\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to get disk usage for app ${this.id}`, error)\n\t\t\treturn 0\n\t\t}\n\t}\n\n\tasync getLogs() {\n\t\tconst inheritStdio = false\n\t\tconst result = await appScript(this.#umbreld, 'logs', this.id, inheritStdio)\n\t\treturn stripAnsi(result.stdout)\n\t}\n\n\tasync getContainerIp(service: string) {\n\t\t// Retrieve the container name from the compose file\n\t\t// This works because we have a temporary patch to force all container names to the old Compose scheme to maintain compatibility between Compose v1 and v2\n\t\tconst compose = await this.readCompose()\n\t\tconst containerName = compose.services![service].container_name\n\n\t\tif (!containerName) throw new Error(`No container_name found for service ${service} in app ${this.id}`)\n\n\t\tconst {stdout: containerIp} =\n\t\t\tawait $`docker inspect -f {{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}} ${containerName}`\n\n\t\treturn containerIp\n\t}\n\n\t// Returns a validated list of paths that should be ignored when backing up the app\n\t// This allows apps to signal to umbrelOS noncritical high churn or high data files\n\t// that can be ignored from backups like logs/cache/blockchain data/etc.\n\tasync getBackupIgnoredFilePaths() {\n\t\tconst manifest = await this.readManifest()\n\t\tif (!manifest.backupIgnore) return []\n\n\t\t// Sanitise paths\n\t\tconst backupIgnore = []\n\t\tfor (let path of manifest.backupIgnore) {\n\t\t\t// Only allow a limited subset of chars to strip out traversals and other weird stuff we don't want to allow\n\t\t\t// while supporting simple '*' globbing that Kopia understands in .kopiaignore\n\t\t\t// TODO: consider adding other globbing chars like '?' (single-char wildcard) and '**' (recursive wildcard).\n\t\t\tif (!/^[-a-zA-Z0-9._\\/*]+$/.test(path)) {\n\t\t\t\tthis.logger.error(`Invalid backupIgnore path ${path} for app ${this.id}, skipping`)\n\t\t\t\tcontinue // Skip invalid paths\n\t\t\t}\n\n\t\t\t// Convert to absolute path and normalise traversals\n\t\t\tpath = nodePath.join(this.dataDirectory, path)\n\n\t\t\t// Ensure path doesn't escape the app's data directory\n\t\t\tif (!path.startsWith(this.dataDirectory)) {\n\t\t\t\tthis.logger.error(`Invalid backupIgnore path ${path} for app ${this.id}, skipping`)\n\t\t\t\tcontinue // Skip paths that escape the app's data directory\n\t\t\t}\n\n\t\t\t// Save the sanitised path\n\t\t\tbackupIgnore.push(path)\n\t\t}\n\n\t\treturn backupIgnore\n\t}\n\n\t// Returns a specific widget's info from an app's manifest\n\tasync getWidgetMetadata(widgetName: string) {\n\t\tconst manifest = await this.readManifest()\n\t\tif (!manifest.widgets) throw new Error(`No widgets found for app ${this.id}`)\n\n\t\tconst widgetMetadata = manifest.widgets.find((widget) => widget.id === widgetName)\n\t\tif (!widgetMetadata) throw new Error(`Invalid widget ${widgetName} for app ${this.id}`)\n\n\t\treturn widgetMetadata\n\t}\n\n\t// Returns a specific widget's data\n\tasync getWidgetData(widgetId: string) {\n\t\t// Get widget info from the app's manifest\n\t\tconst widgetMetadata = await this.getWidgetMetadata(widgetId)\n\n\t\tconst url = new URL(`http://${widgetMetadata.endpoint}`)\n\t\tconst service = url.hostname\n\n\t\turl.hostname = await this.getContainerIp(service)\n\n\t\ttry {\n\t\t\tconst response = await fetch(url)\n\n\t\t\tif (!response.ok) throw new Error(`Failed to fetch data from ${url}: ${response.statusText}`)\n\n\t\t\tconst widgetData = (await response.json()) as {[key: string]: any}\n\t\t\treturn widgetData\n\t\t} catch (error) {\n\t\t\tif (error instanceof Error) {\n\t\t\t\tthrow new Error(`Failed to fetch data from ${url}: ${error.message}`)\n\t\t\t} else {\n\t\t\t\tthrow new Error(`An unexpected error occured while fetching data from ${url}: ${error}`)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Get the app's dependencies with selected dependencies applied\n\tasync getDependencies() {\n\t\tconst [{dependencies}, selectedDependencies] = await Promise.all([\n\t\t\tthis.readManifest(),\n\t\t\tthis.getSelectedDependencies(),\n\t\t])\n\t\treturn dependencies?.map((dependencyId) => selectedDependencies?.[dependencyId] ?? dependencyId) ?? []\n\t}\n\n\t// Get the app's selected dependencies\n\tasync getSelectedDependencies() {\n\t\tconst [{dependencies}, selectedDependencies] = await Promise.all([\n\t\t\tthis.readManifest(),\n\t\t\tthis.store.get('dependencies'),\n\t\t])\n\t\treturn fillSelectedDependencies(dependencies, selectedDependencies)\n\t}\n\n\t// Set the app's selected dependencies\n\tasync setSelectedDependencies(selectedDependencies: Record<string, string>) {\n\t\tconst {dependencies} = await this.readManifest()\n\t\tconst filledSelectedDependencies = fillSelectedDependencies(dependencies, selectedDependencies)\n\t\tconst success = await this.store.set('dependencies', filledSelectedDependencies)\n\t\tif (success) {\n\t\t\tthis.restart().catch((error) => {\n\t\t\t\tthis.logger.error(`Failed to restart '${this.id}'`, error)\n\t\t\t})\n\t\t}\n\t\treturn success\n\t}\n\n\t// Check if app is ignored from backups\n\tasync isBackupIgnored() {\n\t\treturn (await this.store.get('backupIgnore')) || false\n\t}\n\n\t// Set if app is ignored from backups\n\tasync setBackupIgnored(backupIgnore: boolean) {\n\t\treturn this.store.set('backupIgnore', backupIgnore)\n\t}\n\n\t// Set if app should auto start on boot\n\tasync setAutoStart(autoStart: boolean) {\n\t\treturn this.store.set('autoStart', autoStart)\n\t}\n\n\t// Get if app should auto start on boot\n\tasync shouldAutoStart() {\n\t\treturn (await this.store.get('autoStart')) ?? true\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/apps.integration.test.ts",
    "content": "import {setTimeout} from 'node:timers/promises'\nimport path from 'node:path'\nimport {expect, beforeAll, afterAll, test, vi} from 'vitest'\nimport fse from 'fs-extra'\nimport yaml from 'js-yaml'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\nimport {BACKUP_RESTORE_FIRST_START_FLAG} from '../../constants.js'\nimport runGitServer from '../test-utilities/run-git-server.js'\nimport type {AppManifest} from './schema.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\nlet communityAppStoreGitServer: Awaited<ReturnType<typeof runGitServer>>\n\nbeforeAll(async () => {\n\t;[umbreld, communityAppStoreGitServer] = await Promise.all([createTestUmbreld(), runGitServer()])\n})\n\nafterAll(async () => {\n\tawait Promise.all([communityAppStoreGitServer.close(), umbreld.cleanup()])\n})\n\n// The following tests are stateful and must be run in order\n\ntest.sequential('list() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.apps.list.query()).rejects.toThrow('Invalid token')\n})\n\ntest.sequential('install() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.apps.install.mutate({appId: 'sparkles-hello-world'})).rejects.toThrow('Invalid token')\n})\n\ntest.sequential('state() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.apps.state.query({appId: 'sparkles-hello-world'})).rejects.toThrow('Invalid token')\n})\n\ntest.sequential('restart() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.apps.restart.mutate({appId: 'sparkles-hello-world'})).rejects.toThrow('Invalid token')\n})\n\ntest.sequential('update() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.apps.update.mutate({appId: 'sparkles-hello-world'})).rejects.toThrow('Invalid token')\n})\n\ntest.sequential('trackOpen() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.apps.trackOpen.mutate({appId: 'sparkles-hello-world'})).rejects.toThrow('Invalid token')\n})\n\ntest.sequential('trackOpen() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.apps.setTorEnabled.mutate(true)).rejects.toThrow('Invalid token')\n})\n\ntest.sequential('getBackupIgnoredPaths() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.apps.getBackupIgnoredPaths.query({appId: 'sparkles-hello-world'})).rejects.toThrow(\n\t\t'Invalid token',\n\t)\n})\n\ntest.sequential('login', async () => {\n\tawait expect(umbreld.registerAndLogin()).resolves.toBe(true)\n})\n\ntest.sequential('list() returns no apps when none are installed', async () => {\n\tconst installedApps = await umbreld.client.apps.list.query()\n\texpect(installedApps.length).toStrictEqual(0)\n})\n\ntest.sequential('install() throws error on unknown app id', async () => {\n\tawait expect(umbreld.client.apps.install.mutate({appId: 'unknown-app-id'})).rejects.toThrow('not found')\n})\n\ntest.sequential('install() throws error on invalid app id', async () => {\n\tawait expect(umbreld.client.apps.install.mutate({appId: 'invalid-id-@/!'})).rejects.toThrow('Invalid')\n})\n\ntest.sequential('restart() throws error on unknown app id', async () => {\n\tawait expect(umbreld.client.apps.restart.mutate({appId: 'sparkles-hello-world'})).rejects.toThrow('not found')\n})\n\ntest.sequential('update() throws error on unknown app id', async () => {\n\tawait expect(umbreld.client.apps.update.mutate({appId: 'sparkles-hello-world'})).rejects.toThrow('not found')\n})\n\ntest.sequential('trackOpen() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.apps.trackOpen.mutate({appId: 'sparkles-hello-world'})).rejects.toThrow('not found')\n})\n\ntest.sequential('getBackupIgnoredPaths() throws error on unknown app id', async () => {\n\tawait expect(umbreld.client.apps.getBackupIgnoredPaths.query({appId: 'sparkles-hello-world'})).rejects.toThrow(\n\t\t'not found',\n\t)\n})\n\ntest.sequential('install() installs an app', async () => {\n\tawait expect(umbreld.client.apps.install.mutate({appId: 'sparkles-hello-world'})).resolves.toStrictEqual(true)\n})\n\ntest.sequential('state() shows app install state', async () => {\n\tawait expect(umbreld.client.apps.state.query({appId: 'sparkles-hello-world'})).resolves.toSatisfy((value) =>\n\t\t['installing', 'ready'].includes((value as any).state),\n\t)\n\t// TODO: Test this more extensively once we've implemented the behaviour\n})\n\ntest.sequential('state() becomes ready once install completes', async () => {\n\tlet lastState: any\n\tdo {\n\t\tlastState = await umbreld.client.apps.state.query({appId: 'sparkles-hello-world'})\n\t\tif (lastState && lastState.state === 'ready') break\n\t\tawait setTimeout(1000)\n\t} while (true)\n\tawait expect(lastState).toMatchObject({state: 'ready'})\n})\n\ntest.sequential('list() lists installed apps', async () => {\n\tawait expect(umbreld.client.apps.list.query()).resolves.toMatchObject([\n\t\t{\n\t\t\tid: 'sparkles-hello-world',\n\t\t\tname: 'Hello World',\n\t\t\ticon: 'https://svgur.com/i/mvA.svg',\n\t\t\tport: 4000,\n\t\t\tcredentials: {\n\t\t\t\tdefaultUsername: '',\n\t\t\t\tdefaultPassword: '',\n\t\t\t},\n\t\t\tdependencies: [],\n\t\t\thiddenService: '',\n\t\t\tpath: '',\n\t\t\tstate: 'ready',\n\t\t\tversion: '1.0.0',\n\t\t},\n\t])\n})\n\ntest.sequential('getBackupIgnoredPaths() returns sanitised absolute paths for installed app', async () => {\n\tconst dataDir = umbreld.instance.dataDirectory\n\tconst expected = ['data', 'logs', 'cache'].map((p) => path.join(dataDir, 'app-data', 'sparkles-hello-world', p))\n\tawait expect(umbreld.client.apps.getBackupIgnoredPaths.query({appId: 'sparkles-hello-world'})).resolves.toStrictEqual(\n\t\texpected,\n\t)\n})\n\ntest.sequential(\"getBackupIgnoredPaths() supports '*' globs\", async () => {\n\t// Modify manifest to include glob patterns\n\tconst manifestPath = path.join(umbreld.instance.dataDirectory, 'app-data', 'sparkles-hello-world', 'umbrel-app.yml')\n\tconst manifest = yaml.load(await fse.readFile(manifestPath, 'utf8')) as AppManifest\n\tmanifest.backupIgnore = ['data/*', 'logs/*']\n\tawait fse.writeFile(manifestPath, yaml.dump(manifest))\n\n\t// Compute expected absolute paths for valid entries\n\tconst base = path.join(umbreld.instance.dataDirectory, 'app-data', 'sparkles-hello-world')\n\tconst expected = [path.join(base, 'data/*'), path.join(base, 'logs/*')]\n\n\tconst result = await umbreld.client.apps.getBackupIgnoredPaths.query({appId: 'sparkles-hello-world'})\n\n\t// Should include valid globbed paths\n\texpect(result).toEqual(expected)\n})\n\ntest.sequential('getBackupIgnoredPaths() ignores unsupported globbing characters', async () => {\n\t// Modify manifest to include unsupported glob patterns\n\tconst manifestPath = path.join(umbreld.instance.dataDirectory, 'app-data', 'sparkles-hello-world', 'umbrel-app.yml')\n\tconst manifest = yaml.load(await fse.readFile(manifestPath, 'utf8')) as AppManifest\n\tmanifest.backupIgnore = [\n\t\t'logs/*', // valid simple glob we support\n\t\t'logs/?', // unsupported single-char glob\n\t\t'logs/[a]', // unsupported character class\n\t\t'logs/{a}', // unsupported brace expansion\n\t]\n\tawait fse.writeFile(manifestPath, yaml.dump(manifest))\n\n\t// Expect only the valid '*' glob to be returned (sanitised absolute path)\n\tconst base = path.join(umbreld.instance.dataDirectory, 'app-data', 'sparkles-hello-world')\n\tconst expected = [path.join(base, 'logs/*')]\n\n\tconst result = await umbreld.client.apps.getBackupIgnoredPaths.query({appId: 'sparkles-hello-world'})\n\n\texpect(result).toEqual(expected)\n})\n\ntest.sequential('getBackupIgnoredPaths() returns empty array when app has no backupIgnore paths', async () => {\n\t// Remove backupIgnore from installed app's manifest\n\tconst manifestPath = path.join(umbreld.instance.dataDirectory, 'app-data', 'sparkles-hello-world', 'umbrel-app.yml')\n\tconst original = yaml.load(await fse.readFile(manifestPath, 'utf8')) as AppManifest\n\tdelete original.backupIgnore\n\tawait fse.writeFile(manifestPath, yaml.dump(original))\n\n\tawait expect(umbreld.client.apps.getBackupIgnoredPaths.query({appId: 'sparkles-hello-world'})).resolves.toStrictEqual(\n\t\t[],\n\t)\n})\n\ntest.sequential('auto-reinstalls app when data directory is missing on first boot after restore', async () => {\n\t// Ensure the app is currently installed (from previous sequential test)\n\tconst preApps = await umbreld.client.apps.list.query()\n\texpect(preApps.some((a: any) => a.id === 'sparkles-hello-world')).toBe(true)\n\n\t// Simulate excluded-from-backup state by removing the app's data directory while keeping the app ID in the store\n\tawait umbreld.instance.stop()\n\tconst appDataDir = path.join(umbreld.instance.dataDirectory, 'app-data', 'sparkles-hello-world')\n\tawait fse.remove(appDataDir)\n\n\t// Touch the restore-first-start marker to indicate this is a restore boot\n\tconst restoreFlagPath = path.join(umbreld.instance.dataDirectory, BACKUP_RESTORE_FIRST_START_FLAG)\n\tawait fse.ensureFile(restoreFlagPath)\n\n\t// Start umbreld; missing app should be auto-reinstalled in background\n\tawait umbreld.instance.start()\n\t// Re-install can complete quickly so we skip asserting initial absence to avoid flakiness.\n\n\t// Poll until the app reaches ready state (auto-installed and started)\n\tlet ready = false\n\tfor (let i = 0; i < 60; i++) {\n\t\tconst state: any = await umbreld.client.apps.state.query({appId: 'sparkles-hello-world'}).catch(() => null)\n\t\tif (state?.state === 'ready') {\n\t\t\tready = true\n\t\t\tbreak\n\t\t}\n\t\tawait setTimeout(1000)\n\t}\n\texpect(ready).toBe(true)\n})\n\ntest.sequential('does not missing data-dir app on non-restore boot', async () => {\n\t// Remove data dir without creating the restore marker\n\tawait umbreld.instance.stop()\n\tconst appDataDir = path.join(umbreld.instance.dataDirectory, 'app-data', 'sparkles-hello-world')\n\tawait fse.remove(appDataDir)\n\n\t// We spy on apps.install to prove \"no scheduling occurred\" when the marker is absent.\n\tconst installSpy = vi.spyOn(umbreld.instance.apps, 'install')\n\n\t// Reset the per-boot flag that was set to true by the previous test\n\tumbreld.instance.isBackupRestoreFirstStart = false\n\n\t// Start umbreld; without marker we should NOT auto-reinstall (i.e., install should never be called)\n\tawait umbreld.instance.start()\n\n\t// Wait a few seconds then assert no install was invoked\n\tawait setTimeout(5000)\n\texpect(installSpy).not.toHaveBeenCalled()\n\t// And the data directory should still be missing\n\tawait expect(fse.pathExists(appDataDir)).resolves.toBe(false)\n\n\tinstallSpy.mockRestore()\n})\n\ntest.sequential('restart() restarts an installed app', async () => {\n\t// Ensure installed for restart (previous tests may leave it uninstalled)\n\tawait umbreld.client.apps.install.mutate({appId: 'sparkles-hello-world'}).catch(() => {})\n\tawait expect(umbreld.client.apps.restart.mutate({appId: 'sparkles-hello-world'})).resolves.toStrictEqual(true)\n\t// TODO: Check this actually worked\n})\n\ntest.sequential('update() updates an installed app', async () => {\n\tawait expect(umbreld.client.apps.update.mutate({appId: 'sparkles-hello-world'})).resolves.toStrictEqual(true)\n\t// TODO: Check this actually worked\n})\n\ntest.sequential(\"umbreld restart doesn't start stopped apps\", async () => {\n\t// Stop the app\n\tawait expect(umbreld.client.apps.stop.mutate({appId: 'sparkles-hello-world'})).resolves.toStrictEqual(true)\n\n\t// Restart umbreld\n\tawait umbreld.instance.stop()\n\tawait umbreld.instance.start()\n\n\t// Verify the previously stopped app is still stopped\n\tawait expect(umbreld.client.apps.state.query({appId: 'sparkles-hello-world'})).resolves.toMatchObject({\n\t\tstate: 'stopped',\n\t\tprogress: 0,\n\t})\n})\n\ntest.sequential('umbreld restart starts all non-stopped apps', async () => {\n\t// Start the previosly stopped app\n\tawait expect(umbreld.client.apps.start.mutate({appId: 'sparkles-hello-world'})).resolves.toStrictEqual(true)\n\n\t// Restart umbreld\n\tawait umbreld.instance.stop()\n\tawait umbreld.instance.start()\n\n\t// Verify the previously stopped app has started\n\tawait expect(umbreld.client.apps.state.query({appId: 'sparkles-hello-world'})).resolves.toSatisfy((value) =>\n\t\t['starting', 'ready'].includes((value as any).state),\n\t)\n})\n\ntest.sequential('trackOpen() tracks an app open', async () => {\n\tawait expect(umbreld.client.apps.update.mutate({appId: 'sparkles-hello-world'})).resolves.toStrictEqual(true)\n\t// TODO: Check this actually worked\n})\n\ntest.sequential('setTorEnabled() toggles the Tor setting', async () => {\n\tawait expect(umbreld.client.apps.setTorEnabled.mutate(true)).resolves.toStrictEqual(true)\n\tawait expect(umbreld.client.apps.getTorEnabled.query()).resolves.toStrictEqual(true)\n\tawait expect(umbreld.client.apps.setTorEnabled.mutate(false)).resolves.toStrictEqual(true)\n\tawait expect(umbreld.client.apps.getTorEnabled.query()).resolves.toStrictEqual(false)\n})\n\ntest.sequential('uninstall() uninstalls an app', async () => {\n\tawait expect(umbreld.client.apps.uninstall.mutate({appId: 'sparkles-hello-world'})).resolves.toStrictEqual(true)\n\tconst installedApps = await umbreld.client.apps.list.query()\n})\n\ntest.sequential('list() lists no apps after uninstall', async () => {\n\tconst installedApps = await umbreld.client.apps.list.query()\n\texpect(installedApps.length).toStrictEqual(0)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/apps.ts",
    "content": "import {fileURLToPath} from 'node:url'\nimport {dirname, join} from 'node:path'\n\nimport fse from 'fs-extra'\nimport {$} from 'execa'\nimport pRetry from 'p-retry'\nimport semver from 'semver'\n\nimport randomToken from '../../modules/utilities/random-token.js'\nimport type Umbreld from '../../index.js'\nimport appEnvironment from './legacy-compat/app-environment.js'\nimport type {AppSettings} from './schema.js'\nimport App, {readManifestInDirectory} from './app.js'\nimport type {AppManifest} from './schema.js'\nimport {fillSelectedDependencies} from '../utilities/dependencies.js'\n\nexport default class Apps {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\tinstances: App[] = []\n\tisTorBeingToggled = false\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(name.toLowerCase())\n\t}\n\n\t// This is a really brutal and heavy handed way of cleaning up old Docker state.\n\t// We should only do this sparingly. It's needed if an old version of Docker\n\t// didn't shutdown cleanly and then we update to a new version of Docker.\n\t// The next version of Docker can have issues starting containers if the old\n\t// containers/networks are still hanging around. We had this issue because sometimes\n\t// 0.5.4 installs didn't clean up properly on shutdown and it causes critical errors\n\t// bringing up containers in 1.0.\n\tasync cleanDockerState() {\n\t\ttry {\n\t\t\tconst containerIds = (await $`docker ps -aq`).stdout.split('\\n').filter(Boolean)\n\t\t\tif (containerIds.length) {\n\t\t\t\tthis.logger.log('Cleaning up old containers...')\n\t\t\t\tawait $({stdio: 'inherit'})`docker stop --time 30 ${containerIds}`\n\t\t\t\tawait $({stdio: 'inherit'})`docker rm ${containerIds}`\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to clean containers`, error)\n\t\t}\n\t\ttry {\n\t\t\tthis.logger.log('Cleaning up old networks...')\n\t\t\tawait $({stdio: 'inherit'})`docker network prune -f`\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to clean networks`, error)\n\t\t}\n\t}\n\n\tasync start() {\n\t\t// Set apps to empty array on first start\n\t\tif ((await this.#umbreld.store.get('apps')) === undefined) {\n\t\t\tawait this.#umbreld.store.set('apps', [])\n\t\t}\n\n\t\t// Set torEnabled to false on first start\n\t\tif ((await this.#umbreld.store.get('torEnabled')) === undefined) {\n\t\t\tawait this.#umbreld.store.set('torEnabled', false)\n\t\t}\n\n\t\t// Set recentlyOpenedApps to empty array on first start\n\t\tif ((await this.#umbreld.store.get('recentlyOpenedApps')) === undefined) {\n\t\t\tawait this.#umbreld.store.set('recentlyOpenedApps', [])\n\t\t}\n\n\t\t// Create a random umbrel seed on first start if one doesn't exist.\n\t\t// This is only used to determinstically derive app seed, app password\n\t\t// and custom app specific environment variables. It's needed to maintain\n\t\t// compatibility with legacy apps. In the future we'll migrate to apps\n\t\t// storing their own random seed/password/etc inside their own data directory.\n\t\tconst umbrelSeedFile = `${this.#umbreld.dataDirectory}/db/umbrel-seed/seed`\n\t\tif (!(await fse.exists(umbrelSeedFile))) {\n\t\t\tthis.logger.log('Creating Umbrel seed')\n\t\t\tawait fse.ensureFile(umbrelSeedFile)\n\t\t\tawait fse.writeFile(umbrelSeedFile, randomToken(256))\n\t\t}\n\n\t\t// Setup bin dir\n\t\ttry {\n\t\t\tconst currentFilename = fileURLToPath(import.meta.url)\n\t\t\tconst currentDirname = dirname(currentFilename)\n\t\t\tconst binSourcePath = join(currentDirname, 'legacy-compat/bin')\n\t\t\tconst binDestPath = `${this.#umbreld.dataDirectory}/bin`\n\t\t\tawait fse.mkdirp(binDestPath)\n\t\t\tconst bins = await fse.readdir(binSourcePath)\n\t\t\tthis.logger.log(`Copying bins to ${binDestPath}`)\n\t\t\tfor (const bin of bins) {\n\t\t\t\tthis.logger.log(`Copying ${bin}`)\n\t\t\t\tconst source = join(binSourcePath, bin)\n\t\t\t\tconst dest = join(binDestPath, bin)\n\t\t\t\tawait fse.copyFile(source, dest)\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to copy bins`, error)\n\t\t}\n\n\t\t// Create app instances\n\t\tconst appIds = await this.#umbreld.store.get('apps')\n\t\tthis.instances = appIds.map((appId) => new App(this.#umbreld, appId))\n\n\t\t// Don't save references to any apps that don't have a data directory on\n\t\t// startup. This will allow apps that were excluded from backups to be\n\t\t// reinstalled when the system is restored. Otherwise they'll have an id\n\t\t// entry but no data dir and will be stuck in a `not-running` state.\n\t\tconst appIdsMissingDataDir: string[] = []\n\t\tfor (const app of this.instances) {\n\t\t\tconst appDataDirectoryExists = await fse.pathExists(app.dataDirectory).catch(() => false)\n\t\t\tif (!appDataDirectoryExists) {\n\t\t\t\tthis.logger.error(`App ${app.id} does not have a data directory, removing from instances`)\n\t\t\t\tthis.instances = this.instances.filter((instanceApp) => instanceApp.id !== app.id)\n\t\t\t\tappIdsMissingDataDir.push(app.id)\n\t\t\t}\n\t\t}\n\n\t\t// Force the app state to starting so users don't get confused.\n\t\t// They aren't actually starting yet, we need to make sure the app env is up first.\n\t\t// But if that takes a long time users see all their apps listed as not running and\n\t\t// get confused.\n\t\tfor (const app of this.instances) app.state = 'starting'\n\n\t\t// Attempt to pre-load local Docker images\n\t\ttry {\n\t\t\t// Loop over iamges in /images\n\t\t\tconst images = await fse.readdir(`/images`)\n\t\t\tawait Promise.all(\n\t\t\t\timages.map(async (image) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tthis.logger.log(`Pre-loading local Docker image ${image}`)\n\t\t\t\t\t\tawait $({stdio: 'inherit'})`docker load --input /images/${image}`\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tthis.logger.error(`Failed to pre-load local Docker image ${image}`, error)\n\t\t\t\t\t}\n\t\t\t\t}),\n\t\t\t)\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to pre-load local Docker images`, error)\n\t\t}\n\n\t\t// Start app environment\n\t\ttry {\n\t\t\ttry {\n\t\t\t\tawait appEnvironment(this.#umbreld, 'up')\n\t\t\t} catch (error) {\n\t\t\t\tthis.logger.error(`Failed to start app environment`, error)\n\t\t\t\tthis.logger.log('Attempting to clean Docker state before retrying...')\n\t\t\t\tawait this.cleanDockerState()\n\t\t\t}\n\t\t\tawait pRetry(() => appEnvironment(this.#umbreld, 'up'), {\n\t\t\t\tonFailedAttempt: (error) => {\n\t\t\t\t\tthis.logger.error(\n\t\t\t\t\t\t`Attempt ${error.attemptNumber} starting app environmnet failed. There are ${error.retriesLeft} retries left.`,\n\t\t\t\t\t\terror,\n\t\t\t\t\t)\n\t\t\t\t},\n\t\t\t\tretries: 2, // This will do exponential backoff for 1s, 2s\n\t\t\t})\n\t\t} catch (error) {\n\t\t\t// Log the error but continue to try to bring apps up to make it a less bad failure\n\t\t\tthis.logger.error(`Failed to start app environment`, error)\n\t\t}\n\n\t\ttry {\n\t\t\t// Set permissions for tor data directory\n\t\t\tawait $`sudo chown -R 1000:1000 ${this.#umbreld.dataDirectory}/tor`\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to set permissions for Tor data directory`, error)\n\t\t}\n\n\t\tthis.logger.log('Starting apps')\n\t\t// Snapshot of currently installed apps (minus apps missing their data directories that will be reinstalled)\n\t\t// We start these apps (save Promise), fire reinstalls without awaiting, then await the starts.\n\t\tconst appsToStart = [...this.instances]\n\t\tconst startAppsPromise = Promise.all(\n\t\t\tappsToStart.map(async (app) => {\n\t\t\t\tconst shouldStart = await app.shouldAutoStart()\n\t\t\t\tif (!shouldStart) {\n\t\t\t\t\tthis.logger.log(`Skipping app ${app.id} (autoStart disabled)`)\n\t\t\t\t\tapp.state = 'stopped'\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\treturn app.start().catch((error) => {\n\t\t\t\t\t// We handle individual errors here to prevent apps start from throwing\n\t\t\t\t\t// if a single app fails.\n\t\t\t\t\tapp.state = 'unknown'\n\t\t\t\t\tthis.logger.error(`Failed to start app ${app.id}`, error)\n\t\t\t\t})\n\t\t\t}),\n\t\t)\n\n\t\t// If this is the first boot after a backup restore, we kick off reinstalls of any apps that are missing their data directory.\n\t\t// e.g., due to restoring a backup where the app was excluded.\n\t\t// We fire and forget here so users see apps installing as soon as possible.\n\t\tthis.reinstallMissingAppsAfterRestore(appIdsMissingDataDir).catch((error) =>\n\t\t\tthis.logger.error('Failed to schedule app reinstalls after restore', error),\n\t\t)\n\n\t\t// Wait for current installed apps to finish starting\n\t\tawait startAppsPromise\n\t}\n\n\tprivate async reinstallMissingAppsAfterRestore(appIds: string[]) {\n\t\t// Only run on the first start after a backup restore\n\t\tif (!this.#umbreld.isBackupRestoreFirstStart) return\n\n\t\t// If there are no apps to reinstall, return early\n\t\tif (appIds.length === 0) return\n\n\t\tthis.logger.log(`Detected ${appIds.length} app(s) missing a data directory after restore, reinstalling...`)\n\t\ttry {\n\t\t\t// Best effort retry to ensure app repositories are pulled before reinstalling\n\t\t\t// app stores are excluded from backups so first boot after recovery won't have them.\n\t\t\tawait pRetry(\n\t\t\t\tasync () => {\n\t\t\t\t\tawait this.#umbreld.appStore.update()\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tretries: 3,\n\t\t\t\t\tonFailedAttempt: (error) => {\n\t\t\t\t\t\tthis.logger.error(\n\t\t\t\t\t\t\t`Failed to update app store before reinstalls (attempt ${error.attemptNumber}, ${error.retriesLeft} retries left).`,\n\t\t\t\t\t\t\terror,\n\t\t\t\t\t\t)\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t)\n\t\t} catch (error) {\n\t\t\tthis.logger.error('Exhausted retries updating app store before reinstalls', error)\n\n\t\t\t// If we fail, we return early because no appstore repos exist and installs will fail\n\t\t\t// We won't retry on a later boot (marker file already deleted).\n\t\t\treturn\n\t\t}\n\n\t\tfor (const appId of appIds) {\n\t\t\t// Fire off all installs in parallel without blocking\n\t\t\t// TODO: Consider adding concurrency limiting for app installs to avoid overwhelming system resources\n\t\t\tthis.install(appId).catch((error) => this.logger.error(`Failed to reinstall app ${appId}`, error))\n\t\t}\n\t}\n\n\tasync stop() {\n\t\tthis.logger.log('Stopping apps')\n\t\tawait Promise.all(\n\t\t\tthis.instances.map((app) =>\n\t\t\t\tapp.stop().catch((error) => {\n\t\t\t\t\t// We handle individual errors here to prevent apps stop from throwing\n\t\t\t\t\t// if a single app fails.\n\t\t\t\t\tthis.logger.error(`Failed to stop app ${app.id}`, error)\n\t\t\t\t}),\n\t\t\t),\n\t\t)\n\n\t\tthis.logger.log('Stopping app environment')\n\t\tawait pRetry(() => appEnvironment(this.#umbreld, 'down'), {\n\t\t\tonFailedAttempt: (error) => {\n\t\t\t\tthis.logger.error(\n\t\t\t\t\t`Attempt ${error.attemptNumber} stopping app environmnet failed. There are ${error.retriesLeft} retries left.`,\n\t\t\t\t)\n\t\t\t},\n\t\t\tretries: 2,\n\t\t})\n\t}\n\n\tasync isInstalled(appId: string) {\n\t\treturn this.instances.some((app) => app.id === appId)\n\t}\n\n\tgetApp(appId: string) {\n\t\tconst app = this.instances.find((app) => app.id === appId)\n\t\tif (!app) throw new Error(`App ${appId} not found`)\n\n\t\treturn app\n\t}\n\n\tasync install(appId: string, alternatives?: AppSettings['dependencies']) {\n\t\tif (await this.isInstalled(appId)) throw new Error(`App ${appId} is already installed`)\n\n\t\tthis.logger.log(`Installing app ${appId}`)\n\t\tconst appTemplatePath = await this.#umbreld.appStore.getAppTemplateFilePath(appId)\n\n\t\tlet manifest: AppManifest\n\t\ttry {\n\t\t\tmanifest = await readManifestInDirectory(appTemplatePath)\n\t\t} catch {\n\t\t\tthrow new Error('App template not found')\n\t\t}\n\t\tconst manifestVersionValid = semver.valid(manifest.manifestVersion)\n\t\tif (!manifestVersionValid) {\n\t\t\tthrow new Error('App manifest version is invalid')\n\t\t}\n\t\tconst umbrelVersionValid = semver.valid(this.#umbreld.version)\n\t\tconst manifestVersionIsSupported = !!umbrelVersionValid && semver.lte(manifestVersionValid, umbrelVersionValid)\n\t\tif (!manifestVersionIsSupported) {\n\t\t\tthrow new Error(`App manifest version not supported`)\n\t\t}\n\n\t\tthis.logger.log(`Setting up data directory for ${appId}`)\n\t\tconst appDataDirectory = `${this.#umbreld.dataDirectory}/app-data/${appId}`\n\t\tawait fse.mkdirp(appDataDirectory)\n\n\t\t// We use rsync to copy to preserve permissions\n\t\tawait $`rsync --archive --verbose --exclude \".gitkeep\" ${appTemplatePath}/. ${appDataDirectory}`\n\n\t\t// Save reference to app instance\n\t\tconst app = new App(this.#umbreld, appId)\n\t\tconst filledSelectedDependencies = fillSelectedDependencies(manifest.dependencies, alternatives)\n\t\tawait app.store.set('dependencies', filledSelectedDependencies)\n\t\tthis.instances.push(app)\n\n\t\t// Complete the install process via the app script\n\t\ttry {\n\t\t\t// We quickly try to start the app env before installing the app. In most normal cases\n\t\t\t// this just quickly returns and does nothing since the app env is already running.\n\t\t\t// However in the case where the app env is down this ensures we start it again.\n\t\t\tawait appEnvironment(this.#umbreld, 'up')\n\t\t\tawait app.install()\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to install app ${appId}`, error)\n\t\t\tthis.instances = this.instances.filter((app) => app.id !== appId)\n\t\t\treturn false\n\t\t}\n\n\t\t// Save installed app\n\t\tawait this.#umbreld.store.getWriteLock(async ({get, set}) => {\n\t\t\tlet apps = await get('apps')\n\t\t\tapps.push(appId)\n\t\t\t// Make sure we never add dupes\n\t\t\t// This can happen after restoring a backup with an excluded app and then reinstalling it\n\t\t\tapps = [...new Set(apps)]\n\t\t\tawait set('apps', apps)\n\t\t})\n\n\t\treturn true\n\t}\n\n\tasync uninstall(appId: string) {\n\t\t// If we can't read an app's dependencies for any reason just skip that app, don't abort the uninstall\n\t\tconst allDependencies = await Promise.all(this.instances.map((app) => app.getDependencies().catch(() => null)))\n\t\tconst isDependency = allDependencies.some((dependencies) => dependencies?.includes(appId))\n\t\tif (isDependency) throw new Error(`App ${appId} is a dependency of another app and cannot be uninstalled`)\n\n\t\tconst app = this.getApp(appId)\n\n\t\tconst uninstalled = await app.uninstall()\n\t\tif (uninstalled) {\n\t\t\t// Remove app instance\n\t\t\tthis.instances = this.instances.filter((app) => app.id !== appId)\n\t\t}\n\t\treturn uninstalled\n\t}\n\n\tasync restart(appId: string) {\n\t\tconst app = this.getApp(appId)\n\n\t\treturn app.restart()\n\t}\n\n\tasync update(appId: string) {\n\t\tconst app = this.getApp(appId)\n\n\t\treturn app.update()\n\t}\n\n\tasync trackOpen(appId: string) {\n\t\tconst app = this.getApp(appId)\n\n\t\t// Save installed app\n\t\tawait this.#umbreld.store.getWriteLock(async ({get, set}) => {\n\t\t\tlet recentlyOpenedApps = await get('recentlyOpenedApps')\n\n\t\t\t// Add app.id to the beginning of the array\n\t\t\trecentlyOpenedApps.unshift(app.id)\n\n\t\t\t// Remove duplicates\n\t\t\trecentlyOpenedApps = [...new Set(recentlyOpenedApps)]\n\n\t\t\t// Limit to 10\n\t\t\trecentlyOpenedApps = recentlyOpenedApps.slice(0, 10)\n\n\t\t\tawait set('recentlyOpenedApps', recentlyOpenedApps)\n\t\t})\n\n\t\treturn true\n\t}\n\n\tasync recentlyOpened() {\n\t\treturn this.#umbreld.store.get('recentlyOpenedApps')\n\t}\n\n\tasync setTorEnabled(torEnabled: boolean) {\n\t\tif (this.isTorBeingToggled) {\n\t\t\tthrow new Error(\n\t\t\t\t'Tor is already in the process of being toggled. Please wait until the current process is finished.',\n\t\t\t)\n\t\t}\n\t\tthis.isTorBeingToggled = true\n\t\ttry {\n\t\t\tconst currentTorEnabled = await this.#umbreld.store.get('torEnabled')\n\n\t\t\t// Check if we're applying the current setting\n\t\t\tif (currentTorEnabled === torEnabled) {\n\t\t\t\tthrow new Error(`Tor is already ${torEnabled ? 'enabled' : 'disabled'}`)\n\t\t\t}\n\n\t\t\t// Toggle Tor\n\t\t\tawait this.stop()\n\t\t\tawait this.#umbreld.store.set('torEnabled', torEnabled)\n\t\t\tawait this.start()\n\n\t\t\treturn true\n\t\t} finally {\n\t\t\tthis.isTorBeingToggled = false\n\t\t}\n\t}\n\n\tasync getTorEnabled() {\n\t\treturn this.#umbreld.store.get('torEnabled')\n\t}\n\n\tasync setSelectedDependencies(appId: string, dependencies: Record<string, string>) {\n\t\tconst app = this.getApp(appId)\n\t\treturn app.setSelectedDependencies(dependencies)\n\t}\n\n\tasync getDependents(appId: string) {\n\t\tconst allDependencies = await Promise.all(\n\t\t\tthis.instances.map(async (app) => ({\n\t\t\t\tid: app.id,\n\t\t\t\t// If we can't read an app's dependencies for any reason just skip that app, don't abort\n\t\t\t\tdependencies: await app.getDependencies().catch(() => [] as string[]),\n\t\t\t})),\n\t\t)\n\t\treturn allDependencies.filter(({dependencies}) => dependencies.includes(appId)).map(({id}) => id)\n\t}\n\n\tasync setHideCredentialsBeforeOpen(appId: string, value: boolean) {\n\t\tconst app = this.getApp(appId)\n\t\treturn app.store.set('hideCredentialsBeforeOpen', value)\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/legacy-compat/app-environment.ts",
    "content": "import {fileURLToPath} from 'node:url'\nimport {dirname, join} from 'node:path'\n\nimport {$} from 'execa'\n\nimport type Umbreld from '../../../index.js'\n\nexport default async function appEnvironment(umbreld: Umbreld, command: string) {\n\tlet inheritStdio = true\n\t// Prevent breaking test output\n\tif (process.env.TEST === 'true') inheritStdio = false\n\n\tconst currentFilename = fileURLToPath(import.meta.url)\n\tconst currentDirname = dirname(currentFilename)\n\tconst composePath = join(currentDirname, 'docker-compose.yml')\n\tconst torEnabled = await umbreld.store.get('torEnabled')\n\tconst options = {\n\t\tstdio: inheritStdio ? 'inherit' : 'pipe',\n\t\tcwd: umbreld.dataDirectory,\n\t\tenv: {\n\t\t\tUMBREL_DATA_DIR: umbreld.dataDirectory,\n\t\t\t// TODO: Load these from somewhere more appropriate\n\t\t\tNETWORK_IP: '10.21.0.0',\n\t\t\tGATEWAY_IP: '10.21.0.1',\n\t\t\tDASHBOARD_IP: '10.21.21.3',\n\t\t\tMANAGER_IP: '10.21.21.4',\n\t\t\tAUTH_IP: '10.21.21.6',\n\t\t\tAUTH_PORT: '2000',\n\t\t\tTOR_PROXY_IP: '10.21.21.11',\n\t\t\tTOR_PROXY_PORT: '9050',\n\t\t\tTOR_PASSWORD: 'mLcLDdt5qqMxlq3wv8Din3UD44bTZHzRFhIktw38kWg=',\n\t\t\tTOR_HASHED_PASSWORD: '16:158FBE422B1A9D996073BE2B9EC38852C70CE12362CA016F8F6859C426',\n\t\t\tUMBREL_AUTH_SECRET: 'DEADBEEF', // Not used, just left in for compatibility reasons\n\t\t\tJWT_SECRET: await umbreld.server.getJwtSecret(),\n\t\t\tUMBRELD_RPC_HOST: `host.docker.internal:${umbreld.server.port}`, // TODO: Check host.docker.internal works on linux\n\t\t\tUMBREL_LEGACY_COMPAT_DIR: currentDirname,\n\t\t\tUMBREL_TORRC: torEnabled ? `${currentDirname}/tor-server-torrc` : `${currentDirname}/tor-proxy-torrc`,\n\t\t},\n\t}\n\tif (command === 'up') {\n\t\tawait $(\n\t\t\toptions as any,\n\t\t)`docker compose --project-name umbrel --file ${composePath} ${command} --build --detach --remove-orphans`\n\t} else {\n\t\tawait $(options as any)`docker compose --project-name umbrel --file ${composePath} ${command}`\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/legacy-compat/app-script",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# This script is copied in from <1.0 Umbrel and modified to work with umbreld.\n# It provides a compatibility layer so the old app system works reliably.\n\n# TODO: Hardcoding these for now since they used to be read from .env\n# Figure out what other stuff we need from the old .env and how to pass it through.\nexport NETWORK_IP='10.21.0.0'\nexport GATEWAY_IP='10.21.0.1'\nexport AUTH_PORT='2000'\nexport UMBREL_AUTH_SECRET='DEADBEEF'\nexport MANAGER_IP=\"10.21.21.4\"\n\nVERSION=\"0.0.3\"\n\nUMBREL_ROOT=\"${SCRIPT_UMBREL_ROOT}\"\nUSER_FILE=\"${UMBREL_ROOT}/db/user.json\"\n\nCURRENT_SCRIPT_PATH=\"$(realpath \"${BASH_SOURCE[0]}\")\"\n\nAPP_PROXY_SERVICE_NAME=\"app_proxy\"\n# We'll pass this in from umbreld\n# REMOTE_TOR_ACCESS=\"false\"\n# if [[ -f \"${USER_FILE}\" ]]; then\n#   REMOTE_TOR_ACCESS=$(cat \"${USER_FILE}\" | jq 'has(\"remoteTorAccess\") and .remoteTorAccess')\n# fi\n\nshow_help() {\n  cat << EOF\nCLI (v${VERSION}) for managing Umbrel apps\n\nUsage: app <command> <app> [<arguments>]\n\nCommands:\n    install                     Pulls down images for an app and starts it\n    uninstall                   Removes images and destroys all data for an app\n    reinstall                   Calls 'uninstall', followed by 'install' for an app\n    start                       Starts an installed app\n    stop                        Stops an installed app\n    restart                     Restarts an installed app\n    compose                     Passes all arguments to docker-compose\n    ls-installed                Lists installed apps\n    ls-dependencies             Lists dependencies of an app\n    ls-transitive-dependencies  Lists transitive dependencies of an app\nEOF\n}\n\n# Use GNU cp on macos\nif [[ \"$(uname)\" = \"Darwin\" ]]; then\n    cp=\"gcp\"\nelse\n    cp=\"cp\"\nfi\n\ncheck_dependencies () {\n  for cmd in \"$@\"; do\n    if ! command -v $cmd >/dev/null 2>&1; then\n      >&2 echo \"This script requires \\\"${cmd}\\\" to be installed\"\n      exit 1\n    fi\n  done\n}\n\nlist_installed_apps() {\n  # cat \"${USER_FILE}\" 2> /dev/null | jq -r 'if has(\"installedApps\") then .installedApps else [] end | join(\"\\n\")' || true\n  local yaml_file=\"${UMBREL_ROOT}/umbrel.yaml\"\n  yq e '.apps[]' \"${yaml_file}\" 2> /dev/null || true\n}\n\nlist_dependencies_of() {\n  local app=\"$1\"\n  local app_data_dir=\"${UMBREL_ROOT}/app-data/${app}\"\n  local app_manifest_file=\"${app_data_dir}/umbrel-app.yml\"\n  local app_settings_file=\"${app_data_dir}/settings.yml\"\n\n  # Get the app's dependencies and substitute with alternatives if present\n  local dependencies=$(yq e '.dependencies[]' \"${app_manifest_file}\" 2> /dev/null || true)\n  for dep in $dependencies; do\n    yq e \".dependencies.${dep} // \\\"${dep}\\\"\" \"${app_settings_file}\" 2> /dev/null || echo \"${dep}\"\n  done\n}\n\nlist_transitive_dependencies_of() {\n  local app=\"$1\"\n  local -A processed=()\n  local -A processing=()\n  local output=()\n\n  process_dependencies_in_post_order() {\n    local current_app=\"$1\"\n\n    # Skip if already processed\n    if [[ -n \"${processed[$current_app]:-}\" ]]; then\n      return\n    fi\n\n    # Skip on circular dependencies\n    if [[ -n \"${processing[$current_app]:-}\" ]]; then\n      return 1\n    fi\n\n    processing[\"$current_app\"]=1\n    local deps=($(list_dependencies_of \"$current_app\"))\n\n    # Process dependencies before processing the app itself\n    for dep in \"${deps[@]}\"; do\n      if ! process_dependencies_in_post_order \"$dep\"; then\n        echo \"Error: Circular dependency in app $app: $current_app -> $dep\" >&2\n        processing[\"$current_app\"]=0\n        return 1\n      fi\n    done\n\n    processed[\"$current_app\"]=1\n    processing[\"$current_app\"]=0\n    output+=(\"$current_app\")\n  }\n\n  process_dependencies_in_post_order \"$app\"\n\n  # Omit the app itself\n  for ((i = 0; i < ${#output[@]} - 1; i++)); do\n    echo \"${output[i]}\"\n  done\n}\n\n# Deterministically derives 128 bits of cryptographically secure entropy\nderive_entropy () {\n  # Make sure we use the seed from the real Umbrel installation if this is\n  # an OTA update.\n  SEED_FILE=\"${UMBREL_ROOT}/db/umbrel-seed/seed\"\n  if [[ ! -f \"${SEED_FILE}\" ]] && [[ -f \"${UMBREL_ROOT}/../.umbrel\" ]]; then\n    SEED_FILE=\"${UMBREL_ROOT}/../db/umbrel-seed/seed\"\n  fi\n\n  identifier=\"${1}\"\n  umbrel_seed=$(cat \"${SEED_FILE}\") || true\n\n  if [[ -z \"$umbrel_seed\" ]] || [[ -z \"$identifier\" ]]; then\n    >&2 echo \"Missing derivation parameter, this is unsafe, exiting.\"\n    exit 1\n  fi\n\n  # We need `sed 's/^.* //'` to trim the \"(stdin)= \" prefix from some versions of openssl\n  printf \"%s\" \"${identifier}\" | openssl dgst -sha256 -hmac \"${umbrel_seed}\" | sed 's/^.* //'\n}\n\n# Setup env. for this context for a given app\nsource_app() {\n  local -r app=\"${1}\"\n\n  local -r app_domain=\"$(hostname -s 2>/dev/null || echo \"umbrel\").local\"\n  local -r app_entropy_identifier=\"app-${app}-seed\"\n\n  # Load in existing Umbrel .env\n  # So that apps in their exports.sh can access\n  # e.g. $TOR_PROXY_IP, $TOR_PROXY_PORT\n  # [[ -f \"${UMBREL_ROOT}/.env\" ]] && . \"${UMBREL_ROOT}/.env\"\n\n  export NETWORK_IP=\"${NETWORK_IP}\"\n\n  # Set other useful vars. used in exports\n  export DEVICE_HOSTNAME=\"$(cat /proc/sys/kernel/hostname 2>/dev/null || echo \"umbrel\")\"\n  export DEVICE_DOMAIN_NAME=\"${DEVICE_HOSTNAME}.local\"\n\n  # Set env using all transitive dependencies exports.sh\n  # Do this first so that no app exports can\n  # Override any app specific exports defined below\n  EXPORTS_TOR_DATA_DIR=\"${UMBREL_ROOT}/tor/data\"\n\n  APPS_TO_SOURCE=\"$(list_transitive_dependencies_of \"$app\")\"\n\n  # $app might not be in the 'installed apps list' yet\n  # i.e. If it is currently being installed\n  # So we'll add it to the list of apps that will be 'sourced'\n  if ! echo \"${APPS_TO_SOURCE}\" | grep --quiet \"^${app}$\"; then\n    APPS_TO_SOURCE=\"${APPS_TO_SOURCE}\"$'\\n'\"${app}\"\n  fi\n\n  for EXPORTS_APP_ID in $APPS_TO_SOURCE; do\n    EXPORTS_APP_DIR=\"${UMBREL_ROOT}/app-data/${EXPORTS_APP_ID}\"\n    EXPORTS_APP_FILE=\"${EXPORTS_APP_DIR}/exports.sh\"\n    EXPORTS_APP_DATA_DIR=\"${EXPORTS_APP_DIR}/data\"\n\n    if [[ -f \"${EXPORTS_APP_FILE}\" ]]; then\n      # We replace the literal text \"${UMBREL_ROOT}/scripts/app\" within the exports.sh file with the path to this script\n      sed -i 's|\"${UMBREL_ROOT}/scripts/app\"|'\"${CURRENT_SCRIPT_PATH}\"'|g' \"${EXPORTS_APP_FILE}\"\n\n      # We replace the literal text \"${UMBREL_ROOT}/db/user.json\" within the exports.sh file with the path to the umbrel.yaml file\n      # This specifically handles the Tailscale app\n      sed -i 's|\"${UMBREL_ROOT}/db/user.json\"|\"${UMBREL_ROOT}/umbrel.yaml\"|g' \"${EXPORTS_APP_FILE}\"\n\n      # Source the modified temporary exports file\n      . \"${EXPORTS_APP_FILE}\"\n    fi\n  done\n\n  # App specific exports\n  export APP_ID=\"${app}\"\n  export APP_MANIFEST_FILE=\"${app_data_dir}/umbrel-app.yml\"\n  export APP_VERSION=$(cat \"${APP_MANIFEST_FILE}\" | yq '.version')\n  \n  # This provides the app proxy with context of the app\n  export APP_PROXY_HOSTNAME=\"app_proxy_${app}\"\n  export APP_PROXY_PORT=$(cat \"${APP_MANIFEST_FILE}\" | yq '.port')\n  \n  export APP_DATA_DIR=\"${app_data_dir}\"\n  export APP_DOMAIN=\"${app_domain}\"\n  export APP_HIDDEN_SERVICE=\"not-enabled.onion\"\n  if [[ \"${REMOTE_TOR_ACCESS}\" == \"true\" ]]; then\n    export APP_HIDDEN_SERVICE=\"$(cat \"${app_hidden_service_file}\" 2>/dev/null || echo \"notyetset.onion\")\"\n  fi\n  export APP_SEED=$(derive_entropy \"${app_entropy_identifier}\")\n  export APP_PASSWORD=$(derive_entropy \"${app_entropy_identifier}-APP_PASSWORD\")\n\n  # Tor specific exports\n  export TOR_DATA_DIR=\"${UMBREL_ROOT}/tor/data\"\n  export TOR_ENTRYPOINT_SCRIPT=\"${SCRIPT_DOCKER_FRAGMENTS}/tor-entrypoint.sh\"\n  export TOR_HS_APP_DIR=\"/data/app-${app}\"\n  export TOR_HS_PORTS=\"80:${APP_PROXY_HOSTNAME}:${APP_PROXY_PORT}\"\n\n  # TODO: Look into why this needed to be commented out for Mark.\n  # tor_extra_hs_varname=$(echo \"APP_${APP_ID^^}_TOR_HS_EXTRA_PORTS\" | tr '-' '_')\n  # tor_hs_extra_ports=\"${!tor_extra_hs_varname:-}\"\n\n  # if [[ ! -z \"${tor_hs_extra_ports}\" ]]; then\n  #   export TOR_HS_PORTS=\"${TOR_HS_PORTS} ${tor_hs_extra_ports}\"\n  # fi\n\n  # Other\n  export UMBREL_ROOT\n}\n\n# Check dependencies\ncheck_dependencies docker jq yq openssl envsubst\n\nif [ -z ${1+x} ]; then\n  command=\"\"\nelse\n  command=\"$1\"\nfi\n\n# Lists installed apps\nif [[ \"$command\" = \"ls-installed\" ]]; then\n  list_installed_apps\n\n  exit\nfi\n\nif [ -z ${2+x} ]; then\n  show_help\n  exit 1\nelse\n  app=\"$2\"\n\n  # Lists (transitive) dependencies\n  if [[ \"$command\" = \"ls-dependencies\" ]]; then\n    list_dependencies_of \"$app\"\n    exit\n  elif [[ \"$command\" = \"ls-transitive-dependencies\" ]]; then\n    list_transitive_dependencies_of \"$app\"\n    exit\n  fi\n\n  # repo=$(cat \"${USER_FILE}\" 2> /dev/null | jq -r \".appOrigin.\\\"${app}\\\"\" || true)\n  # repo_path=$(\"${UMBREL_ROOT}/scripts/repo\" \"path\" \"${repo}\")\n  # app_repo_dir=\"${repo_path}/${app}\"\n  app_repo_dir=\"${SCRIPT_APP_REPO_DIR}\"\n  app_data_dir=\"${UMBREL_ROOT}/app-data/${app}\"\n\n  app_hidden_service_file=\"${UMBREL_ROOT}/tor/data/app-${app}/hostname\"\n  \n  if [[ \"${app}\" == \"installed\" ]]; then\n    for app in $(list_installed_apps); do\n      if [[ \"${app}\" != \"\" ]]; then\n        \"${0}\" \"${1}\" \"${app}\" \"${@:3}\" &\n      fi\n    done\n    wait\n    exit\n  fi\n  \n  if [[ -z \"${app}\" ]]; then\n    >&2 echo \"Error: \\\"${app}\\\" is not a valid app\"\n    exit 1\n  fi\nfi\n\nif [ -z ${3+x} ]; then\n  args=\"\"\nelse\n  args=\"${@:3}\"\nfi\n\nexecute_hook() {\n  local -r app=\"${1}\"\n  local -r name=\"${2}\"\n\n  local -r app_hooks_dir=\"${UMBREL_ROOT}/app-data/${app}/hooks\"\n  local -r hook=\"${app_hooks_dir}/${name}\"\n\n  if [[ -x \"${hook}\" ]]; then\n    echo \"Executing hook: ${hook}\"\n\n    # We replace the literal text \"${UMBREL_ROOT}/scripts/app\" within the hook file with the path to this script\n    sed -i 's|\"${UMBREL_ROOT}/scripts/app\"|'\"${CURRENT_SCRIPT_PATH}\"'|g' \"${hook}\"\n\n    # Swallow non-zero exit code\n    \"${hook}\" || true\n  fi\n}\n\ncompose() {\n  local -r app=\"${1}\"\n  shift\n\n  # Source env.\n  source_app \"${app}\"\n\n  # Define support compose files\n  local -r app_proxy_compose_file=\"${SCRIPT_DOCKER_FRAGMENTS}/docker-compose.app_proxy.yml\"\n  local -r tor_compose_file=\"${SCRIPT_DOCKER_FRAGMENTS}/docker-compose.tor.yml\"\n  local -r common_compose_file=\"${SCRIPT_DOCKER_FRAGMENTS}/docker-compose.common.yml\"\n  local -r app_compose_file=\"${app_data_dir}/docker-compose.yml\"\n\n  local -r umbrel_env_file=\"${UMBREL_ROOT}/.env\"\n\n  # We need to use the proxy compose file first\n  # To allow vars. in the app's compose file to override variables\n  compose_files=()\n\n  # Detect if the 'app_proxy' service has been defined\n  # In the app's docker-compose file\n  has_app_proxy_service=$(cat \"${app_compose_file}\" | yq \".services | has(\\\"${APP_PROXY_SERVICE_NAME}\\\")\")\n\n  if [[ \"${has_app_proxy_service}\" == \"true\" ]]; then\n    compose_files+=( \"--file\" \"${app_proxy_compose_file}\" )\n  fi\n\n  # If remote Tor access is enabled\n  # Then include a compose file for Tor\n  if [[ \"${REMOTE_TOR_ACCESS}\" == \"true\" ]]; then\n    compose_files+=( \"--file\" \"${tor_compose_file}\" )\n  fi\n\n  # Add app's compose file last so that it can override\n  # Any of the other compose files\n  compose_files+=( \"--file\" \"${common_compose_file}\" )\n  compose_files+=( \"--file\" \"${app_compose_file}\" )\n\n  # Merge compose files and args. passed into 'compose'\n  compose_args=(\"${compose_files[@]}\" \"${@}\")\n\n  # TODO: We removed the .env source, we probs need to add that back in somehow\n  # --env-file \"${umbrel_env_file}\" \\\n  docker compose \\\n    --project-name \"${app}\" \\\n    \"${compose_args[@]}\"\n}\n\nupdate_installed_apps() {\n  local -r action=\"${1}\"\n  local -r app=\"${2}\"\n  local -r repo=\"${3:-null}\"\n\n  while ! (set -o noclobber; echo \"$$\" > \"${USER_FILE}.lock\") 2> /dev/null; do\n    echo \"Waiting for JSON lock to be released for ${app} update...\"\n    sleep 1\n  done\n  # This will cause the lock-file to be deleted in case of a\n  # premature exit.\n  trap \"rm -f \"${USER_FILE}.lock\"; exit $?\" INT TERM EXIT\n\n  [[ \"${action}\" == \"add\" ]] && operator=\"+\" || operator=\"-\"\n  updated_json=$(cat \"${USER_FILE}\" | jq \".installedApps |= (. ${operator} [\\\"${app}\\\"] | unique)\")\n  echo \"${updated_json}\" > \"${USER_FILE}\"\n\n  if [[ \"${action}\" == \"add\" ]]; then\n    updated_json=$(cat \"${USER_FILE}\" | jq \".appOrigin |= (. ${operator} {\\\"${app}\\\":\\\"${repo}\\\"})\")\n  else\n    updated_json=$(cat \"${USER_FILE}\" | jq \"del(.appOrigin.\\\"${app}\\\")\")\n  fi\n  echo \"${updated_json}\" > \"${USER_FILE}\"\n\n  rm -f \"${USER_FILE}.lock\"\n}\n\ntemplate_app() {\n  local -r app=\"${1}\"\n\n  # Loop over all templates within app and populate them\n  APP_TEMPLATE_FILES=\"${app_data_dir}/*.template\"\n  \n  shopt -s nullglob\n  for APP_TEMPLATE_INPUT_FILE in $APP_TEMPLATE_FILES; do\n    # Output filename is the same as input with .template stripped off\n    APP_TEMPLATE_OUTPUT_FILE=\"${APP_TEMPLATE_INPUT_FILE%.*}\"\n\n    # First we'll copy the file so we ensure the output\n    # has the same fs permissions as the input\n    $cp --archive \"${APP_TEMPLATE_INPUT_FILE}\" \"${APP_TEMPLATE_OUTPUT_FILE}\"\n    cat \"${APP_TEMPLATE_INPUT_FILE}\" | envsubst > \"${APP_TEMPLATE_OUTPUT_FILE}\"\n  done\n}\n\ncopy_app_files() {\n  local -r files_to_copy=\"${1}\"\n\n  for filename in $files_to_copy; do\n    APP_FILES=\"${app_repo_dir}/${filename}\"\n\n    for app_file in $APP_FILES; do\n      if [[ -f \"${app_file}\" ]] || [[ -d \"${app_file}\" ]]; then\n        $cp --archive \"${app_file}\" \"${app_data_dir}\"\n      fi\n    done\n  done\n}\n\nwait_for_tor_hs() {\n  local -r app=\"${1}\"\n  \n  # Check if the app's hidden service hostname\n  # Has been already generated and exit early\n  if [[ -f \"${app_hidden_service_file}\" ]]; then\n    return\n  fi\n\n  # Check that the app has the App Proxy service defined\n  local -r app_compose_file=\"${app_data_dir}/docker-compose.yml\"\n  has_app_proxy_service=$(cat \"${app_compose_file}\" | yq \".services | has(\\\"${APP_PROXY_SERVICE_NAME}\\\")\")\n\n  if [[ \"${has_app_proxy_service}\" == \"false\" ]]; then\n    echo\n    >&2 echo \"Warning: \\\"${app}\\\" has no '${APP_PROXY_SERVICE_NAME}' defined\"\n    >&2 echo \"         \\\"${app}\\\" needs this to generate Tor HS\"\n    echo\n    return\n  fi\n\n  # If a tor service will start\n  # and there is no existing tor hs hostname\n  # Let's allow 10 seconds to generate it and then start the app\n  if [[ \"${REMOTE_TOR_ACCESS}\" == \"true\" ]]; then\n    echo \"Generating hidden services for ${app}...\"\n    # We must first start the App Proxy\n    # So that it's hostname is resolvable by Tor\n    # More details here: https://github.com/torproject/tor/blob/01bda6c23f58947ad1e20ea6367a5c260f53dfab/src/feature/hs/hs_common.c#L743\n    # And here: https://github.com/torproject/tor/blob/22552ad88e1e95ef9d2c6655c7602b7b25836075/src/lib/net/resolve.c#L297\n    # Otherwise Tor will throw this error:\n    # Unparseable address in hidden service port configuration.\n    compose \"${app}\" up --detach app_proxy\n    compose \"${app}\" up --detach tor_server\n\n    for attempt in $(seq 1 100); do\n      if [[ -f \"${app_hidden_service_file}\" ]]; then\n        echo \"Hidden service file created successfully!\"\n        break\n      fi\n      sleep 0.1\n    done\n\n    if [[ ! -f \"${app_hidden_service_file}\" ]]; then\n      echo \"Hidden service file wasn't created\"\n    fi\n  fi\n}\n\nstart_app() {\n  local -r app=\"${1}\"\n\n  # Source env.\n  source_app \"${app}\"\n\n  # Now apply templates\n  template_app \"${app}\"\n\n  # Wait for Tor's HS hostname to exist\n  wait_for_tor_hs \"${app}\"\n\n  execute_hook \"${app}\" \"pre-start\"\n\n  # Start all the app's containers\n  compose \"${app}\" up --detach --build\n\n  execute_hook \"${app}\" \"post-start\"\n}\n\n# Check that the app is installed\nmust_be_installed_guard() {\n  if ! list_installed_apps | grep --quiet \"^${app}$\"; then\n    >&2 echo \"Error: app \\\"${app}\\\" is not installed yet\"\n    exit 1\n  fi\n}\n\n# Pulls down images for an app and starts it\nif [[ \"$command\" = \"install\" ]]; then\n\n#   repo=$(\"${UMBREL_ROOT}/scripts/repo\" \"locate\" \"${app}\")\n\n#   if [[ -z \"${repo}\" ]]; then\n    # >&2 echo \"Error: \\\"${app}\\\" not found in any local app repo\"\n    # exit 1\n#   fi\n\n#   app_repo_dir=$(\"${UMBREL_ROOT}/scripts/repo\" \"path\" \"${repo}\")\n#   app_repo_dir=\"${app_repo_dir}/${app}\"\n\n#   echo \"Installing '${app}' from: ${repo}\"\n\n#   echo \"Setting up data dir for app ${app}...\"\n#   mkdir -p \"${app_data_dir}\"\n\n#   # Copy all app files\n#   rsync --archive --verbose --exclude \".gitkeep\" \"${app_repo_dir}/.\" \"${app_data_dir}\"\n\n  execute_hook \"${app}\" \"pre-install\"\n\n  # Source env.\n  source_app \"${app}\"\n\n  # Now apply templates\n  template_app \"${app}\"\n\n  echo \"Pulling images for app ${app}...\"\n  compose \"${app}\" pull\n\n#   if [[ \"$*\" != *\"--skip-start\"* ]]; then\n#     echo \"Starting app ${app}...\"\n    start_app \"${app}\"\n#   fi\n\n#   echo \"Saving app ${app} in DB...\"\n#   update_installed_apps add \"${app}\" \"${repo}\"\n\n  execute_hook \"${app}\" \"post-install\"\n\n  echo \"Successfully installed app ${app}\"\n  exit\nfi\n\n# Removes images and destroys all data for an app\nif [[ \"$command\" = \"uninstall\" ]]; then\n\n  must_be_installed_guard\n\n  execute_hook \"${app}\" \"pre-uninstall\"\n\n  # If a post uninstal hook exists\n  # Then make a copy before it's deleted below\n  app_hooks_dir=\"${UMBREL_ROOT}/app-data/${app}/hooks\"\n  post_uninstall_app_hook=\"${app_hooks_dir}/post-uninstall\"\n  if [[ -x \"${post_uninstall_app_hook}\" ]]; then\n    temp_post_uninstall_app_hook=\"/tmp/${app}-post-uninstall\"\n\n    $cp --archive \"${post_uninstall_app_hook}\" \"${temp_post_uninstall_app_hook}\"\n\n    post_uninstall_app_hook=\"${temp_post_uninstall_app_hook}\"\n  else\n    post_uninstall_app_hook=\"\"\n  fi\n\n  echo \"Removing images for app ${app}...\"\n  compose \"${app}\" down --rmi all --remove-orphans\n\n  echo \"Deleting app data for app ${app}...\"\n  if [[ -d \"${app_data_dir}\" ]]; then\n    rm -rf \"${app_data_dir}\"\n  fi\n\n  echo \"Removing app ${app} from DB...\"\n  update_installed_apps remove \"${app}\"\n\n  if [[ ! -z \"${post_uninstall_app_hook}\" ]]; then\n    \"${post_uninstall_app_hook}\" || true\n\n    rm -rf \"${post_uninstall_app_hook}\"\n  fi\n\n  echo \"Successfully uninstalled app ${app}\"\n  exit\nfi\n\n# Stops an installed app\nif [[ \"$command\" = \"stop\" ]]; then\n\n#   must_be_installed_guard\n\n  execute_hook \"${app}\" \"pre-stop\"\n\n  echo \"Stopping app ${app}...\"\n  compose \"${app}\" rm --force --stop\n\n  execute_hook \"${app}\" \"post-stop\"\n\n  exit\nfi\n\nif [[ \"$command\" = \"reinstall\" ]]; then\n\n  \"${0}\" \"uninstall\" \"${app}\"\n\n  echo\n  \"${0}\" \"install\" \"${app}\"\n\n  exit\nfi\n\n# Starts an installed app\nif [[ \"$command\" = \"start\" ]]; then\n\n#   must_be_installed_guard\n\n  echo \"Starting app ${app}...\"\n  start_app \"${app}\"\n\n  exit\nfi\n\n# Restarts an installed app\nif [[ \"$command\" = \"restart\" ]]; then\n\n  \"${0}\" \"stop\" \"${app}\"\n\n  \"${0}\" \"start\" \"${app}\"\n\n  exit\nfi\n\n# Get logs for an app\nif [[ \"$command\" = \"logs\" ]]; then\n  compose \"${app}\" logs --tail 500\n\n  exit\nfi\n\n# Update an installed app\nif [[ \"$command\" = \"update\" ]]; then\n\n  # must_be_installed_guard\n\n  # Check that the app folder still exists\n  # Within the associated local app repo\n  if [[ ! -d \"${app_repo_dir}\" ]]; then\n    >&2 echo \"Error: Local app repo no longer exists for ${app}\"\n    exit 1\n  fi\n\n  echo \"Updating '${app}' from: ${app_repo_dir}\"\n  # Save current images to clean up later\n  app_compose_file=\"${app_data_dir}/docker-compose.yml\"\n  app_old_images=$(yq e '.services | map(select(.image != null)) | .[].image' \"${app_compose_file}\")\n\n  if [[ \"$*\" != *\"--skip-stop\"* ]]; then\n    \"${0}\" \"stop\" \"${app}\"\n  fi\n\n  execute_hook \"${app}\" \"pre-update\"\n  \n  # App updates will only copy files from this whitelist:\n  UPDATE_FILES_WHITELIST_PRE=\"docker-compose.yml *.template exports.sh torrc hooks\"\n\n  # We copy umbrel-app.yml after the app has started\n  # That way the frontend knows the update has finished\n  # And the app is running again\n  UPDATE_FILES_WHITELIST_POST=\"umbrel-app.yml\"\n\n  copy_app_files \"${UPDATE_FILES_WHITELIST_PRE}\"\n\n  # Ensure remaining files are copied in case of unexpected exit\n  trap \"copy_app_files \"${UPDATE_FILES_WHITELIST_POST}\"; exit $?\" INT TERM EXIT\n\n  # Source env. after new exports.sh is copied (done above via 'copy_app_files')\n  source_app \"${app}\"\n\n  # Now apply templates\n  template_app \"${app}\"\n\n  echo \"Pulling images for app ${app}...\"\n  compose \"${app}\" pull\n\n  # Copy remaining files to mark update as complete\n  copy_app_files \"${UPDATE_FILES_WHITELIST_POST}\"\n\n  if [[ \"$*\" != *\"--skip-start\"* ]]; then\n    \"${0}\" \"start\" \"${app}\"\n    # Remove any old images we don't need anymore\n    docker rmi $app_old_images || true\n  fi\n\n  execute_hook \"${app}\" \"post-update\"\n\n  exit\nfi\n\n# Update an installed app\nif [[ \"$command\" = \"pre-patch-update\" ]]; then\n\n  # must_be_installed_guard\n\n  # Check that the app folder still exists\n  # Within the associated local app repo\n  if [[ ! -d \"${app_repo_dir}\" ]]; then\n    >&2 echo \"Error: Local app repo no longer exists for ${app}\"\n    exit 1\n  fi\n\n  echo \"Updating '${app}' from: ${app_repo_dir}\"\n  # Save current images to clean up later\n  app_compose_file=\"${app_data_dir}/docker-compose.yml\"\n  app_old_images=$(yq e '.services | map(select(.image != null)) | .[].image' \"${app_compose_file}\")\n\n  if [[ \"$*\" != *\"--skip-stop\"* ]]; then\n    \"${0}\" \"stop\" \"${app}\"\n  fi\n\n  execute_hook \"${app}\" \"pre-update\"\n  \n  # App updates will only copy files from this whitelist:\n  UPDATE_FILES_WHITELIST_PRE=\"docker-compose.yml *.template exports.sh torrc hooks\"\n\n  # We copy umbrel-app.yml after the app has started\n  # That way the frontend knows the update has finished\n  # And the app is running again\n  UPDATE_FILES_WHITELIST_POST=\"umbrel-app.yml\"\n\n  copy_app_files \"${UPDATE_FILES_WHITELIST_PRE}\"\n\n  # Ensure remaining files are copied in case of unexpected exit\n  trap \"copy_app_files \"${UPDATE_FILES_WHITELIST_POST}\"; exit $?\" INT TERM EXIT\n\n  # Source env. after new exports.sh is copied (done above via 'copy_app_files')\n  source_app \"${app}\"\n\n  # Now apply templates\n  template_app \"${app}\"\n\n  # Copy remaining files to mark update as complete\n  copy_app_files \"${UPDATE_FILES_WHITELIST_POST}\"\n\n  exit\nfi\n\n# Update an installed app\nif [[ \"$command\" = \"post-patch-update\" ]]; then\n\n  echo \"Pulling images for app ${app}...\"\n  compose \"${app}\" pull\n\n  if [[ \"$*\" != *\"--skip-start\"* ]]; then\n    \"${0}\" \"start\" \"${app}\"\n    # Remove any old images we don't need anymore\n    # docker rmi $app_old_images || true\n  fi\n\n  execute_hook \"${app}\" \"post-update\"\n\n  exit\nfi\n\n# Nuke app images\nif [[ \"$command\" = \"nuke-images\" ]]; then\n  compose \"${app}\" down --rmi all --remove-orphans\n  exit\nfi\n\n# Passes all arguments to docker-compose\nif [[ \"$command\" = \"compose\" ]]; then\n\n  compose \"${app}\" ${args}\n\n  exit\nfi\n\n# If we get here it means no valid command was supplied\n# Show help and exit\nshow_help\nexit 1"
  },
  {
    "path": "packages/umbreld/source/modules/apps/legacy-compat/app-script.ts",
    "content": "import {fileURLToPath} from 'node:url'\nimport {dirname, join} from 'node:path'\n\nimport {$} from 'execa'\n\nimport type Umbreld from '../../../index.js'\n\nexport default async function appScript(umbreld: Umbreld, command: string, arg: string, inheritStdio: boolean = true) {\n\t// Prevent breaking test output\n\tif (process.env.TEST === 'true') inheritStdio = false\n\n\tconst currentFilename = fileURLToPath(import.meta.url)\n\tconst currentDirname = dirname(currentFilename)\n\tconst scriptPath = join(currentDirname, 'app-script')\n\t// Allow repo to be unset, needed if the repo hasn't been pulled yet after a mmigration\n\t// or a 3rd party app had it's repo uninstalled.\n\tlet SCRIPT_APP_REPO_DIR = ''\n\ttry {\n\t\tSCRIPT_APP_REPO_DIR = await umbreld.appStore.getAppTemplateFilePath(arg)\n\t} catch {}\n\tconst torEnabled = await umbreld.store.get('torEnabled')\n\treturn $({\n\t\tstdio: inheritStdio ? 'inherit' : 'pipe',\n\t\tenv: {\n\t\t\tSCRIPT_UMBREL_ROOT: umbreld.dataDirectory,\n\t\t\tSCRIPT_DOCKER_FRAGMENTS: currentDirname,\n\t\t\tJWT_SECRET: await umbreld.server.getJwtSecret(),\n\t\t\tSCRIPT_APP_REPO_DIR,\n\t\t\tBITCOIN_NETWORK: 'mainnet', // Needed for legacy reasons otherwise the Bitcoin app fails to start\n\t\t\tTOR_PROXY_IP: '10.21.21.11',\n\t\t\tTOR_PROXY_PORT: '9050',\n\t\t\tTOR_PASSWORD: 'mLcLDdt5qqMxlq3wv8Din3UD44bTZHzRFhIktw38kWg=',\n\t\t\tTOR_HASHED_PASSWORD: '16:158FBE422B1A9D996073BE2B9EC38852C70CE12362CA016F8F6859C426',\n\t\t\tREMOTE_TOR_ACCESS: torEnabled ? 'true' : 'false',\n\t\t},\n\t})`${scriptPath} ${command} ${arg}`\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/legacy-compat/bin/bitcoin-cli",
    "content": "#!/usr/bin/env bash\n\necho\necho \"  *** Deprecation notice ***\"\necho \"  In a future version of Umbrel, 'bitcoin-cli' will be removed.\"\necho\n\nresult=$(docker exec -it bitcoin_bitcoind_1 bitcoin-cli \"$@\")\n\n# We need to echo with quotes to preserve output formatting\necho \"$result\""
  },
  {
    "path": "packages/umbreld/source/modules/apps/legacy-compat/bin/lncli",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nUMBREL_ROOT=\"$(readlink -f $(dirname \"${BASH_SOURCE[0]}\")/..)\"\n\necho\necho \"  *** Deprecation notice ***\"\necho \"  In a future version of Umbrel, 'lncli' will be removed.\"\necho\n\nresult=$(docker exec -it lightning_lnd_1 lncli \"$@\") \n\n# We need to echo with quotes to preserve output formatting\necho \"$result\""
  },
  {
    "path": "packages/umbreld/source/modules/apps/legacy-compat/docker-compose.app_proxy.yml",
    "content": "version: '3.7'\n\nservices:\n  app_proxy:\n    image: getumbrel/app-proxy:1.0.0@sha256:49eb600c4667c4b948055e33171b42a509b7e0894a77e0ca40df8284c77b52fb\n    # build: ../../../../../../containers/app-proxy\n    user: '1000:1000'\n    restart: on-failure\n    hostname: $APP_PROXY_HOSTNAME\n    ports:\n      - '${APP_PROXY_PORT}:${APP_PROXY_PORT}'\n    volumes:\n      - '${APP_MANIFEST_FILE}:/extra/umbrel-app.yml:ro'\n      - '${TOR_DATA_DIR}:/var/lib/tor:ro'\n      - '${APP_DATA_DIR}:/data:ro'\n    environment:\n      LOG_LEVEL: info\n      PROXY_PORT: $APP_PROXY_PORT\n      PROXY_AUTH_ADD: 'true'\n      PROXY_AUTH_WHITELIST:\n      PROXY_AUTH_BLACKLIST:\n      APP_HOST:\n      APP_PORT:\n      AUTH_SERVICE_PORT: $AUTH_PORT\n      UMBREL_AUTH_SECRET: $UMBREL_AUTH_SECRET\n      MANAGER_IP: $MANAGER_IP\n      MANAGER_PORT: 3006\n      JWT_SECRET: $JWT_SECRET\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/legacy-compat/docker-compose.common.yml",
    "content": "version: '3.7'\n\nnetworks:\n  default:\n    external:\n      name: umbrel_main_network\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/legacy-compat/docker-compose.tor.yml",
    "content": "version: '3.7'\n\nservices:\n  tor_server:\n    image: getumbrel/tor:0.4.7.8@sha256:2ace83f22501f58857fa9b403009f595137fa2e7986c4fda79d82a8119072b6a\n    user: '1000:1000'\n    restart: on-failure\n    volumes:\n      - ${TOR_ENTRYPOINT_SCRIPT}:/umbrel/entrypoint.sh\n      - ${TOR_DATA_DIR}:/data\n    environment:\n      HOME: '/tmp'\n      HS_DIR: '${TOR_HS_APP_DIR}'\n      HS_PORTS: '${TOR_HS_PORTS}'\n    entrypoint: '/umbrel/entrypoint.sh'\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/legacy-compat/docker-compose.yml",
    "content": "version: '3.7'\n\nservices:\n  tor_proxy:\n    container_name: tor_proxy\n    image: getumbrel/tor:0.4.7.8@sha256:2ace83f22501f58857fa9b403009f595137fa2e7986c4fda79d82a8119072b6a\n    user: '1000:1000'\n    restart: on-failure\n    volumes:\n      - ${UMBREL_TORRC}:/etc/tor/torrc:ro\n      - ${UMBREL_DATA_DIR}/tor/data:/data\n    environment:\n      HOME: '/tmp'\n    networks:\n      default:\n        ipv4_address: $TOR_PROXY_IP\n  auth:\n    container_name: auth\n    image: getumbrel/auth-server:1.0.5@sha256:b4a4b37896911a85fb74fa159e010129abd9dff751a40ef82f724ae066db3c2a\n    user: '1000:1000'\n    # build:\n    #   dockerfile: containers/app-auth/Dockerfile\n    #   context: ../../../../../../\n    restart: on-failure\n    environment:\n      PORT: $AUTH_PORT\n      UMBREL_AUTH_SECRET: $UMBREL_AUTH_SECRET\n      MANAGER_IP: $MANAGER_IP\n      MANAGER_PORT: 3006\n      DASHBOARD_IP: $DASHBOARD_IP\n      DASHBOARD_PORT: 3004\n      JWT_SECRET: $JWT_SECRET\n      UMBRELD_RPC_HOST: $UMBRELD_RPC_HOST\n    volumes:\n      - ${UMBREL_DATA_DIR}/tor/data:/var/lib/tor:ro\n      - ${UMBREL_DATA_DIR}/app-data:/app-data:ro\n      - ${UMBREL_DATA_DIR}:/data:ro\n    ports:\n      - '${AUTH_PORT}:${AUTH_PORT}'\n    extra_hosts:\n      - 'host.docker.internal:host-gateway'\n    networks:\n      default:\n        ipv4_address: $AUTH_IP\nnetworks:\n  default:\n    name: umbrel_main_network\n    ipam:\n      driver: default\n      config:\n        - subnet: '$NETWORK_IP/16'\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/legacy-compat/tor-entrypoint.sh",
    "content": "#!/usr/bin/env bash\n\nTORRC_PATH=\"/tmp/torrc\"\n\necho \"HiddenServiceDir ${HS_DIR}\" > \"${TORRC_PATH}\"\n\n# Loop through all ports we want to expose\n# On this hidden service\nfor service in $HS_PORTS\ndo\n  virtual_port=$(echo $service | cut -d : -f 1)\n  source_host=$(echo $service | cut -d : -f 2)\n  source_port=$(echo $service | cut -d : -f 3)\n  echo \"HiddenServicePort ${virtual_port} ${source_host}:${source_port}\" >> \"${TORRC_PATH}\"\ndone\n\ntor -f \"${TORRC_PATH}\""
  },
  {
    "path": "packages/umbreld/source/modules/apps/legacy-compat/tor-proxy-torrc",
    "content": "# Warning: it's not recommended to modify these files directly. Any\n# modifications you make can break the functionality of your umbrel. These files\n# are automatically reset with every Umbrel update.\n\n# Bind only to \"10.21.21.11\" which is the tor IP within the container\nSocksPort   10.21.21.11:9050\nControlPort 10.21.21.11:29051\n\nHashedControlPassword 16:158FBE422B1A9D996073BE2B9EC38852C70CE12362CA016F8F6859C426"
  },
  {
    "path": "packages/umbreld/source/modules/apps/legacy-compat/tor-server-torrc",
    "content": "# Warning: it's not recommended to modify these files directly. Any\n\n# modifications you make can break the functionality of your umbrel. These files\n\n# are automatically reset with every Umbrel update.\n\n# Bind only to \"10.21.21.11\" which is the tor IP within the container\n\nSocksPort 10.21.21.11:9050\nControlPort 10.21.21.11:29051\n\nHashedControlPassword 16:158FBE422B1A9D996073BE2B9EC38852C70CE12362CA016F8F6859C426\n\n# Umbrel\n\n# Dashboard Hidden Service\n\nHiddenServiceDir /data/web\nHiddenServicePort 80 172.17.0.1:80\n\n# Auth Hidden Service\n\nHiddenServiceDir /data/auth\nHiddenServicePort 80 10.21.21.6:2000\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/routes.ts",
    "content": "import z from 'zod'\n\nimport {router, privateProcedure} from '../server/trpc/trpc.js'\n\nexport const appStore = router({\n\t// Returns the app store registry\n\tregistry: privateProcedure.query(async ({ctx}) => ctx.appStore.registry()),\n\n\t// Add a repository to the app store\n\taddRepository: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\turl: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.appStore.addRepository(input.url)),\n\n\t// Remove a repository to the app store\n\tremoveRepository: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\turl: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.appStore.removeRepository(input.url)),\n})\n\nexport const apps = router({\n\t// List all apps\n\tlist: privateProcedure.query(async ({ctx}) => {\n\t\tconst apps = ctx.apps.instances\n\t\tconst torEnabled = await ctx.umbreld.store.get('torEnabled')\n\n\t\tconst appData = await Promise.all(\n\t\t\tapps.map(async (app) => {\n\t\t\t\ttry {\n\t\t\t\t\tlet [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tname,\n\t\t\t\t\t\t\tversion,\n\t\t\t\t\t\t\ticon,\n\t\t\t\t\t\t\tport,\n\t\t\t\t\t\t\tpath,\n\t\t\t\t\t\t\twidgets,\n\t\t\t\t\t\t\tdefaultUsername,\n\t\t\t\t\t\t\tdefaultPassword,\n\t\t\t\t\t\t\tdeterministicPassword,\n\t\t\t\t\t\t\tdependencies,\n\t\t\t\t\t\t\timplements: implements_,\n\t\t\t\t\t\t\ttorOnly,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tselectedDependencies,\n\t\t\t\t\t] = await Promise.all([app.readManifest(), app.getSelectedDependencies()])\n\n\t\t\t\t\tconst hiddenService = torEnabled ? await app.readHiddenService() : ''\n\t\t\t\t\tif (deterministicPassword) {\n\t\t\t\t\t\tdefaultPassword = await app.deriveDeterministicPassword()\n\t\t\t\t\t}\n\t\t\t\t\tconst hasCredentials = !!defaultUsername || !!defaultPassword\n\t\t\t\t\tconst showCredentialsBeforeOpen = hasCredentials && !(await app.store.get('hideCredentialsBeforeOpen'))\n\t\t\t\t\treturn {\n\t\t\t\t\t\tid: app.id,\n\t\t\t\t\t\tname,\n\t\t\t\t\t\tversion,\n\t\t\t\t\t\ticon: icon ?? `https://getumbrel.github.io/umbrel-apps-gallery/${app.id}/icon.svg`,\n\t\t\t\t\t\tport,\n\t\t\t\t\t\tpath,\n\t\t\t\t\t\tstate: app.state,\n\t\t\t\t\t\tcredentials: {\n\t\t\t\t\t\t\tdefaultUsername,\n\t\t\t\t\t\t\tdefaultPassword,\n\t\t\t\t\t\t\tshowBeforeOpen: showCredentialsBeforeOpen,\n\t\t\t\t\t\t},\n\t\t\t\t\t\thiddenService,\n\t\t\t\t\t\twidgets,\n\t\t\t\t\t\tdependencies,\n\t\t\t\t\t\tselectedDependencies,\n\t\t\t\t\t\timplements: implements_,\n\t\t\t\t\t\ttorOnly,\n\t\t\t\t\t}\n\t\t\t\t} catch (error) {\n\t\t\t\t\tctx.apps.logger.error(`Failed to read manifest for app ${app.id}`, error)\n\t\t\t\t\treturn {id: app.id, error: (error as Error).message}\n\t\t\t\t}\n\t\t\t}),\n\t\t)\n\n\t\tconst appDataSortedByNames = appData.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))\n\n\t\treturn appDataSortedByNames\n\t}),\n\n\t// Install an app\n\tinstall: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tappId: z.string(),\n\t\t\t\talternatives: z.record(z.string()).optional(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.apps.install(input.appId, input.alternatives)),\n\n\t// Get state\n\t// Temporarily used for polling the state of app mutations until we implement subscriptions\n\tstate: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tappId: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.query(async ({ctx, input}) => {\n\t\t\tif (!(await ctx.apps.isInstalled(input.appId))) {\n\t\t\t\treturn {\n\t\t\t\t\tstate: 'not-installed' as const,\n\t\t\t\t\tprogress: 0,\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst app = ctx.apps.getApp(input.appId)\n\n\t\t\treturn {\n\t\t\t\tstate: app.state,\n\t\t\t\tprogress: app.stateProgress,\n\t\t\t} as const\n\t\t}),\n\n\t// Uninstall an app\n\tuninstall: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tappId: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.apps.uninstall(input.appId)),\n\n\t// Restart an app\n\trestart: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tappId: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.apps.restart(input.appId)),\n\n\t// Start an app\n\tstart: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tappId: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.apps.getApp(input.appId).start()),\n\n\t// Stop an app\n\tstop: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tappId: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.apps.getApp(input.appId).stop({persistState: true})),\n\n\t// Update an app\n\tupdate: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tappId: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.apps.update(input.appId)),\n\n\t// Get logs for an app\n\tlogs: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tappId: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.query(async ({ctx, input}) => ctx.apps.getApp(input.appId).getLogs()),\n\n\ttrackOpen: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tappId: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.apps.trackOpen(input.appId)),\n\n\trecentlyOpened: privateProcedure.query(({ctx}) => ctx.apps.recentlyOpened()),\n\n\tsetTorEnabled: privateProcedure.input(z.boolean()).mutation(({ctx, input}) => ctx.apps.setTorEnabled(input)),\n\tgetTorEnabled: privateProcedure.query(({ctx}) => ctx.apps.getTorEnabled()),\n\n\tsetSelectedDependencies: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tappId: z.string(),\n\t\t\t\tdependencies: z.record(z.string()),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.apps.setSelectedDependencies(input.appId, input.dependencies)),\n\n\tdependents: privateProcedure.input(z.string()).query(async ({ctx, input}) => ctx.apps.getDependents(input)),\n\n\thideCredentialsBeforeOpen: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tappId: z.string(),\n\t\t\t\tvalue: z.boolean(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.apps.setHideCredentialsBeforeOpen(input.appId, input.value)),\n\n\tisBackupIgnored: privateProcedure\n\t\t.input(z.object({appId: z.string()}))\n\t\t.query(async ({ctx, input}) => ctx.apps.getApp(input.appId).isBackupIgnored()),\n\n\tbackupIgnore: privateProcedure\n\t\t.input(z.object({appId: z.string(), value: z.boolean()}))\n\t\t.mutation(async ({ctx, input}) => ctx.apps.getApp(input.appId).setBackupIgnored(input.value)),\n\n\t// Get backupIgnored paths for an app\n\tgetBackupIgnoredPaths: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tappId: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.query(async ({ctx, input}) => ctx.apps.getApp(input.appId).getBackupIgnoredFilePaths()),\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/apps/schema.ts",
    "content": "import {z} from 'zod'\nimport semver from 'semver'\n\n// TODO: this is used outside of the apps module, move it somewhere more appropriate\nexport type ProgressStatus = {\n\trunning: boolean\n\t/** From 0 to 100 */\n\tprogress: number\n\tdescription: string\n\terror: boolean | string\n}\n\nexport const AppRepositoryMetaSchema = z.object({\n\tid: z.string(),\n\tname: z.string(),\n})\n\nexport type AppRepositoryMeta = z.infer<typeof AppRepositoryMetaSchema>\n\nconst validateSemanticVersion = z.string().refine(semver.valid, {\n\tmessage: 'invalid semantic version',\n})\n\n// We might want to describe this further so we can do runtime valdiation with\n// useful errors like tagline max length etc.\nexport const AppManifestSchema = z.object({\n\tmanifestVersion: validateSemanticVersion,\n\tid: z.string(),\n\tdisabled: z.boolean().optional(),\n\tname: z.string(),\n\ttagline: z.string(),\n\ticon: z.string().optional(),\n\tcategory: z.string(),\n\t// TODO (apps refactor): switch to semantic versions?\n\tversion: z.string(),\n\tport: z.number().int(),\n\tdescription: z.string(),\n\twebsite: z.string().url(),\n\t// TODO: one developer/submitter is an integer\n\tdeveloper: z.union([z.string(), z.number()]).optional(),\n\tsubmitter: z.union([z.string(), z.number()]).optional(),\n\tsubmission: z.string().url().optional(),\n\t// TODO: some apps have an empty repo string\n\trepo: z.union([z.string().url(), z.string().length(0)]).optional(),\n\tsupport: z.string(),\n\tgallery: z.array(z.string()),\n\treleaseNotes: z.string().optional(),\n\tdependencies: z.array(z.string()).optional(),\n\tpermissions: z.array(z.string()).optional(),\n\tpath: z.string().optional(),\n\tdefaultUsername: z.string().optional(),\n\tdefaultPassword: z.string().optional(),\n\tdeterministicPassword: z.boolean().optional(),\n\toptimizedForUmbrelHome: z.boolean().optional(),\n\ttorOnly: z.boolean().optional(),\n\t// In bytes\n\tinstallSize: z.number().int().optional(),\n\t// TODO: Define this type\n\twidgets: z.array(z.any()).optional(),\n\tdefaultShell: z.string().optional(),\n\timplements: z.array(z.string()).optional(),\n\tbackupIgnore: z.array(z.string()).optional(),\n})\n\nexport type AppManifest = z.infer<typeof AppManifestSchema>\n\nfunction isRecord(value: unknown): value is Record<string, any> {\n\treturn typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction tryNormalizeVersion(version: number | string) {\n\t// Convert versions parsed as a number, e.g. `1` or `1.2`\n\tif (typeof version === 'number') {\n\t\tversion = String(version)\n\t}\n\t// Retain valid version\n\tif (semver.valid(version)) {\n\t\treturn version\n\t}\n\t// Otherwise try to coerce, e.g. 1 to 1.0.0\n\tconst coerced = semver.coerce(version)\n\treturn coerced ? coerced.toString() : version\n}\n\nexport function validateManifest(parsed: unknown): AppManifest {\n\tif (!isRecord(parsed)) {\n\t\tthrow new Error('invalid manifest')\n\t}\n\tparsed.manifestVersion = tryNormalizeVersion(parsed.manifestVersion)\n\n\t// TODO (apps refactor): switch to semantic versions?\n\t// parsed.version = tryNormalizeVersion(parsed.version)\n\n\t// TODO (apps refactor): enable schema validation\n\t// return AppManifestSchema.parse(parsed)\n\n\treturn parsed as AppManifest\n}\n\nexport const AppSettingsSchema = z.object({\n\thideCredentialsBeforeOpen: z.boolean().optional(),\n\tdependencies: z.record(z.string()).optional(),\n\tbackupIgnore: z.boolean().optional(),\n\tautoStart: z.boolean().optional(),\n})\n\nexport type AppSettings = z.infer<typeof AppSettingsSchema>\n"
  },
  {
    "path": "packages/umbreld/source/modules/backups/backups.backupProgress.test.ts",
    "content": "import {expect, beforeAll, afterAll, test, describe} from 'vitest'\n\nimport fse from 'fs-extra'\nimport {delay} from 'es-toolkit'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\ndescribe(`backupProgress()`, () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.backups.backupProgress.query()).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('returns an empty array if no backups are in progress', async () => {\n\t\tawait expect(umbreld.client.backups.backupProgress.query()).resolves.toMatchObject([])\n\t})\n\n\ttest('returns backup progress during backup operation', async () => {\n\t\t// Create fake usb drive\n\t\tconst backupPath = `${umbreld.instance.dataDirectory}/external/SanDisk`\n\t\tawait fse.mkdir(backupPath, {recursive: false})\n\n\t\t// Create some test data to backup\n\t\tconst testDataPath = `${umbreld.instance.dataDirectory}/home/backup-progress-test.txt`\n\t\tawait fse.mkdir(`${umbreld.instance.dataDirectory}/home`, {recursive: true})\n\t\tawait fse.writeFile(testDataPath, 'test content for backup progress')\n\n\t\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\t\tpath: '/External/SanDisk',\n\t\t\tpassword: 'test-password',\n\t\t})\n\n\t\t// Listen for all backup progress events\n\t\tconst collectedEvents: any[] = []\n\t\tconst removeListener = umbreld.instance.eventBus.on(\n\t\t\t'backups:backup-progress',\n\t\t\t(progress) => void collectedEvents.push(JSON.parse(JSON.stringify(progress))),\n\t\t)\n\n\t\t// Test we start with no backups in progress\n\t\tawait expect(umbreld.client.backups.backupProgress.query()).resolves.toMatchObject([])\n\n\t\t// Start the backup\n\t\tconst backupPromise = umbreld.client.backups.backup.mutate({repositoryId})\n\n\t\t// Wait for the backup to complete\n\t\tconst result = await backupPromise\n\t\texpect(result).toBe(true)\n\n\t\t// Test we end with no backups in progress\n\t\tawait expect(umbreld.client.backups.backupProgress.query()).resolves.toMatchObject([])\n\n\t\t// Test all the events we collected\n\t\texpect(collectedEvents.length).toBeGreaterThanOrEqual(2)\n\t\texpect(collectedEvents.at(0)).toMatchObject([\n\t\t\t{\n\t\t\t\trepositoryId,\n\t\t\t\tpercent: 0,\n\t\t\t},\n\t\t])\n\t\texpect(collectedEvents.at(-1)).toMatchObject([])\n\n\t\t// Clean up\n\t\tremoveListener()\n\t\tawait fse.remove(backupPath)\n\t})\n\n\t// Skip this for now, come back to this when we test backup events heavily\n\ttest.skip('handles multiple concurrent backups', async () => {\n\t\t// Create two test repositories\n\t\tconst backupPath1 = `${umbreld.instance.dataDirectory}/external/USB-1`\n\t\tconst backupPath2 = `${umbreld.instance.dataDirectory}/external/USB-2`\n\t\tawait fse.mkdir(backupPath1, {recursive: true})\n\t\tawait fse.mkdir(backupPath2, {recursive: true})\n\n\t\t// Create test data\n\t\tconst testDataPath = `${umbreld.instance.dataDirectory}/home/concurrent-backup-test.txt`\n\t\tawait fse.writeFile(testDataPath, 'test content for concurrent backups')\n\n\t\tconst repositoryId1 = await umbreld.client.backups.createRepository.mutate({\n\t\t\tpath: '/External/USB-1',\n\t\t\tpassword: 'password1',\n\t\t})\n\t\tconst repositoryId2 = await umbreld.client.backups.createRepository.mutate({\n\t\t\tpath: '/External/USB-2',\n\t\t\tpassword: 'password2',\n\t\t})\n\n\t\t// Start both backups concurrently\n\t\tconst backup1Promise = umbreld.client.backups.backup.mutate({repositoryId: repositoryId1})\n\t\tconst backup2Promise = umbreld.client.backups.backup.mutate({repositoryId: repositoryId2})\n\n\t\t// Wait for both to start\n\t\tawait delay(100)\n\n\t\t// Test we have two backup operations in progress\n\t\tconst progressInProgress = await umbreld.client.backups.backupProgress.query()\n\t\texpect(progressInProgress).toHaveLength(2)\n\n\t\tconst repo1Progress = progressInProgress.find((p) => p.repositoryId === repositoryId1)\n\t\tconst repo2Progress = progressInProgress.find((p) => p.repositoryId === repositoryId2)\n\n\t\texpect(repo1Progress).toMatchObject({\n\t\t\trepositoryId: repositoryId1,\n\t\t\tpercent: expect.any(Number),\n\t\t})\n\t\texpect(repo2Progress).toMatchObject({\n\t\t\trepositoryId: repositoryId2,\n\t\t\tpercent: expect.any(Number),\n\t\t})\n\n\t\t// Wait for both backups to complete\n\t\tawait Promise.all([backup1Promise, backup2Promise])\n\n\t\t// Test we end with no backups in progress\n\t\tawait expect(umbreld.client.backups.backupProgress.query()).resolves.toMatchObject([])\n\n\t\t// Clean up\n\t\tawait fse.remove(backupPath1)\n\t\tawait fse.remove(backupPath2)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/backups/backups.integration.test.ts",
    "content": "import {setTimeout} from 'node:timers/promises'\n\nimport {expect, test, beforeEach, afterEach, vi} from 'vitest'\nimport fse from 'fs-extra'\nimport yaml from 'js-yaml'\nimport {execa} from 'execa'\nimport pRetry from 'p-retry'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\nimport {BACKUP_RESTORE_FIRST_START_FLAG} from '../../constants.js'\nimport * as system from '../system/system.js'\nimport type {AppManifest} from '../apps/schema.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeEach(async () => {\n\tprocess.env.UMBRELD_RESTORE_SKIP_REBOOT = 'true'\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\nafterEach(async () => umbreld.cleanup())\n\n// Create a `/Home/Backups`, directory, share it via Samba, and then mount it back\n// to ourself as a network share at `/Network/localhost/Backups (Umbrel)`\nasync function createBackupShare(umbreld: Awaited<ReturnType<typeof createTestUmbreld>>, directoryName = 'Backups') {\n\tawait umbreld.client.files.createDirectory.mutate({path: `/Home/${directoryName}`})\n\tawait umbreld.client.files.addShare.mutate({path: `/Home/${directoryName}`})\n\tconst password = await umbreld.client.files.sharePassword.query()\n\t// Retry cos it might take a few seconds for the share to be available\n\tconst backupSharePath = await pRetry(\n\t\tasync () =>\n\t\t\tumbreld.client.files.addNetworkShare.mutate({\n\t\t\t\thost: 'localhost',\n\t\t\t\tshare: `${directoryName} (Umbrel)`,\n\t\t\t\tusername: 'umbrel',\n\t\t\t\tpassword,\n\t\t\t}),\n\t\t{retries: 5, factor: 1},\n\t)\n\n\treturn backupSharePath\n}\n\ntest('createRepository() throws on system path', async () => {\n\t// Create a new backup repository\n\tawait expect(\n\t\tumbreld.client.backups.createRepository.mutate({\n\t\t\tpath: `${umbreld.instance.dataDirectory}`,\n\t\t\tpassword: 'test-password',\n\t\t}),\n\t).rejects.toThrow('Invalid path')\n})\n\ntest('createRepository() creates and stores a repository on external storage', async () => {\n\t// Check we start with no repositories\n\tawait expect(umbreld.client.backups.getRepositories.query()).resolves.toEqual([])\n\n\t// Create fake usb drive\n\tawait fse.mkdir(`${umbreld.instance.dataDirectory}/external/SanDisk`, {recursive: false})\n\n\t// Create a new backup repository\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: `/External/SanDisk`,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Verify repository was stored\n\tconst repositories = await umbreld.client.backups.getRepositories.query()\n\texpect(repositories).toHaveLength(1)\n\texpect(repositories[0]).toMatchObject({id: repositoryId})\n\n\t// Verify the repository ID follows the expected format\n\texpect(repositoryId).toMatch(/[a-f0-9]{8}$/)\n})\n\ntest('createRepository() creates and stores a repository on network storage', async () => {\n\t// Check we start with no repositories\n\tawait expect(umbreld.client.backups.getRepositories.query()).resolves.toEqual([])\n\n\t// Create a network share and mount it\n\tconst backupNetworkSharePath = await createBackupShare(umbreld)\n\n\t// Create a new backup repository\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: backupNetworkSharePath,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Verify repository was stored\n\tconst repositories = await umbreld.client.backups.getRepositories.query()\n\texpect(repositories).toHaveLength(1)\n\texpect(repositories[0]).toMatchObject({id: repositoryId})\n\n\t// Verify the repository ID follows the expected format\n\texpect(repositoryId).toMatch(/[a-f0-9]{8}$/)\n})\n\ntest('getRepositories() returns repositories with lastBackup date', async () => {\n\t// Check we start with no repositories\n\tawait expect(umbreld.client.backups.getRepositories.query()).resolves.toEqual([])\n\n\t// Create a network share and mount it\n\tconst backupNetworkSharePath = await createBackupShare(umbreld)\n\n\t// Create a new backup repository\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: backupNetworkSharePath,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Check we have a repository with no lastBackup date\n\tconst repositories = await umbreld.client.backups.getRepositories.query()\n\texpect(repositories).toMatchObject([\n\t\texpect.objectContaining({\n\t\t\tid: expect.stringMatching(/[a-f0-9]{8}$/),\n\t\t\tpath: `${backupNetworkSharePath}/Umbrel Backup.backup`,\n\t\t}),\n\t])\n\texpect(repositories[0].lastBackup).toBeUndefined()\n\n\t// Do a backup\n\tawait umbreld.client.backups.backup.mutate({repositoryId})\n\n\t// Check we have a repository with a lastBackup date\n\tawait expect(umbreld.client.backups.getRepositories.query()).resolves.toMatchObject([\n\t\texpect.objectContaining({\n\t\t\tid: expect.stringMatching(/[a-f0-9]{8}$/),\n\t\t\tpath: `${backupNetworkSharePath}/Umbrel Backup.backup`,\n\t\t\tlastBackup: expect.any(Number),\n\t\t}),\n\t])\n})\n\ntest('forgetRepository() forgets a repository', async () => {\n\t// Check we start with no repositories\n\tawait expect(umbreld.client.backups.getRepositories.query()).resolves.toEqual([])\n\n\t// Create fake usb drive\n\tawait fse.mkdir(`${umbreld.instance.dataDirectory}/external/SanDisk`, {recursive: false})\n\n\t// Create a new backup repository\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: `/External/SanDisk`,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Verify repository was stored\n\tawait expect(umbreld.client.backups.getRepositories.query()).resolves.toHaveLength(1)\n\n\t// Forget the repository\n\tawait umbreld.client.backups.forgetRepository.mutate({repositoryId})\n\n\t// Verify repository was forgotten\n\tawait expect(umbreld.client.backups.getRepositories.query()).resolves.toHaveLength(0)\n})\n\ntest('backup() creates a backup successfully', async () => {\n\t// Create a network share and mount it\n\tconst backupNetworkSharePath = await createBackupShare(umbreld)\n\n\t// Create a new backup repository\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: backupNetworkSharePath,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Check we have no backups\n\tawait expect(umbreld.client.backups.listBackups.query({repositoryId})).resolves.toHaveLength(0)\n\n\t// Do the backup\n\tawait expect(umbreld.client.backups.backup.mutate({repositoryId})).resolves.toBe(true)\n\n\t// Verify backup was created by listing backups\n\tconst backups = await umbreld.client.backups.listBackups.query({repositoryId})\n\texpect(backups).toHaveLength(1)\n\texpect(backups[0]).toMatchObject({\n\t\tid: expect.stringMatching(`^${repositoryId}:`),\n\t\ttime: expect.any(Number),\n\t\tsize: expect.any(Number),\n\t})\n\n\t// Verify it includes expected backup files\n\tconst files = await umbreld.client.backups.listBackupFiles.query({backupId: backups[0].id})\n\texpect(files).toContain('umbrel.yaml')\n\texpect(files).toContain('app-data')\n\texpect(files).toContain('home')\n\texpect(files).toContain('secrets')\n\texpect(files).toContain('trash')\n\n\t// Verify it excludes files we want to ignore\n\texpect(files).not.toContain('app-stores')\n\texpect(files).not.toContain('external')\n\texpect(files).not.toContain('network')\n\texpect(files).not.toContain('thumbnails')\n})\n\ntest('backup() throws error for non-existent repository', async () => {\n\tawait expect(umbreld.client.backups.backup.mutate({repositoryId: 'non-existent-repo'})).rejects.toThrow(\n\t\t'Repository non-existent-repo not found',\n\t)\n})\n\ntest('listBackups() throws error for non-existent repository', async () => {\n\tawait expect(umbreld.client.backups.listBackups.query({repositoryId: 'non-existent-repo'})).rejects.toThrow(\n\t\t'Repository non-existent-repo not found',\n\t)\n})\n\ntest('createRepository() prevents duplicate repositories in store', async () => {\n\t// Create a network share and mount it\n\tconst backupNetworkSharePath = await createBackupShare(umbreld)\n\n\t// Create a new backup repository\n\tawait umbreld.client.backups.createRepository.mutate({\n\t\tpath: backupNetworkSharePath,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Create same repository again\n\tawait expect(\n\t\tumbreld.client.backups.createRepository.mutate({\n\t\t\tpath: backupNetworkSharePath,\n\t\t\tpassword: 'test-password',\n\t\t}),\n\t).rejects.toThrow('Repository already exists')\n})\n\ntest('multiple backups create separate snapshots', async () => {\n\t// Create test data\n\tconst testDataPath = `${umbreld.instance.dataDirectory}/home/multi-backup-test.txt`\n\tawait fse.mkdir(`${umbreld.instance.dataDirectory}/home`, {recursive: true})\n\tawait fse.writeFile(testDataPath, 'initial content')\n\n\t// Create a network share and mount it\n\tconst backupNetworkSharePath = await createBackupShare(umbreld)\n\n\t// Create a new backup repository\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: backupNetworkSharePath,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Create first backup\n\tawait umbreld.client.backups.backup.mutate({repositoryId})\n\tconst firstBackups = await umbreld.client.backups.listBackups.query({repositoryId})\n\texpect(firstBackups).toHaveLength(1)\n\n\t// Wait a bit and modify data\n\tawait setTimeout(1000)\n\tawait fse.writeFile(testDataPath, 'modified content')\n\n\t// Create second backup\n\tawait umbreld.client.backups.backup.mutate({repositoryId})\n\tconst secondBackups = await umbreld.client.backups.listBackups.query({repositoryId})\n\texpect(secondBackups).toHaveLength(2)\n\n\t// Verify backups have different IDs and times\n\texpect(secondBackups[0].id).not.toBe(secondBackups[1].id)\n\texpect(secondBackups[0].time).not.toBe(secondBackups[1].time)\n})\n\ntest('mountBackup() mounts and unmountBackup() unmounts a backup', async () => {\n\t// Create test data in home and app-data directories to match expected structure\n\tconst homeTestPath = `${umbreld.instance.dataDirectory}/home/test-home-file.txt`\n\tconst appDataTestPath = `${umbreld.instance.dataDirectory}/app-data/test-app-file.txt`\n\n\tawait fse.writeFile(homeTestPath, 'home test content')\n\tawait fse.writeFile(appDataTestPath, 'app data test content')\n\n\t// Create a network share and mount it\n\tconst backupNetworkSharePath = await createBackupShare(umbreld)\n\n\t// Create a new backup repository\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: backupNetworkSharePath,\n\t\tpassword: 'test-password',\n\t})\n\tawait umbreld.client.backups.backup.mutate({repositoryId})\n\n\t// Get the backup to mount\n\tconst backups = await umbreld.client.backups.listBackups.query({repositoryId})\n\texpect(backups).toHaveLength(1)\n\tconst backup = backups[0]\n\n\t// Test mountBackup - this should list contents and attempt to mount\n\tawait umbreld.client.backups.mountBackup.mutate({backupId: backup.id})\n\n\tawait setTimeout(1000)\n\n\t// Verify that the backup root directory structure was created\n\tconst {backupRoot} = umbreld.instance.backups\n\texpect(await fse.pathExists(backupRoot)).toBe(true)\n\n\t// // The specific dated directory should exist\n\tconst dateDirs = await fse.readdir(backupRoot)\n\texpect(dateDirs).toHaveLength(1)\n\texpect(dateDirs[0]).toMatch(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/)\n\n\t// The created files exist at the expected paths\n\tconst backupHomeTestPath = `${backupRoot}/${dateDirs[0]}/Home/test-home-file.txt`\n\texpect(await fse.pathExists(backupHomeTestPath)).toBe(true)\n\n\tconst backupAppDataTestPath = `${backupRoot}/${dateDirs[0]}/Apps/test-app-file.txt`\n\texpect(await fse.pathExists(backupAppDataTestPath)).toBe(true)\n\n\t// Test we can unmount the backup\n\texpect(fse.pathExists(`${backupRoot}/${dateDirs[0]}`)).resolves.toBe(true)\n\tawait umbreld.client.backups.unmountBackup.mutate({directoryName: dateDirs[0]})\n\texpect(fse.pathExists(`${backupRoot}/${dateDirs[0]}`)).resolves.toBe(false)\n})\n\ntest('listAllBackups() returns backups from all repositories', async () => {\n\t// Create two test repositories\n\tconst repositoryId1 = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: await createBackupShare(umbreld),\n\t\tpassword: 'test-password',\n\t})\n\tconst repositoryId2 = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: await createBackupShare(umbreld, 'Backups 2'),\n\t\tpassword: 'test-password',\n\t})\n\n\t// Create a backup in the first repository\n\tawait umbreld.client.backups.backup.mutate({repositoryId: repositoryId1})\n\n\t// Create a backup in the second repository\n\tawait umbreld.client.backups.backup.mutate({repositoryId: repositoryId2})\n\n\t// Create another backup in the first repository\n\tawait umbreld.client.backups.backup.mutate({repositoryId: repositoryId1})\n\n\t// Test listAllBackups\n\tconst allBackups = await umbreld.client.backups.listAllBackups.query()\n\texpect(allBackups).toHaveLength(3)\n\n\t// Verify backups from both repositories are included\n\tconst repo1Backups = allBackups.filter((backup) => backup.id.startsWith(`${repositoryId1}:`))\n\tconst repo2Backups = allBackups.filter((backup) => backup.id.startsWith(`${repositoryId2}:`))\n\texpect(repo1Backups).toHaveLength(2)\n\texpect(repo2Backups).toHaveLength(1)\n\n\t// Verify backups are in the expected order\n\texpect(allBackups).toEqual([\n\t\texpect.objectContaining({id: expect.stringMatching(`^${repositoryId1}:`)}),\n\t\texpect.objectContaining({id: expect.stringMatching(`^${repositoryId2}:`)}),\n\t\texpect.objectContaining({id: expect.stringMatching(`^${repositoryId1}:`)}),\n\t])\n})\n\ntest('backup() reports progress during backup operation', async () => {\n\t// Create a network share and mount it\n\tconst backupNetworkSharePath = await createBackupShare(umbreld)\n\n\t// Create a new backup repository\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: backupNetworkSharePath,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Create 5MB file\n\tconst sourceDirectory = `${umbreld.instance.dataDirectory}/home/orig`\n\tawait fse.mkdir(sourceDirectory, {recursive: true})\n\tconst largeFile = `${sourceDirectory}/5MB.bin`\n\tawait fse.writeFile(largeFile, Buffer.alloc(5 * 1024 * 1024))\n\n\t// Throttle backup speed to make it slow enough to report progress events\n\tconst destinationDirectory = `${umbreld.instance.dataDirectory}/home/slow`\n\tawait fse.mkdir(destinationDirectory, {recursive: true})\n\tawait execa('bindfs', ['--read-rate=1000k', '--write-rate=1000k', sourceDirectory, destinationDirectory])\n\n\t// Listen for backup progress events\n\tconst progressEvents: any[] = []\n\tconst removeListener = umbreld.instance.eventBus.on(\n\t\t'backups:backup-progress',\n\t\t(progress) => void progressEvents.push(JSON.parse(JSON.stringify(progress))),\n\t)\n\n\t// Test we start with no backups in progress\n\tawait expect(umbreld.client.backups.backupProgress.query()).resolves.toMatchObject([])\n\n\t// Do the backup\n\tawait umbreld.client.backups.backup.mutate({repositoryId})\n\n\t// Test we end with no backups in progress\n\tawait expect(umbreld.client.backups.backupProgress.query()).resolves.toMatchObject([])\n\n\t// Verify we received progress events\n\t// The first and last events are the setup and teardown\n\t// Inbetween events are from parsinc the process output\n\texpect(progressEvents.at(0)).toMatchObject([{repositoryId, percent: 0}])\n\texpect(progressEvents.at(-2)).toMatchObject([{repositoryId, percent: expect.any(Number)}])\n\texpect(progressEvents.at(-1)).toMatchObject([])\n\n\t// Clean up\n\tremoveListener()\n\tawait execa('umount', [destinationDirectory])\n})\n\ntest('backupOnInterval() backs up in the background on an interval', async () => {\n\t// Set frequent backup interval and restart umbreld\n\tumbreld.instance.backups.backupInterval = 100 // 100ms\n\tawait umbreld.instance.stop()\n\tawait umbreld.instance.start()\n\n\t// Create a network share and mount it\n\tconst backupNetworkSharePath = await createBackupShare(umbreld)\n\n\t// Create a new backup repository\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: backupNetworkSharePath,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Do no explicit backups, just wait until we have at least 3 backups\n\t// to verify the background job is working\n\tawait pRetry(\n\t\tasync () => {\n\t\t\tconst backups = await umbreld.client.backups.listBackups.query({repositoryId})\n\t\t\texpect(backups.length).toBeGreaterThanOrEqual(3)\n\t\t},\n\t\t{retries: 10, factor: 1},\n\t)\n})\n\ntest('backups respect user ignored paths', async () => {\n\t// Create a network share and mount it\n\tconst backupNetworkSharePath = await createBackupShare(umbreld)\n\n\t// Create a new backup repository\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: backupNetworkSharePath,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Check we have no backups\n\tawait expect(umbreld.client.backups.listBackups.query({repositoryId})).resolves.toHaveLength(0)\n\n\t// Do the backup\n\tawait expect(umbreld.client.backups.backup.mutate({repositoryId})).resolves.toBe(true)\n\n\t// Verify backup was created and includes /Home\n\tlet backups = await umbreld.client.backups.listBackups.query({repositoryId})\n\texpect(backups).toHaveLength(1)\n\tlet files = await umbreld.client.backups.listBackupFiles.query({backupId: backups[0].id})\n\texpect(files).toContain('home')\n\n\t// Check /Home is not listed as ignored\n\tawait expect(umbreld.client.backups.getIgnoredPaths.query()).resolves.not.toContain('/Home')\n\n\t// Ignore /Home\n\tawait expect(umbreld.client.backups.addIgnoredPath.mutate({path: '/Home'})).resolves.toBe(true)\n\n\t// Check /Home is listed as ignored\n\tawait expect(umbreld.client.backups.getIgnoredPaths.query()).resolves.toContain('/Home')\n\n\t// Check ignoring non home path throws\n\tawait expect(umbreld.client.backups.addIgnoredPath.mutate({path: '/App/foo'})).rejects.toThrow(\n\t\t'Path to exclude must be in /Home',\n\t)\n\n\t// Do another backup\n\tawait expect(umbreld.client.backups.backup.mutate({repositoryId})).resolves.toBe(true)\n\n\t// Verify backup was created and doesn't include /Home\n\tbackups = await umbreld.client.backups.listBackups.query({repositoryId})\n\texpect(backups).toHaveLength(2)\n\tfiles = await umbreld.client.backups.listBackupFiles.query({backupId: backups[1].id})\n\texpect(files).not.toContain('home')\n\n\t// Remove ignored path\n\tawait expect(umbreld.client.backups.removeIgnoredPath.mutate({path: '/Home'})).resolves.toBe(true)\n\n\t// Check /Home is no longer listed as ignored\n\tawait expect(umbreld.client.backups.getIgnoredPaths.query()).resolves.not.toContain('/Home')\n\n\t// Check removing non home path throws\n\tawait expect(umbreld.client.backups.removeIgnoredPath.mutate({path: '/External'})).rejects.toThrow(\n\t\t'Path to exclude must be in /Home',\n\t)\n\n\t// Do another backup\n\tawait expect(umbreld.client.backups.backup.mutate({repositoryId})).resolves.toBe(true)\n\n\t// Verify backup includes /Home again\n\tbackups = await umbreld.client.backups.listBackups.query({repositoryId})\n\texpect(backups).toHaveLength(3)\n\tfiles = await umbreld.client.backups.listBackupFiles.query({backupId: backups[2].id})\n\texpect(files).toContain('home')\n})\n\ntest('backups respect app backupIgnore glob patterns', async () => {\n\t// Install app\n\tawait expect(umbreld.client.apps.install.mutate({appId: 'sparkles-hello-world'})).resolves.toStrictEqual(true)\n\n\t// Create files in the app's data directory\n\tconst appDataDir = `${umbreld.instance.dataDirectory}/app-data/sparkles-hello-world`\n\tawait fse.mkdir(`${appDataDir}/logs`, {recursive: true})\n\tawait fse.mkdir(`${appDataDir}/important-data`, {recursive: true})\n\tawait fse.writeFile(`${appDataDir}/logs/app.log`, 'log content')\n\tawait fse.writeFile(`${appDataDir}/important-data/config.json`, 'important config')\n\n\t// Modify the app's manifest to include a logs/* glob pattern for backupIgnore\n\tconst manifestPath = `${appDataDir}/umbrel-app.yml`\n\tconst manifest = yaml.load(await fse.readFile(manifestPath, 'utf8')) as AppManifest\n\tmanifest.backupIgnore = ['logs/*']\n\tawait fse.writeFile(manifestPath, yaml.dump(manifest))\n\n\t// Create a network share and mount it\n\tconst backupNetworkSharePath = await createBackupShare(umbreld)\n\n\t// Create a new backup repository\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: backupNetworkSharePath,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Do the backup\n\tawait expect(umbreld.client.backups.backup.mutate({repositoryId})).resolves.toBe(true)\n\n\t// Verify backup was created and includes app-data\n\tlet backups = await umbreld.client.backups.listBackups.query({repositoryId})\n\texpect(backups).toHaveLength(1)\n\tlet files = await umbreld.client.backups.listBackupFiles.query({backupId: backups[0].id})\n\texpect(files).toContain('app-data')\n\n\t// Verify logs directory exists but its contents are ignored by the glob\n\tconst appDirFiles = await umbreld.client.backups.listBackupFiles.query({\n\t\tbackupId: backups[0].id,\n\t\tpath: '/app-data/sparkles-hello-world',\n\t})\n\texpect(appDirFiles).toContain('logs')\n\texpect(appDirFiles).toContain('important-data')\n\n\tconst logsDirFiles = await umbreld.client.backups.listBackupFiles.query({\n\t\tbackupId: backups[0].id,\n\t\tpath: '/app-data/sparkles-hello-world/logs',\n\t})\n\texpect(logsDirFiles).not.toContain('app.log')\n\n\t// Verify that non-globbed files are included in the backup\n\tconst importantDirFiles = await umbreld.client.backups.listBackupFiles.query({\n\t\tbackupId: backups[0].id,\n\t\tpath: '/app-data/sparkles-hello-world/important-data',\n\t})\n\texpect(importantDirFiles).toContain('config.json')\n})\n\ntest('backups handle disconnected network shares gracefully', async () => {\n\t// Set the share watch interval to 100ms and restart umbreld\n\tumbreld.instance.files.networkStorage.shareWatchInterval = 100\n\tawait umbreld.instance.stop()\n\tawait umbreld.instance.start()\n\n\t// Create a network share and mount it\n\tconst mountPath = await createBackupShare(umbreld)\n\n\t// Verify the share is mounted and accessible\n\tawait umbreld.client.files.list.query({path: mountPath})\n\n\t// Create a new backup repository\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: mountPath,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Check we have no backups\n\tawait expect(umbreld.client.backups.listBackups.query({repositoryId})).resolves.toHaveLength(0)\n\n\t// Do the backup\n\tawait expect(umbreld.client.backups.backup.mutate({repositoryId})).resolves.toBe(true)\n\n\t// Verify backup was created\n\tawait expect(umbreld.client.backups.listBackups.query({repositoryId})).resolves.toHaveLength(1)\n\n\t// Remove the share\n\tawait umbreld.client.files.removeShare.mutate({path: '/Home/Backups'})\n\n\t// Verify the share is no longer mounted\n\t// TODO: For some reason if we remove this line the test fails, look into why\n\tawait expect(umbreld.client.files.list.query({path: mountPath})).rejects.toThrow('EHOSTDOWN')\n\n\t// Attempt another backup that fails\n\tawait expect(umbreld.client.backups.backup.mutate({repositoryId})).rejects.toThrow('host is down')\n\n\t// Add the share again\n\tawait umbreld.client.files.addShare.mutate({path: '/Home/Backups'})\n\n\t// Attempt another backup\n\tawait pRetry(() => expect(umbreld.client.backups.backup.mutate({repositoryId})).resolves.toBe(true), {\n\t\tretries: 5,\n\t\tfactor: 1,\n\t})\n\n\t// Verify a second backup was created\n\tawait expect(umbreld.client.backups.listBackups.query({repositoryId})).resolves.toHaveLength(2)\n})\n\ntest('getRepositorySize() returns the size of a repository', async () => {\n\t// Create a network share and mount it\n\tconst backupNetworkSharePath = await createBackupShare(umbreld)\n\n\t// Create a new backup repository\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: backupNetworkSharePath,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Get the size of the repository\n\t// Check used is 0 because we haven't done a backup yet\n\tawait expect(umbreld.client.backups.getRepositorySize.query({repositoryId})).resolves.toMatchObject({\n\t\tused: 0,\n\t\tcapacity: expect.any(Number),\n\t\tavailable: expect.any(Number),\n\t})\n\n\t// Do a backup\n\tawait umbreld.client.backups.backup.mutate({repositoryId})\n\n\t// Get the size of the repository\n\t// Check used is greater than 0 because we've done a backup\n\tawait expect(umbreld.client.backups.getRepositorySize.query({repositoryId})).resolves.toMatchObject({\n\t\tused: {asymmetricMatch: (v: unknown) => typeof v === 'number' && v > 0},\n\t\tcapacity: expect.any(Number),\n\t\tavailable: expect.any(Number),\n\t})\n})\n\ntest('backups sets user notification if backups have not run in over 24 hours', async () => {\n\t// Create a network share and mount it\n\tconst backupNetworkSharePath = await createBackupShare(umbreld)\n\n\t// Create a new backup repository\n\tawait umbreld.client.backups.createRepository.mutate({\n\t\tpath: backupNetworkSharePath,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Verify we have no notifications\n\tawait expect(umbreld.client.notifications.get.query()).resolves.toHaveLength(0)\n\n\t// Disconnect the network share so the next backup fails\n\tawait umbreld.client.files.removeShare.mutate({path: '/Home/Backups'})\n\n\t// Verify the share is no longer mounted\n\t// TODO: For some reason if we remove this line the test fails, look into why\n\tawait expect(umbreld.client.files.list.query({path: backupNetworkSharePath})).rejects.toThrow('EHOSTDOWN')\n\n\t// Set frequent backup interval\n\tumbreld.instance.backups.backupInterval = 100 // 100ms\n\n\t// Wait for some backup attempts\n\tawait setTimeout(30000)\n\n\t// Verify we still have no notifications\n\t// (we shouldn't have a notification unless 24 hours has passed)\n\tawait expect(umbreld.client.notifications.get.query()).resolves.toHaveLength(0)\n\n\t// Mock time 24 hours in the future to trigger the notification\n\tconst now = Date.now()\n\tconst TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000\n\tconst viNow = vi.spyOn(Date, 'now').mockImplementation(() => now + TWENTY_FOUR_HOURS)\n\n\t// Verify we have a notification\n\tawait pRetry(\n\t\t() =>\n\t\t\texpect(umbreld.client.notifications.get.query()).resolves.toMatchObject([\n\t\t\t\texpect.stringMatching(/^backups-failing:/),\n\t\t\t]),\n\t\t{\n\t\t\tretries: 30,\n\t\t\tfactor: 1,\n\t\t},\n\t)\n\n\t// Add the share again so backups can complete again\n\tawait umbreld.client.files.addShare.mutate({path: '/Home/Backups'})\n\n\t// Wait for share to be available and trigger a backup\n\tconst repositories = await umbreld.client.backups.getRepositories.query()\n\tawait pRetry(() => umbreld.client.backups.backup.mutate({repositoryId: repositories[0].id}), {\n\t\tretries: 10,\n\t\tfactor: 1,\n\t})\n\n\t// Verify the notification is removed after successful backup\n\tawait expect(umbreld.client.notifications.get.query()).resolves.toHaveLength(0)\n\n\t// Unmock time\n\tviNow.mockRestore()\n\n\t// Stop excessive backups\n\tumbreld.instance.backups.backupInterval = 1000000\n})\n\ntest('backup can be restored on the current Umbrel install', async () => {\n\t// Create a file to mark the installation\n\tawait umbreld.client.files.createDirectory.mutate({path: '/Home/original-umbrel'})\n\n\t// Create fake usb drive\n\tawait fse.mkdir(`${umbreld.instance.dataDirectory}/external/SanDisk`, {recursive: false})\n\n\t// Create a new backup repository on the fake usb drive\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: `/External/SanDisk`,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Do a backup\n\tawait umbreld.client.backups.backup.mutate({repositoryId})\n\n\t// Nuke the marker file\n\tawait umbreld.client.files.trash.mutate({path: '/Home/original-umbrel'})\n\n\t// Check the backup was created\n\tawait expect(umbreld.client.backups.listBackups.query({repositoryId})).resolves.toHaveLength(1)\n\n\t// Listen for restore progress events\n\tconst restoreProgressEvents: any[] = []\n\tconst removeListener = umbreld.instance.eventBus.on(\n\t\t'backups:restore-progress',\n\t\t(progress) => void restoreProgressEvents.push(JSON.parse(JSON.stringify(progress))),\n\t)\n\n\t// Get the latest backup and restore it\n\tconst backups = await umbreld.client.backups.listBackups.query({repositoryId})\n\tconst latestBackup = backups.at(-1)!\n\texpect(latestBackup).toBeDefined()\n\n\t// Simulate insufficient free space and assert restore fails\n\t// We need to have enough free space for the backup + a 5GB buffer.\n\t// We simulate a 10GB disk with 4GB available which should fail.\n\tconst ONE_GB = 1024 * 1024 * 1024\n\tconst getSystemDiskUsageSpy = vi.spyOn(system, 'getSystemDiskUsage').mockResolvedValue({\n\t\tsize: ONE_GB * 10,\n\t\ttotalUsed: ONE_GB * 6,\n\t\tavailable: ONE_GB * 4,\n\t})\n\tawait expect(umbreld.client.backups.restoreBackup.mutate({backupId: latestBackup.id})).rejects.toThrow(\n\t\t'[not-enough-space]',\n\t)\n\tgetSystemDiskUsageSpy.mockRestore()\n\n\t// Now restore should succeed with normal disk usage\n\tawait umbreld.client.backups.restoreBackup.mutate({backupId: latestBackup.id})\n\n\t// After restore (no reboot in tests), the restore marker should exist under /import\n\tconst importFlagPath = `${umbreld.instance.dataDirectory}/import/${BACKUP_RESTORE_FIRST_START_FLAG}`\n\texpect(await fse.pathExists(importFlagPath)).toBe(true)\n\n\t// Verify we received progress events\n\texpect(restoreProgressEvents.at(0)).toMatchObject({backupId: latestBackup.id, progress: 0, running: true})\n\texpect(restoreProgressEvents.at(-2)).toMatchObject({\n\t\tbackupId: latestBackup.id,\n\t\tprogress: expect.any(Number),\n\t\tbytesPerSecond: expect.any(Number),\n\t\trunning: true,\n\t})\n\texpect(restoreProgressEvents.at(-1)).toMatchObject({running: false, progress: 100, error: false})\n\n\t// Verify current progress is not running (final status is 100% after success)\n\tawait expect(umbreld.client.backups.restoreStatus.query()).resolves.toMatchObject({\n\t\trunning: false,\n\t\tprogress: 100,\n\t\terror: false,\n\t})\n\n\t// Check we have no marker file\n\texpect(await fse.pathExists(`${umbreld.instance.dataDirectory}/home/original-umbrel`)).toBe(false)\n\n\t// Stop and start to simulate a reboot\n\tawait umbreld.instance.stop()\n\tawait umbreld.instance.start()\n\n\t// Check we now have the marker file\n\texpect(await fse.pathExists(`${umbreld.instance.dataDirectory}/home/original-umbrel`)).toBe(true)\n\n\t// Destroy the new umbrel instance\n\tremoveListener()\n})\n\ntest('backup can be restored on a fresh umbrel during setup', async () => {\n\t// Create a file to mark the installation\n\tawait umbreld.client.files.createDirectory.mutate({path: '/Home/original-umbrel'})\n\n\t// Create fake usb drive\n\tawait fse.mkdir(`${umbreld.instance.dataDirectory}/external/SanDisk`, {recursive: false})\n\n\t// Create a new backup repository on the fake usb drive\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: `/External/SanDisk`,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Do a backup\n\tawait umbreld.client.backups.backup.mutate({repositoryId})\n\n\t// Check the backup was created\n\tawait expect(umbreld.client.backups.listBackups.query({repositoryId})).resolves.toHaveLength(1)\n\n\t// Create a new umbrel instance\n\tconst newUmbreld = await createTestUmbreld({autoLogin: false, autoStart: true})\n\n\t// Simulate connecting the previous usb drive to it\n\tawait fse.move(\n\t\t`${umbreld.instance.dataDirectory}/external/SanDisk`,\n\t\t`${newUmbreld.instance.dataDirectory}/external/SanDisk`,\n\t)\n\n\t// Connect to the USB backup repository\n\tconst newRepositoryId = await newUmbreld.client.backups.connectToExistingRepository.mutate({\n\t\tpath: `/External/SanDisk`,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Listen for restore progress events\n\tconst restoreProgressEvents: any[] = []\n\tconst removeListener = newUmbreld.instance.eventBus.on(\n\t\t'backups:restore-progress',\n\t\t(progress) => void restoreProgressEvents.push(JSON.parse(JSON.stringify(progress))),\n\t)\n\n\t// Get the latest backup and restore it\n\tconst backups = await newUmbreld.client.backups.listBackups.query({repositoryId: newRepositoryId})\n\tconst latestBackup = backups.at(-1)!\n\texpect(latestBackup).toBeDefined()\n\tawait newUmbreld.client.backups.restoreBackup.mutate({backupId: latestBackup.id})\n\n\t// After restore (no reboot in tests), the restore marker should exist under /import\n\tconst newImportFlagPath = `${newUmbreld.instance.dataDirectory}/import/${BACKUP_RESTORE_FIRST_START_FLAG}`\n\texpect(await fse.pathExists(newImportFlagPath)).toBe(true)\n\n\t// Verify we received progress events\n\texpect(restoreProgressEvents.at(0)).toMatchObject({backupId: latestBackup.id, progress: 0, running: true})\n\texpect(restoreProgressEvents.at(-2)).toMatchObject({\n\t\tbackupId: latestBackup.id,\n\t\tprogress: expect.any(Number),\n\t\tbytesPerSecond: expect.any(Number),\n\t\trunning: true,\n\t})\n\texpect(restoreProgressEvents.at(-1)).toMatchObject({running: false, progress: 100, error: false})\n\n\t// Verify current progress is not running (final status is 100% after success)\n\tawait expect(newUmbreld.client.backups.restoreStatus.query()).resolves.toMatchObject({\n\t\trunning: false,\n\t\tprogress: 100,\n\t\terror: false,\n\t})\n\n\t// Check we have no user and no marker file\n\tawait expect(newUmbreld.client.user.exists.query()).resolves.toBe(false)\n\texpect(await fse.pathExists(`${newUmbreld.instance.dataDirectory}/home/original-umbrel`)).toBe(false)\n\n\t// Stop and start to simulate a reboot\n\tawait newUmbreld.instance.stop()\n\tawait newUmbreld.instance.start()\n\n\t// Check we now have a user and the marker file from the previous installation\n\tawait expect(newUmbreld.client.user.exists.query()).resolves.toBe(true)\n\texpect(await fse.pathExists(`${newUmbreld.instance.dataDirectory}/home/original-umbrel`)).toBe(true)\n\n\t// Destroy the new umbrel instance\n\tremoveListener()\n\tawait newUmbreld.cleanup()\n})\n\ntest('connectToExistingRepository() cleans up failed connection details', async () => {\n\t// Create fake usb drive\n\tawait fse.mkdir(`${umbreld.instance.dataDirectory}/external/SanDisk`, {recursive: false})\n\n\t// Create a new backup repository on the fake usb drive\n\tconst repositoryId = await umbreld.client.backups.createRepository.mutate({\n\t\tpath: `/External/SanDisk`,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Do a backup\n\tawait umbreld.client.backups.backup.mutate({repositoryId})\n\n\t// Check the backup was created\n\tawait expect(umbreld.client.backups.listBackups.query({repositoryId})).resolves.toHaveLength(1)\n\n\t// Create a new umbrel instance\n\tconst newUmbreld = await createTestUmbreld({autoLogin: false, autoStart: true})\n\n\t// Simulate connecting the previous usb drive to it\n\tawait fse.move(\n\t\t`${umbreld.instance.dataDirectory}/external/SanDisk`,\n\t\t`${newUmbreld.instance.dataDirectory}/external/SanDisk`,\n\t)\n\n\t// Connect to the USB backup repository with a bad passsword\n\tawait expect(\n\t\tnewUmbreld.client.backups.connectToExistingRepository.mutate({\n\t\t\tpath: `/External/SanDisk`,\n\t\t\tpassword: 'incorrect-password',\n\t\t}),\n\t).rejects.toThrow('invalid repository password')\n\n\t// Connect to the USB backup repository with a good password\n\tconst newRepositoryId = await newUmbreld.client.backups.connectToExistingRepository.mutate({\n\t\tpath: `/External/SanDisk`,\n\t\tpassword: 'test-password',\n\t})\n\n\t// Check list succeeds and doesn't throw with the incorrect password\n\tawait newUmbreld.client.backups.listBackups.query({repositoryId: newRepositoryId})\n\n\t// Destroy the new umbrel instance\n\tawait newUmbreld.cleanup()\n})\n\n// Auth error tests\ntest('getRepositories() throws invalid error without auth token', async () => {\n\tawait expect(umbreld.unauthenticatedClient.backups.getRepositories.query()).rejects.toThrow('Invalid token')\n})\n\ntest('getRepositorySize() throws invalid error without auth token', async () => {\n\tawait expect(\n\t\tumbreld.unauthenticatedClient.backups.getRepositorySize.query({repositoryId: 'test-repo'}),\n\t).rejects.toThrow('Invalid token')\n})\n\ntest('createRepository() throws invalid error without auth token', async () => {\n\tawait expect(\n\t\tumbreld.unauthenticatedClient.backups.createRepository.mutate({\n\t\t\tpath: '/Network/test',\n\t\t\tpassword: 'test-password',\n\t\t}),\n\t).rejects.toThrow('Invalid token')\n})\n\ntest('backup() throws invalid error without auth token', async () => {\n\tawait expect(umbreld.unauthenticatedClient.backups.backup.mutate({repositoryId: 'test-repo'})).rejects.toThrow(\n\t\t'Invalid token',\n\t)\n})\n\ntest('listBackups() throws invalid error without auth token', async () => {\n\tawait expect(umbreld.unauthenticatedClient.backups.listBackups.query({repositoryId: 'test-repo'})).rejects.toThrow(\n\t\t'Invalid token',\n\t)\n})\n\ntest('listAllBackups() throws invalid error without auth token', async () => {\n\tawait expect(umbreld.unauthenticatedClient.backups.listAllBackups.query()).rejects.toThrow('Invalid token')\n})\n\ntest('listBackupFiles() throws invalid error without auth token', async () => {\n\tawait expect(\n\t\tumbreld.unauthenticatedClient.backups.listBackupFiles.query({backupId: 'test-repo:test-backup'}),\n\t).rejects.toThrow('Invalid token')\n})\n\ntest('mountBackup() throws invalid error without auth token', async () => {\n\tawait expect(\n\t\tumbreld.unauthenticatedClient.backups.mountBackup.mutate({backupId: 'test-repo:test-backup'}),\n\t).rejects.toThrow('Invalid token')\n})\n\ntest('unmountBackup() throws invalid error without auth token', async () => {\n\tawait expect(\n\t\tumbreld.unauthenticatedClient.backups.unmountBackup.mutate({directoryName: 'test-directory'}),\n\t).rejects.toThrow('Invalid token')\n})\n\ntest('backupProgress() throws invalid error without auth token', async () => {\n\tawait expect(umbreld.unauthenticatedClient.backups.backupProgress.query()).rejects.toThrow('Invalid token')\n})\n\ntest('getIgnoredPaths() throws invalid error without auth token', async () => {\n\tawait expect(umbreld.unauthenticatedClient.backups.getIgnoredPaths.query()).rejects.toThrow('Invalid token')\n})\n\ntest('addIgnoredPath() throws invalid error without auth token', async () => {\n\tawait expect(umbreld.unauthenticatedClient.backups.addIgnoredPath.mutate({path: '/Home/test'})).rejects.toThrow(\n\t\t'Invalid token',\n\t)\n})\n\ntest('removeIgnoredPath() throws invalid error without auth token', async () => {\n\tawait expect(umbreld.unauthenticatedClient.backups.removeIgnoredPath.mutate({path: '/Home/test'})).rejects.toThrow(\n\t\t'Invalid token',\n\t)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/backups/backups.ts",
    "content": "import {createHash} from 'node:crypto'\nimport nodePath from 'node:path'\nimport {setTimeout} from 'node:timers/promises'\n\nimport {execa, ExecaError, ExecaChildProcess} from 'execa'\nimport fse from 'fs-extra'\nimport pQueue from 'p-queue'\nimport prettyBytes from 'pretty-bytes'\n\nimport randomToken from '../../modules/utilities/random-token.js'\nimport {copyWithProgress} from '../utilities/copy-with-progress.js'\n\n// TODO: These should be refactored into proper umbreld modules\nimport {getSystemDiskUsage} from '../system/system.js'\nimport {setSystemStatus} from '../system/routes.js'\nimport {reboot} from '../system/system.js'\nimport {BACKUP_RESTORE_FIRST_START_FLAG} from '../../constants.js'\nimport type Umbreld from '../../index.js'\nimport type {ProgressStatus} from '../apps/schema.js'\n\ntype Backup = {\n\t// Our internal id in the format: <repositoryId>:<snapshotId>\n\tid: string\n\ttime: number\n\tsize: number\n}\n\ntype BackupProgress = {\n\trepositoryId: string\n\tpercent: number\n}\n\n// RestoreStatus extends ProgressStatus with optional restore-specific fields\n// ProgressStatus includes: running: boolean, progress: number (0-100), description: string, error: boolean | string\nexport type RestoreStatus = ProgressStatus & {\n\tbackupId?: string\n\tbytesPerSecond?: number\n\tsecondsRemaining?: number\n}\n\nexport type BackupsInProgress = BackupProgress[]\n\nexport default class Backups {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\tinternalMountPath: string\n\tbackupRoot: string\n\tbackupsInProgress: BackupsInProgress = []\n\trestoreStatus: RestoreStatus = {\n\t\trunning: false,\n\t\tprogress: 0,\n\t\tdescription: '',\n\t\terror: false,\n\t\t// backupId, bytesPerSecond, and secondsRemaining are undefined by default\n\t}\n\trunning = false\n\tstartedAt?: number\n\tbackupInterval = 1000 * 60 * 60 // 1 hour\n\tbackupJobPromise?: Promise<void>\n\tkopiaQueue = new pQueue({concurrency: 1})\n\tbackupDirectoryName = 'Umbrel Backup.backup'\n\trunningKopiaProcesses: ExecaChildProcess[] = []\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(name.toLocaleLowerCase())\n\t\tthis.internalMountPath = nodePath.join(umbreld.dataDirectory, 'backup-mounts')\n\t\tthis.backupRoot = umbreld.files.getBaseDirectory('/Backups')\n\t}\n\n\tasync start() {\n\t\tthis.logger.log('Starting backups')\n\t\tthis.running = true\n\t\tthis.startedAt = Date.now()\n\n\t\t// Cleanup any left over backup mounts\n\t\tawait this.unmountAll().catch((error) => this.logger.error('Error unmounting backups', error))\n\n\t\t// Fire off background backup process\n\t\tthis.backupJobPromise = this.backupOnInterval().catch((error) =>\n\t\t\tthis.logger.error('Error running backups on interval', error),\n\t\t)\n\t}\n\n\tasync stop() {\n\t\tthis.logger.log('Stopping backups')\n\t\tthis.running = false\n\n\t\tconst ONE_SECOND = 1000\n\n\t\t// Cleanup any currently mounted backups (up to 5s)\n\t\tawait Promise.race([\n\t\t\tsetTimeout(ONE_SECOND * 5),\n\t\t\t(async () => {\n\t\t\t\tthis.logger.log('Cleaning up mounts')\n\t\t\t\tawait this.unmountAll().catch((error) => this.logger.error('Error unmounting backups', error))\n\t\t\t})(),\n\t\t])\n\n\t\t// Kill any running kopia processes\n\t\tfor (const process of this.runningKopiaProcesses) process.kill('SIGTERM', {forceKillAfterTimeout: ONE_SECOND * 3})\n\n\t\t// Wait for any backup jobs (up to 5s)\n\t\tawait Promise.race([\n\t\t\tsetTimeout(ONE_SECOND * 5),\n\t\t\t(async () => {\n\t\t\t\tthis.logger.log('Waiting for any backup job to finish')\n\t\t\t\tif (this.backupJobPromise) await this.backupJobPromise.catch(() => {})\n\t\t\t\tawait Promise.allSettled(this.runningKopiaProcesses)\n\t\t\t})(),\n\t\t])\n\t}\n\n\t// Run backups in background\n\tasync backupOnInterval() {\n\t\tthis.logger.log('Scheduling backups interval')\n\t\tlet lastRun = Date.now()\n\t\twhile (this.running) {\n\t\t\tawait setTimeout(100)\n\t\t\tconst userExists = await this.#umbreld.user.exists()\n\t\t\tconst shouldRun = userExists && !this.restoreStatus.running && Date.now() - lastRun >= this.backupInterval\n\t\t\tif (!shouldRun) continue\n\t\t\tlastRun = Date.now()\n\n\t\t\tthis.logger.log('Running backups interval')\n\t\t\tconst repositories = await this.getRepositories().catch((error) => {\n\t\t\t\tthis.logger.error('Error getting repositories', error)\n\t\t\t\treturn []\n\t\t\t})\n\n\t\t\t// Run each backup\n\t\t\tfor (const repository of repositories) {\n\t\t\t\t// Skip if we're shutting down\n\t\t\t\tif (!this.running) break\n\n\t\t\t\t// Skip if we already have a backup in progress\n\t\t\t\tconst isAlreadyBackingUp = this.backupsInProgress.some((progress) => progress.repositoryId === repository.id)\n\t\t\t\tif (isAlreadyBackingUp) {\n\t\t\t\t\tthis.logger.log(`Backup already in progress for ${repository.path}`)\n\t\t\t\t} else {\n\t\t\t\t\tawait this.backup(repository.id).catch((error) =>\n\t\t\t\t\t\tthis.logger.error(`Error backing up ${repository.id}`, error),\n\t\t\t\t\t)\n\t\t\t\t}\n\n\t\t\t\t// Alert the user if backups have failed for over 24 hours\n\t\t\t\tconst {lastBackup} = await this.getRepository(repository.id)\n\t\t\t\tconst hoursSinceLastBackup = (Date.now() - (lastBackup || this.startedAt!)) / (1000 * 60 * 60)\n\t\t\t\tif (hoursSinceLastBackup > 24) {\n\t\t\t\t\tthis.logger.error(`Backup for ${repository.path} has not run in over 24 hours`)\n\t\t\t\t\tawait this.#umbreld.notifications.add(`backups-failing:${repository.id}`).catch(() => {})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthis.logger.log('Backups interval complete')\n\t\t}\n\t}\n\n\t// Get repositories\n\tasync getRepositories() {\n\t\treturn (await this.#umbreld.store.get('backups.repositories')) || []\n\t}\n\n\t// Get repository by id\n\tasync getRepository(id: string) {\n\t\tconst repositories = await this.getRepositories()\n\t\tconst repository = repositories.find((repository) => repository.id === id)\n\t\tif (!repository) throw new Error(`Repository ${id} not found`)\n\t\treturn repository\n\t}\n\n\t// Run a kopia command\n\t// We just default to bypassing the queue to effectively disable it now. It was causing blocking problems.\n\t// We can carefully re-enable it in select places in the future if we need to.\n\tasync kopia(\n\t\tflags: string[] = [],\n\t\t{onOutput, bypassQueue = true}: {onOutput?: (output: string) => void; bypassQueue?: boolean} = {},\n\t) {\n\t\t// Refuse to spawn new kopia processes if we're shutting down\n\t\tif (!this.running) throw new Error('[shutting-down] Refusing to spawn new kopia processes')\n\n\t\tconst spawnKopiaProcess = async () => {\n\t\t\t// Spawn process\n\t\t\tconst env = {\n\t\t\t\tKOPIA_CHECK_FOR_UPDATES: 'false',\n\t\t\t\tXDG_CACHE_HOME: '/kopia/cache',\n\t\t\t\tXDG_CONFIG_HOME: '/kopia/config',\n\t\t\t}\n\t\t\tconst process = execa('kopia', flags, {env})\n\n\t\t\t// Store reference to running process\n\t\t\tthis.runningKopiaProcesses.push(process)\n\t\t\t// Remove the process reference once the process is no longer running\n\t\t\tprocess\n\t\t\t\t.finally(() => (this.runningKopiaProcesses = this.runningKopiaProcesses.filter((p) => p !== process)))\n\t\t\t\t.catch(() => {}) // Swallow errors here to avoid unhandled promise rejections (they should be handled by the caller)\n\n\t\t\t// Pipe output to verbose logger and optional onOutput handler\n\t\t\tconst handleOutput = (data: Buffer) => {\n\t\t\t\tconst line = data.toString()\n\t\t\t\tthis.logger.verbose(line.trim())\n\t\t\t\tonOutput?.(line)\n\t\t\t}\n\t\t\tprocess.stdout?.on('data', (data) => handleOutput(data))\n\t\t\tprocess.stderr?.on('data', (data) => handleOutput(data))\n\n\t\t\t// Return process promise\n\t\t\treturn process\n\t\t}\n\t\t// Ensure we only run one kopia process at a time\n\t\treturn bypassQueue ? spawnKopiaProcess() : this.kopiaQueue.add(spawnKopiaProcess)\n\t}\n\n\t// Create a repository\n\tasync createRepository(virtualPath: string, password: string) {\n\t\tconst createNew = true\n\t\treturn this.addRepository(virtualPath, password, createNew)\n\t}\n\n\t// Connect to existing repository\n\tasync connectToExistingRepository(virtualPath: string, password: string) {\n\t\tconst createNew = false\n\t\treturn this.addRepository(virtualPath, password, createNew)\n\t}\n\n\t// Add a repository to the store and connect to it\n\t// Conditionally creates a new repository if createNew is true\n\tasync addRepository(virtualPath: string, password: string, createNew = true) {\n\t\tvirtualPath = nodePath.join(virtualPath, this.backupDirectoryName)\n\n\t\t// Check we have either a network share or external drive\n\t\tconst systemPath = await this.#umbreld.files.virtualToSystemPath(virtualPath).catch(() => '')\n\t\tconst isNetworkPath = systemPath.startsWith(this.#umbreld.files.getBaseDirectory('/Network'))\n\t\tconst isExternalPath = systemPath.startsWith(this.#umbreld.files.getBaseDirectory('/External'))\n\t\tif (!isNetworkPath && !isExternalPath) throw new Error(`Invalid path ${virtualPath}`)\n\n\t\t// TODO: We might also want to store some kind of unique identifier like filesystem uuid. Otherwise\n\t\t// a different destination mounted at the same path could be used as the destination. Or if there\n\t\t// are two external drives \"Untitled\" and \"Untitled (2)\" that are then mounted in different orders\n\t\t// we won't be able to resolve the path.\n\n\t\t// Derive 128 bit hex string from password\n\t\t// This is not key stretching, key stretching is handled internally by kopia with scrypt.\n\t\t// This is just to avoid keeping plain text passwords around in the store.\n\t\tpassword = createHash('sha256').update(password).digest('hex').slice(0, 16)\n\n\t\t// Derive unique id from path\n\t\tconst id = createHash('sha256').update(virtualPath).digest('hex').slice(0, 8)\n\n\t\t// Create kopia repository if we're creating a new one\n\t\tif (createNew) {\n\t\t\tthis.logger.log(`Creating repository ${id}`)\n\n\t\t\t// Create the directory\n\t\t\tawait fse.mkdir(systemPath, {recursive: false}).catch((error) => {\n\t\t\t\tif (error.code === 'EEXIST') throw new Error(`Repository already exists at ${virtualPath}`)\n\t\t\t\tthrow error\n\t\t\t})\n\t\t\tawait this.#umbreld.files.chownSystemPath(systemPath).catch(() => {}) // Might throw on fs without chown\n\n\t\t\t// Create the kopia repository\n\t\t\t// TODO: Investigate all the possible options here\n\t\t\tawait this.kopia([\n\t\t\t\t'repository',\n\t\t\t\t'create',\n\t\t\t\t'filesystem',\n\t\t\t\t// Location to backup the data to\n\t\t\t\t`--path=${systemPath}`,\n\t\t\t\t// Path to local config file for this repository\n\t\t\t\t// These don't seem to need to be persisted. If you nuke them they\n\t\t\t\t// get recreated the next time we connect.\n\t\t\t\t`--config-file=/kopia/config/${id}.config`,\n\t\t\t\t// Password for the repository\n\t\t\t\t`--password=${password}`,\n\t\t\t])\n\t\t}\n\n\t\t// Update the store\n\t\tawait this.#umbreld.store.getWriteLock(async ({set}) => {\n\t\t\tconst repositories = await this.getRepositories()\n\n\t\t\t// Sanity check to prevent dupes but this shouldn't ever happen because\n\t\t\t// the repository creation should fail\n\t\t\tconst repositoryExists = repositories.some((existingRepository) => existingRepository.id === id)\n\t\t\tif (!repositoryExists) repositories.push({id, path: virtualPath, password})\n\n\t\t\tawait set('backups.repositories', repositories)\n\t\t})\n\n\t\t// Connect to repository\n\t\tawait this.connect(id).catch(async (error) => {\n\t\t\t// If connecting fails when setting up an existing repository it means the details are incorrect.\n\t\t\t// Clean up and remove it from the store.\n\t\t\tconst isConnectingToExistingRepository = !createNew\n\t\t\tif (isConnectingToExistingRepository) await this.forgetRepository(id).catch(() => {})\n\t\t\tthrow error\n\t\t})\n\n\t\tthis.logger.log(`Connected to repository ${id}`)\n\t\treturn id\n\t}\n\n\t// Forget a repository\n\tasync forgetRepository(repositoryId: string) {\n\t\tthis.logger.log(`Forgetting repository ${repositoryId}`)\n\n\t\t// TODO: Ideally we would unmount any mounts we have from this repository but we\n\t\t// don't really have a clean way to do that with the current architecture. Probably\n\t\t// fine for now.\n\n\t\tawait this.#umbreld.store.getWriteLock(async ({set}) => {\n\t\t\tlet repositories = await this.getRepositories()\n\t\t\trepositories = repositories.filter((repository) => repository.id !== repositoryId)\n\t\t\tawait set('backups.repositories', repositories)\n\t\t})\n\n\t\tthis.logger.log(`Forgot repository ${repositoryId}`)\n\t}\n\n\t// Restore a backup\n\tasync restoreBackup(backupId: string) {\n\t\tif (this.restoreStatus.running) throw new Error('[in-progress] Restore already in progress')\n\t\tlet success = false\n\n\t\t// Check we have enough free space to restore the backup\n\t\tconst backup = await this.getBackup(backupId)\n\t\tconst diskUsage = await getSystemDiskUsage(this.#umbreld)\n\t\tconst buffer = 1024 * 1024 * 1024 * 5 // 5GB\n\t\tconst neededSpace = backup.size + buffer\n\t\tif (diskUsage.available < neededSpace) throw new Error('[not-enough-space] Not enough free space to restore backup')\n\n\t\tthis.logger.log(`Restoring backup ${backupId}`)\n\t\tsetSystemStatus('restoring')\n\t\tconst temporaryData = `${this.#umbreld.dataDirectory}/.temporary-migration`\n\t\tconst finalData = `${this.#umbreld.dataDirectory}/import`\n\n\t\t// Set restore status to running and emit operation status event\n\t\tthis.restoreStatus = {\n\t\t\trunning: true,\n\t\t\tprogress: 0,\n\t\t\tdescription: 'Restoring backup',\n\t\t\terror: false,\n\t\t\tbackupId,\n\t\t\tbytesPerSecond: 0,\n\t\t}\n\t\tthis.#umbreld.eventBus.emit('backups:restore-progress', this.restoreStatus)\n\n\t\ttry {\n\t\t\t// If mount fails, finally will emit failure state that UI can handle in the restore cover\n\t\t\tconst backupDirectoryName = await this.mountBackup(backupId)\n\t\t\tconst internalBackupMountpoint = nodePath.join(this.internalMountPath, backupDirectoryName)\n\n\t\t\t// Copy over data dir from previous install to temp dir while preserving permissions\n\t\t\tawait fse.remove(temporaryData)\n\t\t\tlet previousProgress: number\n\t\t\tawait copyWithProgress(`${internalBackupMountpoint}/`, temporaryData, (progress) => {\n\t\t\t\tthis.restoreStatus.progress = progress.progress\n\t\t\t\tthis.restoreStatus.bytesPerSecond = progress.bytesPerSecond\n\t\t\t\tthis.restoreStatus.secondsRemaining = progress.secondsRemaining\n\t\t\t\tthis.#umbreld.eventBus.emit('backups:restore-progress', this.restoreStatus)\n\t\t\t\tif (previousProgress !== this.restoreStatus.progress) {\n\t\t\t\t\tpreviousProgress = this.restoreStatus.progress\n\t\t\t\t\tthis.logger.log(`Restored ${this.restoreStatus.progress}% of backup`)\n\t\t\t\t}\n\t\t\t})\n\t\t\t// We mark that the next boot is the first start after a backup restore.\n\t\t\tawait fse.ensureFile(`${temporaryData}/${BACKUP_RESTORE_FIRST_START_FLAG}`).catch(() => {})\n\t\t\tawait fse.move(temporaryData, finalData, {overwrite: true})\n\t\t\tsuccess = true\n\t\t} finally {\n\t\t\tif (!success) {\n\t\t\t\t// Best-effort cleanup on failure (non-blocking to not delay status updates)\n\t\t\t\t// - Remove temp data to prevent disk space leaks if user never retries\n\t\t\t\t// - Remove any mounts to avoid any issues with retries\n\t\t\t\tfse.remove(temporaryData).catch(() => {})\n\t\t\t\tthis.unmountAll().catch(() => {})\n\n\t\t\t\t// Emit failure status\n\t\t\t\tthis.restoreStatus = {\n\t\t\t\t\trunning: false,\n\t\t\t\t\tprogress: 0,\n\t\t\t\t\tdescription: 'Restore failed',\n\t\t\t\t\terror: 'Restore failed',\n\t\t\t\t\t// backupId, bytesPerSecond, secondsRemaining are undefined\n\t\t\t\t}\n\t\t\t\tthis.#umbreld.eventBus.emit('backups:restore-progress', this.restoreStatus)\n\t\t\t}\n\n\t\t\t// Reset system status to 'running' on failure, or always in test mode (no reboot)\n\t\t\tif (!success || process.env.UMBRELD_RESTORE_SKIP_REBOOT === 'true') setSystemStatus('running')\n\t\t}\n\n\t\tif (success) {\n\t\t\tthis.restoreStatus = {\n\t\t\t\trunning: false,\n\t\t\t\tprogress: 100,\n\t\t\t\tdescription: 'Restore complete',\n\t\t\t\terror: false,\n\t\t\t}\n\t\t\tthis.#umbreld.eventBus.emit('backups:restore-progress', this.restoreStatus)\n\n\t\t\t// Dirty hack to allow us to test restore without rebooting\n\t\t\tif (process.env.UMBRELD_RESTORE_SKIP_REBOOT !== 'true') {\n\t\t\t\tthis.logger.log(`Rebooting into newly recovered data`)\n\t\t\t\tsetSystemStatus('restarting')\n\t\t\t\tawait this.#umbreld.stop().catch(() => {})\n\t\t\t\tawait reboot()\n\t\t\t}\n\t\t}\n\n\t\treturn\n\t}\n\n\t// Connect to a repository\n\t// We must be connected to a repository before we can backup to it\n\tprivate async connect(repositoryId: string) {\n\t\tconst repository = await this.getRepository(repositoryId)\n\n\t\tconst systemPath = this.#umbreld.files.virtualToSystemPathUnsafe(repository.path)\n\t\tawait this.kopia([\n\t\t\t'repository',\n\t\t\t'connect',\n\t\t\t'filesystem',\n\t\t\t// Location to backup the data to\n\t\t\t`--path=${systemPath}`,\n\t\t\t// Path to local config file for this repository\n\t\t\t// These don't seem to need to be persisted. If you nuke them they\n\t\t\t// get recreated the next time we connect.\n\t\t\t`--config-file=/kopia/config/${repository.id}.config`,\n\t\t\t// Password for the repository\n\t\t\t`--password=${repository.password}`,\n\t\t\t// Force the hostname to 'umbrel' so backups always match the same host.\n\t\t\t// Without this if you start backing up, then change your hostname, then\n\t\t\t// continue to backup, kopia will see these as backups originating from\n\t\t\t// different machines.\n\t\t\t'--override-hostname=umbrel',\n\t\t])\n\t}\n\n\t// Wrapper for kopia commands that interact with a repository\n\tasync repository(\n\t\trepositoryId: string,\n\t\tflags: string[] = [],\n\t\t{onOutput, bypassQueue = true}: {onOutput?: (output: string) => void; bypassQueue?: boolean} = {},\n\t) {\n\t\t// Check we're connected to the repository\n\t\t// We technically only need to connect once, but there's no downside to connecting\n\t\t// once we're already connected. This also conveniently means we auto retry connecting\n\t\t// to repos that weren't accessible before.\n\t\tawait this.connect(repositoryId)\n\n\t\t// Run the command\n\t\treturn this.kopia([...flags, `--config-file=/kopia/config/${repositoryId}.config`], {onOutput, bypassQueue})\n\t}\n\n\t// Get size of a repository\n\tasync getRepositorySize(repositoryId: string) {\n\t\tconst repository = await this.getRepository(repositoryId)\n\n\t\t// Get the used size of the repository\n\t\tconst stats = await this.repository(repository.id, ['content', 'stats', '--raw'])\n\t\tconst sizeLinePattern = 'Total Packed: '\n\t\tconst sizeLine = stats.stdout.split('\\n').find((line) => line.startsWith(sizeLinePattern)) || ''\n\t\tconst used = Number(sizeLine.replace(sizeLinePattern, '').split(' ')[0])\n\n\t\t// Get the capacity and available space of the repository\n\t\tconst status = await this.repository(repository.id, ['repository', 'status', '--json'])\n\t\tconst {capacity, available} = JSON.parse(status.stdout).volume\n\t\treturn {used, capacity, available}\n\t}\n\n\t// Backup the umbrel data directory to a repository\n\tasync backup(repositoryId: string) {\n\t\tconst repository = await this.getRepository(repositoryId)\n\t\tthis.logger.log(`Backing up to ${repository.path}`)\n\n\t\t// Ensure policy is enforced\n\t\tthis.logger.log(`Ensuring policy is enforced`)\n\t\tawait this.repository(repository.id, [\n\t\t\t'policy',\n\t\t\t'set',\n\t\t\t'--global',\n\t\t\t// Retention policy\n\t\t\t'--keep-latest=10',\n\t\t\t'--keep-hourly=24',\n\t\t\t'--keep-daily=7',\n\t\t\t'--keep-weekly=4',\n\t\t\t'--keep-monthly=12',\n\t\t\t'--keep-annual=0',\n\t\t\t// Compression\n\t\t\t'--compression=zstd-fastest',\n\t\t\t// Never cross fs boundaries\n\t\t\t'--one-file-system=true',\n\t\t\t// Throttle CPU usage\n\t\t\t'--max-parallel-file-reads=1',\n\t\t])\n\t\tthis.logger.log(`Retention policy enforced`)\n\n\t\t// Ensure we have the latest ignore file before backing up\n\t\tthis.logger.verbose(`Ensuring ignore file is up to date`)\n\t\tawait this.createIgnoreFile()\n\n\t\t// Initialize progress tracking\n\t\tconst backupProgress: BackupProgress = {repositoryId, percent: 0}\n\t\tthis.backupsInProgress.push(backupProgress)\n\t\tthis.#umbreld.eventBus.emit('backups:backup-progress', this.backupsInProgress)\n\n\t\ttry {\n\t\t\t// Create the snapshot\n\t\t\t// TODO: Attempt recovering from device out of space errors by deleting old snapshots\n\t\t\tthis.logger.log(`Creating snapshot`)\n\t\t\tawait this.repository(repository.id, ['snapshot', 'create', this.#umbreld.dataDirectory], {\n\t\t\t\tonOutput: (output) => {\n\t\t\t\t\t// Pluck progress in brackets from output like:\n\t\t\t\t\t// '/ 1 hashing, 216 hashed (1.6 GB), 21121 cached (5.4 GB), uploaded 1.4 GB, estimated 7.6 GB (91.6%) 0s left'\n\t\t\t\t\tconst match = output.match(/estimated.*\\((\\d+(?:\\.\\d+)?)%\\).*left/)\n\t\t\t\t\tif (!match) return\n\n\t\t\t\t\t// Update progress\n\t\t\t\t\tbackupProgress.percent = Number(match[1])\n\t\t\t\t\tthis.#umbreld.eventBus.emit('backups:backup-progress', this.backupsInProgress)\n\t\t\t\t},\n\t\t\t})\n\n\t\t\t// Clear any backup failure notifications if we get a successful backup\n\t\t\tawait this.#umbreld.notifications.clear(`backups-failing:${repository.id}`).catch(() => {})\n\n\t\t\tthis.logger.log(`Backed up ${repository.path}`)\n\n\t\t\t// Save last backed up date\n\t\t\tawait this.#umbreld.store.getWriteLock(async ({set}) => {\n\t\t\t\tconst repositories = await this.getRepositories()\n\t\t\t\trepositories.find((repository) => repository.id === repositoryId)!.lastBackup = Date.now()\n\t\t\t\tawait set('backups.repositories', repositories)\n\t\t\t})\n\n\t\t\t// Check the size of the repository\n\t\t\tconst size = await this.getRepositorySize(repository.id)\n\t\t\tthis.logger.log(\n\t\t\t\t`${repository.path} size after backup: Used ${prettyBytes(size.used)} of ${prettyBytes(size.capacity)}`,\n\t\t\t)\n\n\t\t\treturn true\n\t\t} finally {\n\t\t\t// Remove progress tracking\n\t\t\tthis.backupsInProgress = this.backupsInProgress.filter((progress) => progress !== backupProgress)\n\t\t\tthis.#umbreld.eventBus.emit('backups:backup-progress', this.backupsInProgress)\n\t\t}\n\t}\n\n\t// Get ignored paths\n\tasync getIgnoredPaths() {\n\t\treturn (await this.#umbreld.store.get('backups.ignore')) || []\n\t}\n\n\t// Set ignored paths\n\tasync addIgnoredPath(path: string) {\n\t\tpath = nodePath.resolve(path)\n\t\tconst isHomePath = path === '/Home' || path.startsWith('/Home/')\n\t\tif (!isHomePath) throw new Error(`Path to exclude must be in /Home`)\n\n\t\tawait this.#umbreld.store.getWriteLock(async ({set}) => {\n\t\t\tlet ignore = await this.getIgnoredPaths()\n\t\t\tignore = Array.from(new Set([...ignore, path]))\n\t\t\tawait set('backups.ignore', ignore)\n\t\t})\n\t\treturn true\n\t}\n\n\t// Remove ignored path\n\tasync removeIgnoredPath(path: string) {\n\t\tpath = nodePath.resolve(path)\n\t\tconst isHomePath = path === '/Home' || path.startsWith('/Home/')\n\t\tif (!isHomePath) throw new Error(`Path to exclude must be in /Home`)\n\n\t\tawait this.#umbreld.store.getWriteLock(async ({set}) => {\n\t\t\tlet ignore = await this.getIgnoredPaths()\n\t\t\tignore = ignore.filter((p) => p !== path)\n\t\t\tawait set('backups.ignore', ignore)\n\t\t})\n\t\treturn true\n\t}\n\n\t// Create ignore file for kopia\n\tasync createIgnoreFile() {\n\t\tconst ignoreFilePath = nodePath.join(this.#umbreld.dataDirectory, '.kopiaignore')\n\t\tlet ignoreFileContents = []\n\n\t\t// Ignore non critical directories that can be rebuilt and cause a lot of churn\n\t\tignoreFileContents.push('app-stores')\n\t\tignoreFileContents.push(this.#umbreld.files.thumbnails.thumbnailDirectory)\n\n\t\t// Ignore temporary migration directory\n\t\tignoreFileContents.push('.temporary-migration')\n\n\t\t// Ignore backup mount points\n\t\tignoreFileContents.push(this.internalMountPath)\n\t\tignoreFileContents.push(this.backupRoot)\n\n\t\t// Add all user specified ignored paths\n\t\tconst alwaysIgnoredPaths = ['/External', '/Network']\n\t\tconst userIgnoredPaths = await this.getIgnoredPaths().catch(() => [])\n\t\t;[...alwaysIgnoredPaths, ...userIgnoredPaths].forEach((path) => {\n\t\t\ttry {\n\t\t\t\tconst systemPath = this.#umbreld.files.virtualToSystemPathUnsafe(path)\n\t\t\t\tignoreFileContents.push(systemPath)\n\t\t\t} catch (error) {\n\t\t\t\tthis.logger.error(`Failed to get system path for ignored path ${path}`, error)\n\t\t\t}\n\t\t})\n\n\t\t// Loop over apps\n\t\tawait Promise.all(\n\t\t\tthis.#umbreld.apps.instances.map(async (app) => {\n\t\t\t\t// Ignore entire data dir of user specified apps to ignore\n\t\t\t\tconst isIgnored = await app.isBackupIgnored().catch((error) => {\n\t\t\t\t\t// If some app is in a broken state don't kill the whole backup\n\t\t\t\t\tthis.logger.error(`Failed to get backup ignored status for ${app.id}`, error)\n\t\t\t\t\treturn false\n\t\t\t\t})\n\t\t\t\tif (isIgnored) ignoreFileContents.push(app.dataDirectory)\n\n\t\t\t\t// Ignore paths that apps have signaled should be ignored\n\t\t\t\tconst backupIgnore = await app.getBackupIgnoredFilePaths().catch((error) => {\n\t\t\t\t\t// If some app is in a broken state don't kill the whole backup\n\t\t\t\t\tthis.logger.error(`Failed to get backup ignored file paths for ${app.id}`, error)\n\t\t\t\t\treturn []\n\t\t\t\t})\n\t\t\t\tignoreFileContents.push(...backupIgnore)\n\t\t\t}),\n\t\t)\n\n\t\t// Map all paths to absolute backup root paths\n\t\t// We make them absolute from the root like `/app-stores` instead of `app-stores` because\n\t\t// the relative would match any file or directory called `app-stores` but prepending with `/`\n\t\t// ensure it ony matches the exact path we want to ignore. Also if we use absolute system paths\n\t\t// we won't get match because kopia is assuming `/` is the backup root not the system root.\n\t\tignoreFileContents = ignoreFileContents.map((path) => {\n\t\t\t// If it's an absolute system path, convert it to a relative data directory path\n\t\t\tif (path.startsWith(this.#umbreld.dataDirectory)) path = nodePath.relative(this.#umbreld.dataDirectory, path)\n\t\t\t// All paths should now be relative to the data directory which is the backup root,\n\t\t\t// prepend a `/` to make these absolute paths from from the backup root from kopia's perspective\n\t\t\tif (!path.startsWith('/')) path = `/${path}`\n\t\t\treturn path\n\t\t})\n\n\t\t// Write the file atomically\n\t\tconst temporaryIgnoreFilePath = `${ignoreFilePath}.${randomToken(32)}`\n\t\tawait fse.writeFile(temporaryIgnoreFilePath, ignoreFileContents.join('\\n'))\n\t\tawait fse.move(temporaryIgnoreFilePath, ignoreFilePath, {overwrite: true})\n\t}\n\n\t// List backups\n\tasync listBackups(repositoryId: string) {\n\t\tconst repository = await this.getRepository(repositoryId)\n\t\tthis.logger.log(`Listing backups for ${repository.path}`)\n\n\t\t// Dump all snapshots in JSON format\n\t\tconst snapshots = await this.repository(repository.id, ['snapshot', 'list', '--json'])\n\n\t\t// Parse the JSON output\n\t\tconst snapshotsParsed = JSON.parse(snapshots.stdout)\n\n\t\t// Create typed backup object from snapshot output with composite IDs\n\t\tconst backups: Backup[] = []\n\t\tfor (const snapshot of snapshotsParsed) {\n\t\t\tbackups.push({\n\t\t\t\tid: `${repositoryId}:${snapshot.id}`,\n\t\t\t\ttime: new Date(snapshot.startTime).getTime(),\n\t\t\t\tsize: Number(snapshot.stats.totalSize),\n\t\t\t})\n\t\t}\n\n\t\t// Sort by time ascending\n\t\treturn backups.sort((a, b) => a.time - b.time)\n\t}\n\n\t// List all backups\n\tasync listAllBackups() {\n\t\tconst repositories = await this.getRepositories()\n\t\tconst backups: Backup[] = []\n\t\tawait Promise.all(\n\t\t\trepositories.map(async (repository) => {\n\t\t\t\tconst repositoryBackups = await this.listBackups(repository.id).catch((error) => {\n\t\t\t\t\t// If we can't list backups for a repository don't kill the whole backup list\n\t\t\t\t\tthis.logger.error(`Failed to list backups for ${repository.id}`, error)\n\t\t\t\t\treturn []\n\t\t\t\t})\n\t\t\t\tbackups.push(...repositoryBackups)\n\t\t\t}),\n\t\t)\n\n\t\t// Sort by time ascending\n\t\treturn backups.sort((a, b) => a.time - b.time)\n\t}\n\n\t// Parse a backup id into its repository id and snapshot id\n\tparseBackupId(backupId: string) {\n\t\tconst [repositoryId, snapshotId] = backupId.split(':')\n\t\treturn {repositoryId, snapshotId}\n\t}\n\n\t// Get a specific backup by id\n\tasync getBackup(backupId: string) {\n\t\tconst {repositoryId} = this.parseBackupId(backupId)\n\t\tconst backups = await this.listBackups(repositoryId)\n\t\tconst backup = backups.find((backup) => backup.id === backupId)\n\t\tif (!backup) throw new Error(`[not-found] Backup ${backupId} not found`)\n\t\treturn backup\n\t}\n\n\t// List the files in a backup\n\t// Note: you can append a path to a backup id to traverse the fs\n\tasync listBackupFiles(backupId: string, path = '/') {\n\t\tconst {repositoryId, snapshotId} = this.parseBackupId(backupId)\n\t\tconst ls = await this.repository(repositoryId, ['ls', `${snapshotId}${path}`])\n\t\treturn ls.stdout.split('\\n')\n\t}\n\n\t// Mount backup\n\tasync mountBackup(backupId: string) {\n\t\tconst {repositoryId, snapshotId} = this.parseBackupId(backupId)\n\n\t\t// Get the backup time for directory naming\n\t\tconst backup = await this.getBackup(backupId)\n\t\tif (!backup) throw new Error(`Backup ${backupId} not found`)\n\n\t\tthis.logger.log(`Mounting backup ${backupId}`)\n\n\t\tthis.logger.verbose(`Setting up internal mount`)\n\t\tconst directoryName = new Date(backup.time).toISOString()\n\t\tconst internalMountpoint = nodePath.join(this.internalMountPath, directoryName)\n\t\tawait fse.mkdir(internalMountpoint, {recursive: true})\n\t\tlet mountProcessExitCode = null\n\t\tthis.repository(repositoryId, ['mount', snapshotId, internalMountpoint], {bypassQueue: true})\n\t\t\t.then((process) => (mountProcessExitCode = process.exitCode))\n\t\t\t.catch((error) => {\n\t\t\t\tthis.logger.error(`Failed to mount backup ${backupId}`, error)\n\t\t\t\tmountProcessExitCode = (error as ExecaError).exitCode\n\t\t\t})\n\n\t\t// Wait for the mount to complete\n\t\tconst startTime = Date.now()\n\t\tconst timeout = 10_000 // 10 seconds\n\t\twhile (true) {\n\t\t\t// Check timeout\n\t\t\tif (Date.now() - startTime > timeout) throw new Error(`Mount timeout after ${timeout}ms`)\n\n\t\t\t// Check if process has exited\n\t\t\tif (mountProcessExitCode !== null) throw new Error(`Mount exited with code ${mountProcessExitCode}`)\n\n\t\t\t// Check if mountpoint has contents\n\t\t\tconst contents = await fse.readdir(internalMountpoint).catch(() => [])\n\t\t\tif (contents.length > 0) break // Mount complete\n\n\t\t\t// Wait a bit before checking again\n\t\t\tawait setTimeout(100)\n\t\t}\n\t\tthis.logger.verbose(`Internal mount complete`)\n\n\t\tthis.logger.verbose(`Setting up virtual filesystem mounts`)\n\t\tconst backupRoot = nodePath.join(this.backupRoot, directoryName)\n\t\tconst homeMount = nodePath.join(backupRoot, 'Home')\n\t\tconst appsMount = nodePath.join(backupRoot, 'Apps')\n\t\tawait fse.mkdir(homeMount, {recursive: true})\n\t\tawait fse.mkdir(appsMount, {recursive: true})\n\t\tawait execa('mount', ['--bind', nodePath.join(internalMountpoint, 'home'), homeMount])\n\t\tawait execa('mount', ['--bind', nodePath.join(internalMountpoint, 'app-data'), appsMount])\n\t\tthis.logger.log(`Virtual filesystem mount complete`)\n\n\t\treturn directoryName\n\t}\n\n\t// Unmount a backup\n\t// We use the directory name here because we may not have the full backup object if we're cleaning up\n\tasync unmountBackup(directoryName: string) {\n\t\tthis.logger.log(`Unmounting backup ${directoryName}`)\n\n\t\t// Unmount virtual filesystem mounts\n\t\tconst backupRoot = nodePath.join(this.backupRoot, directoryName)\n\t\tconst homeMount = nodePath.join(backupRoot, 'Home')\n\t\tconst appsMount = nodePath.join(backupRoot, 'Apps')\n\t\tawait execa('umount', [homeMount]).catch((error) =>\n\t\t\tthis.logger.error(`Failed to unmount ${homeMount}: ${error.message}`),\n\t\t)\n\t\tawait execa('umount', [appsMount]).catch((error) =>\n\t\t\tthis.logger.error(`Failed to unmount ${appsMount}: ${error.message}`),\n\t\t)\n\t\tawait fse.remove(backupRoot).catch((error) => this.logger.error(`Failed to remove ${backupRoot}: ${error.message}`))\n\n\t\t// Unmount internal mount\n\t\tconst internalMountpoint = nodePath.join(this.internalMountPath, directoryName)\n\t\tawait execa('umount', [internalMountpoint]).catch((error) =>\n\t\t\tthis.logger.error(`Failed to unmount ${internalMountpoint}: ${error.message}`),\n\t\t)\n\t\tawait fse\n\t\t\t.remove(internalMountpoint)\n\t\t\t.catch((error) => this.logger.error(`Failed to remove ${internalMountpoint}: ${error.message}`))\n\n\t\tthis.logger.log(`Unmounted backup ${directoryName}`)\n\t\treturn true\n\t}\n\n\t// Check if we have any backups mounted and unmount them\n\tasync unmountAll(): Promise<void> {\n\t\t// List current backups mounted in the virtual filesystem\n\t\tconst backups = await fse.readdir(this.backupRoot).catch(() => [])\n\n\t\t// Unmount each backup\n\t\tawait Promise.all(\n\t\t\tbackups.map((backup) =>\n\t\t\t\tthis.unmountBackup(backup).catch((error) => this.logger.error(`Failed to unmount ${backup}: ${error.message}`)),\n\t\t\t),\n\t\t)\n\n\t\t// We should now have no backups mounted but just incase we somehow have an internal backup mounted\n\t\t// without a virtual filesystem mount we check for internal mount directories too\n\t\tconst internalMounts = await fse.readdir(this.internalMountPath).catch(() => [])\n\t\tawait Promise.all(\n\t\t\tinternalMounts.map((internalMount) =>\n\t\t\t\tthis.unmountBackup(internalMount).catch((error) =>\n\t\t\t\t\tthis.logger.error(`Failed to unmount ${internalMount}: ${error.message}`),\n\t\t\t\t),\n\t\t\t),\n\t\t)\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/backups/routes.ts",
    "content": "import z from 'zod'\n\nimport {router, privateProcedure, publicProcedureWhenNoUserExists} from '../server/trpc/trpc.js'\n\nexport default router({\n\t// Get all backup repositories\n\tgetRepositories: privateProcedure.query(async ({ctx}) => {\n\t\tconst repositories = await ctx.umbreld.backups.getRepositories()\n\n\t\t// Only return properties we want to expose\n\t\treturn repositories.map(({id, path, lastBackup}) => ({id, path, lastBackup}))\n\t}),\n\n\t// Get size of a repository\n\tgetRepositorySize: privateProcedure\n\t\t.input(z.object({repositoryId: z.string()}))\n\t\t.query(async ({ctx, input}) => ctx.umbreld.backups.getRepositorySize(input.repositoryId)),\n\n\t// Create a new backup repository\n\tcreateRepository: privateProcedure\n\t\t.input(z.object({path: z.string(), password: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.backups.createRepository(input.path, input.password)),\n\n\t// Forget a repository\n\tforgetRepository: privateProcedure\n\t\t.input(z.object({repositoryId: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.backups.forgetRepository(input.repositoryId)),\n\n\t// Do a backup right now\n\tbackup: privateProcedure\n\t\t.input(z.object({repositoryId: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.backups.backup(input.repositoryId)),\n\n\t// List backups for a repository\n\tlistBackups: publicProcedureWhenNoUserExists\n\t\t.input(z.object({repositoryId: z.string()}))\n\t\t.query(async ({ctx, input}) => ctx.umbreld.backups.listBackups(input.repositoryId)),\n\n\t// List all backups for all repositories\n\tlistAllBackups: privateProcedure.query(async ({ctx}) => ctx.umbreld.backups.listAllBackups()),\n\n\t// List files in a backup\n\t// Only really used for testing and debug\n\tlistBackupFiles: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tbackupId: z.string(),\n\t\t\t\tpath: z.string().optional(),\n\t\t\t}),\n\t\t)\n\t\t.query(async ({ctx, input}) => ctx.umbreld.backups.listBackupFiles(input.backupId, input.path)),\n\n\t// Mount a backup\n\tmountBackup: privateProcedure\n\t\t.input(z.object({backupId: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.backups.mountBackup(input.backupId)),\n\n\t// Unmount a backup\n\tunmountBackup: privateProcedure\n\t\t.input(z.object({directoryName: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.backups.unmountBackup(input.directoryName)),\n\n\t// Get progress of backup operations\n\tbackupProgress: privateProcedure.query(async ({ctx}) => ctx.umbreld.backups.backupsInProgress),\n\n\t// Get ignored paths\n\tgetIgnoredPaths: privateProcedure.query(async ({ctx}) => ctx.umbreld.backups.getIgnoredPaths()),\n\n\t// Add an ignored path\n\taddIgnoredPath: privateProcedure\n\t\t.input(z.object({path: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.backups.addIgnoredPath(input.path)),\n\n\t// Remove an ignored path\n\tremoveIgnoredPath: privateProcedure\n\t\t.input(z.object({path: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.backups.removeIgnoredPath(input.path)),\n\n\t// Connect to an existing repository\n\tconnectToExistingRepository: publicProcedureWhenNoUserExists\n\t\t.input(z.object({path: z.string(), password: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.backups.connectToExistingRepository(input.path, input.password)),\n\n\t// Restore a backup\n\trestoreBackup: publicProcedureWhenNoUserExists\n\t\t.input(z.object({backupId: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.backups.restoreBackup(input.backupId)),\n\n\t// Get status of restore operations\n\trestoreStatus: publicProcedureWhenNoUserExists.query(async ({ctx}) => ctx.umbreld.backups.restoreStatus),\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/blacklist-uas/blacklist-uas.ts",
    "content": "import {globby} from 'globby'\nimport fse from 'fs-extra'\nimport {$} from 'execa'\n\n// By default Linux uses the UAS driver for most devices. This causes major\n// stability problems on the Raspberry Pi 4, not due to issues with UAS, but due\n// to devices running in UAS mode using much more power. The Pi can't reliably\n// provide enough power to the USB port and the entire system experiences\n// extreme instability. By blacklisting all devices from the UAS driver on first\n// and then rebooting we fall back to the mass-storage driver, which results in\n// decreased performance, but lower power usage, and much better system stability.\n//\n// We use console.err for logs so they appear in umbrel-external-storage logs and\n// console.log to signal to the mount script that we're rebooting.\nexport default async function blacklistUASDriver() {\n\ttry {\n\t\tconsole.error('Checking for UAS devices to blacklist')\n\t\tconst justDidRebootFile = '/umbrel-just-did-reboot'\n\t\t// Only run on Raspberry Pi 4\n\t\tconst cpuInfo = await fse.readFile('/proc/cpuinfo')\n\t\tif (!cpuInfo.includes('Raspberry Pi 4 ')) {\n\t\t\tconsole.error('Not running on Pi 4, exiting...')\n\t\t\treturn\n\t\t}\n\t\tconsole.error('Running on Pi 4')\n\t\tconst blacklist = []\n\t\t// Get all USB device uevent files\n\t\tconst usbDeviceUeventFiles = await globby('/sys/bus/usb/devices/*/uevent')\n\t\tfor (const ueventFile of usbDeviceUeventFiles) {\n\t\t\tconst uevent = await fse.readFile(ueventFile, 'utf8')\n\t\t\tif (!uevent.includes('DRIVER=uas')) continue\n\t\t\tconst [vendorId, productId] = uevent\n\t\t\t\t.split('\\n')\n\t\t\t\t.find((line) => line?.startsWith('PRODUCT='))!\n\t\t\t\t.replace('PRODUCT=', '')\n\t\t\t\t.split('/')\n\t\t\tconst deviceId = `${vendorId}:${productId}`\n\t\t\tconsole.error(`UAS device found ${deviceId}`)\n\t\t\tblacklist.push(deviceId)\n\t\t}\n\n\t\t// Don't reboot if we don't have any UAS devices\n\t\tif (blacklist.length === 0) {\n\t\t\tconsole.error('No UAS devices found!')\n\t\t\tawait fse.remove(justDidRebootFile)\n\t\t\treturn\n\t\t}\n\n\t\t// Check we're not in a boot loop\n\t\tif (await fse.pathExists(justDidRebootFile)) {\n\t\t\tconsole.error('We just rebooted, we could be in a bootloop, skipping reboot')\n\t\t\treturn\n\t\t}\n\n\t\t// Read current cmdline\n\t\tconsole.error(`Applying quirks to cmdline.txt`)\n\t\tlet cmdline = await fse.readFile('/boot/cmdline.txt', 'utf8')\n\n\t\t// Don't apply quirks if they're already applied\n\t\tconst quirksAlreadyApplied = blacklist.every((deviceId) => cmdline.includes(`${deviceId}:u`))\n\t\tif (quirksAlreadyApplied) {\n\t\t\tconsole.error('UAS quirks already applied, skipping')\n\t\t\treturn\n\t\t}\n\n\t\t// Remove any current quirks\n\t\tcmdline = cmdline\n\t\t\t.trim()\n\t\t\t.split(' ')\n\t\t\t.filter((flag) => !flag.startsWith('usb-storage.quirks='))\n\t\t\t.join(' ')\n\t\t// Add new quirks\n\t\tconst quirks = blacklist.map((deviceId) => `${deviceId}:u`).join(',')\n\t\tcmdline = `${cmdline} usb-storage.quirks=${quirks}`\n\n\t\t// Remount /boot as writable\n\t\tawait $`mount -o remount,rw /boot`\n\t\t// Write new cmdline\n\t\tawait fse.writeFile('/boot/cmdline.txt', cmdline)\n\n\t\t// Reboot the system\n\t\tconsole.error(`Rebooting`)\n\t\t// We must make exactly this console log so we can detect a reboot in the mount script and halt\n\t\tconsole.log('mount-script-halt')\n\t\t// We need to make sure we commit before rebooting otherwise\n\t\t// OTA updates will get instantly rolled back.\n\t\ttry {\n\t\t\tawait $`rugix-ctrl system commit`\n\t\t} catch {}\n\t\tawait fse.writeFile(justDidRebootFile, cmdline)\n\t\tawait $`reboot`\n\t\treturn true\n\t} catch (error) {\n\t\tconsole.error(`Failed to blacklist UAS driver: ${(error as Error).message}`)\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/cli-client.ts",
    "content": "import process from 'node:process'\n\nimport {createTRPCClient, httpLink, createWSClient, wsLink, splitLink} from '@trpc/client'\nimport fse from 'fs-extra'\n\nimport * as jwt from './jwt.js'\n\nimport {type AppRouter, httpOnlyPaths} from './server/trpc/common.js'\n\n// TODO: Maybe just read the endpoint from the data dir\nconst dataDir = process.env.UMBREL_DATA_DIR ?? '/home/umbrel/umbrel'\nconst trpcEndpoint = process.env.UMBREL_TRPC_ENDPOINT ?? `http://localhost/trpc`\n\nasync function signJwt() {\n\tconst secret = await fse.readFile(`${dataDir}/secrets/jwt`, {encoding: 'utf8'})\n\tconst token = await jwt.sign(secret)\n\treturn token\n}\n\n// The CLI client always authenticates by signing a JWT; no unauthenticated mode.\n// We use HTTP only for `httpOnlyPaths` (needs request/response semantics like cookies/headers), and WS otherwise.\nconst trpc = createTRPCClient<AppRouter>({\n\tlinks: [\n\t\tsplitLink({\n\t\t\tcondition: (operation) => httpOnlyPaths.includes(operation.path as (typeof httpOnlyPaths)[number]),\n\t\t\ttrue: httpLink({\n\t\t\t\turl: trpcEndpoint,\n\t\t\t\theaders: async () => ({\n\t\t\t\t\tAuthorization: `Bearer ${await signJwt()}`,\n\t\t\t\t}),\n\t\t\t}),\n\t\t\tfalse: wsLink({\n\t\t\t\tclient: createWSClient({url: async () => `${trpcEndpoint}?token=${await signJwt()}`}),\n\t\t\t}),\n\t\t}),\n\t],\n})\n\nfunction parseValue(value: string): any {\n\t// Check if the value can be parsed as JSON\n\ttry {\n\t\treturn JSON.parse(value)\n\t} catch {\n\t\t// If not, check if the value can be converted to a number\n\t\tif (/^\\d+\\.?\\d*$/.test(value)) {\n\t\t\treturn Number(value)\n\t\t}\n\n\t\t// Check if the value is a comma-separated list\n\t\tif (value.includes(',')) {\n\t\t\treturn value.split(',').map((v) => parseValue(v))\n\t\t}\n\n\t\t// Return as string\n\t\treturn String(value)\n\t}\n}\n\nfunction parseArgs(args: string[]): any {\n\tif (args.length === 1) return parseValue(args[0])\n\n\tconst result: Record<string, any> = {}\n\tfor (let i = 0; i < args.length; i++) {\n\t\tif (!args[i].startsWith('--')) throw new Error('Invalid argument')\n\t\tconst key = args[i].slice(2)\n\t\tconst value = parseValue(args[i + 1])\n\t\tresult[key] = value\n\t\ti++ // Skip next item which is the current value\n\t}\n\n\treturn result\n}\n\ntype CliClientOptions = {\n\tquery: string\n\targs: string[]\n}\n\nexport const cliClient = async ({query, args}: CliClientOptions) => {\n\t// Parse flags into an object\n\tconst parsedArgs = parseArgs(args)\n\n\t// Split the query into parts and grab the procedure via dot notation\n\tconst parts = query.split('.')\n\tlet procedure: any = trpc\n\tfor (const part of parts) procedure = procedure[part]\n\n\t// Subscription\n\tif (parts.at(-1) === 'subscribe') {\n\t\treturn await new Promise((resolve, reject) => {\n\t\t\tprocedure(parsedArgs, {\n\t\t\t\tonData: (data: any) => console.log(JSON.stringify(data, null, 2)),\n\t\t\t\tonError: (error: any) => reject(error),\n\t\t\t\tonComplete: () => resolve(void 0),\n\t\t\t})\n\t\t})\n\t}\n\n\t// Query or Mutation\n\tconsole.log(JSON.stringify(await procedure(parsedArgs), null, 2))\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/dbus/dbus.ts",
    "content": "// TODO: Move this into a system submodule when we have a\n// cleaned up system module\n\nimport dbus from '@homebridge/dbus-native'\nimport {throttle} from 'es-toolkit'\nimport type Umbreld from '../../index.js'\n\nexport default class Dbus {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\t#removeDiskEventListeners?: () => void\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(name.toLocaleLowerCase())\n\t}\n\n\tasync start() {\n\t\tthis.logger.log('Starting dbus')\n\n\t\tawait this.addDiskEventListeners().catch((error) => {\n\t\t\tthis.logger.error(`Failed to add disk event listeners`, error)\n\t\t})\n\t}\n\n\tasync addDiskEventListeners() {\n\t\tthis.logger.log('Attaching disk event listeners')\n\n\t\t// Create throttled event emitter since we often get lots of events at once\n\t\tconst sendThrottledEvent = throttle(async () => this.#umbreld.eventBus.emit('system:disk:change'), 100, {\n\t\t\tedges: ['leading'],\n\t\t})\n\n\t\t// Setup event handler\n\t\tconst handleDeviceChange = (unitName: string) => {\n\t\t\t// Check if we have a new disk device service from systemd\n\t\t\tconst isDisk = typeof unitName === 'string' && unitName.includes('disk') && unitName.endsWith('.device')\n\t\t\tif (isDisk) sendThrottledEvent()\n\t\t}\n\n\t\t// Attach event handler to systemd service events\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tdbus\n\t\t\t\t.systemBus()\n\t\t\t\t.getService('org.freedesktop.systemd1')\n\t\t\t\t.getInterface('/org/freedesktop/systemd1', 'org.freedesktop.systemd1.Manager', (error, manager) => {\n\t\t\t\t\tif (error) return reject(error)\n\n\t\t\t\t\t// Add listeners\n\t\t\t\t\tmanager.addListener('UnitNew', handleDeviceChange)\n\t\t\t\t\tmanager.addListener('UnitRemoved', handleDeviceChange)\n\n\t\t\t\t\t// Create cleanup function\n\t\t\t\t\tthis.#removeDiskEventListeners = () => {\n\t\t\t\t\t\tthis.logger.log('Removing disk event listeners')\n\t\t\t\t\t\tmanager.removeListener('UnitNew', handleDeviceChange)\n\t\t\t\t\t\tmanager.removeListener('UnitRemoved', handleDeviceChange)\n\t\t\t\t\t\tthis.#removeDiskEventListeners = undefined\n\t\t\t\t\t}\n\n\t\t\t\t\tresolve(true)\n\t\t\t\t})\n\t\t})\n\t}\n\n\tasync stop() {\n\t\tthis.logger.log('Stopping dbus')\n\t\tthis.#removeDiskEventListeners?.()\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/development.ts",
    "content": "import type Umbreld from '../index.js'\nimport {$} from 'execa'\nimport fse from 'fs-extra'\nimport {escapeSpecialRegExpLiterals} from './utilities/regexp.js'\n\n// Override hostname used in development\nexport async function overrideDevelopmentHostname(umbreld: Umbreld, hostname: string) {\n\ttry {\n\t\t// Update static hostname and hosts mapping\n\t\tawait fse.writeFile('/etc/hostname', `${hostname}\\n`)\n\t\tconst etcHosts = await fse.readFile('/etc/hosts', 'utf8')\n\t\tconst hostnameInEtcHostsRe = new RegExp(\n\t\t\t`^\\\\s*${escapeSpecialRegExpLiterals('127.0.0.1')}\\\\s+${escapeSpecialRegExpLiterals(hostname)}\\\\s*$`,\n\t\t\t'm',\n\t\t)\n\t\tif (!hostnameInEtcHostsRe.test(etcHosts)) {\n\t\t\tawait fse.writeFile('/etc/hosts', `${etcHosts.trimEnd()}\\n127.0.0.1       ${hostname}\\n`)\n\t\t}\n\t\t// Apply new hostname\n\t\tawait $`hostname ${hostname}`\n\t\t// Restart hostname-dependent services\n\t\tawait $`systemctl restart avahi-daemon`\n\t\tumbreld.logger.log(`Applied development hostname '${hostname}'`)\n\t\treturn true\n\t} catch (error) {\n\t\tumbreld.logger.error(`Failed to apply development hostname`, error)\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/event-bus/event-bus.ts",
    "content": "import Emittery from 'emittery'\n\nimport type Umbreld from '../../index.js'\nimport type {FileChangeEvent} from '../files/watcher.js'\nimport type {OperationsInProgress} from '../files/files.js'\nimport type {BackupsInProgress, RestoreStatus} from '../backups/backups.js'\nimport type {ExpansionStatus, FailsafeTransitionStatus, RebuildStatus, ReplaceStatus} from '../hardware/raid.js'\n\n// Type assertion to ensure all events in EventTypes are defined in events\ntype MissingInEvents = Exclude<keyof EventTypes, (typeof events)[number]>\ntype _AssertEveryKeyIsListed = MissingInEvents extends never ? true : [`✘ Add these to events →`, MissingInEvents]\nconst _eventsIncludesAllKeys: _AssertEveryKeyIsListed = true\n\n// Statically define event names for use in rpc argument validation\nexport const events = [\n\t'files:watcher:change',\n\t'files:operation-progress',\n\t'backups:backup-progress',\n\t'backups:restore-progress',\n\t'system:disk:change',\n\t'files:external-storage:change',\n\t'raid:expansion-progress',\n\t'raid:failsafe-transition-progress',\n\t'raid:rebuild-progress',\n\t'raid:replace-progress',\n] as const satisfies readonly (keyof EventTypes)[]\n\n// Statically define event types\nexport type EventTypes = {\n\t// Fires when a watched file changes\n\t'files:watcher:change': FileChangeEvent\n\t// Fires repeatedly while file operations (copy/move) are in progress\n\t// with the current progress of each operation\n\t'files:operation-progress': OperationsInProgress\n\t// Fires repeatedly while backup operations are in progress\n\t// with the current progress of each backup\n\t'backups:backup-progress': BackupsInProgress\n\t// Fires repeatedly while a restore operation is in progress\n\t// with the current status of the restore operation\n\t'backups:restore-progress': RestoreStatus\n\t// Fires when the connected block devices change\n\t// e.g attaching/removing a USB drive\n\t'system:disk:change': undefined\n\t// Fires when the accessible external storage devices change\n\t// e.g mounting/unmounting a USB drive\n\t'files:external-storage:change': undefined\n\t// Fires when RAID expansion progress changes\n\t'raid:expansion-progress': ExpansionStatus\n\t// Fires when failsafe transition progress changes\n\t'raid:failsafe-transition-progress': FailsafeTransitionStatus\n\t// Fires when RAID rebuild progress changes\n\t'raid:rebuild-progress': RebuildStatus\n\t// Fires when RAID replace progress changes\n\t'raid:replace-progress': ReplaceStatus\n}\n\nexport default class EventBus {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\t#emitter = new Emittery<EventTypes>()\n\t// Add an event listener\n\t// Returns an unsubscribe function\n\ton = this.#emitter.on.bind(this.#emitter)\n\t// Wait for an event to be called once\n\t// Returns the event data\n\tonce = this.#emitter.once.bind(this.#emitter)\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(name.toLocaleLowerCase())\n\t}\n\n\t// Stream events\n\tstream(event: keyof EventTypes, {signal}: {signal?: AbortSignal} = {}) {\n\t\tconst iterator = this.#emitter.events(event)\n\n\t\t// An optional AbortSignal instance can be passed in to immediately\n\t\t// abort the stream. This is useful to avoid memory leaks when clients\n\t\t// subscribe to events and then disconnect without unsubscribing first.\n\t\tsignal?.addEventListener('abort', () => iterator.return?.(), {once: true})\n\n\t\treturn iterator\n\t}\n\n\t// Emit an event\n\temit: Emittery<EventTypes>['emit'] = (event: keyof EventTypes, data?: EventTypes[keyof EventTypes]) => {\n\t\tthis.logger.verbose(`${event} ${data === undefined ? '' : JSON.stringify(data)}`)\n\t\treturn this.#emitter.emit(event, data).catch((error) => {\n\t\t\t// Make sure we catch any unhandled errors so they don't crash the process\n\t\t\tthis.logger.error(`Handler failed for event ${event}`, error)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/event-bus/routes.ts",
    "content": "import z from 'zod'\nimport {cloneDeep} from 'es-toolkit'\n\nimport {router, privateProcedure} from '../server/trpc/trpc.js'\n\nimport {type EventTypes, events} from './event-bus.js'\n\nexport default router({\n\t// Listen for events\n\tlisten: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tevent: z.enum(events),\n\t\t\t}),\n\t\t)\n\t\t.subscription(async function* ({ctx, input, signal}) {\n\t\t\t// Stream the events\n\t\t\t// We pass in the AbortSignal so the stream can be immediately cleaned up\n\t\t\t// when the client disconnects to avoid memory leaks.\n\t\t\tfor await (let event of ctx.umbreld.eventBus.stream(input.event, {signal})) {\n\t\t\t\t// Reformat the files:watcher:change event so it's suitable to be consumed by the client\n\t\t\t\tif (input.event === 'files:watcher:change') {\n\t\t\t\t\t// Clone event to avoid mutating the original event object\n\t\t\t\t\tevent = cloneDeep(event) as EventTypes['files:watcher:change']\n\n\t\t\t\t\t// Convert the system path to a virtual path\n\t\t\t\t\tevent.path = ctx.umbreld.files.systemToVirtualPath(event.path)\n\t\t\t\t}\n\n\t\t\t\t// Stream the event to the client\n\t\t\t\tyield event\n\t\t\t}\n\t\t}),\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/api.download.integration.test.ts",
    "content": "import {expect, test, beforeEach, beforeAll, afterAll} from 'vitest'\nimport fse from 'fs-extra'\nimport AdmZip from 'adm-zip'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\n// Create a new umbreld instance for each test\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\nbeforeEach(async () => {\n\t// Clean up any files from previous tests\n\tawait fse.emptyDir(`${umbreld.instance.dataDirectory}/home`)\n})\n\n// Helper function to extract files from a zip buffer\nfunction extractZipBuffer(buffer: Buffer): Record<string, string> {\n\t// Get zip entries\n\tconst zip = new AdmZip(buffer)\n\tconst zipEntries = zip.getEntries()\n\n\t// Create a map of file names to their contents\n\tconst files: Record<string, string> = {}\n\tfor (const entry of zipEntries) files[entry.entryName] = entry.getData().toString('utf8')\n\n\treturn files\n}\n\ntest('GET /api/files/download throws unauthorized error whithout cookie', async () => {\n\tconst error = await umbreld.unauthenticatedApi.get('files/download').catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(401)\n\texpect(error.response.body).toMatchObject({error: 'unauthorized'})\n})\n\ntest('GET /api/files/download throws 400 error without path parameter', async () => {\n\tconst error = await umbreld.api.get('files/download').catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(400)\n\texpect(error.response.body).toMatchObject({error: 'bad request'})\n})\n\ntest('GET /api/files/download throws 404 error when file does not exist', async () => {\n\tconst error = await umbreld.api.get('files/download?path=/Home/does-not-exist').catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(404)\n\texpect(error.response.body).toMatchObject({error: 'not found'})\n})\n\ntest('GET /api/files/download throws 404 error on directory traversal attempt', async () => {\n\tconst error = await umbreld.api.get('files/download?path=/Home/../../../../etc/passwd').catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(404)\n\texpect(error.response.body).toMatchObject({error: 'not found'})\n})\n\ntest('GET /api/files/download throws 404 error on relative path', async () => {\n\tconst paths = ['Home/file.txt', './Home/file.txt', '../home/file.txt', 'file.txt']\n\n\tfor (const path of paths) {\n\t\tconst error = await umbreld.api.get(`files/download?path=${path}`).catch((error) => error)\n\t\texpect(error).toBeInstanceOf(Error)\n\t\texpect(error.response.statusCode).toBe(404)\n\t\texpect(error.response.body).toMatchObject({error: 'not found'})\n\t}\n})\n\ntest('GET /api/files/download throws 404 error on symlink traversal attempt', async () => {\n\t// Create a symlink to the root directory\n\tawait fse.ensureSymlink('/', `${umbreld.instance.dataDirectory}/home/symlink-to-root`)\n\n\t// Attempt to access files through the symlink\n\tconst error = await umbreld.api.get('files/download?path=/Home/symlink-to-root/etc/passwd').catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(404)\n\texpect(error.response.body).toMatchObject({error: 'not found'})\n})\n\ntest('GET /api/files/download throws 404 error when one of multiple files does not exist', async () => {\n\t// Create one file\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/file.txt`, 'contents')\n\n\t// Try to download it along with a non-existent file\n\tconst error = await umbreld.api\n\t\t.get('files/download?path=/Home/file.txt&path=/Home/does-not-exist')\n\t\t.catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(404)\n\texpect(error.response.body).toMatchObject({error: 'not found'})\n})\n\ntest('GET /api/files/download throws 400 error when paths are in different directories', async () => {\n\t// Create two files in different directories\n\tawait fse.ensureDir(`${umbreld.instance.dataDirectory}/home/dir1`)\n\tawait fse.ensureDir(`${umbreld.instance.dataDirectory}/home/dir2`)\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/dir1/file1.txt`, 'contents1')\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/dir2/file2.txt`, 'contents2')\n\n\t// Try to download both files\n\tconst error = await umbreld.api\n\t\t.get('files/download?path=/Home/dir1/file1.txt&path=/Home/dir2/file2.txt')\n\t\t.catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(400)\n\texpect(error.response.body).toMatchObject({error: 'paths must be in same directory'})\n})\n\ntest('GET /api/files/download downloads a file with a valid cookie', async () => {\n\t// Create a file\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/file.txt`, 'contents')\n\n\t// Download the file\n\tconst response = await umbreld.api.get('files/download?path=/Home/file.txt', {responseType: 'text'})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.body).toBe('contents')\n\texpect(response.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''file.txt`)\n})\n\ntest('GET /api/files/download downloads a file with special characters in name', async () => {\n\t// Create a file with special characters in the name\n\tconst fileName = 'file with spaces & special chars 漢字.txt'\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/${fileName}`, 'special contents')\n\n\t// Download the file\n\tconst response = await umbreld.api.get(`files/download?path=/Home/${encodeURIComponent(fileName)}`, {\n\t\tresponseType: 'text',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.body).toBe('special contents')\n\texpect(response.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`)\n})\n\ntest('GET /api/files/download creates a zip archive for multiple files', async () => {\n\t// Create multiple files\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/file1.txt`, 'contents1')\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/file2.txt`, 'contents2')\n\n\t// Download the files\n\tconst response = await umbreld.api.get('files/download?path=/Home/file1.txt&path=/Home/file2.txt', {\n\t\tresponseType: 'buffer',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.headers['content-type']).toBe('application/zip')\n\texpect(response.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''umbrel-files.zip`)\n\n\t// Extract and verify the zip contents\n\tconst files = await extractZipBuffer(response.body)\n\texpect(files['file1.txt']).toBe('contents1')\n\texpect(files['file2.txt']).toBe('contents2')\n})\n\ntest('GET /api/files/download creates a zip archive for a directory', async () => {\n\t// Create a directory with files\n\tawait fse.ensureDir(`${umbreld.instance.dataDirectory}/home/testdir`)\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/testdir/file1.txt`, 'contents1')\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/testdir/file2.txt`, 'contents2')\n\tawait fse.ensureDir(`${umbreld.instance.dataDirectory}/home/testdir/subdir`)\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/testdir/subdir/file3.txt`, 'contents3')\n\n\t// Download the directory\n\tconst response = await umbreld.api.get('files/download?path=/Home/testdir', {\n\t\tresponseType: 'buffer',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.headers['content-type']).toBe('application/zip')\n\texpect(response.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''testdir.zip`)\n\n\t// Extract and verify the zip contents\n\tconst files = await extractZipBuffer(response.body)\n\texpect(files['testdir/file1.txt']).toBe('contents1')\n\texpect(files['testdir/file2.txt']).toBe('contents2')\n\texpect(files['testdir/subdir/file3.txt']).toBe('contents3')\n})\n\ntest('GET /api/files/download handles files with spaces and special characters', async () => {\n\t// Create a file with special characters in the name\n\tconst filename = 'file with spaces & special chars 漢字.txt'\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/${filename}`, 'special content')\n\n\t// Download the file\n\tconst response = await umbreld.api.get(`files/download?path=/Home/${encodeURIComponent(filename)}`, {\n\t\tresponseType: 'text',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.body).toBe('special content')\n\texpect(response.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''${encodeURIComponent(filename)}`)\n})\n\ntest('GET /api/files/download handles empty directories correctly', async () => {\n\t// Create an empty directory\n\tawait fse.ensureDir(`${umbreld.instance.dataDirectory}/home/empty-dir`)\n\n\t// Download the directory\n\tconst response = await umbreld.api.get('files/download?path=/Home/empty-dir', {\n\t\tresponseType: 'buffer',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.headers['content-type']).toBe('application/zip')\n\texpect(response.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''empty-dir.zip`)\n\n\t// Extract and verify the zip contents - should be an empty folder\n\tconst files = await extractZipBuffer(response.body)\n\texpect(Object.keys(files).length).toBe(0) // No files in the empty directory\n})\n\ntest('GET /api/files/download handles files with zero content correctly', async () => {\n\t// Create an empty file\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/empty-file.txt`, '')\n\n\t// Download the file\n\tconst response = await umbreld.api.get('files/download?path=/Home/empty-file.txt')\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.body).toBe('')\n\texpect(response.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''empty-file.txt`)\n})\n\ntest('GET /api/files/download handles binary files correctly', async () => {\n\t// Create a small binary file\n\tconst binaryData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd, 0xfc])\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/binary-file.bin`, binaryData)\n\n\t// Download the file\n\tconst response = await umbreld.api.get('files/download?path=/Home/binary-file.bin', {\n\t\tresponseType: 'buffer',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(Buffer.from(response.body)).toEqual(binaryData)\n\texpect(response.headers['content-disposition']).toBe(`attachment; filename*=UTF-8''binary-file.bin`)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/api.thumbnail.integration.test.ts",
    "content": "import nodePath from 'node:path'\nimport crypto from 'node:crypto'\n\nimport {expect, test, beforeEach, beforeAll, afterAll} from 'vitest'\nimport fse from 'fs-extra'\nimport {$} from 'execa'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\n// Create a new umbreld instance for each test\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\nbeforeEach(async () => {\n\t// Clean up thumbnails directory\n\tawait fse.emptyDir(`${umbreld.instance.dataDirectory}/thumbnails`)\n})\n\ntest('GET /api/files/thumbnail/:thumbnail throws unauthorized error without cookie', async () => {\n\tconst error = await umbreld.unauthenticatedApi.get('files/thumbnail/12345.webp').catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(401)\n\texpect(error.response.body).toMatchObject({error: 'unauthorized'})\n})\n\ntest('GET /api/files/thumbnail/:thumbnail throws 404 error without a thumbnail path', async () => {\n\tconst error = await umbreld.api.get('files/thumbnail/').catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(404)\n})\n\ntest('GET /api/files/thumbnail/:thumbnail throws 404 error when thumbnail does not exist', async () => {\n\t// Generate a valid sha256 hash that doesn't exist in the system\n\tconst validHash = crypto.createHash('sha256').update('nonexistent-file').digest('hex')\n\tconst error = await umbreld.api.get(`files/thumbnail/${validHash}.webp`).catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\t// When using express.static, we get a 404 HTML page\n\texpect(error.response.statusCode).toBe(404)\n})\n\ntest('GET /api/files/thumbnail/:thumbnail serves a thumbnail with valid hash and correct cache headers', async () => {\n\t// Create a mock thumbnail file with a valid hex hash\n\tconst hash = crypto.createHash('sha256').update('test-thumbnail-data').digest('hex')\n\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\tconst thumbnailPath = nodePath.join(thumbnailDir, `${hash}.webp`)\n\n\t// Create a simple image file (1x1 pixel webp)\n\t// const thumbnailData = Buffer.from('RIFF\\x1A\\0\\0\\0WEBPVP8 \\x0E\\0\\0\\0\\x10\\0\\0\\0\\0\\0\\0\\0', 'binary')\n\t// await fse.writeFile(thumbnailPath, thumbnailData)\n\n\t// Create a small test webp image with ImageMagick\n\tawait $`convert -size 1x1 canvas:red ${thumbnailPath}`\n\n\t// Verify the thumbnail file was created\n\texpect(await fse.pathExists(thumbnailPath)).toBe(true)\n\n\t// Request the thumbnail through the API\n\t// Use responseType: 'buffer' to override the default behaviour and get binary data instead of trying to parse as JSON\n\t// This allows us to compare the response body to the original thumbnail file\n\tconst response = await umbreld.api.get(`files/thumbnail/${hash}.webp`, {responseType: 'buffer'})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\n\t// Check that we get cache headers\n\texpect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable')\n\n\t// Check content type header is for webp images\n\texpect(response.headers['content-type']).toBe('image/webp')\n\n\t// Check that we got some binary data\n\texpect(response.body.length).toBeGreaterThan(0)\n\n\t// Verify that the response body is the same as the original thumbnail file\n\tconst originalThumbnailData = await fse.readFile(thumbnailPath)\n\tconst responseThumbnailData = response.body\n\texpect(originalThumbnailData).toEqual(responseThumbnailData)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/api.ts",
    "content": "import nodePath from 'node:path'\nimport {pipeline} from 'node:stream/promises'\n\nimport express from 'express'\nimport fse from 'fs-extra'\n\nimport type {ApiOptions} from '../server/index.js'\n\nexport default function api({publicApi, privateApi, umbreld}: ApiOptions) {\n\t// Serve thumbnails from the thumbnails directory\n\t// GET /api/files/thumbnail/:thumbnail\n\tprivateApi.use(\n\t\t'/thumbnail',\n\t\t// Serve the thumbnail assets\n\t\texpress.static(umbreld.files.thumbnails.thumbnailDirectory, {\n\t\t\t// Thumbnail assets are named with a hash that only changes when the file is modified\n\t\t\t// So we can cache these aggressively\n\t\t\tmaxAge: '1 year',\n\t\t\timmutable: true,\n\t\t\t// Don't serve directory indexes\n\t\t\tindex: false,\n\t\t}),\n\t\t// If we don't get a file hit, return a 404\n\t\t(request, response) => response.status(404).json({error: 'not found'}),\n\t)\n\n\t// Downloads a file, directory or multiple files\n\t// GET /api/files/download?path=/Home/file.txt&path=/Home/file-2.txt\n\tprivateApi.get('/download', async (request, response) => {\n\t\t// Normalise a single path or multiple paths into an array\n\t\tlet virtualPaths: string[] = []\n\t\tif (typeof request.query.path === 'string') virtualPaths = [String(request.query.path)]\n\t\tif (Array.isArray(request.query.path)) virtualPaths = request.query.path.map(String)\n\n\t\t// Check that at least one path is provided\n\t\tif (virtualPaths.length < 1) return response.status(400).json({error: 'bad request'})\n\n\t\t// Get file data\n\t\tconst files = await Promise.all(\n\t\t\tvirtualPaths.map(async (path) => {\n\t\t\t\ttry {\n\t\t\t\t\tconst systemPath = await umbreld.files.virtualToSystemPath(path)\n\t\t\t\t\tif (!(await fse.exists(systemPath))) throw new Error('not found')\n\t\t\t\t\treturn systemPath\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// This means a file doesn't exist (or can't be safely resolved) so we return a 404\n\t\t\t\t\tresponse.status(404).json({error: 'not found'})\n\t\t\t\t\tthrow error\n\t\t\t\t}\n\t\t\t}),\n\t\t)\n\n\t\t// If we only have a single file, serve it directly\n\t\tif (files.length === 1 && (await fse.stat(files[0])).isFile()) {\n\t\t\tconst filename = nodePath.basename(files[0])\n\t\t\tresponse.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`)\n\t\t\treturn response.sendFile(files[0])\n\t\t}\n\n\t\t// Create an archive and stream it to the response\n\t\ttry {\n\t\t\t// For directory or multiple files, create zip archive\n\t\t\tconst filename = umbreld.files.archive.zipName(files, {defaultName: 'umbrel-files.zip'})\n\t\t\tresponse.setHeader('Content-Type', 'application/zip')\n\t\t\tresponse.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`)\n\n\t\t\tconst zipStream = await umbreld.files.archive.createZipStream(files)\n\t\t\tawait pipeline(zipStream, response)\n\t\t} catch (error) {\n\t\t\tif ((error as Error).message === 'paths must be in same directory') {\n\t\t\t\treturn response.status(400).json({error: (error as Error).message})\n\t\t\t}\n\n\t\t\tthrow error\n\t\t}\n\t})\n\n\t// Views a file\n\t// GET /api/files/view?path=/Home/file.txt\n\tprivateApi.get('/view', async (request, response) => {\n\t\ttry {\n\t\t\tif (typeof request.query.path !== 'string') return response.status(400).json({error: 'path is required'})\n\t\t\tconst systemPath = await umbreld.files.virtualToSystemPath(request.query.path)\n\t\t\tconst status = await umbreld.files.status(systemPath)\n\t\t\tif (status.type === 'directory') return response.status(400).json({error: 'cannot view a directory'})\n\t\t\tresponse.sendFile(systemPath)\n\t\t} catch (error) {\n\t\t\treturn response.status(404).json({error: 'not found'})\n\t\t}\n\t})\n\n\t// Uploads a file\n\t// POST /api/files/upload?path=/Home/file.txt&collision=error|keep-both|replace\n\t// Note: We must set the `Connection: close` header on error to prevent the XHR upload logic\n\t// from uploading the entire file before checking for errors in the response. cURL handles this\n\t// without the extra header, I'm not sure why it's only needed in the browser.\n\tprivateApi.post('/upload', async (request, response) => {\n\t\t// Check we have a path\n\t\tif (typeof request.query.path !== 'string') {\n\t\t\tresponse.setHeader('Connection', 'close')\n\t\t\treturn response.status(400).json({error: 'path is required'})\n\t\t}\n\n\t\t// Get the collision strategy\n\t\tconst collision = typeof request.query.collision === 'string' ? request.query.collision : 'error'\n\t\tconst isValidCollisionParameter = ['error', 'keep-both', 'replace'].includes(collision)\n\t\tif (!isValidCollisionParameter) {\n\t\t\tresponse.setHeader('Connection', 'close')\n\t\t\treturn response.status(400).json({error: 'invalid collision parameter'})\n\t\t}\n\n\t\t// Check path is valid\n\t\tlet systemPath = await umbreld.files.virtualToSystemPath(request.query.path).catch((error) => {\n\t\t\tresponse.setHeader('Connection', 'close')\n\t\t\tresponse.status(400).json({error: 'invalid path'})\n\t\t\tthrow error\n\t\t})\n\n\t\t// Handle name conflicts\n\t\t// TODO: Implement resume support\n\t\tconst exists = await fse.pathExists(systemPath)\n\t\tif (exists) {\n\t\t\tif (collision === 'error') {\n\t\t\t\tresponse.setHeader('Connection', 'close')\n\t\t\t\treturn response.status(400).json({error: '[destination-already-exists]'})\n\t\t\t\t// For 'keep-both' we generate a unique name for the file\n\t\t\t} else if (collision === 'keep-both') systemPath = await umbreld.files.getUniqueName(systemPath)\n\t\t\t// For 'replace' we simply continue with the upload over the original file\n\t\t}\n\n\t\t// TODO: Check available disk space\n\t\t// We need the frontend to provide the total size of the file\n\n\t\t// Temporary file to store the uploaded data\n\t\t// We do this to avoid ending up with partially uploaded files of the correct name.\n\t\t// It's clear that a partially uploaded file with the .umbrel-upload suffix is not a\n\t\t// completed upload.\n\t\t// It also sets the groundwork for resuming uploads in the future.\n\t\t// It also means that fs change events during upload are fired for\n\t\t// .somefile.jpg.umbrel-upload not somefile.jpg so we don't trigger loads of\n\t\t// thumbnail generation attempts (matching the .jpg suffix) until the file is fully uploaded.\n\t\t// Using a dotfile also automatically hides these temporary files from most file listings\n\t\tconst fileName = nodePath.basename(systemPath)\n\t\tconst directory = nodePath.dirname(systemPath)\n\t\tconst temporarySystemPath = nodePath.join(directory, `.${fileName}.umbrel-upload`)\n\n\t\t// Ensure containing directories exist\n\t\tawait fse.ensureDir(nodePath.dirname(temporarySystemPath))\n\n\t\t// Write the file\n\t\tawait pipeline(request, fse.createWriteStream(temporarySystemPath)).catch(async (error) => {\n\t\t\t// Clean up the temporary file\n\t\t\tawait fse.remove(temporarySystemPath).catch(() => {})\n\n\t\t\t// Return an error\n\t\t\tresponse.setHeader('Connection', 'close')\n\t\t\tresponse.status(500).json({error: 'error writing file'})\n\t\t\tthrow error\n\t\t})\n\n\t\t// Rename the temporary file to the final path\n\t\tawait fse.rename(temporarySystemPath, systemPath)\n\n\t\t// Set owner to the umbrel user\n\t\t// We do nothing on fail because this isn't supported on all filesystems.\n\t\t// e.g this is expected to throw on external exFAT drives.\n\t\tawait umbreld.files.chownSystemPath(systemPath).catch(() => {})\n\n\t\t// Return success\n\t\treturn response.status(200).json({path: umbreld.files.systemToVirtualPath(systemPath)})\n\t})\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/api.upload.integration.test.ts",
    "content": "import {setTimeout as sleep} from 'node:timers/promises'\nimport nodePath from 'node:path'\nimport {Writable} from 'node:stream'\nimport {once} from 'node:events'\n\nimport {vi, expect, beforeAll, afterAll, test, beforeEach, afterEach} from 'vitest'\nimport fse from 'fs-extra'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\nafterAll(() => umbreld.cleanup())\nbeforeEach(() => fse.emptyDir(`${umbreld.instance.dataDirectory}/home`))\nafterEach(() => vi.restoreAllMocks())\n\ntest('POST /api/files/upload throws unauthorized error without cookie', async () => {\n\tconst error = await umbreld.unauthenticatedApi\n\t\t.post('files/upload?path=/Home/test-file.txt', {body: 'test content'})\n\t\t.catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(401)\n\texpect(error.response.body).toMatchObject({error: 'unauthorized'})\n})\n\ntest('POST /api/files/upload throws 400 error without path parameter', async () => {\n\tconst error = await umbreld.api.post('files/upload', {body: 'test content'}).catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(400)\n\texpect(error.response.body).toMatchObject({error: 'path is required'})\n})\n\ntest('POST /api/files/upload throws 400 error on directory traversal attempt', async () => {\n\tconst error = await umbreld.api\n\t\t.post('files/upload?path=/Home/../../../../etc/dangerous-file.txt', {body: 'malicious content'})\n\t\t.catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(400)\n\texpect(error.response.body).toMatchObject({error: 'invalid path'})\n})\n\ntest('POST /api/files/upload throws 400 error on relative path', async () => {\n\tconst paths = ['Home/file.txt', './Home/file.txt', '../home/file.txt', 'file.txt']\n\n\tfor (const path of paths) {\n\t\tconst error = await umbreld.api.post(`files/upload?path=${path}`, {body: 'test content'}).catch((error) => error)\n\t\texpect(error).toBeInstanceOf(Error)\n\t\texpect(error.response.statusCode).toBe(400)\n\t\texpect(error.response.body).toMatchObject({error: 'invalid path'})\n\t}\n})\n\ntest('POST /api/files/upload throws 400 error on symlink traversal attempt', async () => {\n\t// Create a symlink to the root directory\n\tawait fse.ensureSymlink('/', `${umbreld.instance.dataDirectory}/home/symlink-to-root`)\n\n\t// Attempt to upload a file through the symlink\n\tconst error = await umbreld.api\n\t\t.post('files/upload?path=/Home/symlink-to-root/etc/dangerous-file.txt', {body: 'malicious content'})\n\t\t.catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(400)\n\texpect(error.response.body).toMatchObject({error: 'invalid path'})\n\n\t// Clean up\n\tawait fse.remove(`${umbreld.instance.dataDirectory}/home/symlink-to-root`)\n})\n\ntest('POST /api/files/upload successfully uploads a file with valid cookie and returns success response', async () => {\n\t// Upload a file\n\tconst response = await umbreld.api.post('files/upload?path=/Home/new-file.txt', {\n\t\tbody: 'uploaded content',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.body).toEqual({path: '/Home/new-file.txt'})\n\n\t// Verify the file was created\n\tconst exists = await fse.pathExists(`${umbreld.instance.dataDirectory}/home/new-file.txt`)\n\texpect(exists).toBe(true)\n\n\t// Verify the content\n\tconst content = await fse.readFile(`${umbreld.instance.dataDirectory}/home/new-file.txt`, 'utf8')\n\texpect(content).toBe('uploaded content')\n})\n\ntest('POST /api/files/upload creates parent directories if they do not exist', async () => {\n\t// Upload a file to a path with non-existent directories\n\tconst response = await umbreld.api.post('files/upload?path=/Home/new-dir/sub-dir/new-file.txt', {\n\t\tbody: 'nested content',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\n\t// Verify the directories and file were created\n\tconst exists = await fse.pathExists(`${umbreld.instance.dataDirectory}/home/new-dir/sub-dir/new-file.txt`)\n\texpect(exists).toBe(true)\n\n\t// Verify the content\n\tconst content = await fse.readFile(`${umbreld.instance.dataDirectory}/home/new-dir/sub-dir/new-file.txt`, 'utf8')\n\texpect(content).toBe('nested content')\n})\n\ntest('POST /api/files/upload handles files with special characters in name', async () => {\n\t// File name with special characters\n\tconst fileName = 'file with spaces & special chars 漢字.txt'\n\n\t// Upload the file\n\tconst response = await umbreld.api.post(`files/upload?path=/Home/${encodeURIComponent(fileName)}`, {\n\t\tbody: 'special content',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\n\t// Verify the file was created\n\tconst exists = await fse.pathExists(`${umbreld.instance.dataDirectory}/home/${fileName}`)\n\texpect(exists).toBe(true)\n\n\t// Verify the content\n\tconst content = await fse.readFile(`${umbreld.instance.dataDirectory}/home/${fileName}`, 'utf8')\n\texpect(content).toBe('special content')\n})\n\ntest('POST /api/files/upload handles files with URL-encoded characters in path', async () => {\n\t// File name with characters that need URL encoding\n\tconst filename = 'file+with?query&params.txt'\n\n\t// Upload the file\n\tconst response = await umbreld.api.post(`files/upload?path=/Home/${encodeURIComponent(filename)}`, {\n\t\tbody: 'url encoded content',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\n\t// Verify the file was created\n\tconst exists = await fse.pathExists(`${umbreld.instance.dataDirectory}/home/${filename}`)\n\texpect(exists).toBe(true)\n\n\t// Verify the content\n\tconst content = await fse.readFile(`${umbreld.instance.dataDirectory}/home/${filename}`, 'utf8')\n\texpect(content).toBe('url encoded content')\n})\n\ntest('POST /api/files/upload handles empty files correctly', async () => {\n\t// Upload an empty file\n\tconst response = await umbreld.api.post('files/upload?path=/Home/empty-file.txt', {body: ''})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\n\t// Verify the file was created\n\tconst exists = await fse.pathExists(`${umbreld.instance.dataDirectory}/home/empty-file.txt`)\n\texpect(exists).toBe(true)\n\n\t// Verify the content is empty\n\tconst content = await fse.readFile(`${umbreld.instance.dataDirectory}/home/empty-file.txt`, 'utf8')\n\texpect(content).toBe('')\n})\n\ntest('POST /api/files/upload handles binary data correctly', async () => {\n\t// Binary data as base64\n\tconst binaryData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd, 0xfc]).toString('base64')\n\n\t// Upload binary file\n\tconst response = await umbreld.api.post('files/upload?path=/Home/binary-file.bin', {\n\t\tbody: binaryData,\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\n\t// Verify the file was created\n\tconst exists = await fse.pathExists(`${umbreld.instance.dataDirectory}/home/binary-file.bin`)\n\texpect(exists).toBe(true)\n\n\t// Verify the content\n\tconst content = await fse.readFile(`${umbreld.instance.dataDirectory}/home/binary-file.bin`)\n\texpect(content).toEqual(Buffer.from(binaryData))\n})\n\ntest('POST /api/files/upload creates file with correct permissions', async () => {\n\t// Upload a file\n\tconst response = await umbreld.api.post('files/upload?path=/Home/permissions-test.txt', {\n\t\tbody: 'permissions content',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\n\t// Check permissions\n\tconst stats = await fse.stat(`${umbreld.instance.dataDirectory}/home/permissions-test.txt`)\n\texpect(stats.uid).toBe(1000) // Check owner is umbrel user\n\texpect(stats.gid).toBe(1000) // Check group is umbrel group\n})\n\ntest('POST /api/files/upload handles write errors correctly', async () => {\n\t// Create a test file to verify it gets cleaned up\n\tconst systemPath = `${umbreld.instance.dataDirectory}/home/should-fail.txt`\n\tconst temporarySystemPath = `${systemPath}.umbrel-upload`\n\tawait fse.ensureDir(nodePath.dirname(systemPath))\n\n\t// Mock a writable stream that will immediately fail when written to\n\tvi.spyOn(fse, 'createWriteStream').mockImplementation((path) => {\n\t\tconst mockStream = new Writable({\n\t\t\twrite: (chunk, encoding, callback) => callback(new Error('Simulated disk full error')),\n\t\t}) as any\n\t\tmockStream.path = path\n\t\treturn mockStream\n\t})\n\n\t// Try to upload a file - this should trigger the simulated error\n\tconst error = await umbreld.api\n\t\t.post('files/upload?path=/Home/should-fail.txt', {body: 'This upload should fail due to a simulated disk error'})\n\t\t.catch((err) => err)\n\n\t// Verify the error response\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(500)\n\texpect(error.response.body).toEqual({error: 'error writing file'})\n\n\t// Verify the file was cleaned up (doesn't exist)\n\tawait expect(fse.pathExists(systemPath)).resolves.toBe(false)\n\tawait expect(fse.pathExists(temporarySystemPath)).resolves.toBe(false)\n})\n\ntest('POST /api/files/upload correctly handles streaming data in chunks', async () => {\n\t// Test file path\n\tconst filePath = '/Home/streaming-test.txt'\n\tconst systemPath = `${umbreld.instance.dataDirectory}/home/streaming-test.txt`\n\tconst directory = nodePath.dirname(systemPath)\n\tconst fileName = nodePath.basename(systemPath)\n\tconst temporarySystemPath = nodePath.join(directory, `.${fileName}.umbrel-upload`)\n\n\t// Get a stream for the request\n\tconst uploadStream = umbreld.api.stream.post(`files/upload?path=${filePath}`)\n\n\t// Check file doesn't yet exist\n\tawait expect(fse.pathExists(systemPath)).resolves.toBe(false)\n\tawait expect(fse.pathExists(temporarySystemPath)).resolves.toBe(false)\n\n\t// Chunks of data to pipe to the upload stream\n\tconst chunks = [\n\t\tBuffer.from('First chunk of data - '),\n\t\tBuffer.from('Second chunk of data - '),\n\t\tBuffer.from('Third chunk of data - '),\n\t]\n\n\tfor (const chunk of chunks) {\n\t\t// Write the chunk to the upload stream\n\t\tuploadStream.write(chunk)\n\n\t\t// Wait to let the chunk be processed\n\t\tawait sleep(100)\n\n\t\t// Check only temporary file exists\n\t\tawait expect(fse.pathExists(temporarySystemPath)).resolves.toBe(true)\n\t\tawait expect(fse.pathExists(systemPath)).resolves.toBe(false)\n\n\t\t// Check the temporary file contains the current chunk\n\t\tawait expect(fse.readFile(temporarySystemPath, 'utf8')).resolves.toContain(chunk.toString())\n\t}\n\n\t// End the stream\n\tuploadStream.end()\n\n\t// Check response is ok\n\tconst [response] = await once(uploadStream, 'response')\n\texpect(response.statusCode).toBe(200)\n\n\t// Check if the file was moved to the final path\n\tawait expect(fse.pathExists(temporarySystemPath)).resolves.toBe(false)\n\tawait expect(fse.pathExists(systemPath)).resolves.toBe(true)\n\n\t// Check the content of the final file\n\tawait expect(fse.readFile(systemPath, 'utf8')).resolves.toBe(chunks.join(''))\n})\n\ntest('POST /api/files/upload cleans up temporary files when client aborts partially uploaded file', async () => {\n\t// Test file path\n\tconst filePath = '/Home/aborted-upload.txt'\n\tconst systemPath = `${umbreld.instance.dataDirectory}/home/aborted-upload.txt`\n\tconst directory = nodePath.dirname(systemPath)\n\tconst fileName = nodePath.basename(systemPath)\n\tconst temporarySystemPath = nodePath.join(directory, `.${fileName}.umbrel-upload`)\n\n\t// Get a stream for the request\n\tconst uploadStream = umbreld.api.stream.post(`files/upload?path=${filePath}`)\n\n\t// Check files don't exist yet\n\tawait expect(fse.pathExists(systemPath)).resolves.toBe(false)\n\tawait expect(fse.pathExists(temporarySystemPath)).resolves.toBe(false)\n\n\t// Write the chunk to the upload stream\n\tuploadStream.write(Buffer.from('First chunk'))\n\n\t// Wait to verify the temporary file was created\n\tawait sleep(100)\n\n\t// Verify temporary file exists but not the final file\n\tawait expect(fse.pathExists(temporarySystemPath)).resolves.toBe(true)\n\tawait expect(fse.pathExists(systemPath)).resolves.toBe(false)\n\n\t// Now abort the request\n\tuploadStream.destroy()\n\n\t// Wait for backend to handle the abortion\n\tawait sleep(100)\n\n\t// Verify temporary file is left partiall uploaded\n\tawait expect(fse.pathExists(temporarySystemPath)).resolves.toBe(false)\n\tawait expect(fse.pathExists(systemPath)).resolves.toBe(false)\n})\n\ntest('POST /api/files/upload with collision=error (default) throws 400 when file already exists', async () => {\n\t// Create a file\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/collision-test.txt`, 'original content')\n\n\t// Try to upload to the same path\n\tconst error = await umbreld.api\n\t\t.post('files/upload?path=/Home/collision-test.txt', {body: 'new content'})\n\t\t.catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(400)\n\texpect(error.response.body).toMatchObject({error: '[destination-already-exists]'})\n\n\t// Verify the file wasn't changed\n\tconst content = await fse.readFile(`${umbreld.instance.dataDirectory}/home/collision-test.txt`, 'utf8')\n\texpect(content).toBe('original content')\n})\n\ntest('POST /api/files/upload with collision=error throws 400 when explicitly set and file already exists', async () => {\n\t// Create a file\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/explicit-error-test.txt`, 'original content')\n\n\t// Try to upload to the same path with explicit error strategy\n\tconst error = await umbreld.api\n\t\t.post('files/upload?path=/Home/explicit-error-test.txt&collision=error', {body: 'new content'})\n\t\t.catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(400)\n\texpect(error.response.body).toMatchObject({error: '[destination-already-exists]'})\n\n\t// Verify the file wasn't changed\n\tconst content = await fse.readFile(`${umbreld.instance.dataDirectory}/home/explicit-error-test.txt`, 'utf8')\n\texpect(content).toBe('original content')\n})\n\ntest('POST /api/files/upload with collision=keep-both creates uniquely named file when file already exists', async () => {\n\t// Create a file\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/keep-both-test.txt`, 'original content')\n\n\t// Upload to the same path with keep-both strategy\n\tconst response = await umbreld.api.post('files/upload?path=/Home/keep-both-test.txt&collision=keep-both', {\n\t\tbody: 'new content',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.body).toEqual({path: '/Home/keep-both-test (2).txt'})\n\n\t// Verify both files exist with correct content\n\tconst originalContent = await fse.readFile(`${umbreld.instance.dataDirectory}/home/keep-both-test.txt`, 'utf8')\n\texpect(originalContent).toBe('original content')\n\n\tconst newContent = await fse.readFile(`${umbreld.instance.dataDirectory}/home/keep-both-test (2).txt`, 'utf8')\n\texpect(newContent).toBe('new content')\n})\n\ntest('POST /api/files/upload with collision=keep-both increments number for multiple collisions', async () => {\n\t// Create a file and its first duplicate\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/multiple-test.txt`, 'original content')\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/multiple-test (2).txt`, 'first duplicate')\n\n\t// Upload to the same path with keep-both strategy\n\tconst response = await umbreld.api.post('files/upload?path=/Home/multiple-test.txt&collision=keep-both', {\n\t\tbody: 'second duplicate',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.body).toEqual({path: '/Home/multiple-test (3).txt'})\n\n\t// Verify all files exist with correct content\n\tconst originalContent = await fse.readFile(`${umbreld.instance.dataDirectory}/home/multiple-test.txt`, 'utf8')\n\texpect(originalContent).toBe('original content')\n\n\tconst firstDuplicate = await fse.readFile(`${umbreld.instance.dataDirectory}/home/multiple-test (2).txt`, 'utf8')\n\texpect(firstDuplicate).toBe('first duplicate')\n\n\tconst secondDuplicate = await fse.readFile(`${umbreld.instance.dataDirectory}/home/multiple-test (3).txt`, 'utf8')\n\texpect(secondDuplicate).toBe('second duplicate')\n})\n\ntest('POST /api/files/upload with collision=replace overwrites existing file', async () => {\n\t// Create a file\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/replace-test.txt`, 'original content')\n\n\t// Upload to the same path with replace strategy\n\tconst response = await umbreld.api.post('files/upload?path=/Home/replace-test.txt&collision=replace', {\n\t\tbody: 'replacement content',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.body).toEqual({path: '/Home/replace-test.txt'})\n\n\t// Verify file exists with new content\n\tconst content = await fse.readFile(`${umbreld.instance.dataDirectory}/home/replace-test.txt`, 'utf8')\n\texpect(content).toBe('replacement content')\n})\n\ntest('POST /api/files/upload with invalid collision parameter returns 400 error', async () => {\n\tconst error = await umbreld.api\n\t\t.post('files/upload?path=/Home/invalid-collision-test.txt&collision=invalid', {body: 'test content'})\n\t\t.catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(400)\n\texpect(error.response.body).toMatchObject({error: 'invalid collision parameter'})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/api.view.integration.test.ts",
    "content": "import {expect, beforeAll, afterAll, test, beforeEach} from 'vitest'\nimport fse from 'fs-extra'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\nbeforeEach(async () => {\n\t// Clean up any files from previous tests\n\tawait fse.emptyDir(`${umbreld.instance.dataDirectory}/home`)\n})\n\ntest('GET /api/files/view throws unauthorized error without cookie', async () => {\n\tconst error = await umbreld.unauthenticatedApi.get('files/view').catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(401)\n\texpect(error.response.body).toMatchObject({error: 'unauthorized'})\n})\n\ntest('GET /api/files/view throws 404 error without path parameter', async () => {\n\tconst error = await umbreld.api.get('files/view').catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(400)\n\texpect(error.response.body).toMatchObject({error: 'path is required'})\n})\n\ntest('GET /api/files/view throws 404 error when file does not exist', async () => {\n\tconst error = await umbreld.api.get('files/view?path=/Home/does-not-exist').catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(404)\n\texpect(error.response.body).toMatchObject({error: 'not found'})\n})\n\ntest('GET /api/files/view throws 404 error on directory traversal attempt', async () => {\n\tconst error = await umbreld.api.get('files/view?path=/Home/../../../../etc/passwd').catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(404)\n\texpect(error.response.body).toMatchObject({error: 'not found'})\n})\n\ntest('GET /api/files/view throws 404 error on relative path', async () => {\n\tconst paths = ['Home/file.txt', './Home/file.txt', '../home/file.txt', 'file.txt']\n\n\tfor (const path of paths) {\n\t\tconst error = await umbreld.api.get(`files/view?path=${path}`).catch((error) => error)\n\t\texpect(error).toBeInstanceOf(Error)\n\t\texpect(error.response.statusCode).toBe(404)\n\t\texpect(error.response.body).toMatchObject({error: 'not found'})\n\t}\n})\n\ntest('GET /api/files/view throws 404 error on symlink traversal attempt', async () => {\n\t// Create a symlink to the root directory\n\tawait fse.ensureSymlink('/', `${umbreld.instance.dataDirectory}/home/symlink-to-root`)\n\n\t// Attempt to access files through the symlink\n\tconst error = await umbreld.api.get('files/view?path=/Home/symlink-to-root/etc/passwd').catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(404)\n\texpect(error.response.body).toMatchObject({error: 'not found'})\n\n\t// Clean up\n\tawait fse.remove(`${umbreld.instance.dataDirectory}/home/symlink-to-root`)\n})\n\ntest('GET /api/files/view throws 404 error when trying to view a directory', async () => {\n\t// Create a directory\n\tawait fse.ensureDir(`${umbreld.instance.dataDirectory}/home/test-dir`)\n\n\t// Try to view the directory\n\tconst error = await umbreld.api.get('files/view?path=/Home/test-dir').catch((error) => error)\n\texpect(error).toBeInstanceOf(Error)\n\texpect(error.response.statusCode).toBe(400)\n\texpect(error.response.body).toMatchObject({error: 'cannot view a directory'})\n})\n\ntest('GET /api/files/view serves a file with a valid cookie', async () => {\n\t// Create a file\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/file.txt`, 'contents')\n\n\t// View the file\n\tconst response = await umbreld.api.get('files/view?path=/Home/file.txt', {\n\t\tresponseType: 'text',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.body).toBe('contents')\n\t// View doesn't set Content-Disposition header as it's for viewing, not downloading\n})\n\ntest('GET /api/files/view handles files with special characters in name', async () => {\n\t// Create a file with special characters in the name\n\tconst fileName = 'file with spaces & special chars 漢字.txt'\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/${fileName}`, 'special contents')\n\n\t// View the file\n\tconst response = await umbreld.api.get(`files/view?path=/Home/${encodeURIComponent(fileName)}`, {\n\t\tresponseType: 'text',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.body).toBe('special contents')\n})\n\ntest('GET /api/files/view handles files with URL-encoded characters in path', async () => {\n\t// Create a file with characters that need URL encoding\n\tconst filename = 'file+with?query&params.txt'\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/${filename}`, 'url encoded content')\n\n\t// View the file using URL encoded path\n\tconst response = await umbreld.api.get(`files/view?path=/Home/${encodeURIComponent(filename)}`, {\n\t\tresponseType: 'text',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.body).toBe('url encoded content')\n})\n\ntest('GET /api/files/view handles files with zero content correctly', async () => {\n\t// Create an empty file\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/empty-file.txt`, '')\n\n\t// View the file\n\tconst response = await umbreld.api.get('files/view?path=/Home/empty-file.txt', {\n\t\tresponseType: 'text',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(response.body).toBe('')\n})\n\ntest('GET /api/files/view handles binary files correctly', async () => {\n\t// Create a small binary file\n\tconst binaryData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd, 0xfc])\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/binary-file.bin`, binaryData)\n\n\t// View the file\n\tconst response = await umbreld.api.get('files/view?path=/Home/binary-file.bin', {\n\t\tresponseType: 'buffer',\n\t})\n\n\t// Assert the response is correct\n\texpect(response.statusCode).toBe(200)\n\texpect(Buffer.from(response.body)).toEqual(binaryData)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/archive.integration.test.ts",
    "content": "import {expect, test, beforeEach, beforeAll, afterAll, describe} from 'vitest'\nimport fse from 'fs-extra'\nimport nodePath from 'node:path'\nimport AdmZip from 'adm-zip'\nimport {$} from 'execa'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\n// Create a new umbreld instance for tests\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\nbeforeEach(async () => {\n\t// Clean up any files from previous tests\n\tawait fse.emptyDir(`${umbreld.instance.dataDirectory}/home`)\n})\n\n// Helper function to extract files from a zip buffer\nfunction extractZipBuffer(buffer: Buffer): Record<string, string> {\n\t// Get zip entries\n\tconst zip = new AdmZip(buffer)\n\tconst zipEntries = zip.getEntries()\n\n\t// Create a map of file names to their contents\n\tconst files: Record<string, string> = {}\n\tfor (const entry of zipEntries) {\n\t\tif (!entry.isDirectory) {\n\t\t\tfiles[entry.entryName] = entry.getData().toString('utf8')\n\t\t}\n\t}\n\n\treturn files\n}\n\ndescribe('archive()', () => {\n\ttest('throws unauthorized error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.archive.mutate({paths: ['/Home/test.txt']})).rejects.toThrow(\n\t\t\t'Invalid token',\n\t\t)\n\t})\n\n\ttest('throws error on directory traversal attempt', async () => {\n\t\tawait expect(\n\t\t\tumbreld.client.files.archive.mutate({\n\t\t\t\tpaths: ['/Home/../../../../etc/passwd'],\n\t\t\t}),\n\t\t).rejects.toThrow()\n\t})\n\n\ttest('throws error on non-existent path', async () => {\n\t\tawait expect(\n\t\t\tumbreld.client.files.archive.mutate({\n\t\t\t\tpaths: ['/Home/nonexistent-file.txt'],\n\t\t\t}),\n\t\t).rejects.toThrow()\n\t})\n\n\ttest('throws error on relative paths', async () => {\n\t\tawait Promise.all(\n\t\t\t['', ' ', '.', '..', 'Home', 'Home/file.txt'].map(async (path) => {\n\t\t\t\tawait expect(\n\t\t\t\t\tumbreld.client.files.archive.mutate({\n\t\t\t\t\t\tpaths: [path],\n\t\t\t\t\t}),\n\t\t\t\t).rejects.toThrow()\n\t\t\t}),\n\t\t)\n\t})\n\n\ttest('throws error when paths are in different directories', async () => {\n\t\t// Create test directories and files\n\t\tawait fse.ensureDir(`${umbreld.instance.dataDirectory}/home/dir1`)\n\t\tawait fse.ensureDir(`${umbreld.instance.dataDirectory}/home/dir2`)\n\t\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/dir1/file1.txt`, 'content1')\n\t\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/dir2/file2.txt`, 'content2')\n\n\t\t// Try to archive files from different directories\n\t\tawait expect(\n\t\t\tumbreld.client.files.archive.mutate({\n\t\t\t\tpaths: ['/Home/dir1/file1.txt', '/Home/dir2/file2.txt'],\n\t\t\t}),\n\t\t).rejects.toThrow('paths must be in same directory')\n\t})\n\n\ttest('successfully creates a zip archive from a single file', async () => {\n\t\t// Create a test file\n\t\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/test-file.txt`, 'test content')\n\n\t\t// Archive the file\n\t\tconst zipPath = await umbreld.client.files.archive.mutate({\n\t\t\tpaths: ['/Home/test-file.txt'],\n\t\t})\n\n\t\t// Check archive file exists\n\t\texpect(zipPath).toBe('/Home/test-file.txt.zip')\n\t\tconst zipFile = `${umbreld.instance.dataDirectory}/home/test-file.txt.zip`\n\t\tawait expect(fse.pathExists(zipFile)).resolves.toBe(true)\n\n\t\t// Check archive contents\n\t\tconst zipBuffer = await fse.readFile(zipFile)\n\t\tconst files = extractZipBuffer(zipBuffer)\n\t\texpect(files['test-file.txt']).toBe('test content')\n\t})\n\n\ttest('successfully creates a zip archive from multiple files', async () => {\n\t\t// Create test files\n\t\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/file1.txt`, 'content1')\n\t\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/file2.txt`, 'content2')\n\n\t\t// Archive the files\n\t\tconst zipPath = await umbreld.client.files.archive.mutate({\n\t\t\tpaths: ['/Home/file1.txt', '/Home/file2.txt'],\n\t\t})\n\n\t\t// Check archive file exists\n\t\texpect(zipPath).toBe('/Home/Archive.zip')\n\t\tconst zipFile = `${umbreld.instance.dataDirectory}/home/Archive.zip`\n\t\tawait expect(fse.pathExists(zipFile)).resolves.toBe(true)\n\n\t\t// Check archive contents\n\t\tconst zipBuffer = await fse.readFile(zipFile)\n\t\tconst files = extractZipBuffer(zipBuffer)\n\t\texpect(files['file1.txt']).toBe('content1')\n\t\texpect(files['file2.txt']).toBe('content2')\n\t})\n\n\ttest('successfully creates a zip archive from a directory', async () => {\n\t\t// Create a test directory with files\n\t\tawait fse.ensureDir(`${umbreld.instance.dataDirectory}/home/test-dir`)\n\t\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/test-dir/file1.txt`, 'content1')\n\t\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/test-dir/file2.txt`, 'content2')\n\t\tawait fse.ensureDir(`${umbreld.instance.dataDirectory}/home/test-dir/subdir`)\n\t\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/test-dir/subdir/file3.txt`, 'content3')\n\n\t\t// Archive the directory\n\t\tconst zipPath = await umbreld.client.files.archive.mutate({\n\t\t\tpaths: ['/Home/test-dir'],\n\t\t})\n\n\t\t// Check archive file exists\n\t\texpect(zipPath).toBe('/Home/test-dir.zip')\n\t\tconst zipFile = `${umbreld.instance.dataDirectory}/home/test-dir.zip`\n\t\tawait expect(fse.pathExists(zipFile)).resolves.toBe(true)\n\n\t\t// Check archive contents\n\t\tconst zipBuffer = await fse.readFile(zipFile)\n\t\tconst files = extractZipBuffer(zipBuffer)\n\t\texpect(files['test-dir/file1.txt']).toBe('content1')\n\t\texpect(files['test-dir/file2.txt']).toBe('content2')\n\t\texpect(files['test-dir/subdir/file3.txt']).toBe('content3')\n\t})\n\n\ttest('creates a uniquely named zip archive when a file with the same name already exists', async () => {\n\t\t// Create a test file\n\t\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/unique-test.txt`, 'test content')\n\n\t\t// Create a zip file that would conflict with the generated name\n\t\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/unique-test.txt.zip`, 'existing zip')\n\n\t\t// Archive the file\n\t\tconst zipPath = await umbreld.client.files.archive.mutate({\n\t\t\tpaths: ['/Home/unique-test.txt'],\n\t\t})\n\n\t\t// Expect a unique name (with (2) appended)\n\t\texpect(zipPath).toBe('/Home/unique-test.txt (2).zip')\n\t\tconst zipFile = `${umbreld.instance.dataDirectory}/home/unique-test.txt (2).zip`\n\t\tawait expect(fse.pathExists(zipFile)).resolves.toBe(true)\n\n\t\t// Check archive contents\n\t\tconst zipBuffer = await fse.readFile(zipFile)\n\t\tconst files = extractZipBuffer(zipBuffer)\n\t\texpect(files['unique-test.txt']).toBe('test content')\n\n\t\t// Original zip file should remain untouched\n\t\tconst originalZipContent = await fse.readFile(`${umbreld.instance.dataDirectory}/home/unique-test.txt.zip`, 'utf8')\n\t\texpect(originalZipContent).toBe('existing zip')\n\t})\n\n\ttest('handles files with special characters in name', async () => {\n\t\t// Create a file with special characters\n\t\tconst fileName = 'special & chars 漢字.txt'\n\t\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/${fileName}`, 'special content')\n\n\t\t// Archive the file\n\t\tconst zipPath = await umbreld.client.files.archive.mutate({\n\t\t\tpaths: [`/Home/${fileName}`],\n\t\t})\n\n\t\t// Check archive file exists\n\t\texpect(zipPath).toBe(`/Home/${fileName}.zip`)\n\t\tconst zipFile = `${umbreld.instance.dataDirectory}/home/${fileName}.zip`\n\t\tawait expect(fse.pathExists(zipFile)).resolves.toBe(true)\n\n\t\t// Check archive contents\n\t\tconst zipBuffer = await fse.readFile(zipFile)\n\t\tconst files = extractZipBuffer(zipBuffer)\n\t\texpect(files[fileName]).toBe('special content')\n\t})\n\n\ttest('handles empty directories correctly', async () => {\n\t\t// Create an empty directory\n\t\tawait fse.ensureDir(`${umbreld.instance.dataDirectory}/home/empty-dir`)\n\n\t\t// Archive the directory\n\t\tconst zipPath = await umbreld.client.files.archive.mutate({\n\t\t\tpaths: ['/Home/empty-dir'],\n\t\t})\n\n\t\t// Check archive file exists\n\t\texpect(zipPath).toBe('/Home/empty-dir.zip')\n\t\tconst zipFile = `${umbreld.instance.dataDirectory}/home/empty-dir.zip`\n\t\tawait expect(fse.pathExists(zipFile)).resolves.toBe(true)\n\n\t\t// An empty directory creates an empty zip with no entries\n\t\tconst zipBuffer = await fse.readFile(zipFile)\n\t\tconst zip = new AdmZip(zipBuffer)\n\t\tconst entries = zip.getEntries()\n\t\texpect(entries.length).toBe(0)\n\t})\n})\n\ndescribe('unarchive()', () => {\n\ttest('throws unauthorized error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.unarchive.mutate({path: '/Home/test.zip'})).rejects.toThrow(\n\t\t\t'Invalid token',\n\t\t)\n\t})\n\n\ttest('throws error on directory traversal attempt', async () => {\n\t\tawait expect(\n\t\t\tumbreld.client.files.unarchive.mutate({\n\t\t\t\tpath: '/Home/../../../../etc/passwd.zip',\n\t\t\t}),\n\t\t).rejects.toThrow()\n\t})\n\n\ttest('throws error on non-existent path', async () => {\n\t\tawait expect(\n\t\t\tumbreld.client.files.unarchive.mutate({\n\t\t\t\tpath: '/Home/nonexistent-file.zip',\n\t\t\t}),\n\t\t).rejects.toThrow()\n\t})\n\n\ttest('throws error on relative paths', async () => {\n\t\tawait Promise.all(\n\t\t\t['', ' ', '.', '..', 'Home', 'Home/file.zip'].map(async (path) => {\n\t\t\t\tawait expect(\n\t\t\t\t\tumbreld.client.files.unarchive.mutate({\n\t\t\t\t\t\tpath,\n\t\t\t\t\t}),\n\t\t\t\t).rejects.toThrow()\n\t\t\t}),\n\t\t)\n\t})\n\n\ttest('throws error on unsupported file format', async () => {\n\t\t// Create a file with unsupported extension\n\t\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/test.txt`, 'This is not an archive')\n\n\t\t// Try to extract it\n\t\tawait expect(\n\t\t\tumbreld.client.files.unarchive.mutate({\n\t\t\t\tpath: '/Home/test.txt',\n\t\t\t}),\n\t\t).rejects.toThrow('[operation-not-allowed]')\n\t})\n\n\t// The following tests require unar to be installed\n\ttest('extracts a zip archive correctly', async () => {\n\t\t// Create a test zip file with actual zip format\n\t\tconst zipFile = `${umbreld.instance.dataDirectory}/home/test-extract.zip`\n\t\tconst zip = new AdmZip()\n\t\tzip.addFile('file1.txt', Buffer.from('content1', 'utf8'))\n\t\tzip.addFile('file2.txt', Buffer.from('content2', 'utf8'))\n\t\tzip.addFile('subdir/file3.txt', Buffer.from('content3', 'utf8'))\n\t\tawait fse.writeFile(zipFile, zip.toBuffer())\n\n\t\t// Confirm the file exists\n\t\tawait expect(fse.pathExists(zipFile)).resolves.toBe(true)\n\n\t\t// Extract the archive\n\t\tconst extractPath = await umbreld.client.files.unarchive.mutate({\n\t\t\tpath: '/Home/test-extract.zip',\n\t\t})\n\n\t\t// Verify extracted folder path\n\t\texpect(extractPath).toBe('/Home/test-extract')\n\t\tconst extractDir = `${umbreld.instance.dataDirectory}/home/test-extract`\n\n\t\t// Verify extracted contents\n\t\tawait expect(fse.readFile(`${extractDir}/file1.txt`, 'utf8')).resolves.toBe('content1')\n\t\tawait expect(fse.readFile(`${extractDir}/file2.txt`, 'utf8')).resolves.toBe('content2')\n\t\tawait expect(fse.readFile(`${extractDir}/subdir/file3.txt`, 'utf8')).resolves.toBe('content3')\n\t})\n\n\ttest('creates a unique directory name if target already exists', async () => {\n\t\t// Create a directory that would conflict with the extraction path\n\t\tawait fse.ensureDir(`${umbreld.instance.dataDirectory}/home/conflict-test`)\n\t\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/conflict-test/existing-file.txt`, 'existing content')\n\n\t\t// Create a test zip file\n\t\tconst zipFile = `${umbreld.instance.dataDirectory}/home/conflict-test.zip`\n\t\tconst zip = new AdmZip()\n\t\tzip.addFile('new-file.txt', Buffer.from('new content', 'utf8'))\n\t\tawait fse.writeFile(zipFile, zip.toBuffer())\n\n\t\t// Extract the archive\n\t\tconst extractPath = await umbreld.client.files.unarchive.mutate({\n\t\t\tpath: '/Home/conflict-test.zip',\n\t\t})\n\n\t\t// Verify extracted folder gets a unique name\n\t\texpect(extractPath).toBe('/Home/conflict-test (2)')\n\n\t\t// Verify both directories exist with correct content\n\t\tawait expect(\n\t\t\tfse.readFile(`${umbreld.instance.dataDirectory}/home/conflict-test/existing-file.txt`, 'utf8'),\n\t\t).resolves.toBe('existing content')\n\t\tawait expect(\n\t\t\tfse.readFile(`${umbreld.instance.dataDirectory}/home/conflict-test (2)/new-file.txt`, 'utf8'),\n\t\t).resolves.toBe('new content')\n\t})\n\n\ttest('handles files with special characters in name', async () => {\n\t\t// Create a zip file with special characters in name\n\t\tconst fileName = 'special & chars 漢字.zip'\n\t\tconst zipFile = `${umbreld.instance.dataDirectory}/home/${fileName}`\n\n\t\tconst zip = new AdmZip()\n\t\tzip.addFile('test.txt', Buffer.from('special content', 'utf8'))\n\t\tawait fse.writeFile(zipFile, zip.toBuffer())\n\n\t\t// Extract the archive\n\t\tconst extractPath = await umbreld.client.files.unarchive.mutate({\n\t\t\tpath: `/Home/${fileName}`,\n\t\t})\n\n\t\t// Verify extracted folder path (base name without extension)\n\t\texpect(extractPath).toBe('/Home/special & chars 漢字')\n\n\t\t// Verify extracted content\n\t\tconst extractedFile = `${umbreld.instance.dataDirectory}/home/special & chars 漢字/test.txt`\n\t\tawait expect(fse.readFile(extractedFile, 'utf8')).resolves.toBe('special content')\n\t})\n\n\t// Test each archive type\n\tconst archiveTypes = [\n\t\t{extension: '.tar', command: 'tar --create --file'},\n\t\t{extension: '.tar.gz', command: 'tar --create --gzip --file'},\n\t\t{extension: '.tgz', command: 'tar --create --gzip --file'},\n\t\t{extension: '.tar.bz2', command: 'tar --create --bzip2 --file'},\n\t\t{extension: '.tar.xz', command: 'tar --create --xz --file'},\n\t\t{\n\t\t\textension: '.zip',\n\t\t\tcommand: 'zip -r',\n\t\t\tarchive:\n\t\t\t\t'UEsDBAoAAAAAABKRdFo3fMmGFAAAABQAAAANABwAdGVzdC1maWxlLnR4dFVUCQADo1ncZ6NZ3Gd1eAsAAQQAAAAABAAAAABhcmNoaXZlIHRlc3QgY29udGVudFBLAQIeAwoAAAAAABKRdFo3fMmGFAAAABQAAAANABgAAAAAAAEAAACkgQAAAAB0ZXN0LWZpbGUudHh0VVQFAAOjWdxndXgLAAEEAAAAAAQAAAAAUEsFBgAAAAABAAEAUwAAAFsAAAAAAA==',\n\t\t},\n\t\t{\n\t\t\textension: '.7z',\n\t\t\tcommand: '7z a',\n\t\t\tarchive:\n\t\t\t\t'N3q8ryccAARJrGAoGAAAAAAAAABiAAAAAAAAAOWGm00BABNhcmNoaXZlIHRlc3QgY29udGVudAABBAYAAQkYAAcLAQABISEBAAwUAAgKATd8yYYAAAUBGQwAAAAAAAAAAAAAAAARHQB0AGUAcwB0AC0AZgBpAGwAZQAuAHQAeAB0AAAAFAoBAABqURnDmdsBFQYBACCApIEAAA==',\n\t\t},\n\t\t{\n\t\t\textension: '.rar',\n\t\t\tcommand: 'rar a',\n\t\t\tarchive:\n\t\t\t\t'UmFyIRoHAQAzkrXlCgEFBgAFAQGAgAB/pngvIwIClAAGlACkgwIpWdxnN3zJhoAAAQ10ZXN0LWZpbGUudHh0YXJjaGl2ZSB0ZXN0IGNvbnRlbnQdd1ZRAwUEAA==',\n\t\t},\n\t]\n\tfor (const type of archiveTypes) {\n\t\ttest(`extracts a ${type.extension} archive correctly`, async () => {\n\t\t\tconst sourceDir = `${umbreld.instance.dataDirectory}/home/archive-test`\n\t\t\tconst archiveFile = `${umbreld.instance.dataDirectory}/home/archive-test${type.extension}`\n\n\t\t\tif (type.archive) {\n\t\t\t\t// We include pre-created base64 archives for types we don't have tooling installed for\n\t\t\t\tawait fse.writeFile(archiveFile, Buffer.from(type.archive, 'base64'))\n\t\t\t} else {\n\t\t\t\t// For other types, we create the archive on the fly\n\n\t\t\t\t// Create a test directory and file\n\t\t\t\tawait fse.ensureDir(sourceDir)\n\t\t\t\tawait fse.writeFile(`${sourceDir}/test-file.txt`, 'archive test content')\n\n\t\t\t\t// Create the archive\n\t\t\t\tawait $({cwd: sourceDir})`${type.command.split(' ')} ${archiveFile} .`\n\n\t\t\t\t// Delete the original files\n\t\t\t\tawait fse.remove(sourceDir)\n\t\t\t}\n\n\t\t\t// Verify the archive was created\n\t\t\tawait expect(fse.pathExists(archiveFile)).resolves.toBe(true)\n\n\t\t\t// Verify the original files do not exist\n\t\t\tawait expect(fse.pathExists(sourceDir)).resolves.toBe(false)\n\n\t\t\t// Extract the archive through the API\n\t\t\tconst extractPath = await umbreld.client.files.unarchive.mutate({\n\t\t\t\tpath: `/Home/archive-test${type.extension}`,\n\t\t\t})\n\n\t\t\t// Verify extracted folder path\n\t\t\t// Common expected path is the archive name without extension\n\t\t\texpect(extractPath).toBe(`/Home/archive-test`)\n\n\t\t\t// Verify the extracted file contains the expected content\n\t\t\tawait expect(fse.pathExists(sourceDir)).resolves.toBe(true)\n\t\t\tawait expect(fse.readdir(sourceDir)).resolves.toStrictEqual(['test-file.txt'])\n\t\t\tawait expect(fse.readFile(`${sourceDir}/test-file.txt`, 'utf8')).resolves.toBe('archive test content')\n\t\t})\n\t}\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/archive.ts",
    "content": "import archiver from 'archiver'\nimport compressible from 'compressible'\nimport fse from 'fs-extra'\nimport mime from 'mime-types'\nimport nodePath from 'node:path'\nimport {pipeline} from 'node:stream/promises'\n\nimport {$} from 'execa'\n\nimport type Umbreld from '../../index.js'\n\ntype ZipEntryData = archiver.EntryData & {store?: boolean}\n\nexport default class Archive {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(`files:${name.toLocaleLowerCase()}`)\n\t}\n\n\t// No background tasks\n\tasync start() {}\n\tasync stop() {}\n\n\t// Get the name for a zip archive based on it's contents\n\tzipName(files: string[], {defaultName = 'Archive.zip'} = {}) {\n\t\tif (files.length === 1) return `${nodePath.basename(files[0])}.zip`\n\t\treturn defaultName\n\t}\n\n\t// Decide whether to skip ZIP deflate compression for a file.\n\t// We compress only when the MIME type is known to be compressible.\n\t// For unknown MIME types, we default to storing without compression\n\t// to avoid wasted CPU and degraded download performance for binary/media files.\n\t#shouldSkipCompression(filePath: string): boolean {\n\t\tconst mimeType = mime.lookup(filePath)\n\t\tif (typeof mimeType !== 'string') return true\n\t\treturn compressible(mimeType) !== true\n\t}\n\n\t// Returns a readable stream of a zip archive from a list of system paths\n\tasync createZipStream(systemPaths: string[]) {\n\t\t// Check that all paths are in the same directory\n\t\t// This is to avoid collisions in the zip archive\n\t\t// e.g:\n\t\t// /foo/file.txt\n\t\t// /bar/file.txt\n\t\t// would result in a zip archive with two files called file.txt\n\t\tconst directories = systemPaths.map((systemPath) => nodePath.dirname(systemPath))\n\t\tconst uniqueDirectories = new Set(directories)\n\t\tif (uniqueDirectories.size > 1) throw new Error('paths must be in same directory')\n\n\t\tconst archive = archiver('zip')\n\t\tfor (const systemPath of systemPaths) {\n\t\t\tconst status = await fse.stat(systemPath)\n\n\t\t\tif (status.isDirectory()) {\n\t\t\t\t// For directories, we use a callback to set compression options per file\n\t\t\t\tarchive.directory(systemPath, nodePath.basename(systemPath), (entry) => {\n\t\t\t\t\tconst zipEntry = entry as ZipEntryData\n\t\t\t\t\tif (zipEntry.stats?.isFile() && this.#shouldSkipCompression(zipEntry.name)) zipEntry.store = true\n\t\t\t\t\treturn zipEntry\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\t// For files, we set compression options directly\n\t\t\t\tconst options: ZipEntryData = {name: nodePath.basename(systemPath)}\n\t\t\t\tif (this.#shouldSkipCompression(systemPath)) options.store = true\n\t\t\t\tarchive.file(systemPath, options)\n\t\t\t}\n\t\t}\n\n\t\t// We convert any finalize rejection to a stream error so callers can handle it.\n\t\tarchive.finalize().catch((error) => archive.emit('error', error))\n\t\treturn archive\n\t}\n\n\t// Creates a zip archive\n\t// TODO: There's probably a race condition where creating the same archive twice at the same time\n\t// will cause the second to overwrite the first. Think of a better way to handle this.\n\tasync createZipFile(virtualPaths: string[]) {\n\t\t// Convert virtual paths to system paths\n\t\tconst systemPaths = await Promise.all(\n\t\t\tvirtualPaths.map((virtualPath) => this.#umbreld.files.virtualToSystemPath(virtualPath)),\n\t\t)\n\n\t\t// Calculate the zip path\n\t\tlet zipPath = nodePath.join(nodePath.dirname(systemPaths[0]), this.zipName(systemPaths))\n\t\tzipPath = await this.#umbreld.files.getUniqueName(zipPath)\n\n\t\t// Create a zip stream\n\t\t// TODO: Add progress reporting\n\t\tconst zipStream = await this.createZipStream(systemPaths)\n\t\tconst writeStream = fse.createWriteStream(zipPath)\n\t\tawait pipeline(zipStream, writeStream)\n\n\t\t// Return virtual path of the zip archive\n\t\treturn this.#umbreld.files.systemToVirtualPath(zipPath)\n\t}\n\n\t// Creates an archive (alias for createZipFile)\n\tasync archive(virtualPaths: string[]) {\n\t\treturn this.createZipFile(virtualPaths)\n\t}\n\n\t// Check if the archive format is supported\n\tisUnarchiveable(path: string) {\n\t\tconst supportedArchiveFormats = ['.tar.gz', '.tgz', '.tar.bz2', '.tar.xz', '.tar', '.zip', '.7z', '.rar'] as const\n\t\treturn supportedArchiveFormats.some((format) => path.endsWith(format))\n\t}\n\n\t// Unarchives an archive\n\tasync unarchive(virtualPath: string) {\n\t\t// Check if operation is allowed\n\t\tconst allowedOperations = await this.#umbreld.files.getAllowedOperations(virtualPath)\n\t\tif (!allowedOperations.includes('unarchive')) throw new Error('[operation-not-allowed]')\n\n\t\t// Get system path\n\t\tconst systemPath = await this.#umbreld.files.virtualToSystemPath(virtualPath)\n\n\t\t// Calculate target directory\n\t\tconst {name} = this.#umbreld.files.splitExtension(systemPath)\n\t\tlet targetDirectory = nodePath.join(nodePath.dirname(systemPath), name)\n\t\ttargetDirectory = await this.#umbreld.files.getUniqueName(targetDirectory)\n\n\t\t// Unarchive\n\t\t// TODO: Add progress reporting\n\t\tawait $`unar -force-overwrite -no-directory -output-directory ${targetDirectory} ${systemPath}`\n\n\t\t// Return virtual path of the unarchived files\n\t\treturn this.#umbreld.files.systemToVirtualPath(targetDirectory)\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/external-storage.integration.test.ts",
    "content": "import {expect, test, describe, beforeEach, afterEach, vi} from 'vitest'\nimport fse from 'fs-extra'\nimport {delay} from 'es-toolkit'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\n// These tests are not great, they're heavily coupled to the implementation and rely on\n// heavy mocking to simulate external devices in our test environment. In the future if\n// we have some kind of QEMU wrapper to test against real umbrelOS in a VM we should\n// rewrite these tests to not use mocking and test against virtual USB block devices\n// being attached/removed from the VM.\n\n// Mock isUmbrelHome for testing\nlet isRaspberryPiMockValue = false\nafterEach(() => (isRaspberryPiMockValue = false))\nvi.mock('../system/system.js', async () => {\n\tconst original = (await vi.importActual('../system/system.js')) as any\n\treturn {\n\t\t...original,\n\t\tisRaspberryPi: vi.fn(() => isRaspberryPiMockValue),\n\t}\n})\n\n// Mock fse.writeFile for testing\nconst nullWriteFile = (path: string, data: string) => {}\nlet mockWriteFile = nullWriteFile\nafterEach(() => (mockWriteFile = nullWriteFile))\nvi.mock('fs-extra', async () => {\n\tconst originalModule = (await vi.importActual('fs-extra')) as any\n\treturn {\n\t\t...originalModule,\n\t\tdefault: {\n\t\t\t...originalModule.default,\n\t\t\twriteFile: vi.fn((path: string, data: any) => {\n\t\t\t\t// Test if we have a mock that wants to override this command\n\t\t\t\tconst mockResult = mockWriteFile(path, data) as any\n\t\t\t\tif (mockResult) return Promise.resolve(typeof mockResult === 'string' ? {stdout: mockResult} : mockResult)\n\n\t\t\t\t// Otherwise fall back to default execa behaviour\n\t\t\t\treturn originalModule.writeFile(path, data)\n\t\t\t}),\n\t\t},\n\t}\n})\n\n// Generic command mocker we can overwrite for each test\nconst nullMock = (command: string) => {}\nlet mockCommand = nullMock\nafterEach(() => (mockCommand = nullMock))\nvi.mock('execa', async () => {\n\tconst originalModule = (await vi.importActual('execa')) as any\n\treturn {\n\t\t...originalModule,\n\t\t// Mock $ from execa\n\t\t$: vi.fn(function () {\n\t\t\t// Grab all arguments and pull out the command\n\t\t\tconst args = Array.from(arguments)\n\t\t\tconst command = args[0]?.[0] ?? ''\n\n\t\t\t// Test if we have a mock that wants to override this command\n\t\t\tconst mockResult = mockCommand(command) as any\n\t\t\tif (mockResult) return Promise.resolve(typeof mockResult === 'string' ? {stdout: mockResult} : mockResult)\n\n\t\t\t// Otherwise fall back to default execa behaviour\n\t\t\treturn originalModule.$.apply(originalModule, args)\n\t\t}),\n\t}\n})\n\n// Setup umbreld once for all tests\nbeforeEach(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n// Cleanup after all tests\nafterEach(() => umbreld.cleanup())\n\ndescribe('enabled', () => {\n\ttest('is enabled on Umbrel Home and amd64', async () => {\n\t\tawait expect(umbreld.instance.files.externalStorage.supported()).resolves.toBe(true)\n\t})\n\n\ttest('is disabled on Raspberry Pi', async () => {\n\t\tisRaspberryPiMockValue = true\n\t\tawait expect(umbreld.instance.files.externalStorage.supported()).resolves.toBe(false)\n\t})\n})\n\ndescribe('file permissions', () => {\n\ttest('allows hard deletion of external files', async () => {\n\t\t// Create test directory\n\t\tconst testFile = `${umbreld.instance.dataDirectory}/external/My Portable SSD/file.txt`\n\t\tawait fse.ensureFile(testFile)\n\n\t\t// Attempt to delete the directory\n\t\tawait expect(\n\t\t\tumbreld.client.files.delete.mutate({path: '/External/My Portable SSD/file.txt'}),\n\t\t).resolves.not.toThrow()\n\t})\n\n\ttest('does not allow soft trash of external files', async () => {\n\t\t// Create test directory\n\t\tconst testFile = `${umbreld.instance.dataDirectory}/external/My Portable SSD/file.txt`\n\t\tawait fse.ensureFile(testFile)\n\n\t\t// Attempt to delete the directory\n\t\tawait expect(umbreld.client.files.trash.mutate({path: '/External/My Portable SSD/file.txt'})).rejects.toThrow(\n\t\t\t'[operation-not-allowed]',\n\t\t)\n\t})\n\n\ttest('mount points are protected paths', async () => {\n\t\t// Create test directory\n\t\tconst testFile = `${umbreld.instance.dataDirectory}/external/My Portable SSD/file.txt`\n\t\tawait fse.ensureFile(testFile)\n\n\t\t// Trash\n\t\tawait expect(umbreld.client.files.trash.mutate({path: '/External/My Portable SSD'})).rejects.toThrow(\n\t\t\t'[operation-not-allowed]',\n\t\t)\n\t\t// Delete\n\t\tawait expect(umbreld.client.files.delete.mutate({path: '/External/My Portable SSD'})).rejects.toThrow(\n\t\t\t'[operation-not-allowed]',\n\t\t)\n\t\t// Move\n\t\tawait expect(\n\t\t\tumbreld.client.files.move.mutate({path: '/External/My Portable SSD', toDirectory: '/Home'}),\n\t\t).rejects.toThrow('[operation-not-allowed]')\n\t\t// Rename\n\t\tawait expect(\n\t\t\tumbreld.client.files.rename.mutate({path: '/External/My Portable SSD', newName: 'My Renamed SSD'}),\n\t\t).rejects.toThrow('[operation-not-allowed]')\n\t})\n})\n\ndescribe('files.mountedExternalDevices', () => {\n\ttest('throws unauthorized error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.externalDevices.query()).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('returns empty array when no external devices are attached', async () => {\n\t\t// Mock lsblk command to return a valid response for no external disks\n\t\tmockCommand = (command: string) => {\n\t\t\tif (command.startsWith('lsblk')) return JSON.stringify(LSBLK_NO_EXTERNAL_DISK)\n\t\t}\n\n\t\tconst externalDevices = await umbreld.client.files.externalDevices.query()\n\t\tawait expect(externalDevices).toEqual([])\n\t})\n\n\ttest('returns external devices that are attached but not mounted', async () => {\n\t\t// Mock lsblk command to return a valid response for a mounted external disk\n\t\tmockCommand = (command: string) => {\n\t\t\tif (command.startsWith('lsblk')) return JSON.stringify(LSBLK_EXTERNAL_DISK_ATTACHED)\n\t\t}\n\n\t\tconst externalDevices = await umbreld.client.files.externalDevices.query()\n\t\tawait expect(externalDevices).toEqual([\n\t\t\t{\n\t\t\t\tid: 'sda',\n\t\t\t\tname: 'Samsung Portable SSD T5',\n\t\t\t\ttransport: 'usb',\n\t\t\t\tsize: 1000204886016,\n\t\t\t\tisFormatting: false,\n\t\t\t\tisMounted: false,\n\t\t\t\tpartitions: [\n\t\t\t\t\t{\n\t\t\t\t\t\tid: 'sda1',\n\t\t\t\t\t\tlabel: 'EFI',\n\t\t\t\t\t\tmountpoints: [],\n\t\t\t\t\t\tsize: 209715200,\n\t\t\t\t\t\ttype: 'EFI System',\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tid: 'sda2',\n\t\t\t\t\t\tlabel: 'Red T5',\n\t\t\t\t\t\tmountpoints: [],\n\t\t\t\t\t\tsize: 999993376768,\n\t\t\t\t\t\ttype: 'Microsoft basic data',\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t},\n\t\t])\n\t})\n\n\ttest('returns mounted external devices', async () => {\n\t\t// Mock lsblk command to return a valid response for a mounted external disk\n\t\tmockCommand = (command: string) => {\n\t\t\t// We need to replace any /home/umbrel/umbrel paths with the current instances\n\t\t\t// data directory so the internal logic finds mountpath matches in `/External`\n\t\t\tif (command.startsWith('lsblk'))\n\t\t\t\treturn JSON.stringify(LSBLK_EXTERNAL_DISK_MOUNTED).replaceAll(\n\t\t\t\t\t'/home/umbrel/umbrel',\n\t\t\t\t\tumbreld.instance.dataDirectory,\n\t\t\t\t)\n\t\t}\n\n\t\tconst externalDevices = await umbreld.client.files.externalDevices.query()\n\t\tawait expect(externalDevices).toEqual([\n\t\t\t{\n\t\t\t\tid: 'sda',\n\t\t\t\tname: 'Samsung Portable SSD T5',\n\t\t\t\ttransport: 'usb',\n\t\t\t\tsize: 1000204886016,\n\t\t\t\tisFormatting: false,\n\t\t\t\tisMounted: true,\n\t\t\t\tpartitions: [\n\t\t\t\t\t{\n\t\t\t\t\t\tid: 'sda1',\n\t\t\t\t\t\tlabel: 'EFI',\n\t\t\t\t\t\tmountpoints: [],\n\t\t\t\t\t\tsize: 209715200,\n\t\t\t\t\t\ttype: 'EFI System',\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tid: 'sda2',\n\t\t\t\t\t\tlabel: 'Red T5',\n\t\t\t\t\t\ttype: 'Microsoft basic data',\n\t\t\t\t\t\tsize: 999993376768,\n\t\t\t\t\t\tmountpoints: ['/External/Red T5'],\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t},\n\t\t])\n\n\t\t// We need to set the mock back to not show mounted devices so the shutdown process doesn't try to unmount them and fail\n\t\tmockCommand = (command: string) => {\n\t\t\tif (command.startsWith('lsblk')) return JSON.stringify(LSBLK_NO_EXTERNAL_DISK)\n\t\t}\n\t})\n})\n\ndescribe('files.unmountExternalDevice', () => {\n\ttest('throws unauthorized error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.unmountExternalDevice.mutate({deviceId: 'sda'})).rejects.toThrow(\n\t\t\t'Invalid token',\n\t\t)\n\t})\n\n\ttest('throws error on invalid device id', async () => {\n\t\t// Mock lsblk command to return a valid response for a mounted external disk\n\t\tmockCommand = (command: string) => {\n\t\t\tif (command.startsWith('lsblk'))\n\t\t\t\treturn JSON.stringify(LSBLK_EXTERNAL_DISK_MOUNTED).replaceAll(\n\t\t\t\t\t'/home/umbrel/umbrel',\n\t\t\t\t\tumbreld.instance.dataDirectory,\n\t\t\t\t)\n\t\t}\n\n\t\t// Missing device\n\t\tawait expect(umbreld.client.files.unmountExternalDevice.mutate({deviceId: 'sdz'})).rejects.toThrow(\n\t\t\t'[invalid-device-id]',\n\t\t)\n\n\t\t// Passed partition id instead of device id\n\t\tawait expect(umbreld.client.files.unmountExternalDevice.mutate({deviceId: 'sda2'})).rejects.toThrow(\n\t\t\t'[invalid-device-id]',\n\t\t)\n\t})\n\n\ttest('unmounts external device', async () => {\n\t\t// Mock lsblk command to return a valid response for no external disks\n\t\t// Mock umount to not actually unmount (this breaks GitHub Actions)\n\t\tmockCommand = (command: string) => {\n\t\t\tif (command.startsWith('lsblk'))\n\t\t\t\treturn JSON.stringify(LSBLK_EXTERNAL_DISK_MOUNTED).replaceAll(\n\t\t\t\t\t'/home/umbrel/umbrel',\n\t\t\t\t\tumbreld.instance.dataDirectory,\n\t\t\t\t)\n\t\t\tif (command.startsWith('umount')) return 'fake umount worked'\n\t\t}\n\n\t\t// Mock fse.writeFile to not actually delete the device\n\t\tmockWriteFile = (path: string, data: string) => {\n\t\t\tif (path === '/sys/block/sda/device/delete') return '1'\n\t\t}\n\n\t\t// Create mountpoint\n\t\tconst mountPoint = `${umbreld.instance.dataDirectory}/external/Red T5`\n\t\tawait fse.ensureDir(mountPoint)\n\n\t\t// Check mountpoint exists\n\t\tawait expect(fse.pathExists(mountPoint)).resolves.toBe(true)\n\n\t\t// Unmount it\n\t\t// This fails at the last point in the test env because /sys/block/sda/device/delete doesn't actually exist\n\t\t// However all the other logic should have been run through successfully\n\t\tawait expect(umbreld.client.files.unmountExternalDevice.mutate({deviceId: 'sda'})).resolves.toBe(true)\n\n\t\t// Check mountpoint was removed\n\t\tawait expect(fse.pathExists(mountPoint)).resolves.toBe(false)\n\t})\n})\n\ndescribe('files.isExternalDeviceConnectedOnUnsupportedDevice', () => {\n\ttest('throws unauthorized error without auth token', async () => {\n\t\tawait expect(\n\t\t\tumbreld.unauthenticatedClient.files.isExternalDeviceConnectedOnUnsupportedDevice.query(),\n\t\t).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('returns false when on Umbrel Home and amd64 but no external devices connected', async () => {\n\t\t// Mock lsblk to show an external USB device\n\t\tmockCommand = (command: string) => {\n\t\t\tif (command.startsWith('lsblk')) return JSON.stringify(LSBLK_NO_EXTERNAL_DISK)\n\t\t\tif (command.startsWith('df')) return {stdout: 'Filesystem\\n/dev/nvme0n1p4'}\n\t\t}\n\n\t\t// Check the result\n\t\tconst result = await umbreld.client.files.isExternalDeviceConnectedOnUnsupportedDevice.query()\n\t\texpect(result).toBe(false)\n\t})\n\n\ttest('returns false when on Umbrel Home and amd64 and external devices connected', async () => {\n\t\t// Mock lsblk to show an external USB device\n\t\tmockCommand = (command: string) => {\n\t\t\tif (command.startsWith('lsblk')) return JSON.stringify(LSBLK_EXTERNAL_DISK_ATTACHED)\n\t\t\tif (command.startsWith('df')) return {stdout: 'Filesystem\\n/dev/nvme0n1p4'}\n\t\t}\n\n\t\t// Check the result\n\t\tconst result = await umbreld.client.files.isExternalDeviceConnectedOnUnsupportedDevice.query()\n\t\texpect(result).toBe(false)\n\t})\n\n\ttest('returns false when on Raspberry Pi but no external devices are connected', async () => {\n\t\t// Set isRaspberryPi to return false\n\t\tisRaspberryPiMockValue = true\n\n\t\t// Mock lsblk to show no external USB devices\n\t\tmockCommand = (command: string) => {\n\t\t\tif (command.startsWith('lsblk')) return JSON.stringify(LSBLK_NO_EXTERNAL_DISK)\n\t\t\tif (command.startsWith('df')) return {stdout: 'Filesystem\\n/dev/nvme0n1p4'}\n\t\t}\n\n\t\t// Check the result\n\t\tconst result = await umbreld.client.files.isExternalDeviceConnectedOnUnsupportedDevice.query()\n\t\texpect(result).toBe(false)\n\t})\n\n\ttest('returns true when on Raspberry Pi and external devices connected', async () => {\n\t\t// Set isRaspberryPi to return false\n\t\tisRaspberryPiMockValue = true\n\n\t\t// Mock lsblk to show external USB devices and df to show system disk is not USB\n\t\tmockCommand = (command: string) => {\n\t\t\tif (command.startsWith('lsblk')) return JSON.stringify(LSBLK_EXTERNAL_DISK_ATTACHED)\n\t\t\tif (command.startsWith('df')) return {stdout: 'Filesystem\\n/dev/nvme0n1p4'}\n\t\t}\n\n\t\t// Check the result\n\t\tconst result = await umbreld.client.files.isExternalDeviceConnectedOnUnsupportedDevice.query()\n\t\texpect(result).toBe(true)\n\t})\n\n\ttest('excludes external devices that contain the data directory (to avoid USB Pi false positives)', async () => {\n\t\t// Set isRaspberryPi to return false\n\t\tisRaspberryPiMockValue = true\n\n\t\t// Mock lsblk to show external USB devices but df to show system disk is USB\n\t\tmockCommand = (command: string) => {\n\t\t\tif (command.startsWith('lsblk')) return JSON.stringify(LSBLK_EXTERNAL_DISK_ATTACHED)\n\t\t\tif (command.startsWith('df')) return {stdout: 'Filesystem\\n/dev/sda2'}\n\t\t}\n\n\t\t// Check the result - should be false as the system disk is on the USB device\n\t\tconst result = await umbreld.client.files.isExternalDeviceConnectedOnUnsupportedDevice.query()\n\t\texpect(result).toBe(false)\n\t})\n})\n\ndescribe('externalstorage.#cleanLeftOverMountPoints()', () => {\n\ttest('cleans up any left over mount points on startup', async () => {\n\t\t// Stop umbreld\n\t\tawait umbreld.instance.stop()\n\n\t\t// Create dummy left over mount point\n\t\tconst leftoverMountPoint = `${umbreld.instance.dataDirectory}/external/My Portable SSD`\n\t\tawait fse.ensureDir(leftoverMountPoint)\n\n\t\t// Check that the mount point exists\n\t\tawait expect(fse.exists(leftoverMountPoint)).resolves.toBe(true)\n\n\t\t// Start umbreld\n\t\tawait umbreld.instance.start()\n\n\t\t// Check that the mount point was cleaned up\n\t\tawait expect(fse.exists(leftoverMountPoint)).resolves.toBe(false)\n\t})\n})\n\ndescribe('externalstorage.#mountExternalDevices', () => {\n\ttest('does nothing when no external devices are attached', async () => {\n\t\tmockCommand = (command: string) => {\n\t\t\t// Mock lsblk command to return a valid response for no external disks\n\t\t\tif (command.startsWith('lsblk')) return JSON.stringify(LSBLK_NO_EXTERNAL_DISK)\n\t\t\t// Mock mountpoint command to return nonzero exit code so unused mount paths don't get cleaned up\n\t\t\tif (command.startsWith('mountpoint')) return {exitCode: 1}\n\t\t}\n\n\t\t// Simulate disk event\n\t\tawait umbreld.instance.eventBus.emit('system:disk:change')\n\n\t\t// Wait for event to be handled\n\t\tawait delay(500)\n\n\t\t// Check that the mount point was not created\n\t\tconst mountPoint = `${umbreld.instance.dataDirectory}/external/Red T5`\n\t\tconst exists = await fse.pathExists(mountPoint)\n\t\texpect(exists).toBe(false)\n\t})\n\n\t// Skip this for now since it breaks with the new cleanup logic which will remove the directory before we can test it exists\n\t// We should re-enable this if we get proper vm testing working or can reliably detect the fs creation.\n\ttest.skip('mounts a new external disk when it is attached', async () => {\n\t\tmockCommand = (command: string) => {\n\t\t\t// Mock lsblk command to return a valid response for no external disks\n\t\t\tif (command.startsWith('lsblk')) return JSON.stringify(LSBLK_EXTERNAL_DISK_ATTACHED)\n\t\t\t// Mock mountpoint command to return nonzero exit code so unused mount paths don't get cleaned up\n\t\t\tif (command.startsWith('mountpoint')) return {exitCode: 1}\n\t\t}\n\n\t\t// Simulate disk event\n\t\tawait umbreld.instance.eventBus.emit('system:disk:change')\n\n\t\t// Wait for event to be handled\n\t\tawait delay(500)\n\n\t\t// Check that the mount point was not created\n\t\tconst mountPoint = `${umbreld.instance.dataDirectory}/external/Red T5`\n\t\tconst exists = await fse.pathExists(mountPoint)\n\t\texpect(exists).toBe(true)\n\t})\n})\n\ndescribe('filesystem permissions', () => {\n\ttest('/External cannot have new user created directories', async () => {\n\t\tawait expect(umbreld.client.files.createDirectory.mutate({path: '/External/test'})).rejects.toThrow(\n\t\t\t'[operation-not-allowed]',\n\t\t)\n\t})\n\n\ttest('/External cannot have directories copied directly into it', async () => {\n\t\tawait expect(umbreld.client.files.copy.mutate({path: '/Home/Documents', toDirectory: '/External'})).rejects.toThrow(\n\t\t\t'[operation-not-allowed]',\n\t\t)\n\t})\n\n\ttest('/External cannot have directories moved directly into it', async () => {\n\t\tawait expect(umbreld.client.files.move.mutate({path: '/Home/Documents', toDirectory: '/External'})).rejects.toThrow(\n\t\t\t'[operation-not-allowed]',\n\t\t)\n\t})\n})\n\n// Mock lsblk output constants\n// Taken from a physical Umbrel Home with:\n//   lsblk --output-all --json --bytes\n\nconst LSBLK_NO_EXTERNAL_DISK = {\n\tblockdevices: [\n\t\t{\n\t\t\talignment: 0,\n\t\t\t'disc-aln': 0,\n\t\t\tdax: false,\n\t\t\t'disc-gran': 512,\n\t\t\t'disc-max': 2199023255040,\n\t\t\t'disc-zero': false,\n\t\t\tfsavail: null,\n\t\t\tfsroots: [null],\n\t\t\tfssize: null,\n\t\t\tfstype: null,\n\t\t\tfsused: null,\n\t\t\t'fsuse%': null,\n\t\t\tfsver: null,\n\t\t\tgroup: 'disk',\n\t\t\thctl: null,\n\t\t\thotplug: false,\n\t\t\tkname: 'nvme0n1',\n\t\t\tlabel: null,\n\t\t\t'log-sec': 512,\n\t\t\t'maj:min': '259:0',\n\t\t\t'min-io': 512,\n\t\t\tmode: 'brw-rw----',\n\t\t\tmodel: 'PCIe SSD',\n\t\t\tname: 'nvme0n1',\n\t\t\t'opt-io': 0,\n\t\t\towner: 'root',\n\t\t\tpartflags: null,\n\t\t\tpartlabel: null,\n\t\t\tparttype: null,\n\t\t\tparttypename: null,\n\t\t\tpartuuid: null,\n\t\t\tpath: '/dev/nvme0n1',\n\t\t\t'phy-sec': 512,\n\t\t\tpkname: null,\n\t\t\tpttype: 'gpt',\n\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\tra: 128,\n\t\t\trand: false,\n\t\t\trev: null,\n\t\t\trm: false,\n\t\t\tro: false,\n\t\t\trota: false,\n\t\t\t'rq-size': 1023,\n\t\t\tsched: 'none',\n\t\t\tserial: '3EE50743172100007264',\n\t\t\tsize: 2048408248320,\n\t\t\tstart: null,\n\t\t\tstate: 'live',\n\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\tmountpoint: null,\n\t\t\tmountpoints: [null],\n\t\t\ttran: 'nvme',\n\t\t\ttype: 'disk',\n\t\t\tuuid: null,\n\t\t\tvendor: null,\n\t\t\twsame: 0,\n\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\tzoned: 'none',\n\t\t\t'zone-sz': 0,\n\t\t\t'zone-wgran': 0,\n\t\t\t'zone-app': 0,\n\t\t\t'zone-nr': 0,\n\t\t\t'zone-omax': 0,\n\t\t\t'zone-amax': 0,\n\t\t\tchildren: [\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 512,\n\t\t\t\t\t'disc-max': 2199023255040,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: 161460224,\n\t\t\t\t\tfsroots: ['/', '/'],\n\t\t\t\t\tfssize: 209489920,\n\t\t\t\t\tfstype: 'vfat',\n\t\t\t\t\tfsused: 48029696,\n\t\t\t\t\t'fsuse%': '23%',\n\t\t\t\t\tfsver: 'FAT16',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: false,\n\t\t\t\t\tkname: 'nvme0n1p1',\n\t\t\t\t\tlabel: 'ESP',\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '259:1',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'nvme0n1p1',\n\t\t\t\t\t'opt-io': 0,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'ESP',\n\t\t\t\t\tparttype: 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b',\n\t\t\t\t\tparttypename: 'EFI System',\n\t\t\t\t\tpartuuid: '14a31e9d-a8d7-4da0-9eb2-f268dd9d7ad9',\n\t\t\t\t\tpath: '/dev/nvme0n1p1',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'nvme0n1',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\t\t\tra: 128,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 1023,\n\t\t\t\t\tsched: 'none',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 209715200,\n\t\t\t\t\tstart: 16384,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\t\t\tmountpoint: '/mnt/root/boot/efi',\n\t\t\t\t\tmountpoints: ['/mnt/root/boot/efi', '/boot/efi'],\n\t\t\t\t\ttran: 'nvme',\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: '8C09-5015',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 512,\n\t\t\t\t\t'disc-max': 2199023255040,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: 6095024128,\n\t\t\t\t\tfsroots: ['/', '/'],\n\t\t\t\t\tfssize: 10297585664,\n\t\t\t\t\tfstype: 'ext4',\n\t\t\t\t\tfsused: 3711803392,\n\t\t\t\t\t'fsuse%': '36%',\n\t\t\t\t\tfsver: '1.0',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: false,\n\t\t\t\t\tkname: 'nvme0n1p2',\n\t\t\t\t\tlabel: null,\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '259:2',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'nvme0n1p2',\n\t\t\t\t\t'opt-io': 0,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'primary',\n\t\t\t\t\tparttype: '0fc63daf-8483-4772-8e79-3d69d8477de4',\n\t\t\t\t\tparttypename: 'Linux filesystem',\n\t\t\t\t\tpartuuid: '2fe5a278-9b55-4266-8220-6665aa96940b',\n\t\t\t\t\tpath: '/dev/nvme0n1p2',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'nvme0n1',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\t\t\tra: 128,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 1023,\n\t\t\t\t\tsched: 'none',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 10552868864,\n\t\t\t\t\tstart: 425984,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\t\t\tmountpoint: '/mnt/root',\n\t\t\t\t\tmountpoints: ['/mnt/root', '/'],\n\t\t\t\t\ttran: 'nvme',\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: 'f30a5ab4-4925-4ef2-919e-baa907acc271',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 512,\n\t\t\t\t\t'disc-max': 2199023255040,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: null,\n\t\t\t\t\tfsroots: [null],\n\t\t\t\t\tfssize: null,\n\t\t\t\t\tfstype: 'ext4',\n\t\t\t\t\tfsused: null,\n\t\t\t\t\t'fsuse%': null,\n\t\t\t\t\tfsver: '1.0',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: false,\n\t\t\t\t\tkname: 'nvme0n1p3',\n\t\t\t\t\tlabel: null,\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '259:3',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'nvme0n1p3',\n\t\t\t\t\t'opt-io': 0,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'primary',\n\t\t\t\t\tparttype: '0fc63daf-8483-4772-8e79-3d69d8477de4',\n\t\t\t\t\tparttypename: 'Linux filesystem',\n\t\t\t\t\tpartuuid: 'f5e6d27c-4a25-447b-8e08-a9d2e738345a',\n\t\t\t\t\tpath: '/dev/nvme0n1p3',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'nvme0n1',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\t\t\tra: 128,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 1023,\n\t\t\t\t\tsched: 'none',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 10552868864,\n\t\t\t\t\tstart: 21037056,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\t\t\tmountpoint: null,\n\t\t\t\t\tmountpoints: [null],\n\t\t\t\t\ttran: 'nvme',\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: 'a45fe9d9-9fb4-44f1-aaee-95b55beff174',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 512,\n\t\t\t\t\t'disc-max': 2199023255040,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: 1860365219840,\n\t\t\t\t\tfsroots: [\n\t\t\t\t\t\t'/umbrel-os/var/log',\n\t\t\t\t\t\t'/umbrel-os/var/log',\n\t\t\t\t\t\t'/umbrel-os/var/lib/systemd/timesync',\n\t\t\t\t\t\t'/umbrel-os/var/lib/systemd/timesync',\n\t\t\t\t\t\t'/umbrel-os/var/lib/docker',\n\t\t\t\t\t\t'/umbrel-os/var/lib/docker',\n\t\t\t\t\t\t'/umbrel-os/home',\n\t\t\t\t\t\t'/umbrel-os/home',\n\t\t\t\t\t\t'/',\n\t\t\t\t\t\t'/',\n\t\t\t\t\t],\n\t\t\t\t\tfssize: 1963188352000,\n\t\t\t\t\tfstype: 'ext4',\n\t\t\t\t\tfsused: 40008793088,\n\t\t\t\t\t'fsuse%': '2%',\n\t\t\t\t\tfsver: '1.0',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: false,\n\t\t\t\t\tkname: 'nvme0n1p4',\n\t\t\t\t\tlabel: null,\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '259:4',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'nvme0n1p4',\n\t\t\t\t\t'opt-io': 0,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'primary',\n\t\t\t\t\tparttype: '0fc63daf-8483-4772-8e79-3d69d8477de4',\n\t\t\t\t\tparttypename: 'Linux filesystem',\n\t\t\t\t\tpartuuid: 'd1d36e34-2753-4dc7-96eb-3c9b5584e867',\n\t\t\t\t\tpath: '/dev/nvme0n1p4',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'nvme0n1',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\t\t\tra: 128,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 1023,\n\t\t\t\t\tsched: 'none',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 2027084386304,\n\t\t\t\t\tstart: 41648128,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\t\t\tmountpoint: '/mnt/root/data',\n\t\t\t\t\tmountpoints: [\n\t\t\t\t\t\t'/mnt/root/var/log',\n\t\t\t\t\t\t'/var/log',\n\t\t\t\t\t\t'/mnt/root/var/lib/systemd/timesync',\n\t\t\t\t\t\t'/var/lib/systemd/timesync',\n\t\t\t\t\t\t'/mnt/root/var/lib/docker',\n\t\t\t\t\t\t'/var/lib/docker',\n\t\t\t\t\t\t'/mnt/root/home',\n\t\t\t\t\t\t'/home',\n\t\t\t\t\t\t'/mnt/root/data',\n\t\t\t\t\t\t'/data',\n\t\t\t\t\t],\n\t\t\t\t\ttran: 'nvme',\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: '5e29c50c-6b77-48af-bd31-ed97b2c36ea4',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t],\n}\n\nconst LSBLK_EXTERNAL_DISK_ATTACHED = {\n\tblockdevices: [\n\t\t{\n\t\t\talignment: 0,\n\t\t\t'disc-aln': 0,\n\t\t\tdax: false,\n\t\t\t'disc-gran': 0,\n\t\t\t'disc-max': 0,\n\t\t\t'disc-zero': false,\n\t\t\tfsavail: null,\n\t\t\tfsroots: [null],\n\t\t\tfssize: null,\n\t\t\tfstype: null,\n\t\t\tfsused: null,\n\t\t\t'fsuse%': null,\n\t\t\tfsver: null,\n\t\t\tgroup: 'disk',\n\t\t\thctl: '2:0:0:0',\n\t\t\thotplug: true,\n\t\t\tkname: 'sda',\n\t\t\tlabel: null,\n\t\t\t'log-sec': 512,\n\t\t\t'maj:min': '8:0',\n\t\t\t'min-io': 512,\n\t\t\tmode: 'brw-rw----',\n\t\t\tmodel: 'Samsung Portable SSD T5',\n\t\t\tname: 'sda',\n\t\t\t'opt-io': 33553920,\n\t\t\towner: 'root',\n\t\t\tpartflags: null,\n\t\t\tpartlabel: null,\n\t\t\tparttype: null,\n\t\t\tparttypename: null,\n\t\t\tpartuuid: null,\n\t\t\tpath: '/dev/sda',\n\t\t\t'phy-sec': 512,\n\t\t\tpkname: null,\n\t\t\tpttype: 'gpt',\n\t\t\tptuuid: 'fcca3f2a-e019-4f4d-8385-2b660c1d7eec',\n\t\t\tra: 65532,\n\t\t\trand: false,\n\t\t\trev: '0   ',\n\t\t\trm: false,\n\t\t\tro: false,\n\t\t\trota: false,\n\t\t\t'rq-size': 60,\n\t\t\tsched: 'mq-deadline',\n\t\t\tserial: 'S50ZNV0MB00594H',\n\t\t\tsize: 1000204886016,\n\t\t\tstart: null,\n\t\t\tstate: 'running',\n\t\t\tsubsystems: 'block:scsi:usb:pci',\n\t\t\tmountpoint: null,\n\t\t\tmountpoints: [null],\n\t\t\ttran: 'usb',\n\t\t\ttype: 'disk',\n\t\t\tuuid: null,\n\t\t\tvendor: 'Samsung ',\n\t\t\twsame: 0,\n\t\t\twwn: '0x5002538e00000000',\n\t\t\tzoned: 'none',\n\t\t\t'zone-sz': 0,\n\t\t\t'zone-wgran': 0,\n\t\t\t'zone-app': 0,\n\t\t\t'zone-nr': 0,\n\t\t\t'zone-omax': 0,\n\t\t\t'zone-amax': 0,\n\t\t\tchildren: [\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 0,\n\t\t\t\t\t'disc-max': 0,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: null,\n\t\t\t\t\tfsroots: [null],\n\t\t\t\t\tfssize: null,\n\t\t\t\t\tfstype: 'vfat',\n\t\t\t\t\tfsused: null,\n\t\t\t\t\t'fsuse%': null,\n\t\t\t\t\tfsver: 'FAT32',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: true,\n\t\t\t\t\tkname: 'sda1',\n\t\t\t\t\tlabel: 'EFI',\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '8:1',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'sda1',\n\t\t\t\t\t'opt-io': 33553920,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'EFI System Partition',\n\t\t\t\t\tparttype: 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b',\n\t\t\t\t\tparttypename: 'EFI System',\n\t\t\t\t\tpartuuid: '96ae826e-6062-4990-821d-303a632ef496',\n\t\t\t\t\tpath: '/dev/sda1',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'sda',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'fcca3f2a-e019-4f4d-8385-2b660c1d7eec',\n\t\t\t\t\tra: 65532,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 60,\n\t\t\t\t\tsched: 'mq-deadline',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 209715200,\n\t\t\t\t\tstart: 40,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:scsi:usb:pci',\n\t\t\t\t\tmountpoint: null,\n\t\t\t\t\tmountpoints: [null],\n\t\t\t\t\ttran: null,\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: '67E3-17ED',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: '0x5002538e00000000',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 0,\n\t\t\t\t\t'disc-max': 0,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: null,\n\t\t\t\t\tfsroots: [null],\n\t\t\t\t\tfssize: null,\n\t\t\t\t\tfstype: 'exfat',\n\t\t\t\t\tfsused: null,\n\t\t\t\t\t'fsuse%': null,\n\t\t\t\t\tfsver: '1.0',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: true,\n\t\t\t\t\tkname: 'sda2',\n\t\t\t\t\tlabel: 'Red T5',\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '8:2',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'sda2',\n\t\t\t\t\t'opt-io': 33553920,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: null,\n\t\t\t\t\tparttype: 'ebd0a0a2-b9e5-4433-87c0-68b6b72699c7',\n\t\t\t\t\tparttypename: 'Microsoft basic data',\n\t\t\t\t\tpartuuid: '686e6e14-5994-4906-93fb-e986c1caafb0',\n\t\t\t\t\tpath: '/dev/sda2',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'sda',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'fcca3f2a-e019-4f4d-8385-2b660c1d7eec',\n\t\t\t\t\tra: 65532,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 60,\n\t\t\t\t\tsched: 'mq-deadline',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 999993376768,\n\t\t\t\t\tstart: 411648,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:scsi:usb:pci',\n\t\t\t\t\tmountpoint: null,\n\t\t\t\t\tmountpoints: [null],\n\t\t\t\t\ttran: null,\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: '67F5-306E',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: '0x5002538e00000000',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t\t{\n\t\t\talignment: 0,\n\t\t\t'disc-aln': 0,\n\t\t\tdax: false,\n\t\t\t'disc-gran': 512,\n\t\t\t'disc-max': 2199023255040,\n\t\t\t'disc-zero': false,\n\t\t\tfsavail: null,\n\t\t\tfsroots: [null],\n\t\t\tfssize: null,\n\t\t\tfstype: null,\n\t\t\tfsused: null,\n\t\t\t'fsuse%': null,\n\t\t\tfsver: null,\n\t\t\tgroup: 'disk',\n\t\t\thctl: null,\n\t\t\thotplug: false,\n\t\t\tkname: 'nvme0n1',\n\t\t\tlabel: null,\n\t\t\t'log-sec': 512,\n\t\t\t'maj:min': '259:0',\n\t\t\t'min-io': 512,\n\t\t\tmode: 'brw-rw----',\n\t\t\tmodel: 'PCIe SSD',\n\t\t\tname: 'nvme0n1',\n\t\t\t'opt-io': 0,\n\t\t\towner: 'root',\n\t\t\tpartflags: null,\n\t\t\tpartlabel: null,\n\t\t\tparttype: null,\n\t\t\tparttypename: null,\n\t\t\tpartuuid: null,\n\t\t\tpath: '/dev/nvme0n1',\n\t\t\t'phy-sec': 512,\n\t\t\tpkname: null,\n\t\t\tpttype: 'gpt',\n\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\tra: 128,\n\t\t\trand: false,\n\t\t\trev: null,\n\t\t\trm: false,\n\t\t\tro: false,\n\t\t\trota: false,\n\t\t\t'rq-size': 1023,\n\t\t\tsched: 'none',\n\t\t\tserial: '3EE50743172100007264',\n\t\t\tsize: 2048408248320,\n\t\t\tstart: null,\n\t\t\tstate: 'live',\n\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\tmountpoint: null,\n\t\t\tmountpoints: [null],\n\t\t\ttran: 'nvme',\n\t\t\ttype: 'disk',\n\t\t\tuuid: null,\n\t\t\tvendor: null,\n\t\t\twsame: 0,\n\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\tzoned: 'none',\n\t\t\t'zone-sz': 0,\n\t\t\t'zone-wgran': 0,\n\t\t\t'zone-app': 0,\n\t\t\t'zone-nr': 0,\n\t\t\t'zone-omax': 0,\n\t\t\t'zone-amax': 0,\n\t\t\tchildren: [\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 512,\n\t\t\t\t\t'disc-max': 2199023255040,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: 161460224,\n\t\t\t\t\tfsroots: ['/', '/'],\n\t\t\t\t\tfssize: 209489920,\n\t\t\t\t\tfstype: 'vfat',\n\t\t\t\t\tfsused: 48029696,\n\t\t\t\t\t'fsuse%': '23%',\n\t\t\t\t\tfsver: 'FAT16',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: false,\n\t\t\t\t\tkname: 'nvme0n1p1',\n\t\t\t\t\tlabel: 'ESP',\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '259:1',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'nvme0n1p1',\n\t\t\t\t\t'opt-io': 0,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'ESP',\n\t\t\t\t\tparttype: 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b',\n\t\t\t\t\tparttypename: 'EFI System',\n\t\t\t\t\tpartuuid: '14a31e9d-a8d7-4da0-9eb2-f268dd9d7ad9',\n\t\t\t\t\tpath: '/dev/nvme0n1p1',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'nvme0n1',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\t\t\tra: 128,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 1023,\n\t\t\t\t\tsched: 'none',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 209715200,\n\t\t\t\t\tstart: 16384,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\t\t\tmountpoint: '/mnt/root/boot/efi',\n\t\t\t\t\tmountpoints: ['/mnt/root/boot/efi', '/boot/efi'],\n\t\t\t\t\ttran: 'nvme',\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: '8C09-5015',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 512,\n\t\t\t\t\t'disc-max': 2199023255040,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: 6095048704,\n\t\t\t\t\tfsroots: ['/', '/'],\n\t\t\t\t\tfssize: 10297585664,\n\t\t\t\t\tfstype: 'ext4',\n\t\t\t\t\tfsused: 3711778816,\n\t\t\t\t\t'fsuse%': '36%',\n\t\t\t\t\tfsver: '1.0',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: false,\n\t\t\t\t\tkname: 'nvme0n1p2',\n\t\t\t\t\tlabel: null,\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '259:2',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'nvme0n1p2',\n\t\t\t\t\t'opt-io': 0,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'primary',\n\t\t\t\t\tparttype: '0fc63daf-8483-4772-8e79-3d69d8477de4',\n\t\t\t\t\tparttypename: 'Linux filesystem',\n\t\t\t\t\tpartuuid: '2fe5a278-9b55-4266-8220-6665aa96940b',\n\t\t\t\t\tpath: '/dev/nvme0n1p2',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'nvme0n1',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\t\t\tra: 128,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 1023,\n\t\t\t\t\tsched: 'none',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 10552868864,\n\t\t\t\t\tstart: 425984,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\t\t\tmountpoint: '/mnt/root',\n\t\t\t\t\tmountpoints: ['/mnt/root', '/'],\n\t\t\t\t\ttran: 'nvme',\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: 'f30a5ab4-4925-4ef2-919e-baa907acc271',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 512,\n\t\t\t\t\t'disc-max': 2199023255040,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: null,\n\t\t\t\t\tfsroots: [null],\n\t\t\t\t\tfssize: null,\n\t\t\t\t\tfstype: 'ext4',\n\t\t\t\t\tfsused: null,\n\t\t\t\t\t'fsuse%': null,\n\t\t\t\t\tfsver: '1.0',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: false,\n\t\t\t\t\tkname: 'nvme0n1p3',\n\t\t\t\t\tlabel: null,\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '259:3',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'nvme0n1p3',\n\t\t\t\t\t'opt-io': 0,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'primary',\n\t\t\t\t\tparttype: '0fc63daf-8483-4772-8e79-3d69d8477de4',\n\t\t\t\t\tparttypename: 'Linux filesystem',\n\t\t\t\t\tpartuuid: 'f5e6d27c-4a25-447b-8e08-a9d2e738345a',\n\t\t\t\t\tpath: '/dev/nvme0n1p3',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'nvme0n1',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\t\t\tra: 128,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 1023,\n\t\t\t\t\tsched: 'none',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 10552868864,\n\t\t\t\t\tstart: 21037056,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\t\t\tmountpoint: null,\n\t\t\t\t\tmountpoints: [null],\n\t\t\t\t\ttran: 'nvme',\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: 'a45fe9d9-9fb4-44f1-aaee-95b55beff174',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 512,\n\t\t\t\t\t'disc-max': 2199023255040,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: 1860441555968,\n\t\t\t\t\tfsroots: [\n\t\t\t\t\t\t'/umbrel-os/var/log',\n\t\t\t\t\t\t'/umbrel-os/var/log',\n\t\t\t\t\t\t'/umbrel-os/var/lib/systemd/timesync',\n\t\t\t\t\t\t'/umbrel-os/var/lib/systemd/timesync',\n\t\t\t\t\t\t'/umbrel-os/var/lib/docker',\n\t\t\t\t\t\t'/umbrel-os/var/lib/docker',\n\t\t\t\t\t\t'/umbrel-os/home',\n\t\t\t\t\t\t'/umbrel-os/home',\n\t\t\t\t\t\t'/',\n\t\t\t\t\t\t'/',\n\t\t\t\t\t],\n\t\t\t\t\tfssize: 1963188352000,\n\t\t\t\t\tfstype: 'ext4',\n\t\t\t\t\tfsused: 39932456960,\n\t\t\t\t\t'fsuse%': '2%',\n\t\t\t\t\tfsver: '1.0',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: false,\n\t\t\t\t\tkname: 'nvme0n1p4',\n\t\t\t\t\tlabel: null,\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '259:4',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'nvme0n1p4',\n\t\t\t\t\t'opt-io': 0,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'primary',\n\t\t\t\t\tparttype: '0fc63daf-8483-4772-8e79-3d69d8477de4',\n\t\t\t\t\tparttypename: 'Linux filesystem',\n\t\t\t\t\tpartuuid: 'd1d36e34-2753-4dc7-96eb-3c9b5584e867',\n\t\t\t\t\tpath: '/dev/nvme0n1p4',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'nvme0n1',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\t\t\tra: 128,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 1023,\n\t\t\t\t\tsched: 'none',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 2027084386304,\n\t\t\t\t\tstart: 41648128,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\t\t\tmountpoint: '/mnt/root/data',\n\t\t\t\t\tmountpoints: [\n\t\t\t\t\t\t'/mnt/root/var/log',\n\t\t\t\t\t\t'/var/log',\n\t\t\t\t\t\t'/mnt/root/var/lib/systemd/timesync',\n\t\t\t\t\t\t'/var/lib/systemd/timesync',\n\t\t\t\t\t\t'/mnt/root/var/lib/docker',\n\t\t\t\t\t\t'/var/lib/docker',\n\t\t\t\t\t\t'/mnt/root/home',\n\t\t\t\t\t\t'/home',\n\t\t\t\t\t\t'/mnt/root/data',\n\t\t\t\t\t\t'/data',\n\t\t\t\t\t],\n\t\t\t\t\ttran: 'nvme',\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: '5e29c50c-6b77-48af-bd31-ed97b2c36ea4',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t],\n}\n\nconst LSBLK_EXTERNAL_DISK_MOUNTED = {\n\tblockdevices: [\n\t\t{\n\t\t\talignment: 0,\n\t\t\t'disc-aln': 0,\n\t\t\tdax: false,\n\t\t\t'disc-gran': 0,\n\t\t\t'disc-max': 0,\n\t\t\t'disc-zero': false,\n\t\t\tfsavail: null,\n\t\t\tfsroots: [null],\n\t\t\tfssize: null,\n\t\t\tfstype: null,\n\t\t\tfsused: null,\n\t\t\t'fsuse%': null,\n\t\t\tfsver: null,\n\t\t\tgroup: 'disk',\n\t\t\thctl: '2:0:0:0',\n\t\t\thotplug: true,\n\t\t\tkname: 'sda',\n\t\t\tlabel: null,\n\t\t\t'log-sec': 512,\n\t\t\t'maj:min': '8:0',\n\t\t\t'min-io': 512,\n\t\t\tmode: 'brw-rw----',\n\t\t\tmodel: 'Samsung Portable SSD T5',\n\t\t\tname: 'sda',\n\t\t\t'opt-io': 33553920,\n\t\t\towner: 'root',\n\t\t\tpartflags: null,\n\t\t\tpartlabel: null,\n\t\t\tparttype: null,\n\t\t\tparttypename: null,\n\t\t\tpartuuid: null,\n\t\t\tpath: '/dev/sda',\n\t\t\t'phy-sec': 512,\n\t\t\tpkname: null,\n\t\t\tpttype: 'gpt',\n\t\t\tptuuid: 'fcca3f2a-e019-4f4d-8385-2b660c1d7eec',\n\t\t\tra: 65532,\n\t\t\trand: false,\n\t\t\trev: '0   ',\n\t\t\trm: false,\n\t\t\tro: false,\n\t\t\trota: false,\n\t\t\t'rq-size': 60,\n\t\t\tsched: 'mq-deadline',\n\t\t\tserial: 'S50ZNV0MB00594H',\n\t\t\tsize: 1000204886016,\n\t\t\tstart: null,\n\t\t\tstate: 'running',\n\t\t\tsubsystems: 'block:scsi:usb:pci',\n\t\t\tmountpoint: null,\n\t\t\tmountpoints: [null],\n\t\t\ttran: 'usb',\n\t\t\ttype: 'disk',\n\t\t\tuuid: null,\n\t\t\tvendor: 'Samsung ',\n\t\t\twsame: 0,\n\t\t\twwn: '0x5002538e00000000',\n\t\t\tzoned: 'none',\n\t\t\t'zone-sz': 0,\n\t\t\t'zone-wgran': 0,\n\t\t\t'zone-app': 0,\n\t\t\t'zone-nr': 0,\n\t\t\t'zone-omax': 0,\n\t\t\t'zone-amax': 0,\n\t\t\tchildren: [\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 0,\n\t\t\t\t\t'disc-max': 0,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: null,\n\t\t\t\t\tfsroots: [null],\n\t\t\t\t\tfssize: null,\n\t\t\t\t\tfstype: 'vfat',\n\t\t\t\t\tfsused: null,\n\t\t\t\t\t'fsuse%': null,\n\t\t\t\t\tfsver: 'FAT32',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: true,\n\t\t\t\t\tkname: 'sda1',\n\t\t\t\t\tlabel: 'EFI',\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '8:1',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'sda1',\n\t\t\t\t\t'opt-io': 33553920,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'EFI System Partition',\n\t\t\t\t\tparttype: 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b',\n\t\t\t\t\tparttypename: 'EFI System',\n\t\t\t\t\tpartuuid: '96ae826e-6062-4990-821d-303a632ef496',\n\t\t\t\t\tpath: '/dev/sda1',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'sda',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'fcca3f2a-e019-4f4d-8385-2b660c1d7eec',\n\t\t\t\t\tra: 65532,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 60,\n\t\t\t\t\tsched: 'mq-deadline',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 209715200,\n\t\t\t\t\tstart: 40,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:scsi:usb:pci',\n\t\t\t\t\tmountpoint: null,\n\t\t\t\t\tmountpoints: [null],\n\t\t\t\t\ttran: null,\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: '67E3-17ED',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: '0x5002538e00000000',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 0,\n\t\t\t\t\t'disc-max': 0,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: 999946059776,\n\t\t\t\t\tfsroots: ['/', '/', '/', '/'],\n\t\t\t\t\tfssize: 999960870912,\n\t\t\t\t\tfstype: 'exfat',\n\t\t\t\t\tfsused: 14811136,\n\t\t\t\t\t'fsuse%': '0%',\n\t\t\t\t\tfsver: '1.0',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: true,\n\t\t\t\t\tkname: 'sda2',\n\t\t\t\t\tlabel: 'Red T5',\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '8:2',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'sda2',\n\t\t\t\t\t'opt-io': 33553920,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: null,\n\t\t\t\t\tparttype: 'ebd0a0a2-b9e5-4433-87c0-68b6b72699c7',\n\t\t\t\t\tparttypename: 'Microsoft basic data',\n\t\t\t\t\tpartuuid: '686e6e14-5994-4906-93fb-e986c1caafb0',\n\t\t\t\t\tpath: '/dev/sda2',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'sda',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'fcca3f2a-e019-4f4d-8385-2b660c1d7eec',\n\t\t\t\t\tra: 65532,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 60,\n\t\t\t\t\tsched: 'mq-deadline',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 999993376768,\n\t\t\t\t\tstart: 411648,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:scsi:usb:pci',\n\t\t\t\t\tmountpoint: '/mnt/root/home/umbrel/umbrel/external/Red T5',\n\t\t\t\t\tmountpoints: [\n\t\t\t\t\t\t'/mnt/root/home/umbrel/umbrel/external/Red T5',\n\t\t\t\t\t\t'/mnt/root/data/umbrel-os/home/umbrel/umbrel/external/Red T5',\n\t\t\t\t\t\t'/data/umbrel-os/home/umbrel/umbrel/external/Red T5',\n\t\t\t\t\t\t'/home/umbrel/umbrel/external/Red T5',\n\t\t\t\t\t],\n\t\t\t\t\ttran: null,\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: '67F5-306E',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: '0x5002538e00000000',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t\t{\n\t\t\talignment: 0,\n\t\t\t'disc-aln': 0,\n\t\t\tdax: false,\n\t\t\t'disc-gran': 512,\n\t\t\t'disc-max': 2199023255040,\n\t\t\t'disc-zero': false,\n\t\t\tfsavail: null,\n\t\t\tfsroots: [null],\n\t\t\tfssize: null,\n\t\t\tfstype: null,\n\t\t\tfsused: null,\n\t\t\t'fsuse%': null,\n\t\t\tfsver: null,\n\t\t\tgroup: 'disk',\n\t\t\thctl: null,\n\t\t\thotplug: false,\n\t\t\tkname: 'nvme0n1',\n\t\t\tlabel: null,\n\t\t\t'log-sec': 512,\n\t\t\t'maj:min': '259:0',\n\t\t\t'min-io': 512,\n\t\t\tmode: 'brw-rw----',\n\t\t\tmodel: 'PCIe SSD',\n\t\t\tname: 'nvme0n1',\n\t\t\t'opt-io': 0,\n\t\t\towner: 'root',\n\t\t\tpartflags: null,\n\t\t\tpartlabel: null,\n\t\t\tparttype: null,\n\t\t\tparttypename: null,\n\t\t\tpartuuid: null,\n\t\t\tpath: '/dev/nvme0n1',\n\t\t\t'phy-sec': 512,\n\t\t\tpkname: null,\n\t\t\tpttype: 'gpt',\n\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\tra: 128,\n\t\t\trand: false,\n\t\t\trev: null,\n\t\t\trm: false,\n\t\t\tro: false,\n\t\t\trota: false,\n\t\t\t'rq-size': 1023,\n\t\t\tsched: 'none',\n\t\t\tserial: '3EE50743172100007264',\n\t\t\tsize: 2048408248320,\n\t\t\tstart: null,\n\t\t\tstate: 'live',\n\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\tmountpoint: null,\n\t\t\tmountpoints: [null],\n\t\t\ttran: 'nvme',\n\t\t\ttype: 'disk',\n\t\t\tuuid: null,\n\t\t\tvendor: null,\n\t\t\twsame: 0,\n\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\tzoned: 'none',\n\t\t\t'zone-sz': 0,\n\t\t\t'zone-wgran': 0,\n\t\t\t'zone-app': 0,\n\t\t\t'zone-nr': 0,\n\t\t\t'zone-omax': 0,\n\t\t\t'zone-amax': 0,\n\t\t\tchildren: [\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 512,\n\t\t\t\t\t'disc-max': 2199023255040,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: 161460224,\n\t\t\t\t\tfsroots: ['/', '/'],\n\t\t\t\t\tfssize: 209489920,\n\t\t\t\t\tfstype: 'vfat',\n\t\t\t\t\tfsused: 48029696,\n\t\t\t\t\t'fsuse%': '23%',\n\t\t\t\t\tfsver: 'FAT16',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: false,\n\t\t\t\t\tkname: 'nvme0n1p1',\n\t\t\t\t\tlabel: 'ESP',\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '259:1',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'nvme0n1p1',\n\t\t\t\t\t'opt-io': 0,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'ESP',\n\t\t\t\t\tparttype: 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b',\n\t\t\t\t\tparttypename: 'EFI System',\n\t\t\t\t\tpartuuid: '14a31e9d-a8d7-4da0-9eb2-f268dd9d7ad9',\n\t\t\t\t\tpath: '/dev/nvme0n1p1',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'nvme0n1',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\t\t\tra: 128,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 1023,\n\t\t\t\t\tsched: 'none',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 209715200,\n\t\t\t\t\tstart: 16384,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\t\t\tmountpoint: '/mnt/root/boot/efi',\n\t\t\t\t\tmountpoints: ['/mnt/root/boot/efi', '/boot/efi'],\n\t\t\t\t\ttran: 'nvme',\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: '8C09-5015',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 512,\n\t\t\t\t\t'disc-max': 2199023255040,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: 6095024128,\n\t\t\t\t\tfsroots: ['/', '/'],\n\t\t\t\t\tfssize: 10297585664,\n\t\t\t\t\tfstype: 'ext4',\n\t\t\t\t\tfsused: 3711803392,\n\t\t\t\t\t'fsuse%': '36%',\n\t\t\t\t\tfsver: '1.0',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: false,\n\t\t\t\t\tkname: 'nvme0n1p2',\n\t\t\t\t\tlabel: null,\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '259:2',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'nvme0n1p2',\n\t\t\t\t\t'opt-io': 0,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'primary',\n\t\t\t\t\tparttype: '0fc63daf-8483-4772-8e79-3d69d8477de4',\n\t\t\t\t\tparttypename: 'Linux filesystem',\n\t\t\t\t\tpartuuid: '2fe5a278-9b55-4266-8220-6665aa96940b',\n\t\t\t\t\tpath: '/dev/nvme0n1p2',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'nvme0n1',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\t\t\tra: 128,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 1023,\n\t\t\t\t\tsched: 'none',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 10552868864,\n\t\t\t\t\tstart: 425984,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\t\t\tmountpoint: '/mnt/root',\n\t\t\t\t\tmountpoints: ['/mnt/root', '/'],\n\t\t\t\t\ttran: 'nvme',\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: 'f30a5ab4-4925-4ef2-919e-baa907acc271',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 512,\n\t\t\t\t\t'disc-max': 2199023255040,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: null,\n\t\t\t\t\tfsroots: [null],\n\t\t\t\t\tfssize: null,\n\t\t\t\t\tfstype: 'ext4',\n\t\t\t\t\tfsused: null,\n\t\t\t\t\t'fsuse%': null,\n\t\t\t\t\tfsver: '1.0',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: false,\n\t\t\t\t\tkname: 'nvme0n1p3',\n\t\t\t\t\tlabel: null,\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '259:3',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'nvme0n1p3',\n\t\t\t\t\t'opt-io': 0,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'primary',\n\t\t\t\t\tparttype: '0fc63daf-8483-4772-8e79-3d69d8477de4',\n\t\t\t\t\tparttypename: 'Linux filesystem',\n\t\t\t\t\tpartuuid: 'f5e6d27c-4a25-447b-8e08-a9d2e738345a',\n\t\t\t\t\tpath: '/dev/nvme0n1p3',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'nvme0n1',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\t\t\tra: 128,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 1023,\n\t\t\t\t\tsched: 'none',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 10552868864,\n\t\t\t\t\tstart: 21037056,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\t\t\tmountpoint: null,\n\t\t\t\t\tmountpoints: [null],\n\t\t\t\t\ttran: 'nvme',\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: 'a45fe9d9-9fb4-44f1-aaee-95b55beff174',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\talignment: 0,\n\t\t\t\t\t'disc-aln': 0,\n\t\t\t\t\tdax: false,\n\t\t\t\t\t'disc-gran': 512,\n\t\t\t\t\t'disc-max': 2199023255040,\n\t\t\t\t\t'disc-zero': false,\n\t\t\t\t\tfsavail: 1860365182976,\n\t\t\t\t\tfsroots: [\n\t\t\t\t\t\t'/umbrel-os/var/log',\n\t\t\t\t\t\t'/umbrel-os/var/log',\n\t\t\t\t\t\t'/umbrel-os/var/lib/systemd/timesync',\n\t\t\t\t\t\t'/umbrel-os/var/lib/systemd/timesync',\n\t\t\t\t\t\t'/umbrel-os/var/lib/docker',\n\t\t\t\t\t\t'/umbrel-os/var/lib/docker',\n\t\t\t\t\t\t'/umbrel-os/home',\n\t\t\t\t\t\t'/umbrel-os/home',\n\t\t\t\t\t\t'/',\n\t\t\t\t\t\t'/',\n\t\t\t\t\t],\n\t\t\t\t\tfssize: 1963188352000,\n\t\t\t\t\tfstype: 'ext4',\n\t\t\t\t\tfsused: 40008829952,\n\t\t\t\t\t'fsuse%': '2%',\n\t\t\t\t\tfsver: '1.0',\n\t\t\t\t\tgroup: 'disk',\n\t\t\t\t\thctl: null,\n\t\t\t\t\thotplug: false,\n\t\t\t\t\tkname: 'nvme0n1p4',\n\t\t\t\t\tlabel: null,\n\t\t\t\t\t'log-sec': 512,\n\t\t\t\t\t'maj:min': '259:4',\n\t\t\t\t\t'min-io': 512,\n\t\t\t\t\tmode: 'brw-rw----',\n\t\t\t\t\tmodel: null,\n\t\t\t\t\tname: 'nvme0n1p4',\n\t\t\t\t\t'opt-io': 0,\n\t\t\t\t\towner: 'root',\n\t\t\t\t\tpartflags: null,\n\t\t\t\t\tpartlabel: 'primary',\n\t\t\t\t\tparttype: '0fc63daf-8483-4772-8e79-3d69d8477de4',\n\t\t\t\t\tparttypename: 'Linux filesystem',\n\t\t\t\t\tpartuuid: 'd1d36e34-2753-4dc7-96eb-3c9b5584e867',\n\t\t\t\t\tpath: '/dev/nvme0n1p4',\n\t\t\t\t\t'phy-sec': 512,\n\t\t\t\t\tpkname: 'nvme0n1',\n\t\t\t\t\tpttype: 'gpt',\n\t\t\t\t\tptuuid: 'd021fdbe-f203-4f85-a8ba-bda954d239ec',\n\t\t\t\t\tra: 128,\n\t\t\t\t\trand: false,\n\t\t\t\t\trev: null,\n\t\t\t\t\trm: false,\n\t\t\t\t\tro: false,\n\t\t\t\t\trota: false,\n\t\t\t\t\t'rq-size': 1023,\n\t\t\t\t\tsched: 'none',\n\t\t\t\t\tserial: null,\n\t\t\t\t\tsize: 2027084386304,\n\t\t\t\t\tstart: 41648128,\n\t\t\t\t\tstate: null,\n\t\t\t\t\tsubsystems: 'block:nvme:pci',\n\t\t\t\t\tmountpoint: '/mnt/root/data',\n\t\t\t\t\tmountpoints: [\n\t\t\t\t\t\t'/mnt/root/var/log',\n\t\t\t\t\t\t'/var/log',\n\t\t\t\t\t\t'/mnt/root/var/lib/systemd/timesync',\n\t\t\t\t\t\t'/var/lib/systemd/timesync',\n\t\t\t\t\t\t'/mnt/root/var/lib/docker',\n\t\t\t\t\t\t'/var/lib/docker',\n\t\t\t\t\t\t'/mnt/root/home',\n\t\t\t\t\t\t'/home',\n\t\t\t\t\t\t'/mnt/root/data',\n\t\t\t\t\t\t'/data',\n\t\t\t\t\t],\n\t\t\t\t\ttran: 'nvme',\n\t\t\t\t\ttype: 'part',\n\t\t\t\t\tuuid: '5e29c50c-6b77-48af-bd31-ed97b2c36ea4',\n\t\t\t\t\tvendor: null,\n\t\t\t\t\twsame: 0,\n\t\t\t\t\twwn: 'eui.6479a78e1a306219',\n\t\t\t\t\tzoned: 'none',\n\t\t\t\t\t'zone-sz': 0,\n\t\t\t\t\t'zone-wgran': 0,\n\t\t\t\t\t'zone-app': 0,\n\t\t\t\t\t'zone-nr': 0,\n\t\t\t\t\t'zone-omax': 0,\n\t\t\t\t\t'zone-amax': 0,\n\t\t\t\t},\n\t\t\t],\n\t\t},\n\t],\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/external-storage.ts",
    "content": "import nodePath from 'node:path'\nimport {setTimeout} from 'node:timers/promises'\n\nimport pWaitFor from 'p-wait-for'\n\nimport fse from 'fs-extra'\nimport {$} from 'execa'\nimport PQueue from 'p-queue'\n\nimport {isRaspberryPi} from '../system/system.js'\n\nimport type Umbreld from '../../index.js'\n\ntype BlockDevice = {\n\tid: string\n\tname: string\n\t// Type more values here as we use them like emmc or sdcard\n\ttransport: 'unknown' | 'usb' | 'nvme'\n\tsize: number\n\tisMounted: boolean\n\tisFormatting: boolean\n\tpartitions: {\n\t\tid: string\n\t\ttype: string\n\t\tsize: number\n\t\tmountpoints: string[]\n\t\tlabel: string\n\t}[]\n}\n\n// Get block devices\n// TODO: This should probably be in a system module once we have a proper one\nexport async function getBlockDevices() {\n\ttype LsBlkDevice = {\n\t\tname: string\n\t\tlabel?: string\n\t\ttype?: string\n\t\tmountpoints?: string[]\n\t\ttran?: BlockDevice['transport']\n\t\tmodel?: string\n\t\tsize?: number\n\t\tchildren?: LsBlkDevice[]\n\t\tparttypename?: string\n\t}\n\tconst {stdout} = await $`lsblk --output-all --json --bytes`\n\tconst {blockdevices} = JSON.parse(stdout) as {blockdevices: LsBlkDevice[]}\n\n\t// Loop over block devices\n\tconst externalStorageDevices: BlockDevice[] = []\n\tfor (const blockDevice of blockdevices) {\n\t\t// Skip non-disk block devices\n\t\tif (blockDevice.type !== 'disk') continue\n\n\t\t// Create a new external storage device\n\t\tconst device: BlockDevice = {\n\t\t\tid: blockDevice.name,\n\t\t\tname: blockDevice.model ?? 'Untitled',\n\t\t\ttransport: blockDevice.tran ?? 'unknown',\n\t\t\tsize: blockDevice.size ?? 0,\n\t\t\tisMounted: false,\n\t\t\tisFormatting: false,\n\t\t\tpartitions: [],\n\t\t}\n\n\t\t// Create partitions\n\t\tfor (const partition of blockDevice.children ?? []) {\n\t\t\t// Skip any non-partition block devices\n\t\t\tif (partition.type !== 'part') continue\n\n\t\t\t// Add the partition to the device\n\t\t\tdevice.partitions.push({\n\t\t\t\tid: partition.name,\n\t\t\t\ttype: partition.parttypename ?? 'unknown',\n\t\t\t\tlabel: partition.label?.trim() ?? 'Untitled',\n\t\t\t\tsize: partition.size ?? 0,\n\t\t\t\tmountpoints: partition.mountpoints?.filter(Boolean) ?? [],\n\t\t\t})\n\t\t}\n\n\t\tdevice.isMounted = device.partitions.some((partition) => partition.mountpoints.length > 0)\n\n\t\t// Add the device to the list\n\t\texternalStorageDevices.push(device)\n\t}\n\n\treturn externalStorageDevices\n}\n\nexport default class ExternalStorage {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\t#mountQueue = new PQueue({concurrency: 1})\n\t#removeDeviceChangeListener?: () => void\n\tformatJobs: Set<string> = new Set()\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(`files:${name.toLocaleLowerCase()}`)\n\t}\n\n\t// Only enable this module on non raspberry pi devices.\n\t// We disable on Pi due to unreliable power issues when running USB storage devices\n\t// and also due to complexities with the current mount script.\n\tasync supported() {\n\t\tconst isNotRaspberryPi = !(await isRaspberryPi())\n\t\treturn isNotRaspberryPi\n\t}\n\n\t// Add listener\n\tasync start() {\n\t\t// Don't run through start process if we're not enabled\n\t\tconst isEnabled = await this.supported()\n\t\tif (!isEnabled) return\n\n\t\tthis.logger.log('Starting external storage')\n\n\t\t// Safely clean up any left over mount points\n\t\tawait this.#cleanLeftOverMountPoints()\n\n\t\t// Auto mount any external devices\n\t\tawait this.#mountExternalDevices().catch((error) => {\n\t\t\tthis.logger.error(`Failed to mount external devices on startup`, error)\n\t\t})\n\n\t\t// Attach disk change listener and auto mount any new external devices\n\t\tthis.#removeDeviceChangeListener = this.#umbreld.eventBus.on('system:disk:change', async () => {\n\t\t\tthis.logger.log('Device change detected')\n\t\t\tawait this.#mountExternalDevices()\n\t\t})\n\t}\n\n\t// Remove listener\n\tasync stop() {\n\t\t// Don't run through stop process if we're not enabled\n\t\tconst isEnabled = await this.supported()\n\t\tif (!isEnabled) return\n\n\t\tthis.logger.log('Stopping external storage')\n\t\tthis.#removeDeviceChangeListener?.()\n\n\t\t// Unmount all external devices\n\t\tconst ONE_SECOND = 1000\n\t\tawait Promise.race([\n\t\t\tsetTimeout(ONE_SECOND * 10),\n\t\t\t(async () => {\n\t\t\t\tthis.logger.log('Unmounting all external devices')\n\t\t\t\tawait this.#unmountAllMountedExternalDevices().catch((error) =>\n\t\t\t\t\tthis.logger.error('Error unmounting external devices', error),\n\t\t\t\t)\n\t\t\t})(),\n\t\t])\n\t}\n\n\t// Mount external disks\n\tasync #mountExternalDevices() {\n\t\t// Run through single threaded queue so we don't try to mount concurrently\n\t\treturn this.#mountQueue.add(async () => {\n\t\t\t// Get external devices\n\t\t\t// Sometimes it takes a while until partition labels and types show up so we wait if we have an\n\t\t\t// unknown partition type. We check type not partition label since partition label sometimes doesn't\n\t\t\t// exist at all due to nothing being set.\n\t\t\t// We stop waiting after 2 seconds just incase everything has loaded but we have some weird partition\n\t\t\t// type that is always unknown.\n\t\t\t// If we don't do this we'll end up mounting all partitions as \"Untitled\".\n\t\t\tlet externalStorageDevices: BlockDevice[] = []\n\t\t\tawait pWaitFor(\n\t\t\t\tasync () => {\n\t\t\t\t\texternalStorageDevices = await this.#getExternalDevices()\n\t\t\t\t\tconst hasMissingData = externalStorageDevices.some((device) =>\n\t\t\t\t\t\tdevice.partitions.some((partition) => partition.type === 'unknown'),\n\t\t\t\t\t)\n\t\t\t\t\treturn !hasMissingData\n\t\t\t\t},\n\t\t\t\t{interval: 100, timeout: {milliseconds: 2000, fallback: () => {}}},\n\t\t\t)\n\n\t\t\t// Loop over external devices\n\t\t\tfor (const device of externalStorageDevices) {\n\t\t\t\t// Skip devices that are currently being formatted\n\t\t\t\tif (this.formatJobs.has(device.id)) continue\n\n\t\t\t\t// Loop over partitions\n\t\t\t\tfor (const partition of device.partitions) {\n\t\t\t\t\t// Skip partitions that are already mounted\n\t\t\t\t\tif (partition.mountpoints.length > 0) continue\n\n\t\t\t\t\t// Skip EFI partitions since they're just confusing for users\n\t\t\t\t\tif (partition.type === 'EFI System') continue\n\n\t\t\t\t\t// We have a new partition to mount\n\t\t\t\t\tthis.logger.log(`Mounting new partition ${device.name} ${partition.label}`)\n\t\t\t\t\ttry {\n\t\t\t\t\t\t// Derive mountpoint\n\t\t\t\t\t\tconst externalBaseSystemPath = this.#umbreld.files.getBaseDirectory('/External')\n\t\t\t\t\t\tconst sanitisedLabel = partition.label.replace(/[^a-zA-Z0-9 '\\_\\-]/g, '')\n\t\t\t\t\t\tlet systemMountpoint = nodePath.join(externalBaseSystemPath, sanitisedLabel)\n\t\t\t\t\t\tsystemMountpoint = await this.#umbreld.files.getUniqueName(systemMountpoint)\n\n\t\t\t\t\t\t// Mount partition\n\t\t\t\t\t\tawait fse.ensureDir(systemMountpoint)\n\t\t\t\t\t\tawait this.#umbreld.files.chownSystemPath(systemMountpoint)\n\t\t\t\t\t\tawait $`mount /dev/${partition.id} ${systemMountpoint}`\n\n\t\t\t\t\t\t// Log on success\n\t\t\t\t\t\tconst virtualMountPoint = this.#umbreld.files.systemToVirtualPath(systemMountpoint)\n\t\t\t\t\t\tthis.logger.log(`Mounted partition ${device.name} ${partition.label} as ${virtualMountPoint}`)\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t// Just log the error and continue to the next partition\n\t\t\t\t\t\tthis.logger.error(`Failed to mount partition ${device.name} ${partition.label}`, error)\n\n\t\t\t\t\t\t// Clean up left over mount points\n\t\t\t\t\t\tawait this.#cleanLeftOverMountPoints().catch((error) => {\n\t\t\t\t\t\t\tthis.logger.error(`Failed to clean up left over mount points`, error)\n\t\t\t\t\t\t})\n\t\t\t\t\t} finally {\n\t\t\t\t\t\t// Broadcast event signalling that the external storage devices have changed\n\t\t\t\t\t\tthis.#umbreld.eventBus.emit('files:external-storage:change')\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n\n\t// Unmount partition from external disk\n\tasync unmountExternalDevice(deviceId: string, {remove = true} = {}) {\n\t\t// We run this through the mount queue so we don't clean up mount\n\t\t// points that are in the process of being mounted.\n\t\t// This can happen if the user unmounts a device while attaching another.\n\t\treturn await this.#mountQueue.add(async () => {\n\t\t\t// Get mount points for block device\n\t\t\tconst externalBlockDevices = await this.#getExternalDevices()\n\t\t\tconst blockDevice = externalBlockDevices.find((device) => device.id === deviceId)\n\t\t\tif (!blockDevice) throw new Error('[invalid-device-id]')\n\t\t\tthis.logger.log(`Unmounting device ${deviceId}`)\n\n\t\t\t// Loop over partitions\n\t\t\tlet failedUnmounts = false\n\t\t\tfor (const partition of blockDevice.partitions) {\n\t\t\t\t// Skip partitions that aren't mounted\n\t\t\t\tif (partition.mountpoints.length == 0) continue\n\n\t\t\t\t// Unmount device\n\t\t\t\tthis.logger.log(`Unmounting partition ${partition.id}`)\n\t\t\t\tawait $`umount --all-targets /dev/${partition.id}`.catch((error) => {\n\t\t\t\t\t// Just log the error and continue to next partition\n\t\t\t\t\tthis.logger.error(`Failed to unmount partition ${partition.id}`, error)\n\t\t\t\t\tfailedUnmounts = true\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Clean up any left over mount points\n\t\t\tawait this.#cleanLeftOverMountPoints()\n\n\t\t\t// Remove the block device so we don't auto mount it until it's\n\t\t\t// been removed and re-attached\n\t\t\tif (remove) await fse.writeFile(`/sys/block/${deviceId}/device/delete`, '1')\n\n\t\t\t// Broadcast event signalling that the external storage devices have changed\n\t\t\tthis.#umbreld.eventBus.emit('files:external-storage:change')\n\n\t\t\t// Signal that some unmounts failed\n\t\t\tif (failedUnmounts) throw new Error('[failed-unmounts]')\n\n\t\t\treturn true\n\t\t})\n\t}\n\n\t// Format external device\n\tasync formatExternalDevice({\n\t\tdeviceId,\n\t\tfilesystem,\n\t\tlabel,\n\t}: {\n\t\tdeviceId: string\n\t\tfilesystem: 'ext4' | 'exfat'\n\t\tlabel: string\n\t}) {\n\t\ttry {\n\t\t\t// Check if job is already in progress\n\t\t\tif (this.formatJobs.has(deviceId)) throw new Error('[format-job-already-in-progress]')\n\t\t\tthis.formatJobs.add(deviceId)\n\n\t\t\t// Check valid filessytem type\n\t\t\tif (filesystem !== 'ext4' && filesystem !== 'exfat') throw new Error('[invalid-filesystem]')\n\n\t\t\t// Check label is valid\n\t\t\tconst labelSupportedCharacters = /^[A-Za-z0-9-_ ]+$/.test(label)\n\t\t\tconst labelSupportedLength = label.length >= 1 && label.length <= 11\n\t\t\tconst labelIsValid = labelSupportedCharacters && labelSupportedLength\n\t\t\tif (!labelIsValid) throw new Error('[invalid-label]')\n\n\t\t\tthis.logger.log(`Formatting device ${deviceId} as ${filesystem} with label ${label}`)\n\n\t\t\t// If the device is currently mounted, unmount it\n\t\t\tconst mountedExternalDevices = await this.getMountedExternalDevices()\n\t\t\tconst isDeviceMounted = mountedExternalDevices.some((device) => device.id === deviceId)\n\t\t\tif (isDeviceMounted) await this.unmountExternalDevice(deviceId, {remove: false})\n\n\t\t\t// Clean partition table\n\t\t\tthis.logger.log(`Cleaning partition table for device ${deviceId}`)\n\t\t\tawait $`sgdisk --zap-all /dev/${deviceId}`\n\n\t\t\t// Remove all filesystem signatures\n\t\t\tthis.logger.log(`Removing all filesystem signatures for device ${deviceId}`)\n\t\t\tawait $`wipefs -a /dev/${deviceId}`\n\n\t\t\t// Create new partition table\n\t\t\tthis.logger.log(`Creating new partition table for device ${deviceId}`)\n\t\t\tawait $`parted -s /dev/${deviceId} --align optimal mklabel gpt mkpart primary ext4 0% 100%`\n\n\t\t\t// Wait for changes\n\t\t\tthis.logger.log(`Waiting for changes for device ${deviceId}`)\n\t\t\tawait $`partprobe /dev/${deviceId}`\n\t\t\tawait $`udevadm settle`\n\n\t\t\t// Create filesystem\n\t\t\tif (filesystem === 'ext4') {\n\t\t\t\tthis.logger.log(`Creating ext4 filesystem for device ${deviceId}`)\n\t\t\t\tawait $`mkfs.ext4 -F -L ${label} /dev/${deviceId}1`\n\t\t\t} else if (filesystem === 'exfat') {\n\t\t\t\tthis.logger.log(`Creating exfat filesystem for device ${deviceId}`)\n\t\t\t\tawait $`mkfs.exfat -n ${label} /dev/${deviceId}1`\n\t\t\t}\n\n\t\t\tthis.logger.log(`Successfully formatted device ${deviceId} as ${filesystem} with label ${label}`)\n\t\t\treturn true\n\t\t} finally {\n\t\t\tthis.formatJobs.delete(deviceId)\n\t\t}\n\t}\n\n\t// Get external devices\n\tasync #getExternalDevices() {\n\t\t// Get all block devices\n\t\tconst blockDevices = await getBlockDevices()\n\n\t\t// Filter out any non-USB devices\n\t\treturn blockDevices.filter((device) => device.transport === 'usb')\n\t}\n\n\t// Get external devices but only show mount points that are under /External\n\t// Also decorate with useful flags like isMounted and isFormatting\n\tasync getExternalDevicesWithVirtualMountPoints() {\n\t\t// Get all block devices\n\t\tconst externalBlockDevices = await this.#getExternalDevices()\n\n\t\t// Loop over devices\n\t\tconst externalBaseSystemPath = this.#umbreld.files.getBaseDirectory('/External')\n\t\tfor (const device of externalBlockDevices) {\n\t\t\t// Loop over partitions\n\t\t\tfor (const partition of device.partitions) {\n\t\t\t\t// Format partitions to only contain /External mount points\n\t\t\t\tpartition.mountpoints = partition.mountpoints\n\t\t\t\t\t.filter((mountpoint) => mountpoint.startsWith(externalBaseSystemPath))\n\t\t\t\t\t.map((mountpoint) => this.#umbreld.files.systemToVirtualPath(mountpoint))\n\t\t\t}\n\n\t\t\t// Update isMounted flag to only apply to vfs mounts\n\t\t\tdevice.isMounted = device.partitions.some((partition) => partition.mountpoints.length > 0)\n\n\t\t\t// Check if we have a formatting job running for this device\n\t\t\tdevice.isFormatting = this.formatJobs.has(device.id)\n\t\t}\n\n\t\treturn externalBlockDevices\n\t}\n\n\t// Get all umbreld mounted external devices\n\tasync getMountedExternalDevices() {\n\t\t// Get all block devices\n\t\tconst externalBlockDevices = await this.getExternalDevicesWithVirtualMountPoints()\n\n\t\t// Loop over devices\n\t\tfor (const device of externalBlockDevices) {\n\t\t\t// Filter out partitions without mount points from device\n\t\t\tdevice.partitions = device.partitions.filter((partition) => partition.mountpoints.length > 0)\n\t\t}\n\n\t\t// Filter out block devices without partitions\n\t\tconst mountedExternalDevices = externalBlockDevices.filter((device) => device.partitions.length > 0)\n\n\t\treturn mountedExternalDevices\n\t}\n\n\t// Unmount all mounted external devices\n\tasync #unmountAllMountedExternalDevices() {\n\t\t// Loop over all mounted external devices\n\t\tfor (const device of await this.getMountedExternalDevices()) {\n\t\t\t// Unmount the device\n\t\t\t// We don't want to remove the device since this isn't a hard eject.\n\t\t\t// We want the device to be detected if we start again.\n\t\t\tawait this.unmountExternalDevice(device.id, {remove: false}).catch((error) => {\n\t\t\t\t// Just log the error and continue to next device\n\t\t\t\tthis.logger.error(`Failed to unmount external device ${device.id}`, error)\n\t\t\t})\n\t\t}\n\t}\n\n\t// Clean left over mount points\n\tasync #cleanLeftOverMountPoints() {\n\t\t// Loop over all mount points in /External\n\t\tconst externalBaseSystemPath = this.#umbreld.files.getBaseDirectory('/External')\n\t\tconst mountPoints = await fse.readdir(externalBaseSystemPath)\n\t\tfor (const mountPoint of mountPoints) {\n\t\t\ttry {\n\t\t\t\t// Check if any are not currently used and safe to remove\n\t\t\t\tconst mountPointSystemPath = nodePath.join(externalBaseSystemPath, mountPoint)\n\t\t\t\tconst isMountPointEmpty = (await fse.readdir(mountPointSystemPath)).length === 0\n\t\t\t\tconst isMountPointUnmounted = (await $({reject: false})`mountpoint ${mountPointSystemPath}`).exitCode !== 0\n\t\t\t\tconst isSafeToRemove = isMountPointEmpty && isMountPointUnmounted\n\n\t\t\t\t// Remove the mount point if it's safe to do so\n\t\t\t\tif (isSafeToRemove) {\n\t\t\t\t\tthis.logger.log(`Cleaning up left over mount point ${mountPoint}`)\n\t\t\t\t\tawait fse.remove(mountPointSystemPath)\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\t// Just log the error and continue to next mount point\n\t\t\t\tthis.logger.error(`Failed to clean up left over mount point ${mountPoint}`, error)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Check if an external drive is connected on unsupported hardware\n\t// This is used to notify unsupported users why they can't see their hardware.\n\tasync isExternalDeviceConnectedOnUnsupportedDevice() {\n\t\tconst isSupported = await this.supported()\n\t\tlet externalBlockDevices = await this.#getExternalDevices()\n\n\t\t// Exclude any external disks that include the current data directory.\n\t\t// This prevents USB storage based Raspberry Pi's detecting their main\n\t\t// USB storage drive as a connected external drive.\n\t\tconst df = await $`df ${this.#umbreld.dataDirectory} --output=source`\n\t\tconst dataDirDisk = df.stdout.split('\\n').pop()?.split('/').pop()?.replace(/\\d+$/, '')\n\t\texternalBlockDevices = externalBlockDevices.filter((blockDevice) => blockDevice.id !== dataDirDisk)\n\n\t\treturn !isSupported && externalBlockDevices.length > 0\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/favorites.integration.test.ts",
    "content": "import {expect, beforeEach, afterEach, describe, test} from 'vitest'\n\nimport fse from 'fs-extra'\nimport {delay} from 'es-toolkit'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\n// Create a new umbreld instance for each test\nbeforeEach(async () => (umbreld = await createTestUmbreld({autoLogin: true})))\nafterEach(async () => await umbreld.cleanup())\n\ndescribe('favorites()', () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.favorites.query()).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('returns default favorites on first start', async () => {\n\t\tconst favorites = await umbreld.client.files.favorites.query()\n\t\texpect(favorites).toStrictEqual(['/Home/Downloads', '/Home/Documents', '/Home/Photos', '/Home/Videos'])\n\t})\n\n\ttest('only returns existing directories', async () => {\n\t\t// Create test directories\n\t\tconst testDirectory1 = `${umbreld.instance.dataDirectory}/home/favorites-existing-test1`\n\t\tconst testDirectory2 = `${umbreld.instance.dataDirectory}/home/favorites-existing-test2`\n\t\tawait fse.mkdir(testDirectory1)\n\t\tawait fse.mkdir(testDirectory2)\n\n\t\t// Add both directories to favorites\n\t\tawait umbreld.client.files.addFavorite.mutate({\n\t\t\tpath: '/Home/favorites-existing-test1',\n\t\t})\n\t\tawait umbreld.client.files.addFavorite.mutate({\n\t\t\tpath: '/Home/favorites-existing-test2',\n\t\t})\n\n\t\t// Delete one directory\n\t\tawait fse.remove(testDirectory1)\n\n\t\t// Verify only existing directory is returned in favorites\n\t\tconst favorites = await umbreld.client.files.favorites.query()\n\t\texpect(favorites).not.toContain('/Home/favorites-existing-test1')\n\t\texpect(favorites).toContain('/Home/favorites-existing-test2')\n\t})\n})\n\ndescribe('#handleFileChange()', () => {\n\ttest('automatically removes favorites when directory is deleted', async () => {\n\t\t// Create test directories\n\t\tconst testDirectoryToDelete = `${umbreld.instance.dataDirectory}/home/favorites-auto-remove-test`\n\t\tconst testDirectoryToKeep = `${umbreld.instance.dataDirectory}/home/favorites-keep-test`\n\t\tawait fse.mkdir(testDirectoryToDelete)\n\t\tawait fse.mkdir(testDirectoryToKeep)\n\n\t\t// Wait for the creation fs events to fire\n\t\tawait delay(100)\n\n\t\t// Add both directories to favorites\n\t\tawait umbreld.client.files.addFavorite.mutate({\n\t\t\tpath: '/Home/favorites-auto-remove-test',\n\t\t})\n\t\tawait umbreld.client.files.addFavorite.mutate({\n\t\t\tpath: '/Home/favorites-keep-test',\n\t\t})\n\n\t\t// Verify directories are in favorites\n\t\tlet favorites = await umbreld.client.files.favorites.query()\n\t\texpect(favorites).toContain('/Home/favorites-auto-remove-test')\n\t\texpect(favorites).toContain('/Home/favorites-keep-test')\n\n\t\t// Delete one directory\n\t\tawait fse.remove(testDirectoryToDelete)\n\n\t\t// Wait for watcher to process the deletion\n\t\tawait delay(100)\n\n\t\t// Verify deleted directory is removed from the store\n\t\t// but the kept directory remains\n\t\t// We check the store directly here because the RPC query auto\n\t\t// strips non-existent files from the result\n\t\tfavorites = await umbreld.instance.store.get('files.favorites')\n\t\texpect(favorites).not.toContain('/Home/favorites-auto-remove-test')\n\t\texpect(favorites).toContain('/Home/favorites-keep-test')\n\t})\n\n\ttest('automatically removes child favorites when parent directory is deleted', async () => {\n\t\t// Create test directories\n\t\tconst parentDirectory = `${umbreld.instance.dataDirectory}/home/parent-directory`\n\t\tconst childDirectory = `${parentDirectory}/child-directory`\n\t\tawait fse.mkdir(parentDirectory)\n\t\tawait fse.mkdir(childDirectory)\n\n\t\t// Wait for the creation fs events to fire\n\t\tawait delay(100)\n\n\t\t// Add child directory to favorites\n\t\tawait umbreld.client.files.addFavorite.mutate({\n\t\t\tpath: '/Home/parent-directory/child-directory',\n\t\t})\n\n\t\t// Verify directories are in favorites\n\t\tlet favorites = await umbreld.client.files.favorites.query()\n\t\texpect(favorites).toContain('/Home/parent-directory/child-directory')\n\n\t\t// Delete parent directory (which also removes the child)\n\t\tawait fse.remove(parentDirectory)\n\n\t\t// Wait for watcher to process the deletion\n\t\tawait delay(100)\n\n\t\t// Verify deleted directory is removed from the store\n\t\t// We check the store directly here because the RPC query auto\n\t\t// strips non-existent files from the result\n\t\tfavorites = await umbreld.instance.store.get('files.favorites')\n\t\texpect(favorites).not.toContain('/Home/parent-directory/child-directory')\n\t})\n})\n\ndescribe('addFavorite()', () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.addFavorite.mutate({path: '/Home/Documents'})).rejects.toThrow(\n\t\t\t'Invalid token',\n\t\t)\n\t})\n\n\ttest('throws on non-directory paths', async () => {\n\t\t// Create test file\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/favorites-test`\n\t\tawait fse.mkdir(testDirectory)\n\t\tawait fse.writeFile(`${testDirectory}/file.txt`, 'test content')\n\n\t\t// Attempt to favorite a file\n\t\tawait expect(\n\t\t\tumbreld.client.files.addFavorite.mutate({\n\t\t\t\tpath: '/Home/favorites-test/file.txt',\n\t\t\t}),\n\t\t).rejects.toThrow('[operation-not-allowed]')\n\t})\n\n\ttest('successfully adds a directory to favorites', async () => {\n\t\t// Create test directory\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/favorites-test`\n\t\tawait fse.mkdir(testDirectory)\n\n\t\t// Add directory to favorites\n\t\tawait expect(\n\t\t\tumbreld.client.files.addFavorite.mutate({\n\t\t\t\tpath: '/Home/favorites-test',\n\t\t\t}),\n\t\t).resolves.toBe(true)\n\n\t\t// Verify directory is in favorites\n\t\tconst favorites = await umbreld.client.files.favorites.query()\n\t\texpect(favorites).toContain('/Home/favorites-test')\n\t})\n\n\ttest('ignores duplicate favorites', async () => {\n\t\t// Create test directory\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/favorites-duplicate-test`\n\t\tawait fse.mkdir(testDirectory)\n\n\t\t// Add directory to favorites twice\n\t\tawait umbreld.client.files.addFavorite.mutate({\n\t\t\tpath: '/Home/favorites-duplicate-test',\n\t\t})\n\t\tawait expect(\n\t\t\tumbreld.client.files.addFavorite.mutate({\n\t\t\t\tpath: '/Home/favorites-duplicate-test',\n\t\t\t}),\n\t\t).resolves.toBe(true)\n\n\t\t// Verify directory appears only once in favorites\n\t\tconst favorites = await umbreld.client.files.favorites.query()\n\t\tconst count = favorites.filter((f) => f === '/Home/favorites-duplicate-test').length\n\t\texpect(count).toBe(1)\n\t})\n})\n\ndescribe('removeFavorite()', () => {\n\ttest('successfully removes a directory from favorites', async () => {\n\t\t// Create test directory\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/favorites-remove-test`\n\t\tawait fse.mkdir(testDirectory)\n\n\t\t// Add directory to favorites\n\t\tawait umbreld.client.files.addFavorite.mutate({\n\t\t\tpath: '/Home/favorites-remove-test',\n\t\t})\n\n\t\t// Remove from favorites\n\t\tawait expect(\n\t\t\tumbreld.client.files.removeFavorite.mutate({\n\t\t\t\tpath: '/Home/favorites-remove-test',\n\t\t\t}),\n\t\t).resolves.toBe(true)\n\n\t\t// Verify directory is not in favorites\n\t\tconst favorites = await umbreld.client.files.favorites.query()\n\t\texpect(favorites).not.toContain('/Home/favorites-remove-test')\n\t})\n\n\ttest('returns false when removing non-existent favorite', async () => {\n\t\tawait expect(\n\t\t\tumbreld.client.files.removeFavorite.mutate({\n\t\t\t\tpath: '/Home/non-existent-favorite',\n\t\t\t}),\n\t\t).resolves.toBe(false)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/favorites.ts",
    "content": "import type Umbreld from '../../index.js'\n\nimport type {FileChangeEvent} from './watcher.js'\n\nexport default class Favorites {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\t#removeFileChangeListener?: () => void\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(`files:${name.toLocaleLowerCase()}`)\n\t}\n\n\t// Add listener\n\tasync start() {\n\t\tthis.logger.log('Starting favorites')\n\n\t\t// Attach listener\n\t\tthis.#removeFileChangeListener = this.#umbreld.eventBus.on(\n\t\t\t'files:watcher:change',\n\t\t\tthis.#handleFileChange.bind(this),\n\t\t)\n\t}\n\n\t// Get favorites\n\tasync #get() {\n\t\tconst favorites = await this.#umbreld.store.get('files.favorites')\n\t\treturn favorites || []\n\t}\n\n\t// Remove favorites on deletion\n\t// TODO: It would be nice if we could handle updating favorites when the favorited directory is\n\t// moved/renamed. It's not trivial because this can happen via something external like an app or SMB\n\t// and there's no way to tell the difference between a move/rename and a deletion/recreation.\n\tasync #handleFileChange(event: FileChangeEvent) {\n\t\tif (event.type !== 'delete') return\n\t\tconst favorites = await this.#get()\n\t\tconst virtualDeletedPath = this.#umbreld.files.systemToVirtualPath(event.path)\n\t\tconst deletedFavorites = favorites.filter((favorite) => favorite.startsWith(virtualDeletedPath))\n\t\tfor (const favorite of deletedFavorites) await this.removeFavorite(favorite)\n\t}\n\n\t// List favorited directories\n\tasync listFavorites() {\n\t\t// Get favorites from the store\n\t\tconst favorites = await this.#get()\n\n\t\t// Strip out any favorites that aren't existing directories\n\t\tconst mappedFavorites = await Promise.all(\n\t\t\tfavorites.map(async (favorite) => {\n\t\t\t\tconst systemPath = await this.#umbreld.files.virtualToSystemPath(favorite)\n\t\t\t\tconst file = await this.#umbreld.files.status(systemPath).catch(() => undefined)\n\t\t\t\tif (file?.type !== 'directory') return undefined\n\t\t\t\treturn favorite\n\t\t\t}),\n\t\t)\n\t\tconst filteredFavorites = mappedFavorites.filter((favorite) => favorite !== undefined)\n\n\t\treturn filteredFavorites\n\t}\n\n\t// Save a favorite directory\n\tasync addFavorite(virtualPath: string) {\n\t\t// Check operation is allowed\n\t\tconst allowedOperations = await this.#umbreld.files.getAllowedOperations(virtualPath)\n\t\tif (!allowedOperations.includes('favorite')) throw new Error('[operation-not-allowed]')\n\n\t\t// Save entry in the store\n\t\tawait this.#umbreld.store.getWriteLock(async ({get, set}) => {\n\t\t\tconst favorites = await this.#get()\n\t\t\tlet favorite = favorites.find((favorite) => favorite === virtualPath)\n\t\t\tif (favorite) return\n\t\t\tfavorites.push(virtualPath)\n\t\t\tawait set('files.favorites', favorites)\n\t\t})\n\n\t\treturn true\n\t}\n\n\t// Remove a favorite directory\n\tasync removeFavorite(virtualPath: string) {\n\t\tlet deleted = false\n\t\tawait this.#umbreld.store.getWriteLock(async ({get, set}) => {\n\t\t\tconst favorites = await this.#get()\n\t\t\tconst newFavorites = favorites.filter((favorite) => favorite !== virtualPath)\n\t\t\tdeleted = newFavorites.length < favorites.length\n\t\t\tif (deleted) await set('files.favorites', newFavorites)\n\t\t})\n\t\treturn deleted\n\t}\n\n\t// Remove listener\n\tasync stop() {\n\t\tthis.logger.log('Stopping favorites')\n\t\tthis.#removeFileChangeListener?.()\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/files-reflink-copy.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\n\ndescribe('Reflink copy on ZFS', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds NVMe device and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('sets up RAID storage mode', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(1)\n\t\tconst deviceId = devices[0].id!\n\t\texpect(deviceId).toBeDefined()\n\t\tawait umbreld.signup({raidDevices: [deviceId], raidType: 'storage'})\n\t})\n\n\ttest('waits for RAID setup to complete and logs in', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch (error) {\n\t\t\t\t\tif (error instanceof Error && error.message.includes('fetch failed')) {\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t\tthrow error\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 2000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('copies a file using reflink (block cloning) instead of rsync', async () => {\n\t\tconst testFileSizeMb = 100\n\n\t\t// Create a 100MB test file on the ZFS filesystem\n\t\tawait umbreld.vm.ssh(`dd if=/dev/urandom of=~/umbrel/home/test-file.bin bs=1M count=${testFileSizeMb} 2>/dev/null`)\n\t\tawait umbreld.vm.ssh('sync')\n\n\t\t// Get block clone savings before copy\n\t\tconst savedBefore = Number((await umbreld.vm.ssh('zpool get -Hp -o value bclonesaved')).trim())\n\n\t\t// Copy the file via the API\n\t\tawait umbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/test-file.bin',\n\t\t\ttoDirectory: '/Home',\n\t\t\tcollision: 'keep-both',\n\t\t})\n\t\tawait umbreld.vm.ssh('sync')\n\n\t\t// Verify the copy exists and has the correct content\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'test-file (2).bin')).toBe(true)\n\t\tconst sourceHash = (await umbreld.vm.ssh('md5sum ~/umbrel/home/test-file.bin')).trim().split(/\\s+/)[0]\n\t\tconst copyHash = (await umbreld.vm.ssh('md5sum ~/umbrel/home/\"test-file (2).bin\"')).trim().split(/\\s+/)[0]\n\t\texpect(copyHash).toBe(sourceHash)\n\n\t\t// Verify block cloning was used by checking bclonesaved increased\n\t\t// If rsync was used instead of reflink, bclonesaved would not change\n\t\tconst savedAfter = Number((await umbreld.vm.ssh('zpool get -Hp -o value bclonesaved')).trim())\n\t\tconst savedMb = (savedAfter - savedBefore) / (1024 * 1024)\n\t\texpect(savedMb).toBeGreaterThan(testFileSizeMb / 2)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/files.copy.integration.test.ts",
    "content": "import {expect, beforeAll, afterAll, test, vi} from 'vitest'\nimport {$} from 'execa'\nimport fse from 'fs-extra'\n\nlet getDiskUsageByPathReturnValue = null as any\nvi.mock('../system/system.js', async (importOriginal) => {\n\tconst original = (await importOriginal()) as any\n\n\treturn {\n\t\t...original,\n\t\tgetDiskUsageByPath: async (umbreld: any) => getDiskUsageByPathReturnValue ?? original.getDiskUsageByPath(umbreld),\n\t}\n})\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\ntest('copy() throws invalid error without auth token', async () => {\n\tawait expect(\n\t\tumbreld.unauthenticatedClient.files.copy.mutate({path: '/Home/Documents', toDirectory: '/Home/Documents-copy'}),\n\t).rejects.toThrow('Invalid token')\n})\n\ntest('copy() throws on directory traversal attempt in source path', async () => {\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/../../../../etc',\n\t\t\ttoDirectory: '/Home',\n\t\t}),\n\t).rejects.toThrow('[invalid-base]')\n})\n\ntest('copy() throws on directory traversal attempt in destination path', async () => {\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home',\n\t\t\ttoDirectory: '/Home/../../../../etc',\n\t\t}),\n\t).rejects.toThrow('[invalid-base]')\n})\n\ntest('copy() throws on symlink traversal attempt in source path', async () => {\n\t// Create a symlink to the root directory\n\tawait $`ln -s / ${umbreld.instance.dataDirectory}/home/symlink-to-root`\n\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/symlink-to-root/etc',\n\t\t\ttoDirectory: '/Home',\n\t\t}),\n\t).rejects.toThrow('[escapes-base]')\n\n\t// Clean up\n\tawait fse.remove(`${umbreld.instance.dataDirectory}/home/symlink-to-root`)\n})\n\ntest('copy() throws on symlink traversal attempt in destination path', async () => {\n\t// Create a symlink to the root directory\n\tawait $`ln -s / ${umbreld.instance.dataDirectory}/home/symlink-to-root`\n\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home',\n\t\t\ttoDirectory: '/Home/symlink-to-root/etc',\n\t\t}),\n\t).rejects.toThrow('[escapes-base]')\n\n\t// Clean up\n\tawait fse.remove(`${umbreld.instance.dataDirectory}/home/symlink-to-root`)\n})\n\ntest('copy() throws on relative paths', async () => {\n\tawait Promise.all(\n\t\t['', ' ', '.', '..', 'Home', 'Home/..', 'Home/Documents'].map(async (path) => {\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.copy.mutate({\n\t\t\t\t\tpath,\n\t\t\t\t\ttoDirectory: '/Home/Documents',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[path-not-absolute]')\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.copy.mutate({\n\t\t\t\t\tpath: '/Home/Documents',\n\t\t\t\t\ttoDirectory: path,\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[path-not-absolute]')\n\t\t}),\n\t)\n})\n\ntest('copy() throws on non existent source path', async () => {\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/DoesNotExist',\n\t\t\ttoDirectory: '/Home',\n\t\t}),\n\t).rejects.toThrow('[invalid-base]')\n})\n\ntest('copy() throws on non existent destination path', async () => {\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home',\n\t\t\ttoDirectory: '/Home/DoesNotExist',\n\t\t}),\n\t).rejects.toThrow('[destination-not-exist]')\n})\n\ntest('copy() throws copying to self', async () => {\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home',\n\t\t\ttoDirectory: '/Home',\n\t\t}),\n\t).rejects.toThrow('[subdir-of-self]')\n})\n\ntest('copy() throws copying to subdir of self', async () => {\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home',\n\t\t\ttoDirectory: '/Home/Documents',\n\t\t}),\n\t).rejects.toThrow('[subdir-of-self]')\n})\n\ntest('copy() throws if there is not enough free space on the destination', async () => {\n\t// Check copy works as expected when there is enough space\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/Documents',\n\t\t\ttoDirectory: '/Home',\n\t\t\tcollision: 'keep-both',\n\t\t}),\n\t).resolves.toBeTruthy()\n\n\t// Set up mock to return insufficient free space\n\tconst ONE_GB = 1024 * 1024 * 1024\n\tgetDiskUsageByPathReturnValue = {\n\t\tsize: ONE_GB * 10,\n\t\ttotalUsed: ONE_GB * 10,\n\t\tavailable: 0,\n\t}\n\n\t// Check copy fails when there is not enough free space\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/Documents',\n\t\t\ttoDirectory: '/Home',\n\t\t\tcollision: 'keep-both',\n\t\t}),\n\t).rejects.toThrow('[not-enough-space]')\n\n\t// Reset mock\n\tgetDiskUsageByPathReturnValue = null\n\n\t// Check copy works as expected when there is enough space again\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/Documents',\n\t\t\ttoDirectory: '/Home',\n\t\t\tcollision: 'keep-both',\n\t\t}),\n\t).resolves.toBeTruthy()\n})\n\ntest('copy() copies a single file to a directory', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-file-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/source`)\n\tawait fse.writeFile(`${testDirectory}/source/source.txt`, '')\n\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t// Verify the directory is empty\n\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject([])\n\n\t// Copy the file\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-file-test/source/source.txt',\n\t\t\ttoDirectory: '/Home/copy-file-test/destination',\n\t\t}),\n\t).resolves.toBe('/Home/copy-file-test/destination/source.txt')\n\n\t// Verify the copy\n\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject(['source.txt'])\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy() copies a single file to a directory with a trailing slash', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-file-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/source`)\n\tawait fse.writeFile(`${testDirectory}/source/source.txt`, '')\n\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t// Verify the directory is empty\n\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject([])\n\n\t// Copy the file\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-file-test/source/source.txt',\n\t\t\ttoDirectory: '/Home/copy-file-test/destination/',\n\t\t}),\n\t).resolves.toBe('/Home/copy-file-test/destination/source.txt')\n\n\t// Verify the copy\n\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject(['source.txt'])\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy() handles copying files to same directory by appending numbers regardless of collision strategy', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-same-dir-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.writeFile(`${testDirectory}/original.txt`, '')\n\n\t// Copy the file to the same directory with default collision strategy\n\t// In same directory, this should still append numbers even though default is 'error'\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-same-dir-test/original.txt',\n\t\t\ttoDirectory: '/Home/copy-same-dir-test',\n\t\t}),\n\t).resolves.toBe('/Home/copy-same-dir-test/original (2).txt')\n\n\t// Verify both files exist\n\tawait expect(fse.pathExists(`${testDirectory}/original.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/original (2).txt`)).resolves.toBe(true)\n\n\t// Try with explicit 'replace' collision strategy - should still append numbers\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-same-dir-test/original.txt',\n\t\t\ttoDirectory: '/Home/copy-same-dir-test',\n\t\t\tcollision: 'replace',\n\t\t}),\n\t).resolves.toBe('/Home/copy-same-dir-test/original (3).txt')\n\n\t// Verify all files exist\n\tawait expect(fse.pathExists(`${testDirectory}/original.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/original (2).txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/original (3).txt`)).resolves.toBe(true)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy() handles copying files to different directories by throwing on name conflict by default', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-conflict-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/source`)\n\tawait fse.writeFile(`${testDirectory}/source/file.txt`, '')\n\n\t// Create a destination file with the same name\n\tawait fse.mkdir(`${testDirectory}/destination`)\n\tawait fse.writeFile(`${testDirectory}/destination/file.txt`, '')\n\n\t// Try to copy the file and verify that it fails with default 'error' collision strategy\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-conflict-test/source/file.txt',\n\t\t\ttoDirectory: '/Home/copy-conflict-test/destination',\n\t\t}),\n\t).rejects.toThrow('[destination-already-exists]')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy(path, {collision: \"keep-both\"}) keeps both files by appending numbers', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-keep-both-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/source`)\n\tawait fse.writeFile(`${testDirectory}/source/file.txt`, 'source content')\n\tawait fse.mkdir(`${testDirectory}/destination`)\n\tawait fse.writeFile(`${testDirectory}/destination/file.txt`, 'destination content')\n\n\t// Copy the file with 'keep-both' collision strategy\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-keep-both-test/source/file.txt',\n\t\t\ttoDirectory: '/Home/copy-keep-both-test/destination',\n\t\t\tcollision: 'keep-both',\n\t\t}),\n\t).resolves.toBe('/Home/copy-keep-both-test/destination/file (2).txt')\n\n\t// Verify both files exist at the destination\n\tawait expect(fse.pathExists(`${testDirectory}/destination/file.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/destination/file (2).txt`)).resolves.toBe(true)\n\n\t// Verify the contents are preserved\n\tawait expect(fse.readFile(`${testDirectory}/destination/file.txt`, 'utf8')).resolves.toBe('destination content')\n\tawait expect(fse.readFile(`${testDirectory}/destination/file (2).txt`, 'utf8')).resolves.toBe('source content')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy(path, {collision: \"replace\"}) replaces existing files', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-replace-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/source`)\n\tawait fse.writeFile(`${testDirectory}/source/file.txt`, 'source content')\n\tawait fse.mkdir(`${testDirectory}/destination`)\n\tawait fse.writeFile(`${testDirectory}/destination/file.txt`, 'destination content')\n\n\t// Copy the file with 'replace' collision strategy\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-replace-test/source/file.txt',\n\t\t\ttoDirectory: '/Home/copy-replace-test/destination',\n\t\t\tcollision: 'replace',\n\t\t}),\n\t).resolves.toBe('/Home/copy-replace-test/destination/file.txt')\n\n\t// Verify the file exists at the destination\n\tawait expect(fse.pathExists(`${testDirectory}/destination/file.txt`)).resolves.toBe(true)\n\n\t// Verify the content is replaced\n\tawait expect(fse.readFile(`${testDirectory}/destination/file.txt`, 'utf8')).resolves.toBe('source content')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy() copies a directory with contents', async () => {\n\t// Create test directory structure\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-directory-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/source`)\n\tawait fse.writeFile(`${testDirectory}/source/file1.txt`, 'content1')\n\tawait fse.writeFile(`${testDirectory}/source/file2.txt`, 'content2')\n\tawait fse.mkdir(`${testDirectory}/source/subdir`)\n\tawait fse.writeFile(`${testDirectory}/source/subdir/file3.txt`, 'content3')\n\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t// Verify the directory is empty\n\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject([])\n\n\t// Copy the directory\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-directory-test/source',\n\t\t\ttoDirectory: '/Home/copy-directory-test/destination',\n\t\t}),\n\t).resolves.toBe('/Home/copy-directory-test/destination/source')\n\n\t// Verify the copy\n\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject(['source'])\n\tawait expect(fse.readdir(`${testDirectory}/destination/source`)).resolves.toMatchObject([\n\t\t'file1.txt',\n\t\t'file2.txt',\n\t\t'subdir',\n\t])\n\tawait expect(fse.readdir(`${testDirectory}/destination/source/subdir`)).resolves.toMatchObject(['file3.txt'])\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy() copies a directory with contents with a trailing slash', async () => {\n\t// Create test directory structure\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-directory-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/source`)\n\tawait fse.writeFile(`${testDirectory}/source/file1.txt`, 'content1')\n\tawait fse.writeFile(`${testDirectory}/source/file2.txt`, 'content2')\n\tawait fse.mkdir(`${testDirectory}/source/subdir`)\n\tawait fse.writeFile(`${testDirectory}/source/subdir/file3.txt`, 'content3')\n\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t// Verify the directory is empty\n\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject([])\n\n\t// Copy the directory\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-directory-test/source',\n\t\t\ttoDirectory: '/Home/copy-directory-test/destination/',\n\t\t}),\n\t).resolves.toBe('/Home/copy-directory-test/destination/source')\n\n\t// Verify the copy\n\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject(['source'])\n\tawait expect(fse.readdir(`${testDirectory}/destination/source`)).resolves.toMatchObject([\n\t\t'file1.txt',\n\t\t'file2.txt',\n\t\t'subdir',\n\t])\n\tawait expect(fse.readdir(`${testDirectory}/destination/source/subdir`)).resolves.toMatchObject(['file3.txt'])\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy() handles copying directories to same parent directory by appending numbers regardless of collision strategy', async () => {\n\t// Create test directory with subdirectory\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-same-dir-test-directory`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/original`)\n\tawait fse.writeFile(`${testDirectory}/original/file.txt`, 'content')\n\n\t// Copy the directory to the same parent directory with default collision strategy\n\t// In same directory, this should still append numbers even though default is 'error'\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-same-dir-test-directory/original',\n\t\t\ttoDirectory: '/Home/copy-same-dir-test-directory',\n\t\t}),\n\t).resolves.toBe('/Home/copy-same-dir-test-directory/original (2)')\n\n\t// Verify both directories exist with their contents\n\tawait expect(fse.pathExists(`${testDirectory}/original`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/original/file.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/original (2)`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/original (2)/file.txt`)).resolves.toBe(true)\n\n\t// Try with explicit 'replace' collision strategy - should still append numbers\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-same-dir-test-directory/original',\n\t\t\ttoDirectory: '/Home/copy-same-dir-test-directory',\n\t\t\tcollision: 'replace',\n\t\t}),\n\t).resolves.toBe('/Home/copy-same-dir-test-directory/original (3)')\n\n\t// Verify all directories exist with their contents\n\tawait expect(fse.pathExists(`${testDirectory}/original`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/original/file.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/original (2)`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/original (2)/file.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/original (3)`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/original (3)/file.txt`)).resolves.toBe(true)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy() handles copying directories to a different directory by throwing on name conflict by default', async () => {\n\t// Create test directory structure\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-dir-conflict-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/source`)\n\tawait fse.writeFile(`${testDirectory}/source/file.txt`, 'source content')\n\n\t// Create a destination directory with the same name\n\tawait fse.mkdir(`${testDirectory}/destination`)\n\tawait fse.mkdir(`${testDirectory}/destination/source`)\n\tawait fse.writeFile(`${testDirectory}/destination/source/file.txt`, 'destination content')\n\n\t// Try to copy the directory and verify that it fails with default 'error' collision strategy\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-dir-conflict-test/source',\n\t\t\ttoDirectory: '/Home/copy-dir-conflict-test/destination',\n\t\t}),\n\t).rejects.toThrow('[destination-already-exists]')\n\n\t// Verify destination content remains unchanged\n\tawait expect(fse.readFile(`${testDirectory}/destination/source/file.txt`, 'utf8')).resolves.toBe(\n\t\t'destination content',\n\t)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy(path, {collision: \"keep-both\"}) keeps both directories by appending numbers', async () => {\n\t// Create test directory structure\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-dir-keep-both-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/docs`)\n\tawait fse.writeFile(`${testDirectory}/docs/file.txt`, 'source content')\n\tawait fse.mkdir(`${testDirectory}/destination`)\n\tawait fse.mkdir(`${testDirectory}/destination/docs`)\n\tawait fse.writeFile(`${testDirectory}/destination/docs/file.txt`, 'destination content')\n\n\t// Copy the directory with 'keep-both' collision strategy\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-dir-keep-both-test/docs',\n\t\t\ttoDirectory: '/Home/copy-dir-keep-both-test/destination',\n\t\t\tcollision: 'keep-both',\n\t\t}),\n\t).resolves.toBe('/Home/copy-dir-keep-both-test/destination/docs (2)')\n\n\t// Verify both directories exist at the destination\n\tawait expect(fse.pathExists(`${testDirectory}/destination/docs`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/destination/docs (2)`)).resolves.toBe(true)\n\n\t// Verify the contents are preserved in both directories\n\tawait expect(fse.readFile(`${testDirectory}/destination/docs/file.txt`, 'utf8')).resolves.toBe('destination content')\n\tawait expect(fse.readFile(`${testDirectory}/destination/docs (2)/file.txt`, 'utf8')).resolves.toBe('source content')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy(path, {collision: \"replace\"}) completely replaces existing directories', async () => {\n\t// Create test directory structure\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-dir-replace-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/docs`)\n\tawait fse.writeFile(`${testDirectory}/docs/file1.txt`, 'file 1 source content')\n\tawait fse.writeFile(`${testDirectory}/docs/file2.txt`, 'file 2 source content')\n\tawait fse.mkdir(`${testDirectory}/destination`)\n\tawait fse.mkdir(`${testDirectory}/destination/docs`)\n\tawait fse.writeFile(`${testDirectory}/destination/docs/file1.txt`, 'file 1 destination content')\n\tawait fse.writeFile(`${testDirectory}/destination/docs/file3.txt`, 'file 3 destination content')\n\n\t// Copy the directory with 'replace' collision strategy\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-dir-replace-test/docs',\n\t\t\ttoDirectory: '/Home/copy-dir-replace-test/destination',\n\t\t\tcollision: 'replace',\n\t\t}),\n\t).resolves.toBe('/Home/copy-dir-replace-test/destination/docs')\n\n\t// Verify the directory exists at the destination\n\tawait expect(fse.pathExists(`${testDirectory}/destination/docs`)).resolves.toBe(true)\n\n\t// Verify source content is now at the destination\n\tawait expect(fse.readFile(`${testDirectory}/destination/docs/file1.txt`, 'utf8')).resolves.toBe(\n\t\t'file 1 source content',\n\t)\n\tawait expect(fse.readFile(`${testDirectory}/destination/docs/file2.txt`, 'utf8')).resolves.toBe(\n\t\t'file 2 source content',\n\t)\n\n\t// Verify file3.txt no longer exists (since it was only in the destination)\n\tawait expect(fse.pathExists(`${testDirectory}/destination/docs/file3.txt`)).resolves.toBe(false)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy() throws on too many duplicate names from existing paths', async () => {\n\t// Create test directory and files\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-existing-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.writeFile(`${testDirectory}/source.txt`, '')\n\n\t// Copy the file to create the maximum number of copies\n\tconst maxPossibleCopies = 100\n\tfor (let i = 2; i <= maxPossibleCopies; i++) {\n\t\tawait expect(\n\t\t\tumbreld.client.files.copy.mutate({\n\t\t\t\tpath: '/Home/copy-existing-test/source.txt',\n\t\t\t\ttoDirectory: '/Home/copy-existing-test',\n\t\t\t}),\n\t\t).resolves.toBe(`/Home/copy-existing-test/source (${i}).txt`)\n\t}\n\n\t// Verify the copies\n\tawait expect(fse.readdir(`${testDirectory}`)).resolves.toMatchObject(\n\t\texpect.arrayContaining([\n\t\t\t'source.txt',\n\t\t\t...Array.from({length: maxPossibleCopies - 2}).map((_, index) => `source (${index + 2}).txt`),\n\t\t]),\n\t)\n\n\t// Check creating one more fails\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-existing-test/source.txt',\n\t\t\ttoDirectory: `/Home/copy-existing-test`,\n\t\t}),\n\t).rejects.toThrow('[unique-name-index-exceeded]')\n})\n\ntest('copy() copies symlinks as symlinks', async () => {\n\t// Create test directory and files\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-symlink-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/source`)\n\tawait fse.writeFile(`${testDirectory}/source/file.txt`, 'content')\n\tawait fse.symlink(`${testDirectory}/source/file.txt`, `${testDirectory}/source/link`)\n\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t// Copy the symlink\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-symlink-test/source/link',\n\t\t\ttoDirectory: '/Home/copy-symlink-test/destination',\n\t\t}),\n\t).resolves.toBe('/Home/copy-symlink-test/destination/link')\n\n\t// Verify the symlink was copied correctly\n\tconst isSymlink = await fse.lstat(`${testDirectory}/destination/link`).then((stats) => stats.isSymbolicLink())\n\texpect(isSymlink).toBe(true)\n\n\t// Verify the symlink points to the correct target\n\tconst linkTarget = await fse.readlink(`${testDirectory}/destination/link`)\n\texpect(linkTarget).toBe(`${testDirectory}/source/file.txt`)\n\n\t// Verify reading through the symlink works\n\tconst content = await fse.readFile(`${testDirectory}/destination/link`, 'utf8')\n\texpect(content).toBe('content')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy() copies files inside a symlink', async () => {\n\t// Create test directory and files\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-symlink-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/source`)\n\tawait fse.writeFile(`${testDirectory}/source/file.txt`, 'content')\n\tawait fse.symlink(`${testDirectory}/source`, `${testDirectory}/symlink`)\n\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t// Copy the file\n\tawait expect(\n\t\tumbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/copy-symlink-test/symlink/file.txt',\n\t\t\ttoDirectory: '/Home/copy-symlink-test/destination',\n\t\t}),\n\t).resolves.toBe('/Home/copy-symlink-test/destination/file.txt')\n\n\t// Verify the copy\n\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject(['file.txt'])\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('copy() preserves file permissions, ownership and timestamps', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/copy-permissions-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/source`)\n\tconst sourceFile = `${testDirectory}/source/file.txt`\n\tawait fse.writeFile(sourceFile, 'test content')\n\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t// Set specific permissions (0o644 = rw-r--r--) and timestamps\n\tconst originalPermissions = 0o644\n\tawait fse.chmod(sourceFile, originalPermissions)\n\n\t// Set specific ownership (use umbrel user ID from files class)\n\tconst uid = 1234\n\tconst gid = 1234\n\tawait fse.chown(sourceFile, uid, gid)\n\n\t// Set a specific timestamp (1 day ago)\n\tconst pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000)\n\tawait fse.utimes(sourceFile, pastDate, pastDate)\n\n\t// Get original stats for later comparison\n\tconst originalStats = await fse.stat(sourceFile)\n\n\t// Copy the file\n\tconst result = await umbreld.client.files.copy.mutate({\n\t\tpath: '/Home/copy-permissions-test/source/file.txt',\n\t\ttoDirectory: '/Home/copy-permissions-test/destination',\n\t})\n\texpect(result).toBe('/Home/copy-permissions-test/destination/file.txt')\n\n\t// Get stats of the copied file\n\tconst copiedFile = `${testDirectory}/destination/file.txt`\n\tconst copiedStats = await fse.stat(copiedFile)\n\n\t// Verify the permissions are preserved\n\texpect(copiedStats.mode).toBe(originalStats.mode)\n\n\t// Verify ownership is preserved\n\texpect(copiedStats.uid).toBe(originalStats.uid)\n\texpect(copiedStats.gid).toBe(originalStats.gid)\n\n\t// Verify the timestamps are preserved\n\texpect(copiedStats.mtime.getTime()).toBe(originalStats.mtime.getTime())\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/files.createDirectory.integration.test.ts",
    "content": "import {expect, beforeAll, afterAll, test} from 'vitest'\nimport {$} from 'execa'\nimport fse from 'fs-extra'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\ntest('createDirectory() throws invalid error without auth token', async () => {\n\tawait expect(\n\t\tumbreld.unauthenticatedClient.files.createDirectory.mutate({path: '/Home/new-directory'}),\n\t).rejects.toThrow('Invalid token')\n})\n\ntest('createDirectory() throws on directory traversal attempt', async () => {\n\tawait expect(umbreld.client.files.createDirectory.mutate({path: '/Home/../../../../etc/new-dir'})).rejects.toThrow(\n\t\t'[invalid-base]',\n\t)\n})\n\ntest('createDirectory() throws on symlink traversal attempt', async () => {\n\t// Create a symlink to the root directory\n\tawait $`ln -s / ${umbreld.instance.dataDirectory}/home/symlink-to-root`\n\n\t// Attempt to create directory through symlink\n\tawait expect(umbreld.client.files.createDirectory.mutate({path: '/Home/symlink-to-root/new-dir'})).rejects.toThrow(\n\t\t'[escapes-base]',\n\t)\n\n\t// Clean up\n\tawait fse.remove(umbreld.instance.dataDirectory + '/home/symlink-to-root')\n})\n\ntest('createDirectory() throws on relative paths', async () => {\n\tawait Promise.all(\n\t\t['', ' ', '.', '..', 'Home', 'Home/new-dir', 'Home/../new-dir'].map((path) =>\n\t\t\texpect(umbreld.client.files.createDirectory.mutate({path})).rejects.toThrow('[path-not-absolute]'),\n\t\t),\n\t)\n})\n\ntest('createDirectory() throws on invalid base directory', async () => {\n\tawait expect(umbreld.client.files.createDirectory.mutate({path: '/Invalid/test-directory'})).rejects.toThrow(\n\t\t'[invalid-base]',\n\t)\n})\n\ntest(\"createDirectory() throws when containing directory doesn't exist\", async () => {\n\tconst path = '/Home/parent/child/grandchild'\n\n\t// Create nested directories\n\tawait expect(umbreld.client.files.createDirectory.mutate({path})).rejects.toThrow('[parent-not-exist]')\n})\n\ntest('createDirectory() throws when creating directory inside a file', async () => {\n\tconst path = '/Home/file.txt/new-dir'\n\n\t// Create file\n\tawait fse.writeFile(umbreld.instance.dataDirectory + '/home/file.txt', 'test')\n\n\t// Create nested directories\n\tawait expect(umbreld.client.files.createDirectory.mutate({path})).rejects.toThrow('[parent-not-directory]')\n})\n\ntest('createDirectory() creates directory in /Home', async () => {\n\tconst path = '/Home/test-directory'\n\n\t// Create directory\n\tawait expect(umbreld.client.files.createDirectory.mutate({path})).resolves.toBe(true)\n\n\t// Verify directory exists\n\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\texpect(listing.files).toContainEqual(\n\t\texpect.objectContaining({\n\t\t\tname: 'test-directory',\n\t\t\tpath,\n\t\t\ttype: 'directory',\n\t\t}),\n\t)\n\n\t// Clean up\n\tawait fse.remove(umbreld.instance.dataDirectory + '/home/test-directory')\n})\n\ntest('createDirectory() returns true for existing directories', async () => {\n\tconst path = '/Home/existing-directory'\n\n\t// Create directory first time\n\tawait umbreld.client.files.createDirectory.mutate({path})\n\n\t// Try creating same directory again\n\tawait expect(umbreld.client.files.createDirectory.mutate({path})).resolves.toBe(true)\n\n\t// Clean up\n\tawait fse.remove(umbreld.instance.dataDirectory + '/home/existing-directory')\n})\n\ntest('createDirectory() creates directory with correct permissions', async () => {\n\tconst path = '/Home/permissions-test'\n\n\t// Create directory\n\tawait umbreld.client.files.createDirectory.mutate({path})\n\n\t// Check permissions\n\tconst stats = await fse.stat(umbreld.instance.dataDirectory + '/home/permissions-test')\n\texpect(stats.uid).toBe(1000) // Check owner is umbrel user\n\texpect(stats.gid).toBe(1000) // Check group is umbrel group\n\n\t// Clean up\n\tawait fse.remove(umbreld.instance.dataDirectory + '/home/permissions-test')\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/files.delete.test.ts",
    "content": "import {expect, beforeAll, afterAll, afterEach, test, vi} from 'vitest'\nimport fse from 'fs-extra'\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\nafterEach(async () => {\n\t// Restore any mocks\n\tvi.restoreAllMocks()\n\n\t// Clean up any test files that might have been created\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst trashMetaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\tfor (const file of await fse.readdir(trashDir)) await fse.remove(`${trashDir}/${file}`)\n\tfor (const file of await fse.readdir(trashMetaDir)) await fse.remove(`${trashMetaDir}/${file}`)\n})\n\ntest('delete() throws invalid error without auth token', async () => {\n\tawait expect(umbreld.unauthenticatedClient.files.delete.mutate({path: '/Trash/test-file.txt'})).rejects.toThrow(\n\t\t'Invalid token',\n\t)\n})\n\ntest('delete() successfully deletes a file in trash', async () => {\n\t// Create a file in trash\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst metaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\tawait fse.writeFile(`${trashDir}/test-file.txt`, 'test content')\n\tawait fse.writeFile(`${metaDir}/test-file.txt.json`, JSON.stringify({path: '/Home/test-file.txt'}))\n\n\t// Verify the file exists in trash\n\tawait expect(fse.pathExists(`${trashDir}/test-file.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${metaDir}/test-file.txt.json`)).resolves.toBe(true)\n\n\t// Delete the file\n\tawait umbreld.client.files.delete.mutate({path: '/Trash/test-file.txt'})\n\n\t// Verify the file is deleted\n\tawait expect(fse.pathExists(`${trashDir}/test-file.txt`)).resolves.toBe(false)\n\t// Note: The metadata file is not automatically deleted by the delete operation\n\tawait expect(fse.pathExists(`${metaDir}/test-file.txt.json`)).resolves.toBe(true)\n})\n\ntest('delete() successfully deletes a directory in trash', async () => {\n\t// Create a directory in trash\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst metaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\n\t// Create a directory with nested files\n\tawait fse.mkdir(`${trashDir}/test-dir`, {recursive: true})\n\tawait fse.mkdir(`${trashDir}/test-dir/nested`, {recursive: true})\n\tawait fse.writeFile(`${trashDir}/test-dir/file1.txt`, 'content 1')\n\tawait fse.writeFile(`${trashDir}/test-dir/nested/file2.txt`, 'content 2')\n\tawait fse.writeFile(`${metaDir}/test-dir.json`, JSON.stringify({path: '/Home/test-dir'}))\n\n\t// Verify the directory exists in trash\n\tawait expect(fse.pathExists(`${trashDir}/test-dir`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${metaDir}/test-dir.json`)).resolves.toBe(true)\n\n\t// Delete the directory\n\tawait umbreld.client.files.delete.mutate({path: '/Trash/test-dir'})\n\n\t// Verify the directory is deleted\n\tawait expect(fse.pathExists(`${trashDir}/test-dir`)).resolves.toBe(false)\n\t// Note: The metadata file is not automatically deleted by the delete operation\n\tawait expect(fse.pathExists(`${metaDir}/test-dir.json`)).resolves.toBe(true)\n})\n\ntest('delete() silently succeeds when trying to delete a non-existent file', async () => {\n\t// Attempt to delete a non-existent file\n\tawait expect(umbreld.client.files.delete.mutate({path: '/Trash/non-existent-file.txt'})).resolves.toBe(true)\n})\n\ntest('delete() throws error when trying to delete a file outside of trash', async () => {\n\t// Create a test file outside of trash\n\tconst homeDir = `${umbreld.instance.dataDirectory}/home`\n\tawait fse.ensureDir(homeDir)\n\tawait fse.writeFile(`${homeDir}/protected-file.txt`, 'protected content')\n\n\t// Verify the file exists\n\tawait expect(fse.pathExists(`${homeDir}/protected-file.txt`)).resolves.toBe(true)\n\n\t// Attempt to delete the file outside of trash\n\tawait expect(umbreld.client.files.delete.mutate({path: '/Home/protected-file.txt'})).rejects.toThrow()\n\n\t// Verify the file still exists\n\tawait expect(fse.pathExists(`${homeDir}/protected-file.txt`)).resolves.toBe(true)\n\n\t// Clean up\n\tawait fse.remove(`${homeDir}/protected-file.txt`)\n})\n\ntest('delete() can delete a nested file in trash', async () => {\n\t// Create a directory structure in trash\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst metaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\n\t// Create a directory with nested files\n\tawait fse.mkdir(`${trashDir}/test-dir`, {recursive: true})\n\tawait fse.mkdir(`${trashDir}/test-dir/nested`, {recursive: true})\n\tawait fse.writeFile(`${trashDir}/test-dir/file1.txt`, 'content 1')\n\tawait fse.writeFile(`${trashDir}/test-dir/nested/file2.txt`, 'content 2')\n\tawait fse.writeFile(`${metaDir}/test-dir.json`, JSON.stringify({path: '/Home/test-dir'}))\n\n\t// Verify the nested file exists\n\tawait expect(fse.pathExists(`${trashDir}/test-dir/nested/file2.txt`)).resolves.toBe(true)\n\n\t// Delete the nested file\n\tawait umbreld.client.files.delete.mutate({path: '/Trash/test-dir/nested/file2.txt'})\n\n\t// Verify the nested file is deleted but the directory structure remains\n\tawait expect(fse.pathExists(`${trashDir}/test-dir/nested/file2.txt`)).resolves.toBe(false)\n\tawait expect(fse.pathExists(`${trashDir}/test-dir/nested`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${trashDir}/test-dir/file1.txt`)).resolves.toBe(true)\n})\n\ntest('delete() handles errors gracefully', async () => {\n\t// Create a test file in trash\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst metaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\tawait fse.writeFile(`${trashDir}/test-file.txt`, 'test content')\n\tawait fse.writeFile(`${metaDir}/test-file.txt.json`, JSON.stringify({path: '/Home/test-file.txt'}))\n\n\t// Mock the remove function to simulate a failure\n\tvi.spyOn(fse, 'remove').mockImplementation(async (path: string) => {\n\t\tthrow new Error('Simulated removal error')\n\t})\n\n\t// Attempt to delete the file\n\tawait expect(umbreld.client.files.delete.mutate({path: '/Trash/test-file.txt'})).resolves.toBe(false)\n\n\t// Verify the file still exists\n\tawait expect(fse.pathExists(`${trashDir}/test-file.txt`)).resolves.toBe(true)\n})\n\ntest('delete() can handle files with special characters in the name', async () => {\n\t// Create a file with special characters in the name\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst metaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\tconst specialFileName = 'special-!@#$%^&*()_+.txt'\n\n\tawait fse.writeFile(`${trashDir}/${specialFileName}`, 'special content')\n\tawait fse.writeFile(`${metaDir}/${specialFileName}.json`, JSON.stringify({path: `/Home/${specialFileName}`}))\n\n\t// Verify the file exists\n\tawait expect(fse.pathExists(`${trashDir}/${specialFileName}`)).resolves.toBe(true)\n\n\t// Delete the file\n\tawait umbreld.client.files.delete.mutate({path: `/Trash/${specialFileName}`})\n\n\t// Verify the file is deleted\n\tawait expect(fse.pathExists(`${trashDir}/${specialFileName}`)).resolves.toBe(false)\n})\n\n// Directory traversal tests\ntest('delete() throws on directory traversal attempt', async () => {\n\tawait expect(\n\t\tumbreld.client.files.delete.mutate({\n\t\t\tpath: '/Trash/../../../../etc/passwd',\n\t\t}),\n\t).rejects.toThrow('[operation-not-allowed]')\n})\n\ntest('delete() throws on relative path traversal attempt', async () => {\n\tawait expect(\n\t\tumbreld.client.files.delete.mutate({\n\t\t\tpath: '/Trash/../Home/important-file.txt',\n\t\t}),\n\t).rejects.toThrow('[operation-not-allowed]')\n})\n\ntest('delete() throws on symlink traversal attempt', async () => {\n\t// Create a symlink in trash that points outside the trash directory\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tawait fse.ensureDir(trashDir)\n\n\t// Create a target file that we'll try to delete through the symlink\n\tconst targetDir = `${umbreld.instance.dataDirectory}/symlink-target`\n\tawait fse.ensureDir(targetDir)\n\tawait fse.writeFile(`${targetDir}/important-file.txt`, 'important content')\n\n\t// Create a symlink in trash pointing to the target directory\n\tawait fse.symlink(targetDir, `${trashDir}/symlink-to-outside`)\n\n\t// Attempt to delete a file through the symlink\n\tawait expect(\n\t\tumbreld.client.files.delete.mutate({\n\t\t\tpath: '/Trash/symlink-to-outside/important-file.txt',\n\t\t}),\n\t).rejects.toThrow('[escapes-base]')\n\n\t// Verify the target file still exists\n\tawait expect(fse.pathExists(`${targetDir}/important-file.txt`)).resolves.toBe(true)\n\n\t// Clean up\n\tawait fse.remove(targetDir)\n\tawait fse.remove(`${trashDir}/symlink-to-outside`)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/files.emptyTrash.test.ts",
    "content": "import {expect, beforeAll, afterAll, afterEach, test, vi} from 'vitest'\nimport fse from 'fs-extra'\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\nafterEach(async () => {\n\t// Nuke trash state after each test\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst trashMetaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\tfor (const file of await fse.readdir(trashDir)) await fse.remove(`${trashDir}/${file}`)\n\tfor (const file of await fse.readdir(trashMetaDir)) await fse.remove(`${trashMetaDir}/${file}`)\n})\n\ntest('emptyTrash() throws invalid error without auth token', async () => {\n\tawait expect(umbreld.unauthenticatedClient.files.emptyTrash.mutate()).rejects.toThrow('Invalid token')\n})\n\ntest('emptyTrash() successfully empties an empty trash', async () => {\n\t// Verify trash is empty\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst trashFiles = await fse.readdir(trashDir)\n\texpect(trashFiles.length).toBe(0)\n\n\t// Empty the trash\n\tconst result = await umbreld.client.files.emptyTrash.mutate()\n\n\t// Verify the result\n\texpect(result).toBe(true)\n})\n\ntest('emptyTrash() successfully empties trash with a single file', async () => {\n\t// Create a file in trash with metadata\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst metaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\tawait fse.writeFile(`${trashDir}/test-file.txt`, 'test content')\n\tawait fse.writeFile(`${metaDir}/test-file.txt.json`, JSON.stringify({path: '/Home/test-file.txt'}))\n\n\t// Verify the file exists in trash\n\tawait expect(fse.pathExists(`${trashDir}/test-file.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${metaDir}/test-file.txt.json`)).resolves.toBe(true)\n\n\t// Empty the trash\n\tconst result = await umbreld.client.files.emptyTrash.mutate()\n\n\t// Verify the result\n\texpect(result).toBe(true)\n\n\t// Verify the file is removed from trash\n\tawait expect(fse.pathExists(`${trashDir}/test-file.txt`)).resolves.toBe(false)\n\tawait expect(fse.pathExists(`${metaDir}/test-file.txt.json`)).resolves.toBe(false)\n})\n\ntest('emptyTrash() successfully empties trash with multiple files', async () => {\n\t// Create multiple files in trash with metadata\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst metaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\n\tconst numberOfFiles = 1000\n\n\t// Create the files\n\tfor (let i = 1; i <= numberOfFiles; i++) {\n\t\tawait fse.writeFile(`${trashDir}/file${i}.txt`, `content ${i}`)\n\t\tawait fse.writeFile(`${metaDir}/file${i}.txt.json`, JSON.stringify({path: `/Home/file${i}.txt`}))\n\t}\n\n\t// Verify the files exist in trash\n\tfor (let i = 1; i <= numberOfFiles; i++) {\n\t\tawait expect(fse.pathExists(`${trashDir}/file${i}.txt`)).resolves.toBe(true)\n\t\tawait expect(fse.pathExists(`${metaDir}/file${i}.txt.json`)).resolves.toBe(true)\n\t}\n\n\t// Empty the trash\n\tconst startTime = Date.now()\n\tconst result = await umbreld.client.files.emptyTrash.mutate()\n\tconst endTime = Date.now()\n\tconst duration = endTime - startTime\n\n\t// Check empty trash wasn't unreasonably slow\n\texpect(duration).toBeLessThan(1000)\n\n\t// Verify the result\n\texpect(result).toBe(true)\n\n\t// Verify the files are removed from trash\n\tfor (let i = 1; i <= numberOfFiles; i++) {\n\t\tawait expect(fse.pathExists(`${trashDir}/file${i}.txt`)).resolves.toBe(false)\n\t\tawait expect(fse.pathExists(`${metaDir}/file${i}.txt.json`)).resolves.toBe(false)\n\t}\n})\n\ntest('emptyTrash() successfully empties trash with directories', async () => {\n\t// Create a directory structure in trash with metadata\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst metaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\n\t// Create a directory with nested files\n\tawait fse.mkdir(`${trashDir}/test-dir`, {recursive: true})\n\tawait fse.mkdir(`${trashDir}/test-dir/nested`, {recursive: true})\n\tawait fse.writeFile(`${trashDir}/test-dir/file1.txt`, 'content 1')\n\tawait fse.writeFile(`${trashDir}/test-dir/nested/file2.txt`, 'content 2')\n\tawait fse.writeFile(`${metaDir}/test-dir.json`, JSON.stringify({path: '/Home/test-dir'}))\n\n\t// Verify the directory exists in trash\n\tawait expect(fse.pathExists(`${trashDir}/test-dir`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${metaDir}/test-dir.json`)).resolves.toBe(true)\n\n\t// Empty the trash\n\tconst result = await umbreld.client.files.emptyTrash.mutate()\n\n\t// Verify the result\n\texpect(result).toBe(true)\n\n\t// Verify the directory is removed from trash\n\tawait expect(fse.pathExists(`${trashDir}/test-dir`)).resolves.toBe(false)\n\tawait expect(fse.pathExists(`${metaDir}/test-dir.json`)).resolves.toBe(false)\n})\n\ntest('emptyTrash() handles missing metadata files', async () => {\n\t// Create files in trash without metadata\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\n\t// Create files without metadata\n\tawait fse.writeFile(`${trashDir}/no-meta1.txt`, 'content 1')\n\tawait fse.writeFile(`${trashDir}/no-meta2.txt`, 'content 2')\n\n\t// Verify the files exist in trash\n\tawait expect(fse.pathExists(`${trashDir}/no-meta1.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${trashDir}/no-meta2.txt`)).resolves.toBe(true)\n\n\t// Empty the trash\n\tconst result = await umbreld.client.files.emptyTrash.mutate()\n\n\t// Verify the result\n\texpect(result).toBe(true)\n\n\t// Verify the files are removed from trash\n\tawait expect(fse.pathExists(`${trashDir}/no-meta1.txt`)).resolves.toBe(false)\n\tawait expect(fse.pathExists(`${trashDir}/no-meta2.txt`)).resolves.toBe(false)\n})\n\ntest('emptyTrash() handles metadata files without corresponding trash files', async () => {\n\t// Create metadata files without corresponding trash files\n\tconst metaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\n\t// Create metadata files without trash files\n\tawait fse.writeFile(`${metaDir}/orphaned1.txt.json`, JSON.stringify({path: '/Home/orphaned1.txt'}))\n\tawait fse.writeFile(`${metaDir}/orphaned2.txt.json`, JSON.stringify({path: '/Home/orphaned2.txt'}))\n\n\t// Verify the metadata files exist\n\tawait expect(fse.pathExists(`${metaDir}/orphaned1.txt.json`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${metaDir}/orphaned2.txt.json`)).resolves.toBe(true)\n\n\t// Empty the trash\n\tconst result = await umbreld.client.files.emptyTrash.mutate()\n\n\t// Verify the result\n\texpect(result).toBe(true)\n\n\t// Verify the metadata files are removed\n\tawait expect(fse.pathExists(`${metaDir}/orphaned1.txt.json`)).resolves.toBe(false)\n\tawait expect(fse.pathExists(`${metaDir}/orphaned2.txt.json`)).resolves.toBe(false)\n})\n\ntest('emptyTrash() handles mixed content types', async () => {\n\t// Create a mix of files, directories, and metadata\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst metaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\n\t// Create regular files with metadata\n\tawait fse.writeFile(`${trashDir}/file1.txt`, 'content 1')\n\tawait fse.writeFile(`${metaDir}/file1.txt.json`, JSON.stringify({path: '/Home/file1.txt'}))\n\n\t// Create directory with metadata\n\tawait fse.mkdir(`${trashDir}/dir1`, {recursive: true})\n\tawait fse.writeFile(`${trashDir}/dir1/nested.txt`, 'nested content')\n\tawait fse.writeFile(`${metaDir}/dir1.json`, JSON.stringify({path: '/Home/dir1'}))\n\n\t// Create file without metadata\n\tawait fse.writeFile(`${trashDir}/no-meta.txt`, 'no meta content')\n\n\t// Create metadata without file\n\tawait fse.writeFile(`${metaDir}/orphaned.txt.json`, JSON.stringify({path: '/Home/orphaned.txt'}))\n\n\t// Empty the trash\n\tconst result = await umbreld.client.files.emptyTrash.mutate()\n\n\t// Verify the result\n\texpect(result).toBe(true)\n\n\t// Verify everything is removed\n\tawait expect(fse.pathExists(`${trashDir}/file1.txt`)).resolves.toBe(false)\n\tawait expect(fse.pathExists(`${metaDir}/file1.txt.json`)).resolves.toBe(false)\n\tawait expect(fse.pathExists(`${trashDir}/dir1`)).resolves.toBe(false)\n\tawait expect(fse.pathExists(`${metaDir}/dir1.json`)).resolves.toBe(false)\n\tawait expect(fse.pathExists(`${trashDir}/no-meta.txt`)).resolves.toBe(false)\n\tawait expect(fse.pathExists(`${metaDir}/orphaned.txt.json`)).resolves.toBe(false)\n})\n\ntest('emptyTrash() reports failures correctly', async () => {\n\t// Create a test file that we'll make unremovable\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst metaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\n\t// Create a regular file that can be deleted\n\tawait fse.writeFile(`${trashDir}/good-file.txt`, 'good content')\n\tawait fse.writeFile(`${metaDir}/good-file.txt.json`, JSON.stringify({path: '/Home/good-file.txt'}))\n\n\t// Create a file that will cause an error when deleted\n\tawait fse.writeFile(`${trashDir}/bad-file.txt`, 'bad content')\n\tawait fse.writeFile(`${metaDir}/bad-file.txt.json`, JSON.stringify({path: '/Home/bad-file.txt'}))\n\n\t// Mock the remove function to simulate a failure for the bad file\n\tconst originalRemove = fse.remove\n\tvi.spyOn(fse, 'remove').mockImplementation(async (path: string) => {\n\t\tif (path.includes('bad-file')) {\n\t\t\tthrow new Error('Simulated removal error')\n\t\t}\n\t\treturn originalRemove(path)\n\t})\n\n\ttry {\n\t\t// Empty the trash\n\t\tconst result = await umbreld.client.files.emptyTrash.mutate()\n\n\t\t// Verify the result\n\t\texpect(result).toBe(false)\n\n\t\t// Verify the good file is removed\n\t\tawait expect(fse.pathExists(`${trashDir}/good-file.txt`)).resolves.toBe(false)\n\t\tawait expect(fse.pathExists(`${metaDir}/good-file.txt.json`)).resolves.toBe(false)\n\n\t\t// The bad file should still exist\n\t\tawait expect(fse.pathExists(`${trashDir}/bad-file.txt`)).resolves.toBe(true)\n\t\tawait expect(fse.pathExists(`${metaDir}/bad-file.txt.json`)).resolves.toBe(true)\n\t} finally {\n\t\t// Restore the original mocks\n\t\tvi.restoreAllMocks()\n\n\t\t// Clean up the bad file manually\n\t\tawait originalRemove(`${trashDir}/bad-file.txt`)\n\t\tawait originalRemove(`${metaDir}/bad-file.txt.json`)\n\t}\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/files.list.integration.test.ts",
    "content": "import {setTimeout as sleep} from 'node:timers/promises'\n\nimport {vi, expect, beforeAll, afterAll, test} from 'vitest'\nimport {$} from 'execa'\nimport fse from 'fs-extra'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\ntest('list() throws invalid error whithout auth token', async () => {\n\tawait expect(umbreld.unauthenticatedClient.files.list.query({path: '/'})).rejects.toThrow('Invalid token')\n})\n\ntest('list() throws on directory traversal attempt', async () => {\n\tawait expect(umbreld.client.files.list.query({path: '/Home/../../../../etc'})).rejects.toThrow('[invalid-base]')\n})\n\ntest('list() throws on symlink traversal attempt', async () => {\n\t// Create a symlink to the root directory in at the virtual path /Home/symlink-to-root\n\tawait $`ln -s / ${umbreld.instance.dataDirectory}/home/symlink-to-root`\n\t// Ensure the symlink exists at the correct location\n\tawait expect(umbreld.client.files.list.query({path: '/Home'})).resolves.toMatchObject({\n\t\tfiles: expect.arrayContaining([\n\t\t\texpect.objectContaining({\n\t\t\t\tname: 'symlink-to-root',\n\t\t\t}),\n\t\t]),\n\t})\n\t// Attempt to list it\n\tawait expect(umbreld.client.files.list.query({path: '/Home/symlink-to-root'})).rejects.toThrow('[escapes-base]')\n\n\t// Remove the symlink\n\tawait fse.remove(umbreld.instance.dataDirectory + '/home/symlink-to-root')\n})\n\ntest('list() throws on relative paths', async () => {\n\tawait Promise.all(\n\t\t['', ' ', '.', '..', 'Home', 'Home/..', 'Home/Documents'].map((path) =>\n\t\t\texpect(umbreld.client.files.list.query({path})).rejects.toThrow('[path-not-absolute]'),\n\t\t),\n\t)\n})\n\ntest('list() throws on non existent paths', async () => {\n\tawait Promise.all([\n\t\texpect(umbreld.client.files.list.query({path: '/DoesNotExist'})).rejects.toThrow('[invalid-base]'),\n\t\texpect(umbreld.client.files.list.query({path: '/Home/DoesNotExist'})).rejects.toThrow('[does-not-exist]'),\n\t])\n})\n\ntest('list() lists the root directory', async () => {\n\tawait expect(umbreld.client.files.list.query({path: '/'})).resolves.toMatchObject({\n\t\tname: '',\n\t\tpath: '/',\n\t\ttype: 'directory',\n\t\tsize: 0,\n\t\tmodified: expect.any(Number),\n\t\toperations: [],\n\t\tfiles: ['Apps', 'Backups', 'External', 'Home', 'Network', 'Trash'].map((name) => ({\n\t\t\tname,\n\t\t\tpath: `/${name}`,\n\t\t\ttype: 'directory',\n\t\t\tsize: 0,\n\t\t\tmodified: expect.any(Number),\n\t\t\toperations: expect.arrayContaining(['copy']),\n\t\t})),\n\t})\n})\n\ntest('list() lists the /Home directory', async () => {\n\tawait expect(umbreld.client.files.list.query({path: '/Home'})).resolves.toMatchObject({\n\t\tname: 'Home',\n\t\tpath: '/Home',\n\t\ttype: 'directory',\n\t\tsize: 0,\n\t\tmodified: expect.any(Number),\n\t\toperations: expect.arrayContaining(['copy']),\n\t\tfiles: [\n\t\t\t{\n\t\t\t\tname: 'Documents',\n\t\t\t\tpath: '/Home/Documents',\n\t\t\t\ttype: 'directory',\n\t\t\t\tsize: 0,\n\t\t\t\tmodified: expect.any(Number),\n\t\t\t\toperations: expect.arrayContaining(['move', 'copy']),\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'Downloads',\n\t\t\t\tpath: '/Home/Downloads',\n\t\t\t\ttype: 'directory',\n\t\t\t\tsize: 0,\n\t\t\t\tmodified: expect.any(Number),\n\t\t\t\toperations: expect.arrayContaining(['copy']),\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'Photos',\n\t\t\t\tpath: '/Home/Photos',\n\t\t\t\ttype: 'directory',\n\t\t\t\tsize: 0,\n\t\t\t\tmodified: expect.any(Number),\n\t\t\t\toperations: expect.arrayContaining(['move', 'copy']),\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'Videos',\n\t\t\t\tpath: '/Home/Videos',\n\t\t\t\ttype: 'directory',\n\t\t\t\tsize: 0,\n\t\t\t\tmodified: expect.any(Number),\n\t\t\t\toperations: expect.arrayContaining(['move', 'copy']),\n\t\t\t},\n\t\t],\n\t})\n})\n\ntest('list() returns correct types for various files and directories', async () => {\n\t// Create a test directory with files of different types\n\tconst mimeDir = `${umbreld.instance.dataDirectory}/home/mime-test`\n\tawait fse.mkdir(mimeDir)\n\n\t// Create test files with different mime types\n\tawait Promise.all([\n\t\tfse.writeFile(`${mimeDir}/text.txt`, ''),\n\t\tfse.writeFile(`${mimeDir}/image.png`, ''),\n\t\tfse.writeFile(`${mimeDir}/video.mp4`, ''),\n\t\tfse.writeFile(`${mimeDir}/unknown`, ''),\n\t])\n\n\t// Create a subdirectory\n\tconst subDir = `${mimeDir}/subdir`\n\tawait fse.mkdir(subDir)\n\n\t// Create a symlink\n\tconst symlinkPath = `${mimeDir}/symlink-to-text`\n\tawait fse.symlink(`${mimeDir}/text.txt`, symlinkPath)\n\n\t// Query the directory\n\tconst mimeTypes = await umbreld.client.files.list.query({path: '/Home/mime-test'})\n\n\t// Check the types\n\t;[\n\t\t{name: 'text.txt', type: 'text/plain'},\n\t\t{name: 'image.png', type: 'image/png'},\n\t\t{name: 'video.mp4', type: 'video/mp4'},\n\t\t{name: 'unknown', type: 'application/octet-stream'},\n\t\t{name: 'subdir', type: 'directory'},\n\t\t{name: 'symlink-to-text', type: 'symbolic-link'},\n\t].forEach(({name, type}) => {\n\t\texpect(mimeTypes.files.find((file) => file.name === name)?.type).toEqual(type)\n\t})\n\n\t// Clean up\n\tawait fse.remove(mimeDir)\n})\n\ntest('list() shows dotfiles', async () => {\n\t// Create a test directory with dotfiles\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/dotfiles-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create regular files and dotfiles\n\tawait Promise.all([\n\t\tfse.writeFile(`${testDirectory}/regular.txt`, ''),\n\t\tfse.writeFile(`${testDirectory}/.dotfile`, ''),\n\t\tfse.writeFile(`${testDirectory}/.hidden-config`, ''),\n\t])\n\n\t// Query the directory listing\n\tconst listing = await umbreld.client.files.list.query({\n\t\tpath: '/Home/dotfiles-test',\n\t})\n\n\t// Verify that dotfiles are included in the listing\n\texpect(listing.files).toEqual(\n\t\texpect.arrayContaining([\n\t\t\texpect.objectContaining({\n\t\t\t\tname: '.dotfile',\n\t\t\t\tpath: '/Home/dotfiles-test/.dotfile',\n\t\t\t}),\n\t\t\texpect.objectContaining({\n\t\t\t\tname: '.hidden-config',\n\t\t\t\tpath: '/Home/dotfiles-test/.hidden-config',\n\t\t\t}),\n\t\t]),\n\t)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('list() hides .DS_Store files', async () => {\n\t// Create a test directory with .DS_Store file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/ds-store-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create regular files and .DS_Store file\n\tawait Promise.all([\n\t\tfse.writeFile(`${testDirectory}/regular.txt`, ''),\n\t\tfse.writeFile(`${testDirectory}/.DS_Store`, ''),\n\t])\n\n\t// Query the directory listing\n\tconst listing = await umbreld.client.files.list.query({\n\t\tpath: '/Home/ds-store-test',\n\t})\n\n\t// Verify that .DS_Store is not included but other files are\n\texpect(listing.files.map((file) => file.name)).not.toContain('.DS_Store')\n\texpect(listing.files.map((file) => file.name)).toContain('regular.txt')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('list() paginates directory listings', async () => {\n\t// Create a test directory with 150 files\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/pagination-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create 150 files\n\tawait Promise.all(\n\t\tArray.from({length: 150}, (_, i) => i + 1).map((i) =>\n\t\t\tfse.writeFile(`${testDirectory}/file${i.toString().padStart(3, '0')}.txt`, ''),\n\t\t),\n\t)\n\n\t// Test first page (100 files because that's the default limit)\n\tconst firstPage = await umbreld.client.files.list.query({path: '/Home/pagination-test'})\n\texpect(firstPage.files).toHaveLength(100)\n\texpect(firstPage.files[0].name).toBe('file001.txt')\n\texpect(firstPage.files[99].name).toBe('file100.txt')\n\texpect(firstPage.totalFiles).toBe(150)\n\texpect(firstPage.hasMore).toBe(true)\n\n\t// Test second page (50 files)\n\tconst secondPage = await umbreld.client.files.list.query({\n\t\tpath: '/Home/pagination-test',\n\t\tlastFile: firstPage.files[99].name,\n\t})\n\texpect(secondPage.files).toHaveLength(50)\n\texpect(secondPage.files[0].name).toBe('file101.txt')\n\texpect(secondPage.files[49].name).toBe('file150.txt')\n\texpect(secondPage.totalFiles).toBe(150)\n\texpect(secondPage.hasMore).toBe(false)\n\n\t// Test third page (0 files)\n\tconst thirdPage = await umbreld.client.files.list.query({\n\t\tpath: '/Home/pagination-test',\n\t\tlastFile: secondPage.files[49].name,\n\t})\n\texpect(thirdPage.files).toHaveLength(0)\n\texpect(thirdPage.totalFiles).toBe(150)\n\texpect(thirdPage.hasMore).toBe(false)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('list() paginates directory listings with a custom limit', async () => {\n\t// Create a test directory with 150 files\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/custom-limit-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create 150 files\n\tawait Promise.all(\n\t\tArray.from({length: 150}, (_, i) => i + 1).map((i) =>\n\t\t\tfse.writeFile(`${testDirectory}/file${i.toString().padStart(3, '0')}.txt`, ''),\n\t\t),\n\t)\n\n\t// Test with a custom limit of 42 files\n\tconst customLimit = 42\n\tconst result = await umbreld.client.files.list.query({\n\t\tpath: '/Home/custom-limit-test',\n\t\tlimit: customLimit,\n\t})\n\n\t// Verify exact number of files matches the custom limit\n\texpect(result.files).toHaveLength(customLimit)\n\texpect(result.files[0].name).toBe('file001.txt')\n\texpect(result.files[customLimit - 1].name).toBe(`file${customLimit.toString().padStart(3, '0')}.txt`)\n\texpect(result.totalFiles).toBe(150)\n\texpect(result.hasMore).toBe(true)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest(\"list() truncates a listing if it's larger than the max listing size\", async () => {\n\tconst maxListingSize = 10000\n\t// Create a test directory with just under the max listing size\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/max-listing-size`\n\tawait fse.mkdir(testDirectory)\n\tawait Promise.all(\n\t\tArray.from({length: maxListingSize - 1}, (_, i) => i + 1).map((i) =>\n\t\t\tfse.writeFile(`${testDirectory}/file${i.toString()}.txt`, ''),\n\t\t),\n\t)\n\n\t// Test results are not truncated\n\tawait expect(umbreld.client.files.list.query({path: '/Home/max-listing-size'})).resolves.not.toHaveProperty(\n\t\t'truncatedAt',\n\t)\n\n\t// Create one more file\n\tawait fse.writeFile(`${testDirectory}/file${maxListingSize}.txt`, '')\n\n\t// Test results are truncated\n\tawait expect(umbreld.client.files.list.query({path: '/Home/max-listing-size'})).resolves.toHaveProperty(\n\t\t'truncatedAt',\n\t\tmaxListingSize,\n\t)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('list() sorts by name', async () => {\n\t// Create a test directory with files - using unique path\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/sort-by-name-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create test files with different names\n\tawait Promise.all([\n\t\tfse.writeFile(`${testDirectory}/b.txt`, ''),\n\t\tfse.writeFile(`${testDirectory}/c.txt`, ''),\n\t\tfse.writeFile(`${testDirectory}/a.txt`, ''),\n\t])\n\n\t// Test ascending sort\n\tconst ascending = await umbreld.client.files.list.query({\n\t\tpath: '/Home/sort-by-name-test',\n\t\tsortBy: 'name',\n\t\tsortOrder: 'ascending',\n\t})\n\texpect(ascending.files.map((f) => f.name)).toEqual(['a.txt', 'b.txt', 'c.txt'])\n\n\t// Test descending sort\n\tconst descending = await umbreld.client.files.list.query({\n\t\tpath: '/Home/sort-by-name-test',\n\t\tsortBy: 'name',\n\t\tsortOrder: 'descending',\n\t})\n\texpect(descending.files.map((f) => f.name)).toEqual(['c.txt', 'b.txt', 'a.txt'])\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('list() sorts by modified time', async () => {\n\t// Create a test directory with files - using unique path\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/sort-by-modified-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create files with different modified times\n\tawait fse.writeFile(`${testDirectory}/oldest.txt`, '')\n\tawait sleep(100)\n\tawait fse.writeFile(`${testDirectory}/middle.txt`, '')\n\tawait sleep(100)\n\tawait fse.writeFile(`${testDirectory}/newest.txt`, '')\n\n\t// Test ascending sort (oldest first)\n\tconst ascending = await umbreld.client.files.list.query({\n\t\tpath: '/Home/sort-by-modified-test',\n\t\tsortBy: 'modified',\n\t\tsortOrder: 'ascending',\n\t})\n\texpect(ascending.files.map((f) => f.name)).toEqual(['oldest.txt', 'middle.txt', 'newest.txt'])\n\n\t// Test descending sort (newest first)\n\tconst descending = await umbreld.client.files.list.query({\n\t\tpath: '/Home/sort-by-modified-test',\n\t\tsortBy: 'modified',\n\t\tsortOrder: 'descending',\n\t})\n\texpect(descending.files.map((f) => f.name)).toEqual(['newest.txt', 'middle.txt', 'oldest.txt'])\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('list() sorts by size', async () => {\n\t// Create a test directory with files - using unique path\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/sort-by-size-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create files with different sizes\n\tawait fse.writeFile(`${testDirectory}/small.txt`, 'a')\n\tawait fse.writeFile(`${testDirectory}/medium.txt`, 'aaa')\n\tawait fse.writeFile(`${testDirectory}/large.txt`, 'aaaaa')\n\n\t// Test ascending sort (smallest first)\n\tconst ascending = await umbreld.client.files.list.query({\n\t\tpath: '/Home/sort-by-size-test',\n\t\tsortBy: 'size',\n\t\tsortOrder: 'ascending',\n\t})\n\texpect(ascending.files.map((f) => f.name)).toEqual(['small.txt', 'medium.txt', 'large.txt'])\n\n\t// Test descending sort (largest first)\n\tconst descending = await umbreld.client.files.list.query({\n\t\tpath: '/Home/sort-by-size-test',\n\t\tsortBy: 'size',\n\t\tsortOrder: 'descending',\n\t})\n\texpect(descending.files.map((f) => f.name)).toEqual(['large.txt', 'medium.txt', 'small.txt'])\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('list() sorts by type', async () => {\n\t// Create a test directory with files - using unique path\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/sort-by-type-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create files with different types\n\tawait fse.writeFile(`${testDirectory}/document.txt`, '')\n\tawait fse.writeFile(`${testDirectory}/image.png`, '')\n\tawait fse.writeFile(`${testDirectory}/archive.zip`, '')\n\n\t// Test ascending sort\n\tconst ascending = await umbreld.client.files.list.query({\n\t\tpath: '/Home/sort-by-type-test',\n\t\tsortBy: 'type',\n\t\tsortOrder: 'ascending',\n\t})\n\texpect(ascending.files.map((f) => f.name)).toEqual(['archive.zip', 'image.png', 'document.txt'])\n\n\t// Test descending sort\n\tconst descending = await umbreld.client.files.list.query({\n\t\tpath: '/Home/sort-by-type-test',\n\t\tsortBy: 'type',\n\t\tsortOrder: 'descending',\n\t})\n\texpect(descending.files.map((f) => f.name)).toEqual(['document.txt', 'image.png', 'archive.zip'])\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('list() sorts files numerically by name', async () => {\n\t// Create a test directory with files named numerically\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/numeric-sort-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create files with numeric names\n\tawait fse.writeFile(`${testDirectory}/0.txt`, '')\n\tawait fse.writeFile(`${testDirectory}/1.txt`, '')\n\tawait fse.writeFile(`${testDirectory}/2.txt`, '')\n\tawait fse.writeFile(`${testDirectory}/3.txt`, '')\n\tawait fse.writeFile(`${testDirectory}/4.txt`, '')\n\tawait fse.writeFile(`${testDirectory}/5.txt`, '')\n\tawait fse.writeFile(`${testDirectory}/6.txt`, '')\n\tawait fse.writeFile(`${testDirectory}/7.txt`, '')\n\tawait fse.writeFile(`${testDirectory}/8.txt`, '')\n\tawait fse.writeFile(`${testDirectory}/9.txt`, '')\n\tawait fse.writeFile(`${testDirectory}/10.txt`, '')\n\n\t// Test ascending sort by name\n\tconst ascending = await umbreld.client.files.list.query({\n\t\tpath: '/Home/numeric-sort-test',\n\t\tsortBy: 'name',\n\t\tsortOrder: 'ascending',\n\t})\n\texpect(ascending.files.map((f) => f.name)).toEqual([\n\t\t'0.txt',\n\t\t'1.txt',\n\t\t'2.txt',\n\t\t'3.txt',\n\t\t'4.txt',\n\t\t'5.txt',\n\t\t'6.txt',\n\t\t'7.txt',\n\t\t'8.txt',\n\t\t'9.txt',\n\t\t'10.txt',\n\t])\n\n\t// Test descending sort by name\n\tconst descending = await umbreld.client.files.list.query({\n\t\tpath: '/Home/numeric-sort-test',\n\t\tsortBy: 'name',\n\t\tsortOrder: 'descending',\n\t})\n\texpect(descending.files.map((f) => f.name)).toEqual([\n\t\t'10.txt',\n\t\t'9.txt',\n\t\t'8.txt',\n\t\t'7.txt',\n\t\t'6.txt',\n\t\t'5.txt',\n\t\t'4.txt',\n\t\t'3.txt',\n\t\t'2.txt',\n\t\t'1.txt',\n\t\t'0.txt',\n\t])\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('list() falls back to name sorting when numeric values are equal', async () => {\n\t// Create a test directory with files - using unique path\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/sort-fallback-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create files with the same size but different names\n\tawait fse.writeFile(`${testDirectory}/0.txt`, 'same size')\n\tawait fse.writeFile(`${testDirectory}/1.txt`, 'same size')\n\tawait fse.writeFile(`${testDirectory}/2.txt`, 'same size')\n\tawait fse.writeFile(`${testDirectory}/3.txt`, 'same size')\n\tawait fse.writeFile(`${testDirectory}/4.txt`, 'same size')\n\tawait fse.writeFile(`${testDirectory}/5.txt`, 'same size')\n\tawait fse.writeFile(`${testDirectory}/6.txt`, 'same size')\n\tawait fse.writeFile(`${testDirectory}/7.txt`, 'same size')\n\tawait fse.writeFile(`${testDirectory}/8.txt`, 'same size')\n\tawait fse.writeFile(`${testDirectory}/9.txt`, 'same size')\n\tawait fse.writeFile(`${testDirectory}/10.txt`, 'same size')\n\n\t// Test ascending sort by size, should fall back to name\n\tconst ascending = await umbreld.client.files.list.query({\n\t\tpath: '/Home/sort-fallback-test',\n\t\tsortBy: 'size',\n\t\tsortOrder: 'ascending',\n\t})\n\texpect(ascending.files.map((f) => f.name)).toEqual([\n\t\t'0.txt',\n\t\t'1.txt',\n\t\t'2.txt',\n\t\t'3.txt',\n\t\t'4.txt',\n\t\t'5.txt',\n\t\t'6.txt',\n\t\t'7.txt',\n\t\t'8.txt',\n\t\t'9.txt',\n\t\t'10.txt',\n\t])\n\n\t// Test descending sort by size, should fall back to name\n\tconst descending = await umbreld.client.files.list.query({\n\t\tpath: '/Home/sort-fallback-test',\n\t\tsortBy: 'size',\n\t\tsortOrder: 'descending',\n\t})\n\texpect(descending.files.map((f) => f.name)).toEqual([\n\t\t'10.txt',\n\t\t'9.txt',\n\t\t'8.txt',\n\t\t'7.txt',\n\t\t'6.txt',\n\t\t'5.txt',\n\t\t'4.txt',\n\t\t'3.txt',\n\t\t'2.txt',\n\t\t'1.txt',\n\t\t'0.txt',\n\t])\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('list() reports size as zero for directories', async () => {\n\t// Create a test directory with a subdirectory and files - using unique path\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/dir-size-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/subdir`)\n\n\t// Add files to the subdirectory\n\tawait fse.writeFile(`${testDirectory}/subdir/file1.txt`, 'content1')\n\tawait fse.writeFile(`${testDirectory}/subdir/file2.txt`, 'content2')\n\n\t// Query the directory listing\n\tconst listing = await umbreld.client.files.list.query({\n\t\tpath: '/Home/dir-size-test',\n\t})\n\n\t// Check that the directory size is reported as zero\n\tconst subdir = listing.files.find((f) => f.name === 'subdir')\n\texpect(subdir).toBeDefined()\n\texpect(subdir!.size).toBe(0)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('list() reports correct size for files in bytes', async () => {\n\t// Create a test directory with files - using unique path\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/file-size-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create files with specific sizes\n\tawait fse.writeFile(`${testDirectory}/file1.txt`, '12345') // 5 bytes\n\tawait fse.writeFile(`${testDirectory}/file2.txt`, '1234567890') // 10 bytes\n\n\t// Query the directory listing\n\tconst listing = await umbreld.client.files.list.query({\n\t\tpath: '/Home/file-size-test',\n\t})\n\n\t// Check that file sizes are reported correctly\n\tconst file1 = listing.files.find((f) => f.name === 'file1.txt')\n\texpect(file1).toBeDefined()\n\texpect(file1!.size).toBe(5)\n\n\tconst file2 = listing.files.find((f) => f.name === 'file2.txt')\n\texpect(file2).toBeDefined()\n\texpect(file2!.size).toBe(10)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('list() reports correct modified time for a single file', async () => {\n\t// Create a test directory - using unique path\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/modified-time-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Get the time before creating the file\n\tconst beforeCreation = Date.now()\n\tawait sleep(100)\n\n\t// Create a file\n\tawait fse.writeFile(`${testDirectory}/file1.txt`, 'content1')\n\n\t// Get the time after creating the file\n\tawait sleep(100)\n\tconst afterCreation = Date.now()\n\n\t// Query the directory listing\n\tconst listing = await umbreld.client.files.list.query({\n\t\tpath: '/Home/modified-time-test',\n\t})\n\n\t// Check that the file modified time is reported correctly\n\tconst file = listing.files.find((f) => f.name === 'file1.txt')\n\texpect(file).toBeDefined()\n\texpect(file!.modified).toBeGreaterThanOrEqual(beforeCreation)\n\texpect(file!.modified).toBeLessThanOrEqual(afterCreation)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('list() handles unreadable files gracefully without killing the entire listing', async () => {\n\t// Create a test directory - using unique path\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/unreadable-files-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create a readable file\n\tawait fse.writeFile(`${testDirectory}/readable.txt`, 'readable content')\n\n\t// Create an unreadable file\n\tawait fse.writeFile(`${testDirectory}/unreadable.txt`, 'unreadable content')\n\n\t// Mock fse.lstat to throw an error for the unreadable file\n\tconst originalLstat = fse.lstat\n\tvi.spyOn(fse, 'lstat').mockImplementation(async (path: fse.PathLike) => {\n\t\t// Mock a stat failure if reading the unreadable file\n\t\tif (path.toString().endsWith('/unreadable.txt')) throw new Error('Permission denied')\n\n\t\t// Else pass through to the original logic\n\t\treturn originalLstat(path)\n\t})\n\n\t// Query the directory listing\n\tawait expect(\n\t\tumbreld.client.files.list.query({\n\t\t\tpath: '/Home/unreadable-files-test',\n\t\t}),\n\t).resolves.toMatchObject({\n\t\tfiles: [\n\t\t\texpect.objectContaining({\n\t\t\t\tname: 'readable.txt',\n\t\t\t\tpath: '/Home/unreadable-files-test/readable.txt',\n\t\t\t\ttype: 'text/plain',\n\t\t\t\tsize: 16,\n\t\t\t\tmodified: expect.any(Number),\n\t\t\t}),\n\t\t],\n\t})\n\n\t// Restore the original lstat implementation\n\tvi.restoreAllMocks()\n\n\t// Check the mock is removed\n\t// Query the directory listing\n\tawait expect(\n\t\tumbreld.client.files.list.query({\n\t\t\tpath: '/Home/unreadable-files-test',\n\t\t}),\n\t).resolves.toMatchObject({\n\t\tfiles: expect.arrayContaining([\n\t\t\texpect.objectContaining({\n\t\t\t\tname: 'unreadable.txt',\n\t\t\t\ttype: 'text/plain',\n\t\t\t}),\n\t\t]),\n\t})\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/files.move.integration.test.ts",
    "content": "import {expect, beforeAll, afterAll, beforeEach, afterEach, test, describe, vi} from 'vitest'\nimport {execa, $} from 'execa'\nimport fse from 'fs-extra'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\nlet testDirectory = ''\nafterEach(async () => {\n\t// Nuke trash state after each test\n\tif (testDirectory) await fse.remove(testDirectory)\n})\n\nconst forceSlowMoveWithProgressValues = ['false', 'true']\nfor (const forceSlowMoveWithProgress of forceSlowMoveWithProgressValues) {\n\tdescribe(`move() with UMBRELD_FORCE_SLOW_MOVE_WITH_PROGRESS=${forceSlowMoveWithProgress}`, () => {\n\t\tbeforeEach(() => {\n\t\t\tprocess.env.UMBRELD_FORCE_SLOW_MOVE_WITH_PROGRESS = forceSlowMoveWithProgress\n\t\t})\n\t\ttest('move() throws invalid error without auth token', async () => {\n\t\t\tawait expect(\n\t\t\t\tumbreld.unauthenticatedClient.files.move.mutate({\n\t\t\t\t\tpath: '/Home/Documents',\n\t\t\t\t\ttoDirectory: '/Home/Documents-moved',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('Invalid token')\n\t\t})\n\n\t\ttest('move() throws on directory traversal attempt in source path', async () => {\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Home/../../../../etc',\n\t\t\t\t\ttoDirectory: '/Home',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[invalid-base]')\n\t\t})\n\n\t\ttest('move() throws on directory traversal attempt in destination path', async () => {\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Home/Documents',\n\t\t\t\t\ttoDirectory: '/Home/../../../../etc',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[invalid-base]')\n\t\t})\n\n\t\ttest('move() throws on symlink traversal attempt in source path', async () => {\n\t\t\t// Create a symlink to the root directory\n\t\t\tawait $`ln -s / ${umbreld.instance.dataDirectory}/home/symlink-to-root`\n\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Home/symlink-to-root/etc',\n\t\t\t\t\ttoDirectory: '/Home',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[escapes-base]')\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(`${umbreld.instance.dataDirectory}/home/symlink-to-root`)\n\t\t})\n\n\t\ttest('move() throws on symlink traversal attempt in destination path', async () => {\n\t\t\t// Create a symlink to the root directory\n\t\t\tawait $`ln -s / ${umbreld.instance.dataDirectory}/home/symlink-to-root`\n\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Home/Documents',\n\t\t\t\t\ttoDirectory: '/Home/symlink-to-root/etc',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[escapes-base]')\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(`${umbreld.instance.dataDirectory}/home/symlink-to-root`)\n\t\t})\n\n\t\ttest('move() throws on relative paths', async () => {\n\t\t\tawait Promise.all(\n\t\t\t\t['', ' ', '.', '..', 'Home', 'Home/..', 'Home/Documents'].map(async (path) => {\n\t\t\t\t\tawait expect(\n\t\t\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\t\t\tpath,\n\t\t\t\t\t\t\ttoDirectory: '/Home/Documents',\n\t\t\t\t\t\t}),\n\t\t\t\t\t).rejects.toThrow('[path-not-absolute]')\n\t\t\t\t\tawait expect(\n\t\t\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\t\t\tpath: '/Home/Documents',\n\t\t\t\t\t\t\ttoDirectory: path,\n\t\t\t\t\t\t}),\n\t\t\t\t\t).rejects.toThrow('[path-not-absolute]')\n\t\t\t\t}),\n\t\t\t)\n\t\t})\n\n\t\ttest('move() throws on non existent source path', async () => {\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Home/DoesNotExist',\n\t\t\t\t\ttoDirectory: '/Home/Documents',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[source-not-exists]')\n\t\t})\n\n\t\ttest('move() throws on non existent destination path', async () => {\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Home/Documents',\n\t\t\t\t\ttoDirectory: '/Home/DoesNotExist',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[destination-not-exist]')\n\t\t})\n\n\t\ttest('move() throws when moving a directory into itself', async () => {\n\t\t\t// For safety, moving a directory into its own destination should throw.\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Home/Documents',\n\t\t\t\t\ttoDirectory: '/Home/Documents',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[subdir-of-self]')\n\t\t})\n\n\t\ttest('move() throws when moving a directory into a subdirectory of itself', async () => {\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/inside-self-move-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Home/inside-self-move-test',\n\t\t\t\t\ttoDirectory: '/Home/inside-self-move-test/source',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[subdir-of-self]')\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest.each(['/Home', '/Apps', '/Home/Downloads'])(\n\t\t\t'move() throws when trying to move protected directory %s',\n\t\t\tasync (path) => {\n\t\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/protected-move-test`\n\t\t\t\tawait fse.mkdir(testDirectory)\n\n\t\t\t\tawait expect(\n\t\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\t\tpath,\n\t\t\t\t\t\ttoDirectory: '/Home/protected-move-test',\n\t\t\t\t\t}),\n\t\t\t\t).rejects.toThrow('[operation-not-allowed]')\n\n\t\t\t\tawait fse.remove(testDirectory)\n\t\t\t},\n\t\t)\n\n\t\ttest('move() throws when trying to move a protected path out of /Apps/', async () => {\n\t\t\t// Install a test app\n\t\t\tawait expect(umbreld.client.apps.install.mutate({appId: 'sparkles-hello-world'})).resolves.toStrictEqual(true)\n\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/protected-app-move-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Apps/sparkles-hello-world',\n\t\t\t\t\ttoDirectory: '/Home/protected-app-move-test',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[operation-not-allowed]')\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t\tawait umbreld.client.apps.uninstall.mutate({appId: 'sparkles-hello-world'})\n\t\t})\n\n\t\ttest('move() does not throw when moving an unprotected path out of /Apps/', async () => {\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/unprotected-apps-move-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\n\t\t\t// create a directory in /Apps/ that is not an installed app id\n\t\t\tawait fse.mkdir(`${umbreld.instance.dataDirectory}/app-data/not-an-app-id`)\n\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Apps/not-an-app-id',\n\t\t\t\t\ttoDirectory: '/Home/unprotected-apps-move-test',\n\t\t\t\t}),\n\t\t\t).resolves.toBe('/Home/unprotected-apps-move-test/not-an-app-id')\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() throws when moving to the root directory', async () => {\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Home/Documents',\n\t\t\t\t\ttoDirectory: '/',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[invalid-base]')\n\t\t})\n\n\t\ttest('move() throws on too many duplicate names from existing paths when using the \"keep-both\" collision strategy', async () => {\n\t\t\t// Create test directory and file\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-existing-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.writeFile(`${testDirectory}/source.txt`, '')\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\t\t\tawait fse.writeFile(`${testDirectory}/destination/source.txt`, '')\n\n\t\t\t// Create the maximum number of moved duplicates using the \"keep-both\" collision strategy\n\t\t\tconst maxPossibleCopies = 100\n\t\t\tfor (let i = 2; i <= maxPossibleCopies; i++) {\n\t\t\t\tawait expect(\n\t\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\t\tpath: '/Home/move-existing-test/source.txt',\n\t\t\t\t\t\ttoDirectory: '/Home/move-existing-test/destination',\n\t\t\t\t\t\tcollision: 'keep-both',\n\t\t\t\t\t}),\n\t\t\t\t).resolves.toBe(`/Home/move-existing-test/destination/source (${i}).txt`)\n\t\t\t\t// Re-create the file after the move so that the next move also sees a collision\n\t\t\t\tawait fse.writeFile(`${testDirectory}/source.txt`, '')\n\t\t\t}\n\n\t\t\t// Check that creating one more duplicate throws an error\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Home/move-existing-test/source.txt',\n\t\t\t\t\ttoDirectory: `/Home/move-existing-test/destination`,\n\t\t\t\t\tcollision: 'keep-both',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[unique-name-index-exceeded]')\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() moves a single file to a directory', async () => {\n\t\t\t// Create test directory and file\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-file-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/source.txt`, 'test content')\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t\t\t// Verify the destination directory is empty\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject([])\n\n\t\t\t// Move the file\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-file-test/source/source.txt',\n\t\t\t\ttoDirectory: '/Home/move-file-test/destination',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-file-test/destination/source.txt')\n\n\t\t\t// Verify the move: destination should have the file and source should not exist\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject(['source.txt'])\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/source/source.txt`)).resolves.toBe(false)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() uses correct move operation', async () => {\n\t\t\t// Create test directory and file\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-test-impl`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/source.txt`, 'test content')\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t\t\t// Verify the destination directory is empty\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject([])\n\n\t\t\t// Attach spy\n\t\t\tvi.mock('execa', {spy: true})\n\n\t\t\t// Move the file\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-test-impl/source/source.txt',\n\t\t\t\ttoDirectory: '/Home/move-test-impl/destination',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-test-impl/destination/source.txt')\n\n\t\t\t// Check that the correct move operation was used\n\t\t\tif (forceSlowMoveWithProgress === 'true') {\n\t\t\t\t// When UMBRELD_FORCE_SLOW_MOVE_WITH_PROGRESS is true, rsync should be used\n\t\t\t\texpect(execa).toHaveBeenCalledWith('rsync', expect.anything())\n\t\t\t} else {\n\t\t\t\t// When UMBRELD_FORCE_SLOW_MOVE_WITH_PROGRESS is false, normal move should be used\n\t\t\t\texpect(execa).not.toHaveBeenCalled()\n\t\t\t}\n\n\t\t\t// Verify the move: destination should have the file and source should not exist\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject(['source.txt'])\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/source/source.txt`)).resolves.toBe(false)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() moves a single file to a directory with a trailing slash', async () => {\n\t\t\t// Create test directory and file\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-file-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/source.txt`, 'test content')\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t\t\t// Verify the destination directory is empty\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject([])\n\n\t\t\t// Move the file\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-file-test/source/source.txt',\n\t\t\t\ttoDirectory: '/Home/move-file-test/destination/',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-file-test/destination/source.txt')\n\n\t\t\t// Verify the move\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject(['source.txt'])\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/source/source.txt`)).resolves.toBe(false)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() handles moving a file to the current containing directory by doing nothing', async () => {\n\t\t\t// Create test directory and file\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-same-dir-file-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.writeFile(`${testDirectory}/source.txt`, 'original content')\n\n\t\t\t// Get file's initial modified timestamp for comparison\n\t\t\tconst initialStats = await fse.stat(`${testDirectory}/source.txt`)\n\t\t\tconst initialModified = initialStats.mtimeMs\n\n\t\t\t// Try to move the file to its current directory\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-same-dir-file-test/source.txt',\n\t\t\t\ttoDirectory: '/Home/move-same-dir-file-test',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-same-dir-file-test/source.txt')\n\n\t\t\t// Verify the file is still in the same location with the same content\n\t\t\tconst exists = await fse.pathExists(`${testDirectory}/source.txt`)\n\t\t\texpect(exists).toBe(true)\n\t\t\tconst content = await fse.readFile(`${testDirectory}/source.txt`, 'utf8')\n\t\t\texpect(content).toBe('original content')\n\n\t\t\t// Verify the file timestamp hasn't changed\n\t\t\tconst finalStats = await fse.stat(`${testDirectory}/source.txt`)\n\t\t\texpect(finalStats.mtimeMs).toBe(initialModified)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() handles moving a file to a different directory by throwing on name conflict by default', async () => {\n\t\t\t// Create test directory structure\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-file-conflict-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/file.txt`, 'source content')\n\n\t\t\t// Create a destination file with the same name\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\t\t\tawait fse.writeFile(`${testDirectory}/destination/file.txt`, 'destination content')\n\n\t\t\t// Try to move the file and verify that it fails with default 'error' collision strategy\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Home/move-file-conflict-test/source/file.txt',\n\t\t\t\t\ttoDirectory: '/Home/move-file-conflict-test/destination',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[destination-already-exists]')\n\n\t\t\t// Verify source and destination content remains unchanged\n\t\t\tawait expect(fse.readFile(`${testDirectory}/source/file.txt`, 'utf8')).resolves.toBe('source content')\n\t\t\tawait expect(fse.readFile(`${testDirectory}/destination/file.txt`, 'utf8')).resolves.toBe('destination content')\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move(path, {collision: \"keep-both\"}) keeps both files by appending a number to the moved file', async () => {\n\t\t\t// Create test directory structure\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-keep-both-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/file.txt`, 'source content')\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\t\t\tawait fse.writeFile(`${testDirectory}/destination/file.txt`, 'destination content')\n\n\t\t\t// Move the file with 'keep-both' collision strategy\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-keep-both-test/source/file.txt',\n\t\t\t\ttoDirectory: '/Home/move-keep-both-test/destination',\n\t\t\t\tcollision: 'keep-both',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-keep-both-test/destination/file (2).txt')\n\n\t\t\t// Verify both files exist at the destination\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/destination/file.txt`)).resolves.toBe(true)\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/destination/file (2).txt`)).resolves.toBe(true)\n\n\t\t\t// Verify the contents are preserved\n\t\t\tawait expect(fse.readFile(`${testDirectory}/destination/file.txt`, 'utf8')).resolves.toBe('destination content')\n\t\t\tawait expect(fse.readFile(`${testDirectory}/destination/file (2).txt`, 'utf8')).resolves.toBe('source content')\n\n\t\t\t// Verify the source file no longer exists\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/source/file.txt`)).resolves.toBe(false)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move(path, {collision: \"replace\"}) replaces the existing file with the moved file', async () => {\n\t\t\t// Create test directory structure\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-replace-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/file.txt`, 'source content')\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\t\t\tawait fse.writeFile(`${testDirectory}/destination/file.txt`, 'destination content')\n\n\t\t\t// Move the file with 'replace' collision strategy\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-replace-test/source/file.txt',\n\t\t\t\ttoDirectory: '/Home/move-replace-test/destination',\n\t\t\t\tcollision: 'replace',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-replace-test/destination/file.txt')\n\n\t\t\t// Verify the file exists at the destination\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/destination/file.txt`)).resolves.toBe(true)\n\n\t\t\t// Verify the content is replaced\n\t\t\tawait expect(fse.readFile(`${testDirectory}/destination/file.txt`, 'utf8')).resolves.toBe('source content')\n\n\t\t\t// Verify the source file no longer exists\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/source/file.txt`)).resolves.toBe(false)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() moves a directory with contents', async () => {\n\t\t\t// Create test directory structure\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-directory-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/file1.txt`, 'content1')\n\t\t\tawait fse.writeFile(`${testDirectory}/source/file2.txt`, 'content2')\n\t\t\tawait fse.mkdir(`${testDirectory}/source/subdir`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/subdir/file3.txt`, 'content3')\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t\t\t// Verify destination is empty\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject([])\n\n\t\t\t// Move the directory\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-directory-test/source',\n\t\t\t\ttoDirectory: '/Home/move-directory-test/destination',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-directory-test/destination/source')\n\n\t\t\t// Verify the move: destination has the directory and source no longer exists\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject(['source'])\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination/source`)).resolves.toMatchObject([\n\t\t\t\t'file1.txt',\n\t\t\t\t'file2.txt',\n\t\t\t\t'subdir',\n\t\t\t])\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination/source/subdir`)).resolves.toMatchObject(['file3.txt'])\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/source`)).resolves.toBe(false)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() moves a directory with contents with a trailing slash', async () => {\n\t\t\t// Create test directory structure\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-directory-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/file1.txt`, 'content1')\n\t\t\tawait fse.writeFile(`${testDirectory}/source/file2.txt`, 'content2')\n\t\t\tawait fse.mkdir(`${testDirectory}/source/subdir`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/subdir/file3.txt`, 'content3')\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t\t\t// Verify destination directory is empty\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject([])\n\n\t\t\t// Move the directory\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-directory-test/source',\n\t\t\t\ttoDirectory: '/Home/move-directory-test/destination/',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-directory-test/destination/source')\n\n\t\t\t// Verify the move\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject(['source'])\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination/source`)).resolves.toMatchObject([\n\t\t\t\t'file1.txt',\n\t\t\t\t'file2.txt',\n\t\t\t\t'subdir',\n\t\t\t])\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination/source/subdir`)).resolves.toMatchObject(['file3.txt'])\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/source`)).resolves.toBe(false)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() handles moving a directory to the current containing directory by doing nothing', async () => {\n\t\t\t// Create test directory structure\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-same-dir-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/subdir`)\n\t\t\tawait fse.writeFile(`${testDirectory}/subdir/file.txt`, 'test content')\n\n\t\t\t// Get directory's initial modified timestamp for comparison\n\t\t\tconst initialStats = await fse.stat(`${testDirectory}/subdir`)\n\t\t\tconst initialModified = initialStats.mtimeMs\n\n\t\t\t// Try to move the directory to its current parent directory\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-same-dir-test/subdir',\n\t\t\t\ttoDirectory: '/Home/move-same-dir-test',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-same-dir-test/subdir')\n\n\t\t\t// Verify the directory is still in the same location with the same content\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/subdir`)).resolves.toBe(true)\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/subdir/file.txt`)).resolves.toBe(true)\n\t\t\tawait expect(fse.readFile(`${testDirectory}/subdir/file.txt`, 'utf8')).resolves.toBe('test content')\n\n\t\t\t// Verify the directory timestamp hasn't changed\n\t\t\tconst finalStats = await fse.stat(`${testDirectory}/subdir`)\n\t\t\texpect(finalStats.mtimeMs).toBe(initialModified)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() handles moving a directory to a different directory by throwing on name conflict by default', async () => {\n\t\t\t// Create test directory structure\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-dir-conflict-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/file.txt`, 'source content')\n\n\t\t\t// Create a destination directory with the same name\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\t\t\tawait fse.mkdir(`${testDirectory}/destination/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/destination/source/file.txt`, 'destination content')\n\n\t\t\t// Try to move the directory and verify that it fails with default 'error' collision strategy\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.move.mutate({\n\t\t\t\t\tpath: '/Home/move-dir-conflict-test/source',\n\t\t\t\t\ttoDirectory: '/Home/move-dir-conflict-test/destination',\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[destination-already-exists]')\n\n\t\t\t// Verify source and destination content remains unchanged\n\t\t\tawait expect(fse.readFile(`${testDirectory}/source/file.txt`, 'utf8')).resolves.toBe('source content')\n\t\t\tawait expect(fse.readFile(`${testDirectory}/destination/source/file.txt`, 'utf8')).resolves.toBe(\n\t\t\t\t'destination content',\n\t\t\t)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move(path, {collision: \"keep-both\"}) keeps both directories by appending a number to the moved directory', async () => {\n\t\t\t// Create test directory structure\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-dir-keep-both-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/file.txt`, 'source content')\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\t\t\tawait fse.mkdir(`${testDirectory}/destination/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/destination/source/file.txt`, 'destination content')\n\n\t\t\t// Move the directory with 'keep-both' collision strategy\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-dir-keep-both-test/source',\n\t\t\t\ttoDirectory: '/Home/move-dir-keep-both-test/destination',\n\t\t\t\tcollision: 'keep-both',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-dir-keep-both-test/destination/source (2)')\n\n\t\t\t// Verify both directories exist at the destination\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/destination/source`)).resolves.toBe(true)\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/destination/source (2)`)).resolves.toBe(true)\n\n\t\t\t// Verify the contents are preserved in both directories\n\t\t\tawait expect(fse.readFile(`${testDirectory}/destination/source/file.txt`, 'utf8')).resolves.toBe(\n\t\t\t\t'destination content',\n\t\t\t)\n\t\t\tawait expect(fse.readFile(`${testDirectory}/destination/source (2)/file.txt`, 'utf8')).resolves.toBe(\n\t\t\t\t'source content',\n\t\t\t)\n\n\t\t\t// Verify the source directory no longer exists\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/source`)).resolves.toBe(false)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move(path, {collision: \"replace\"}) replaces the existing directory with the moved directory', async () => {\n\t\t\t// Create test directory structure\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-dir-replace-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/file1.txt`, 'file 1 source content')\n\t\t\tawait fse.writeFile(`${testDirectory}/source/file2.txt`, 'file 2 source content')\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\t\t\tawait fse.mkdir(`${testDirectory}/destination/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/destination/source/file1.txt`, 'file 1 destination content')\n\t\t\tawait fse.writeFile(`${testDirectory}/destination/source/file3.txt`, 'file 3 destination content')\n\n\t\t\t// Move the directory with 'replace' collision strategy\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-dir-replace-test/source',\n\t\t\t\ttoDirectory: '/Home/move-dir-replace-test/destination',\n\t\t\t\tcollision: 'replace',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-dir-replace-test/destination/source')\n\n\t\t\t// Verify the directory exists at the destination\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/destination/source`)).resolves.toBe(true)\n\n\t\t\t// Verify source content replaced the destination content\n\t\t\tawait expect(fse.readFile(`${testDirectory}/destination/source/file1.txt`, 'utf8')).resolves.toBe(\n\t\t\t\t'file 1 source content',\n\t\t\t)\n\t\t\tawait expect(fse.readFile(`${testDirectory}/destination/source/file2.txt`, 'utf8')).resolves.toBe(\n\t\t\t\t'file 2 source content',\n\t\t\t)\n\n\t\t\t// Verify the destination-only file no longer exists (was replaced)\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/destination/source/file3.txt`)).resolves.toBe(false)\n\n\t\t\t// Verify the source directory no longer exists\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/source`)).resolves.toBe(false)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() moves symlinks as symlinks', async () => {\n\t\t\t// Create test directory and file\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-symlink-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/file.txt`, 'content')\n\t\t\t// Create a symlink in the source directory\n\t\t\tawait fse.symlink(`${testDirectory}/source/file.txt`, `${testDirectory}/source/link`)\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t\t\t// Move the symlink\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-symlink-test/source/link',\n\t\t\t\ttoDirectory: '/Home/move-symlink-test/destination',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-symlink-test/destination/link')\n\n\t\t\t// Verify the symlink was moved as a symlink\n\t\t\tconst isSymlink = await fse.lstat(`${testDirectory}/destination/link`).then((stats) => stats.isSymbolicLink())\n\t\t\texpect(isSymlink).toBe(true)\n\n\t\t\t// Verify the symlink points to the correct target (the target remains unchanged)\n\t\t\tconst linkTarget = await fse.readlink(`${testDirectory}/destination/link`)\n\t\t\texpect(linkTarget).toBe(`${testDirectory}/source/file.txt`)\n\n\t\t\t// Verify that reading through the symlink works\n\t\t\tconst content = await fse.readFile(`${testDirectory}/destination/link`, 'utf8')\n\t\t\texpect(content).toBe('content')\n\n\t\t\t// Also, check that the original symlink no longer exists\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/source/link`)).resolves.toBe(false)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() moves files inside a symlink', async () => {\n\t\t\t// Create test directory and file\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-symlink-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tawait fse.writeFile(`${testDirectory}/source/file.txt`, 'content')\n\t\t\t// Create a symlink pointing to the source directory\n\t\t\tawait fse.symlink(`${testDirectory}/source`, `${testDirectory}/symlink`)\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t\t\t// Move the file through the symlink path\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-symlink-test/symlink/file.txt',\n\t\t\t\ttoDirectory: '/Home/move-symlink-test/destination',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-symlink-test/destination/file.txt')\n\n\t\t\t// Verify that the file was moved and the original no longer exists\n\t\t\tawait expect(fse.readdir(`${testDirectory}/destination`)).resolves.toMatchObject(['file.txt'])\n\t\t\tawait expect(fse.pathExists(`${testDirectory}/source/file.txt`)).resolves.toBe(false)\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() preserves file permissions, ownership and timestamps', async () => {\n\t\t\t// Create test directory and file\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-permissions-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\t\tconst sourceFile = `${testDirectory}/source/file.txt`\n\t\t\tawait fse.writeFile(sourceFile, 'test content')\n\t\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t\t\t// Set specific permissions (0o644 = rw-r--r--) and timestamps\n\t\t\tconst originalPermissions = 0o644\n\t\t\tawait fse.chmod(sourceFile, originalPermissions)\n\n\t\t\t// Set specific ownership (use umbrel user ID from files class)\n\t\t\tconst uid = 1234\n\t\t\tconst gid = 1234\n\t\t\tawait fse.chown(sourceFile, uid, gid)\n\n\t\t\t// Set a specific timestamp (1 day ago)\n\t\t\tconst pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000)\n\t\t\tawait fse.utimes(sourceFile, pastDate, pastDate)\n\n\t\t\t// Get original stats for later comparison\n\t\t\tconst originalStats = await fse.stat(sourceFile)\n\n\t\t\t// Check time applied correctly\n\t\t\texpect(originalStats.mtime.getTime()).toBe(pastDate.getTime())\n\n\t\t\t// Move the file\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-permissions-test/source/file.txt',\n\t\t\t\ttoDirectory: '/Home/move-permissions-test/destination',\n\t\t\t})\n\t\t\texpect(result).toBe('/Home/move-permissions-test/destination/file.txt')\n\n\t\t\t// Get stats of the moved file\n\t\t\tconst movedFile = `${testDirectory}/destination/file.txt`\n\t\t\tconst movedStats = await fse.stat(movedFile)\n\n\t\t\t// Verify the permissions are preserved\n\t\t\texpect(movedStats.mode).toBe(originalStats.mode)\n\n\t\t\t// Verify ownership is preserved\n\t\t\texpect(movedStats.uid).toBe(originalStats.uid)\n\t\t\texpect(movedStats.gid).toBe(originalStats.gid)\n\n\t\t\t// Verify the timestamps are preserved\n\t\t\texpect(movedStats.mtime.getTime()).toBe(originalStats.mtime.getTime())\n\n\t\t\t// Clean up\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\n\t\ttest('move() to same directory is a no-op', async () => {\n\t\t\t// Create test directory and file\n\t\t\ttestDirectory = `${umbreld.instance.dataDirectory}/home/move-same-directory-test`\n\t\t\tawait fse.mkdir(testDirectory)\n\t\t\tawait fse.writeFile(`${testDirectory}/source.txt`, 'content')\n\n\t\t\t// Attempt to move the file to the same directory it is already in.\n\t\t\t// With the new behavior, we should receive the original virtual path with no\n\t\t\t// renaming occurring.\n\t\t\tconst result = await umbreld.client.files.move.mutate({\n\t\t\t\tpath: '/Home/move-same-directory-test/source.txt',\n\t\t\t\ttoDirectory: '/Home/move-same-directory-test',\n\t\t\t})\n\t\t\t// Since the destination is the file's containing folder, the move operation is a no-op.\n\t\t\texpect(result).toBe('/Home/move-same-directory-test/source.txt')\n\n\t\t\t// Verify that the file still exists at the same location\n\t\t\tawait expect(fse.readdir(testDirectory)).resolves.toMatchObject(['source.txt'])\n\n\t\t\t// Clean up the test directory\n\t\t\tawait fse.remove(testDirectory)\n\t\t})\n\t})\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/files.operationProgress.test.ts",
    "content": "import {expect, beforeAll, afterAll, test, describe} from 'vitest'\n\nimport fse from 'fs-extra'\nimport {delay} from 'es-toolkit'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\n// This simulates a move across filesystems\nprocess.env.UMBRELD_FORCE_SLOW_MOVE_WITH_PROGRESS = 'true'\n// This forces 100 KB/s copies so we can test progress\nprocess.env.UMBRELD_FORCE_100KBS_COPY = 'true'\n\ndescribe(`operationProgress()`, () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.operationProgress.query()).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('returns an empty array if no operations are in progress', async () => {\n\t\tawait expect(umbreld.client.files.operationProgress.query()).resolves.toMatchObject([])\n\t})\n\n\ttest('returns copy progress', async () => {\n\t\t// Create test directory and file\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/test-copy-progress`\n\t\tawait fse.mkdir(testDirectory)\n\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\tconst KB = 1024\n\t\tawait fse.writeFile(`${testDirectory}/source/source.bin`, Buffer.alloc(100 * KB))\n\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t\t// Listen for all copy events\n\t\t// TODO: use actual tRPC subscriptions for this\n\t\tconst collectedEvents: any[] = []\n\t\tconst removeListener = umbreld.instance.eventBus.on(\n\t\t\t'files:operation-progress',\n\t\t\t(operations) => void collectedEvents.push(JSON.parse(JSON.stringify(operations))),\n\t\t)\n\t\t// Test we start with no operations in progress\n\t\tawait expect(umbreld.client.files.operationProgress.query()).resolves.toMatchObject([])\n\n\t\t// Copy the file\n\t\tconst copyPromise = umbreld.client.files.copy.mutate({\n\t\t\tpath: '/Home/test-copy-progress/source/source.bin',\n\t\t\ttoDirectory: '/Home/test-copy-progress/destination',\n\t\t})\n\n\t\t// Wait for the copy to start\n\t\tawait delay(100)\n\n\t\t// Test we have a copy operation in progress\n\t\tawait expect(umbreld.client.files.operationProgress.query()).resolves.toMatchObject([\n\t\t\t{\n\t\t\t\ttype: 'copy',\n\t\t\t\tfile: expect.objectContaining({\n\t\t\t\t\tpath: '/Home/test-copy-progress/source/source.bin',\n\t\t\t\t}),\n\t\t\t\tdestinationPath: '/Home/test-copy-progress/destination/source.bin',\n\t\t\t\tpercent: expect.any(Number),\n\t\t\t\tbytesPerSecond: expect.any(Number),\n\t\t\t},\n\t\t])\n\n\t\t// Wait for the copy to complete\n\t\tconst result = await copyPromise\n\t\texpect(result).toBe('/Home/test-copy-progress/destination/source.bin')\n\n\t\t// Test we end with no operations in progress\n\t\tawait expect(umbreld.client.files.operationProgress.query()).resolves.toMatchObject([])\n\n\t\t// Test all the events we collected\n\t\texpect(collectedEvents.length).toBeGreaterThanOrEqual(3)\n\t\texpect(collectedEvents.at(0)).toMatchObject([\n\t\t\t{\n\t\t\t\ttype: 'copy',\n\t\t\t\tfile: expect.objectContaining({\n\t\t\t\t\tpath: '/Home/test-copy-progress/source/source.bin',\n\t\t\t\t}),\n\t\t\t\tdestinationPath: '/Home/test-copy-progress/destination/source.bin',\n\t\t\t\tpercent: 0,\n\t\t\t\tbytesPerSecond: 0,\n\t\t\t},\n\t\t])\n\t\texpect(collectedEvents.at(-2)).toMatchObject([\n\t\t\t{\n\t\t\t\ttype: 'copy',\n\t\t\t\tfile: expect.objectContaining({\n\t\t\t\t\tpath: '/Home/test-copy-progress/source/source.bin',\n\t\t\t\t}),\n\t\t\t\tdestinationPath: '/Home/test-copy-progress/destination/source.bin',\n\t\t\t\tpercent: 100,\n\t\t\t\tbytesPerSecond: expect.any(Number),\n\t\t\t\tsecondsRemaining: 0,\n\t\t\t},\n\t\t])\n\t\texpect(collectedEvents.at(-1)).toMatchObject([])\n\n\t\t// Clean up\n\t\tremoveListener()\n\t\tawait fse.remove(testDirectory)\n\t})\n\n\ttest('returns move progress during inter filesystem move', async () => {\n\t\t// Create test directory and file\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/test-move-progress`\n\t\tawait fse.mkdir(testDirectory)\n\t\tawait fse.mkdir(`${testDirectory}/source`)\n\t\tconst KB = 1024\n\t\tawait fse.writeFile(`${testDirectory}/source/source.bin`, Buffer.alloc(100 * KB))\n\t\tawait fse.mkdir(`${testDirectory}/destination`)\n\n\t\t// Listen for all copy events\n\t\t// TODO: use actual tRPC subscriptions for this\n\t\tconst collectedEvents: any[] = []\n\t\tconst removeListener = umbreld.instance.eventBus.on(\n\t\t\t'files:operation-progress',\n\t\t\t(operations) => void collectedEvents.push(JSON.parse(JSON.stringify(operations))),\n\t\t)\n\t\t// Test we start with no operations in progress\n\t\tawait expect(umbreld.client.files.operationProgress.query()).resolves.toMatchObject([])\n\n\t\t// Move the file\n\t\tconst movePromise = umbreld.client.files.move.mutate({\n\t\t\tpath: '/Home/test-move-progress/source/source.bin',\n\t\t\ttoDirectory: '/Home/test-move-progress/destination',\n\t\t})\n\n\t\t// Wait for the move to start\n\t\tawait delay(100)\n\n\t\t// Test we have a move operation in progress\n\t\tawait expect(umbreld.client.files.operationProgress.query()).resolves.toMatchObject([\n\t\t\t{\n\t\t\t\ttype: 'move',\n\t\t\t\tfile: expect.objectContaining({\n\t\t\t\t\tpath: '/Home/test-move-progress/source/source.bin',\n\t\t\t\t}),\n\t\t\t\tdestinationPath: '/Home/test-move-progress/destination/source.bin',\n\t\t\t\tpercent: expect.any(Number),\n\t\t\t\tbytesPerSecond: expect.any(Number),\n\t\t\t},\n\t\t])\n\n\t\t// Wait for the move to complete\n\t\tconst result = await movePromise\n\t\texpect(result).toBe('/Home/test-move-progress/destination/source.bin')\n\n\t\t// Test we end with no operations in progress\n\t\tawait expect(umbreld.client.files.operationProgress.query()).resolves.toMatchObject([])\n\n\t\t// Test all the events we collected\n\t\texpect(collectedEvents.length).toBeGreaterThanOrEqual(3)\n\t\texpect(collectedEvents.at(0)).toMatchObject([\n\t\t\t{\n\t\t\t\ttype: 'move',\n\t\t\t\tfile: expect.objectContaining({\n\t\t\t\t\tpath: '/Home/test-move-progress/source/source.bin',\n\t\t\t\t}),\n\t\t\t\tdestinationPath: '/Home/test-move-progress/destination/source.bin',\n\t\t\t\tpercent: 0,\n\t\t\t\tbytesPerSecond: 0,\n\t\t\t},\n\t\t])\n\t\texpect(collectedEvents.at(-2)).toMatchObject([\n\t\t\t{\n\t\t\t\ttype: 'move',\n\t\t\t\tfile: expect.objectContaining({\n\t\t\t\t\tpath: '/Home/test-move-progress/source/source.bin',\n\t\t\t\t}),\n\t\t\t\tdestinationPath: '/Home/test-move-progress/destination/source.bin',\n\t\t\t\tpercent: 100,\n\t\t\t\tbytesPerSecond: expect.any(Number),\n\t\t\t\tsecondsRemaining: 0,\n\t\t\t},\n\t\t])\n\t\texpect(collectedEvents.at(-1)).toMatchObject([])\n\n\t\t// Clean up\n\t\tremoveListener()\n\t\tawait fse.remove(testDirectory)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/files.preferences.integration.test.ts",
    "content": "import {expect, beforeAll, afterAll, beforeEach, describe, test} from 'vitest'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\ndescribe('viewPreferences()', () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.viewPreferences.query()).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('returns default view preferences on first start', async () => {\n\t\tconst viewPreferences = await umbreld.client.files.viewPreferences.query()\n\t\texpect(viewPreferences).toStrictEqual({\n\t\t\tview: 'list',\n\t\t\tsortBy: 'name',\n\t\t\tsortOrder: 'ascending',\n\t\t})\n\t})\n\n\ttest('returns updated view preferences from the store', async () => {\n\t\t// Write a modified value to the store\n\t\tawait umbreld.instance.store.set('files.preferences', {\n\t\t\tview: 'icons',\n\t\t\tsortBy: 'modified',\n\t\t\tsortOrder: 'descending',\n\t\t})\n\n\t\tconst viewPreferences = await umbreld.client.files.viewPreferences.query()\n\t\texpect(viewPreferences).toStrictEqual({\n\t\t\tview: 'icons',\n\t\t\tsortBy: 'modified',\n\t\t\tsortOrder: 'descending',\n\t\t})\n\t})\n})\n\ndescribe('updateViewPreferences()', () => {\n\t// Reset to default state before each test\n\tbeforeEach(async () => {\n\t\tawait umbreld.instance.store.set('files.preferences', {\n\t\t\tview: 'list',\n\t\t\tsortBy: 'name',\n\t\t\tsortOrder: 'ascending',\n\t\t})\n\t})\n\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(\n\t\t\tumbreld.unauthenticatedClient.files.updateViewPreferences.mutate({\n\t\t\t\tview: 'icons',\n\t\t\t}),\n\t\t).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('successfully updates view property', async () => {\n\t\tconst updatedPreferences = await umbreld.client.files.updateViewPreferences.mutate({\n\t\t\tview: 'icons',\n\t\t})\n\n\t\texpect(updatedPreferences).toStrictEqual({\n\t\t\tview: 'icons',\n\t\t\tsortBy: 'name',\n\t\t\tsortOrder: 'ascending',\n\t\t})\n\n\t\t// Verify the preferences were saved to the store\n\t\tconst storedPreferences = await umbreld.instance.store.get('files.preferences')\n\t\texpect(storedPreferences).toStrictEqual({\n\t\t\tview: 'icons',\n\t\t\tsortBy: 'name',\n\t\t\tsortOrder: 'ascending',\n\t\t})\n\t})\n\n\ttest('successfully updates sortBy property', async () => {\n\t\tconst updatedPreferences = await umbreld.client.files.updateViewPreferences.mutate({\n\t\t\tsortBy: 'modified',\n\t\t})\n\n\t\texpect(updatedPreferences).toStrictEqual({\n\t\t\tview: 'list',\n\t\t\tsortBy: 'modified',\n\t\t\tsortOrder: 'ascending',\n\t\t})\n\n\t\t// Verify the preferences were saved to the store\n\t\tconst storedPreferences = await umbreld.instance.store.get('files.preferences')\n\t\texpect(storedPreferences).toStrictEqual({\n\t\t\tview: 'list',\n\t\t\tsortBy: 'modified',\n\t\t\tsortOrder: 'ascending',\n\t\t})\n\t})\n\n\ttest('successfully updates sortOrder property', async () => {\n\t\tconst updatedPreferences = await umbreld.client.files.updateViewPreferences.mutate({\n\t\t\tsortOrder: 'descending',\n\t\t})\n\n\t\texpect(updatedPreferences).toStrictEqual({\n\t\t\tview: 'list',\n\t\t\tsortBy: 'name',\n\t\t\tsortOrder: 'descending',\n\t\t})\n\n\t\t// Verify the preferences were saved to the store\n\t\tconst storedPreferences = await umbreld.instance.store.get('files.preferences')\n\t\texpect(storedPreferences).toStrictEqual({\n\t\t\tview: 'list',\n\t\t\tsortBy: 'name',\n\t\t\tsortOrder: 'descending',\n\t\t})\n\t})\n\n\ttest('successfully updates multiple properties in a single call', async () => {\n\t\tconst updatedPreferences = await umbreld.client.files.updateViewPreferences.mutate({\n\t\t\tview: 'icons',\n\t\t\tsortBy: 'size',\n\t\t\tsortOrder: 'descending',\n\t\t})\n\n\t\texpect(updatedPreferences).toStrictEqual({\n\t\t\tview: 'icons',\n\t\t\tsortBy: 'size',\n\t\t\tsortOrder: 'descending',\n\t\t})\n\n\t\t// Verify the preferences were saved to the store\n\t\tconst storedPreferences = await umbreld.instance.store.get('files.preferences')\n\t\texpect(storedPreferences).toStrictEqual({\n\t\t\tview: 'icons',\n\t\t\tsortBy: 'size',\n\t\t\tsortOrder: 'descending',\n\t\t})\n\t})\n\n\ttest('preserves existing properties when updating partial preferences', async () => {\n\t\t// Set initial non-default preferences\n\t\tawait umbreld.instance.store.set('files.preferences', {\n\t\t\tview: 'icons',\n\t\t\tsortBy: 'modified',\n\t\t\tsortOrder: 'descending',\n\t\t})\n\n\t\t// Update just one property\n\t\tconst updatedPreferences = await umbreld.client.files.updateViewPreferences.mutate({\n\t\t\tsortBy: 'size',\n\t\t})\n\n\t\t// Check that only the specified property was updated\n\t\texpect(updatedPreferences).toStrictEqual({\n\t\t\tview: 'icons',\n\t\t\tsortBy: 'size',\n\t\t\tsortOrder: 'descending',\n\t\t})\n\t})\n\n\ttest('handles sequential updates correctly', async () => {\n\t\t// Update step 1\n\t\tawait umbreld.client.files.updateViewPreferences.mutate({\n\t\t\tview: 'icons',\n\t\t})\n\n\t\t// Update step 2\n\t\tawait umbreld.client.files.updateViewPreferences.mutate({\n\t\t\tsortBy: 'modified',\n\t\t})\n\n\t\t// Update step 3\n\t\tconst finalPreferences = await umbreld.client.files.updateViewPreferences.mutate({\n\t\t\tsortOrder: 'descending',\n\t\t})\n\n\t\t// Check final state\n\t\texpect(finalPreferences).toStrictEqual({\n\t\t\tview: 'icons',\n\t\t\tsortBy: 'modified',\n\t\t\tsortOrder: 'descending',\n\t\t})\n\n\t\t// Verify the store has the correct final state\n\t\tconst storedPreferences = await umbreld.instance.store.get('files.preferences')\n\t\texpect(storedPreferences).toStrictEqual({\n\t\t\tview: 'icons',\n\t\t\tsortBy: 'modified',\n\t\t\tsortOrder: 'descending',\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/files.rename.integration.test.ts",
    "content": "import {expect, beforeAll, afterAll, test} from 'vitest'\nimport fse from 'fs-extra'\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\ntest('rename() throws invalid error without auth token', async () => {\n\tawait expect(\n\t\tumbreld.unauthenticatedClient.files.rename.mutate({path: '/Home/Documents', newName: 'Documents-copy'}),\n\t).rejects.toThrow('Invalid token')\n})\n\ntest('rename() throws when newName is empty', async () => {\n\tconst testDir = `${umbreld.instance.dataDirectory}/home/rename-test-empty`\n\tawait fse.mkdir(testDir, {recursive: true})\n\n\tconst filePath = `${testDir}/empty.txt`\n\tawait fse.writeFile(filePath, 'content empty')\n\n\tconst virtualFilePath = '/Home/rename-test-empty/empty.txt'\n\tawait expect(\n\t\tumbreld.client.files.rename.mutate({\n\t\t\tpath: virtualFilePath,\n\t\t\tnewName: '',\n\t\t}),\n\t).rejects.toThrow('String must contain')\n\n\tawait fse.remove(testDir)\n})\n\ntest('rename() throws on protected paths', async () => {\n\t// In our implementation, /Home/Downloads is protected.\n\t// Ensure the directory exists.\n\tconst testDir = `${umbreld.instance.dataDirectory}/home/Downloads`\n\tawait fse.mkdir(testDir, {recursive: true})\n\n\tawait expect(\n\t\tumbreld.client.files.rename.mutate({\n\t\t\tpath: '/Home/Downloads',\n\t\t\tnewName: 'DownloadsRenamed',\n\t\t}),\n\t).rejects.toThrow('[operation-not-allowed]')\n})\n\ntest('rename() throws when source file/directory does not exist', async () => {\n\t// Create a valid directory but do not create the file to be renamed.\n\tconst testDir = `${umbreld.instance.dataDirectory}/home/rename-nonexistent`\n\tawait fse.mkdir(testDir, {recursive: true})\n\n\tconst virtualFilePath = '/Home/rename-nonexistent/nonexistent.txt'\n\tawait expect(\n\t\tumbreld.client.files.rename.mutate({\n\t\t\tpath: virtualFilePath,\n\t\t\tnewName: 'shouldNotMatter.txt',\n\t\t}),\n\t).rejects.toThrow('[source-not-exists]')\n\tawait fse.remove(testDir)\n})\n\ntest('rename() throws when the source virtual path is not absolute', async () => {\n\t// Passing a non-absolute path should throw an error during conversion.\n\tawait expect(\n\t\tumbreld.client.files.rename.mutate({\n\t\t\tpath: 'Home/relative/file.txt',\n\t\t\tnewName: 'renamed.txt',\n\t\t}),\n\t).rejects.toThrow('[path-not-absolute]')\n})\n\ntest('rename() throws when destination already exists', async () => {\n\tconst testDir = `${umbreld.instance.dataDirectory}/home/rename-test-unique`\n\tawait fse.mkdir(testDir, {recursive: true})\n\n\t// Create a file at the destination name that should conflict.\n\tconst conflictPath = `${testDir}/target.txt`\n\tawait fse.writeFile(conflictPath, 'conflict')\n\n\t// Create the file that we want to rename.\n\tconst originalPath = `${testDir}/original.txt`\n\tawait fse.writeFile(originalPath, 'original content')\n\n\tconst virtualOriginalPath = '/Home/rename-test-unique/original.txt'\n\tawait expect(\n\t\tumbreld.client.files.rename.mutate({\n\t\t\tpath: virtualOriginalPath,\n\t\t\tnewName: 'target.txt',\n\t\t}),\n\t).rejects.toThrow('[destination-already-exists]')\n\n\tawait fse.remove(testDir)\n})\n\ntest('rename() throws on filename traversal attack', async () => {\n\tconst testDir = `${umbreld.instance.dataDirectory}/home/rename-test-invalid`\n\tawait fse.mkdir(testDir, {recursive: true})\n\n\t// Create a source file that will be attempted to be renamed.\n\tconst originalFilePath = `${testDir}/original.txt`\n\tawait fse.writeFile(originalFilePath, 'some content')\n\n\tconst virtualFilePath = '/Home/rename-test-invalid/original.txt'\n\tawait expect(\n\t\tumbreld.client.files.rename.mutate({\n\t\t\tpath: virtualFilePath,\n\t\t\tnewName: 'traversal/attack.txt',\n\t\t}),\n\t).rejects.toThrow('[invalid-filename]')\n\tawait expect(\n\t\tumbreld.client.files.rename.mutate({\n\t\t\tpath: virtualFilePath,\n\t\t\tnewName: 'traversal/../attack.txt',\n\t\t}),\n\t).rejects.toThrow('[invalid-filename]')\n\n\tawait fse.remove(testDir)\n})\n\ntest('rename() throws on invalid characters in filename', async () => {\n\tconst testDir = `${umbreld.instance.dataDirectory}/home/rename-test-invalid`\n\tawait fse.mkdir(testDir, {recursive: true})\n\n\t// Create a source file that will be attempted to be renamed.\n\tconst originalFilePath = `${testDir}/original.txt`\n\tawait fse.writeFile(originalFilePath, 'some content')\n\n\tconst virtualFilePath = '/Home/rename-test-invalid/original.txt'\n\tawait expect(\n\t\tumbreld.client.files.rename.mutate({\n\t\t\tpath: virtualFilePath,\n\t\t\tnewName: 'invalid:name.txt',\n\t\t}),\n\t).rejects.toThrow('[invalid-filename]')\n\n\tawait fse.remove(testDir)\n})\n\ntest('rename() renames a file successfully', async () => {\n\tconst testDir = `${umbreld.instance.dataDirectory}/home/rename-test-file`\n\tawait fse.mkdir(testDir, {recursive: true})\n\n\tconst originalFilePath = `${testDir}/original.txt`\n\tawait fse.writeFile(originalFilePath, 'hello world')\n\n\tconst virtualOriginalPath = '/Home/rename-test-file/original.txt'\n\tconst result = await umbreld.client.files.rename.mutate({\n\t\tpath: virtualOriginalPath,\n\t\tnewName: 'renamed.txt',\n\t})\n\texpect(result).toBe('/Home/rename-test-file/renamed.txt')\n\n\t// Check that the original file no longer exists\n\texpect(await fse.pathExists(originalFilePath)).toBe(false)\n\t// Check that the renamed file exists in the system\n\tconst renamedSystemPath = `${testDir}/renamed.txt`\n\texpect(await fse.pathExists(renamedSystemPath)).toBe(true)\n\n\tawait fse.remove(testDir)\n})\n\ntest('rename() renames a directory successfully', async () => {\n\tconst testDir = `${umbreld.instance.dataDirectory}/home/rename-test-dir`\n\tawait fse.mkdir(testDir, {recursive: true})\n\n\tconst originalDirPath = `${testDir}/original_dir`\n\tawait fse.mkdir(originalDirPath)\n\t// Create a file inside the directory\n\tawait fse.writeFile(`${originalDirPath}/file.txt`, 'content')\n\n\tconst virtualOriginalPath = '/Home/rename-test-dir/original_dir'\n\tconst result = await umbreld.client.files.rename.mutate({\n\t\tpath: virtualOriginalPath,\n\t\tnewName: 'renamed_dir',\n\t})\n\texpect(result).toBe('/Home/rename-test-dir/renamed_dir')\n\n\tconst renamedDirSystemPath = `${testDir}/renamed_dir`\n\texpect(await fse.pathExists(renamedDirSystemPath)).toBe(true)\n\t// Verify file inside the renamed directory is still present\n\texpect(await fse.pathExists(`${renamedDirSystemPath}/file.txt`)).toBe(true)\n\n\tawait fse.remove(testDir)\n})\n\ntest('rename() returns the same path when newName is identical to current name', async () => {\n\tconst testDir = `${umbreld.instance.dataDirectory}/home/rename-test-same`\n\tawait fse.mkdir(testDir, {recursive: true})\n\n\tconst filePath = `${testDir}/same.txt`\n\tawait fse.writeFile(filePath, 'content same')\n\n\tconst virtualFilePath = '/Home/rename-test-same/same.txt'\n\tconst result = await umbreld.client.files.rename.mutate({\n\t\tpath: virtualFilePath,\n\t\tnewName: 'same.txt',\n\t})\n\t// No change is needed so the original virtual path is returned.\n\texpect(result).toBe(virtualFilePath)\n\texpect(await fse.pathExists(filePath)).toBe(true)\n\n\tawait fse.remove(testDir)\n})\n\ntest('rename() renames a symlink without altering its target', async () => {\n\tconst testDir = `${umbreld.instance.dataDirectory}/home/rename-test-symlink`\n\tawait fse.mkdir(testDir, {recursive: true})\n\n\t// Create a target file.\n\tconst targetFile = `${testDir}/target.txt`\n\tawait fse.writeFile(targetFile, 'link content')\n\n\t// Create a symlink pointing to the target file.\n\tconst symlinkPath = `${testDir}/link`\n\tawait fse.symlink(targetFile, symlinkPath)\n\n\tconst virtualSymlinkPath = '/Home/rename-test-symlink/link'\n\tconst result = await umbreld.client.files.rename.mutate({\n\t\tpath: virtualSymlinkPath,\n\t\tnewName: 'link-renamed',\n\t})\n\texpect(result).toBe('/Home/rename-test-symlink/link-renamed')\n\n\tconst newSymlinkSystemPath = `${testDir}/link-renamed`\n\tconst stats = await fse.lstat(newSymlinkSystemPath)\n\texpect(stats.isSymbolicLink()).toBe(true)\n\t// Verify that the symlink still points to the same target.\n\tconst symlinkTarget = await fse.readlink(newSymlinkSystemPath)\n\texpect(symlinkTarget).toBe(targetFile)\n\n\tawait fse.remove(testDir)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/files.restore.test.ts",
    "content": "import {expect, beforeAll, afterAll, afterEach, test} from 'vitest'\nimport fse from 'fs-extra'\nimport {$} from 'execa'\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\nafterEach(async () => {\n\t// Nuke trash state after each test\n\tconst trashDir = `${umbreld.instance.dataDirectory}/trash`\n\tconst trashMetaDir = `${umbreld.instance.dataDirectory}/trash-meta`\n\tfor (const file of await fse.readdir(trashDir)) await fse.remove(`${trashDir}/${file}`)\n\tfor (const file of await fse.readdir(trashMetaDir)) await fse.remove(`${trashMetaDir}/${file}`)\n})\n\ntest('restore() throws invalid error without auth token', async () => {\n\t// Now try to restore without auth\n\tawait expect(umbreld.unauthenticatedClient.files.restore.mutate({path: '/Trash/foo'})).rejects.toThrow(\n\t\t'Invalid token',\n\t)\n})\n\ntest('restore() throws on directory traversal attempt', async () => {\n\tawait expect(\n\t\tumbreld.client.files.restore.mutate({\n\t\t\tpath: '/Trash/../../../../etc',\n\t\t}),\n\t).rejects.toThrow('[operation-not-allowed]')\n})\n\ntest('restore() throws on symlink traversal attempt', async () => {\n\t// Create a symlink to the root directory\n\tawait $`ln -s / ${umbreld.instance.dataDirectory}/trash/symlink-to-root`\n\n\tawait expect(\n\t\tumbreld.client.files.restore.mutate({\n\t\t\tpath: '/Trash/symlink-to-root/etc',\n\t\t}),\n\t).rejects.toThrow('[escapes-base]')\n\n\t// Clean up\n\tawait fse.remove(`${umbreld.instance.dataDirectory}/trash/symlink-to-root`)\n})\n\ntest('restore() throws on relative paths', async () => {\n\tawait Promise.all(\n\t\t['', ' ', '.', '..', 'Trash', 'Trash/..', 'Trash/file.txt'].map(async (path) => {\n\t\t\tawait expect(\n\t\t\t\tumbreld.client.files.restore.mutate({\n\t\t\t\t\tpath,\n\t\t\t\t}),\n\t\t\t).rejects.toThrow('[operation-not-allowed]')\n\t\t}),\n\t)\n})\n\ntest('restore() throws on non-existent path', async () => {\n\tawait expect(\n\t\tumbreld.client.files.restore.mutate({\n\t\t\tpath: '/Trash/DoesNotExist',\n\t\t}),\n\t).rejects.toThrow('[source-not-exists]')\n})\n\ntest('restore() throws on non-trash paths', async () => {\n\tawait expect(\n\t\tumbreld.client.files.restore.mutate({\n\t\t\tpath: '/Home/file.txt',\n\t\t}),\n\t).rejects.toThrow('[operation-not-allowed]')\n})\n\ntest('restore() throws when metadata file is missing', async () => {\n\t// Create a file in trash without metadata\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/trash/no-meta.txt`, 'content')\n\n\tawait expect(\n\t\tumbreld.client.files.restore.mutate({\n\t\t\tpath: '/Trash/no-meta.txt',\n\t\t}),\n\t).rejects.toThrow('[trash-meta-not-exists]')\n\n\t// Clean up\n\tawait fse.remove(`${umbreld.instance.dataDirectory}/trash/no-meta.txt`)\n})\n\ntest('restore() successfully restores a file from trash', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/restore-file-test`\n\tawait fse.mkdir(testDirectory, {recursive: true})\n\tawait fse.writeFile(`${testDirectory}/restore-file.txt`, 'test content')\n\n\tconst virtualPath = '/Home/restore-file-test/restore-file.txt'\n\n\t// Trash the file using the actual trash method\n\tconst trashPath = await umbreld.client.files.trash.mutate({\n\t\tpath: virtualPath,\n\t})\n\texpect(trashPath).toBe('/Trash/restore-file.txt')\n\n\t// Verify the file no longer exists at the original location\n\tawait expect(fse.pathExists(`${testDirectory}/restore-file.txt`)).resolves.toBe(false)\n\n\t// Now restore the file\n\tawait expect(umbreld.client.files.restore.mutate({path: trashPath})).resolves.toBe(virtualPath)\n\n\t// Verify the file no longer exists in trash\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}/trash/restore-file.txt`)).resolves.toBe(false)\n\n\t// Verify the file exists at the original location\n\tawait expect(fse.pathExists(`${testDirectory}/restore-file.txt`)).resolves.toBe(true)\n\n\t// Verify the content is preserved\n\tawait expect(fse.readFile(`${testDirectory}/restore-file.txt`, 'utf8')).resolves.toBe('test content')\n\n\t// Verify metadata file is deleted\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}/trash-meta/restore-file.txt.json`)).resolves.toBe(\n\t\tfalse,\n\t)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('restore() successfully restores a directory with contents from trash', async () => {\n\t// Create test directory structure\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/restore-dir-test`\n\tawait fse.mkdir(testDirectory, {recursive: true})\n\tawait fse.mkdir(`${testDirectory}/restore-dir`)\n\tawait fse.writeFile(`${testDirectory}/restore-dir/file1.txt`, 'content1')\n\tawait fse.writeFile(`${testDirectory}/restore-dir/file2.txt`, 'content2')\n\tawait fse.mkdir(`${testDirectory}/restore-dir/nested`)\n\tawait fse.writeFile(`${testDirectory}/restore-dir/nested/file3.txt`, 'content3')\n\n\t// Trash the directory using the actual trash method\n\tconst trashPath = await umbreld.client.files.trash.mutate({\n\t\tpath: '/Home/restore-dir-test/restore-dir',\n\t})\n\n\t// Verify the directory is moved to trash\n\tawait expect(fse.pathExists(`${testDirectory}/restore-dir`)).resolves.toBe(false)\n\n\t// Now restore the directory\n\tconst restoredPath = await umbreld.client.files.restore.mutate({\n\t\tpath: trashPath,\n\t})\n\n\t// Verify the directory is moved from trash\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}/trash/restore-dir`)).resolves.toBe(false)\n\n\t// Verify the directory exists at the original location\n\tawait expect(fse.pathExists(`${testDirectory}/restore-dir`)).resolves.toBe(true)\n\n\t// Verify the contents are preserved\n\tawait expect(fse.pathExists(`${testDirectory}/restore-dir/file1.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/restore-dir/file2.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/restore-dir/nested/file3.txt`)).resolves.toBe(true)\n\n\t// Verify metadata file is deleted\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}/trash-meta/restore-dir.json`)).resolves.toBe(false)\n\n\t// Verify the returned path matches the original path\n\texpect(restoredPath).toBe('/Home/restore-dir-test/restore-dir')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('restore() successfully restores a child directory from trash', async () => {\n\t// Create test directory structure\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/restore-child-dir-test`\n\tawait fse.mkdir(testDirectory, {recursive: true})\n\tawait fse.mkdir(`${testDirectory}/child-dir`)\n\tawait fse.writeFile(`${testDirectory}/child-dir/file1.txt`, 'content1')\n\n\t// Trash the directory\n\tconst trashPath = await umbreld.client.files.trash.mutate({\n\t\tpath: '/Home/restore-child-dir-test',\n\t})\n\texpect(trashPath).toBe('/Trash/restore-child-dir-test')\n\n\t// Verify the directory is moved to trash\n\tawait expect(fse.pathExists(`${testDirectory}/restore-child-dir`)).resolves.toBe(false)\n\n\t// Now restore the child directory\n\tconst restoredPath = await umbreld.client.files.restore.mutate({\n\t\tpath: '/Trash/restore-child-dir-test/child-dir',\n\t})\n\texpect(restoredPath).toBe('/Home/restore-child-dir-test/child-dir')\n\n\t// Verify the directory is moved from trash\n\tawait expect(\n\t\tfse.pathExists(`${umbreld.instance.dataDirectory}/trash/restore-child-dir-test/child-dir`),\n\t).resolves.toBe(false)\n\n\t// Verify the directory exists at the original location\n\tawait expect(fse.pathExists(`${testDirectory}/child-dir`)).resolves.toBe(true)\n})\n\ntest('restore() successfully restores multiple subdirectories from trash one after another', async () => {\n\t// Create test directory structure with two subdirectories\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/restore-multiple-subdirs-test`\n\tawait fse.mkdir(testDirectory, {recursive: true})\n\n\t// Create first subdir with content\n\tawait fse.mkdir(`${testDirectory}/subdir1`, {recursive: true})\n\tawait fse.writeFile(`${testDirectory}/subdir1/file1.txt`, 'content1')\n\n\t// Create second subdir with content\n\tawait fse.mkdir(`${testDirectory}/subdir2`, {recursive: true})\n\tawait fse.writeFile(`${testDirectory}/subdir2/file2.txt`, 'content2')\n\n\t// Trash the parent directory\n\tconst trashPath = await umbreld.client.files.trash.mutate({\n\t\tpath: '/Home/restore-multiple-subdirs-test',\n\t})\n\texpect(trashPath).toBe('/Trash/restore-multiple-subdirs-test')\n\n\t// Verify the directory is moved to trash\n\tawait expect(fse.pathExists(testDirectory)).resolves.toBe(false)\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}/trash/restore-multiple-subdirs-test`)).resolves.toBe(\n\t\ttrue,\n\t)\n\n\t// Now restore the first subdirectory\n\tconst restoredPath1 = await umbreld.client.files.restore.mutate({\n\t\tpath: '/Trash/restore-multiple-subdirs-test/subdir1',\n\t})\n\texpect(restoredPath1).toBe('/Home/restore-multiple-subdirs-test/subdir1')\n\n\t// Verify the first subdirectory is restored\n\tawait expect(fse.pathExists(`${testDirectory}/subdir1`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/subdir1/file1.txt`)).resolves.toBe(true)\n\n\t// Verify the second subdirectory is still in trash\n\tawait expect(\n\t\tfse.pathExists(`${umbreld.instance.dataDirectory}/trash/restore-multiple-subdirs-test/subdir2`),\n\t).resolves.toBe(true)\n\n\t// Now restore the second subdirectory\n\tconst restoredPath2 = await umbreld.client.files.restore.mutate({\n\t\tpath: '/Trash/restore-multiple-subdirs-test/subdir2',\n\t})\n\texpect(restoredPath2).toBe('/Home/restore-multiple-subdirs-test/subdir2')\n\n\t// Verify the second subdirectory is restored\n\tawait expect(fse.pathExists(`${testDirectory}/subdir2`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/subdir2/file2.txt`)).resolves.toBe(true)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('restore() throws on name conflict', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/conflict-test`\n\tawait fse.mkdir(testDirectory, {recursive: true})\n\tawait fse.writeFile(`${testDirectory}/conflict-file.txt`, 'original content')\n\n\t// Trash the file\n\tconst trashPath = await umbreld.client.files.trash.mutate({\n\t\tpath: '/Home/conflict-test/conflict-file.txt',\n\t})\n\n\t// Create a new file with the same name\n\tawait fse.writeFile(`${testDirectory}/conflict-file.txt`, 'new content')\n\n\t// Now restore the file\n\tawait expect(\n\t\tumbreld.client.files.restore.mutate({\n\t\t\tpath: trashPath,\n\t\t}),\n\t).rejects.toThrow('[destination-already-exists]')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('restore(path, {collision: \"keep-both\"}) handles name conflict', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/conflict-keep-both-test`\n\tawait fse.mkdir(testDirectory, {recursive: true})\n\tawait fse.writeFile(`${testDirectory}/conflict-file.txt`, 'original content')\n\n\t// Trash the file\n\tconst trashPath = await umbreld.client.files.trash.mutate({\n\t\tpath: '/Home/conflict-keep-both-test/conflict-file.txt',\n\t})\n\texpect(trashPath).toBe('/Trash/conflict-file.txt')\n\n\t// Create a new file with the same name\n\tawait fse.writeFile(`${testDirectory}/conflict-file.txt`, 'new content')\n\n\t// Now restore the file\n\tawait expect(\n\t\tumbreld.client.files.restore.mutate({\n\t\t\tpath: trashPath,\n\t\t\tcollision: 'keep-both',\n\t\t}),\n\t).resolves.toBe('/Home/conflict-keep-both-test/conflict-file (2).txt')\n\n\t// Verify both files exist at the original location\n\tawait expect(fse.pathExists(`${testDirectory}/conflict-file.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/conflict-file (2).txt`)).resolves.toBe(true)\n\n\t// Verify the contents are preserved\n\tawait expect(fse.readFile(`${testDirectory}/conflict-file.txt`, 'utf8')).resolves.toBe('new content')\n\tawait expect(fse.readFile(`${testDirectory}/conflict-file (2).txt`, 'utf8')).resolves.toBe('original content')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('restore(path, {collision: \"replace\"}) handles name conflict', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/conflict-replace-test`\n\tawait fse.mkdir(testDirectory, {recursive: true})\n\tawait fse.writeFile(`${testDirectory}/conflict-file.txt`, 'original content')\n\n\t// Trash the file\n\tconst trashPath = await umbreld.client.files.trash.mutate({\n\t\tpath: '/Home/conflict-replace-test/conflict-file.txt',\n\t})\n\texpect(trashPath).toBe('/Trash/conflict-file.txt')\n\n\t// Create a new file with the same name\n\tawait fse.writeFile(`${testDirectory}/conflict-file.txt`, 'new content')\n\n\t// Now restore the file\n\tawait expect(\n\t\tumbreld.client.files.restore.mutate({\n\t\t\tpath: trashPath,\n\t\t\tcollision: 'replace',\n\t\t}),\n\t).resolves.toBe('/Home/conflict-replace-test/conflict-file.txt')\n\n\t// Verify the file no longer exists in trash\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}/trash/conflict-file.txt`)).resolves.toBe(false)\n\n\t// Verify the file exists at the original location\n\tawait expect(fse.pathExists(`${testDirectory}/conflict-file.txt`)).resolves.toBe(true)\n\n\t// Verify the content is replaced\n\tawait expect(fse.readFile(`${testDirectory}/conflict-file.txt`, 'utf8')).resolves.toBe('original content')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('restore(path, {collision: \"replace\"}) with directory replaces entire target directory instead of merging', async () => {\n\t// Create original directory with multiple files\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/dir-replace-test`\n\tawait fse.ensureDir(testDirectory)\n\tawait fse.writeFile(`${testDirectory}/file1.txt`, 'original file1')\n\tawait fse.writeFile(`${testDirectory}/file2.txt`, 'original file2')\n\tawait fse.writeFile(`${testDirectory}/file3.txt`, 'original file3')\n\n\t// Trash the directory\n\tconst trashPath = await umbreld.client.files.trash.mutate({\n\t\tpath: '/Home/dir-replace-test',\n\t})\n\texpect(trashPath).toBe('/Trash/dir-replace-test')\n\n\t// Verify the directory no longer exists at the original location\n\tawait expect(fse.pathExists(testDirectory)).resolves.toBe(false)\n\n\t// Create a new directory with the same name but different content\n\tawait fse.ensureDir(testDirectory)\n\tawait fse.writeFile(`${testDirectory}/fileA.txt`, 'new fileA')\n\tawait fse.writeFile(`${testDirectory}/fileB.txt`, 'new fileB')\n\n\t// Now restore the directory with replace option\n\tconst restoredPath = await umbreld.client.files.restore.mutate({\n\t\tpath: trashPath,\n\t\tcollision: 'replace',\n\t})\n\n\t// Verify the path is correct\n\texpect(restoredPath).toBe('/Home/dir-replace-test')\n\n\t// Verify the original directory content is restored\n\tawait expect(fse.pathExists(`${testDirectory}/file1.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/file2.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${testDirectory}/file3.txt`)).resolves.toBe(true)\n\n\t// Verify the new directory content is gone (replaced, not merged)\n\tawait expect(fse.pathExists(`${testDirectory}/fileA.txt`)).resolves.toBe(false)\n\tawait expect(fse.pathExists(`${testDirectory}/fileB.txt`)).resolves.toBe(false)\n\n\t// Verify the content of restored files is correct\n\tawait expect(fse.readFile(`${testDirectory}/file1.txt`, 'utf8')).resolves.toBe('original file1')\n\tawait expect(fse.readFile(`${testDirectory}/file2.txt`, 'utf8')).resolves.toBe('original file2')\n\tawait expect(fse.readFile(`${testDirectory}/file3.txt`, 'utf8')).resolves.toBe('original file3')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('restore() creates parent directories if they do not exist', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/parent-test`\n\tawait fse.mkdir(testDirectory, {recursive: true})\n\tawait fse.writeFile(`${testDirectory}/nested-restore.txt`, 'test content')\n\n\t// Trash the file\n\tconst trashPath = await umbreld.client.files.trash.mutate({\n\t\tpath: '/Home/parent-test/nested-restore.txt',\n\t})\n\n\t// Remove the parent directory\n\tawait fse.remove(testDirectory)\n\n\t// Now restore the file - this should recreate the parent directory\n\tconst restoredPath = await umbreld.client.files.restore.mutate({\n\t\tpath: trashPath,\n\t})\n\n\t// Verify the file exists at the original location\n\tawait expect(fse.pathExists(`${testDirectory}/nested-restore.txt`)).resolves.toBe(true)\n\n\t// Verify the content is preserved\n\tawait expect(fse.readFile(`${testDirectory}/nested-restore.txt`, 'utf8')).resolves.toBe('test content')\n\n\t// Verify the returned path matches the original path\n\texpect(restoredPath).toBe('/Home/parent-test/nested-restore.txt')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('restore() preserves symlinks when restoring directories', async () => {\n\t// Create test directory with a file and a relative symlink to it\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/symlink-test`\n\tawait fse.mkdir(testDirectory, {recursive: true})\n\tawait fse.writeFile(`${testDirectory}/target.txt`, 'target content')\n\n\t// Create a relative symlink (not absolute path)\n\tawait fse.symlink('target.txt', `${testDirectory}/symlink.txt`)\n\n\t// Verify the directory exists\n\tawait expect(fse.pathExists(testDirectory)).resolves.toBe(true)\n\n\t// Trash the entire directory\n\tconst trashPath = await umbreld.client.files.trash.mutate({\n\t\tpath: '/Home/symlink-test',\n\t})\n\n\t// Verify the directory no longer exists\n\tawait expect(fse.pathExists(testDirectory)).resolves.toBe(false)\n\n\t// Now restore the directory\n\tconst restoredPath = await umbreld.client.files.restore.mutate({\n\t\tpath: trashPath,\n\t})\n\n\t// Verify the directory was restored\n\tawait expect(fse.pathExists(testDirectory)).resolves.toBe(true)\n\n\t// Verify the symlink exists and is still a symlink\n\tconst symlinkPath = `${testDirectory}/symlink.txt`\n\tawait expect(fse.pathExists(symlinkPath)).resolves.toBe(true)\n\tconst isSymlink = await fse.lstat(symlinkPath).then((stats) => stats.isSymbolicLink())\n\texpect(isSymlink).toBe(true)\n\n\t// Verify the symlink still points to the relative target\n\tconst linkTarget = await fse.readlink(symlinkPath)\n\texpect(linkTarget).toBe('target.txt')\n\n\t// Verify reading through the symlink works\n\tconst content = await fse.readFile(symlinkPath, 'utf8')\n\texpect(content).toBe('target content')\n\n\t// Verify the returned path matches the original path\n\texpect(restoredPath).toBe('/Home/symlink-test')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/files.trash.test.ts",
    "content": "import {expect, beforeAll, afterAll, test} from 'vitest'\nimport fse from 'fs-extra'\nimport {$} from 'execa'\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\ntest('trash() throws invalid error without auth token', async () => {\n\tawait expect(umbreld.unauthenticatedClient.files.trash.mutate({path: '/Home/Documents'})).rejects.toThrow(\n\t\t'Invalid token',\n\t)\n})\n\ntest('trash() throws on directory traversal attempt', async () => {\n\tawait expect(\n\t\tumbreld.client.files.trash.mutate({\n\t\t\tpath: '/Home/../../../../etc',\n\t\t}),\n\t).rejects.toThrow('[invalid-base]')\n})\n\ntest('trash() throws on symlink traversal attempt', async () => {\n\t// Create a symlink to the root directory\n\tawait $`ln -s / ${umbreld.instance.dataDirectory}/home/symlink-to-root`\n\n\tawait expect(\n\t\tumbreld.client.files.trash.mutate({\n\t\t\tpath: '/Home/symlink-to-root/etc',\n\t\t}),\n\t).rejects.toThrow('[escapes-base]')\n\n\t// Clean up\n\tawait fse.remove(`${umbreld.instance.dataDirectory}/home/symlink-to-root`)\n})\n\ntest('trash() throws on relative paths', async () => {\n\tawait Promise.all(\n\t\t['', ' ', '.', '..', 'Home', 'Home/..', 'Home/Documents'].map(async (path) =>\n\t\t\texpect(umbreld.client.files.trash.mutate({path})).rejects.toThrow('[path-not-absolute]'),\n\t\t),\n\t)\n})\n\ntest('trash() throws on non-existent path', async () => {\n\tawait expect(\n\t\tumbreld.client.files.trash.mutate({\n\t\t\tpath: '/Home/DoesNotExist',\n\t\t}),\n\t).rejects.toThrow('[source-not-exists]')\n})\n\ntest('trash() throws on protected paths', async () => {\n\t// Create test directory\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/Downloads`\n\tawait fse.mkdir(testDirectory, {recursive: true})\n\n\tawait expect(\n\t\tumbreld.client.files.trash.mutate({\n\t\t\tpath: '/Home/Downloads',\n\t\t}),\n\t).rejects.toThrow('[operation-not-allowed]')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n})\n\ntest('trash() successfully moves a file to trash', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/trash-file-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.writeFile(`${testDirectory}/file.txt`, 'test content')\n\n\t// Verify the file exists\n\tawait expect(fse.pathExists(`${testDirectory}/file.txt`)).resolves.toBe(true)\n\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}/trash`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}/trash-meta`)).resolves.toBe(true)\n\n\t// Trash the file\n\tawait expect(umbreld.client.files.trash.mutate({path: '/Home/trash-file-test/file.txt'})).resolves.toBe(\n\t\t'/Trash/file.txt',\n\t)\n\n\t// Verify the file is moved to trash\n\tawait expect(fse.pathExists(`${testDirectory}/file.txt`)).resolves.toBe(false)\n\n\t// Verify the file exists in trash\n\tconst trashSystemPath = `${umbreld.instance.dataDirectory}/trash/file.txt`\n\tawait expect(fse.pathExists(trashSystemPath)).resolves.toBe(true)\n\n\t// Verify the content is preserved\n\tawait expect(fse.readFile(trashSystemPath, 'utf8')).resolves.toBe('test content')\n\n\t// Verify metadata file exists\n\tconst metaPath = `${umbreld.instance.dataDirectory}/trash-meta/file.txt.json`\n\tawait expect(fse.pathExists(metaPath)).resolves.toBe(true)\n\n\t// Verify metadata contains original virtual path\n\tconst meta = await fse.readJson(metaPath)\n\texpect(meta.path).toBe('/Home/trash-file-test/file.txt')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n\tawait fse.remove(trashSystemPath)\n\tawait fse.remove(metaPath)\n})\n\ntest('trash() successfully moves a directory with contents to trash', async () => {\n\t// Create test directory structure\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/trash-directory-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.mkdir(`${testDirectory}/subdir`)\n\tawait fse.writeFile(`${testDirectory}/subdir/file1.txt`, 'content1')\n\tawait fse.writeFile(`${testDirectory}/subdir/file2.txt`, 'content2')\n\tawait fse.mkdir(`${testDirectory}/subdir/nested`)\n\tawait fse.writeFile(`${testDirectory}/subdir/nested/file3.txt`, 'content3')\n\n\t// Verify the directory exists\n\tawait expect(fse.pathExists(`${testDirectory}/subdir`)).resolves.toBe(true)\n\n\t// Trash the directory\n\tawait expect(umbreld.client.files.trash.mutate({path: '/Home/trash-directory-test/subdir'})).resolves.toBe(\n\t\t'/Trash/subdir',\n\t)\n\n\t// Verify the directory is moved to trash\n\tawait expect(fse.pathExists(`${testDirectory}/subdir`)).resolves.toBe(false)\n\n\t// Verify the directory exists in trash\n\tconst trashSystemPath = `${umbreld.instance.dataDirectory}/trash/subdir`\n\tawait expect(fse.pathExists(trashSystemPath)).resolves.toBe(true)\n\n\t// Verify the contents are preserved\n\tawait expect(fse.pathExists(`${trashSystemPath}/file1.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${trashSystemPath}/file2.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${trashSystemPath}/nested/file3.txt`)).resolves.toBe(true)\n\n\t// Verify metadata file exists\n\tconst metaPath = `${umbreld.instance.dataDirectory}/trash-meta/subdir.json`\n\tawait expect(fse.pathExists(metaPath)).resolves.toBe(true)\n\n\t// Verify metadata contains original path\n\tconst meta = await fse.readJson(metaPath)\n\texpect(meta.path).toBe('/Home/trash-directory-test/subdir')\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n\tawait fse.remove(trashSystemPath)\n\tawait fse.remove(metaPath)\n})\n\ntest('trash() handles name conflicts by appending numbers', async () => {\n\t// Create test directory and files\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/trash-conflict-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.writeFile(`${testDirectory}/file.txt`, 'content1')\n\n\t// Trash the file\n\tawait expect(\n\t\tumbreld.client.files.trash.mutate({\n\t\t\tpath: '/Home/trash-conflict-test/file.txt',\n\t\t}),\n\t).resolves.toBe('/Trash/file.txt')\n\n\t// Create a new file with the same name\n\tawait fse.writeFile(`${testDirectory}/file.txt`, 'content2')\n\n\t// Trash the file again\n\tawait expect(\n\t\tumbreld.client.files.trash.mutate({\n\t\t\tpath: '/Home/trash-conflict-test/file.txt',\n\t\t}),\n\t).resolves.toBe('/Trash/file (2).txt')\n\n\t// Verify the file is moved to trash with a unique name\n\tawait expect(fse.pathExists(`${testDirectory}/file.txt`)).resolves.toBe(false)\n\n\t// Verify both files exist in trash\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}/trash/file.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}/trash/file (2).txt`)).resolves.toBe(true)\n\n\t// Verify metadata files exist with the correct name\n\tconst metaPath = `${umbreld.instance.dataDirectory}/trash-meta/file.txt.json`\n\tawait expect(fse.pathExists(metaPath)).resolves.toBe(true)\n\tconst metaPath2 = `${umbreld.instance.dataDirectory}/trash-meta/file (2).txt.json`\n\tawait expect(fse.pathExists(metaPath2)).resolves.toBe(true)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n\tawait fse.remove(`${umbreld.instance.dataDirectory}/trash/file.txt`)\n\tawait fse.remove(`${umbreld.instance.dataDirectory}/trash/file (2).txt`)\n\tawait fse.remove(metaPath)\n\tawait fse.remove(metaPath2)\n})\n\ntest('trash() handles trashing two files of the same name at the same time', async () => {\n\t// Create test directory and files\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/trash-conflict-async-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.writeFile(`${testDirectory}/file.txt`, 'content1')\n\tawait fse.mkdir(`${testDirectory}/subdir`)\n\tawait fse.writeFile(`${testDirectory}/subdir/file.txt`, 'content2')\n\n\t// Trash both files concurrently\n\tawait expect(\n\t\tPromise.all([\n\t\t\tumbreld.client.files.trash.mutate({\n\t\t\t\tpath: '/Home/trash-conflict-async-test/file.txt',\n\t\t\t}),\n\t\t\tumbreld.client.files.trash.mutate({\n\t\t\t\tpath: '/Home/trash-conflict-async-test/subdir/file.txt',\n\t\t\t}),\n\t\t]),\n\t).resolves.toMatchObject(['/Trash/file.txt', '/Trash/file (2).txt'])\n\n\t// Verify the file is moved to trash with a unique name\n\tawait expect(fse.pathExists(`${testDirectory}/file.txt`)).resolves.toBe(false)\n\tawait expect(fse.pathExists(`${testDirectory}/subdir/file.txt`)).resolves.toBe(false)\n\n\t// Verify both files exist in trash\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}/trash/file.txt`)).resolves.toBe(true)\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}/trash/file (2).txt`)).resolves.toBe(true)\n\n\t// Verify metadata files exist with the correct name\n\tconst metaPath = `${umbreld.instance.dataDirectory}/trash-meta/file.txt.json`\n\tawait expect(fse.pathExists(metaPath)).resolves.toBe(true)\n\tconst metaPath2 = `${umbreld.instance.dataDirectory}/trash-meta/file (2).txt.json`\n\tawait expect(fse.pathExists(metaPath2)).resolves.toBe(true)\n\n\t// Clean up\n\tawait fse.remove(testDirectory)\n\tawait fse.remove(`${umbreld.instance.dataDirectory}/trash/file.txt`)\n\tawait fse.remove(`${umbreld.instance.dataDirectory}/trash/file (2).txt`)\n\tawait fse.remove(metaPath)\n\tawait fse.remove(metaPath2)\n})\n\ntest('trash() preserves symlinks when trashing', async () => {\n\t// Create a target file and symlink\n\tawait fse.mkdir(`${umbreld.instance.dataDirectory}/home/trash-symlink-test`, {recursive: true})\n\tawait fse.writeFile(`${umbreld.instance.dataDirectory}/home/trash-symlink-test/target.txt`, 'target content')\n\tawait fse.symlink(\n\t\t`${umbreld.instance.dataDirectory}/home/trash-symlink-test/target.txt`,\n\t\t`${umbreld.instance.dataDirectory}/home/trash-symlink-test/symlink`,\n\t)\n\n\t// Trash the symlink\n\tawait expect(\n\t\tumbreld.client.files.trash.mutate({\n\t\t\tpath: '/Home/trash-symlink-test/symlink',\n\t\t}),\n\t).resolves.toBe('/Trash/symlink')\n\n\t// Verify the symlink is moved to trash\n\tawait expect(fse.pathExists(`${umbreld.instance.dataDirectory}/home/trash-symlink-test/symlink`)).resolves.toBe(false)\n\n\t// Verify it's still a symlink in trash\n\tconst trashedSymlink = `${umbreld.instance.dataDirectory}/trash/symlink`\n\tconst isSymlink = await fse.lstat(trashedSymlink).then((stats) => stats.isSymbolicLink())\n\texpect(isSymlink).toBe(true)\n\n\t// Verify the symlink points to the correct target\n\tconst linkTarget = await fse.readlink(trashedSymlink)\n\texpect(linkTarget).toBe(`${umbreld.instance.dataDirectory}/home/trash-symlink-test/target.txt`)\n\n\t// Verify reading through the symlink works\n\tconst content = await fse.readFile(trashedSymlink, 'utf8')\n\texpect(content).toBe('target content')\n\n\t// Clean up\n\tawait fse.remove(`${umbreld.instance.dataDirectory}/home/trash-symlink-test`)\n\tawait fse.remove(trashedSymlink)\n\tawait fse.remove(`${umbreld.instance.dataDirectory}/trash-meta/symlink.json`)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/files.ts",
    "content": "/**\n * Note: ext4 Filesystem Directory Entry Limit\n * ------------------------------------------\n * The current ext4 filesystem in umbrelOS is created with the `dir_index` feature\n * enabled (for faster name lookups in large directories), but *without* the `large_dir`\n * feature enabled (which would increase the limit on the number of files per directory).\n * See https://man7.org/linux/man-pages/man5/ext4.5.html\n *\n * Without `large_dir`, the `dir_index` hash tree has a limited depth, restricting the number of entries\n * in a single directory. In testing, the limit is on the order of a few hundreds of thousands, but not millions, of files.\n *\n * Exceeding this limit will cause file creation/write errors, visible in `dmesg` as:\n *   `EXT4-fs warning ... ext4_dx_add_entry: Directory ... index full, reach max htree level`\n *   `EXT4-fs warning ... ext4_dx_add_entry: Large directory feature is not enabled...`\n * It stems from the `dir_index` htree reaching its maximum depth without `large_dir`.\n */\n\nimport nodePath from 'node:path'\nimport {cp, constants} from 'node:fs/promises'\n\nimport mime from 'mime-types'\nimport fse from 'fs-extra'\nimport {minimatch} from 'minimatch'\nimport isValidFilename from 'valid-filename'\nimport pRetry from 'p-retry'\n\nimport {copyWithProgress} from '../utilities/copy-with-progress.js'\n\nimport {getDiskUsageByPath} from '../system/system.js'\n\nimport Watcher from './watcher.js'\nimport Recents from './recents.js'\nimport Favorites from './favorites.js'\nimport Archive from './archive.js'\nimport Thumbnails from './thumbnails.js'\nimport Samba from './samba.js'\nimport ExternalStorage from './external-storage.js'\nimport NetworkStorage from './network-storage.js'\nimport Search from './search.js'\n\nimport type Umbreld from '../../index.js'\n\nconst ALL_OPERATIONS = [\n\t'copy',\n\t'move',\n\t'rename',\n\t'trash',\n\t'restore',\n\t'delete',\n\t'favorite',\n\t'unarchive',\n\t'share',\n\t'writable',\n] as const\n\ntype FileOperation = (typeof ALL_OPERATIONS)[number]\n\ntype File = {\n\tname: string\n\tpath: string\n\ttype: string\n\tsize: number\n\tmodified: number\n\toperations: FileOperation[]\n\tthumbnail?: string\n}\n\ntype DirectoryListing = File & {\n\tfiles: File[]\n\ttruncatedAt?: number\n}\n\ntype Trashmeta = {\n\tpath: string\n}\n\ntype BaseDirectory = '/Home' | '/Trash' | '/Apps' | '/External' | '/Backups' | '/Network'\n\ntype ViewPreferences = {\n\tview: 'icons' | 'list'\n\tsortBy: 'name' | 'type' | 'modified' | 'size'\n\tsortOrder: 'ascending' | 'descending'\n}\n\nconst DEFAULT_VIEW_PREFERENCES: ViewPreferences = {\n\tview: 'list',\n\tsortBy: 'name',\n\tsortOrder: 'ascending',\n}\n\ntype OperationProgress = {\n\ttype: 'copy' | 'move'\n\tfile: File\n\tdestinationPath: string\n\tpercent: number\n\tbytesPerSecond: number\n\tsecondsRemaining?: number\n}\n\nexport type OperationsInProgress = OperationProgress[]\n\nexport default class Files {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\tbaseDirectories: Map<string, string>\n\ttrashMetaDirectory: string\n\tfileOwner = {userId: 1000, groupId: 1000}\n\tmaxDirectoryListing = 10000\n\t// Prevent loads of .DS_Store (macOS) and .directory (KDE Dolphin) results\n\thiddenFiles = ['.DS_Store', '.directory']\n\thiddenExtensions = ['.umbrel-upload']\n\toperationsInProgress: OperationsInProgress = []\n\twatcher: Watcher\n\trecents: Recents\n\tfavorites: Favorites\n\tarchive: Archive\n\tthumbnails: Thumbnails\n\tsamba: Samba\n\texternalStorage: ExternalStorage\n\tnetworkStorage: NetworkStorage\n\tsearch: Search\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(name.toLowerCase())\n\n\t\tthis.baseDirectories = new Map<BaseDirectory, string>([\n\t\t\t['/Home', `${umbreld.dataDirectory}/home`],\n\t\t\t['/Trash', `${umbreld.dataDirectory}/trash`],\n\t\t\t['/Apps', `${umbreld.dataDirectory}/app-data`],\n\t\t\t['/External', `${umbreld.dataDirectory}/external`],\n\t\t\t['/Backups', `${umbreld.dataDirectory}/backups`],\n\t\t\t['/Network', `${umbreld.dataDirectory}/network`],\n\t\t])\n\n\t\tthis.watcher = new Watcher(umbreld, {paths: ['/Home', '/Trash', '/Apps']})\n\t\tthis.recents = new Recents(umbreld, {paths: ['/Home']})\n\t\tthis.favorites = new Favorites(umbreld)\n\t\tthis.archive = new Archive(umbreld)\n\t\tthis.thumbnails = new Thumbnails(umbreld)\n\t\tthis.samba = new Samba(umbreld)\n\t\tthis.externalStorage = new ExternalStorage(umbreld)\n\t\tthis.networkStorage = new NetworkStorage(umbreld)\n\t\tthis.search = new Search(umbreld)\n\n\t\t// TODO: This should really be in a proper DB, refactor this once we've moved to SQLite\n\t\tthis.trashMetaDirectory = `${umbreld.dataDirectory}/trash-meta`\n\t}\n\n\tasync start() {\n\t\tthis.logger.log('Starting files')\n\n\t\t// Ensure all base directories exist\n\t\tawait Promise.all(\n\t\t\t[...this.baseDirectories.keys()].map((baseDirectory) =>\n\t\t\t\tthis.createDirectory(baseDirectory).catch((error) => {\n\t\t\t\t\tthis.logger.error(`Failed to ensure directory '${baseDirectory}' exists`, error)\n\t\t\t\t}),\n\t\t\t),\n\t\t)\n\n\t\t// Ensure the trash meta directory exists\n\t\tawait fse.ensureDir(this.trashMetaDirectory).catch((error) => {\n\t\t\tthis.logger.error(`Failed to ensure directory ${this.trashMetaDirectory} exists`, error)\n\t\t})\n\t\tawait this.chownSystemPath(this.trashMetaDirectory)\n\n\t\t// Do any required one time setup tasks.\n\t\tawait this.firstRun()\n\n\t\t// Start submodules\n\t\tawait this.watcher.start().catch((error) => this.logger.error(`Failed to start watcher`, error))\n\t\tawait this.samba.start().catch((error) => this.logger.error(`Failed to start samba`, error))\n\t\tawait this.externalStorage.start().catch((error) => this.logger.error(`Failed to start external storage`, error))\n\t\tawait this.networkStorage.start().catch((error) => this.logger.error(`Failed to start network storage`, error))\n\t\tawait this.recents.start().catch((error) => this.logger.error(`Failed to start recents`, error))\n\t\tawait this.favorites.start().catch((error) => this.logger.error(`Failed to start favorites`, error))\n\t\tawait this.thumbnails.start().catch((error) => this.logger.error(`Failed to start thumbnails`, error))\n\t}\n\n\tasync firstRun() {\n\t\t// Check if we've already setup favorites\n\t\tconst isFavoritesInitialized = (await this.#umbreld.store.get('files.favorites')) === undefined\n\t\tif (!isFavoritesInitialized) return\n\n\t\t// Initialize default favorites\n\t\tconst defaultFavourites = ['/Home/Downloads', '/Home/Documents', '/Home/Photos', '/Home/Videos']\n\t\tfor (const favorite of defaultFavourites) {\n\t\t\tawait this.createDirectory(favorite).catch((error) =>\n\t\t\t\tthis.logger.error(`Failed to ensure directory '${favorite}' exists`, error),\n\t\t\t)\n\t\t\tawait this.favorites\n\t\t\t\t.addFavorite(favorite)\n\t\t\t\t.catch((error) => this.logger.error(`Failed to initialize favorite '${favorite}'`, error))\n\t\t}\n\t}\n\n\tasync stop() {\n\t\tthis.logger.log('Stopping files')\n\n\t\t// Stop submodules\n\t\tawait this.recents.stop().catch((error) => this.logger.error(`Failed to stop recents`, error))\n\t\tawait this.favorites.stop().catch((error) => this.logger.error(`Failed to stop favorites`, error))\n\t\tawait this.thumbnails.stop().catch((error) => this.logger.error(`Failed to stop thumbnails`, error))\n\t\tawait this.externalStorage.stop().catch((error) => this.logger.error(`Failed to stop external storage`, error))\n\t\tawait this.networkStorage.stop().catch((error) => this.logger.error(`Failed to stop network storage`, error))\n\t\tawait this.samba.stop().catch((error) => this.logger.error(`Failed to stop samba`, error))\n\t\tawait this.watcher.stop().catch((error) => this.logger.error(`Failed to stop watcher`, error))\n\t}\n\n\t// Typesafe wrapper to get the system path of a base directory\n\tgetBaseDirectory(virtualPath: BaseDirectory) {\n\t\tconst path = this.baseDirectories.get(virtualPath)\n\t\tif (!path) throw new Error(`[base-directory-not-found] ${virtualPath}`)\n\t\treturn path\n\t}\n\n\t// Creates a new directory at the given virtual path.\n\t// Returns true if the directory already exists.\n\tasync createDirectory(virtualPath: string) {\n\t\t// Check if operation is allowed\n\t\tconst containingDirectory = nodePath.dirname(virtualPath)\n\t\tconst containingDirectoryAllowedOperations = await this.getAllowedOperations(containingDirectory)\n\t\tif (!containingDirectoryAllowedOperations.includes('writable')) throw new Error('[operation-not-allowed]')\n\n\t\t// Get system path\n\t\tconst path = await this.virtualToSystemPath(virtualPath)\n\n\t\t// Check if the directory already exists\n\t\tif (await fse.pathExists(path)) return true\n\n\t\t// Create the directory\n\t\tawait fse.mkdir(path).catch((error) => {\n\t\t\tif (error?.message?.includes('ENOENT')) throw new Error('[parent-not-exist]')\n\t\t\tif (error?.message?.includes('ENOTDIR')) throw new Error('[parent-not-directory]')\n\t\t\tthrow new Error(`[mkdir-failed] ${error?.message}`)\n\t\t})\n\n\t\t// Set owner to the umbrel user\n\t\t// We do nothing on fail because this isn't supported on all filesystems.\n\t\t// e.g this is expected to throw on external exFAT drives.\n\t\tawait this.chownSystemPath(path).catch(() => {})\n\n\t\treturn true\n\t}\n\n\t// Set owner of system path to umbrel user\n\tasync chownSystemPath(systemPath: string) {\n\t\tawait fse.chown(systemPath, this.fileOwner.userId, this.fileOwner.groupId)\n\t}\n\n\t// Gets file status given a system path.\n\t// We use a system path here because everywhere we call this\n\t// we already have a system path so we know it's safe. Also\n\t// converting a system path back into a virtual path for the\n\t// return value is cheap but converting a virtual path into a\n\t// system path is expensive and we call this on every file in\n\t// a directory.\n\tasync status(systemPath: string): Promise<File> {\n\t\t// Get the path and filename\n\t\tconst path = this.systemToVirtualPath(systemPath)\n\t\tconst name = nodePath.basename(path)\n\n\t\t// Get stats, operations, and thumbnail concurrently\n\t\t// This will ensure that we complete these as fast as the slowest operation\n\t\tconst [stats, operations, thumbnail] = await Promise.all([\n\t\t\t// We use lstat to ensure we don't follow symlinks\n\t\t\tfse.lstat(systemPath),\n\n\t\t\t// Get the allowed operations\n\t\t\tthis.getAllowedOperations(path),\n\n\t\t\t// Get the thumbnail for supported file types only if the thumbnail already exists (does not generate a missing thumbnail)\n\t\t\tthis.thumbnails.getExistingThumbnail(systemPath).catch(() => undefined),\n\t\t])\n\n\t\t// Get the type\n\t\tlet type\n\t\tif (stats.isDirectory()) type = 'directory'\n\t\telse if (stats.isSymbolicLink()) type = 'symbolic-link'\n\t\telse if (stats.isSocket()) type = 'socket'\n\t\telse if (stats.isBlockDevice()) type = 'block-device'\n\t\telse if (stats.isCharacterDevice()) type = 'character-device'\n\t\telse if (stats.isFIFO()) type = 'fifo'\n\t\telse type = mime.lookup(name) || 'application/octet-stream'\n\n\t\t// Get the size in bytes\n\t\tlet size = stats.size\n\t\t// Set dir size to zero for now\n\t\t// TODO: Implement directory size index for efficient lookups\n\t\tif (type === 'directory') size = 0\n\n\t\t// Get the modified time\n\t\tconst modified = stats.mtime.getTime()\n\n\t\treturn {\n\t\t\tname,\n\t\t\tpath,\n\t\t\ttype,\n\t\t\tsize,\n\t\t\tmodified,\n\t\t\toperations,\n\t\t\tthumbnail,\n\t\t}\n\t}\n\n\t// Checks if a filename is hidden\n\tisHidden(filename: string) {\n\t\treturn (\n\t\t\tthis.hiddenFiles.includes(filename) || this.hiddenExtensions.some((extension) => filename.endsWith(extension))\n\t\t)\n\t}\n\n\t// Lists the contents of the root directory.\n\t// This is a special case since the root directory doesn't map to a system path.\n\tasync #listRoot() {\n\t\tconst files = await Promise.all([...this.baseDirectories.values()].map((systemPath) => this.status(systemPath)))\n\t\treturn {\n\t\t\tname: '',\n\t\t\tpath: '/',\n\t\t\ttype: 'directory',\n\t\t\tsize: 0,\n\t\t\tmodified: 0,\n\t\t\toperations: [],\n\t\t\tfiles,\n\t\t}\n\t}\n\n\t// Lists the contents of a directory given a virtual path.\n\t// Will return all files in the directory up to this.maxDirectoryListing\n\t// We safely stream the directory to avoid blowing up Node.js if the directory is large.\n\tasync list(virtualPath: string): Promise<DirectoryListing> {\n\t\tvirtualPath = normalizePath(virtualPath)\n\n\t\t// Special handling for the root directory since it doesn't map to a system parth\n\t\tif (virtualPath === '/') return this.#listRoot()\n\n\t\t// Get the system path and directory details\n\t\tconst systemPath = await this.virtualToSystemPath(virtualPath)\n\t\tconst directoryDetails = await this.status(systemPath).catch((error) => {\n\t\t\tif (error?.message?.includes('ENOENT')) throw new Error('[does-not-exist]')\n\t\t\tthrow error\n\t\t})\n\n\t\t// List the contents of the directory\n\t\tconst fileJobs = []\n\t\tlet truncatedAt: number | undefined = undefined\n\t\t// We open an async iterator to the directory so we can safely stream a large directory\n\t\t// and exit if it gets too big.\n\t\t// Iterate over the directory contents\n\t\tlet count = 0\n\t\tfor await (const fileSystemPath of getDirectoryStream(systemPath)) {\n\t\t\t// Skip hidden files\n\t\t\tif (this.isHidden(nodePath.basename(fileSystemPath))) continue\n\n\t\t\t// Push the file details job to the queue to limit concurrency\n\t\t\tfileJobs.push(\n\t\t\t\tthis.status(fileSystemPath).catch((error) => {\n\t\t\t\t\tthis.logger.error(`Failed to get status for '${fileSystemPath}'`, error)\n\t\t\t\t\treturn undefined\n\t\t\t\t}),\n\t\t\t)\n\t\t\tcount++\n\t\t\t// If we've reached the maximum number of files, set the truncatedAt property\n\t\t\t// and break out of the loop.\n\t\t\tif (count >= this.maxDirectoryListing) {\n\t\t\t\ttruncatedAt = this.maxDirectoryListing\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// Filter out any files that failed to get status\n\t\tconst files = (await Promise.all(fileJobs)).filter((file) => file !== undefined) as File[]\n\n\t\treturn {\n\t\t\t...directoryDetails,\n\t\t\tfiles,\n\t\t\ttruncatedAt,\n\t\t}\n\t}\n\n\t// Recursively stream the contents of a virtual directory\n\tasync *streamContents(virtualPath: string) {\n\t\tconst systemPath = await this.virtualToSystemPath(virtualPath)\n\t\tconst directoryStream = getDirectoryStream(systemPath, {recursive: true})\n\t\tfor await (const systemPath of directoryStream) yield systemPath\n\t}\n\n\t// Internal utility to copy (or copy and delete (psuedo-move)) a file or directory using rsync and report progress\n\tasync #copyWithProgress(sourceSystemPath: string, destinationSystemPath: string, {move = false} = {}) {\n\t\t// Error handling consistent with fse.copy and move\n\t\tconst destinationExists = await fse.exists(destinationSystemPath)\n\t\tif (destinationExists) throw new Error('[destination-already-exists]')\n\t\tif (destinationSystemPath.startsWith(sourceSystemPath)) throw new Error('[subdir-of-self]')\n\n\t\t// Create initial progress tracker and emit operation progress event\n\t\tconst operationProgress: OperationProgress = {\n\t\t\ttype: move ? 'move' : 'copy',\n\t\t\tfile: await this.status(sourceSystemPath),\n\t\t\tdestinationPath: this.systemToVirtualPath(destinationSystemPath),\n\t\t\tpercent: 0,\n\t\t\tbytesPerSecond: 0,\n\t\t}\n\t\tthis.operationsInProgress.push(operationProgress)\n\t\tthis.#umbreld.eventBus.emit('files:operation-progress', this.operationsInProgress)\n\n\t\t// Attempt instant copy via reflink on supported filesystems (e.g. zfs)\n\t\ttry {\n\t\t\tawait cp(sourceSystemPath, destinationSystemPath, {\n\t\t\t\trecursive: true,\n\t\t\t\tpreserveTimestamps: true,\n\t\t\t\tmode: constants.COPYFILE_FICLONE_FORCE,\n\t\t\t})\n\n\t\t\t// Emit 100% progress\n\t\t\toperationProgress.percent = 100\n\t\t\tthis.#umbreld.eventBus.emit('files:operation-progress', this.operationsInProgress)\n\t\t} catch {\n\t\t\t// Reflink not supported, fall back to rsync with progress tracking\n\t\t\tawait copyWithProgress(sourceSystemPath, destinationSystemPath, (progress) => {\n\t\t\t\toperationProgress.percent = progress.progress\n\t\t\t\toperationProgress.bytesPerSecond = progress.bytesPerSecond\n\t\t\t\toperationProgress.secondsRemaining = progress.secondsRemaining\n\t\t\t\tthis.#umbreld.eventBus.emit('files:operation-progress', this.operationsInProgress)\n\t\t\t})\n\t\t} finally {\n\t\t\t// Remove the progress tracker and emit operation progress event\n\t\t\tthis.operationsInProgress = this.operationsInProgress.filter((operation) => operation !== operationProgress)\n\t\t\tthis.#umbreld.eventBus.emit('files:operation-progress', this.operationsInProgress)\n\t\t}\n\n\t\t// If we're moving, delete the source file or directory on completion\n\t\tif (move) await fse.remove(sourceSystemPath)\n\t}\n\t// Copies a file or directory from one virtual path to another.\n\tasync copy(sourceVirtualPath: string, destinationVirtualDirectory: string, {collision = 'error'} = {}) {\n\t\t// Check if operation is allowed\n\t\tconst allowedOperations = await this.getAllowedOperations(destinationVirtualDirectory)\n\t\tif (!allowedOperations.includes('writable')) throw new Error('[operation-not-allowed]')\n\n\t\t// Get the system paths\n\t\tlet sourceSystemPath = await this.virtualToSystemPath(sourceVirtualPath)\n\t\tconst destinationSystemDirectory = await this.virtualToSystemPath(destinationVirtualDirectory)\n\n\t\t// Error if the source doesn't exist\n\t\tconst sourceExists = await fse.exists(sourceSystemPath)\n\t\tif (!sourceExists) throw new Error('[source-not-exists]')\n\n\t\t// Error if the destination directory doesn't exist\n\t\tconst targetExists = await fse.exists(destinationSystemDirectory)\n\t\tif (!targetExists) throw new Error(`[destination-not-exist]`)\n\n\t\t// Check we have enough free space on the destination\n\t\tconst sourceStats = await fse.stat(sourceSystemPath)\n\t\tconst diskUsage = await getDiskUsageByPath(destinationSystemDirectory)\n\t\tconst buffer = 1024 * 1024 * 1024 * 1 // 1GB\n\t\tconst neededSpace = sourceStats.size + buffer\n\t\tif (diskUsage.available < neededSpace) throw new Error('[not-enough-space]')\n\n\t\t// Add trailing slash to source path if it's a directoryso we only copy the contents\n\t\tif (sourceStats.isDirectory()) sourceSystemPath = `${sourceSystemPath}/`\n\n\t\t// Build absolute destination path\n\t\tlet destinationSystemPath = nodePath.join(destinationSystemDirectory, nodePath.basename(sourceSystemPath))\n\n\t\t// Always use 'keep-both' collision handling for same directory copies\n\t\tconst isSameDirectory = nodePath.dirname(sourceVirtualPath) === destinationVirtualDirectory\n\t\tif (isSameDirectory) collision = 'keep-both'\n\n\t\t// Handle name collisions\n\t\tif (collision === 'error') {\n\t\t\tconst destinationExists = await fse.pathExists(destinationSystemPath)\n\t\t\tif (destinationExists) throw new Error('[destination-already-exists]')\n\t\t} else if (collision === 'keep-both') {\n\t\t\tdestinationSystemPath = await this.getUniqueName(destinationSystemPath)\n\t\t} else if (collision === 'replace') {\n\t\t\t// Remove the destination file/directory so that in the case of a directory, the contents are fully replaced\n\t\t\t// This entire fse.remove and subsequent fse.copy action is not atomic. If the copy fails, the original destination content will not be restored.\n\t\t\tawait fse.remove(destinationSystemPath)\n\t\t}\n\n\t\t// Perform the copy operation\n\t\tawait this.#copyWithProgress(sourceSystemPath, destinationSystemPath)\n\n\t\t// Return the virtual path of the new copy\n\t\treturn this.systemToVirtualPath(destinationSystemPath)\n\t}\n\n\t// Moves a file or directory from one virtual path to another.\n\tasync move(sourceVirtualPath: string, destinationVirtualDirectory: string, {collision = 'error'} = {}) {\n\t\t// If the destination is the current containing folder then the file is already in the correct location\n\t\t// so we don't need to do anything.\n\t\tif (nodePath.dirname(sourceVirtualPath) === destinationVirtualDirectory) return sourceVirtualPath\n\n\t\t// Check if operation is allowed on source\n\t\tconst allowedSourceOperations = await this.getAllowedOperations(sourceVirtualPath)\n\t\tif (!allowedSourceOperations.includes('move')) throw new Error('[operation-not-allowed]')\n\n\t\t// Check if operation is allowed on destination\n\t\tconst allowedDestinationOperations = await this.getAllowedOperations(destinationVirtualDirectory)\n\t\tif (!allowedDestinationOperations.includes('writable')) throw new Error('[operation-not-allowed]')\n\n\t\t// Get the system paths\n\t\tlet sourceSystemPath = await this.virtualToSystemPath(sourceVirtualPath)\n\t\tconst destinationSystemDirectory = await this.virtualToSystemPath(destinationVirtualDirectory)\n\n\t\t// Error if the source doesn't exist\n\t\tconst sourceStats = await fse.stat(sourceSystemPath).catch(() => {\n\t\t\tthrow new Error('[source-not-exists]')\n\t\t})\n\n\t\t// Error if the destination directory doesn't exist\n\t\tconst targetDirectoryStats = await fse.stat(destinationSystemDirectory).catch(() => {\n\t\t\tthrow new Error('[destination-not-exist]')\n\t\t})\n\n\t\t// Add trailing slash to source path if it's a directoryso we only copy the contents\n\t\tif ((await fse.lstat(sourceSystemPath)).isDirectory()) sourceSystemPath = `${sourceSystemPath}/`\n\n\t\t// Build absolute destination path\n\t\tlet destinationSystemPath = nodePath.join(destinationSystemDirectory, nodePath.basename(sourceSystemPath))\n\n\t\t// Handle name collisions\n\t\tif (collision === 'keep-both') destinationSystemPath = await this.getUniqueName(destinationSystemPath)\n\t\tif (collision === 'replace') await fse.remove(destinationSystemPath)\n\n\t\t// Toggle move operation based on for cross fs moves.\n\t\t// Also allow overriding this so we can test both variants in the test suite.\n\t\tconst forceSlowMoveWithProgress = process.env.UMBRELD_FORCE_SLOW_MOVE_WITH_PROGRESS === 'true'\n\t\tconst isMovingAcrossFilesystems = sourceStats.dev !== targetDirectoryStats.dev\n\t\tif (isMovingAcrossFilesystems || forceSlowMoveWithProgress) {\n\t\t\t// If we're moving across filesystems there will be a slow copy and delete so\n\t\t\t// we'll use our own implementation that reports progress.\n\t\t\tawait this.#copyWithProgress(sourceSystemPath, destinationSystemPath, {move: true})\n\t\t} else {\n\t\t\t// Otherwise we can use native system move for instant atomic move on the same filesystem.\n\t\t\tawait move(sourceSystemPath, destinationSystemPath)\n\t\t}\n\n\t\t// Return the virtual path of the new location\n\t\treturn this.systemToVirtualPath(destinationSystemPath)\n\t}\n\n\t// Rename a file or directory\n\tasync rename(sourceVirtualPath: string, newName: string): Promise<string> {\n\t\t// Check if operation is allowed.\n\t\tconst allowedOperations = await this.getAllowedOperations(sourceVirtualPath)\n\t\tif (!allowedOperations.includes('rename')) throw new Error(`[operation-not-allowed]`)\n\n\t\t// Ensure that a new name is valid.\n\t\tif (!isValidFilename(newName)) throw new Error(`[invalid-filename] Invalid filename: '${newName}'`)\n\n\t\t// Convert the source virtual path into a system path.\n\t\tconst sourceSystemPath = await this.virtualToSystemPath(sourceVirtualPath)\n\n\t\t// If the new name is identical to the current base name, do nothing.\n\t\tconst currentName = nodePath.basename(sourceSystemPath)\n\t\tif (currentName === newName) return sourceVirtualPath\n\n\t\t// Determine the parent directory (system path) and compute the new candidate system path.\n\t\tconst parentDirectory = nodePath.dirname(sourceSystemPath)\n\t\tconst targetSystemPath = nodePath.join(parentDirectory, newName)\n\n\t\t// Perform the renaming operation by moving the file/directory.\n\t\tawait move(sourceSystemPath, targetSystemPath)\n\n\t\t// Convert the target system path back into a virtual path and return it.\n\t\treturn this.systemToVirtualPath(targetSystemPath)\n\t}\n\n\t// Trash a file or directory\n\tasync trash(virtualPath: string) {\n\t\t// Check if operation is allowed\n\t\tconst allowedOperations = await this.getAllowedOperations(virtualPath)\n\t\tif (!allowedOperations.includes('trash')) throw new Error('[operation-not-allowed]')\n\n\t\t// Get the system path\n\t\t// This is important to piggy back on for validation logic\n\t\tconst systemPath = await this.virtualToSystemPath(virtualPath)\n\n\t\t// Calculate the target trash system path\n\t\tconst trashSystemRoot = await this.virtualToSystemPath('/Trash')\n\t\tconst trashSystemPath = await nodePath.join(trashSystemRoot, nodePath.basename(systemPath))\n\n\t\t// Retry on error to work around collision race condition\n\t\t// TODO: Add better handling in getUniqueName() for this.\n\t\tlet uniqueTrashSystemPath = ''\n\t\tawait pRetry(\n\t\t\tasync () => {\n\t\t\t\t// Get a unique trash system path\n\t\t\t\tuniqueTrashSystemPath = await this.getUniqueName(trashSystemPath, {maxIndex: 1000})\n\n\t\t\t\t// Move the file or directory to the trash\n\t\t\t\tawait move(systemPath, uniqueTrashSystemPath)\n\t\t\t},\n\t\t\t{\n\t\t\t\tretries: 10,\n\t\t\t\tminTimeout: 100,\n\t\t\t\tmaxTimeout: 100,\n\t\t\t\tshouldRetry: (error) => error.message === '[destination-already-exists]',\n\t\t\t},\n\t\t)\n\n\t\t// Write the meta data for the trashed file or directory\n\t\t// TODO: Migrate this to SQLite\n\t\tconst trashMetaSystemPath = nodePath.join(\n\t\t\tthis.trashMetaDirectory,\n\t\t\t`${nodePath.basename(uniqueTrashSystemPath)}.json`,\n\t\t)\n\t\tawait fse.writeFile(trashMetaSystemPath, JSON.stringify({path: virtualPath} satisfies Trashmeta))\n\n\t\t// Return the virtual path of the trashed file or directory\n\t\treturn this.systemToVirtualPath(uniqueTrashSystemPath)\n\t}\n\n\t// Restore a file or directory from the trash\n\tasync restore(trashVirtualPath: string, {collision = 'error'} = {}) {\n\t\t// Check if operation is allowed\n\t\tconst allowedOperations = await this.getAllowedOperations(trashVirtualPath)\n\t\tif (!allowedOperations.includes('restore')) throw new Error('[operation-not-allowed]')\n\n\t\t// Get the system path\n\t\tconst trashSystemPath = await this.virtualToSystemPath(trashVirtualPath)\n\t\tif (!(await fse.pathExists(trashSystemPath))) throw new Error('[source-not-exists]')\n\n\t\t// Read the meta data for the trashed file or directory\n\t\tconst pathSegments = trashVirtualPath.split('/').filter(Boolean)\n\t\tconst isChild = pathSegments.length > 2\n\t\t// Always use the second path segment so we can recover child files and directories\n\t\tconst trashMetaSystemPath = nodePath.join(this.trashMetaDirectory, `${pathSegments[1]}.json`)\n\t\tlet targetSystemPath: string\n\t\ttry {\n\t\t\tconst trashMeta = (await fse.readJson(trashMetaSystemPath)) as Trashmeta\n\t\t\ttargetSystemPath = await this.virtualToSystemPath(trashMeta.path)\n\t\t\t// Calculate full path if we're recovering a child file or directory\n\t\t\tif (isChild) targetSystemPath = nodePath.join(targetSystemPath, pathSegments.slice(2).join('/'))\n\t\t} catch (error) {\n\t\t\tif ((error as Error)?.message?.includes('ENOENT')) throw new Error('[trash-meta-not-exists]')\n\t\t\tthrow error\n\t\t}\n\n\t\t// Handle name conflicts\n\t\tif (collision === 'keep-both') targetSystemPath = await this.getUniqueName(targetSystemPath)\n\t\tconst moveOptions = collision === 'replace' ? {overwrite: true} : {}\n\n\t\t// Move the file or directory to the new location\n\t\tawait move(trashSystemPath, targetSystemPath, moveOptions)\n\n\t\t// Delete the meta data if we're recovering a root file or directory\n\t\tif (!isChild) await fse.remove(trashMetaSystemPath)\n\n\t\t// Return the virtual path of the restored file or directory\n\t\treturn this.systemToVirtualPath(targetSystemPath)\n\t}\n\n\t// Empty the trash\n\tasync emptyTrash() {\n\t\tlet success = true\n\n\t\t// Get the system path for the trash directory\n\t\tconst trashDirectory = await this.virtualToSystemPath('/Trash')\n\n\t\t// Stream the trash directory contents\n\t\tfor await (const systemPath of getDirectoryStream(trashDirectory)) {\n\t\t\tawait fse.remove(systemPath).catch((error) => {\n\t\t\t\tthis.logger.error(`Failed to remove '${nodePath.basename(systemPath)}' from trash`, error)\n\t\t\t\tsuccess = false\n\t\t\t})\n\t\t}\n\t\tfor await (const systemPath of getDirectoryStream(this.trashMetaDirectory)) {\n\t\t\tawait fse.remove(systemPath).catch((error) => {\n\t\t\t\tthis.logger.error(`Failed to remove '${nodePath.basename(systemPath)}' from trash meta`, error)\n\t\t\t\tsuccess = false\n\t\t\t})\n\t\t}\n\n\t\treturn success\n\t}\n\n\t// Permanently delete a file or directory\n\tasync delete(virtualPath: string) {\n\t\t// Check if operation is allowed\n\t\tconst allowedOperations = await this.getAllowedOperations(virtualPath)\n\t\tif (!allowedOperations.includes('delete')) throw new Error('[operation-not-allowed]')\n\n\t\t// Get the system path\n\t\tconst systemPath = await this.virtualToSystemPath(virtualPath)\n\n\t\t// Delete the file or directory\n\t\ttry {\n\t\t\tawait fse.remove(systemPath)\n\t\t\treturn true\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to delete '${systemPath}'`, error)\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Get allowed operations for a given path\n\tasync getAllowedOperations(virtualPath: string): Promise<FileOperation[]> {\n\t\t// Get file status\n\t\tlet isFile = false\n\t\tlet isDirectory = false\n\t\ttry {\n\t\t\tconst file = await fse.lstat(await this.virtualToSystemPath(virtualPath))\n\t\t\tisFile = file.isFile()\n\t\t\tisDirectory = file.isDirectory()\n\t\t} catch {}\n\n\t\t// Start with all operations\n\t\tconst operations = new Set(ALL_OPERATIONS)\n\n\t\t// Remove non-default operations\n\t\toperations.delete('restore')\n\t\toperations.delete('delete')\n\t\toperations.delete('favorite')\n\t\toperations.delete('unarchive')\n\t\toperations.delete('share')\n\n\t\t// Add file specific operations\n\t\tif (isFile) {\n\t\t\tif (this.archive.isUnarchiveable(virtualPath)) operations.add('unarchive')\n\t\t}\n\n\t\t// Add directory specific operations\n\t\tif (isDirectory) {\n\t\t\toperations.add('favorite')\n\t\t\toperations.add('share')\n\t\t}\n\n\t\t// Disable creating files in readonly directories\n\t\tconst isReadonly =\n\t\t\tvirtualPath === '/External' ||\n\t\t\tmatch(virtualPath, ['/Network', '/Network/*']) ||\n\t\t\tvirtualPath === '/Backups' ||\n\t\t\tvirtualPath.startsWith('/Backups/')\n\t\tif (isReadonly) operations.delete('writable')\n\n\t\t// Remove destructive operations if the path is protected\n\t\t// Note only the exact paths are protected, not necessarily the children.\n\t\t// e.g /Home/Downloads is protected but /Home/Downloads/file.txt is not.\n\t\t// Children could be protected with /Home/Downloads/**\n\t\tlet isProtected = match(virtualPath, [\n\t\t\t'/*',\n\t\t\t'/Home/Downloads',\n\t\t\t'/External/*',\n\t\t\t'/Network/*',\n\t\t\t'/Network/*/*',\n\t\t\t'/Backups',\n\t\t\t'/Backups/**',\n\t\t])\n\n\t\t// For /Apps/* paths, only protect if the app id is installed\n\t\tif (match(virtualPath, ['/Apps/*'])) {\n\t\t\tconst appId = nodePath.basename(virtualPath)\n\t\t\tisProtected = await this.#umbreld.apps.isInstalled(appId)\n\t\t}\n\n\t\tif (isProtected) {\n\t\t\toperations.delete('move')\n\t\t\toperations.delete('rename')\n\t\t\toperations.delete('trash')\n\t\t\toperations.delete('delete')\n\t\t}\n\n\t\t// Unshareable paths\n\t\tconst isUnshareable = match(virtualPath, [\n\t\t\t'/Apps',\n\t\t\t'/Apps/*',\n\t\t\t'/External',\n\t\t\t'/External/**',\n\t\t\t'/Network',\n\t\t\t'/Network/**',\n\t\t\t'/Backups',\n\t\t\t'/Backups/**',\n\t\t])\n\t\tif (isUnshareable) operations.delete('share')\n\n\t\t// External files (not external root or top level mount points)\n\t\tconst isExternal = match(virtualPath, ['/External/*/**'])\n\t\tconst isNetwork = match(virtualPath, ['/Network/*/*/**'])\n\t\tif (isExternal || isNetwork) {\n\t\t\t// Only allow hard delete so we don't copy to internal storage\n\t\t\toperations.delete('trash')\n\t\t\toperations.add('delete')\n\t\t}\n\n\t\t// Add trash specific operations\n\t\tconst isTrash = match(virtualPath, ['/Trash/**'])\n\t\tif (isTrash) {\n\t\t\toperations.delete('unarchive')\n\t\t\toperations.delete('share')\n\t\t\toperations.delete('favorite')\n\t\t\toperations.delete('trash')\n\t\t\toperations.add('restore')\n\t\t\toperations.add('delete')\n\t\t}\n\n\t\treturn Array.from(operations)\n\t}\n\n\t// Split the extension from the file name\n\t// Handles complex extensions like archive.tar.gz and file.txt.gz\n\tsplitExtension(path: string) {\n\t\t// TODO: Handle complex extensions like .tar.gz\n\t\tlet extension = nodePath.extname(path)\n\t\tlet name = nodePath.basename(path)\n\t\tif (extension) name = name.slice(0, -extension.length)\n\n\t\t// Handle tar.* extensions\n\t\tconst tar = '.tar'\n\t\tif (name.endsWith(tar)) {\n\t\t\tname = name.slice(0, -tar.length)\n\t\t\textension = `${tar}${extension}`\n\t\t}\n\n\t\treturn {name, extension}\n\t}\n\n\t// Get unique name for a file or directory\n\t// If the path doesn't exist we return the original path.\n\t// If the path exists we will append a number to the end of the file name\n\t// until we find a unique name.\n\t// Note that if two operations call this soon after each other with the\n\t// the same path before the first one has created the file at the unique path\n\t// it's possible that we will return the same \"unique\" name for both calls.\n\t// We could implement some kind of cache to avoid this but it's unlikely to be an issue.\n\tasync getUniqueName(systemPath: string, {maxIndex = 100} = {}) {\n\t\t// TODO: Handle complex extensions like .tar.gz\n\t\tconst {name, extension} = this.splitExtension(systemPath)\n\t\tconst path = nodePath.dirname(systemPath)\n\n\t\tlet index = 2\n\t\tlet uniquePath = systemPath\n\t\twhile (await fse.pathExists(uniquePath)) {\n\t\t\tif (index > maxIndex) throw new Error(`[unique-name-index-exceeded]`)\n\t\t\tuniquePath = nodePath.join(path, `${name} (${index})${extension ? extension : ''}`)\n\t\t\tindex++\n\t\t}\n\n\t\treturn uniquePath\n\t}\n\n\t// We expose an unsafe conversion method that's only suitable to be used on trusted paths.\n\t// This method is sync and doesn't touch the fs for validation which is important for some use cases\n\t// for internal code where we just need to convert between path types but don't want to validate anything.\n\tvirtualToSystemPathUnsafe(virtualPath: string) {\n\t\t// Normalize virtual path before lookup so directory traversal attacks cannot be resolved.\n\t\t// e.g: /Home/../../../../etc/passwd normalizes to /etc/passwd which won't get a match in the base directories lookup.\n\t\tvirtualPath = normalizePath(virtualPath)\n\n\t\t// Ensure the path is absolute, we can't resolve relative paths.\n\t\t// e.g /Home/file.pdf can be resolved but Home/file.pdf can't.\n\t\tif (!nodePath.posix.isAbsolute(virtualPath)) throw new Error(`[path-not-absolute]`)\n\n\t\t// Split the path into segments and lookup the system path for the base directory\n\t\tconst segments = virtualPath.split('/').filter(Boolean)\n\t\tconst basePath = this.baseDirectories.get(`/${segments[0]}`)\n\n\t\t// Error if we don't find a matching base directory\n\t\tif (!basePath) throw new Error(`[invalid-base] No valid base directory found for path: ${virtualPath}`)\n\n\t\t// Swap out the base directory with it's system path and resolve any symlinks\n\t\t// or directory traversals to get the real path.\n\t\tsegments[0] = basePath\n\t\tconst systemPath = segments.join('/')\n\n\t\treturn systemPath\n\t}\n\n\t// Converts a virtual path to a system path.\n\t// Ensures that the path is safe and does not escape the expected base directory.\n\t// If the full path doesn't exist it validates symlinks up to the deepest existing path.\n\tasync virtualToSystemPath(virtualPath: string) {\n\t\t// Split the path into segments and lookup the system path for the base directory\n\t\tconst segments = virtualPath.split('/').filter(Boolean)\n\t\tconst basePath = this.baseDirectories.get(`/${segments[0]}`)!\n\n\t\tconst systemPath = this.virtualToSystemPathUnsafe(virtualPath)\n\n\t\t// Ensure the deepest existing real path doesn't resolve to a directory outside\n\t\t// of the expected base path. We use realpath to resolve symlinks. This prevents\n\t\t// escaping the base directory if a symlink is in the path.\n\t\t// e.g:\n\t\t// /Home/symlink-to-root/etc/passwd\n\t\tconst deepestExistingPath = await getDeepestExistingPath(systemPath)\n\t\tconst deepestExistingRealPath = await fse.realpath(deepestExistingPath)\n\t\tconst realPath = systemPath.replace(deepestExistingPath, deepestExistingRealPath)\n\t\tif (!realPath.startsWith(basePath)) throw new Error(`[escapes-base] '${virtualPath}' escapes '${basePath}'`)\n\n\t\t// We return the system path not the real path because at this point we know\n\t\t// the path is safe and we want to return the path as it was passed in.\n\t\t// Otherwise we'd resolve symlinks in the path and weird stuff would happen\n\t\t// like copying a symlink to a file resulting in copying the file instead of the symlink.\n\t\t// e.g:\n\t\t// /Home/symlink-to-documents\n\t\t// would resolve to system path for /Home/Documents not the actual symlink path.\n\t\treturn systemPath\n\t}\n\n\t// Converts a system path to a virtual path.\n\t// Ensures that the path is safe and does not escape the expected base directory.\n\tsystemToVirtualPath(systemPath: string) {\n\t\t// Normalize the system path to handle any directory traversals\n\t\tsystemPath = normalizePath(systemPath)\n\n\t\t// Find the base directory this path belongs to by checking if it starts with any of the base paths\n\t\tfor (const [baseDirectory, basePath] of this.baseDirectories) {\n\t\t\tif (systemPath.startsWith(basePath)) {\n\t\t\t\t// Replace the system base path with the virtual base directory name\n\t\t\t\tconst virtualPath = systemPath.replace(basePath, baseDirectory)\n\t\t\t\t// Normalize to handle any remaining path oddities\n\t\t\t\treturn normalizePath(virtualPath)\n\t\t\t}\n\t\t}\n\n\t\tthrow new Error(`[invalid-path] Path '${systemPath}' is not within any base directory`)\n\t}\n\n\t// Get view preferences\n\tasync getViewPreferences(): Promise<ViewPreferences> {\n\t\tconst viewPreferences = await this.#umbreld.store.get('files.preferences')\n\t\treturn viewPreferences || DEFAULT_VIEW_PREFERENCES\n\t}\n\n\t// Update view preferences\n\tasync updateViewPreferences(newViewPreferences: Partial<ViewPreferences>): Promise<ViewPreferences> {\n\t\tlet updatedViewPreferences: ViewPreferences\n\n\t\t// Save the new preferences to the store\n\t\tawait this.#umbreld.store.getWriteLock(async ({get, set}) => {\n\t\t\tconst currentViewPreferences = await this.getViewPreferences()\n\t\t\tupdatedViewPreferences = {...currentViewPreferences, ...newViewPreferences}\n\t\t\tawait set('files.preferences', updatedViewPreferences)\n\t\t})\n\n\t\treturn updatedViewPreferences!\n\t}\n}\n\n// Match a path against a list of glob patterns\nfunction match(path: string, patterns: string[]) {\n\t// TODO: Cache Regex creation if perf becomes an issue\n\treturn patterns.some((pattern) => minimatch(path, pattern, {dot: true}))\n}\n\n// Resolve traversals and always trim trailing trash\nfunction normalizePath(path: string) {\n\t// Reduce `.`, `..` and multiple slashes to their canonical form\n\tconst normalized = nodePath.posix.normalize(path)\n\n\t// Trim trailing slash, except for the root directory\n\tif (normalized === '/') return normalized\n\treturn normalized.endsWith('/') ? normalized.slice(0, -1) : normalized\n}\n\n// Given a file path will return the deepest existing path.\nasync function getDeepestExistingPath(path: string) {\n\t// Resolve the input to an absolute path\n\tlet currentPath = nodePath.resolve(path)\n\n\twhile (true) {\n\t\t// Check if the current path exists\n\t\tif (await fse.pathExists(currentPath)) return currentPath\n\n\t\t// Move up one level in the path hierarchy\n\t\tconst parentPath = nodePath.dirname(currentPath)\n\n\t\t// If we're at the root and it doesn't exist, throw an error cos\n\t\t// something really bad has happened and we're gonna infinite loop.\n\t\tif (parentPath === currentPath) throw new Error(`[cant-find-root] Can't validate path if entire tree doesn't exist`)\n\n\t\tcurrentPath = parentPath\n\t}\n}\n\n// Wrap with our own method with nicer error handling\nasync function move(sourceSystemPath: string, targetSystemPath: string, {overwrite = false} = {}) {\n\treturn fse.move(sourceSystemPath, targetSystemPath, {overwrite}).catch((error) => {\n\t\tconst message = error?.message || ''\n\t\tif (message.includes('ENOENT')) throw new Error('[source-not-exists]')\n\t\tif (message.includes('dest already exists')) throw new Error('[destination-already-exists]')\n\t\tif (message.includes('subdirectory of itself')) throw new Error('[subdir-of-self]')\n\t\tthrow new Error(`[move-failed] ${error?.message}`)\n\t})\n}\n\n// Stream the contents of a directory\n// Optionally recurse into subdirectories\nexport async function* getDirectoryStream(directory: string, options?: {recursive?: boolean}) {\n\t// We have to use any here because @tsconfig/node22 types are incorrect and don't recognise options.recursive\n\tconst directoryListing = await fse.opendir(directory, options as any)\n\ttry {\n\t\t// Again we need any due to incorrect types\n\t\tfor await (const file of directoryListing) yield nodePath.join((file as any).parentPath, file.name)\n\t} finally {\n\t\t// Ensure the directory is closed if we error\n\t\tdirectoryListing.close().catch(() => {})\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/network-storage.integration.test.ts",
    "content": "import nodePath from 'node:path'\n\nimport {expect, beforeEach, afterEach, describe, test} from 'vitest'\n\nimport fse from 'fs-extra'\nimport {delay} from 'es-toolkit'\nimport pRetry from 'p-retry'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\n// Create a new umbreld instance for each test\nbeforeEach(async () => (umbreld = await createTestUmbreld({autoLogin: true})))\nafterEach(async () => await umbreld.cleanup())\n\n// Helper to setup a network share for testing\nasync function createNetworkShare(umbreld: Awaited<ReturnType<typeof createTestUmbreld>>, shareName: string) {\n\t// Create a test directory and add it as a local Samba share\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/${shareName}`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.writeFile(`${testDirectory}/test-file.txt`, 'test content')\n\n\t// Add directory as a Samba share\n\tawait umbreld.client.files.addShare.mutate({path: `/Home/${shareName}`})\n\n\t// Get share password\n\tconst sharePassword = await umbreld.client.files.sharePassword.query()\n\n\t// Add the local share as a network share\n\tconst mountPath = await pRetry(\n\t\t() =>\n\t\t\tumbreld.client.files.addNetworkShare.mutate({\n\t\t\t\thost: 'localhost',\n\t\t\t\tshare: `${shareName} (Umbrel)`,\n\t\t\t\tusername: 'umbrel',\n\t\t\t\tpassword: sharePassword,\n\t\t\t}),\n\t\t{retries: 5, factor: 1},\n\t)\n\n\treturn mountPath\n}\n\ndescribe('listNetworkShares()', () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.listNetworkShares.query()).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('returns empty array on first start', async () => {\n\t\tconst shares = await umbreld.client.files.listNetworkShares.query()\n\t\texpect(shares).toStrictEqual([])\n\t})\n\n\ttest('returns network shares with mount status', async () => {\n\t\tconst mountPath = await createNetworkShare(umbreld, 'network-test-share')\n\n\t\t// List network shares\n\t\tconst shares = await umbreld.client.files.listNetworkShares.query()\n\t\texpect(shares).toHaveLength(1)\n\t\texpect(shares[0]).toEqual({\n\t\t\thost: 'localhost',\n\t\t\tshare: 'network-test-share (Umbrel)',\n\t\t\tmountPath,\n\t\t\tisMounted: true,\n\t\t})\n\t})\n})\n\ndescribe('addNetworkShare()', () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(\n\t\t\tumbreld.unauthenticatedClient.files.addNetworkShare.mutate({\n\t\t\t\thost: 'localhost',\n\t\t\t\tshare: 'test',\n\t\t\t\tusername: 'user',\n\t\t\t\tpassword: 'pass',\n\t\t\t}),\n\t\t).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('successfully adds and mounts a network share', async () => {\n\t\tconst mountPath = await createNetworkShare(umbreld, 'samba-network-test')\n\n\t\texpect(mountPath).toBe('/Network/localhost/samba-network-test (Umbrel)')\n\n\t\t// Verify the share is mounted and accessible\n\t\tconst networkFiles = await umbreld.client.files.list.query({path: mountPath})\n\t\texpect(networkFiles.files).toHaveLength(1)\n\t\texpect(networkFiles.files[0].name).toBe('test-file.txt')\n\n\t\t// Test writing a new directory in the network share\n\t\tawait umbreld.client.files.createDirectory.mutate({path: `${mountPath}/new-directory`})\n\t\tconst result = await umbreld.client.files.list.query({path: mountPath})\n\t\texpect(result.files.map((f) => f.name)).toContain('new-directory')\n\t})\n\n\ttest('throws error when adding duplicate network share', async () => {\n\t\tawait createNetworkShare(umbreld, 'duplicate-network-test')\n\n\t\t// Get share password\n\t\tconst sharePassword = await umbreld.client.files.sharePassword.query()\n\n\t\t// Try to add the same share again\n\t\tawait expect(\n\t\t\tumbreld.client.files.addNetworkShare.mutate({\n\t\t\t\thost: 'localhost',\n\t\t\t\tshare: 'duplicate-network-test (Umbrel)',\n\t\t\t\tusername: 'umbrel',\n\t\t\t\tpassword: sharePassword,\n\t\t\t}),\n\t\t).rejects.toThrow('already exists')\n\t})\n\n\ttest('throws error with invalid credentials', async () => {\n\t\t// Create a test directory and add it as a local Samba share\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/invalid-creds-test`\n\t\tawait fse.mkdir(testDirectory)\n\n\t\t// Add directory as a Samba share\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/invalid-creds-test'})\n\n\t\t// Wait for Samba to start\n\t\tawait delay(3000)\n\n\t\t// Try to add network share with wrong password\n\t\tawait expect(\n\t\t\tumbreld.client.files.addNetworkShare.mutate({\n\t\t\t\thost: 'localhost',\n\t\t\t\tshare: 'invalid-creds-test (Umbrel)',\n\t\t\t\tusername: 'umbrel',\n\t\t\t\tpassword: 'wrong-password',\n\t\t\t}),\n\t\t).rejects.toThrow()\n\t})\n\n\ttest('cleans up mount directory when mount fails', async () => {\n\t\t// Try to mount a non-existent share\n\t\tawait expect(\n\t\t\tumbreld.client.files.addNetworkShare.mutate({\n\t\t\t\thost: 'non-existent-host.local',\n\t\t\t\tshare: 'non-existent-share',\n\t\t\t\tusername: 'test',\n\t\t\t\tpassword: 'secret',\n\t\t\t}),\n\t\t).rejects.toThrow()\n\n\t\t// Verify no leftover directories were created\n\t\tconst networkFiles = await umbreld.client.files.list.query({path: '/Network'})\n\t\texpect(networkFiles.files).toHaveLength(0)\n\t})\n})\n\ndescribe('removeNetworkShare()', () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(\n\t\t\tumbreld.unauthenticatedClient.files.removeNetworkShare.mutate({mountPath: '/Network/test/share'}),\n\t\t).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('throws error when removing non-existent share', async () => {\n\t\tawait expect(\n\t\t\tumbreld.client.files.removeNetworkShare.mutate({\n\t\t\t\tmountPath: '/Network/non-existent/share',\n\t\t\t}),\n\t\t).rejects.toThrow('Share with mount path /Network/non-existent/share not found')\n\t})\n\n\ttest('successfully removes a network share', async () => {\n\t\tconst mountPath = await createNetworkShare(umbreld, 'remove-network-test')\n\n\t\t// Verify share exists\n\t\tconst sharesBefore = await umbreld.client.files.listNetworkShares.query()\n\t\texpect(sharesBefore).toHaveLength(1)\n\n\t\t// Remove the network share\n\t\tconst result = await umbreld.client.files.removeNetworkShare.mutate({mountPath})\n\t\texpect(result).toBe(true)\n\n\t\t// Verify share is removed\n\t\tconst sharesAfter = await umbreld.client.files.listNetworkShares.query()\n\t\texpect(sharesAfter).toHaveLength(0)\n\t})\n})\n\ndescribe('discoverNetworkShareServers()', () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.discoverNetworkShareServers.query()).rejects.toThrow(\n\t\t\t'Invalid token',\n\t\t)\n\t})\n\n\t// Skipping for now since this will fail in CI\n\t// TODO: Fix this test by running a full blown umbrel-dev instance in CI\n\ttest.skip('returns array of discovered servers', async () => {\n\t\tconst servers = await umbreld.client.files.discoverNetworkShareServers.query()\n\t\texpect(servers).toContain('umbrel-dev.local')\n\t})\n})\n\ndescribe('discoverNetworkSharesOnServer()', () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(\n\t\t\tumbreld.unauthenticatedClient.files.discoverNetworkSharesOnServer.query({\n\t\t\t\thost: 'localhost',\n\t\t\t\tusername: 'user',\n\t\t\t\tpassword: 'pass',\n\t\t\t}),\n\t\t).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('throws error with invalid credentials', async () => {\n\t\t// Create a test directory and add it as a Samba share\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/discover-invalid-test`\n\t\tawait fse.mkdir(testDirectory)\n\n\t\t// Add directory as a Samba share\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/discover-invalid-test'})\n\n\t\t// Wait for Samba to start\n\t\tawait delay(3000)\n\n\t\t// Try to discover shares with wrong credentials\n\t\tawait expect(\n\t\t\tumbreld.client.files.discoverNetworkSharesOnServer.query({\n\t\t\t\thost: 'localhost',\n\t\t\t\tusername: 'umbrel',\n\t\t\t\tpassword: 'wrong-password',\n\t\t\t}),\n\t\t).rejects.toThrow()\n\t})\n\n\ttest('discovers shares on local Samba server', async () => {\n\t\t// Create test directories and add them as Samba shares\n\t\tconst testDirectory1 = `${umbreld.instance.dataDirectory}/home/discover-test-1`\n\t\tconst testDirectory2 = `${umbreld.instance.dataDirectory}/home/discover-test-2`\n\t\tawait fse.mkdir(testDirectory1)\n\t\tawait fse.mkdir(testDirectory2)\n\n\t\t// Add directories as Samba shares\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/discover-test-1'})\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/discover-test-2'})\n\n\t\t// Wait for Samba to start and be ready\n\t\tawait delay(1000)\n\n\t\t// Get share password\n\t\tconst sharePassword = await umbreld.client.files.sharePassword.query()\n\n\t\t// Discover shares on localhost\n\t\tconst shares = await umbreld.client.files.discoverNetworkSharesOnServer.query({\n\t\t\thost: 'localhost',\n\t\t\tusername: 'umbrel',\n\t\t\tpassword: sharePassword,\n\t\t})\n\n\t\t// Should find our test shares\n\t\texpect(shares).toMatchObject(expect.arrayContaining(['discover-test-1 (Umbrel)', 'discover-test-2 (Umbrel)']))\n\t})\n})\n\ndescribe('isServerAnUmbrelDevice()', () => {\n\ttest('returns true for an umbrel device', async () => {\n\t\tconst address = `localhost:${umbreld.instance.server.port}`\n\t\tconst isServerAnUmbrelDevice = await umbreld.client.files.isServerAnUmbrelDevice.query({address})\n\t\texpect(isServerAnUmbrelDevice).toBe(true)\n\t})\n\n\ttest('returns false for a non-umbrel device', async () => {\n\t\tconst address = 'localhost:12345'\n\t\tconst isServerAnUmbrelDevice = await umbreld.client.files.isServerAnUmbrelDevice.query({address})\n\t\texpect(isServerAnUmbrelDevice).toBe(false)\n\t})\n})\n\ndescribe('file permissions', () => {\n\ttest('allows hard deletion of network files', async () => {\n\t\tconst mountPath = await createNetworkShare(umbreld, 'network-deletion-test')\n\n\t\t// Attempt to hard delete a file from the network share\n\t\tawait expect(umbreld.client.files.delete.mutate({path: `${mountPath}/test-file.txt`})).resolves.not.toThrow()\n\t})\n\n\ttest('does not allow soft trash of network files', async () => {\n\t\tconst mountPath = await createNetworkShare(umbreld, 'network-trash-test')\n\n\t\t// Attempt to trash a file from the network share\n\t\tawait expect(umbreld.client.files.trash.mutate({path: `${mountPath}/test-file.txt`})).rejects.toThrow(\n\t\t\t'[operation-not-allowed]',\n\t\t)\n\t})\n\n\ttest('network mount points are protected paths', async () => {\n\t\tconst mountPath = await createNetworkShare(umbreld, 'network-protected-test')\n\n\t\t// Test each level of the mount path is protected\n\t\texpect(mountPath).toBe('/Network/localhost/network-protected-test (Umbrel)')\n\t\tconst hostnamePath = '/Network/localhost'\n\t\tconst networkPath = '/Network'\n\t\tfor (const path of [networkPath, hostnamePath, mountPath]) {\n\t\t\t// Trash\n\t\t\tawait expect(umbreld.client.files.trash.mutate({path})).rejects.toThrow('[operation-not-allowed]')\n\t\t\t// Delete\n\t\t\tawait expect(umbreld.client.files.delete.mutate({path})).rejects.toThrow('[operation-not-allowed]')\n\t\t\t// Move\n\t\t\tawait expect(umbreld.client.files.move.mutate({path, toDirectory: '/Home'})).rejects.toThrow(\n\t\t\t\t'[operation-not-allowed]',\n\t\t\t)\n\t\t\t// Rename\n\t\t\tawait expect(umbreld.client.files.rename.mutate({path, newName: 'Renamed Network Share'})).rejects.toThrow(\n\t\t\t\t'[operation-not-allowed]',\n\t\t\t)\n\t\t\t// Can't have siblings created\n\t\t\t// Skip /Network cos /test is not a valid base path\n\t\t\tif (path !== networkPath) {\n\t\t\t\tconst siblingPath = nodePath.join(nodePath.dirname(path), 'test')\n\t\t\t\tawait expect(umbreld.client.files.createDirectory.mutate({path: siblingPath})).rejects.toThrow(\n\t\t\t\t\t'[operation-not-allowed]',\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t})\n\n\ttest('network storage paths cannot be shared', async () => {\n\t\tconst mountPath = await createNetworkShare(umbreld, 'network-sharing-test')\n\n\t\t// Test that network paths cannot be shared\n\t\texpect(mountPath).toBe('/Network/localhost/network-sharing-test (Umbrel)')\n\t\tconst hostnamePath = '/Network/localhost'\n\t\tconst networkPath = '/Network'\n\t\tconst shareFilePath = `${mountPath}/test-file.txt`\n\n\t\tfor (const path of [networkPath, hostnamePath, mountPath, shareFilePath]) {\n\t\t\tawait expect(umbreld.client.files.addShare.mutate({path})).rejects.toThrow('[operation-not-allowed]')\n\t\t}\n\t})\n})\n\ndescribe('behaviour', () => {\n\ttest('auto mounts an added network share on startup', async () => {\n\t\tconst mountPath = await createNetworkShare(umbreld, 'startup-test')\n\n\t\texpect(mountPath).toBe('/Network/localhost/startup-test (Umbrel)')\n\n\t\t// Verify the share is mounted and accessible\n\t\tconst networkFiles = await umbreld.client.files.list.query({path: mountPath})\n\t\texpect(networkFiles.files).toHaveLength(1)\n\t\texpect(networkFiles.files[0].name).toBe('test-file.txt')\n\n\t\t// Set the share watch interval to 100ms and restart umbreld\n\t\tumbreld.instance.files.networkStorage.shareWatchInterval = 100\n\t\tawait umbreld.instance.stop()\n\t\tawait umbreld.instance.start()\n\n\t\t// Verify the share is still mounted and accessible\n\t\t// Retry a few times because it might take a while for the share to be available\n\t\tawait pRetry(\n\t\t\tasync () => {\n\t\t\t\tconst networkFilesAfterRestart = await umbreld.client.files.list.query({path: mountPath})\n\t\t\t\texpect(networkFilesAfterRestart.files).toHaveLength(1)\n\t\t\t\texpect(networkFilesAfterRestart.files[0].name).toBe('test-file.txt')\n\t\t\t},\n\t\t\t{retries: 10, factor: 1},\n\t\t)\n\t})\n\n\ttest('auto mounts remounts a network share if it goes offline and then comes back online', async () => {\n\t\t// Set the share watch interval to 100ms and restart umbreld\n\t\tumbreld.instance.files.networkStorage.shareWatchInterval = 100\n\t\tawait umbreld.instance.stop()\n\t\tawait umbreld.instance.start()\n\n\t\tconst mountPath = await createNetworkShare(umbreld, 'reconnect-test')\n\n\t\texpect(mountPath).toBe('/Network/localhost/reconnect-test (Umbrel)')\n\n\t\t// Verify the share is mounted and accessible\n\t\tconst networkFiles = await umbreld.client.files.list.query({path: mountPath})\n\t\texpect(networkFiles.files).toHaveLength(1)\n\t\texpect(networkFiles.files[0].name).toBe('test-file.txt')\n\n\t\t// Remove the share\n\t\tawait umbreld.client.files.removeShare.mutate({path: '/Home/reconnect-test'})\n\n\t\t// Verify the share is no longer mounted\n\t\tawait pRetry(() => expect(umbreld.client.files.list.query({path: mountPath})).rejects.toThrow('EHOSTDOWN'), {\n\t\t\tretries: 60,\n\t\t\tfactor: 1,\n\t\t})\n\n\t\t// Add the share again\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/reconnect-test'})\n\n\t\t// Verify the share got automatically remounted\n\t\tawait pRetry(\n\t\t\tasync () => {\n\t\t\t\tconst networkFilesAfterRestart = await umbreld.client.files.list.query({path: mountPath})\n\t\t\t\texpect(networkFilesAfterRestart.files).toHaveLength(1)\n\t\t\t\texpect(networkFilesAfterRestart.files[0].name).toBe('test-file.txt')\n\t\t\t},\n\t\t\t{retries: 10, factor: 1},\n\t\t)\n\t})\n\n\ttest('cleans up mounts on shutdown', async () => {\n\t\tconst mountPath = await createNetworkShare(umbreld, 'cleanup-test')\n\n\t\texpect(mountPath).toBe('/Network/localhost/cleanup-test (Umbrel)')\n\n\t\t// Verify the share is mounted and accessible\n\t\tconst networkFiles = await umbreld.client.files.list.query({path: mountPath})\n\t\texpect(networkFiles.files).toHaveLength(1)\n\t\texpect(networkFiles.files[0].name).toBe('test-file.txt')\n\n\t\t// Check mount point exists\n\t\tconst systemMountPath = await umbreld.instance.files.virtualToSystemPath(mountPath)\n\t\texpect(fse.existsSync(systemMountPath)).toBe(true)\n\n\t\t// Stop umbreld\n\t\tawait umbreld.instance.stop().catch((error) => console.log(error))\n\n\t\t// Check mount point is removed\n\t\texpect(fse.existsSync(systemMountPath)).toBe(false)\n\n\t\t// Start umbreld again (just to afterEach stop doesn't fail)\n\t\tawait umbreld.instance.start()\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/network-storage.ts",
    "content": "import nodePath from 'node:path'\nimport {setTimeout} from 'node:timers/promises'\n\nimport fse from 'fs-extra'\nimport {$} from 'execa'\nimport ky from 'ky'\n\nimport {getHostname} from '../system/system.js'\n\nimport type Umbreld from '../../index.js'\n\ntype NetworkShare = {\n\thost: string\n\tshare: string\n\tusername: string\n\tpassword: string\n\tmountPath: string\n}\n\nexport default class NetworkStorage {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\tmountedShares: Set<string>\n\tshareWatchInterval = 1000 * 60 // One minute\n\tisRunning = false\n\twatchJobPromise?: Promise<void>\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(`files:${name.toLowerCase()}`)\n\t\tthis.mountedShares = new Set()\n\t}\n\n\tasync start() {\n\t\tthis.isRunning = true\n\t\tthis.watchJobPromise = this.#watchAndMountShares().catch((error) =>\n\t\t\tthis.logger.error('Error watching and mounting shares', error),\n\t\t)\n\t}\n\n\tasync stop() {\n\t\tthis.logger.log('Stopping network storage')\n\t\tthis.isRunning = false\n\n\t\tconst ONE_SECOND = 1000\n\n\t\t// Wait for background job to finish\n\t\tif (this.watchJobPromise) {\n\t\t\tawait Promise.race([\n\t\t\t\tsetTimeout(ONE_SECOND * 10),\n\t\t\t\t(async () => {\n\t\t\t\t\tthis.logger.log('Waiting for background job to finish')\n\t\t\t\t\tawait this.watchJobPromise!.catch(() => {})\n\t\t\t\t})(),\n\t\t\t])\n\t\t}\n\n\t\t// Cleanup any currently mounted shares\n\t\tawait Promise.race([\n\t\t\tsetTimeout(ONE_SECOND * 10),\n\t\t\t(async () => {\n\t\t\t\tthis.logger.log('Unmounting shares')\n\t\t\t\tawait this.#unmountAllShares().catch((error) => this.logger.error('Error unmounting shares', error))\n\t\t\t})(),\n\t\t])\n\t}\n\n\t// List all shares from the store\n\tasync getShares() {\n\t\treturn (await this.#umbreld.store.get('files.networkStorage')) || []\n\t}\n\n\t// List all shares including mount status\n\tasync getShareInfo() {\n\t\tconst shares = await this.getShares()\n\t\treturn shares.map(({host, share, mountPath}) => ({\n\t\t\thost,\n\t\t\tshare,\n\t\t\tmountPath,\n\t\t\tisMounted: this.mountedShares.has(mountPath),\n\t\t}))\n\t}\n\n\t// Constantly check if shares are mounted and if not, mount them\n\tasync #watchAndMountShares() {\n\t\tthis.logger.log('Scheduling network share watch interval')\n\t\tlet lastRun = 0\n\t\twhile (this.isRunning) {\n\t\t\tawait setTimeout(100)\n\t\t\tconst shouldRun = Date.now() - lastRun >= this.shareWatchInterval\n\t\t\tif (!shouldRun) continue\n\t\t\tlastRun = Date.now()\n\n\t\t\tthis.logger.verbose('Running network share watch interval')\n\t\t\tconst shares = await this.getShares()\n\t\t\tawait Promise.all(\n\t\t\t\tshares.map(async (share) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tif (await this.#isMounted(share)) {\n\t\t\t\t\t\t\tthis.mountedShares.add(share.mountPath)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tthis.mountedShares.delete(share.mountPath)\n\t\t\t\t\t\t\tawait this.#mountShare(share)\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (error) {}\n\t\t\t\t}),\n\t\t\t)\n\t\t\tthis.logger.verbose('Network share watch interval complete')\n\t\t}\n\t}\n\n\t// Check if a share is mounted\n\tasync #isMounted(share: NetworkShare): Promise<boolean> {\n\t\ttry {\n\t\t\tconst systemMountPath = await this.#umbreld.files.virtualToSystemPathUnsafe(share.mountPath)\n\t\t\tawait $`mountpoint ${systemMountPath}`\n\n\t\t\treturn true\n\t\t} catch (error) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Attempt to mount a share\n\tasync #mountShare(share: NetworkShare): Promise<void> {\n\t\tthis.logger.log(`Mounting network share: ${share.mountPath}`)\n\n\t\t// Ensure mount directory exists\n\t\tconst systemMountPath = this.#umbreld.files.virtualToSystemPathUnsafe(share.mountPath)\n\t\tawait fse.ensureDir(systemMountPath)\n\n\t\ttry {\n\t\t\t// Mount the network share\n\t\t\tconst smbPath = `//${share.host}/${share.share}`\n\t\t\tconst {userId, groupId} = this.#umbreld.files.fileOwner\n\t\t\tawait $`mount -t cifs ${smbPath} ${systemMountPath} -o username=${share.username},password=${share.password},uid=${userId},gid=${groupId},iocharset=utf8`\n\t\t\tthis.mountedShares.add(share.mountPath)\n\t\t\tthis.logger.log(`Successfully mounted network share: ${smbPath} to ${share.mountPath}`)\n\t\t} catch (error) {\n\t\t\t// Clean up the directory we created if mount fails\n\t\t\tthis.logger.error(`Failed to mount network share: ${share.mountPath}, cleaning up mount directory`)\n\t\t\tthis.#unmountShare(share).catch((error) =>\n\t\t\t\tthis.logger.error(`Failed to clean up mount directory after mount failure: ${share.mountPath}`, error),\n\t\t\t)\n\n\t\t\t// Re-throw the original mount error\n\t\t\tthrow error\n\t\t}\n\t}\n\n\t// Unmount a share, don't throw on failure\n\tasync #unmountShare(share: NetworkShare): Promise<void> {\n\t\tthis.logger.log(`Unmounting network share: ${share.mountPath}`)\n\t\ttry {\n\t\t\t// If we're mounted, unmount\n\t\t\tconst systemMountPath = this.#umbreld.files.virtualToSystemPathUnsafe(share.mountPath)\n\t\t\tif (await this.#isMounted(share)) await $`umount ${systemMountPath}`\n\n\t\t\t// Clean up empty mount directory\n\t\t\tawait fse.rmdir(systemMountPath)\n\n\t\t\t// Clean up parent dir if it's empty\n\t\t\tconst parentDirectory = nodePath.dirname(systemMountPath)\n\t\t\tconst parentFiles = await fse.readdir(parentDirectory)\n\t\t\tconst isParentEmpty = parentFiles.length === 0\n\t\t\tconst isParentChildOfNetwork =\n\t\t\t\tnodePath.dirname(parentDirectory) === this.#umbreld.files.getBaseDirectory('/Network')\n\t\t\tif (isParentEmpty && isParentChildOfNetwork) await fse.rmdir(parentDirectory)\n\n\t\t\tthis.mountedShares.delete(share.mountPath)\n\t\t\tthis.logger.log(`Successfully unmounted network share: ${share.mountPath}`)\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to unmount network share ${share.mountPath}`, error)\n\t\t}\n\t}\n\n\t// Unmount all shares concurrently\n\tasync #unmountAllShares(): Promise<void> {\n\t\tconst shares = await this.getShares()\n\t\tawait Promise.all(shares.map(async (share) => this.#unmountShare(share)))\n\t}\n\n\t// Add a new share\n\tasync addShare(newShare: Omit<NetworkShare, 'mountPath'>) {\n\t\t// Generate mount path\n\t\tconst sanitize = (string: string) => string.replace(/[^a-zA-Z0-9\\-\\.\\' \\(\\)]/g, '')\n\t\tconst mountPath = `/Network/${sanitize(newShare.host)}/${sanitize(newShare.share)}`\n\n\t\t// Check if the share already exists\n\t\tconst alreadyExists = await this.getShare(mountPath)\n\t\t\t.then(() => true)\n\t\t\t.catch(() => false)\n\t\tif (alreadyExists) throw new Error(`Share with mount path ${mountPath} already exists`)\n\n\t\t// Create share object\n\t\tconst share: NetworkShare = {...newShare, mountPath}\n\n\t\t// Check we can mount the share\n\t\tawait this.#mountShare(share)\n\n\t\t// Save new share in the store\n\t\tawait this.#umbreld.store.getWriteLock(async ({set}) => {\n\t\t\tconst shares = await this.getShares()\n\t\t\tif (shares.find((existingShare) => existingShare.mountPath === share.mountPath)) return\n\t\t\tshares.push(share)\n\t\t\tawait set('files.networkStorage', shares)\n\t\t})\n\n\t\treturn share.mountPath\n\t}\n\n\t// Get a share by mount path\n\tasync getShare(mountPath: string) {\n\t\tconst shares = await this.getShares()\n\t\tconst share = shares.find((share) => share.mountPath === mountPath)\n\t\tif (!share) throw new Error(`Share with mount path ${mountPath} not found`)\n\t\treturn share\n\t}\n\n\t// Remove a share\n\tasync removeShare(sharePath: string) {\n\t\tconst share = await this.getShare(sharePath)\n\n\t\t// Attempt to unmount the share first\n\t\tawait this.#unmountShare(share)\n\n\t\t// Remove the share from the store\n\t\tawait this.#umbreld.store.getWriteLock(async ({set}) => {\n\t\t\tconst shares = await this.getShares()\n\t\t\tconst newShares = shares.filter((existingShare) => existingShare.mountPath !== sharePath)\n\t\t\tawait set('files.networkStorage', newShares)\n\t\t})\n\n\t\treturn true\n\t}\n\n\t// Discover available servers\n\t// Used to help the user find servers if they don't already know the address\n\tasync discoverServers() {\n\t\tconst avahiBrowse = await $`avahi-browse --resolve --terminate _smb._tcp --parsable`\n\n\t\tconst hostname = await getHostname().catch(() => '')\n\n\t\tconst servers = avahiBrowse.stdout\n\t\t\t.split('\\n')\n\t\t\t// Grab mDNS domain name\n\t\t\t.map((line) => line.split(';')[6])\n\t\t\t// Filter out empty values\n\t\t\t.filter((line) => typeof line === 'string' && line !== '')\n\t\t\t// Filter out the current hostname\n\t\t\t.filter((line) => line !== `${hostname}.local`)\n\n\t\t// Only return each address once\n\t\treturn Array.from(new Set(servers))\n\t}\n\n\t// Discover shares for a given samba server\n\t// Used to help the user find share names if they don't already know them\n\tasync discoverSharesOnServer(host: string, username: string, password: string) {\n\t\t// TODO: Figure out if we can speed this up\n\t\t// The command usually returns data quite quickly but then hangs for like 10 seconds\n\t\t// and returns some weird compatibility error. Is there some way we can disable whatever\n\t\t// is causing the hang so we can get the command to return as soon as we have the info\n\t\t// we care about?\n\t\tconst smbclient = await $`smbclient --list //${host} --user ${username} --password ${password} --grepable`\n\n\t\tconst shares = smbclient.stdout\n\t\t\t// Process line by line\n\t\t\t.split('\\n')\n\t\t\t// Filter out any lines that don't have 3 '|' separated columns\n\t\t\t.filter((line) => line.split('|').length === 3)\n\t\t\t// Grab the second column (the share name)\n\t\t\t.map((line) => line.split('|')[1])\n\t\t\t// Filter out the IPC$ share that Samba always creates\n\t\t\t.filter((share) => share !== 'IPC$')\n\n\t\treturn shares\n\t}\n\n\t// Checks if the given network address is an Umbrel device\n\tasync isServerAnUmbrelDevice(address: string) {\n\t\ttry {\n\t\t\tconst responseText = (await ky(`http://${address}/trpc/system.version`, {timeout: 1000}).text()) as any\n\t\t\treturn responseText.toLowerCase().includes('umbrel')\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/recents.test.ts",
    "content": "import {expect, test, beforeEach, afterEach} from 'vitest'\nimport fse from 'fs-extra'\nimport {delay} from 'es-toolkit'\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\n// Create new umbreld instance for each test to clear recent state\nbeforeEach(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\n// Clean up after each test\nafterEach(async () => {\n\tawait umbreld.cleanup()\n})\n\ntest('recents() throws invalid error without auth token', async () => {\n\tawait expect(umbreld.unauthenticatedClient.files.recents.query()).rejects.toThrow('Invalid token')\n})\n\ntest('recents() returns recently modified files in correct order', async () => {\n\t// Create test directory and files\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/recents-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create files with different timestamps\n\tawait fse.writeFile(`${testDirectory}/file1.txt`, 'content1')\n\tawait fse.writeFile(`${testDirectory}/file2.txt`, 'content2')\n\tawait fse.writeFile(`${testDirectory}/file3.txt`, 'content3')\n\n\t// Allow time for events to fire\n\tawait delay(100)\n\n\t// Get recent files\n\tconst recentFiles = await umbreld.client.files.recents.query()\n\n\t// Verify the order (most recent first)\n\texpect(recentFiles.map((file) => file.name)).toStrictEqual(['file3.txt', 'file2.txt', 'file1.txt'])\n})\n\ntest('recents() updates when files are modified', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/recents-update-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.writeFile(`${testDirectory}/original.txt`, 'original content')\n\n\t// Allow time for events to fire\n\tawait delay(100)\n\n\t// Get initial recent files\n\tlet recentFiles = await umbreld.client.files.recents.query()\n\texpect(recentFiles.map((file) => file.name)).toStrictEqual(['original.txt'])\n\n\t// Modify the file\n\tawait fse.writeFile(`${testDirectory}/original.txt`, 'modified content')\n\n\t// Allow time for events to fire\n\tawait delay(100)\n\n\t// Get updated recent files\n\trecentFiles = await umbreld.client.files.recents.query()\n\texpect(recentFiles.map((file) => file.name)).toStrictEqual(['original.txt'])\n})\n\ntest('recents() removes deleted files', async () => {\n\t// Create test directory and files\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/recents-delete-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.writeFile(`${testDirectory}/to-delete.txt`, 'temporary content')\n\tawait fse.writeFile(`${testDirectory}/keep.txt`, 'keeping this')\n\n\t// Wait for watcher to process the creation\n\tawait delay(100)\n\n\t// Verify files are in recent list\n\tlet recentFiles = await umbreld.client.files.recents.query()\n\texpect(recentFiles.map((file) => file.name)).toStrictEqual(['to-delete.txt', 'keep.txt'])\n\n\t// Delete one file\n\tawait fse.remove(`${testDirectory}/to-delete.txt`)\n\n\t// Wait for watcher to process the deletion\n\tawait delay(100)\n\n\t// Verify deleted file is removed from recents\n\trecentFiles = await umbreld.client.files.recents.query()\n\texpect(recentFiles.map((file) => file.name)).toStrictEqual(['keep.txt'])\n})\n\ntest('recents() ignores hidden files', async () => {\n\t// Create test directory and files\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/recents-hidden-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.writeFile(`${testDirectory}/.DS_Store`, 'hidden content')\n\tawait fse.writeFile(`${testDirectory}/visible.txt`, 'visible content')\n\n\t// Allow time for events to fire\n\tawait delay(100)\n\n\t// Get recent files\n\tconst recentFiles = await umbreld.client.files.recents.query()\n\n\t// Verify only visible file is included\n\texpect(recentFiles.map((file) => file.name)).toStrictEqual(['visible.txt'])\n})\n\ntest('recents() ignores files after they are sent to trash', async () => {\n\t// Create test directory and files\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/recents-trash-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.writeFile(`${testDirectory}/to-trash.txt`, 'trash content')\n\tawait fse.writeFile(`${testDirectory}/keep.txt`, 'keep content')\n\n\t// Allow time for events to fire\n\tawait delay(100)\n\n\t// Verify files are in recent list\n\tlet recentFiles = await umbreld.client.files.recents.query()\n\texpect(recentFiles.map((file) => file.name)).toStrictEqual(['to-trash.txt', 'keep.txt'])\n\n\t// Move file to trash using the RPC client\n\tconst virtualPath = `/Home/recents-trash-test/to-trash.txt`\n\tawait umbreld.client.files.trash.mutate({path: virtualPath})\n\n\t// Allow time for events to fire\n\tawait delay(100)\n\n\t// Verify trashed file is removed from recents\n\trecentFiles = await umbreld.client.files.recents.query()\n\texpect(recentFiles.map((file) => file.name)).toStrictEqual(['keep.txt'])\n})\n\ntest('recents() persists after umbreld restart', async () => {\n\t// Create test directory and file\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/recents-persist-test`\n\tawait fse.mkdir(testDirectory)\n\tawait fse.writeFile(`${testDirectory}/persist.txt`, 'persist content')\n\n\t// Allow time for events to fire\n\tawait delay(100)\n\n\t// Verify file is in recents\n\tlet recentFiles = await umbreld.client.files.recents.query()\n\texpect(recentFiles.map((file) => file.name)).toStrictEqual(['persist.txt'])\n\n\t// Check changes are not yet persisted\n\tlet recentFileInStore = await umbreld.instance.store.get('files.recents')\n\texpect(recentFileInStore).toHaveLength(0)\n\n\t// Stop umbreld\n\tawait umbreld.instance.stop()\n\n\t// Check changes were persisted\n\trecentFileInStore = await umbreld.instance.store.get('files.recents')\n\texpect(recentFileInStore).toHaveLength(1)\n\texpect(recentFileInStore[0].endsWith('persist.txt')).toBe(true)\n})\n\ntest('recents() ignores app data files', async () => {\n\t// Create test files in both home and app data directories\n\tconst homeDirectory = `${umbreld.instance.dataDirectory}/home/recents-app-test`\n\tconst appDirectory = `${umbreld.instance.dataDirectory}/app-data/recents-app-test`\n\tawait fse.mkdir(homeDirectory)\n\tawait fse.mkdir(appDirectory)\n\n\t// Create files in both directories\n\tawait fse.writeFile(`${homeDirectory}/home-file.txt`, 'home content')\n\tawait fse.writeFile(`${appDirectory}/app-file.txt`, 'app content')\n\n\t// Allow time for events to fire\n\tawait delay(100)\n\n\t// Get recent files\n\tconst recentFiles = await umbreld.client.files.recents.query()\n\n\t// Verify only home file is included, app file is ignored\n\texpect(recentFiles.map((file) => file.name)).toStrictEqual(['home-file.txt'])\n})\n\ntest('recents() respects maximum number of entries', async () => {\n\t// Create test directory\n\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/recents-max-test`\n\tawait fse.mkdir(testDirectory)\n\n\t// Create one more file than the maximum limit\n\tconst maxRecents = 50\n\tfor (let i = 1; i <= maxRecents + 1; i++) {\n\t\tawait fse.writeFile(`${testDirectory}/file${i}.txt`, '')\n\t\t// Allow time for events to fire\n\t\tawait delay(100)\n\t}\n\n\t// Get recent files\n\tconst recentFiles = await umbreld.client.files.recents.query()\n\n\t// Verify we only get the maximum number of entries\n\texpect(recentFiles.length).toBe(maxRecents)\n\n\t// Verify the most recent files are included (highest numbers)\n\tconst fileNames = recentFiles.map((file) => file.name)\n\t// ['file 51.txt', 'file50.txt', ..., 'file2.txt']\n\tconst expectedFileNames = Array.from({length: maxRecents}, (_, i) => `file${maxRecents + 1 - i}.txt`)\n\texpect(fileNames).toStrictEqual(expectedFileNames)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/recents.ts",
    "content": "import nodePath from 'node:path'\n\nimport PQueue from 'p-queue'\nimport fse from 'fs-extra'\nimport {debounce} from 'es-toolkit'\n\nimport type Umbreld from '../../index.js'\n\nimport type {FileChangeEvent} from './watcher.js'\n\nexport default class Recents {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\t#removeFileChangeListener?: () => void\n\t// Debounce the write to disk to prevent excessive writes when many events are triggered\n\t#debouncedWrite = debounce(this.#directWrite.bind(this), 1000)\n\trecentFiles: string[] = []\n\tmaxRecents = 50\n\tpaths: string[]\n\tqueue = new PQueue({concurrency: 1})\n\n\tconstructor(umbreld: Umbreld, {paths}: {paths: string[]}) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(`files:${name.toLocaleLowerCase()}`)\n\t\tthis.paths = paths\n\t}\n\n\t// Add listener\n\tasync start() {\n\t\tthis.logger.log('Starting recents')\n\n\t\t// Read recent files from disk and set initial value if undefined\n\t\t// TODO: This should really be stored in a proper database.\n\t\t// Migrate this to SQLite once we have it. Or ideally query this\n\t\t// directly from a live filesystem index.\n\t\tthis.recentFiles = await this.#umbreld.store.get('files.recents')\n\t\tif (this.recentFiles === undefined) {\n\t\t\tthis.logger.log('Creating initial recents entry in store')\n\t\t\tthis.recentFiles = []\n\t\t\tawait this.#umbreld.store.set('files.recents', this.recentFiles)\n\t\t}\n\n\t\t// Attach listener\n\t\tthis.#removeFileChangeListener = this.#umbreld.eventBus.on(\n\t\t\t'files:watcher:change',\n\t\t\tthis.#handleFileChange.bind(this),\n\t\t)\n\t}\n\n\t// Get recents\n\tasync get() {\n\t\tconst recents = await Promise.all(\n\t\t\tthis.recentFiles.map(async (virtualPath) => {\n\t\t\t\tconst systemPath = await this.#umbreld.files.virtualToSystemPath(virtualPath)\n\t\t\t\treturn this.#umbreld.files.status(systemPath).catch(() => undefined)\n\t\t\t}),\n\t\t)\n\n\t\t// Filter out any files that don't exist\n\t\tconst filteredRecents = recents.filter((file) => file !== undefined)\n\n\t\treturn filteredRecents\n\t}\n\n\t// Write recents\n\tasync #directWrite() {\n\t\tawait this.#umbreld.store.set('files.recents', this.recentFiles)\n\t}\n\n\t// Handle file change\n\tasync #handleFileChange(event: FileChangeEvent) {\n\t\t// Pipe through a queue to ensure we handle events in order\n\t\treturn this.queue\n\t\t\t.add(async () => {\n\t\t\t\t// Calculate paths\n\t\t\t\tconst systemPath = event.path\n\t\t\t\tconst path = this.#umbreld.files.systemToVirtualPath(systemPath)\n\n\t\t\t\t// Ignore files outside of the watched paths\n\t\t\t\tconst isWatched = this.paths.some((watchedPath) => path.startsWith(`${watchedPath}/`))\n\t\t\t\tif (!isWatched) return\n\n\t\t\t\t// Ignore hidden files\n\t\t\t\tif (this.#umbreld.files.isHidden(nodePath.basename(path))) return\n\n\t\t\t\t// Ignore files in the backups directory\n\t\t\t\tif (path.includes(`/${this.#umbreld.backups.backupDirectoryName}/`)) return\n\n\t\t\t\t// Remove the path from the list if it exists\n\t\t\t\t// This is to prevent duplicates when adding or to remove with a deletion\n\t\t\t\tthis.recentFiles = this.recentFiles.filter((item) => item !== path)\n\n\t\t\t\t// Add the path back to the beginning of the list if it's an update or create\n\t\t\t\tif (['update', 'create'].includes(event.type)) {\n\t\t\t\t\t// Check file is not a directory or non standard file type\n\t\t\t\t\tconst stats = await fse.stat(systemPath)\n\t\t\t\t\tif (!stats.isFile()) return\n\n\t\t\t\t\tthis.recentFiles.unshift(path)\n\t\t\t\t}\n\n\t\t\t\t// Keep the list at maxRecents length\n\t\t\t\tthis.recentFiles = this.recentFiles.slice(0, this.maxRecents)\n\n\t\t\t\t// Write the recent files to disk\n\t\t\t\tthis.#debouncedWrite()\n\t\t\t})\n\t\t\t.catch((error) => this.logger.error(`Failed to handle file change`, error))\n\t}\n\n\t// Remove listener\n\tasync stop() {\n\t\tthis.logger.log('Stopping recents')\n\t\tthis.#removeFileChangeListener?.()\n\t\tthis.#debouncedWrite.cancel()\n\t\tawait this.#directWrite()\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/routes.ts",
    "content": "import z from 'zod'\n\nimport {router, privateProcedure, publicProcedureWhenNoUserExists} from '../server/trpc/trpc.js'\n\nexport default router({\n\t// List a directory\n\tlist: publicProcedureWhenNoUserExists\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tpath: z.string(),\n\t\t\t\tsortBy: z.enum(['name', 'type', 'modified', 'size']).default('name'),\n\t\t\t\tsortOrder: z.enum(['ascending', 'descending']).default('ascending'),\n\t\t\t\tlastFile: z.string().optional(),\n\t\t\t\tlimit: z.number().positive().default(100),\n\t\t\t}),\n\t\t)\n\t\t.query(async ({ctx, input}) => {\n\t\t\tconst directoryListing = await ctx.umbreld.files.list(input.path)\n\t\t\tconst totalFiles = directoryListing.files.length\n\n\t\t\t// Sort the files\n\t\t\t// Ensure numeric sort falls back to text sort if the numeric values are equal.\n\t\t\t// This is to ensure deterministic ordering in the case where multiple files have\n\t\t\t// the same size/date. If ordering becomes non-deterministic then pagination can break.\n\t\t\t// We enable numeric sorting by name, e.g. 1.txt, 2.txt, 10.txt\n\t\t\tconst textSort = new Intl.Collator('en-US', {numeric: true})\n\t\t\tdirectoryListing.files.sort((fileA, fileB) => {\n\t\t\t\tconst a = fileA[input.sortBy]\n\t\t\t\tconst b = fileB[input.sortBy]\n\t\t\t\tif (typeof a === 'string' && typeof b === 'string') return textSort.compare(a, b)\n\t\t\t\tif (typeof a === 'number' && typeof b === 'number') return a - b || textSort.compare(fileA.name, fileB.name)\n\t\t\t\treturn 0\n\t\t\t})\n\n\t\t\t// Handle sort order\n\t\t\tif (input.sortOrder === 'descending') directoryListing.files.reverse()\n\n\t\t\t// Paginate using cursor-style pagination with `lastFile` as the cursor.\n\t\t\t// Unlike offset-based pagination, this ensures consistent results even if files are added, removed, or renamed, etc.\n\t\t\t// as it starts after the last seen file rather than relying on fixed indices.\n\t\t\tlet startIndex = 0\n\t\t\tif (input.lastFile) {\n\t\t\t\tconst lastFileIndex = directoryListing.files.findIndex((file) => file.name === input.lastFile)\n\t\t\t\t// If lastFile found, start after it; otherwise start from beginning\n\t\t\t\tstartIndex = lastFileIndex !== -1 ? lastFileIndex + 1 : 0\n\t\t\t}\n\n\t\t\t// Get the paginated files\n\t\t\tconst paginatedFiles = directoryListing.files.slice(startIndex, startIndex + input.limit)\n\n\t\t\t// Determine if there are more files after this batch\n\t\t\tconst hasMore = startIndex + input.limit < totalFiles\n\n\t\t\treturn {\n\t\t\t\t...directoryListing,\n\t\t\t\t// overwrite the files with the paginated files\n\t\t\t\tfiles: paginatedFiles,\n\t\t\t\ttotalFiles,\n\t\t\t\thasMore,\n\t\t\t}\n\t\t}),\n\n\t// Create a directory\n\tcreateDirectory: privateProcedure\n\t\t.input(z.object({path: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.createDirectory(input.path)),\n\n\t// Copy a file or directory\n\tcopy: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tpath: z.string(),\n\t\t\t\ttoDirectory: z.string(),\n\t\t\t\tcollision: z.enum(['error', 'keep-both', 'replace']).default('error'),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) =>\n\t\t\tctx.umbreld.files.copy(input.path, input.toDirectory, {collision: input.collision}),\n\t\t),\n\n\t// Move a file or directory\n\tmove: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tpath: z.string(),\n\t\t\t\ttoDirectory: z.string(),\n\t\t\t\tcollision: z.enum(['error', 'keep-both', 'replace']).default('error'),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) =>\n\t\t\tctx.umbreld.files.move(input.path, input.toDirectory, {collision: input.collision}),\n\t\t),\n\n\t// Get progress of file operations\n\toperationProgress: privateProcedure.query(async ({ctx}) => ctx.umbreld.files.operationsInProgress),\n\n\t// Rename a file or directory\n\trename: privateProcedure\n\t\t.input(z.object({path: z.string(), newName: z.string().nonempty()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.rename(input.path, input.newName)),\n\n\t// Trash a file or directory\n\ttrash: privateProcedure\n\t\t.input(z.object({path: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.trash(input.path)),\n\n\t// Restore a file or directory from the trash\n\trestore: privateProcedure\n\t\t.input(z.object({path: z.string(), collision: z.enum(['error', 'keep-both', 'replace']).default('error')}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.restore(input.path, {collision: input.collision})),\n\n\t// Empty the trash\n\temptyTrash: privateProcedure.mutation(async ({ctx}) => ctx.umbreld.files.emptyTrash()),\n\n\t// Permanently delete a file or directory\n\tdelete: privateProcedure\n\t\t.input(z.object({path: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.delete(input.path)),\n\n\t// Get favorites\n\tfavorites: privateProcedure.query(async ({ctx}) => ctx.umbreld.files.favorites.listFavorites()),\n\n\t// Add a favorite\n\taddFavorite: privateProcedure\n\t\t.input(z.object({path: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.favorites.addFavorite(input.path)),\n\n\t// Remove a favorite\n\tremoveFavorite: privateProcedure\n\t\t.input(z.object({path: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.favorites.removeFavorite(input.path)),\n\n\t// Get recent files\n\trecents: privateProcedure.query(async ({ctx}) => ctx.umbreld.files.recents.get()),\n\n\t// Get view preferences\n\t// Public only when no user exists for onboarding restore flow (returns defaults); private once a user exists\n\tviewPreferences: publicProcedureWhenNoUserExists.query(async ({ctx}) => ctx.umbreld.files.getViewPreferences()),\n\n\t// Update view preferences\n\tupdateViewPreferences: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tview: z.enum(['icons', 'list']).optional(),\n\t\t\t\tsortBy: z.enum(['name', 'type', 'modified', 'size']).optional(),\n\t\t\t\tsortOrder: z.enum(['ascending', 'descending']).optional(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.updateViewPreferences(input)),\n\n\t// Create a zip archive\n\tarchive: privateProcedure\n\t\t.input(z.object({paths: z.array(z.string()).min(1)}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.archive.archive(input.paths)),\n\n\t// Unarchive a file\n\tunarchive: privateProcedure\n\t\t.input(z.object({path: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.archive.unarchive(input.path)),\n\n\t// Get/generate a thumbnail for a file on demand\n\tgetThumbnail: privateProcedure\n\t\t.input(z.object({path: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.thumbnails.getThumbnailOnDemand(input.path)),\n\n\t// Get the share password\n\tsharePassword: privateProcedure.query(async ({ctx}) => ctx.umbreld.files.samba.getSharePassword()),\n\n\t// Get shares\n\tshares: privateProcedure.query(async ({ctx}) => ctx.umbreld.files.samba.listShares()),\n\n\t// Share a directory\n\taddShare: privateProcedure\n\t\t.input(z.object({path: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.samba.addShare(input.path)),\n\n\t// Remove a share\n\tremoveShare: privateProcedure\n\t\t.input(z.object({path: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.samba.removeShare(input.path)),\n\n\t// Format an external device\n\tformatExternalDevice: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tdeviceId: z.string(),\n\t\t\t\tfilesystem: z.enum(['ext4', 'exfat']),\n\t\t\t\tlabel: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.externalStorage.formatExternalDevice(input)),\n\n\t// Get external storage devices\n\texternalDevices: publicProcedureWhenNoUserExists.query(async ({ctx}) =>\n\t\tctx.umbreld.files.externalStorage.getExternalDevicesWithVirtualMountPoints(),\n\t),\n\n\t// Unmount an external device\n\tunmountExternalDevice: privateProcedure\n\t\t.input(z.object({deviceId: z.string()}))\n\t\t.mutation(async ({ctx, input}) =>\n\t\t\tctx.umbreld.files.externalStorage.unmountExternalDevice(input.deviceId, {remove: true}),\n\t\t),\n\n\t// Check if an external drive is connected on non-Umbrel Home hardware\n\tisExternalDeviceConnectedOnUnsupportedDevice: privateProcedure.query(({ctx}) =>\n\t\tctx.umbreld.files.externalStorage.isExternalDeviceConnectedOnUnsupportedDevice(),\n\t),\n\n\t// Search for a file\n\tsearch: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tquery: z.string(),\n\t\t\t\tmaxResults: z.number().positive().max(1000).default(250).optional(),\n\t\t\t}),\n\t\t)\n\t\t.query(async ({ctx, input}) => ctx.umbreld.files.search.search(input.query, input.maxResults)),\n\n\t// List network shares\n\tlistNetworkShares: publicProcedureWhenNoUserExists.query(async ({ctx}) =>\n\t\tctx.umbreld.files.networkStorage.getShareInfo(),\n\t),\n\n\t// Add a network share\n\taddNetworkShare: publicProcedureWhenNoUserExists\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\thost: z.string(),\n\t\t\t\tshare: z.string(),\n\t\t\t\tusername: z.string(),\n\t\t\t\tpassword: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.networkStorage.addShare(input)),\n\n\t// Remove a network share\n\tremoveNetworkShare: privateProcedure\n\t\t.input(z.object({mountPath: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.files.networkStorage.removeShare(input.mountPath)),\n\n\t// Discover available network share servers\n\tdiscoverNetworkShareServers: publicProcedureWhenNoUserExists.query(async ({ctx}) =>\n\t\tctx.umbreld.files.networkStorage.discoverServers(),\n\t),\n\n\t// Discover shares for a given samba server\n\tdiscoverNetworkSharesOnServer: publicProcedureWhenNoUserExists\n\t\t.input(z.object({host: z.string(), username: z.string(), password: z.string()}))\n\t\t.query(async ({ctx, input}) =>\n\t\t\tctx.umbreld.files.networkStorage.discoverSharesOnServer(input.host, input.username, input.password),\n\t\t),\n\n\t// Checks if the given network address is an Umbrel device\n\tisServerAnUmbrelDevice: privateProcedure\n\t\t.input(z.object({address: z.string()}))\n\t\t.query(async ({ctx, input}) => ctx.umbreld.files.networkStorage.isServerAnUmbrelDevice(input.address)),\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/samba.integration.test.ts",
    "content": "import {expect, beforeEach, afterEach, describe, test} from 'vitest'\n\nimport fse from 'fs-extra'\nimport {delay} from 'es-toolkit'\nimport {default as SMB2} from '@tryjsky/v9u-smb2'\nimport tcpPortUsed from 'tcp-port-used'\nimport {$} from 'execa'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\n// Create a new umbreld instance for each test\nbeforeEach(async () => (umbreld = await createTestUmbreld({autoLogin: true})))\nafterEach(async () => await umbreld.cleanup())\n\ndescribe('shares()', () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.shares.query()).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('returns empty array on first start', async () => {\n\t\tconst shares = await umbreld.client.files.shares.query()\n\t\texpect(shares).toStrictEqual([])\n\t})\n\n\ttest('only returns existing directories', async () => {\n\t\t// Create test directories\n\t\tconst testDirectory1 = `${umbreld.instance.dataDirectory}/home/samba-existing-test1`\n\t\tconst testDirectory2 = `${umbreld.instance.dataDirectory}/home/samba-existing-test2`\n\t\tawait fse.mkdir(testDirectory1)\n\t\tawait fse.mkdir(testDirectory2)\n\n\t\t// Add both directories to shares\n\t\tawait umbreld.client.files.addShare.mutate({\n\t\t\tpath: '/Home/samba-existing-test1',\n\t\t})\n\t\tawait umbreld.client.files.addShare.mutate({\n\t\t\tpath: '/Home/samba-existing-test2',\n\t\t})\n\n\t\t// Delete one directory\n\t\tawait fse.remove(testDirectory1)\n\n\t\t// Verify only existing directory is returned in shares\n\t\tconst shares = await umbreld.client.files.shares.query()\n\t\tconst paths = shares.map((share) => share.path)\n\t\texpect(paths).not.toContain('/Home/samba-existing-test1')\n\t\texpect(paths).toContain('/Home/samba-existing-test2')\n\t})\n\n\ttest('returns proper client-facing sharename for non /Home shares', async () => {\n\t\t// Create test directory\n\t\tconst dir = `${umbreld.instance.dataDirectory}/home/samba-clientname-test`\n\t\tawait fse.mkdir(dir)\n\n\t\t// Add to shares\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/samba-clientname-test'})\n\n\t\t// Verify sharename is returned\n\t\tconst shares = await umbreld.client.files.shares.query()\n\t\tconst entry = shares.find((s) => s.path === '/Home/samba-clientname-test') as any\n\t\texpect(entry?.sharename).toBe('samba-clientname-test (Umbrel)')\n\t})\n\n\ttest('returns sharename for Home share', async () => {\n\t\t// Add home directory to shares\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home'})\n\n\t\t// Verify sharename matches username's Umbrel\n\t\tconst shares = await umbreld.client.files.shares.query()\n\t\tconst entry = shares.find((s) => s.path === '/Home') as any\n\t\texpect(entry?.sharename).toBe(\"satoshi's Umbrel\")\n\t})\n})\n\ndescribe('#handleFileChange()', () => {\n\ttest('automatically removes shares when directory is deleted', async () => {\n\t\t// Create test directories\n\t\tconst testDirectoryToDelete = `${umbreld.instance.dataDirectory}/home/samba-auto-remove-test`\n\t\tconst testDirectoryToKeep = `${umbreld.instance.dataDirectory}/home/samba-keep-test`\n\t\tawait fse.mkdir(testDirectoryToDelete)\n\t\tawait fse.mkdir(testDirectoryToKeep)\n\n\t\t// Wait for the creation fs events to fire\n\t\tawait delay(100)\n\n\t\t// Add both directories to shares\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/samba-auto-remove-test'})\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/samba-keep-test'})\n\n\t\t// Verify directories are in shares\n\t\tlet shares = await umbreld.client.files.shares.query()\n\t\tconst paths = shares.map((share) => share.path)\n\t\texpect(paths).toContain('/Home/samba-auto-remove-test')\n\t\texpect(paths).toContain('/Home/samba-keep-test')\n\n\t\t// Delete one directory\n\t\tawait fse.remove(testDirectoryToDelete)\n\n\t\t// Wait for watcher to process the deletion\n\t\tawait delay(100)\n\n\t\t// Verify deleted directory is removed from the store\n\t\t// but the kept directory remains\n\t\t// We check the store directly here because the RPC query auto\n\t\t// strips non-existent files from the result\n\t\tconst storedShares = await umbreld.instance.store.get('files.shares')\n\t\tconst storedPaths = storedShares.map((share) => share.path)\n\t\texpect(storedPaths).not.toContain('/Home/samba-auto-remove-test')\n\t\texpect(storedPaths).toContain('/Home/samba-keep-test')\n\t})\n\n\ttest('automatically removes shares when directory is renamed', async () => {\n\t\t// Create test directory\n\t\tconst originalDirectory = `${umbreld.instance.dataDirectory}/home/original-directory`\n\t\tconst renamedDirectory = `${umbreld.instance.dataDirectory}/home/renamed-directory`\n\t\tawait fse.mkdir(originalDirectory)\n\n\t\t// Wait for the creation fs events to fire\n\t\tawait delay(100)\n\n\t\t// Add directory to shares\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/original-directory'})\n\n\t\t// Verify directory is in shares\n\t\tlet shares = await umbreld.client.files.shares.query()\n\t\tconst paths = shares.map((share) => share.path)\n\t\texpect(paths).toContain('/Home/original-directory')\n\n\t\t// Rename the directory (this causes a delete event for the original path)\n\t\tawait fse.rename(originalDirectory, renamedDirectory)\n\n\t\t// Wait for watcher to process the events\n\t\tawait delay(100)\n\n\t\t// Verify original path is removed from the store\n\t\tconst storedShares = await umbreld.instance.store.get('files.shares')\n\t\tconst storedPaths = storedShares ? storedShares.map((share) => share.path) : []\n\t\texpect(storedPaths).not.toContain('/Home/original-directory')\n\t})\n\n\ttest('automatically removes child shares when parent directory is deleted', async () => {\n\t\t// Create test directories\n\t\tconst parentDirectory = `${umbreld.instance.dataDirectory}/home/parent-directory`\n\t\tconst childDirectory = `${parentDirectory}/child-directory`\n\t\tawait fse.mkdir(parentDirectory)\n\t\tawait fse.mkdir(childDirectory)\n\n\t\t// Wait for the creation fs events to fire\n\t\tawait delay(100)\n\n\t\t// Add child directory to shares\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/parent-directory/child-directory'})\n\n\t\t// Verify directories are in shares\n\t\tlet shares = await umbreld.client.files.shares.query()\n\t\tconst paths = shares.map((share) => share.path)\n\t\texpect(paths).toContain('/Home/parent-directory/child-directory')\n\n\t\t// Delete parent directory (which also removes the child)\n\t\tawait fse.remove(parentDirectory)\n\n\t\t// Wait for watcher to process the deletion\n\t\tawait delay(100)\n\n\t\t// Verify deleted directory is removed from the store\n\t\t// We check the store directly here because the RPC query auto\n\t\t// strips non-existent files from the result\n\t\tconst storedShares = await umbreld.instance.store.get('files.shares')\n\t\tconst storedPaths = storedShares ? storedShares.map((share) => share.path) : []\n\t\texpect(storedPaths).not.toContain('/Home/parent-directory/child-directory')\n\t})\n})\n\ndescribe('addShare()', () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.addShare.mutate({path: '/Home/Documents'})).rejects.toThrow(\n\t\t\t'Invalid token',\n\t\t)\n\t})\n\n\ttest('throws on non-directory paths', async () => {\n\t\t// Create test file\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/samba-test`\n\t\tawait fse.mkdir(testDirectory)\n\t\tawait fse.writeFile(`${testDirectory}/file.txt`, 'test content')\n\n\t\t// Attempt to share a file\n\t\tawait expect(umbreld.client.files.addShare.mutate({path: '/Home/samba-test/file.txt'})).rejects.toThrow(\n\t\t\t'[operation-not-allowed]',\n\t\t)\n\t})\n\n\ttest('throws on unshareable app paths', async () => {\n\t\t// Create test directory\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/app-data/bitcoin`\n\t\tawait fse.mkdir(testDirectory)\n\n\t\t// Attempt to share the directory\n\t\tawait expect(umbreld.client.files.addShare.mutate({path: '/Apps/bitcoin'})).rejects.toThrow(\n\t\t\t'[operation-not-allowed]',\n\t\t)\n\t})\n\n\ttest('throws on unshareable external paths', async () => {\n\t\t// Create test directory\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/external/My Portable SSD`\n\t\tawait fse.ensureDir(testDirectory)\n\n\t\t// Create subdirectory\n\t\tconst subDirectory = `${testDirectory}/sub-directory`\n\t\tawait fse.ensureDir(subDirectory)\n\n\t\t// Attempt to share the directory\n\t\tawait expect(umbreld.client.files.addShare.mutate({path: '/External/My Portable SSD'})).rejects.toThrow(\n\t\t\t'[operation-not-allowed]',\n\t\t)\n\n\t\t// Attempt to share sub directory\n\t\tawait expect(\n\t\t\tumbreld.client.files.addShare.mutate({path: '/External/My Portable SSD/sub-directory'}),\n\t\t).rejects.toThrow('[operation-not-allowed]')\n\t})\n\n\ttest('throws on directory traversal attempt', async () => {\n\t\tawait expect(umbreld.client.files.addShare.mutate({path: '/Home/../../../../etc/share-dir'})).rejects.toThrow(\n\t\t\t'[operation-not-allowed]',\n\t\t)\n\t})\n\n\ttest('throws on symlink traversal attempt', async () => {\n\t\t// Create a symlink to the root directory\n\t\tawait fse.ensureDir(`${umbreld.instance.dataDirectory}/home`)\n\t\tawait fse.symlink('/', `${umbreld.instance.dataDirectory}/home/symlink-to-root`)\n\n\t\t// Attempt to share directory through symlink\n\t\tawait expect(umbreld.client.files.addShare.mutate({path: '/Home/symlink-to-root/etc'})).rejects.toThrow(\n\t\t\t'[operation-not-allowed]',\n\t\t)\n\t})\n\n\ttest('throws on relative paths', async () => {\n\t\tawait Promise.all(\n\t\t\t['', ' ', '.', '..', 'Home', 'Home/shared-dir', 'Home/../shared-dir'].map((path) =>\n\t\t\t\texpect(umbreld.client.files.addShare.mutate({path})).rejects.toThrow('[operation-not-allowed]'),\n\t\t\t),\n\t\t)\n\t})\n\n\ttest('throws on invalid base directory', async () => {\n\t\tawait expect(umbreld.client.files.addShare.mutate({path: '/Invalid/test-share'})).rejects.toThrow(\n\t\t\t'[operation-not-allowed]',\n\t\t)\n\t})\n\n\ttest('successfully adds a directory to shares', async () => {\n\t\t// Create test directory\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/samba-test`\n\t\tawait fse.mkdir(testDirectory)\n\n\t\t// Add directory to shares\n\t\tconst result = await umbreld.client.files.addShare.mutate({path: '/Home/samba-test'})\n\n\t\texpect(result).toBe('/Home/samba-test')\n\n\t\t// Verify directory is in shares\n\t\tconst shares = await umbreld.client.files.shares.query()\n\t\tconst paths = shares.map((share) => share.path)\n\t\texpect(paths).toContain('/Home/samba-test')\n\t})\n\n\ttest('successfully adds home directory to shares', async () => {\n\t\t// Add home directory to shares\n\t\tconst result = await umbreld.client.files.addShare.mutate({path: '/Home'})\n\t\texpect(result).toBe('/Home')\n\n\t\t// Verify directory is in shares\n\t\tconst shares = await umbreld.client.files.shares.query()\n\t\tconst paths = shares.map((share) => share.path)\n\t\texpect(paths).toContain('/Home')\n\t})\n\n\ttest('throws error on duplicate shares', async () => {\n\t\t// Create test directory\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/samba-duplicate-test`\n\t\tawait fse.mkdir(testDirectory)\n\n\t\t// Add directory to shares\n\t\tawait umbreld.client.files.addShare.mutate({\n\t\t\tpath: '/Home/samba-duplicate-test',\n\t\t})\n\n\t\t// Try to add again and expect failure\n\t\tawait expect(umbreld.client.files.addShare.mutate({path: '/Home/samba-duplicate-test'})).rejects.toThrow(\n\t\t\t'[share-already-exists]',\n\t\t)\n\t})\n\n\ttest('auto-generates unique share names when basename conflicts', async () => {\n\t\t// Create test directories with same basename but different paths\n\t\tconst testDirectory1 = `${umbreld.instance.dataDirectory}/home/folder1/same-name`\n\t\tconst testDirectory2 = `${umbreld.instance.dataDirectory}/home/folder2/same-name`\n\t\tawait fse.mkdir(testDirectory1, {recursive: true})\n\t\tawait fse.mkdir(testDirectory2, {recursive: true})\n\n\t\t// Add both directories to shares\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/folder1/same-name'})\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/folder2/same-name'})\n\n\t\t// Verify both directories are added with unique names\n\t\tconst shares = await umbreld.client.files.shares.query()\n\t\tconst names = shares.map((share) => share.name)\n\n\t\t// Verify we have two distinct share names\n\t\texpect(names).toMatchObject(['same-name', 'same-name (2)'])\n\t})\n})\n\ndescribe('removeShare()', () => {\n\ttest('successfully removes a directory from shares', async () => {\n\t\t// Create test directory\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/samba-remove-test`\n\t\tawait fse.mkdir(testDirectory)\n\n\t\t// Add directory to shares\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/samba-remove-test'})\n\n\t\t// Remove from shares\n\t\tconst result = await umbreld.client.files.removeShare.mutate({path: '/Home/samba-remove-test'})\n\n\t\texpect(result).toBe(true)\n\n\t\t// Verify directory is not in shares\n\t\tconst shares = await umbreld.client.files.shares.query()\n\t\tconst paths = shares.map((share) => share.path)\n\t\texpect(paths).not.toContain('/Home/samba-remove-test')\n\t})\n\n\ttest('returns false when removing non-existent share', async () => {\n\t\tconst result = await umbreld.client.files.removeShare.mutate({path: '/Home/non-existent-share'})\n\n\t\texpect(result).toBe(false)\n\t})\n})\n\ndescribe('sharePassword()', () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.sharePassword.query()).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('generates a 128-bit hex string on first run', async () => {\n\t\tconst sharePassword = await umbreld.client.files.sharePassword.query()\n\n\t\t// Check it's a 128-bit hex string (32 hex characters = 128 bits)\n\t\texpect(sharePassword.length).toBe(32)\n\t\texpect(/^[0-9a-f]{32}$/.test(sharePassword)).toBe(true)\n\t})\n\n\ttest('always returns the same password', async () => {\n\t\tconst sharePassword1 = await umbreld.client.files.sharePassword.query()\n\t\tconst sharePassword2 = await umbreld.client.files.sharePassword.query()\n\n\t\t// Verify it's consistently the same password\n\t\texpect(sharePassword1).toBe(sharePassword2)\n\t})\n})\n\ndescribe('samba', () => {\n\tasync function createSmbClient(share: string) {\n\t\tconst password = await umbreld.client.files.sharePassword.query()\n\t\treturn new (SMB2 as any)({\n\t\t\tshare: `\\\\\\\\localhost\\\\${share}`,\n\t\t\tusername: 'umbrel',\n\t\t\tpassword,\n\t\t})\n\t}\n\n\ttest('port is only listening where shares are active', async () => {\n\t\tconst smbPort = 445\n\n\t\t// Check if port is open\n\t\tawait expect(tcpPortUsed.check(smbPort, 'localhost')).resolves.toBe(false)\n\n\t\t// Add home directory to shares\n\t\tawait expect(umbreld.client.files.addShare.mutate({path: '/Home'})).resolves.toBe('/Home')\n\n\t\t// Check if port is open\n\t\tawait expect(tcpPortUsed.check(smbPort, 'localhost')).resolves.toBe(true)\n\n\t\t// Remove share\n\t\tawait expect(umbreld.client.files.removeShare.mutate({path: '/Home'})).resolves.toBe(true)\n\n\t\t// Check if port is closed again\n\t\tawait expect(tcpPortUsed.check(smbPort, 'localhost')).resolves.toBe(false)\n\t})\n\n\ttest('share name has (Umbrel appended)', async () => {\n\t\t// Add home directory to shares\n\t\tawait expect(umbreld.client.files.addShare.mutate({path: '/Home/Documents'})).resolves.toBe('/Home/Documents')\n\n\t\t// create an SMB2 instance\n\t\tconst smb = await createSmbClient('Documents (Umbrel)')\n\n\t\t// Test connection\n\t\tawait expect(smb.exists('non-existent-file.txt')).resolves.toBe(false)\n\t})\n\n\ttest('/Home share is called \"username\\'s Umbrel\"', async () => {\n\t\t// Add home directory to shares\n\t\tawait expect(umbreld.client.files.addShare.mutate({path: '/Home'})).resolves.toBe('/Home')\n\n\t\t// create an SMB2 instance\n\t\tconst smb = await createSmbClient(\"satoshi's Umbrel\")\n\n\t\t// Test connection\n\t\tawait expect(smb.exists('non-existent-file.txt')).resolves.toBe(false)\n\t})\n\n\t// This test can be a little flaky (seemingly due to pure js samba client) so retry on failure\n\ttest('client can interact with share', {retry: 5}, async () => {\n\t\t// Add home directory to shares\n\t\tawait expect(umbreld.client.files.addShare.mutate({path: '/Home'})).resolves.toBe('/Home')\n\n\t\t// create an SMB2 instance\n\t\tconst smb = await createSmbClient(\"satoshi's Umbrel\")\n\n\t\t// Test connection\n\t\tawait expect(smb.exists('non-existent-file.txt')).resolves.toBe(false)\n\n\t\t// Test write\n\t\tawait expect(smb.writeFile('file.txt', 'hello world', {encoding: 'utf8'})).resolves.toBe(undefined)\n\n\t\t// Test file exists on filesystem\n\t\tawait expect(fse.exists(`${umbreld.instance.dataDirectory}/home/file.txt`)).resolves.toBe(true)\n\n\t\t// Test read\n\t\tawait expect(smb.readFile('file.txt', {encoding: 'utf8'})).resolves.toBe('hello world')\n\n\t\t// Remove the share\n\t\tawait expect(umbreld.client.files.removeShare.mutate({path: '/Home'})).resolves.toBe(true)\n\n\t\t// Test read no longer works\n\t\t// For some reason the first read after close hangs and the second throws so we do a dummy read\n\t\t// first that hangs\n\t\tsmb.readFile('file.txt', {encoding: 'utf8'})\n\t\t// And then a second read that throws\n\t\tawait expect(smb.readFile('file.txt', {encoding: 'utf8'})).rejects.toThrow('write EPIPE')\n\t})\n\n\ttest('reloads config when shares are updated', async () => {\n\t\tconst smbHome = await createSmbClient(\"satoshi's Umbrel\")\n\t\tlet smbDocuments = await createSmbClient('Documents (Umbrel)')\n\n\t\t// Add home directory to shares and test it works\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home'})\n\t\tawait expect(smbHome.exists('non-existent-file.txt')).resolves.toBe(false)\n\t\tawait expect(smbDocuments.exists('non-existent-file.txt')).rejects.toThrow('STATUS_BAD_NETWORK_NAME')\n\n\t\t// Add documents share and test it works\n\t\t// We need to recreate the client because for some reason it can't be used after the above error\n\t\tsmbDocuments = await createSmbClient('Documents (Umbrel)')\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/Documents'})\n\t\tawait expect(smbHome.exists('non-existent-file.txt')).resolves.toBe(false)\n\t\tawait expect(smbDocuments.exists('non-existent-file.txt')).resolves.toBe(false)\n\t})\n\n\ttest(\"doesn't allow escaping shared directories via path traversal\", async () => {\n\t\t// Create test directory file\n\t\tconst testFile = `${umbreld.instance.dataDirectory}/home/path-traversal-test/test/file.txt`\n\t\tawait fse.ensureFile(testFile)\n\n\t\t// Create a sensitive file outside the share\n\t\tconst sensitiveFile = `${umbreld.instance.dataDirectory}/secrets/sensitive.txt`\n\t\tawait fse.ensureFile(sensitiveFile)\n\n\t\t// Add test directory to shares\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/path-traversal-test'})\n\n\t\t// Connect to share\n\t\tconst smb = await createSmbClient('path-traversal-test (Umbrel)')\n\n\t\t// Test ..\\\\ syntax works\n\t\tawait expect(smb.readFile('test\\\\..\\\\test\\\\file.txt', {encoding: 'utf8'})).resolves.toBe('')\n\n\t\t// Test traversal outside of share fails\n\t\tawait expect(smb.readFile('..\\\\..\\\\secrets\\\\sensitive.txt', {encoding: 'utf8'})).rejects.toThrow(\n\t\t\t'STATUS_OBJECT_PATH_SYNTAX_BAD',\n\t\t)\n\t})\n\n\ttest(\"doesn't allow escaping shared directories via symlinks\", async () => {\n\t\t// Create a sensitive file outside of files root\n\t\tconst sensitiveFile = `${umbreld.instance.dataDirectory}/secrets/sensitive.txt`\n\t\tawait fse.ensureFile(sensitiveFile)\n\t\tawait fse.writeFile(sensitiveFile, 'sensitive data')\n\n\t\t// Create test directory with symlink to sensitive file and a normal file\n\t\tconst testDirectory = `${umbreld.instance.dataDirectory}/home/symlink-traversal-test`\n\t\tawait fse.ensureDir(testDirectory)\n\t\tawait fse.symlink(sensitiveFile, `${testDirectory}/symlink-to-sensitive`)\n\t\tawait fse.ensureFile(`${testDirectory}/normal-file`)\n\n\t\t// Add test directory to shares\n\t\tawait umbreld.client.files.addShare.mutate({path: '/Home/symlink-traversal-test'})\n\n\t\t// Connect to share\n\t\tconst smb = await createSmbClient('symlink-traversal-test (Umbrel)')\n\n\t\t// Test samba lists the normal file but not the symlink\n\t\tawait expect(smb.readFile('normal-file', {encoding: 'utf8'})).resolves.toBe('')\n\t\tawait expect(smb.readFile('symlink-to-sensitive', {encoding: 'utf8'})).rejects.toThrow(\n\t\t\t'STATUS_OBJECT_NAME_NOT_FOUND',\n\t\t)\n\t})\n})\n\ndescribe('wsdd2', () => {\n\ttest('runs only while samba runs', async () => {\n\t\t// Check wsdd2 is not running\n\t\tawait expect($`systemctl is-active wsdd2`).rejects.toThrow('inactive')\n\n\t\t// Add home directory to shares\n\t\tawait expect(umbreld.client.files.addShare.mutate({path: '/Home'})).resolves.toBe('/Home')\n\n\t\t// Check wsdd2 is running\n\t\tawait expect($`systemctl is-active wsdd2`).resolves.toMatchObject({stdout: 'active'})\n\n\t\t// Remove share\n\t\tawait expect(umbreld.client.files.removeShare.mutate({path: '/Home'})).resolves.toBe(true)\n\n\t\t// Check wsdd2 is not running\n\t\tawait expect($`systemctl is-active wsdd2`).rejects.toThrow('inactive')\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/samba.ts",
    "content": "import nodePath from 'node:path'\n\nimport fse from 'fs-extra'\nimport {$} from 'execa'\n\nimport randomToken from '../utilities/random-token.js'\n\nimport type Umbreld from '../../index.js'\nimport type {FileChangeEvent} from './watcher.js'\n\n// Global Samba config\nconst SMB_CONFIG = `# Generated by umbreld\n\n[global]\n# In standalone operation, a client must first \"log-on\" with a valid username\n# and password stored on this machine.\nserver role = standalone\n\n# The name of the Samba server that will show in clients.\nserver string = Umbrel\n\n# Defer to mDNS for discovery instead of advertising uppercase NETBIOS name.\nmdns name = mdns\n\n# Use Systemd for logging.\n# Samba still tries to log to file, so pipe that to /dev/null.\nlogging = systemd\nlog file = /dev/null\n\n# Make sure that we are not leaking information to guests or anonymous users.\naccess based share enum = yes\nrestrict anonymous = 2\nmap to guest = never\n\n# Better compatibility for macOS clients\n# https://wiki.samba.org/index.php/Configure_Samba_to_Work_Better_with_Mac_OS_X\nvfs objects = catia fruit streams_xattr\nfruit:metadata = stream\nfruit:model = MacSamba\nfruit:veto_appledouble = no\nfruit:nfs_aces = no\nfruit:wipe_intentionally_left_blank_rfork = yes\nfruit:delete_empty_adfiles = yes\nfruit:posix_rename = yes\n\n# Disable printing services.\nload printers = no\ndisable spoolss = yes\n`\n\n// Indiviudal share config\nconst shareConfig = (name: string, path: string) => `\n# Share specific config\n[${name}]\npath = ${path}\nwriteable = yes\n\n# Only allow \"umbrel\" user to access the share.\nvalid users = umbrel\n\n# Handle permissions\n# We force root so samba can read files that could be created by app processes\n# running as various users including root.\n# We inherit owner to avoid breaking permissions for apps that expect files in\n# their data directories to be owned by them.\nforce user = root\nforce group = umbrel\ninherit owner = yes\n\n# Enable Time Machine backups.\nfruit:time machine = yes\n`\n\nexport default class Samba {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\t#removeFileChangeListener?: () => void\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(`files:${name.toLocaleLowerCase()}`)\n\t}\n\n\t// Add listener\n\tasync start() {\n\t\tthis.logger.log('Starting samba')\n\n\t\t// Make sure the share password exists and is applied\n\t\tawait this.applySharePassword().catch((error) => {\n\t\t\tthis.logger.error(`Failed to apply share password`, error)\n\t\t})\n\n\t\t// Apply shares (and start Samba/wsdd2 if needed)\n\t\tawait this.applyShares().catch((error) => {\n\t\t\tthis.logger.error(`Failed to apply shares`, error)\n\t\t})\n\n\t\t// Attach listener\n\t\tthis.#removeFileChangeListener = this.#umbreld.eventBus.on(\n\t\t\t'files:watcher:change',\n\t\t\tthis.#handleFileChange.bind(this),\n\t\t)\n\t}\n\n\t// Remove listener\n\tasync stop() {\n\t\tthis.logger.log('Stopping samba')\n\t\tthis.#removeFileChangeListener?.()\n\t\tawait $`systemctl stop smbd`.catch((error) => this.logger.error(`Failed to stop samba`, error))\n\t\tawait $`systemctl stop wsdd2`.catch((error) => this.logger.error(`Failed to stop wsdd2`, error))\n\t}\n\n\t// Gets the share password\n\t// On first run it will generate a random password and save it to the file.\n\t// TODO: Some kind of umbreld.secrets.get() api for dealing with this kind\n\t// of stuff might be nice in the future.\n\tasync getSharePassword() {\n\t\tconst sharePasswordFile = `${this.#umbreld.dataDirectory}/secrets/share-password`\n\n\t\t// Get or create the share password\n\t\tconst sharePassword = await fse.readFile(sharePasswordFile, 'utf8').catch(async () => {\n\t\t\tthis.logger.log('Creating share password on first run')\n\t\t\tconst sharePassword = randomToken(128)\n\t\t\tawait fse.writeFile(sharePasswordFile, sharePassword)\n\t\t\treturn sharePassword\n\t\t})\n\n\t\treturn sharePassword\n\t}\n\n\t// Applies the share password to the Samba user\n\tasync applySharePassword() {\n\t\tconst sharePassword = await this.getSharePassword()\n\t\tawait $({\n\t\t\tinput: `${sharePassword}\\n${sharePassword}\\n`,\n\t\t})`smbpasswd -s -a umbrel`\n\t}\n\n\t// Apply shares to Samba\n\tasync applyShares() {\n\t\tconst shares = await this.#get()\n\n\t\t// Generate Samba config\n\t\tlet config = SMB_CONFIG\n\t\tfor (const share of shares) {\n\t\t\t// Make Umbrel shares easily detectable in clients\n\t\t\tshare.name = await this.#computeSharename(share.name, share.path)\n\n\t\t\t// Convert to system path\n\t\t\tshare.path = await this.#umbreld.files.virtualToSystemPath(share.path)\n\n\t\t\t// Append the share config\n\t\t\tconfig += shareConfig(share.name, share.path)\n\t\t}\n\n\t\t// Write out Samba config\n\t\tawait fse.writeFile('/etc/samba/smb.conf', config)\n\n\t\t// If we don't have any shares, ensure samba isn't running and return\n\t\tif (shares.length === 0) return await $`systemctl stop smbd`\n\n\t\t// Otherwise start samba, or reload it's config if it's already running\n\t\tawait $`systemctl start smbd`\n\t\tawait $`smbcontrol smbd reload-config`\n\n\t\t// We also start wsdd2 for better Windows discovery.\n\t\t// We need to manually start this along with samba because if we boot with wsdd2\n\t\t// enabled but without samba it will shutdown when it sees samba isn't running.\n\t\t// It won't then auto start if a share is added later.\n\t\tawait $`systemctl start wsdd2`\n\t}\n\n\t// Compute a client-facing sharename so that shares are easily detectable in clients\n\tasync #computeSharename(name: string, path: string) {\n\t\t// Default to \"name (Umbrel)\"\n\t\tlet sharename = `${name} (Umbrel)`\n\t\tif (path === '/Home') {\n\t\t\t// But Share /Home as \"username's Umbrel\"\n\t\t\tconst user = await this.#umbreld.user.get()\n\t\t\tconst username = user?.name\n\t\t\tif (username) sharename = `${username}'s Umbrel`\n\t\t}\n\t\treturn sharename\n\t}\n\n\t// Read current shares from the store\n\tasync #get() {\n\t\tconst shares = await this.#umbreld.store.get('files.shares')\n\t\treturn shares || []\n\t}\n\n\t// Remove shares on deletion\n\t// TODO: It would be nice if we could handle updating favorites when the favorited directory is\n\t// moved/renamed. It's not trivial because this can happen via something external like an app or SMB\n\t// and there's no way to tell the difference between a move/rename and a deletion/recreation.\n\tasync #handleFileChange(event: FileChangeEvent) {\n\t\tif (event.type !== 'delete') return\n\t\tconst shares = await this.#get()\n\t\tconst virtualDeletedPath = this.#umbreld.files.systemToVirtualPath(event.path)\n\t\tconst deletedShares = shares.filter((share) => share.path.startsWith(virtualDeletedPath))\n\t\tfor (const share of deletedShares) await this.removeShare(share.path)\n\t}\n\n\t// List favorited directories\n\tasync listShares() {\n\t\t// Get shares from the store\n\t\tconst shares = await this.#get()\n\n\t\t// Strip out any shares that aren't existing directories\n\t\tconst mappedShares = await Promise.all(\n\t\t\tshares.map(async (share) => {\n\t\t\t\tconst systemPath = await this.#umbreld.files.virtualToSystemPath(share.path)\n\t\t\t\tconst file = await this.#umbreld.files.status(systemPath).catch(() => undefined)\n\t\t\t\tif (file?.type !== 'directory') return undefined\n\t\t\t\treturn share\n\t\t\t}),\n\t\t)\n\t\tconst filteredShares = mappedShares.filter((share) => share !== undefined)\n\n\t\t// Compute client-facing sharenames using the same logic used when generating smb.conf.\n\t\tconst sharesWithSharenames = await Promise.all(\n\t\t\tfilteredShares.map(async (share) => ({\n\t\t\t\t...share,\n\t\t\t\tsharename: await this.#computeSharename(share.name, share.path),\n\t\t\t})),\n\t\t)\n\t\treturn sharesWithSharenames\n\t}\n\n\t// Share a new directory\n\tasync addShare(virtualPath: string) {\n\t\t// Check if operation is allowed\n\t\tconst allowedOperations = await this.#umbreld.files.getAllowedOperations(virtualPath)\n\t\tif (!allowedOperations.includes('share')) throw new Error('[operation-not-allowed]')\n\n\t\t// Add share\n\t\tthis.logger.log(`Adding share for ${virtualPath}`)\n\n\t\t// Aquire write lock on the store\n\t\tawait this.#umbreld.store.getWriteLock(async ({set}) => {\n\t\t\t// Get current shares\n\t\t\tconst shares = await this.#get()\n\n\t\t\t// Error if share already exists\n\t\t\tconst shareExists = shares.some((share) => share.path === virtualPath)\n\t\t\tif (shareExists) throw new Error('[share-already-exists]')\n\n\t\t\t// Set unique share name\n\t\t\tlet name = nodePath.basename(virtualPath)\n\t\t\tlet i = 1\n\t\t\twhile (shares.some((share) => share.name === name)) {\n\t\t\t\ti++\n\t\t\t\tif (i > 10) throw new Error('[share-name-generation-failed]')\n\t\t\t\tname = `${nodePath.basename(virtualPath)} (${i})`\n\t\t\t}\n\n\t\t\t// Add share to the store\n\t\t\tawait set('files.shares', [...shares, {name, path: virtualPath}])\n\t\t})\n\n\t\t// Apply changes to Samba\n\t\tawait this.applyShares()\n\n\t\t// Return virtual path\n\t\treturn virtualPath\n\t}\n\n\t// Remove a share\n\tasync removeShare(virtualPath: string) {\n\t\tthis.logger.log(`Removing share for ${virtualPath}`)\n\n\t\tlet deleted = false\n\t\tawait this.#umbreld.store.getWriteLock(async ({set}) => {\n\t\t\tconst shares = await this.#get()\n\t\t\tconst newShares = shares.filter((share) => share.path !== virtualPath)\n\t\t\tdeleted = newShares.length < shares.length\n\t\t\tif (deleted) await set('files.shares', newShares)\n\t\t})\n\n\t\t// Apply changes to Samba\n\t\tif (deleted) {\n\t\t\t// Note: Clients that are already connected to a removed share will continue to stay\n\t\t\t// connected. This is intentional behaviour by samba to avoid corruption by force disconnecting\n\t\t\t// users while they might be using a share.\n\t\t\t// We can force disconnect with `smbcontrol smbd close-share $share` but it could be dangerous\n\t\t\t// and it doesn't work reliably accross clients. macOS drops the connection immediately. Linux\n\t\t\t// shows the files but errors on any navigation or write. Windows continues to stay connected.\n\t\t\tawait this.applyShares()\n\t\t}\n\n\t\t// Return deleted boolean\n\t\treturn deleted\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/search.integration.test.ts",
    "content": "import {expect, beforeAll, afterAll, describe, test} from 'vitest'\nimport fse from 'fs-extra'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\n// Spin up a single Umbreld instance for the entire test suite to save time.\n// Each test creates its own unique files so state leakage across tests does\n// not affect expectations.\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\ndescribe('files.search()', () => {\n\ttest('throws \"Invalid token\" error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.search.query({query: 'anything'})).rejects.toThrow('Invalid token')\n\t})\n\n\ttest('finds files that match the query', async () => {\n\t\t// Create a unique directory with some files to search for\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/search-find-test`\n\t\tawait fse.mkdir(testDir)\n\n\t\t// Create test files\n\t\tawait Promise.all([\n\t\t\tfse.writeFile(`${testDir}/hello-world.txt`, 'hello world'),\n\t\t\tfse.writeFile(`${testDir}/hello-mars.txt`, 'hello mars'),\n\t\t\tfse.writeFile(`${testDir}/unrelated.txt`, 'nothing to see here'),\n\t\t])\n\n\t\t// Perform the search\n\t\tconst results = await umbreld.client.files.search.query({query: 'hello-world'})\n\n\t\t// Expect the specific file to be returned\n\t\texpect(results).toEqual(\n\t\t\texpect.arrayContaining([\n\t\t\t\texpect.objectContaining({\n\t\t\t\t\tname: 'hello-world.txt',\n\t\t\t\t\tpath: '/Home/search-find-test/hello-world.txt',\n\t\t\t\t}),\n\t\t\t]),\n\t\t)\n\n\t\t// Ensure unrelated file is not returned\n\t\texpect(results.some((file) => file.name === 'unrelated.txt')).toBe(false)\n\t})\n\n\ttest('fuzzy matches against filename', async () => {\n\t\t// Create a unique directory with some files to search for\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/search-fuzzy-test`\n\t\tawait fse.mkdir(testDir)\n\n\t\t// Create test files\n\t\tawait fse.writeFile(`${testDir}/bitcoin.pdf`, '')\n\n\t\t// Perform the search\n\t\tconst results = await umbreld.client.files.search.query({query: 'bit corn'})\n\n\t\t// Expect the specific file to be returned\n\t\texpect(results).toEqual(\n\t\t\texpect.arrayContaining([\n\t\t\t\texpect.objectContaining({\n\t\t\t\t\tname: 'bitcoin.pdf',\n\t\t\t\t\tpath: '/Home/search-fuzzy-test/bitcoin.pdf',\n\t\t\t\t}),\n\t\t\t]),\n\t\t)\n\t})\n\n\ttest('respects maxResults', async () => {\n\t\tconst limitDir = `${umbreld.instance.dataDirectory}/home/search-limit-test`\n\t\tawait fse.mkdir(limitDir)\n\n\t\t// Create more than 10 files that will all match the query\n\t\tconst fileCreationPromises = []\n\t\tfor (let i = 0; i < 20; i++) {\n\t\t\tfileCreationPromises.push(fse.writeFile(`${limitDir}/alpha-${i}.txt`, String(i)))\n\t\t}\n\t\tawait Promise.all(fileCreationPromises)\n\n\t\tconst results = await umbreld.client.files.search.query({query: 'alpha', maxResults: 5})\n\n\t\texpect(results.length).toBe(5)\n\t})\n\n\ttest('returns an empty array when there are no matches', async () => {\n\t\tconst results = await umbreld.client.files.search.query({query: 'completely-nonexistent-query'})\n\t\texpect(results).toStrictEqual([])\n\t})\n\n\ttest('throws when maxResults is unsafely large', async () => {\n\t\tconst maxAllowedValue = 1000\n\n\t\t// Works for max value\n\t\tawait expect(\n\t\t\tumbreld.client.files.search.query({\n\t\t\t\tquery: 'completely-nonexistent-query',\n\t\t\t\tmaxResults: maxAllowedValue,\n\t\t\t}),\n\t\t).resolves.toStrictEqual([])\n\n\t\t// Throws for one over max value\n\t\tawait expect(\n\t\t\tumbreld.client.files.search.query({query: 'completely-nonexistent-query', maxResults: maxAllowedValue + 1}),\n\t\t).rejects.toThrow('too_big')\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/search.ts",
    "content": "import nodePath from 'node:path'\n\nimport {fuzzy} from 'fast-fuzzy'\n\nimport type Umbreld from '../../index.js'\n\nexport default class Search {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\tmatchThreshold = 0.66\n\tmaxResultsDuringSearch = 10_000\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(`files:${name.toLocaleLowerCase()}`)\n\t}\n\n\t// No background tasks\n\tasync start() {}\n\tasync stop() {}\n\n\t// Search for fuzzy matches against all files in the home directory\n\t// TODO: We should index the entire filesystem and search against a real database\n\t// but for now this should work well enough.\n\tasync search(query: string, maxResults = 250) {\n\t\tlet results: {score: number; systemPath: string}[] = []\n\n\t\t// Helper to order the results by score and return the top results\n\t\tconst getBestResults = () => results.sort((a, b) => b.score - a.score).slice(0, maxResults)\n\n\t\t// Iterate over all files in the home directory\n\t\tfor await (const systemPath of this.#umbreld.files.streamContents('/Home')) {\n\t\t\t// Grab the filename\n\t\t\tconst filename = nodePath.basename(systemPath)\n\n\t\t\t// Skip hidden files\n\t\t\tif (this.#umbreld.files.isHidden(filename)) continue\n\n\t\t\t// Calculate the fuzzy match score of just the filename\n\t\t\tconst score = fuzzy(query, filename)\n\n\t\t\t// Save the result if it's a good match\n\t\t\tif (score > this.matchThreshold) {\n\t\t\t\tresults.push({score, systemPath})\n\n\t\t\t\t// Keep memory usage reasonable for searches with lots of matches\n\t\t\t\t// by clearing out the results array if it gets too big\n\t\t\t\tif (results.length >= this.maxResultsDuringSearch) results = getBestResults()\n\t\t\t}\n\t\t}\n\n\t\t// Get the best results\n\t\tresults = getBestResults()\n\n\t\t// Get file objects\n\t\tlet fileReads = await Promise.allSettled(results.map((result) => this.#umbreld.files.status(result.systemPath)))\n\n\t\t// Filter out any files that failed to get status\n\t\tconst files = fileReads.filter((result) => result.status === 'fulfilled').map((result) => result.value)\n\n\t\treturn files\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/thumbnails.integration.test.ts",
    "content": "/*\nTests in this file that involve the background watcher (e.g., those relying on file changes to trigger thumbnail generation) can be flaky in GitHub Actions CI due to variable watcher event delays and generation times (via convert/FFmpeg), stemming from VM resource limits and disk I/O.\n\nWe do the following to give a high probability of success in CI:\n- waitForThumbnailDebounce(): Explicitely waits 1500ms after ops to cover 1000ms debounce + event propagation/CI variability.\n- pollUntil timeouts: continues polling for existence of a thumbnail for 15000ms (15s) for thumbnail generation breathing room in CI.\n- { retry: 5 }: Auto-retries to handle timing outliers (e.g., very slow thumbnail generation or missed events).\n*/\n\nimport nodePath from 'node:path'\n\nimport {expect, test, describe, beforeEach, afterEach, vi} from 'vitest'\nimport fse from 'fs-extra'\nimport {delay} from 'es-toolkit'\nimport {$} from 'execa'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\n// Create new umbreld instance for each test\nbeforeEach(async () => {\n\tumbreld = await createTestUmbreld()\n\tawait umbreld.registerAndLogin()\n})\n\n// Clean up after each test\nafterEach(async () => {\n\tawait umbreld.cleanup()\n})\n\n// Helper to copy fixture files for testing\nasync function copyFixtureFile(\n\tdestinationDir: string,\n\tfixtureName: string = 'master-lossless-image.png',\n\tcustomName?: string,\n) {\n\t// Ensure target directory exists\n\tawait fse.ensureDir(destinationDir)\n\n\t// Fixture files are in the same parent directory as this test file at /fixures/thumbnails\n\tconst fixturePath = nodePath.resolve(__dirname, 'fixtures', 'thumbnails', fixtureName)\n\n\t// Construct destination path by joining directory and fixture name\n\tconst destinationPath = nodePath.join(destinationDir, customName || fixtureName)\n\n\t// Copy the fixture file to the test location\n\tawait fse.copy(fixturePath, destinationPath)\n\n\t// return the destination path\n\treturn destinationPath\n}\n\n// Helper function to poll until a condition is met or timeout occurs\nasync function pollUntil(\n\tcondition: () => Promise<boolean>,\n\t{\n\t\ttimeoutMs = 5000,\n\t\tintervalMs = 100,\n\t\terrorMessage = 'Polling timed out',\n\t\tlabel,\n\t}: {\n\t\ttimeoutMs?: number\n\t\tintervalMs?: number\n\t\terrorMessage?: string\n\t\tlabel?: string\n\t} = {},\n): Promise<void> {\n\tconst startTime = Date.now()\n\n\twhile (Date.now() - startTime < timeoutMs) {\n\t\tif (await condition()) return\n\t\tawait delay(intervalMs)\n\t}\n\n\tthrow new Error(errorMessage)\n}\n\n// Helper to wait for the thumbnail generation debounce period after file operations (1000ms in thumbnails.ts).\n// The production debounce resets on each event for the path (e.g., multiple update's during copy), waiting 1000ms after the last one before generating.\n// We use a slightly longer period (1500ms) to account for FS event propagation, potential reset-triggering events, and CI variability before polling starts.\nconst waitForThumbnailDebounce = () => delay(1500)\n\ndescribe('getThumbnail', () => {\n\ttest('throws invalid error without auth token', async () => {\n\t\tawait expect(umbreld.unauthenticatedClient.files.getThumbnail.mutate({path: '/Home/test.jpg'})).rejects.toThrow(\n\t\t\t'Invalid token',\n\t\t)\n\t})\n\n\ttest.todo('cannot generate thumbnail outside of /Home /Apps /Trash /External')\n\n\ttest('returns a correctly formatted api endpoint URL for a thumbnail', async () => {\n\t\t// Create test image\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test`\n\t\tawait copyFixtureFile(testDir, 'master-lossless-image.png')\n\n\t\t// Get api endpoint URL for thumbnail\n\t\tconst virtualPath = '/Home/thumbnail-test/master-lossless-image.png'\n\t\tconst thumbnailUrl = await umbreld.client.files.getThumbnail.mutate({path: virtualPath})\n\n\t\t// Verify URL matches expected format (api endpoint of /api/files/thumbnail/:hash.webp where :hash is a valid hash)\n\t\texpect(thumbnailUrl).toBeTruthy()\n\t\texpect(thumbnailUrl).toMatch(/^\\/api\\/files\\/thumbnail\\/[a-f0-9]+\\.webp$/i)\n\t})\n\n\t// This test specifically checks that getThumbnail triggers thumbnail generation when one does not exist\n\t// Other getThumbnail tests below this one can only guarantee that the thumbnail hash is returned, but it may have been generated via the background watcher or on-demand\n\ttest('generates thumbnails on-demand when no thumbnail exists', {retry: 5}, async () => {\n\t\t// Create test image\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test`\n\t\tawait copyFixtureFile(testDir, 'master-lossless-image.png')\n\n\t\tawait waitForThumbnailDebounce()\n\n\t\t// Wait for background watcher to generate the thumbnail so we are sure it won't interfere with the test\n\t\t// This is successful if one file exists in the thumbnails directory\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\t\t\treturn thumbnails.length === 1\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: 'Background watcher did not generate thumbnail',\n\t\t\t},\n\t\t)\n\n\t\t// Delete the thumbnail\n\t\tconst thumbnail = await fse.readdir(thumbnailDir)\n\t\tawait fse.remove(nodePath.join(thumbnailDir, thumbnail[0]))\n\n\t\t// Verify thumbnail was deleted by checking that no files exist in the thumbnails directory\n\t\tconst thumbnailsAfterDelete = await fse.readdir(thumbnailDir)\n\t\texpect(thumbnailsAfterDelete.length).toBe(0)\n\n\t\t// Call getThumbnail - it should generate the thumbnail on-demand\n\t\tconst virtualPath = '/Home/thumbnail-test/master-lossless-image.png'\n\t\tconst thumbnailUrl = await umbreld.client.files.getThumbnail.mutate({path: virtualPath})\n\n\t\t// Verify thumbnail was generated\n\t\tconst thumbnailFilename = thumbnailUrl.split('/').pop()\n\t\tconst thumbnailPath = `${thumbnailDir}/${thumbnailFilename}`\n\t\tconst thumbnailExists = await fse.pathExists(thumbnailPath)\n\t\texpect(thumbnailExists).toBe(true)\n\t})\n\n\tconst imageTypes = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']\n\n\tfor (const imageType of imageTypes) {\n\t\ttest(`returns a thumbnail for a ${imageType} file`, async () => {\n\t\t\t// Create test directory\n\t\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test`\n\t\t\tawait fse.ensureDir(testDir)\n\n\t\t\t// Destination path for the test\n\t\t\tconst imagePath = `${testDir}/test-image.${imageType}`\n\n\t\t\t// Use ImageMagick to convert PNG fixture directly to the test image type\n\t\t\tawait $`convert ${nodePath.resolve(__dirname, 'fixtures', 'thumbnails', 'master-lossless-image.png')} ${imagePath}`\n\n\t\t\t// Get \t api endpoint URL\n\t\t\tconst virtualPath = `/Home/thumbnail-test/test-image.${imageType}`\n\t\t\tconst thumbnailUrl = await umbreld.client.files.getThumbnail.mutate({path: virtualPath})\n\n\t\t\t// Verify thumbnail file was created\n\t\t\tconst thumbnailFilename = thumbnailUrl.split('/').pop()\n\t\t\tconst thumbnailPath = `${umbreld.instance.dataDirectory}/thumbnails/${thumbnailFilename}`\n\t\t\tconst thumbnailExists = await fse.pathExists(thumbnailPath)\n\t\t\texpect(thumbnailExists).toBe(true)\n\t\t})\n\t}\n\n\ttest.todo('returns a thumbnail for a heic file once we support it')\n\n\tconst videoTypes = ['.mkv', '.mov', '.mp4', '.3gp', '.avi']\n\n\tfor (const videoType of videoTypes) {\n\t\ttest(`returns a thumbnail for a ${videoType} file`, async () => {\n\t\t\t// Create test directory\n\t\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test`\n\t\t\tawait fse.ensureDir(testDir)\n\n\t\t\t// Destination path for the test\n\t\t\tconst videoPath = `${testDir}/test-video.${videoType}`\n\n\t\t\t// Use ffmpeg to convert mkv fixture directly to the test video type\n\t\t\tawait $`ffmpeg -i ${nodePath.resolve(__dirname, 'fixtures', 'thumbnails', 'master-lossless-video.mkv')} -c:v libx264 ${videoPath}`\n\n\t\t\t// Get api endpoint URL for thumbnail\n\t\t\tconst virtualPath = `/Home/thumbnail-test/test-video.${videoType}`\n\t\t\tconst thumbnailUrl = await umbreld.client.files.getThumbnail.mutate({path: virtualPath})\n\n\t\t\t// Verify thumbnail file was created\n\t\t\tconst thumbnailFilename = thumbnailUrl.split('/').pop()\n\t\t\tconst thumbnailPath = `${umbreld.instance.dataDirectory}/thumbnails/${thumbnailFilename}`\n\t\t\tconst thumbnailExists = await fse.pathExists(thumbnailPath)\n\t\t\texpect(thumbnailExists).toBe(true)\n\t\t})\n\t}\n\n\ttest.todo('returns a thumbnail for a pdf file')\n\n\ttest('returns same thumbnail for renamed files', async () => {\n\t\t// Create test directory and image\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test`\n\t\tconst originalPath = await copyFixtureFile(testDir)\n\n\t\t// Get original thumbnail's api endpoint URL\n\t\tconst virtualPath = '/Home/thumbnail-test/master-lossless-image.png'\n\t\tconst originalThumbnailUrl = await umbreld.client.files.getThumbnail.mutate({path: virtualPath})\n\n\t\t// Rename the file\n\t\tconst renamedPath = nodePath.join(testDir, `renamed${nodePath.extname(originalPath)}`)\n\t\tawait fse.rename(originalPath, renamedPath)\n\n\t\t// Get thumbnail's api endpoint URL for renamed file\n\t\tconst renamedVirtualPath = '/Home/thumbnail-test/renamed.png'\n\t\tconst renamedThumbnailUrl = await umbreld.client.files.getThumbnail.mutate({path: renamedVirtualPath})\n\n\t\t// The thumbnail's api endpoint URL should be the same since filesystem UUID, inode, and date modified are the same\n\t\texpect(renamedThumbnailUrl).toBe(originalThumbnailUrl)\n\t})\n\n\ttest('returns same thumbnail for moved files', async () => {\n\t\t// Create test directories\n\t\tconst sourceDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test/source`\n\t\tconst destDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test/destination`\n\t\tawait fse.ensureDir(sourceDir)\n\t\tawait fse.ensureDir(destDir)\n\n\t\t// Create test image in source directory\n\t\tconst sourcePath = await copyFixtureFile(sourceDir)\n\n\t\t// Get original thumbnail api endpoint URL\n\t\tconst virtualPath = '/Home/thumbnail-test/source/master-lossless-image.png'\n\t\tconst originalThumbnailUrl = await umbreld.client.files.getThumbnail.mutate({path: virtualPath})\n\n\t\t// Move the file to a different directory with same name\n\t\tconst destinationPath = nodePath.join(destDir, nodePath.basename(sourcePath))\n\t\tawait fse.move(sourcePath, destinationPath)\n\n\t\t// Get thumbnail's api endpoint URL for moved file\n\t\tconst movedVirtualPath = '/Home/thumbnail-test/destination/master-lossless-image.png'\n\t\tconst movedThumbnailUrl = await umbreld.client.files.getThumbnail.mutate({path: movedVirtualPath})\n\n\t\t// The thumbnail's api endpoint URL should be the same since filesystem UUID, inode, and date modified are the same\n\t\texpect(movedThumbnailUrl).toBe(originalThumbnailUrl)\n\t})\n\n\ttest('generates a new thumbnail when source file is modified', async () => {\n\t\t// Create test directory and image\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test`\n\t\tconst imagePath = await copyFixtureFile(testDir)\n\n\t\t// Get original thumbnail api endpoint URL\n\t\tconst virtualPath = '/Home/thumbnail-test/master-lossless-image.png'\n\t\tconst originalThumbnailUrl = await umbreld.client.files.getThumbnail.mutate({path: virtualPath})\n\n\t\t// Get path to the original thumbnail file\n\t\tconst originalThumbnailFilename = originalThumbnailUrl.split('/').pop()\n\t\tconst originalThumbnailPath = `${umbreld.instance.dataDirectory}/thumbnails/${originalThumbnailFilename}`\n\n\t\t// Verify original thumbnail was created\n\t\tconst originalThumbnailExists = await fse.pathExists(originalThumbnailPath)\n\t\texpect(originalThumbnailExists).toBe(true)\n\n\t\t// Wait a moment to ensure we get a different timestamp\n\t\tawait delay(100)\n\n\t\t// Modify the file timestamp without changing content\n\t\tconst newTime = new Date()\n\t\tawait fse.utimes(imagePath, newTime, newTime)\n\n\t\t// Get thumbnail's api endpoint URL for modified file - should be a different hash because modification time has changed\n\t\tconst modifiedVirtualPath = '/Home/thumbnail-test/master-lossless-image.png'\n\t\tconst modifiedThumbnailUrl = await umbreld.client.files.getThumbnail.mutate({path: modifiedVirtualPath})\n\t\texpect(modifiedThumbnailUrl).not.toBe(originalThumbnailUrl)\n\n\t\t// Get path to the new thumbnail file\n\t\tconst newThumbnailFilename = modifiedThumbnailUrl.split('/').pop()\n\t\tconst newThumbnailPath = `${umbreld.instance.dataDirectory}/thumbnails/${newThumbnailFilename}`\n\n\t\t// Verify new thumbnail was created\n\t\tconst newThumbnailExists = await fse.pathExists(newThumbnailPath)\n\t\texpect(newThumbnailExists).toBe(true)\n\t})\n\n\ttest('returns existing thumbnails when they exist without generating a new one', async () => {\n\t\t// Create test image\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test`\n\t\tawait copyFixtureFile(testDir)\n\n\t\t// Get thumbnail api endpoint URL first time\n\t\tconst virtualPath = '/Home/thumbnail-test/master-lossless-image.png'\n\t\tconst thumbnailUrl1 = await umbreld.client.files.getThumbnail.mutate({path: virtualPath})\n\n\t\t// Get the thumbnail file stats\n\t\tconst thumbnailFilename = thumbnailUrl1.split('/').pop()\n\t\tconst thumbnailPath = `${umbreld.instance.dataDirectory}/thumbnails/${thumbnailFilename}`\n\t\tconst stats = await fse.stat(thumbnailPath)\n\t\tconst mtime = stats.mtime.getTime()\n\n\t\t// Wait a moment\n\t\tawait delay(100)\n\n\t\t// Request thumbnail again\n\t\tconst thumbnailUrl2 = await umbreld.client.files.getThumbnail.mutate({path: virtualPath})\n\n\t\t// The thumbnail's api endpoint URL should be the same\n\t\texpect(thumbnailUrl2).toBe(thumbnailUrl1)\n\n\t\t// Verify the thumbnail file stats are the same\n\t\tconst stats2 = await fse.stat(thumbnailPath)\n\t\texpect(stats2.mtime.getTime()).toBe(mtime)\n\t})\n})\n\ndescribe('Background file watcher', () => {\n\ttest('generates thumbnails for new image files', {retry: 5}, async () => {\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-watcher-test`\n\t\tawait fse.ensureDir(testDir)\n\n\t\t// Copy the image file to the test directory\n\t\tawait copyFixtureFile(testDir)\n\n\t\tawait waitForThumbnailDebounce()\n\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\n\t\t// Wait for watcher to detect the file and generate thumbnail\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\t\t\treturn thumbnails.length === 1\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: 'Watcher did not generate thumbnail for new image',\n\t\t\t},\n\t\t)\n\n\t\t// Since this is a clean test environment, there should be exactly one thumbnail\n\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\texpect(thumbnails.length).toBe(1)\n\t})\n\n\ttest('does not update thumbnails when files are moved', {retry: 5}, async () => {\n\t\t// Create test directories\n\t\tconst sourceDir = `${umbreld.instance.dataDirectory}/home/thumbnail-watcher-move-test/source`\n\t\tconst destDir = `${umbreld.instance.dataDirectory}/home/thumbnail-watcher-move-test/destination`\n\t\tawait fse.ensureDir(destDir)\n\n\t\t// Copy the image file to the source directory\n\t\tconst sourcePath = await copyFixtureFile(sourceDir)\n\n\t\tawait waitForThumbnailDebounce()\n\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\n\t\t// Wait for watcher to detect the file and generate thumbnail\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\t\t\treturn thumbnails.length === 1\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: 'Watcher did not generate initial thumbnail',\n\t\t\t},\n\t\t)\n\n\t\t// Get the thumbnail file stats\n\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\tconst thumbnailPath = `${thumbnailDir}/${thumbnails[0]}`\n\t\tconst initialStats = await fse.stat(thumbnailPath)\n\t\tconst initialMtime = initialStats.mtime.getTime()\n\n\t\t// Wait a moment to ensure move operation occurs some time after source file creation (won't impact source file's date modified for a move operation)\n\t\t// Allows us to verify that the thumbnail was not re-generated by ensuring the thumbnail's modified time remains the same\n\t\tawait delay(100)\n\n\t\t// Move the file to a different directory with same name\n\t\tconst destinationPath = nodePath.join(destDir, nodePath.basename(sourcePath))\n\t\tawait fse.move(sourcePath, destinationPath)\n\n\t\t// Wait a conservative period to confirm the thumbnail was not re-generated\n\t\t// Note: Fixed delay is a bit hacky for negative assertion; uses 10000ms to cover debounce (1000ms) + potential generation time + CI buffer.\n\t\tawait delay(10000)\n\n\t\t// Read the updated thumbnails directory - should still be just one thumbnail\n\t\t// since moving doesn't change the source file's inode, filesystem UUID, or date modified\n\t\tconst finalThumbnails = await fse.readdir(thumbnailDir)\n\t\texpect(finalThumbnails.length).toBe(1)\n\n\t\t// Verify that the thumnbnails modified time remains the same to ensure it was not re-generated\n\t\tconst finalStats = await fse.stat(thumbnailPath)\n\t\texpect(finalStats.mtime.getTime()).toBe(initialMtime)\n\t})\n\n\ttest('does not update thumbnails when files are renamed', {retry: 5}, async () => {\n\t\t// Create test directory\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-watcher-rename-test`\n\t\tawait fse.ensureDir(testDir)\n\n\t\t// Copy the image file to the test directory\n\t\tconst originalPath = await copyFixtureFile(testDir)\n\n\t\tawait waitForThumbnailDebounce()\n\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\n\t\t// Wait for watcher to detect the file and generate thumbnail\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\t\t\treturn thumbnails.length === 1\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: 'Watcher did not generate initial thumbnail',\n\t\t\t},\n\t\t)\n\n\t\t// Get the thumbnail file stats\n\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\tconst thumbnailPath = `${thumbnailDir}/${thumbnails[0]}`\n\t\tconst initialStats = await fse.stat(thumbnailPath)\n\t\tconst initialMtime = initialStats.mtime.getTime()\n\n\t\t// Wait a moment to ensure rename operation occurs some time after source file creation (won't impact source file's date modified for a rename operation)\n\t\t// Allows us to verify that the thumbnail was not re-generated by ensuring the thumbnail's modified time remains the same\n\t\tawait delay(100)\n\n\t\t// Rename the file in the same directory\n\t\tconst renamedPath = nodePath.join(testDir, `renamed${nodePath.extname(originalPath)}`)\n\t\tawait fse.rename(originalPath, renamedPath)\n\n\t\t// Wait a conservative period to confirm the thumbnail was not re-generated\n\t\t// Note: Fixed delay is a bit hacky for negative assertion; uses 10000ms to cover debounce (1000ms) + potential generation time + CI buffer.\n\t\tawait delay(10000)\n\n\t\t// Read the updated thumbnails directory - should still be just one thumbnail\n\t\t// since renaming doesn't change the inode, filesystem UUID, or date modified\n\t\tconst finalThumbnails = await fse.readdir(thumbnailDir)\n\t\texpect(finalThumbnails.length).toBe(1)\n\n\t\t// Verify that the thumnbnails modified time remains the same\n\t\tconst finalStats = await fse.stat(thumbnailPath)\n\t\texpect(finalStats.mtime.getTime()).toBe(initialMtime)\n\t})\n\n\ttest('updates thumbnails when files are modified', {retry: 5}, async () => {\n\t\t// Create test directory and image\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-watcher-modify-test`\n\t\tconst imagePath = await copyFixtureFile(testDir)\n\n\t\tawait waitForThumbnailDebounce()\n\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\n\t\t// Wait for watcher to detect the file and generate thumbnail\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\t\t\treturn thumbnails.length === 1\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: 'Watcher did not generate initial thumbnail',\n\t\t\t},\n\t\t)\n\n\t\t// Get initial thumbnail info and file stats\n\t\tconst initialThumbnails = await fse.readdir(thumbnailDir)\n\t\texpect(initialThumbnails.length).toBe(1)\n\t\tconst initialThumbnailHash = initialThumbnails[0].split('.')[0]\n\n\t\t// Wait a moment to ensure file timestamps will be different\n\t\tawait delay(100)\n\n\t\t// Modify the file timestamp without changing content\n\t\tconst newTime = new Date()\n\t\tawait fse.utimes(imagePath, newTime, newTime)\n\n\t\tawait waitForThumbnailDebounce()\n\n\t\t// Wait for watcher to detect the modification and generate a new thumbnail\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\t\t\t// Should have second thumbnail\n\t\t\t\treturn thumbnails.length === 2\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: 'Watcher did not generate new thumbnail after file modification',\n\t\t\t},\n\t\t)\n\n\t\t// Get final thumbnails\n\t\tconst finalThumbnails = await fse.readdir(thumbnailDir)\n\n\t\t// There should be two thumbnails\n\t\texpect(finalThumbnails.length).toBe(2)\n\n\t\t// Verify that the new thumbnail is different from the initial one\n\t\tconst newThumbnailExists = finalThumbnails.some((t) => !t.startsWith(initialThumbnailHash))\n\t\texpect(newThumbnailExists).toBe(true)\n\t})\n\n\ttest('ignores directories', {retry: 5}, async () => {\n\t\t// Create test directory\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test`\n\t\tawait fse.ensureDir(testDir)\n\n\t\t// Wait a conservative period to confirm the thumbnail was not generated\n\t\t// Note: Fixed delay is a bit hacky for negative assertion; uses 10000ms to cover debounce (1000ms) + potential generation time + CI buffer.\n\t\tawait delay(10000)\n\n\t\t// Get the thumbnails directory\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\n\t\t// There should be no thumbnails\n\t\texpect(thumbnails.length).toBe(0)\n\t})\n\n\ttest('ignores unsupported file types for thumbnails', {retry: 5}, async () => {\n\t\t// Create test directory\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test`\n\t\tawait fse.ensureDir(testDir)\n\n\t\t// create txt file\n\t\tawait fse.writeFile(`${testDir}/test.txt`, 'test')\n\n\t\tawait waitForThumbnailDebounce()\n\n\t\t// Wait a conservative period to confirm that the thumbnail was not generated\n\t\t// Note: Fixed delay is a bit hacky for negative assertion; uses 10000ms to cover debounce (1000ms) + potential generation time + CI buffer.\n\t\tawait delay(10000)\n\n\t\t// Get the thumbnails directory\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\n\t\t// There should be no thumbnails\n\t\texpect(thumbnails.length).toBe(0)\n\t})\n})\n\ndescribe('files.list() [thumbnail specific]', () => {\n\ttest('includes thumbnail for a file with an existing thumbnail', async () => {\n\t\t// Create test directory\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test`\n\t\tawait fse.ensureDir(testDir)\n\n\t\t// Copy the image file to the test directory\n\t\tawait copyFixtureFile(testDir)\n\n\t\tawait waitForThumbnailDebounce()\n\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\n\t\t// Wait for watcher to detect the file and generate thumbnail\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\t\t\treturn thumbnails.length === 1\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: 'Watcher did not generate initial thumbnail',\n\t\t\t},\n\t\t)\n\n\t\t// List the files in the test directory - files.list()\n\t\tconst files = await umbreld.client.files.list.query({path: '/Home/thumbnail-test'})\n\n\t\t// Verify the thumbnail property is included for the listed file\n\t\texpect(files.files[0].thumbnail).toBeDefined()\n\n\t\t// Verify the thumbnail included in the listed file is the same as the one generated above\n\t\tconst thumbnailsFromDir = await fse.readdir(thumbnailDir)\n\t\tconst thumbnail = thumbnailsFromDir[0]\n\n\t\t// Add api endpoint details for proper comparison\n\t\t// We expect the thumbnail in the listed file to be of the format `/api/files/thumbnail/{hash}.webp`\n\t\tconst thumbnailWithApiEndpoint = `/api/files/thumbnail/${thumbnail}`\n\n\t\texpect(files.files[0].thumbnail).toBe(thumbnailWithApiEndpoint)\n\t})\n\n\ttest('does not include thumbnail for files without an existing thumbnail', async () => {\n\t\t// Create test directory\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test`\n\t\tawait fse.ensureDir(testDir)\n\n\t\t// Copy the image file to the test directory\n\t\tawait copyFixtureFile(testDir)\n\n\t\tawait waitForThumbnailDebounce()\n\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\n\t\t// Wait for watcher to detect the file and generate thumbnail\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\t\t\treturn thumbnails.length === 1\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: 'Watcher did not generate initial thumbnail',\n\t\t\t},\n\t\t)\n\n\t\t// Delete the thumbnail which will be the only file in the thumbnails directory\n\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\tawait fse.remove(`${thumbnailDir}/${thumbnails[0]}`)\n\n\t\t// Verify the thumbnail directory is empty\n\t\tconst finalThumbnails = await fse.readdir(thumbnailDir)\n\t\texpect(finalThumbnails.length).toBe(0)\n\n\t\t// List the files in the test directory - files.list()\n\t\tconst files = await umbreld.client.files.list.query({path: '/Home/thumbnail-test'})\n\n\t\t// Verify that no thumbnail is included for the file\n\t\texpect(files.files[0].thumbnail).toBeUndefined()\n\t})\n\n\ttest('does not include thumbnail for directories', async () => {\n\t\t// Create test directory\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-test`\n\t\tawait fse.ensureDir(testDir)\n\n\t\t// Create a subdirectory\n\t\tawait fse.ensureDir(`${testDir}/subdir`)\n\n\t\t// List the files in the test directory - files.list()\n\t\tconst files = await umbreld.client.files.list.query({path: '/Home/thumbnail-test'})\n\n\t\t// Verify that no thumbnail is included for the directory\n\t\texpect(files.files[0].thumbnail).toBeUndefined()\n\t})\n})\n\ndescribe('recents() [thumbnail specific]', () => {\n\ttest('includes thumbnail for a recent file with an existing thumbnail', {retry: 5}, async () => {\n\t\t// Create test directory\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/recents-thumbnail-test`\n\t\tawait fse.ensureDir(testDir)\n\n\t\t// Copy an image file to the test directory (use the helper)\n\t\tawait copyFixtureFile(testDir, 'master-lossless-image.png', 'recent-image.png')\n\n\t\tawait waitForThumbnailDebounce()\n\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\n\t\t// Wait for watcher to detect the file and generate thumbnail\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\t\t\treturn thumbnails.length === 1\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: 'Watcher did not generate initial thumbnail for recent file',\n\t\t\t},\n\t\t)\n\n\t\t// Get recent files\n\t\tconst recentFiles = await umbreld.client.files.recents.query()\n\n\t\t// Verify the thumbnail property is included for the recent file - there will only be one recent file\n\t\tconst recentImage = recentFiles[0]\n\n\t\t// Verify the thumbnail property is included for the recent file\n\t\texpect(recentImage?.thumbnail).toBeDefined()\n\n\t\t// Verify the thumbnail included is the same as the one generated above\n\t\tconst thumbnailsFromDir = await fse.readdir(thumbnailDir)\n\t\tconst thumbnail = thumbnailsFromDir[0]\n\n\t\t// Add api endpoint details for proper comparison\n\t\tconst thumbnailWithApiEndpoint = `/api/files/thumbnail/${thumbnail}`\n\t\texpect(recentImage?.thumbnail).toBe(thumbnailWithApiEndpoint)\n\t})\n\n\ttest('does not include thumbnail for a recent file without an existing thumbnail', async () => {\n\t\t// Create test directory\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/recents-thumbnail-test`\n\t\tawait fse.ensureDir(testDir)\n\n\t\t// Copy the image file to the test directory\n\t\tawait copyFixtureFile(testDir)\n\n\t\tawait waitForThumbnailDebounce()\n\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\n\t\t// Wait for watcher to detect the file and generate thumbnail\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\t\t\treturn thumbnails.length === 1\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: 'Watcher did not generate initial thumbnail',\n\t\t\t},\n\t\t)\n\n\t\t// Delete the thumbnail which will be the only file in the thumbnails directory\n\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\tawait fse.remove(`${thumbnailDir}/${thumbnails[0]}`)\n\n\t\t// Verify the thumbnail directory is empty\n\t\tconst finalThumbnails = await fse.readdir(thumbnailDir)\n\t\texpect(finalThumbnails.length).toBe(0)\n\n\t\t// Get recent files\n\t\tconst recentFiles = await umbreld.client.files.recents.query()\n\n\t\t// Verify the thumbnail property is not included for the recent file - there will only be one recent file\n\t\texpect(recentFiles[0].thumbnail).toBeUndefined()\n\t})\n})\n\ndescribe('Thumbnail housekeeping', () => {\n\ttest('removes oldest thumbnails when exceeding cleanup threshold', {retry: 5}, async () => {\n\t\t// Set a lower maxThumbnailCount and pruningThreshold for testing\n\t\tconst thumbnailsInstance = umbreld.instance.files.thumbnails\n\t\tthumbnailsInstance.maxThumbnailCount = 20\n\t\tthumbnailsInstance.pruningThreshold = 10\n\n\t\t// Create test images directory\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-cleanup-test`\n\t\tawait fse.mkdir(testDir)\n\n\t\t// STEP 1: Create initial batch of images (just under threshold)\n\t\t// With maxThumbnailCount=20 and pruningThreshold=10, cleanup should happen at 30 images\n\t\t// So we create 29 images first (which shouldn't trigger cleanup)\n\t\tconst initialBatchSize = 29\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\n\t\t// Create first batch of images (29)\n\t\tfor (let i = 0; i < initialBatchSize; i++) {\n\t\t\tawait copyFixtureFile(testDir, 'master-lossless-image.png', `cleanup-${i}.png`)\n\t\t}\n\n\t\t// Wait for watcher to automatically create thumbnails for first batch\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\t\t\treturn thumbnails.length >= initialBatchSize\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: \"Watcher didn't create expected thumbnails for first batch\",\n\t\t\t},\n\t\t)\n\n\t\t// Count thumbnails after first batch\n\t\tconst firstBatchThumbnails = await fse.readdir(thumbnailDir)\n\n\t\t// Verify no cleanup happened yet (we should have all 29 thumbnails)\n\t\texpect(firstBatchThumbnails.length).toBeGreaterThanOrEqual(initialBatchSize)\n\n\t\t// Wait a moment to ensure we're not in the middle of any operations\n\t\tawait delay(500)\n\n\t\t// STEP 2: Create one more image to trigger cleanup\n\t\t// This should be the 30th image, which should trigger cleanup\n\t\tawait copyFixtureFile(testDir, 'master-lossless-image.png', 'cleanup-trigger.png')\n\n\t\t// Wait for cleanup to complete\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\t\t\treturn thumbnails.length <= thumbnailsInstance.maxThumbnailCount && thumbnails.length > 0\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: 'Cleanup did not complete within timeout period',\n\t\t\t},\n\t\t)\n\n\t\t// Get final thumbnail count\n\t\tconst finalThumbnails = await fse.readdir(thumbnailDir)\n\n\t\t// Number of thumbnails should be equal to our test maxThumbnailCount\n\t\texpect(finalThumbnails.length).toBe(20)\n\t})\n\n\ttest('removes excess thumbnails on startup', {retry: 5}, async () => {\n\t\t// Stop umbreld\n\t\tawait umbreld.instance.stop()\n\n\t\t// Set a lower maxThumbnailCount for testing\n\t\tconst maxThumbnailCount = 20\n\t\tumbreld.instance.files.thumbnails.maxThumbnailCount = maxThumbnailCount\n\n\t\t// Create the thumbnails directory if it doesn't exist already\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\t\tawait fse.ensureDir(thumbnailDir)\n\n\t\t// Create excess dummy thumbnail files (e.g., 40 files, which is double the max for this test)\n\t\tconst totalThumbnailCount = maxThumbnailCount * 2\n\n\t\t// Create timestamps with increasing age to ensure deterministic pruning\n\t\tconst now = Date.now()\n\n\t\t// Create dummy thumbnail files with controlled timestamps\n\t\tfor (let i = 0; i < totalThumbnailCount; i++) {\n\t\t\tconst thumbnailPath = `${thumbnailDir}/dummy-${i.toString().padStart(3, '0')}.webp`\n\t\t\tawait fse.writeFile(thumbnailPath, 'dummy thumbnail content')\n\n\t\t\t// Set file timestamps - older files will be removed first\n\t\t\t// First half (0-19) will be older, second half (20-39) will be newer\n\t\t\tconst fileTime = new Date(now - (totalThumbnailCount - i) * 1000)\n\t\t\tawait fse.utimes(thumbnailPath, fileTime, fileTime)\n\t\t}\n\n\t\t// Verify we have the expected number of dummy thumbnails\n\t\tconst initialThumbnails = await fse.readdir(thumbnailDir)\n\t\texpect(initialThumbnails.length).toBe(totalThumbnailCount)\n\n\t\t// Now start the umbrel instance - this should trigger the cleanup on startup\n\t\tawait umbreld.instance.start()\n\n\t\t// Wait for cleanup to complete and verify\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\tconst thumbnails = await fse.readdir(thumbnailDir)\n\t\t\t\treturn thumbnails.length <= maxThumbnailCount\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: 'Startup cleanup did not complete within timeout period',\n\t\t\t},\n\t\t)\n\n\t\t// Get final thumbnail count\n\t\tconst finalThumbnails = await fse.readdir(thumbnailDir)\n\n\t\t// Number of thumbnails should be equal to maxThumbnailCount\n\t\texpect(finalThumbnails.length).toBe(maxThumbnailCount)\n\n\t\t// Verify the newer thumbnails were kept\n\t\t// The thumbnails with higher indices in their names should be kept\n\t\t// (these were the ones with newer timestamps)\n\t\tfor (const thumbnail of finalThumbnails) {\n\t\t\t// Extract the index from the filename\n\t\t\tconst match = thumbnail.match(/dummy-(\\d+)\\.webp/)\n\t\t\tif (match) {\n\t\t\t\tconst index = parseInt(match[1], 10)\n\t\t\t\t// All kept thumbnails should be from the newer half (indices 20-39)\n\t\t\t\texpect(index).toBeGreaterThanOrEqual(totalThumbnailCount - maxThumbnailCount)\n\t\t\t}\n\t\t}\n\t})\n})\n\ndescribe('Queue selection', () => {\n\ttest('uses background queue for watcher events and on-demand queue for explicit requests', {retry: 5}, async () => {\n\t\t// Create test directory\n\t\tconst testDir = `${umbreld.instance.dataDirectory}/home/thumbnail-queue-test`\n\t\tawait fse.ensureDir(testDir)\n\n\t\t// Track queue usage by spying on both queue's add method\n\t\tconst thumbnailsInstance = umbreld.instance.files.thumbnails\n\t\tconst backgroundAddSpy = vi.spyOn(thumbnailsInstance.backgroundQueue, 'add')\n\t\tconst onDemandAddSpy = vi.spyOn(thumbnailsInstance.onDemandQueue, 'add')\n\n\t\t// Reset spy counts before test\n\t\tbackgroundAddSpy.mockClear()\n\t\tonDemandAddSpy.mockClear()\n\n\t\t// PART 1: Test background queue is used for file watcher events\n\n\t\t// Copy the image file to trigger background watcher\n\t\tawait copyFixtureFile(testDir)\n\n\t\t// Wait for the watcher to pick up the file and process it\n\t\tawait pollUntil(\n\t\t\tasync () => {\n\t\t\t\treturn backgroundAddSpy.mock.calls.length > 0\n\t\t\t},\n\t\t\t{\n\t\t\t\ttimeoutMs: 15000,\n\t\t\t\terrorMessage: 'Background queue was not used for watcher-triggered thumbnail',\n\t\t\t},\n\t\t)\n\n\t\t// Verify background queue was used, but on-demand queue was not\n\t\texpect(backgroundAddSpy).toHaveBeenCalled()\n\t\texpect(onDemandAddSpy).not.toHaveBeenCalled()\n\n\t\t// Reset spy counts for second part of test\n\t\tbackgroundAddSpy.mockClear()\n\t\tonDemandAddSpy.mockClear()\n\n\t\t// PART 2: Test on-demand queue is used for explicit thumbnail requests\n\n\t\t// delete the single thumbnail\n\t\tconst thumbnailDir = `${umbreld.instance.dataDirectory}/thumbnails`\n\t\tlet thumbnails = await fse.readdir(thumbnailDir)\n\t\tawait fse.remove(`${thumbnailDir}/${thumbnails[0]}`)\n\n\t\t// Verify that thumbnails dir is empty\n\t\tthumbnails = await fse.readdir(thumbnailDir)\n\t\texpect(thumbnails.length).toBe(0)\n\n\t\t// request the thumbnail via the API\n\t\tconst virtualPath = '/Home/thumbnail-queue-test/master-lossless-image.png'\n\t\tawait umbreld.client.files.getThumbnail.mutate({path: virtualPath})\n\n\t\t// Verify that the thumbnail was generated\n\t\tthumbnails = await fse.readdir(thumbnailDir)\n\t\texpect(thumbnails.length).toBe(1)\n\n\t\t// Verify that the thumbnail was generated in the on-demand queue\n\t\texpect(onDemandAddSpy).toHaveBeenCalled()\n\t\texpect(backgroundAddSpy).not.toHaveBeenCalled()\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/thumbnails.ts",
    "content": "import nodePath from 'node:path'\nimport crypto from 'node:crypto'\nimport os from 'node:os'\n\nimport fse from 'fs-extra'\nimport {$} from 'execa'\nimport PQueue from 'p-queue'\nimport {debounce, type DebouncedFunction} from 'es-toolkit'\n\nimport type Umbreld from '../../index.js'\nimport type {FileChangeEvent} from './watcher.js'\nimport {getDirectoryStream} from './files.js'\n\n// TODO: Add support for .heic files\n// ImageMagick 6.9.11-60 (latest version available in Debian apt repos) supports older heic files but does not support more recent heic files which are now common on most devices\nconst SUPPORTED_THUMBNAIL_EXTENSIONS = [\n\t// Image formats\n\t'.webp',\n\t'.png',\n\t'.jpg',\n\t'.jpeg',\n\t'.gif',\n\t'.avif',\n\t// Video formats\n\t'.mkv',\n\t'.mov',\n\t'.mp4',\n\t'.3gp',\n\t'.avi',\n]\n\nexport default class Thumbnails {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\tthumbnailDirectory: string\n\t// Maximum number of thumbnails to keep on disk\n\tmaxThumbnailCount = 100000\n\t// Trigger cleanup when this many new thumbnails are generated; counter resets after cleanup\n\tpruningThreshold = 1000\n\tthumbnailsSinceLastPruning = 0\n\t// Thumbnail properties - optimized for UI display sizes (20px list view, 56px icons view)\n\t// 112px = 2x the largest UI size for high-DPI displays; 75% quality balances size/quality for webp\n\twidth = 112\n\theight = 112\n\tquality = 75\n\tformat = 'webp'\n\t// The queue for background thumbnail generation that occurs on file change\n\tbackgroundQueue = new PQueue({concurrency: 1})\n\t// The queue for on-demand thumbnail generation.\n\t// We use a concurrency equal to the number of CPU threads to generate thumbnails relatively quickly without overloading the CPU\n\tonDemandQueue = new PQueue({concurrency: os.cpus().length})\n\t// The queue for filesystem UUID lookup requests\n\tfilesystemUuidQueue = new PQueue({concurrency: 1})\n\tdeviceIdtoUuidMap = new Map<number, string>()\n\t// Map to store debounced background thumbnail tasks per filepath\n\t#backgroundThumbnailDebouncers = new Map<string, DebouncedFunction<() => Promise<void>>>()\n\t#removeFileChangeListener?: () => void\n\t#removeDiskChangeListener?: () => void\n\t#isPruning = false\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(`files:${name.toLowerCase()}`)\n\t\tthis.thumbnailDirectory = `${umbreld.dataDirectory}/thumbnails`\n\t}\n\n\tasync start() {\n\t\tthis.logger.log('Starting thumbnails')\n\n\t\t// Ensure thumbnail directory exists\n\t\tawait fse.ensureDir(this.thumbnailDirectory).catch((error) => {\n\t\t\tthis.logger.error(`Failed to ensure directory '${this.thumbnailDirectory}' exists`, error)\n\t\t})\n\n\t\t// TODO: Enable PDF support in ImageMagick in a safe way\n\n\t\t// Initial non-blocking cleanup on startup\n\t\tthis.#pruneOldestThumbnails()\n\n\t\t// Attach listener for file changes\n\t\tthis.#removeFileChangeListener = this.#umbreld.eventBus.on(\n\t\t\t'files:watcher:change',\n\t\t\tthis.#handleFileChange.bind(this),\n\t\t)\n\n\t\t// Attach disk change listener so the UUID cache is cleared when a disk is mounted\n\t\tthis.#removeDiskChangeListener = this.#umbreld.eventBus.on('system:disk:change', () =>\n\t\t\tthis.deviceIdtoUuidMap.clear(),\n\t\t)\n\t}\n\n\t// The debounced background thumbnail generation task for a given systemPath.\n\t// Uses #backgroundThumbnailDebouncers map for per-path debouncing.\n\t// Calling this ensures the actual #generateThumbnail task runs only after 1 second of file change inactivity for the path.\n\t// This avoids rapid regeneration of invalid files while they are in the process of being written.\n\t// Includes self-cleanup from the map.\n\t#debouncedGenerateThumbnail(systemPath: string): void {\n\t\tlet debouncer = this.#backgroundThumbnailDebouncers.get(systemPath)\n\n\t\tif (!debouncer) {\n\t\t\tconst generateThumbnailAndCleanup = async () => {\n\t\t\t\t// Destroy the debouncer now that it's fired to avoid memory leaks\n\t\t\t\tthis.#backgroundThumbnailDebouncers.delete(systemPath)\n\n\t\t\t\t// Generate the thumbnail\n\t\t\t\tawait this.#generateThumbnail(systemPath, {background: true}).catch((error) => {\n\t\t\t\t\t// We catch errors here to prevent unhandled rejections, since this debounced function runs later and outside the original call context.\n\t\t\t\t\tthis.logger.error(`Failed to generate thumbnail for ${systemPath}`, error)\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// Create a debounced version with a 1-second delay\n\t\t\tdebouncer = debounce(generateThumbnailAndCleanup, 1000)\n\n\t\t\t// Store the debouncer in the map by file path\n\t\t\tthis.#backgroundThumbnailDebouncers.set(systemPath, debouncer)\n\t\t}\n\n\t\tthis.logger.verbose(`Debouncing thumbnail generation for ${systemPath}`)\n\t\tdebouncer()\n\t}\n\n\t// Handle file change events from watcher\n\tasync #handleFileChange(event: FileChangeEvent) {\n\t\tconst systemPath = event.path\n\n\t\t// Skip directories and file types that are not supported for thumbnails\n\t\tif (!(await this.#isValidFileForThumbnail(systemPath))) return\n\n\t\t// Only handle create and update events\n\t\t// We don't need to handle delete events. We could explicitly remove the thumbnail here, but the LRU cleanup job will remove the thumbnail eventually.\n\t\t// We don't need to handle rename or move events because we name the thumbnail based on the file's inode, filesystem ID, and date modified, which don't change when the file is renamed or moved.\n\t\tif (event.type === 'create' || event.type === 'update') {\n\t\t\t// Generate the thumbnail in the background queue\n\t\t\t// We use a debouncer to prevent multiple thumbnail creations for the same file when it is being actively written to (e.g., during upload, unarchiving, etc)\n\t\t\tthis.#debouncedGenerateThumbnail(systemPath)\n\t\t}\n\t}\n\n\t// Check if a file type is supported for thumbnails\n\tasync #isValidFileForThumbnail(systemPath: string): Promise<boolean> {\n\t\t// Check if file has a supported extension\n\t\tconst ext = nodePath.extname(systemPath).toLowerCase()\n\t\tif (!SUPPORTED_THUMBNAIL_EXTENSIONS.includes(ext)) return false\n\n\t\t// Sanity check to make sure it exists and is actually a file (not a directory)\n\t\tconst stats = await fse.stat(systemPath).catch(() => null)\n\t\treturn Boolean(stats?.isFile())\n\t}\n\n\t// Gets the unique, persistent identifier for the filesystem that contains a file\n\t// This will not change across reboots or device reassignments\n\t// We return an empty string for filesystems that don't support a uuid (e.g., docker bind mounts, fs overlays, network shares)\n\tasync getFilesystemUuid(systemPath: string, deviceId: number): Promise<string> {\n\t\t// If we have a cached UUID for this device, return it\n\t\tconst cachedUuid = this.deviceIdtoUuidMap.get(deviceId)\n\t\tif (cachedUuid) return cachedUuid\n\n\t\t// If we don't have a cached UUID, we need to get the UUID for the filesystem.\n\t\t// This is added to a queue to prevent spawning too many processes in parallel.\n\t\tconst {stdout} = await this.filesystemUuidQueue.add(\n\t\t\t() => $`findmnt --noheadings --output UUID --target ${systemPath}`,\n\t\t)\n\n\t\t// We set uuid to the actual UUID if it was found or an empty string if the filesystem doesn't support a uuid\n\t\t// If no uuid is found, stdout will already be an empty string\n\t\tconst uuid = stdout.trim()\n\n\t\t// Cache the UUID for this device\n\t\t// If no uuid is found, this will cache an empty string as the value\n\t\tthis.deviceIdtoUuidMap.set(deviceId, uuid)\n\n\t\treturn uuid\n\t}\n\n\t// Returns a hash from a combination of source file metadata that we can use as a unique thumbnail filename\n\tasync getThumbnailHash(systemPath: string): Promise<string> {\n\t\t// Get source file's metadata\n\t\tconst stats = await fse.stat(systemPath)\n\n\t\t// Get the numeric identity of the device where the file is stored\n\t\t// This value can change across system reboots or device reassignments so we can't use it directly as a persistent identifier\n\t\tconst deviceId = stats.dev\n\n\t\t// Convert the device ID to a persistent filesystem UUID\n\t\t// This UUID stays the same regardless of how the filesystem is mounted (i.e., it is consistent across system reboots and device reassignments)\n\t\tconst uuid = await this.getFilesystemUuid(systemPath, deviceId)\n\n\t\t// Create a unique identifier by combining:\n\t\t// - The filesystem's UUID (stays consistent across reboots and device reassignments)\n\t\t// - The file's inode number (stays the same if moved/renamed on same filesystem)\n\t\t// - The file's modification time (changes when content is modified)\n\t\tconst identifier = `${uuid}-${stats.ino}-${stats.mtime.getTime()}`\n\n\t\tconst hash = crypto.createHash('sha256').update(identifier).digest('hex')\n\n\t\treturn hash\n\t}\n\n\t// Get thumbnail system path for a file by its hash\n\thashToThumbnailSystemPath(hash: string): string {\n\t\tconst filename = `${hash}.${this.format}`\n\t\tconst systemPath = nodePath.normalize(nodePath.join(this.thumbnailDirectory, filename))\n\n\t\treturn systemPath\n\t}\n\n\t// TODO: Look into using sharp instead of ImageMagick for performance gains at the possible cost of simplicity\n\t// Generate a thumbnail for a file\n\tasync #generateThumbnail(systemPath: string, {background = true}: {background?: boolean} = {}): Promise<string> {\n\t\tconst hash = await this.getThumbnailHash(systemPath)\n\n\t\t// Check if thumbnail already exists\n\t\t// If it does, it means we don't need to generate a new one\n\t\tconst thumbnailSystemPath = this.hashToThumbnailSystemPath(hash)\n\t\tif (await fse.pathExists(thumbnailSystemPath)) return hash\n\n\t\t// Process through a queue to prevent spawning too many thumbnail generation processes in parallel\n\t\t// We use the background queue for file watcher events and the on-demand queue for explicit requests\n\t\tconst queue = background ? this.backgroundQueue : this.onDemandQueue\n\n\t\t// Passing [0] to ImageMagick selects only the first frame/page (videos, PDFs, etc.)\n\t\t// This flag is ignored for regular images, so we always include it for simplicity\n\t\tawait queue.add(async () => {\n\t\t\tthis.logger.verbose(`Generating thumbnail for ${systemPath}`)\n\t\t\tawait $`convert ${systemPath}[0] -resize ${this.width}x${this.height} -quality ${this.quality} -auto-orient ${thumbnailSystemPath}`\n\t\t})\n\n\t\t// Count generated thumbnails and trigger cleanup if needed\n\t\t// Cleanup is non-blocking and will run in the background\n\t\tthis.thumbnailsSinceLastPruning++\n\t\tif (this.thumbnailsSinceLastPruning >= this.pruningThreshold) {\n\t\t\tthis.thumbnailsSinceLastPruning = 0\n\t\t\tthis.#pruneOldestThumbnails()\n\t\t}\n\n\t\treturn hash\n\t}\n\n\t// Gets a thumbnail hash for a file on demand (generating a thumbnail if needed)\n\t// This is used by the files.getThumbnail() trpc endpoint\n\tasync getThumbnailOnDemand(virtualPath: string): Promise<string> {\n\t\t// First validate the path and check if thumbnail type is supported\n\t\tconst systemPath = await this.#umbreld.files.virtualToSystemPath(virtualPath)\n\t\tif (!(await this.#isValidFileForThumbnail(systemPath))) {\n\t\t\tthrow new Error(`Unsupported file type for thumbnail: ${nodePath.extname(virtualPath).toLowerCase()}`)\n\t\t}\n\n\t\t// Generate the thumbnail in the on-demand queue\n\t\tconst hash = await this.#generateThumbnail(systemPath, {background: false})\n\n\t\t// Return the relative api endpoint URL of the thumbnail\n\t\treturn `/api/files/thumbnail/${hash}.${this.format}`\n\t}\n\n\t// Get an existing thumbnail if it exists, without generating a new one\n\t// This is used by this.umbreld.files.status() to attach an existing thumbnail to a file (as an api endpoint URL)\n\t// We pass in a safe system path from files.status()\n\tasync getExistingThumbnail(systemPath: string): Promise<string | undefined> {\n\t\t// Check if thumbnail type is supported\n\t\tif (!(await this.#isValidFileForThumbnail(systemPath))) return undefined\n\n\t\t// Return undefined if no thumbnail exists\n\t\tconst hash = await this.getThumbnailHash(systemPath)\n\t\tconst thumbnailSystemPath = this.hashToThumbnailSystemPath(hash)\n\t\tconst exists = await fse.pathExists(thumbnailSystemPath)\n\t\tif (!exists) return undefined\n\n\t\t// We set the thumbnail's date modified to the current time to bump it in the LRU cache\n\t\tconst now = new Date()\n\t\tawait fse.utimes(thumbnailSystemPath, now, now).catch((error) => {\n\t\t\t// Even if updating the date modified fails, the thumbnail is still valid and we should return the hash\n\t\t\tthis.logger.error(`Failed to touch thumbnail ${thumbnailSystemPath}`, error)\n\t\t})\n\n\t\t// Return the relative api endpoint URL of the thumbnail\n\t\treturn `/api/files/thumbnail/${hash}.${this.format}`\n\t}\n\n\t// Delete oldest thumbnails if we exceed the maxThumbnailCount\n\tasync #pruneOldestThumbnails(): Promise<void> {\n\t\t// Skip if a pruning operation is already in progress\n\t\tif (this.#isPruning) return\n\n\t\ttry {\n\t\t\tthis.#isPruning = true\n\t\t\tthis.logger.log('Pruning oldest thumbnails')\n\n\t\t\tconst thumbnails: {path: string; mtime: number}[] = []\n\t\t\tlet initialThumbnailCount = 0\n\n\t\t\t// We open an async iterator to the thumbnails directory so we can stream a large directory and not process the entire directory in memory all at once\n\t\t\tfor await (const thumbnailPath of getDirectoryStream(this.thumbnailDirectory)) {\n\t\t\t\tinitialThumbnailCount++\n\n\t\t\t\t// stat each file serially\n\t\t\t\tconst stats = await fse.stat(thumbnailPath).catch((error) => {\n\t\t\t\t\tthis.logger.error(`Failed to stat thumbnail ${thumbnailPath}`, error)\n\n\t\t\t\t\t// If we can't stat a file, we can't process it.\n\t\t\t\t\treturn undefined\n\t\t\t\t})\n\n\t\t\t\tif (!stats) continue\n\t\t\t\tthumbnails.push({path: thumbnailPath, mtime: stats.mtime.getTime()})\n\t\t\t}\n\n\t\t\t// Skip pruning if we are under the maxThumbnailCount\n\t\t\t// We can't check this until we've streamed the entire directory. So we need to do the relatively expensive stat() for each file, but we'll skip the blocking sort() and the removal task if we're under the limit.\n\t\t\tif (initialThumbnailCount <= this.maxThumbnailCount) {\n\t\t\t\tthis.logger.log(\n\t\t\t\t\t`Thumbnail cache has ${initialThumbnailCount}/${this.maxThumbnailCount} thumbnails. No pruning needed`,\n\t\t\t\t)\n\t\t\t\t// The outer 'finally' block will still set #isPruning = false.\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Sort successfully stat'd thumbnails by modification time (oldest first)\n\t\t\tthumbnails.sort((a, b) => a.mtime - b.mtime)\n\n\t\t\t// Calculate how many we need to remove based on the initial count vs. the limit\n\t\t\tconst excessCount = initialThumbnailCount - this.maxThumbnailCount\n\t\t\tlet filesRemoved = 0\n\n\t\t\t// Remove oldest thumbnails (from the successfully stat-ed list) until we're under the limit\n\t\t\t// Iterate up to excessCount, but stop if we run out of stat-ed thumbnails\n\t\t\tfor (let i = 0; i < excessCount && i < thumbnails.length; i++) {\n\t\t\t\tconst thumbnail = thumbnails[i]\n\t\t\t\ttry {\n\t\t\t\t\tawait fse.remove(thumbnail.path)\n\t\t\t\t\tfilesRemoved++\n\t\t\t\t} catch (error) {\n\t\t\t\t\tthis.logger.error(`Failed to remove thumbnail ${thumbnail.path}`, error)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthis.logger.log(\n\t\t\t\t`Removed ${filesRemoved} thumbnails. The thumbnail cache is now at ${initialThumbnailCount - filesRemoved}/${this.maxThumbnailCount}`,\n\t\t\t)\n\t\t} catch (error) {\n\t\t\t// We just log and don't rethrow here\n\t\t\tthis.logger.error(`Failed to clean up thumbnails`, error)\n\t\t} finally {\n\t\t\t// We reset the pruning flag regardless of whether the operation succeeded or failed\n\t\t\tthis.#isPruning = false\n\t\t}\n\t}\n\n\t// Remove listeners\n\t// Any queued thumbnail generations will be cancelled. These would be generated on-demand in the future or in the background if the file were to be modified again.\n\tasync stop() {\n\t\tthis.logger.log('Stopping thumbnails')\n\t\tthis.#removeFileChangeListener?.()\n\t\tthis.#removeDiskChangeListener?.()\n\n\t\t// Cancel debounced background thumbnail tasks\n\t\tthis.#backgroundThumbnailDebouncers.forEach((debouncer) => debouncer.cancel())\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/watcher.ts",
    "content": "import Emittery from 'emittery'\n\nimport watcher from '@parcel/watcher'\nimport {$} from 'execa'\n\nimport type Umbreld from '../../index.js'\n\nexport type FileChangeEvent = watcher.Event\n\nexport default class Watcher {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\t#emitter = new Emittery()\n\tsubscriptions: Map<string, watcher.AsyncSubscription> = new Map()\n\tpathsToWatch: Set<string>\n\n\tconstructor(umbreld: Umbreld, {paths}: {paths: string[]}) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(`files:${name.toLocaleLowerCase()}`)\n\t\tthis.pathsToWatch = new Set(paths)\n\t}\n\n\t// Setup inotify settings and start watchers\n\tasync start() {\n\t\tthis.logger.log('Starting files watcher')\n\n\t\t// Set system inotify limits\n\t\t// https://facebook.github.io/watchman/docs/install#linux-inotify-limits\n\t\tthis.logger.log('Setting system inotify limits')\n\t\t// How many root directories can be watched\n\t\tawait $`sysctl fs.inotify.max_user_instances=256`.catch((error) =>\n\t\t\tthis.logger.error(`Failed to set max user instances`, error),\n\t\t)\n\t\t// How many directories can be watched across all watched roots\n\t\tawait $`sysctl fs.inotify.max_user_watches=122404`.catch((error) =>\n\t\t\tthis.logger.error(`Failed to set max user watches`, error),\n\t\t)\n\t\t// How many events can be queued (smaller number = more likely to have notification overflow)\n\t\tawait $`sysctl fs.inotify.max_queued_events=16384`.catch((error) =>\n\t\t\tthis.logger.error(`Failed to set max queued events`, error),\n\t\t)\n\n\t\t// Watch all paths in the pathsToWatch set\n\t\tfor (const virtualPath of this.pathsToWatch) await this.watch(virtualPath)\n\t}\n\n\t// Watch a virtual path\n\tasync watch(virtualPath: string) {\n\t\ttry {\n\t\t\tconst systemPath = await this.#umbreld.files.virtualToSystemPath(virtualPath)\n\t\t\tconst subscription = await watcher.subscribe(systemPath, (error, events) => {\n\t\t\t\tif (error) return this.logger.error(`Failed to watch directory '${virtualPath}'`, error)\n\t\t\t\tfor (const event of events) this.#umbreld.eventBus.emit('files:watcher:change', event)\n\t\t\t})\n\t\t\tthis.subscriptions.set(virtualPath, subscription)\n\t\t\tthis.logger.log(`Started watching directory '${virtualPath}'`)\n\t\t\treturn true\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to watch directory '${virtualPath}'`, error)\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Stop watchers\n\tasync stop() {\n\t\tfor (const [virtualPath, subscription] of this.subscriptions.entries()) {\n\t\t\tthis.logger.log(`Stopping watcher for directory '${virtualPath}'`)\n\t\t\ttry {\n\t\t\t\tawait subscription.unsubscribe()\n\t\t\t\tthis.subscriptions.delete(virtualPath)\n\t\t\t\tthis.logger.log(`Stopped watching directory '${virtualPath}'`)\n\t\t\t} catch (error) {\n\t\t\t\tthis.logger.error(`Failed to unsubscribe from directory`, error)\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/files/widgets.ts",
    "content": "import type Umbreld from '../../index.js'\n\nexport const filesWidgets = {\n\t'files-recents': async function (umbreld: Umbreld) {\n\t\tconst recentFiles = await umbreld.files.recents.get()\n\n\t\treturn {\n\t\t\ttype: 'files-list',\n\t\t\tlink: '/files/Recents',\n\t\t\trefresh: '5s',\n\t\t\titems: recentFiles.slice(0, 3),\n\t\t\tnoItemsText: 'files-widgets.recents.no-items-text',\n\t\t}\n\t},\n\n\t'files-favorites': async function (umbreld: Umbreld) {\n\t\tconst favorites = await umbreld.files.favorites.listFavorites()\n\n\t\treturn {\n\t\t\ttype: 'files-grid',\n\t\t\trefresh: '30s',\n\t\t\tpaths: favorites.slice(0, 4),\n\t\t\tnoItemsText: 'files-widgets.favorites.no-items-text',\n\t\t}\n\t},\n} as const\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/hardware.ts",
    "content": "import type Umbreld from '../../index.js'\n\nimport InternalStorage from './internal-storage.js'\nimport Raid from './raid.js'\nimport UmbrelPro from './umbrel-pro.js'\n\nexport default class Hardware {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\tinternalStorage: InternalStorage\n\traid: Raid\n\tumbrelPro: UmbrelPro\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(name.toLowerCase())\n\n\t\tthis.internalStorage = new InternalStorage(umbreld)\n\t\tthis.raid = new Raid(umbreld)\n\t\tthis.umbrelPro = new UmbrelPro(umbreld)\n\t}\n\n\tasync start() {\n\t\tthis.logger.log('Starting hardware')\n\n\t\t// Start submodules\n\t\tawait Promise.all([\n\t\t\tthis.internalStorage.start().catch((error) => this.logger.error('Failed to start internal storage', error)),\n\t\t\tthis.raid.start().catch((error) => this.logger.error('Failed to start RAID', error)),\n\t\t\tthis.umbrelPro.start().catch((error) => this.logger.error('Failed to start Umbrel Pro', error)),\n\t\t])\n\t}\n\n\tasync stop() {\n\t\tthis.logger.log('Stopping hardware')\n\n\t\t// Stop submodules\n\t\tawait Promise.all([\n\t\t\tthis.internalStorage.stop().catch((error) => this.logger.error('Failed to stop internal storage', error)),\n\t\t\tthis.raid.stop().catch((error) => this.logger.error('Failed to stop RAID', error)),\n\t\t\tthis.umbrelPro.stop().catch((error) => this.logger.error('Failed to stop Umbrel Pro', error)),\n\t\t])\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/internal-storage-rounding.vm.test.ts",
    "content": "import {expect, beforeAll, afterAll, describe, test} from 'vitest'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\n\ndescribe('Internal storage rounded size', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\n\t// Test sizes in bytes\n\tconst GB_512 = '512000000000'\n\tconst TB_1_EXACT = '1000000000000'\n\tconst TB_4_PHISON = '4096805658624' // Larger Phison SSD\n\tconst TB_4_SAMSUNG = '4000787030016' // Smaller Samsung SSD\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\ttest('adds NVMe devices with various sizes and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1, size: GB_512})\n\t\tawait umbreld.vm.addNvme({slot: 2, size: TB_1_EXACT})\n\t\tawait umbreld.vm.addNvme({slot: 3, size: TB_4_PHISON})\n\t\tawait umbreld.vm.addNvme({slot: 4, size: TB_4_SAMSUNG})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('returns correct rounded sizes for all devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(4)\n\n\t\t// Find devices by slot\n\t\tconst slot1 = devices.find((d) => d.slot === 1)!\n\t\tconst slot2 = devices.find((d) => d.slot === 2)!\n\t\tconst slot3 = devices.find((d) => d.slot === 3)!\n\t\tconst slot4 = devices.find((d) => d.slot === 4)!\n\n\t\t// 512GB - under 1TB, should not be rounded\n\t\texpect(slot1.size).toBe(512_000_000_000)\n\t\texpect(slot1.roundedSize).toBe(512_000_000_000)\n\n\t\t// 1TB exact - should stay at 1TB\n\t\texpect(slot2.size).toBe(1_000_000_000_000)\n\t\texpect(slot2.roundedSize).toBe(1_000_000_000_000)\n\n\t\t// Phison 4TB (~4.097TB) - should round to 4TB\n\t\texpect(slot3.size).toBe(4_096_805_658_624)\n\t\texpect(slot3.roundedSize).toBe(4_000_000_000_000)\n\n\t\t// Samsung 4TB (~4.001TB) - should round to 4TB\n\t\texpect(slot4.size).toBe(4_000_787_030_016)\n\t\texpect(slot4.roundedSize).toBe(4_000_000_000_000)\n\t})\n\n\ttest('Phison and Samsung 4TB drives have matching rounded sizes', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\tconst slot3 = devices.find((d) => d.slot === 3)!\n\t\tconst slot4 = devices.find((d) => d.slot === 4)!\n\n\t\t// Different actual sizes\n\t\texpect(slot3.size).not.toBe(slot4.size)\n\n\t\t// But same rounded sizes for RAID compatibility\n\t\texpect(slot3.roundedSize).toBe(slot4.roundedSize)\n\t\texpect(slot3.roundedSize).toBe(4_000_000_000_000)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/internal-storage-slot-detection.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\n\ndescribe('Internal storage device detection', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\tafterAll(async () => await umbreld?.cleanup())\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('getDevices() returns empty array when no NVMe devices present', async () => {\n\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toEqual([])\n\t})\n\n\tfor (const slot of [1, 2, 3, 4]) {\n\t\ttest(`getDevices() detects NVMe device in slot ${slot}`, async () => {\n\t\t\t// Power off and add NVMe device to slot\n\t\t\tawait umbreld.vm.powerOff()\n\t\t\tawait umbreld.vm.addNvme({slot})\n\t\t\tawait umbreld.vm.powerOn()\n\n\t\t\t// Check NVMe device is detected in the correct slot\n\t\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\t\texpect(devices).toHaveLength(1)\n\t\t\texpect(devices[0].slot).toBe(slot)\n\n\t\t\t// Power off and remove NVMe device from slot\n\t\t\tawait umbreld.vm.powerOff()\n\t\t\tawait umbreld.vm.removeNvme({slot})\n\t\t})\n\t}\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/internal-storage.ts",
    "content": "import fse from 'fs-extra'\nimport nodePath from 'node:path'\n\nimport {$} from 'execa'\n\nimport type Umbreld from '../../index.js'\nimport {getRoundedDeviceSize} from './raid.js'\n\nfunction kelvinToCelsius(kelvin: number): number {\n\treturn kelvin - 273\n}\n\nexport type NvmeDevice = {\n\tdevice: string\n\tid?: string\n\tpciSlotNumber?: number\n\tslot?: number\n\tname: string\n\tmodel: string\n\tserial: string\n\tsize: number\n\troundedSize: number\n\ttemperature?: number\n\ttemperatureWarning?: number\n\ttemperatureCritical?: number\n\tlifetimeUsed?: number\n\tsmartStatus: 'healthy' | 'unhealthy' | 'unknown'\n}\n\ntype NvmeSmartData = {\n\ttemperature?: number\n\ttemperatureWarning?: number\n\ttemperatureCritical?: number\n\tlifetimeUsed?: number\n\tsmartStatus: 'healthy' | 'unhealthy' | 'unknown'\n}\n\n// Parse NVMe SMART log and controller identify data for temperature and health\nasync function getNvmeSmartData(devicePath: string): Promise<NvmeSmartData> {\n\ttry {\n\t\t// Get SMART log and controller identify data in parallel\n\t\tconst [smartLogResult, idCtrlResult] = await Promise.all([\n\t\t\t$`nvme smart-log ${devicePath} --output-format=json`,\n\t\t\t$`nvme id-ctrl ${devicePath} --output-format=json`,\n\t\t])\n\n\t\tconst smartData = JSON.parse(smartLogResult.stdout)\n\t\tconst idCtrlData = JSON.parse(idCtrlResult.stdout)\n\n\t\t// Temperature is reported in Kelvin, convert to Celsius\n\t\t// The field is typically 'temperature' or 'temperature_sensor_1'\n\t\tlet temperature: number | undefined\n\t\tif (typeof smartData.temperature === 'number') {\n\t\t\ttemperature = kelvinToCelsius(smartData.temperature)\n\t\t} else if (typeof smartData.temperature_sensor_1 === 'number') {\n\t\t\ttemperature = kelvinToCelsius(smartData.temperature_sensor_1)\n\t\t}\n\n\t\t// Get warning and critical temperature thresholds from controller identify\n\t\t// wctemp = Warning Composite Temperature Threshold (Kelvin)\n\t\t// cctemp = Critical Composite Temperature Threshold (Kelvin)\n\t\tlet temperatureWarning: number | undefined\n\t\tlet temperatureCritical: number | undefined\n\t\tif (typeof idCtrlData.wctemp === 'number' && idCtrlData.wctemp > 0) {\n\t\t\ttemperatureWarning = kelvinToCelsius(idCtrlData.wctemp)\n\t\t}\n\t\tif (typeof idCtrlData.cctemp === 'number' && idCtrlData.cctemp > 0) {\n\t\t\ttemperatureCritical = kelvinToCelsius(idCtrlData.cctemp)\n\t\t}\n\n\t\t// Get percent_used which indicates how much of the drive's rated endurance has been consumed\n\t\t// Values can exceed 100 if the drive has been used beyond its rated endurance\n\t\tlet lifetimeUsed: number | undefined\n\t\tif (typeof smartData.percent_used === 'number') {\n\t\t\tlifetimeUsed = smartData.percent_used\n\t\t}\n\n\t\t// Check critical warning flags for health status\n\t\t// critical_warning is a bitmask: 0 means healthy\n\t\tconst smartStatus = smartData.critical_warning === 0 ? 'healthy' : 'unhealthy'\n\n\t\treturn {temperature, temperatureWarning, temperatureCritical, lifetimeUsed, smartStatus}\n\t} catch {\n\t\treturn {smartStatus: 'unknown'}\n\t}\n}\n\n// Get the disk/by-id name for a device\n// These paths are more stable than the device name which ddepedns on enumeration order.\nasync function getDeviceId(deviceName: string): Promise<string | undefined> {\n\tconst byIdDir = '/dev/disk/by-id'\n\ttry {\n\t\tconst entries = await fse.readdir(byIdDir)\n\t\tconst matchingIds: string[] = []\n\n\t\tfor (const entry of entries) {\n\t\t\ttry {\n\t\t\t\t// Skip partition entries (they end with -partN)\n\t\t\t\tif (/-part\\d+$/.test(entry)) continue\n\n\t\t\t\tconst fullPath = nodePath.join(byIdDir, entry)\n\t\t\t\tconst target = await fse.readlink(fullPath)\n\t\t\t\tconst resolvedTarget = nodePath.resolve(byIdDir, target)\n\n\t\t\t\tif (resolvedTarget === `/dev/${deviceName}`) matchingIds.push(entry)\n\t\t\t} catch {\n\t\t\t\t// Skip entries that can't be resolved\n\t\t\t}\n\t\t}\n\n\t\tif (matchingIds.length === 0) return undefined\n\n\t\t// Sort by preference order, then alphabetically for determinism\n\t\t// Preference order for by-id names (lower index = higher preference)\n\t\t// We prefer descriptive names with model/serial over opaque identifiers like eui\n\t\tconst preferences = [\n\t\t\t/^nvme-eui\\./,\n\t\t\t/^nvme-nvme\\./,\n\t\t\t/^nvme-(?!eui\\.|nvme\\.)/, // nvme- but not nvme-eui. or nvme-nvme.\n\t\t]\n\t\tmatchingIds.sort((a, b) => {\n\t\t\tconst aIndex = preferences.findIndex((pattern) => pattern.test(a))\n\t\t\tconst bIndex = preferences.findIndex((pattern) => pattern.test(b))\n\t\t\t// -1 means no match, treat as lowest priority\n\t\t\tconst aPriority = aIndex === -1 ? preferences.length : aIndex\n\t\t\tconst bPriority = bIndex === -1 ? preferences.length : bIndex\n\t\t\tif (aPriority !== bPriority) return aPriority - bPriority\n\t\t\treturn a.localeCompare(b)\n\t\t})\n\n\t\treturn matchingIds[0]\n\t} catch {\n\t\t// Directory might not exist or be readable\n\t}\n\treturn undefined\n}\n\n// Get the PCIe Physical Slot Number for an NVMe device from its parent root port\n// This is read from the Slot Capabilities register via lspci and is a stable identifier\n// for the physical pci slot.\nasync function getDevicePciSlotNumber(deviceName: string): Promise<number | undefined> {\n\ttry {\n\t\t// deviceName is like \"nvme0n1\", we need \"nvme0\" for the controller\n\t\tconst controllerName = deviceName.replace(/n\\d+$/, '')\n\t\tconst sysfsPath = `/sys/class/nvme/${controllerName}/device`\n\n\t\t// Resolve the symlink to get the full device path\n\t\t// e.g., /sys/devices/pci0000:00/0000:00:1c.0/0000:01:00.0\n\t\tconst devicePath = await fse.realpath(sysfsPath)\n\n\t\t// Extract the root port address from the path (second PCI address component)\n\t\t// Path format: /sys/devices/pci0000:00/0000:00:1c.0/0000:01:00.0\n\t\tconst match = devicePath.match(/(0000:00:[0-9a-f]+\\.[0-9a-f]+)\\//)\n\t\tif (!match) return undefined\n\n\t\tconst rootPortAddress = match[1]\n\n\t\t// Use lspci to get the Physical Slot Number from Slot Capabilities\n\t\tconst {stdout} = await $`lspci -vvs ${rootPortAddress}`\n\t\tconst slotMatch = stdout.match(/Slot #(\\d+)/)\n\t\tif (slotMatch) return parseInt(slotMatch[1], 10)\n\t} catch {\n\t\t// Device might not exist or lspci might fail\n\t}\n\treturn undefined\n}\n\n// Get all NVMe devices using lsblk and nvme commands\nexport async function getNvmeDevices(): Promise<NvmeDevice[]> {\n\ttype LsBlkDevice = {\n\t\tname: string\n\t\tmodel?: string\n\t\tserial?: string\n\t\tsize?: number\n\t\ttype?: string\n\t\ttran?: string\n\t}\n\n\tconst {stdout} = await $`lsblk --output NAME,MODEL,SERIAL,SIZE,TYPE,TRAN --json --bytes`\n\tconst {blockdevices} = JSON.parse(stdout) as {blockdevices: LsBlkDevice[]}\n\n\t// Filter to only NVMe disk devices\n\tconst nvmeBlockDevices = blockdevices.filter((device) => device.type === 'disk' && device.tran === 'nvme')\n\n\t// Fetch SMART data, device IDs, and PCIe slot numbers for all devices in parallel\n\tconst nvmeDevices = await Promise.all(\n\t\tnvmeBlockDevices.map(async (device) => {\n\t\t\tconst devicePath = `/dev/${device.name}`\n\t\t\tconst [smartData, id, pciSlotNumber] = await Promise.all([\n\t\t\t\tgetNvmeSmartData(devicePath),\n\t\t\t\tgetDeviceId(device.name).catch(() => undefined),\n\t\t\t\tgetDevicePciSlotNumber(device.name).catch(() => undefined),\n\t\t\t])\n\n\t\t\tconst size = device.size ?? 0\n\t\t\treturn {\n\t\t\t\tdevice: device.name,\n\t\t\t\tid,\n\t\t\t\tpciSlotNumber,\n\t\t\t\tname: device.model?.trim() ?? 'Unknown NVMe Device',\n\t\t\t\tmodel: device.model?.trim() ?? 'Unknown',\n\t\t\t\tserial: device.serial?.trim() ?? 'Unknown',\n\t\t\t\tsize,\n\t\t\t\troundedSize: getRoundedDeviceSize(size),\n\t\t\t\ttemperature: smartData.temperature,\n\t\t\t\ttemperatureWarning: smartData.temperatureWarning,\n\t\t\t\ttemperatureCritical: smartData.temperatureCritical,\n\t\t\t\tlifetimeUsed: smartData.lifetimeUsed,\n\t\t\t\tsmartStatus: smartData.smartStatus,\n\t\t\t}\n\t\t}),\n\t)\n\n\t// Sort by id\n\treturn nvmeDevices.sort((a, b) => (a.id ?? '').localeCompare(b.id ?? ''))\n}\n\nexport default class InternalStorage {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(`hardware:${name.toLowerCase()}`)\n\t}\n\n\tasync start() {\n\t\tthis.logger.log('Starting internal storage')\n\t}\n\n\tasync stop() {\n\t\tthis.logger.log('Stopping internal storage')\n\t}\n\n\t// Get all NVMe devices with their info\n\tasync getDevices(): Promise<NvmeDevice[]> {\n\t\tlet devices = await getNvmeDevices()\n\n\t\t// Attach slot numbers on Umbrel Pro\n\t\tif (await this.#umbreld.hardware.umbrelPro.isUmbrelPro()) {\n\t\t\tdevices = devices.map((device) => ({\n\t\t\t\t...device,\n\t\t\t\tslot: this.#umbreld.hardware.umbrelPro.getSsdSlotFromPciSlotNumber(device.pciSlotNumber),\n\t\t\t}))\n\t\t}\n\n\t\t// Sort by slot number if all devices have slots\n\t\tconst haveMissingSlots = devices.some((device) => device.slot === undefined)\n\t\tif (!haveMissingSlots) devices.sort((a, b) => a.slot! - b.slot!)\n\n\t\treturn devices\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-failsafe-space-reporting.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\n\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\nimport type {ExpansionStatus} from './raid.js'\n\nconst toGB = (bytes?: number) => (bytes != null ? `${(bytes / 1e9).toFixed(2)} GB` : 'N/A')\nconst logSpace = (\n\tlabel: string,\n\ts: {totalSpace?: number; usableSpace?: number; usedSpace?: number; freeSpace?: number},\n) =>\n\tconsole.log(label, {\n\t\ttotalSpace: toGB(s.totalSpace),\n\t\tusableSpace: toGB(s.usableSpace),\n\t\tusedSpace: toGB(s.usedSpace),\n\t\tfreeSpace: toGB(s.freeSpace),\n\t})\n\ndescribe('RAID failsafe space reporting consistency', () => {\n\tconst SSD_SIZE = '4G'\n\n\t// Expected results\n\tlet totalSpaceWith2Ssds = 8053063680\n\tlet totalSpaceWith3Ssds = 12538871808\n\tlet totalSpaceWith4Ssds = 16718495744\n\tlet usableSpaceWith2Ssds = 4026531840\n\tlet usableSpaceWith3Ssds = 8359247872\n\tlet usableSpaceWith4Ssds = 12538871808\n\n\t// We can skip this since we've hardcoded the results for 4GB above\n\t// We should only run this once if we change sizes to get the computed values\n\t// Then comment it out again so the tests always run against fixed values.\n\t// Otherwise a bug in calculation logic that changes the results will pass.\n\t// describe('initial failsafe setup with 2 SSDs', () => {\n\t// \tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\t// \tlet failed = false\n\n\t// \tbeforeAll(async () => {\n\t// \t\tumbreld = await createTestVm()\n\t// \t})\n\n\t// \tafterAll(async () => {\n\t// \t\tawait umbreld?.cleanup()\n\t// \t})\n\n\t// \tafterEach(({task}) => {\n\t// \t\tif (task.result?.state === 'fail') failed = true\n\t// \t})\n\n\t// \tbeforeEach(({skip}) => {\n\t// \t\tif (failed) skip()\n\t// \t})\n\n\t// \ttest('adds 2 NVMe devices and boots VM', async () => {\n\t// \t\tawait umbreld.vm.addNvme({slot: 1, size: SSD_SIZE})\n\t// \t\tawait umbreld.vm.addNvme({slot: 2, size: SSD_SIZE})\n\t// \t\tawait umbreld.vm.powerOn()\n\t// \t})\n\n\t// \ttest('sets up failsafe RAID with 2 devices', async () => {\n\t// \t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t// \t\texpect(devices).toHaveLength(2)\n\t// \t\tconst deviceIds = devices.map((d) => d.id!)\n\t// \t\tawait umbreld.signup({raidDevices: deviceIds, raidType: 'failsafe'})\n\t// \t})\n\n\t// \ttest('waits for RAID setup and logs in', async () => {\n\t// \t\tawait pWaitFor(\n\t// \t\t\tasync () => {\n\t// \t\t\t\ttry {\n\t// \t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t// \t\t\t\t} catch (error) {\n\t// \t\t\t\t\tif (error instanceof Error && error.message.includes('fetch failed')) return false\n\t// \t\t\t\t\tthrow error\n\t// \t\t\t\t}\n\t// \t\t\t},\n\t// \t\t\t{interval: 2000, timeout: 600_000},\n\t// \t\t)\n\t// \t\tawait umbreld.login()\n\t// \t})\n\n\t// \ttest('records space with 2 SSDs', async () => {\n\t// \t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t// \t\texpect(status.exists).toBe(true)\n\t// \t\texpect(status.raidType).toBe('failsafe')\n\t// \t\texpect(status.status).toBe('ONLINE')\n\t// \t\texpect(status.devices).toHaveLength(2)\n\t// \t\ttotalSpaceWith2Ssds = status.totalSpace!\n\t// \t\tusableSpaceWith2Ssds = status.usableSpace!\n\t// \t\texpect(totalSpaceWith2Ssds).toBeGreaterThan(0)\n\t// \t\texpect(usableSpaceWith2Ssds).toBeGreaterThan(0)\n\t// \t\tconsole.log(\n\t// \t\t\t`2 SSDs (initial): totalSpace=${toGB(totalSpaceWith2Ssds)}, usableSpace=${toGB(usableSpaceWith2Ssds)}`,\n\t// \t\t)\n\t// \t})\n\t// })\n\n\t// describe('initial failsafe setup with 3 SSDs', () => {\n\t// \tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\t// \tlet failed = false\n\n\t// \tbeforeAll(async () => {\n\t// \t\tumbreld = await createTestVm()\n\t// \t})\n\n\t// \tafterAll(async () => {\n\t// \t\tawait umbreld?.cleanup()\n\t// \t})\n\n\t// \tafterEach(({task}) => {\n\t// \t\tif (task.result?.state === 'fail') failed = true\n\t// \t})\n\n\t// \tbeforeEach(({skip}) => {\n\t// \t\tif (failed) skip()\n\t// \t})\n\n\t// \ttest('adds 3 NVMe devices and boots VM', async () => {\n\t// \t\tawait umbreld.vm.addNvme({slot: 1, size: SSD_SIZE})\n\t// \t\tawait umbreld.vm.addNvme({slot: 2, size: SSD_SIZE})\n\t// \t\tawait umbreld.vm.addNvme({slot: 3, size: SSD_SIZE})\n\t// \t\tawait umbreld.vm.powerOn()\n\t// \t})\n\n\t// \ttest('sets up failsafe RAID with 3 devices', async () => {\n\t// \t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t// \t\texpect(devices).toHaveLength(3)\n\t// \t\tconst deviceIds = devices.map((d) => d.id!)\n\t// \t\tawait umbreld.signup({raidDevices: deviceIds, raidType: 'failsafe'})\n\t// \t})\n\n\t// \ttest('waits for RAID setup and logs in', async () => {\n\t// \t\tawait pWaitFor(\n\t// \t\t\tasync () => {\n\t// \t\t\t\ttry {\n\t// \t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t// \t\t\t\t} catch (error) {\n\t// \t\t\t\t\tif (error instanceof Error && error.message.includes('fetch failed')) return false\n\t// \t\t\t\t\tthrow error\n\t// \t\t\t\t}\n\t// \t\t\t},\n\t// \t\t\t{interval: 2000, timeout: 600_000},\n\t// \t\t)\n\t// \t\tawait umbreld.login()\n\t// \t})\n\n\t// \ttest('records space with 3 SSDs', async () => {\n\t// \t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t// \t\texpect(status.exists).toBe(true)\n\t// \t\texpect(status.raidType).toBe('failsafe')\n\t// \t\texpect(status.status).toBe('ONLINE')\n\t// \t\texpect(status.devices).toHaveLength(3)\n\t// \t\ttotalSpaceWith3Ssds = status.totalSpace!\n\t// \t\tusableSpaceWith3Ssds = status.usableSpace!\n\t// \t\texpect(totalSpaceWith3Ssds).toBeGreaterThan(0)\n\t// \t\texpect(usableSpaceWith3Ssds).toBeGreaterThan(0)\n\t// \t\tconsole.log(\n\t// \t\t\t`3 SSDs (initial): totalSpace=${toGB(totalSpaceWith3Ssds)}, usableSpace=${toGB(usableSpaceWith3Ssds)}`,\n\t// \t\t)\n\t// \t})\n\t// })\n\n\t// describe('initial failsafe setup with 4 SSDs', () => {\n\t// \tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\t// \tlet failed = false\n\n\t// \tbeforeAll(async () => {\n\t// \t\tumbreld = await createTestVm()\n\t// \t})\n\n\t// \tafterAll(async () => {\n\t// \t\tawait umbreld?.cleanup()\n\t// \t})\n\n\t// \tafterEach(({task}) => {\n\t// \t\tif (task.result?.state === 'fail') failed = true\n\t// \t})\n\n\t// \tbeforeEach(({skip}) => {\n\t// \t\tif (failed) skip()\n\t// \t})\n\n\t// \ttest('adds 4 NVMe devices and boots VM', async () => {\n\t// \t\tawait umbreld.vm.addNvme({slot: 1, size: SSD_SIZE})\n\t// \t\tawait umbreld.vm.addNvme({slot: 2, size: SSD_SIZE})\n\t// \t\tawait umbreld.vm.addNvme({slot: 3, size: SSD_SIZE})\n\t// \t\tawait umbreld.vm.addNvme({slot: 4, size: SSD_SIZE})\n\t// \t\tawait umbreld.vm.powerOn()\n\t// \t})\n\n\t// \ttest('sets up failsafe RAID with 4 devices', async () => {\n\t// \t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t// \t\texpect(devices).toHaveLength(4)\n\t// \t\tconst deviceIds = devices.map((d) => d.id!)\n\t// \t\tawait umbreld.signup({raidDevices: deviceIds, raidType: 'failsafe'})\n\t// \t})\n\n\t// \ttest('waits for RAID setup and logs in', async () => {\n\t// \t\tawait pWaitFor(\n\t// \t\t\tasync () => {\n\t// \t\t\t\ttry {\n\t// \t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t// \t\t\t\t} catch (error) {\n\t// \t\t\t\t\tif (error instanceof Error && error.message.includes('fetch failed')) return false\n\t// \t\t\t\t\tthrow error\n\t// \t\t\t\t}\n\t// \t\t\t},\n\t// \t\t\t{interval: 2000, timeout: 600_000},\n\t// \t\t)\n\t// \t\tawait umbreld.login()\n\t// \t})\n\n\t// \ttest('records space with 4 SSDs', async () => {\n\t// \t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t// \t\texpect(status.exists).toBe(true)\n\t// \t\texpect(status.raidType).toBe('failsafe')\n\t// \t\texpect(status.status).toBe('ONLINE')\n\t// \t\texpect(status.devices).toHaveLength(4)\n\t// \t\ttotalSpaceWith4Ssds = status.totalSpace!\n\t// \t\tusableSpaceWith4Ssds = status.usableSpace!\n\t// \t\texpect(totalSpaceWith4Ssds).toBeGreaterThan(0)\n\t// \t\texpect(usableSpaceWith4Ssds).toBeGreaterThan(0)\n\t// \t\tconsole.log(\n\t// \t\t\t`4 SSDs (initial): totalSpace=${toGB(totalSpaceWith4Ssds)}, usableSpace=${toGB(usableSpaceWith4Ssds)}`,\n\t// \t\t)\n\n\t// \t\tconsole.log({\n\t// \t\t\ttotalSpaceWith2Ssds,\n\t// \t\t\ttotalSpaceWith3Ssds,\n\t// \t\t\ttotalSpaceWith4Ssds,\n\t// \t\t\tusableSpaceWith2Ssds,\n\t// \t\t\tusableSpaceWith3Ssds,\n\t// \t\t\tusableSpaceWith4Ssds,\n\t// \t\t})\n\t// \t})\n\t// })\n\n\tdescribe('Incremental expansion tests', () => {\n\t\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\t\tlet firstDeviceId: string\n\t\tlet secondDeviceId: string\n\t\tlet thirdDeviceId: string\n\t\tlet fourthDeviceId: string\n\t\tlet failed = false\n\n\t\tbeforeAll(async () => {\n\t\t\tumbreld = await createTestVm()\n\t\t})\n\n\t\tafterAll(async () => {\n\t\t\tawait umbreld?.cleanup()\n\t\t})\n\n\t\tafterEach(({task}) => {\n\t\t\tif (task.result?.state === 'fail') failed = true\n\t\t})\n\n\t\tbeforeEach(({skip}) => {\n\t\t\tif (failed) skip()\n\t\t})\n\n\t\t// --- Setup: 1 SSD in storage mode ---\n\n\t\ttest('adds 1 NVMe device and boots VM', async () => {\n\t\t\tawait umbreld.vm.addNvme({slot: 1, size: SSD_SIZE})\n\t\t\tawait umbreld.vm.powerOn()\n\t\t})\n\n\t\ttest('detects NVMe device', async () => {\n\t\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\t\texpect(devices).toHaveLength(1)\n\t\t\tfirstDeviceId = devices[0].id!\n\t\t\texpect(firstDeviceId).toBeDefined()\n\t\t})\n\n\t\ttest('registers user with storage RAID config (triggers reboot)', async () => {\n\t\t\tawait umbreld.signup({raidDevices: [firstDeviceId], raidType: 'storage'})\n\t\t})\n\n\t\ttest('waits for VM to come back up and logs in', async () => {\n\t\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\t\tawait umbreld.login()\n\t\t})\n\n\t\ttest('writes 2GB of data in storage mode', async () => {\n\t\t\t// Write 1GB first\n\t\t\tawait umbreld.vm.ssh('dd if=/dev/urandom of=~/data-1ssd.bin bs=1M count=1000')\n\t\t\t// Check how many bytes are needed to bump total used space up to 2GB\n\t\t\tawait umbreld.vm.ssh('sync')\n\t\t\tawait new Promise((r) => setTimeout(r, 5000))\n\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\tconst targetUsed = 2_000_000_000\n\t\t\tconst remaining = targetUsed - (status.usedSpace ?? 0)\n\t\t\tconsole.log(`After 1GB write: usedSpace=${toGB(status.usedSpace)}, remaining to 2GB=${toGB(remaining)}`)\n\t\t\tif (remaining > 0) {\n\t\t\t\tconst remainingMiB = Math.ceil(remaining / (1024 * 1024))\n\t\t\t\tawait umbreld.vm.ssh(`dd if=/dev/urandom of=~/data-1ssd-pad.bin bs=1M count=${remainingMiB}`)\n\t\t\t}\n\t\t})\n\n\t\ttest('reports correct RAID status in storage mode', async () => {\n\t\t\tawait new Promise((r) => setTimeout(r, 10000))\n\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\tlogSpace('1 SSD storage mode:', status)\n\t\t\texpect(status.exists).toBe(true)\n\t\t\texpect(status.raidType).toBe('storage')\n\t\t\texpect(status.status).toBe('ONLINE')\n\t\t\texpect(status.devices).toHaveLength(1)\n\t\t})\n\n\t\t// --- Transition to failsafe with 2nd SSD ---\n\n\t\ttest('shuts down and adds second NVMe device', async () => {\n\t\t\tawait umbreld.vm.powerOff()\n\t\t\tawait umbreld.vm.addNvme({slot: 2, size: SSD_SIZE})\n\t\t\tawait umbreld.vm.powerOn()\n\t\t})\n\n\t\ttest('logs in after adding second device', async () => {\n\t\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\t\tawait umbreld.login()\n\t\t})\n\n\t\ttest('detects both NVMe devices', async () => {\n\t\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\t\texpect(devices).toHaveLength(2)\n\t\t\tsecondDeviceId = devices.find((d) => d.slot === 2)!.id!\n\t\t\texpect(secondDeviceId).toBeDefined()\n\t\t})\n\n\t\ttest('starts transition to failsafe mode with second device', async () => {\n\t\t\tconst result = await umbreld.client.hardware.raid.transitionToFailsafe.mutate({device: secondDeviceId})\n\t\t\texpect(result).toBe(true)\n\t\t})\n\n\t\ttest('waits for VM to come back up after transition', async () => {\n\t\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\t\tawait umbreld.login()\n\t\t})\n\n\t\ttest('waits for transition to complete (2 devices in array)', async () => {\n\t\t\tlet status: Awaited<ReturnType<typeof umbreld.client.hardware.raid.getStatus.query>>\n\t\t\tawait pWaitFor(\n\t\t\t\tasync () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tstatus = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\t\tlogSpace('waiting for transition:', status)\n\t\t\t\t\t\tif (status.failsafeTransitionStatus?.state === 'error') {\n\t\t\t\t\t\t\tthrow new Error(status.failsafeTransitionStatus.error)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (status.failsafeTransitionStatus?.state === 'complete') return true\n\t\t\t\t\t\tif (!status.failsafeTransitionStatus && status.status === 'ONLINE' && status.devices?.length === 2)\n\t\t\t\t\t\t\treturn true\n\t\t\t\t\t\treturn false\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tif (error instanceof Error && error.message.includes('fetch failed')) return false\n\t\t\t\t\t\tthrow error\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t{interval: 2000, timeout: 600_000},\n\t\t\t)\n\t\t})\n\n\t\ttest('pool is ONLINE in failsafe mode with 2 devices', async () => {\n\t\t\tawait new Promise((r) => setTimeout(r, 10000))\n\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\tlogSpace('2 SSDs failsafe mode:', status)\n\t\t\texpect(status.raidType).toBe('failsafe')\n\t\t\texpect(status.status).toBe('ONLINE')\n\t\t\texpect(status.devices).toHaveLength(2)\n\t\t})\n\n\t\ttest('space with 2 SSDs is within 5% of initial 2-SSD setup', async () => {\n\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\n\t\t\tconst totalDiscrepancy = Math.abs(status.totalSpace! - totalSpaceWith2Ssds) / totalSpaceWith2Ssds\n\t\t\tconsole.log(\n\t\t\t\t`totalSpace with 2 SSDs: incremental=${toGB(status.totalSpace)}, initial=${toGB(totalSpaceWith2Ssds)}, discrepancy=${(totalDiscrepancy * 100).toFixed(2)}%`,\n\t\t\t)\n\t\t\texpect(totalDiscrepancy).toBeLessThan(0.05)\n\n\t\t\tconst usableDiscrepancy = Math.abs(status.usableSpace! - usableSpaceWith2Ssds) / usableSpaceWith2Ssds\n\t\t\tconsole.log(\n\t\t\t\t`usableSpace with 2 SSDs: incremental=${toGB(status.usableSpace)}, initial=${toGB(usableSpaceWith2Ssds)}, discrepancy=${(usableDiscrepancy * 100).toFixed(2)}%`,\n\t\t\t)\n\t\t\texpect(usableDiscrepancy).toBeLessThan(0.05)\n\t\t})\n\n\t\t// --- Expand to 3 SSDs ---\n\n\t\ttest('shuts down and adds third NVMe device', async () => {\n\t\t\tawait umbreld.vm.powerOff()\n\t\t\tawait umbreld.vm.addNvme({slot: 3, size: SSD_SIZE})\n\t\t\tawait umbreld.vm.powerOn()\n\t\t})\n\n\t\ttest('logs in after adding third device', async () => {\n\t\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\t\tawait umbreld.login()\n\t\t})\n\n\t\ttest('detects third NVMe device', async () => {\n\t\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\t\texpect(devices).toHaveLength(3)\n\t\t\tthirdDeviceId = devices.find((d) => d.slot === 3)!.id!\n\t\t\texpect(thirdDeviceId).toBeDefined()\n\t\t})\n\n\t\ttest('adds third SSD to RAID array', async () => {\n\t\t\tconst expansionSubscription = umbreld.subscribeToEvents<ExpansionStatus>('raid:expansion-progress')\n\t\t\tawait umbreld.client.hardware.raid.addDevice.mutate({device: thirdDeviceId})\n\n\t\t\t// Wait for expansion to complete\n\t\t\tawait pWaitFor(\n\t\t\t\tasync () => {\n\t\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\tlogSpace('waiting for 3-SSD expansion:', status)\n\t\t\t\t\tconst events = expansionSubscription.collected\n\t\t\t\t\tconst lastEvent = events[events.length - 1]\n\t\t\t\t\treturn lastEvent?.state === 'finished' && lastEvent?.progress === 100\n\t\t\t\t},\n\t\t\t\t{interval: 1000, timeout: 600_000},\n\t\t\t)\n\t\t\texpansionSubscription.unsubscribe()\n\t\t})\n\n\t\ttest('pool is ONLINE in failsafe mode with 3 devices', async () => {\n\t\t\tawait new Promise((r) => setTimeout(r, 10000))\n\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\tlogSpace('3 SSDs failsafe mode:', status)\n\t\t\texpect(status.raidType).toBe('failsafe')\n\t\t\texpect(status.status).toBe('ONLINE')\n\t\t\texpect(status.devices).toHaveLength(3)\n\t\t})\n\n\t\ttest('space with 3 SSDs is within 5% of initial 3-SSD setup', async () => {\n\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\n\t\t\tconst totalDiscrepancy = Math.abs(status.totalSpace! - totalSpaceWith3Ssds) / totalSpaceWith3Ssds\n\t\t\tconsole.log(\n\t\t\t\t`totalSpace with 3 SSDs: incremental=${toGB(status.totalSpace)}, initial=${toGB(totalSpaceWith3Ssds)}, discrepancy=${(totalDiscrepancy * 100).toFixed(2)}%`,\n\t\t\t)\n\t\t\texpect(totalDiscrepancy).toBeLessThan(0.05)\n\n\t\t\tconst usableDiscrepancy = Math.abs(status.usableSpace! - usableSpaceWith3Ssds) / usableSpaceWith3Ssds\n\t\t\tconsole.log(\n\t\t\t\t`usableSpace with 3 SSDs: incremental=${toGB(status.usableSpace)}, initial=${toGB(usableSpaceWith3Ssds)}, discrepancy=${(usableDiscrepancy * 100).toFixed(2)}%`,\n\t\t\t)\n\t\t\texpect(usableDiscrepancy).toBeLessThan(0.05)\n\t\t})\n\n\t\t// --- Expand to 4 SSDs ---\n\n\t\ttest('shuts down and adds fourth NVMe device', async () => {\n\t\t\tawait umbreld.vm.powerOff()\n\t\t\tawait umbreld.vm.addNvme({slot: 4, size: SSD_SIZE})\n\t\t\tawait umbreld.vm.powerOn()\n\t\t})\n\n\t\ttest('logs in after adding fourth device', async () => {\n\t\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\t\tawait umbreld.login()\n\t\t})\n\n\t\ttest('detects fourth NVMe device', async () => {\n\t\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\t\texpect(devices).toHaveLength(4)\n\t\t\tfourthDeviceId = devices.find((d) => d.slot === 4)!.id!\n\t\t\texpect(fourthDeviceId).toBeDefined()\n\t\t})\n\n\t\ttest('adds fourth SSD to RAID array', async () => {\n\t\t\tconst expansionSubscription = umbreld.subscribeToEvents<ExpansionStatus>('raid:expansion-progress')\n\t\t\tawait umbreld.client.hardware.raid.addDevice.mutate({device: fourthDeviceId})\n\n\t\t\t// Wait for expansion to complete\n\t\t\tawait pWaitFor(\n\t\t\t\tasync () => {\n\t\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\tlogSpace('waiting for 4-SSD expansion:', status)\n\t\t\t\t\tconst events = expansionSubscription.collected\n\t\t\t\t\tconst lastEvent = events[events.length - 1]\n\t\t\t\t\treturn lastEvent?.state === 'finished' && lastEvent?.progress === 100\n\t\t\t\t},\n\t\t\t\t{interval: 1000, timeout: 600_000},\n\t\t\t)\n\t\t\texpansionSubscription.unsubscribe()\n\t\t})\n\n\t\ttest('pool is ONLINE in failsafe mode with 4 devices', async () => {\n\t\t\tawait new Promise((r) => setTimeout(r, 10000))\n\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\tlogSpace('4 SSDs failsafe mode:', status)\n\t\t\texpect(status.raidType).toBe('failsafe')\n\t\t\texpect(status.status).toBe('ONLINE')\n\t\t\texpect(status.devices).toHaveLength(4)\n\t\t})\n\n\t\ttest('space with 4 SSDs is within 5% of initial 4-SSD setup', async () => {\n\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\n\t\t\tconst totalDiscrepancy = Math.abs(status.totalSpace! - totalSpaceWith4Ssds) / totalSpaceWith4Ssds\n\t\t\tconsole.log(\n\t\t\t\t`totalSpace with 4 SSDs: incremental=${toGB(status.totalSpace)}, initial=${toGB(totalSpaceWith4Ssds)}, discrepancy=${(totalDiscrepancy * 100).toFixed(2)}%`,\n\t\t\t)\n\t\t\texpect(totalDiscrepancy).toBeLessThan(0.05)\n\n\t\t\tconst usableDiscrepancy = Math.abs(status.usableSpace! - usableSpaceWith4Ssds) / usableSpaceWith4Ssds\n\t\t\tconsole.log(\n\t\t\t\t`usableSpace with 4 SSDs: incremental=${toGB(status.usableSpace)}, initial=${toGB(usableSpaceWith4Ssds)}, discrepancy=${(usableDiscrepancy * 100).toFixed(2)}%`,\n\t\t\t)\n\t\t\texpect(usableDiscrepancy).toBeLessThan(0.05)\n\t\t})\n\n\t\t// --- Replace 4th SSD with new one and check degraded state ---\n\n\t\ttest('shuts down and removes fourth NVMe device', async () => {\n\t\t\tawait umbreld.vm.powerOff()\n\t\t\tawait umbreld.vm.removeNvme({slot: 4})\n\t\t\tawait umbreld.vm.addNvme({slot: 4, size: SSD_SIZE})\n\t\t\tawait umbreld.vm.powerOn()\n\t\t})\n\n\t\ttest('logs in after removing fourth device', async () => {\n\t\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\t\tawait umbreld.login()\n\t\t})\n\n\t\ttest('logs usage after removing 4th SSD', async () => {\n\t\t\tawait new Promise((r) => setTimeout(r, 10000))\n\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\tlogSpace('3 SSDs after removing 4th:', status)\n\t\t\tconsole.log(`RAID status: ${status.status}`)\n\t\t\tconsole.log(`RAID type: ${status.raidType}`)\n\t\t\tconsole.log(`Devices: ${status.devices?.length}`)\n\t\t})\n\n\t\ttest('detects new 4th NVMe device', async () => {\n\t\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\t\texpect(devices).toHaveLength(4)\n\t\t\tfourthDeviceId = devices.find((d) => d.slot === 4)!.id!\n\t\t\texpect(fourthDeviceId).toBeDefined()\n\t\t})\n\n\t\ttest('replaces old 4th SSD with new one', async () => {\n\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\tconst missingDevice = status.devices!.find((d) => d.status !== 'ONLINE')\n\t\t\texpect(missingDevice).toBeDefined()\n\t\t\tconsole.log(`Replacing missing device ${missingDevice!.id} with new device ${fourthDeviceId}`)\n\n\t\t\tawait umbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\t\toldDevice: missingDevice!.id,\n\t\t\t\tnewDevice: fourthDeviceId,\n\t\t\t})\n\t\t})\n\n\t\ttest('waits for rebuild to complete', async () => {\n\t\t\tawait pWaitFor(\n\t\t\t\tasync () => {\n\t\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\tlogSpace('waiting for rebuild:', status)\n\t\t\t\t\treturn status.replace?.state === 'finished' || !status.replace\n\t\t\t\t},\n\t\t\t\t{interval: 1000, timeout: 600_000},\n\t\t\t)\n\t\t})\n\n\t\ttest('logs usage after rebuild', async () => {\n\t\t\tawait new Promise((r) => setTimeout(r, 10000))\n\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\tlogSpace('4 SSDs after rebuild:', status)\n\t\t\tconsole.log(`RAID status: ${status.status}`)\n\t\t\tconsole.log(`RAID type: ${status.raidType}`)\n\t\t\tconsole.log(`Devices: ${status.devices?.length}`)\n\t\t\texpect(status.raidType).toBe('failsafe')\n\t\t\texpect(status.status).toBe('ONLINE')\n\t\t\texpect(status.devices).toHaveLength(4)\n\t\t})\n\n\t\ttest('space with 4 SSDs after rebuild is within 5% of initial 4-SSD setup', async () => {\n\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\n\t\t\tconst totalDiscrepancy = Math.abs(status.totalSpace! - totalSpaceWith4Ssds) / totalSpaceWith4Ssds\n\t\t\tconsole.log(\n\t\t\t\t`totalSpace after rebuild: incremental=${toGB(status.totalSpace)}, initial=${toGB(totalSpaceWith4Ssds)}, discrepancy=${(totalDiscrepancy * 100).toFixed(2)}%`,\n\t\t\t)\n\t\t\texpect(totalDiscrepancy).toBeLessThan(0.05)\n\n\t\t\tconst usableDiscrepancy = Math.abs(status.usableSpace! - usableSpaceWith4Ssds) / usableSpaceWith4Ssds\n\t\t\tconsole.log(\n\t\t\t\t`usableSpace after rebuild: incremental=${toGB(status.usableSpace)}, initial=${toGB(usableSpaceWith4Ssds)}, discrepancy=${(usableDiscrepancy * 100).toFixed(2)}%`,\n\t\t\t)\n\t\t\texpect(usableDiscrepancy).toBeLessThan(0.05)\n\t\t})\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-failsafe.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\nimport type {ExpansionStatus} from './raid.js'\n\ndescribe('RAID failsafe mode', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet firstDeviceId: string\n\tlet secondDeviceId: string\n\tlet thirdDeviceId: string\n\tlet initialUsableSpace: number\n\tlet expansionSubscription: ReturnType<typeof umbreld.subscribeToEvents<ExpansionStatus>>\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds two NVMe devices and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1})\n\t\tawait umbreld.vm.addNvme({slot: 2})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects both NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\tconst device1 = devices.find((d) => d.slot === 1)\n\t\tconst device2 = devices.find((d) => d.slot === 2)\n\t\texpect(device1).toBeDefined()\n\t\texpect(device2).toBeDefined()\n\t\tfirstDeviceId = device1!.id!\n\t\tsecondDeviceId = device2!.id!\n\t})\n\n\ttest('registers user with failsafe RAID config (triggers reboot)', async () => {\n\t\tawait umbreld.signup({raidDevices: [firstDeviceId, secondDeviceId], raidType: 'failsafe'})\n\t})\n\n\ttest('waits for RAID setup to complete and logs in', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// Ignore connection errors while VM is rebooting\n\t\t\t\t\tif (error instanceof Error && error.message.includes('fetch failed')) {\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t\t// Rethrow server errors (e.g., initialRaidSetupError)\n\t\t\t\t\tthrow error\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 2000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('reports correct RAID status after setup', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(2)\n\t\tinitialUsableSpace = status.usableSpace!\n\t\texpect(initialUsableSpace).toBeGreaterThan(0)\n\t})\n\n\ttest('has both devices in the array', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst deviceIds = status.devices!.map((d) => d.id).sort()\n\t\texpect(deviceIds).toEqual([firstDeviceId, secondDeviceId].sort())\n\t})\n\n\ttest('creates marker directory to verify data consistency', async () => {\n\t\tawait umbreld.client.files.createDirectory.mutate({path: '/Home/data-consistency-marker'})\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n\n\ttest('shuts down and adds third SSD', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.addNvme({slot: 3})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('logs in after adding third SSD', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('detects all three NVMe devices after reboot', async () => {\n\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(3)\n\t\tconst thirdDevice = devices.find((d) => d.slot === 3)\n\t\texpect(thirdDevice).toBeDefined()\n\t\tthirdDeviceId = thirdDevice!.id!\n\t\texpect(thirdDeviceId).toBeDefined()\n\t})\n\n\ttest('adds third SSD to RAID array and subscribes to expansion events', async () => {\n\t\t// Subscribe to expansion events before adding the device\n\t\texpansionSubscription = umbreld.subscribeToEvents<ExpansionStatus>('raid:expansion-progress')\n\n\t\tawait umbreld.client.hardware.raid.addDevice.mutate({\n\t\t\tdevice: thirdDeviceId,\n\t\t})\n\t})\n\n\ttest('reports correct RAID status with three devices', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(3)\n\t})\n\n\ttest('has all three devices in the array', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst deviceIds = status.devices!.map((d) => d.id).sort()\n\t\texpect(deviceIds).toEqual([firstDeviceId, secondDeviceId, thirdDeviceId].sort())\n\t})\n\n\t// In RAIDZ1, when attaching a new device, the expansion is async.\n\t// Wait for expansion to complete and verify events only increase.\n\ttest('receives expansion events via WebSocket', async () => {\n\t\t// Wait for expansion to complete via events\n\t\tawait pWaitFor(\n\t\t\t() => {\n\t\t\t\tconst events = expansionSubscription.collected\n\t\t\t\tconst lastEvent = events[events.length - 1]\n\t\t\t\treturn lastEvent?.state === 'finished' && lastEvent?.progress === 100\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 30_000},\n\t\t)\n\n\t\t// Unsubscribe since expansion is complete\n\t\texpansionSubscription.unsubscribe()\n\n\t\tconst events = expansionSubscription.collected\n\t\texpect(events.length).toBeGreaterThan(1)\n\n\t\t// Verify events have correct structure\n\t\tfor (const event of events) {\n\t\t\texpect(['expanding', 'finished', 'canceled']).toContain(event.state)\n\t\t\texpect(event.progress).toBeGreaterThanOrEqual(0)\n\t\t\texpect(event.progress).toBeLessThanOrEqual(100)\n\t\t}\n\n\t\t// Verify progress only increased across events\n\t\tconst progressFromEvents = events.map((e) => e.progress)\n\t\tfor (let i = 1; i < progressFromEvents.length; i++) {\n\t\t\texpect(progressFromEvents[i]).toBeGreaterThanOrEqual(progressFromEvents[i - 1])\n\t\t}\n\n\t\t// Verify we started below 100 and ended at 100\n\t\texpect(progressFromEvents[0]).toBeLessThan(100)\n\t\texpect(progressFromEvents[progressFromEvents.length - 1]).toBe(100)\n\t})\n\n\ttest('usable space increased after expansion', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\treturn status.usableSpace! > initialUsableSpace\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 60_000},\n\t\t)\n\t})\n\n\ttest('marker directory still exists after expansion', async () => {\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-foreign-pool.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\n\n// Tests that SSDs previously used in another Umbrel installation don't interfere\n// with the current installation. The system should mount the correct pool based\n// on the pool name stored in the config, ignoring any foreign pools.\ndescribe('RAID with previously used SSDs', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet currentPoolDevices: string[]\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\t// Phase 1: Set up an Umbrel with SSDs in slots 1+2 (simulates a previous installation)\n\ttest('adds two NVMe devices (slots 1+2) and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1})\n\t\tawait umbreld.vm.addNvme({slot: 2})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects both NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t})\n\n\ttest('registers user with storage RAID using slots 1+2', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\tawait umbreld.signup({raidDevices: devices.map((d) => d.id!), raidType: 'storage'})\n\t})\n\n\ttest('waits for setup to complete', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('verifies RAID setup', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\t// Phase 2: Simulate obtaining SSDs from a different Umbrel\n\t// Disconnect the SSDs, reflash to a fresh OS, then set up with new SSDs\n\ttest('powers off and disconnects SSDs', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.disconnectNvme({slot: 1})\n\t\tawait umbreld.vm.disconnectNvme({slot: 2})\n\t})\n\n\ttest('reflashes to simulate fresh Umbrel', async () => {\n\t\tawait umbreld.vm.reflash()\n\t})\n\n\ttest('adds new NVMe devices (slots 3+4) and boots fresh', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 3})\n\t\tawait umbreld.vm.addNvme({slot: 4})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects new NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\texpect(devices.map((d) => d.slot).sort()).toEqual([3, 4])\n\t})\n\n\ttest('registers user with storage RAID using slots 3+4', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\tcurrentPoolDevices = devices.map((d) => d.id!)\n\t\tawait umbreld.signup({raidDevices: currentPoolDevices, raidType: 'storage'})\n\t})\n\n\ttest('waits for setup to complete', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('verifies RAID setup', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\t// Phase 3: Connect the SSDs from the previous Umbrel and verify they're ignored\n\ttest('powers off and connects SSDs from previous Umbrel', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.connectNvme({slot: 1})\n\t\tawait umbreld.vm.connectNvme({slot: 2})\n\t})\n\n\ttest('boots with all four SSDs', async () => {\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('mounts the current pool and ignores the foreign pool', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.devices?.map((d) => d.id).sort()).toEqual(currentPoolDevices.sort())\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-operations-different-sizes.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\n\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\n\n// Size validation rules for RAID operations:\n//\n// Storage mode (striped vdevs):\n//   - addDevice: allows any size (smaller or larger)\n//   - replaceDevice: rejects smaller than device it's replacing, allows larger\n//\n// Failsafe mode (raidz1):\n//   - addDevice: rejects smaller than current smallest device, allows larger\n//   - replaceDevice: rejects smaller than device it's replacing, allows larger\n//\n// transitionToFailsafe:\n//   - rejects smaller than the only current device, allows larger (tested in raid-transition-different-size.vm.test.ts)\n//\n// Note: \"replace with larger\" cases are tested in raid-replace-larger-capacity.vm.test.ts\n// so they are not duplicated here.\n\ndescribe('RAID size validation', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet smallDeviceId: string\n\tlet mediumDeviceId: string\n\tlet largeDeviceId: string\n\tlet extraLargeDeviceId: string\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds four NVMe devices of different sizes and boots VM', async () => {\n\t\t// Small: 32GB, Medium: 64GB, Large: 128GB, Extra Large: 256GB\n\t\tawait umbreld.vm.addNvme({slot: 1, size: '32G'})\n\t\tawait umbreld.vm.addNvme({slot: 2, size: '64G'})\n\t\tawait umbreld.vm.addNvme({slot: 3, size: '128G'})\n\t\tawait umbreld.vm.addNvme({slot: 4, size: '256G'})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects all four NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(4)\n\t\tsmallDeviceId = devices.find((d) => d.slot === 1)!.id!\n\t\tmediumDeviceId = devices.find((d) => d.slot === 2)!.id!\n\t\tlargeDeviceId = devices.find((d) => d.slot === 3)!.id!\n\t\textraLargeDeviceId = devices.find((d) => d.slot === 4)!.id!\n\t})\n\n\t// ==================== STORAGE MODE TESTS ====================\n\n\ttest('registers user with storage RAID using medium device', async () => {\n\t\tawait umbreld.signup({raidDevices: [mediumDeviceId], raidType: 'storage'})\n\t})\n\n\ttest('waits for storage mode RAID setup to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('confirms storage mode RAID with medium device', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.devices).toHaveLength(1)\n\t})\n\n\ttest('storage mode: allows adding larger device', async () => {\n\t\tawait umbreld.client.hardware.raid.addDevice.mutate({device: largeDeviceId})\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\ttest('storage mode: rejects replacing with smaller device', async () => {\n\t\t// Try to replace large (128GB) with small (32GB) - should fail\n\t\t// Note: must run before adding small to array\n\t\tawait expect(\n\t\t\tumbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\t\toldDevice: largeDeviceId,\n\t\t\t\tnewDevice: smallDeviceId,\n\t\t\t}),\n\t\t).rejects.toThrow()\n\t})\n\n\ttest('storage mode: allows adding smaller device', async () => {\n\t\tawait umbreld.client.hardware.raid.addDevice.mutate({device: smallDeviceId})\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.devices).toHaveLength(3)\n\t})\n\n\t// ==================== FAILSAFE MODE TESTS ====================\n\n\ttest('powers off and reflashes VM for failsafe mode tests', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.reflash()\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('re-detects all four NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(4)\n\t\tsmallDeviceId = devices.find((d) => d.slot === 1)!.id!\n\t\tmediumDeviceId = devices.find((d) => d.slot === 2)!.id!\n\t\tlargeDeviceId = devices.find((d) => d.slot === 3)!.id!\n\t\textraLargeDeviceId = devices.find((d) => d.slot === 4)!.id!\n\t})\n\n\ttest('registers user with failsafe RAID using medium and large devices', async () => {\n\t\tawait umbreld.signup({raidDevices: [mediumDeviceId, largeDeviceId], raidType: 'failsafe'})\n\t})\n\n\ttest('waits for failsafe mode RAID setup to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('confirms failsafe mode RAID with two devices', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\ttest('failsafe mode: rejects adding smaller device', async () => {\n\t\tawait expect(umbreld.client.hardware.raid.addDevice.mutate({device: smallDeviceId})).rejects.toThrow()\n\t})\n\n\ttest('failsafe mode: allows adding larger device', async () => {\n\t\tawait umbreld.client.hardware.raid.addDevice.mutate({device: extraLargeDeviceId})\n\t\t// Wait for expansion to start\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\treturn status.devices?.length === 3\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.devices).toHaveLength(3)\n\t})\n\n\ttest('failsafe mode: rejects replacing with smaller device', async () => {\n\t\t// Try to replace extra large (256GB) with small (32GB) - should fail\n\t\tawait expect(\n\t\t\tumbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\t\toldDevice: extraLargeDeviceId,\n\t\t\t\tnewDevice: smallDeviceId,\n\t\t\t}),\n\t\t).rejects.toThrow()\n\t})\n\n\ttest('waits for pool to be ONLINE', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\treturn status.status === 'ONLINE'\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-preused-drives.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\n\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\n\ndescribe('RAID operations with pre-used ZFS drives', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet device1Id: string\n\tlet device2Id: string\n\tlet device3Id: string\n\tlet device4Id: string\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\t// Phase 1: Create initial RAID with 4 SSDs to \"pre-use\" them with ZFS\n\ttest('adds four NVMe devices and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1})\n\t\tawait umbreld.vm.addNvme({slot: 2})\n\t\tawait umbreld.vm.addNvme({slot: 3})\n\t\tawait umbreld.vm.addNvme({slot: 4})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects all four NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(4)\n\t\tdevice1Id = devices.find((d) => d.slot === 1)!.id!\n\t\tdevice2Id = devices.find((d) => d.slot === 2)!.id!\n\t\tdevice3Id = devices.find((d) => d.slot === 3)!.id!\n\t\tdevice4Id = devices.find((d) => d.slot === 4)!.id!\n\t})\n\n\ttest('registers user with storage RAID using all 4 devices', async () => {\n\t\tawait umbreld.signup({raidDevices: [device1Id, device2Id, device3Id, device4Id], raidType: 'storage'})\n\t})\n\n\ttest('waits for RAID setup to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('confirms RAID is set up with all 4 devices', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.devices).toHaveLength(4)\n\t})\n\n\t// Phase 2: Reflash to clear umbrelOS state but leave ZFS metadata on drives\n\ttest('powers off and reflashes VM', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.reflash()\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\t// Phase 3: Test initial setup with pre-used SSD\n\ttest('detects all devices after factory reset (with ZFS metadata still on them)', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(4)\n\t\t// Re-capture device IDs (should be the same)\n\t\tdevice1Id = devices.find((d) => d.slot === 1)!.id!\n\t\tdevice2Id = devices.find((d) => d.slot === 2)!.id!\n\t\tdevice3Id = devices.find((d) => d.slot === 3)!.id!\n\t\tdevice4Id = devices.find((d) => d.slot === 4)!.id!\n\t})\n\n\ttest('sets up storage mode RAID with pre-used SSD (device 1)', async () => {\n\t\tawait umbreld.signup({raidDevices: [device1Id], raidType: 'storage'})\n\t})\n\n\ttest('waits for RAID setup with pre-used drive to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('confirms storage mode RAID with 1 device', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(1)\n\t})\n\n\t// Phase 4: Test transition to failsafe with pre-used SSD\n\ttest('rejects transition with device already in array', async () => {\n\t\tawait expect(umbreld.client.hardware.raid.transitionToFailsafe.mutate({device: device1Id})).rejects.toThrow(\n\t\t\t'Cannot transition with a device that is already in the RAID array',\n\t\t)\n\t})\n\n\ttest('transitions to failsafe mode with pre-used SSD (device 2)', async () => {\n\t\tawait umbreld.client.hardware.raid.transitionToFailsafe.mutate({device: device2Id})\n\t})\n\n\ttest('waits for transition to complete', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\n\t\tlet transitionError: string | undefined\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\tif (status.failsafeTransitionStatus?.state === 'error') {\n\t\t\t\t\t\ttransitionError = status.failsafeTransitionStatus.error\n\t\t\t\t\t\treturn true // Exit the loop, we'll throw after\n\t\t\t\t\t}\n\t\t\t\t\treturn status.devices?.length === 2\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tif (transitionError) throw new Error(transitionError)\n\t})\n\n\ttest('confirms failsafe mode with 2 devices', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\ttest('waits for transition to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\tif (status.failsafeTransitionStatus?.state === 'complete') return true\n\t\t\t\t\tif (!status.failsafeTransitionStatus && status.status === 'ONLINE') return true\n\t\t\t\t\treturn false\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n\n\t// Phase 5: Test expansion with pre-used SSD\n\ttest('rejects expansion with device already in array', async () => {\n\t\tawait expect(umbreld.client.hardware.raid.addDevice.mutate({device: device1Id})).rejects.toThrow(\n\t\t\t'Cannot add a device that is already in the RAID array',\n\t\t)\n\t})\n\n\ttest('expands failsafe array with pre-used SSD (device 3)', async () => {\n\t\tawait umbreld.client.hardware.raid.addDevice.mutate({device: device3Id})\n\t})\n\n\ttest('waits for expansion to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\treturn status.devices?.length === 3\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n\n\ttest('confirms failsafe mode with 3 devices after expansion', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(status.devices).toHaveLength(3)\n\t})\n\n\t// Phase 6: Test replace with pre-used SSD\n\ttest('rejects replace with device already in array', async () => {\n\t\tawait expect(\n\t\t\tumbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\t\toldDevice: device1Id,\n\t\t\t\tnewDevice: device2Id,\n\t\t\t}),\n\t\t).rejects.toThrow('Cannot replace with a device that is already in the RAID array')\n\t})\n\n\ttest('replaces device 1 with pre-used SSD (device 4)', async () => {\n\t\tawait umbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\toldDevice: device1Id,\n\t\t\tnewDevice: device4Id,\n\t\t})\n\t})\n\n\ttest('waits for replace to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\treturn status.replace?.state === 'finished' || !status.replace\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n\n\ttest('confirms array has device 4 instead of device 1', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst deviceIds = status.devices!.map((d) => d.id)\n\t\texpect(deviceIds).toContain(device4Id)\n\t\texpect(deviceIds).not.toContain(device1Id)\n\t\texpect(status.devices).toHaveLength(3)\n\t})\n\n\t// Phase 7: Reflash again to test storage mode expansion\n\ttest('powers off and reflashes VM again', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.reflash()\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects all devices after second factory reset', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(4)\n\t\tdevice1Id = devices.find((d) => d.slot === 1)!.id!\n\t\tdevice2Id = devices.find((d) => d.slot === 2)!.id!\n\t})\n\n\t// Phase 8: Test storage mode expansion with pre-used SSD\n\ttest('sets up storage mode RAID with device 1', async () => {\n\t\tawait umbreld.signup({raidDevices: [device1Id], raidType: 'storage'})\n\t})\n\n\ttest('waits for storage mode setup to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('rejects storage mode expansion with device already in array', async () => {\n\t\tawait expect(umbreld.client.hardware.raid.addDevice.mutate({device: device1Id})).rejects.toThrow(\n\t\t\t'Cannot add a device that is already in the RAID array',\n\t\t)\n\t})\n\n\ttest('expands storage mode array with pre-used SSD (device 2)', async () => {\n\t\tawait umbreld.client.hardware.raid.addDevice.mutate({device: device2Id})\n\t})\n\n\ttest('confirms storage mode with 2 devices after expansion', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\ttest('pool is ONLINE after storage mode expansion', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.status).toBe('ONLINE')\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-recovery-mode.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\n\ndescribe('RAID mount failure detection', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet firstDeviceId: string\n\tlet secondDeviceId: string\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds two NVMe devices and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1})\n\t\tawait umbreld.vm.addNvme({slot: 2})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects both NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\tfirstDeviceId = devices.find((d) => d.slot === 1)!.id!\n\t\tsecondDeviceId = devices.find((d) => d.slot === 2)!.id!\n\t})\n\n\ttest('registers user with storage RAID using both devices', async () => {\n\t\tawait umbreld.signup({raidDevices: [firstDeviceId, secondDeviceId], raidType: 'storage'})\n\t})\n\n\ttest('waits for RAID setup to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('confirms RAID is online with both devices', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\ttest('checkRaidMountFailure returns false when RAID is healthy', async () => {\n\t\tconst failure = await umbreld.unauthenticatedClient.hardware.raid.checkRaidMountFailure.query()\n\t\texpect(failure).toBe(false)\n\t})\n\n\t// Disconnect one SSD and verify mount failure detection\n\ttest('powers off and disconnects one SSD', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.disconnectNvme({slot: 2})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('checkRaidMountFailure returns true with one SSD disconnected', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: false})\n\t\tconst failure = await umbreld.unauthenticatedClient.hardware.raid.checkRaidMountFailure.query()\n\t\texpect(failure).toBe(true)\n\t})\n\n\ttest('checkRaidMountFailureDevices returns expected device status with one SSD disconnected', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.raid.checkRaidMountFailureDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\tconst firstDevice = devices.find((d) => d.name.includes(firstDeviceId))\n\t\tconst secondDevice = devices.find((d) => d.name.includes(secondDeviceId))\n\t\texpect(firstDevice?.isOk).toBe(true)\n\t\texpect(secondDevice?.isOk).toBe(false)\n\t})\n\n\t// Disconnect both SSDs and verify mount failure detection\n\ttest('powers off and disconnects remaining SSD', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.disconnectNvme({slot: 1})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('checkRaidMountFailure returns true with both SSDs disconnected', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: false})\n\t\tconst failure = await umbreld.unauthenticatedClient.hardware.raid.checkRaidMountFailure.query()\n\t\texpect(failure).toBe(true)\n\t})\n\n\ttest('checkRaidMountFailureDevices returns expected device status with both SSDs disconnected', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.raid.checkRaidMountFailureDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\texpect(devices.every((d) => d.isOk === false)).toBe(true)\n\t})\n\n\t// Reconnect both SSDs and verify recovery\n\ttest('powers off and reconnects both SSDs', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.connectNvme({slot: 1})\n\t\tawait umbreld.vm.connectNvme({slot: 2})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('checkRaidMountFailure returns false after reconnecting SSDs', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t\tconst failure = await umbreld.unauthenticatedClient.hardware.raid.checkRaidMountFailure.query()\n\t\texpect(failure).toBe(false)\n\t})\n\n\ttest('RAID is back online after reconnecting SSDs', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\t// Test recovery mode operations\n\ttest('powers off and disconnects one SSD to enter recovery mode', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.disconnectNvme({slot: 2})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('confirms we are in recovery mode', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: false})\n\t\tconst failure = await umbreld.unauthenticatedClient.hardware.raid.checkRaidMountFailure.query()\n\t\texpect(failure).toBe(true)\n\t})\n\n\ttest('can shutdown from recovery mode', async () => {\n\t\tawait umbreld.unauthenticatedClient.system.shutdown.mutate()\n\t\tawait umbreld.vm.waitForShutdown()\n\t})\n\n\ttest('powers on after shutdown test', async () => {\n\t\tawait umbreld.vm.powerOn()\n\t\tawait umbreld.waitForStartup({waitForUser: false})\n\t\tconst failure = await umbreld.unauthenticatedClient.hardware.raid.checkRaidMountFailure.query()\n\t\texpect(failure).toBe(true)\n\t})\n\n\ttest('can restart from recovery mode', async () => {\n\t\tawait umbreld.unauthenticatedClient.system.restart.mutate()\n\t\t// Wait for VM to restart and come back up\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.unauthenticatedClient.system.status.query().catch(() => '')\n\t\t\t\treturn status === 'running'\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tconst failure = await umbreld.unauthenticatedClient.hardware.raid.checkRaidMountFailure.query()\n\t\texpect(failure).toBe(true)\n\t})\n\n\ttest('can factory reset from recovery mode', async () => {\n\t\t// Factory reset triggers a reboot\n\t\tawait umbreld.unauthenticatedClient.system.factoryReset.mutate({})\n\n\t\t// Wait for VM to come back up after factory reset\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.unauthenticatedClient.system.status.query().catch(() => '')\n\t\t\t\treturn status === 'running'\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\n\t\t// Verify user no longer exists after factory reset\n\t\tconst userExists = await umbreld.unauthenticatedClient.user.exists.query()\n\t\texpect(userExists).toBe(false)\n\t\t// Verify mount failure is false (no RAID config to fail)\n\t\tconst failure = await umbreld.unauthenticatedClient.hardware.raid.checkRaidMountFailure.query()\n\t\texpect(failure).toBe(false)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-replace-larger-capacity.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\n\ndescribe('RAID storage mode - upgrade disk capacity', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet firstDeviceId: string\n\tlet secondDeviceId: string\n\tlet thirdDeviceId: string\n\tlet fourthDeviceId: string\n\tlet initialUsableSpace: number\n\tlet afterFirstReplaceUsableSpace: number\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds two 32GB NVMe devices and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1, size: '32G'})\n\t\tawait umbreld.vm.addNvme({slot: 2, size: '32G'})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects both NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\tconst device1 = devices.find((d) => d.slot === 1)\n\t\tconst device2 = devices.find((d) => d.slot === 2)\n\t\texpect(device1).toBeDefined()\n\t\texpect(device2).toBeDefined()\n\t\tfirstDeviceId = device1!.id!\n\t\tsecondDeviceId = device2!.id!\n\t})\n\n\ttest('registers user with storage RAID config (triggers reboot)', async () => {\n\t\tawait umbreld.signup({raidDevices: [firstDeviceId, secondDeviceId], raidType: 'storage'})\n\t})\n\n\ttest('waits for RAID setup to complete and logs in', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch (error) {\n\t\t\t\t\tif (error instanceof Error && error.message.includes('fetch failed')) {\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t\tthrow error\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('reports correct RAID status', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(2)\n\t\tinitialUsableSpace = status.usableSpace!\n\t\texpect(initialUsableSpace).toBeGreaterThan(0)\n\t\tconsole.log('Initial usable space:', initialUsableSpace)\n\t})\n\n\ttest('creates marker directory to verify data consistency', async () => {\n\t\tawait umbreld.client.files.createDirectory.mutate({path: '/Home/data-consistency-marker'})\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n\n\ttest('shuts down and adds first 64GB replacement drive', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.addNvme({slot: 3, size: '64G'})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('logs in after adding replacement drive', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('detects new 64GB drive in slot 3', async () => {\n\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(3)\n\t\tconst thirdDevice = devices.find((d) => d.slot === 3)\n\t\texpect(thirdDevice).toBeDefined()\n\t\tthirdDeviceId = thirdDevice!.id!\n\t})\n\n\ttest('replaces first 32GB device with 64GB device', async () => {\n\t\tawait umbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\toldDevice: firstDeviceId,\n\t\t\tnewDevice: thirdDeviceId,\n\t\t})\n\t})\n\n\ttest('waits for first replacement to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\treturn status.replace?.state === 'finished'\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n\n\ttest('pool size increased after first replacement (storage mode expands per-disk)', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tafterFirstReplaceUsableSpace = status.usableSpace!\n\t\tconsole.log('Usable space after first replacement:', afterFirstReplaceUsableSpace)\n\t\t// In storage mode (stripe), space increases immediately because each disk is its own vdev\n\t\t// 32GB + 64GB = ~96GB usable\n\t\texpect(afterFirstReplaceUsableSpace).toBeGreaterThan(initialUsableSpace)\n\t})\n\n\ttest('shuts down and adds second 64GB replacement drive', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.addNvme({slot: 4, size: '64G'})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('logs in after adding second replacement drive', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('detects new 64GB drive in slot 4', async () => {\n\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(4)\n\t\tconst fourthDevice = devices.find((d) => d.slot === 4)\n\t\texpect(fourthDevice).toBeDefined()\n\t\tfourthDeviceId = fourthDevice!.id!\n\t})\n\n\ttest('replaces second 32GB device with 64GB device', async () => {\n\t\tawait umbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\toldDevice: secondDeviceId,\n\t\t\tnewDevice: fourthDeviceId,\n\t\t})\n\t})\n\n\ttest('waits for second replacement to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\treturn status.replace?.state === 'finished'\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n\n\ttest('pool size doubled after both replacements', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst finalUsableSpace = status.usableSpace!\n\t\tconsole.log('Final usable space:', finalUsableSpace)\n\t\t// Both disks are now 64GB, so usable should be ~128GB (roughly 2x initial)\n\t\texpect(finalUsableSpace).toBeGreaterThan(afterFirstReplaceUsableSpace)\n\t\t// Should be roughly double the initial space (with some tolerance for metadata)\n\t\texpect(finalUsableSpace).toBeGreaterThan(initialUsableSpace * 1.8)\n\t})\n\n\ttest('RAID pool is ONLINE with correct devices', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(2)\n\t\tconst deviceIds = status.devices!.map((d) => d.id).sort()\n\t\texpect(deviceIds).toEqual([thirdDeviceId, fourthDeviceId].sort())\n\t})\n\n\ttest.skip('marker directory still exists after upgrades', async () => {\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n})\n\ndescribe('RAID failsafe mode - upgrade disk capacity', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet firstDeviceId: string\n\tlet secondDeviceId: string\n\tlet thirdDeviceId: string\n\tlet fourthDeviceId: string\n\tlet initialUsableSpace: number\n\tlet afterFirstReplaceUsableSpace: number\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds two 32GB NVMe devices and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1, size: '32G'})\n\t\tawait umbreld.vm.addNvme({slot: 2, size: '32G'})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects both NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\tconst device1 = devices.find((d) => d.slot === 1)\n\t\tconst device2 = devices.find((d) => d.slot === 2)\n\t\texpect(device1).toBeDefined()\n\t\texpect(device2).toBeDefined()\n\t\tfirstDeviceId = device1!.id!\n\t\tsecondDeviceId = device2!.id!\n\t})\n\n\ttest('registers user with failsafe RAID config (triggers reboot)', async () => {\n\t\tawait umbreld.signup({raidDevices: [firstDeviceId, secondDeviceId], raidType: 'failsafe'})\n\t})\n\n\ttest('waits for RAID setup to complete and logs in', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch (error) {\n\t\t\t\t\tif (error instanceof Error && error.message.includes('fetch failed')) {\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t\tthrow error\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('reports correct RAID status', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(2)\n\t\tinitialUsableSpace = status.usableSpace!\n\t\texpect(initialUsableSpace).toBeGreaterThan(0)\n\t\tconsole.log('Initial usable space:', initialUsableSpace)\n\t})\n\n\ttest('creates marker directory to verify data consistency', async () => {\n\t\tawait umbreld.client.files.createDirectory.mutate({path: '/Home/data-consistency-marker'})\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n\n\ttest('shuts down and adds first 64GB replacement drive', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.addNvme({slot: 3, size: '64G'})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('logs in after adding replacement drive', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('detects new 64GB drive in slot 3', async () => {\n\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(3)\n\t\tconst thirdDevice = devices.find((d) => d.slot === 3)\n\t\texpect(thirdDevice).toBeDefined()\n\t\tthirdDeviceId = thirdDevice!.id!\n\t})\n\n\ttest('replaces first 32GB device with 64GB device', async () => {\n\t\tawait umbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\toldDevice: firstDeviceId,\n\t\t\tnewDevice: thirdDeviceId,\n\t\t})\n\t})\n\n\ttest('waits for first replacement to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\treturn status.replace?.state === 'finished'\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n\n\ttest('pool size unchanged after first replacement (still limited by 32GB disk)', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tafterFirstReplaceUsableSpace = status.usableSpace!\n\t\tconsole.log('Usable space after first replacement:', afterFirstReplaceUsableSpace)\n\t\t// In failsafe mode (raidz1), space is limited by smallest disk\n\t\t// Should still be ~32GB since one disk is still 32GB\n\t\texpect(afterFirstReplaceUsableSpace).toBeLessThan(initialUsableSpace * 1.5)\n\t})\n\n\ttest('shuts down and adds second 64GB replacement drive', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.addNvme({slot: 4, size: '64G'})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('logs in after adding second replacement drive', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('detects new 64GB drive in slot 4', async () => {\n\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(4)\n\t\tconst fourthDevice = devices.find((d) => d.slot === 4)\n\t\texpect(fourthDevice).toBeDefined()\n\t\tfourthDeviceId = fourthDevice!.id!\n\t})\n\n\ttest('replaces second 32GB device with 64GB device', async () => {\n\t\tawait umbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\toldDevice: secondDeviceId,\n\t\t\tnewDevice: fourthDeviceId,\n\t\t})\n\t})\n\n\ttest('waits for second replacement to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\treturn status.replace?.state === 'finished'\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n\n\ttest('pool size doubled after both replacements', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst finalUsableSpace = status.usableSpace!\n\t\tconsole.log('Final usable space:', finalUsableSpace)\n\t\t// Both disks are now 64GB, so usable should be ~64GB (roughly 2x initial)\n\t\texpect(finalUsableSpace).toBeGreaterThan(afterFirstReplaceUsableSpace)\n\t\t// Should be roughly double the initial space (with some tolerance for metadata)\n\t\texpect(finalUsableSpace).toBeGreaterThan(initialUsableSpace * 1.8)\n\t})\n\n\ttest('RAID pool is ONLINE with correct devices', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(2)\n\t\tconst deviceIds = status.devices!.map((d) => d.id).sort()\n\t\texpect(deviceIds).toEqual([thirdDeviceId, fourthDeviceId].sort())\n\t})\n\n\ttest.skip('marker directory still exists after upgrades', async () => {\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-replace.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\nimport type {ReplaceStatus} from './raid.js'\n\ndescribe('RAID device replacement - storage mode', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet firstDeviceId: string\n\tlet secondDeviceId: string\n\tlet thirdDeviceId: string\n\tlet replaceSubscription: ReturnType<typeof umbreld.subscribeToEvents<ReplaceStatus>>\n\tconst replaceStatusCalls: ReplaceStatus[] = []\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds two NVMe devices and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1})\n\t\tawait umbreld.vm.addNvme({slot: 2})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects both NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\tconst device1 = devices.find((d) => d.slot === 1)\n\t\tconst device2 = devices.find((d) => d.slot === 2)\n\t\texpect(device1).toBeDefined()\n\t\texpect(device2).toBeDefined()\n\t\tfirstDeviceId = device1!.id!\n\t\tsecondDeviceId = device2!.id!\n\t})\n\n\ttest('registers user with storage RAID config (triggers reboot)', async () => {\n\t\tawait umbreld.signup({raidDevices: [firstDeviceId, secondDeviceId], raidType: 'storage'})\n\t})\n\n\ttest('waits for RAID setup to complete and logs in', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch (error) {\n\t\t\t\t\tif (error instanceof Error && error.message.includes('fetch failed')) {\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t\tthrow error\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('reports correct RAID status in storage mode', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\ttest('creates marker directory to verify data consistency', async () => {\n\t\tawait umbreld.client.files.createDirectory.mutate({path: '/Home/data-consistency-marker'})\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n\n\ttest('writes test data to ensure resilver takes time', async () => {\n\t\t// Write 2GB of random data so resilver takes long enough to capture progress\n\t\tawait umbreld.vm.ssh('dd if=/dev/urandom of=~/test-data.bin bs=1M count=2000')\n\t})\n\n\ttest('shuts down and adds third NVMe device (replacement drive)', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.addNvme({slot: 3})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('logs in after adding third device', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('detects all three NVMe devices', async () => {\n\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(3)\n\t\tconst thirdDevice = devices.find((d) => d.slot === 3)\n\t\texpect(thirdDevice).toBeDefined()\n\t\tthirdDeviceId = thirdDevice!.id!\n\t})\n\n\ttest('replaces second device with third device in storage mode', async () => {\n\t\t// Subscribe to replace events before starting\n\t\treplaceSubscription = umbreld.subscribeToEvents<ReplaceStatus>('raid:replace-progress')\n\n\t\tawait umbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\toldDevice: secondDeviceId,\n\t\t\tnewDevice: thirdDeviceId,\n\t\t})\n\t})\n\n\ttest('waits for replacement to complete in storage mode', async () => {\n\t\t// Poll status endpoint while waiting, collecting replace status\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\tif (status.replace) {\n\t\t\t\t\treplaceStatusCalls.push(status.replace)\n\t\t\t\t}\n\t\t\t\tconst statusHasFinished = ['finished', 'canceled'].includes(status.replace?.state ?? '')\n\t\t\t\tconst eventsHaveFinished = replaceSubscription.collected.some((e) => ['finished', 'canceled'].includes(e.state))\n\t\t\t\treturn statusHasFinished && eventsHaveFinished\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\n\t\treplaceSubscription.unsubscribe()\n\t})\n\n\ttest('validates replace events have correct structure', () => {\n\t\tconst events = replaceSubscription.collected\n\t\texpect(events.length).toBeGreaterThan(1)\n\n\t\t// Verify events have correct structure\n\t\tfor (const event of events) {\n\t\t\texpect(['rebuilding', 'finished', 'canceled']).toContain(event.state)\n\t\t\texpect(event.progress).toBeGreaterThanOrEqual(0)\n\t\t\texpect(event.progress).toBeLessThanOrEqual(100)\n\t\t}\n\n\t\t// Verify progress only increased across events\n\t\tconst progressFromEvents = events.map((e) => e.progress)\n\t\tfor (let i = 1; i < progressFromEvents.length; i++) {\n\t\t\texpect(progressFromEvents[i]).toBeGreaterThanOrEqual(progressFromEvents[i - 1])\n\t\t}\n\n\t\t// Verify we have intermediate progress and 100%\n\t\texpect(progressFromEvents).toContain(100)\n\t\texpect(progressFromEvents.some((p) => p < 100)).toBe(true)\n\n\t\t// Verify final event is finished state at 100%\n\t\tconst lastEvent = events[events.length - 1]\n\t\texpect(lastEvent.state).toBe('finished')\n\t\texpect(lastEvent.progress).toBe(100)\n\t})\n\n\ttest('validates replace status calls have correct structure', () => {\n\t\texpect(replaceStatusCalls.length).toBeGreaterThan(1)\n\n\t\t// Verify status calls have correct structure\n\t\tfor (const status of replaceStatusCalls) {\n\t\t\texpect(['rebuilding', 'finished', 'canceled']).toContain(status.state)\n\t\t\texpect(status.progress).toBeGreaterThanOrEqual(0)\n\t\t\texpect(status.progress).toBeLessThanOrEqual(100)\n\t\t}\n\n\t\t// Verify progress only increased across status calls\n\t\tconst progressFromStatus = replaceStatusCalls.map((s) => s.progress)\n\t\tfor (let i = 1; i < progressFromStatus.length; i++) {\n\t\t\texpect(progressFromStatus[i]).toBeGreaterThanOrEqual(progressFromStatus[i - 1])\n\t\t}\n\n\t\t// Verify we captured intermediate progress (not just end state)\n\t\texpect(progressFromStatus.some((p) => p < 100)).toBe(true)\n\n\t\t// Verify final status shows completion\n\t\tconst lastStatus = replaceStatusCalls[replaceStatusCalls.length - 1]\n\t\texpect(lastStatus.progress).toBe(100)\n\t\texpect(lastStatus.state).toBe('finished')\n\t})\n\n\ttest('reports correct RAID status after storage mode replacement', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.devices).toHaveLength(2)\n\t\tconst deviceIds = status.devices!.map((d) => d.id).sort()\n\t\texpect(deviceIds).toEqual([firstDeviceId, thirdDeviceId].sort())\n\t})\n\n\ttest('marker directory still exists after replacement', async () => {\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n\n\ttest('logs all collected events and status calls', () => {\n\t\tconst events = replaceSubscription.collected\n\t\tconsole.log('Events collected:', events.length)\n\t\tfor (const event of events) console.log(event)\n\t\tconsole.log('Status calls collected:', replaceStatusCalls.length)\n\t\tfor (const status of replaceStatusCalls) console.log(status)\n\t})\n})\n\ndescribe('RAID device replacement - failsafe mode', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet firstDeviceId: string\n\tlet secondDeviceId: string\n\tlet newSecondDeviceId: string\n\tlet replaceSubscription: ReturnType<typeof umbreld.subscribeToEvents<ReplaceStatus>>\n\tconst replaceStatusCalls: ReplaceStatus[] = []\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds two NVMe devices and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1})\n\t\tawait umbreld.vm.addNvme({slot: 2})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects both NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\tconst device1 = devices.find((d) => d.slot === 1)\n\t\tconst device2 = devices.find((d) => d.slot === 2)\n\t\texpect(device1).toBeDefined()\n\t\texpect(device2).toBeDefined()\n\t\tfirstDeviceId = device1!.id!\n\t\tsecondDeviceId = device2!.id!\n\t})\n\n\ttest('registers user with failsafe RAID config (triggers reboot)', async () => {\n\t\tawait umbreld.signup({raidDevices: [firstDeviceId, secondDeviceId], raidType: 'failsafe'})\n\t})\n\n\ttest('waits for RAID setup to complete and logs in', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch (error) {\n\t\t\t\t\tif (error instanceof Error && error.message.includes('fetch failed')) {\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t\tthrow error\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('reports correct RAID status in failsafe mode', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\ttest('creates marker directory to verify data consistency', async () => {\n\t\tawait umbreld.client.files.createDirectory.mutate({path: '/Home/data-consistency-marker'})\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n\n\ttest('writes test data to ensure resilver takes time', async () => {\n\t\t// Write 2GB of random data so resilver takes long enough to capture progress\n\t\tawait umbreld.vm.ssh('dd if=/dev/urandom of=~/test-data.bin bs=1M count=2000')\n\t})\n\n\ttest('shuts down, nukes second drive, and adds fresh replacement', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.removeNvme({slot: 2})\n\t\tawait umbreld.vm.addNvme({slot: 2})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('logs in after replacing drive', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('detects new replacement drive in slot 2', async () => {\n\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\tconst newDevice2 = devices.find((d) => d.slot === 2)\n\t\texpect(newDevice2).toBeDefined()\n\t\tnewSecondDeviceId = newDevice2!.id!\n\t\t// The new drive should have a different ID than the original\n\t\texpect(newSecondDeviceId).not.toBe(secondDeviceId)\n\t})\n\n\ttest('replaces old device with new device in failsafe mode', async () => {\n\t\t// Subscribe to replace events before starting\n\t\treplaceSubscription = umbreld.subscribeToEvents<ReplaceStatus>('raid:replace-progress')\n\n\t\tawait umbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\toldDevice: secondDeviceId,\n\t\t\tnewDevice: newSecondDeviceId,\n\t\t})\n\t})\n\n\ttest('waits for replacement to complete in failsafe mode', async () => {\n\t\t// Poll status endpoint while waiting, collecting replace status\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\tif (status.replace) {\n\t\t\t\t\treplaceStatusCalls.push(status.replace)\n\t\t\t\t}\n\t\t\t\tconst statusHasFinished = ['finished', 'canceled'].includes(status.replace?.state ?? '')\n\t\t\t\tconst eventsHaveFinished = replaceSubscription.collected.some((e) => ['finished', 'canceled'].includes(e.state))\n\t\t\t\treturn statusHasFinished && eventsHaveFinished\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\n\t\treplaceSubscription.unsubscribe()\n\t})\n\n\ttest('validates replace events have correct structure', () => {\n\t\tconst events = replaceSubscription.collected\n\t\texpect(events.length).toBeGreaterThan(1)\n\n\t\t// Verify events have correct structure\n\t\tfor (const event of events) {\n\t\t\texpect(['rebuilding', 'finished', 'canceled']).toContain(event.state)\n\t\t\texpect(event.progress).toBeGreaterThanOrEqual(0)\n\t\t\texpect(event.progress).toBeLessThanOrEqual(100)\n\t\t}\n\n\t\t// Verify progress only increased across events\n\t\tconst progressFromEvents = events.map((e) => e.progress)\n\t\tfor (let i = 1; i < progressFromEvents.length; i++) {\n\t\t\texpect(progressFromEvents[i]).toBeGreaterThanOrEqual(progressFromEvents[i - 1])\n\t\t}\n\n\t\t// Verify we have intermediate progress and 100%\n\t\texpect(progressFromEvents).toContain(100)\n\t\texpect(progressFromEvents.some((p) => p < 100)).toBe(true)\n\n\t\t// Verify final event is finished state at 100%\n\t\tconst lastEvent = events[events.length - 1]\n\t\texpect(lastEvent.state).toBe('finished')\n\t\texpect(lastEvent.progress).toBe(100)\n\t})\n\n\ttest('validates replace status calls have correct structure', () => {\n\t\texpect(replaceStatusCalls.length).toBeGreaterThan(1)\n\n\t\t// Verify status calls have correct structure\n\t\tfor (const status of replaceStatusCalls) {\n\t\t\texpect(['rebuilding', 'finished', 'canceled']).toContain(status.state)\n\t\t\texpect(status.progress).toBeGreaterThanOrEqual(0)\n\t\t\texpect(status.progress).toBeLessThanOrEqual(100)\n\t\t}\n\n\t\t// Verify progress only increased across status calls\n\t\tconst progressFromStatus = replaceStatusCalls.map((s) => s.progress)\n\t\tfor (let i = 1; i < progressFromStatus.length; i++) {\n\t\t\texpect(progressFromStatus[i]).toBeGreaterThanOrEqual(progressFromStatus[i - 1])\n\t\t}\n\n\t\t// Verify we captured intermediate progress (not just end state)\n\t\texpect(progressFromStatus.some((p) => p < 100)).toBe(true)\n\n\t\t// Verify final status shows completion\n\t\tconst lastStatus = replaceStatusCalls[replaceStatusCalls.length - 1]\n\t\texpect(lastStatus.progress).toBe(100)\n\t\texpect(lastStatus.state).toBe('finished')\n\t})\n\n\ttest('reports correct RAID status after failsafe mode replacement', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(status.devices).toHaveLength(2)\n\t\tconst deviceIds = status.devices!.map((d) => d.id).sort()\n\t\texpect(deviceIds).toEqual([firstDeviceId, newSecondDeviceId].sort())\n\t})\n\n\ttest('pool is in ONLINE state after failsafe replacement', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.status).toBe('ONLINE')\n\t})\n\n\ttest('marker directory still exists after replacement', async () => {\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n\n\ttest('logs all collected events and status calls', () => {\n\t\tconst events = replaceSubscription.collected\n\t\tconsole.log('Events collected:', events.length)\n\t\tfor (const event of events) console.log(event)\n\t\tconsole.log('Status calls collected:', replaceStatusCalls.length)\n\t\tfor (const status of replaceStatusCalls) console.log(status)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-size-rounding.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\n\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\n\ndescribe('RAID transition with real 4TB drive sizes', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet largerPhisonDeviceId: string\n\tlet smallerSamsungDeviceId: string\n\tlet exactGenericDeviceId: string\n\tlet smallSimulatedDeviceId: string\n\tlet failed = false\n\n\t// Real 4TB NVMe drive sizes in bytes\n\tconst LARGER_PHISON_4TB_SIZE = '4096805658624'\n\tconst SMALLER_SAMSUNG_4TB_SIZE = '4000787030016'\n\tconst EXACT_4TB_SIZE = '4000000000000' // Exactly 4TB\n\tconst SMALL_SIMULATED_4TB_SIZE = '4000000010000' // Just over 4TB - tests rounding edge case\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds all four 4TB NVMe devices and boots VM', async () => {\n\t\t// Slot 1: Larger Phison, Slot 2: Smaller Samsung, Slot 3: Exact 4TB, Slot 4: Small simulated (just over 4TB)\n\t\tawait umbreld.vm.addNvme({slot: 1, size: LARGER_PHISON_4TB_SIZE})\n\t\tawait umbreld.vm.addNvme({slot: 2, size: SMALLER_SAMSUNG_4TB_SIZE})\n\t\tawait umbreld.vm.addNvme({slot: 3, size: EXACT_4TB_SIZE})\n\t\tawait umbreld.vm.addNvme({slot: 4, size: SMALL_SIMULATED_4TB_SIZE})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('logs device sizes via lsblk', async () => {\n\t\tconsole.log(await umbreld.vm.ssh('lsblk --bytes'))\n\t})\n\n\ttest('detects all four NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(4)\n\t\tlargerPhisonDeviceId = devices.find((d) => d.slot === 1)!.id!\n\t\tsmallerSamsungDeviceId = devices.find((d) => d.slot === 2)!.id!\n\t\texactGenericDeviceId = devices.find((d) => d.slot === 3)!.id!\n\t\tsmallSimulatedDeviceId = devices.find((d) => d.slot === 4)!.id!\n\t\texpect(largerPhisonDeviceId).toBeDefined()\n\t\texpect(smallerSamsungDeviceId).toBeDefined()\n\t\texpect(exactGenericDeviceId).toBeDefined()\n\t\texpect(smallSimulatedDeviceId).toBeDefined()\n\t})\n\n\ttest('registers user with storage RAID using larger Phison device (triggers reboot)', async () => {\n\t\tawait umbreld.signup({raidDevices: [largerPhisonDeviceId], raidType: 'storage'})\n\t})\n\n\ttest('waits for VM to come back up and logs in', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('reports correct RAID status in storage mode', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(1)\n\t\texpect(status.devices![0].id).toBe(largerPhisonDeviceId)\n\t})\n\n\t// Test replace with smaller device in storage mode\n\ttest('replaces larger Phison with smaller Samsung in storage mode', async () => {\n\t\tconst result = await umbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\toldDevice: largerPhisonDeviceId,\n\t\t\tnewDevice: smallerSamsungDeviceId,\n\t\t})\n\t\texpect(result).toBe(true)\n\t})\n\n\ttest('waits for storage mode replace to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\treturn status.replace?.state === 'finished' || !status.replace\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n\n\ttest('has smaller Samsung in array after replacing larger Phison in storage mode', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(1)\n\t\texpect(status.devices![0].id).toBe(smallerSamsungDeviceId)\n\t})\n\n\t// Replace back to Phison for failsafe tests\n\ttest('replaces smaller Samsung back to larger Phison in storage mode', async () => {\n\t\tconst result = await umbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\toldDevice: smallerSamsungDeviceId,\n\t\t\tnewDevice: largerPhisonDeviceId,\n\t\t})\n\t\texpect(result).toBe(true)\n\t})\n\n\ttest('waits for storage mode replace back to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\treturn status.replace?.state === 'finished' || !status.replace\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n\n\ttest('has larger Phison back in array after replacing smaller Samsung in storage mode', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(1)\n\t\texpect(status.devices![0].id).toBe(largerPhisonDeviceId)\n\t})\n\n\ttest('starts transition to failsafe mode with smaller Samsung device', async () => {\n\t\tconst result = await umbreld.client.hardware.raid.transitionToFailsafe.mutate({device: smallerSamsungDeviceId})\n\t\texpect(result).toBe(true)\n\t})\n\n\ttest('waits for VM to come back up after transition', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('waits for migration to complete (2 devices in array)', async () => {\n\t\tlet status: Awaited<ReturnType<typeof umbreld.client.hardware.raid.getStatus.query>>\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tstatus = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\tif (status.devices?.length === 2) return true\n\t\t\t\t} catch {}\n\t\t\t\tif (status?.failsafeTransitionStatus?.state === 'error') {\n\t\t\t\t\tthrow new Error(status.failsafeTransitionStatus.error)\n\t\t\t\t}\n\t\t\t\treturn false\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\texpect(status!.devices).toHaveLength(2)\n\t})\n\n\ttest('reports correct RAID status in failsafe mode after migration', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(['ONLINE', 'DEGRADED']).toContain(status.status)\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\ttest('has both devices in the array after migration', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst deviceIds = status.devices!.map((d) => d.id).sort()\n\t\texpect(deviceIds).toEqual([smallerSamsungDeviceId, largerPhisonDeviceId].sort())\n\t})\n\n\ttest('waits for transition to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\tif (status.failsafeTransitionStatus?.state === 'complete') return true\n\t\t\t\tif (!status.failsafeTransitionStatus && status.status === 'ONLINE') return true\n\t\t\t\treturn false\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n\n\ttest('pool eventually enters ONLINE state', async () => {\n\t\tlet status: Awaited<ReturnType<typeof umbreld.client.hardware.raid.getStatus.query>>\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tstatus = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\treturn status.status === 'ONLINE'\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\texpect(status!.status).toBe('ONLINE')\n\t})\n\n\ttest('logs device sizes via lsblk', async () => {\n\t\tconsole.log(await umbreld.vm.ssh('lsblk --bytes'))\n\t})\n\n\ttest('expands RAID array with exact 4TB device', async () => {\n\t\tconst result = await umbreld.client.hardware.raid.addDevice.mutate({device: exactGenericDeviceId})\n\t\texpect(result).toBe(true)\n\t})\n\n\ttest('waits for expansion to complete (3 devices in array)', async () => {\n\t\tlet status: Awaited<ReturnType<typeof umbreld.client.hardware.raid.getStatus.query>>\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tstatus = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\treturn status.devices?.length === 3\n\t\t\t\t} catch {}\n\t\t\t\treturn false\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\texpect(status!.devices).toHaveLength(3)\n\t})\n\n\ttest('has all three devices in the array after expansion', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst deviceIds = status.devices!.map((d) => d.id).sort()\n\t\texpect(deviceIds).toEqual([largerPhisonDeviceId, smallerSamsungDeviceId, exactGenericDeviceId].sort())\n\t})\n\n\ttest('pool remains in failsafe mode after expansion', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(['ONLINE', 'DEGRADED']).toContain(status.status)\n\t})\n\n\ttest('logs device sizes via lsblk after expansion', async () => {\n\t\tconsole.log(await umbreld.vm.ssh('lsblk --bytes'))\n\t})\n\n\ttest('replaces larger Phison with small simulated drive', async () => {\n\t\tconst result = await umbreld.client.hardware.raid.replaceDevice.mutate({\n\t\t\toldDevice: largerPhisonDeviceId,\n\t\t\tnewDevice: smallSimulatedDeviceId,\n\t\t})\n\t\texpect(result).toBe(true)\n\t})\n\n\ttest('waits for replace to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\treturn status.replace?.state === 'finished' || !status.replace\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n\n\ttest('has small simulated drive in array instead of larger Phison after replace', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst deviceIds = status.devices!.map((d) => d.id)\n\t\texpect(deviceIds).toContain(smallSimulatedDeviceId)\n\t\texpect(deviceIds).not.toContain(largerPhisonDeviceId)\n\t\texpect(deviceIds).toContain(smallerSamsungDeviceId)\n\t\texpect(deviceIds).toContain(exactGenericDeviceId)\n\t})\n\n\ttest('pool remains in failsafe mode after replace', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(3)\n\t})\n\n\ttest('logs final device sizes via lsblk', async () => {\n\t\tconsole.log(await umbreld.vm.ssh('lsblk --bytes'))\n\t\tconsole.log(await umbreld.vm.ssh('lsblk --bytes'))\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-slot-swap.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\n\ndescribe('RAID survives SSD slot swap', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet firstDeviceId: string\n\tlet secondDeviceId: string\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds two NVMe devices and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1})\n\t\tawait umbreld.vm.addNvme({slot: 2})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects both NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\tconst device1 = devices.find((d) => d.slot === 1)\n\t\tconst device2 = devices.find((d) => d.slot === 2)\n\t\texpect(device1).toBeDefined()\n\t\texpect(device2).toBeDefined()\n\t\tfirstDeviceId = device1!.id!\n\t\tsecondDeviceId = device2!.id!\n\t})\n\n\ttest('registers user with storage RAID config (triggers reboot)', async () => {\n\t\tawait umbreld.signup({raidDevices: [firstDeviceId, secondDeviceId], raidType: 'storage'})\n\t})\n\n\ttest('waits for RAID setup to complete and logs in', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch (error) {\n\t\t\t\t\tif (error instanceof Error && error.message.includes('fetch failed')) {\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t\tthrow error\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('reports correct RAID status', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\ttest('creates marker directory to verify data consistency', async () => {\n\t\tawait umbreld.client.files.createDirectory.mutate({path: '/Home/data-consistency-marker'})\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n\n\ttest('shuts down VM', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t})\n\n\ttest('swaps NVMe devices between slots', async () => {\n\t\t// Swap slots: move 1 -> 3 (temp), 2 -> 1, 3 -> 2\n\t\tawait umbreld.vm.moveNvme({fromSlot: 1, toSlot: 3})\n\t\tawait umbreld.vm.moveNvme({fromSlot: 2, toSlot: 1})\n\t\tawait umbreld.vm.moveNvme({fromSlot: 3, toSlot: 2})\n\t})\n\n\ttest('boots VM after slot swap', async () => {\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('logs in after slot swap', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('detects devices with swapped slots', async () => {\n\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\n\t\t// Device IDs should be the same but in opposite slots\n\t\tconst device1 = devices.find((d) => d.slot === 1)\n\t\tconst device2 = devices.find((d) => d.slot === 2)\n\t\texpect(device1).toBeDefined()\n\t\texpect(device2).toBeDefined()\n\n\t\t// First device should now be in slot 2, second device in slot 1\n\t\texpect(device1!.id).toBe(secondDeviceId)\n\t\texpect(device2!.id).toBe(firstDeviceId)\n\t})\n\n\ttest('RAID pool is still ONLINE after slot swap', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\ttest('RAID devices have correct IDs after slot swap', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst deviceIds = status.devices!.map((d) => d.id).sort()\n\t\texpect(deviceIds).toEqual([firstDeviceId, secondDeviceId].sort())\n\t})\n\n\ttest('marker directory still exists after slot swap', async () => {\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-storage.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\n\ndescribe('RAID storage mode', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet firstDeviceId: string\n\tlet secondDeviceId: string\n\tlet initialTotalSpace: number\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds NVMe device and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects NVMe device in slot 1', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(1)\n\t\texpect(devices[0].slot).toBe(1)\n\t\tfirstDeviceId = devices[0].id!\n\t\texpect(firstDeviceId).toBeDefined()\n\t})\n\n\ttest('registers user with RAID config (triggers reboot)', async () => {\n\t\tawait umbreld.signup({raidDevices: [firstDeviceId], raidType: 'storage'})\n\t})\n\n\ttest('waits for RAID setup to complete and logs in', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// Ignore connection errors while VM is rebooting\n\t\t\t\t\tif (error instanceof Error && error.message.includes('fetch failed')) {\n\t\t\t\t\t\treturn false\n\t\t\t\t\t}\n\t\t\t\t\t// Rethrow server errors (e.g., initialRaidSetupError)\n\t\t\t\t\tthrow error\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 2000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('reports correct RAID status after setup', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(1)\n\t\texpect(status.devices![0].id).toBe(firstDeviceId)\n\t\tinitialTotalSpace = status.totalSpace!\n\t\texpect(initialTotalSpace).toBeGreaterThan(0)\n\t})\n\n\ttest('creates marker directory to verify data consistency', async () => {\n\t\tawait umbreld.client.files.createDirectory.mutate({path: '/Home/data-consistency-marker'})\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n\n\ttest('shuts down and adds second SSD', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.addNvme({slot: 2})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('logs in after adding second SSD', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('detects both NVMe devices after reboot', async () => {\n\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\tconst secondDevice = devices.find((d) => d.slot === 2)\n\t\texpect(secondDevice).toBeDefined()\n\t\tsecondDeviceId = secondDevice!.id!\n\t\texpect(secondDeviceId).toBeDefined()\n\t})\n\n\ttest('adds second SSD to RAID array', async () => {\n\t\tawait umbreld.client.hardware.raid.addDevice.mutate({\n\t\t\tdevice: secondDeviceId,\n\t\t})\n\t})\n\n\ttest('reports correct RAID status with both devices', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\ttest('has both devices in the array', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst deviceIds = status.devices!.map((d) => d.id).sort()\n\t\texpect(deviceIds).toEqual([firstDeviceId, secondDeviceId].sort())\n\t})\n\n\ttest('total space increased after adding second device', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.totalSpace!).toBeGreaterThan(initialTotalSpace)\n\t})\n\n\ttest('marker directory still exists after expansion', async () => {\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'data-consistency-marker')).toBe(true)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-transition-different-size.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\n\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\n\ndescribe('RAID transition with different sized drives', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet smallDeviceId: string\n\tlet mediumDeviceId: string\n\tlet largeDeviceId: string\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds three NVMe devices of different sizes and boots VM', async () => {\n\t\t// Small: 32GB, Medium: 64GB, Large: 128GB\n\t\tawait umbreld.vm.addNvme({slot: 1, size: '32G'})\n\t\tawait umbreld.vm.addNvme({slot: 2, size: '64G'})\n\t\tawait umbreld.vm.addNvme({slot: 3, size: '128G'})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects all three NVMe devices', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(3)\n\t\tsmallDeviceId = devices.find((d) => d.slot === 1)!.id!\n\t\tmediumDeviceId = devices.find((d) => d.slot === 2)!.id!\n\t\tlargeDeviceId = devices.find((d) => d.slot === 3)!.id!\n\t})\n\n\ttest('registers user with storage RAID using medium device', async () => {\n\t\tawait umbreld.signup({raidDevices: [mediumDeviceId], raidType: 'storage'})\n\t})\n\n\ttest('waits for RAID setup to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\treturn await umbreld.unauthenticatedClient.hardware.raid.checkInitialRaidSetupStatus.query()\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tawait umbreld.login()\n\t})\n\n\ttest('confirms storage mode RAID with medium device', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(1)\n\t\texpect(status.devices![0].id).toBe(mediumDeviceId)\n\t})\n\n\ttest('rejects transition to smaller device', async () => {\n\t\tawait expect(umbreld.client.hardware.raid.transitionToFailsafe.mutate({device: smallDeviceId})).rejects.toThrow(\n\t\t\t'Cannot transition to a device smaller than the current device',\n\t\t)\n\t})\n\n\ttest('transitions to failsafe mode with larger device', async () => {\n\t\tawait umbreld.client.hardware.raid.transitionToFailsafe.mutate({device: largeDeviceId})\n\t})\n\n\ttest('waits for VM to come back up after transition', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('waits for migration to complete (2 devices in array)', async () => {\n\t\tlet transitionError: string | undefined\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\tif (status.failsafeTransitionStatus?.state === 'error') {\n\t\t\t\t\t\ttransitionError = status.failsafeTransitionStatus.error\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t\treturn status.devices?.length === 2\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tif (transitionError) throw new Error(transitionError)\n\t})\n\n\ttest('reports correct RAID status in failsafe mode', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(['ONLINE', 'DEGRADED']).toContain(status.status)\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\ttest('waits for transition to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\tif (status.failsafeTransitionStatus?.state === 'complete') return true\n\t\t\t\t\tif (!status.failsafeTransitionStatus && status.status === 'ONLINE') return true\n\t\t\t\t\treturn false\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n\n\ttest('pool eventually enters ONLINE state', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\treturn status.status === 'ONLINE'\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.status).toBe('ONLINE')\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-transition-full-storage.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\n\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\n\ndescribe('RAID storage to failsafe transition with 90% full array', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet firstDeviceId: string\n\tlet secondDeviceId: string\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds one 5GB NVMe device and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1, size: '5G'})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects NVMe device', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(1)\n\t\texpect(devices[0].slot).toBe(1)\n\t\tfirstDeviceId = devices[0].id!\n\t\texpect(firstDeviceId).toBeDefined()\n\t})\n\n\ttest('registers user with storage RAID config (triggers reboot)', async () => {\n\t\tawait umbreld.signup({raidDevices: [firstDeviceId], raidType: 'storage'})\n\t})\n\n\ttest('waits for VM to come back up and logs in', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('reports correct RAID status in storage mode', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(1)\n\t})\n\n\ttest('creates marker directory to verify data migration', async () => {\n\t\tawait umbreld.client.files.createDirectory.mutate({path: '/Home/migration-test-directory'})\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'migration-test-directory')).toBe(true)\n\t})\n\n\ttest('fills array to over 90% capacity', async () => {\n\t\t// Get current usage\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst usedSpace = status.usedSpace ?? 0\n\t\tconst usableSpace = status.usableSpace ?? 1\n\n\t\t// Calculate how much to write to reach 91% (a bit over 90% to ensure we exceed threshold)\n\t\tconst targetUsage = 0.91\n\t\tconst bytesToWrite = Math.ceil(targetUsage * usableSpace - usedSpace)\n\t\tconst mbToWrite = Math.ceil(bytesToWrite / (1024 * 1024))\n\n\t\tconsole.log(`Current usage: ${((usedSpace / usableSpace) * 100).toFixed(1)}%`)\n\t\tconsole.log(`Writing ${mbToWrite}MB to reach ~91% capacity...`)\n\n\t\t// Write the data in one go\n\t\tawait umbreld.vm.ssh(`dd if=/dev/urandom of=~/fill-data.bin bs=1M count=${mbToWrite}`)\n\n\t\t// Verify we're over 90%\n\t\tconst finalStatus = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst finalUsage = ((finalStatus.usedSpace ?? 0) / (finalStatus.usableSpace ?? 1)) * 100\n\t\texpect(finalUsage).toBeGreaterThan(90)\n\t\tconsole.log(`Final usage before transition: ${finalUsage.toFixed(1)}%`)\n\t})\n\n\ttest('shuts down and adds second 5GB NVMe device', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.addNvme({slot: 2, size: '5G'})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('logs in after adding second device', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('detects both NVMe devices', async () => {\n\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\tconst secondDevice = devices.find((d) => d.slot === 2)\n\t\texpect(secondDevice).toBeDefined()\n\t\tsecondDeviceId = secondDevice!.id!\n\t})\n\n\ttest('starts transition to failsafe mode', async () => {\n\t\tconst result = await umbreld.client.hardware.raid.transitionToFailsafe.mutate({\n\t\t\tdevice: secondDeviceId,\n\t\t})\n\t\texpect(result).toBe(true)\n\t})\n\n\ttest('waits for VM to come back up after transition', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('waits for migration to complete (2 devices in array)', async () => {\n\t\tlet status: Awaited<ReturnType<typeof umbreld.client.hardware.raid.getStatus.query>>\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tstatus = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\tif (status.devices?.length === 2) return true\n\t\t\t\t} catch {}\n\t\t\t\tif (status?.failsafeTransitionStatus?.state === 'error') {\n\t\t\t\t\tthrow new Error(status.failsafeTransitionStatus.error)\n\t\t\t\t}\n\t\t\t\treturn false\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\texpect(status!.devices).toHaveLength(2)\n\t})\n\n\ttest('reports correct RAID status in failsafe mode after migration', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('failsafe')\n\t\texpect(['ONLINE', 'DEGRADED']).toContain(status.status)\n\t\texpect(status.devices).toHaveLength(2)\n\t})\n\n\ttest('has both devices in the array after migration', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst deviceIds = status.devices!.map((d) => d.id).sort()\n\t\texpect(deviceIds).toEqual([firstDeviceId, secondDeviceId].sort())\n\t})\n\n\ttest('waits for transition to complete', async () => {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\tif (status.failsafeTransitionStatus?.state === 'complete') return true\n\t\t\t\tif (!status.failsafeTransitionStatus && status.status === 'ONLINE') return true\n\t\t\t\treturn false\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t})\n\n\ttest('pool eventually enters ONLINE state', async () => {\n\t\tlet status: Awaited<ReturnType<typeof umbreld.client.hardware.raid.getStatus.query>>\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tstatus = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\treturn status.status === 'ONLINE'\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\texpect(status!.status).toBe('ONLINE')\n\t})\n\n\ttest('verifies marker directory was migrated correctly', async () => {\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'migration-test-directory')).toBe(true)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid-transition.vm.test.ts",
    "content": "import {expect, beforeAll, beforeEach, afterAll, afterEach, describe, test} from 'vitest'\n\nimport pWaitFor from 'p-wait-for'\n\nimport {createTestVm} from '../test-utilities/create-test-umbreld.js'\nimport type {FailsafeTransitionStatus} from './raid.js'\n\ndescribe('RAID storage to failsafe transition', () => {\n\tlet umbreld: Awaited<ReturnType<typeof createTestVm>>\n\tlet firstDeviceId: string\n\tlet secondDeviceId: string\n\tlet syncSubscription: ReturnType<typeof umbreld.subscribeToEvents<FailsafeTransitionStatus>>\n\tlet rebuildSubscription: ReturnType<typeof umbreld.subscribeToEvents<FailsafeTransitionStatus>>\n\t// Collect all status endpoint responses for verification\n\tconst transitionStatusCalls: FailsafeTransitionStatus[] = []\n\tlet failed = false\n\n\tbeforeAll(async () => {\n\t\tumbreld = await createTestVm()\n\t})\n\n\tafterAll(async () => {\n\t\tawait umbreld?.cleanup()\n\t})\n\n\tafterEach(({task}) => {\n\t\tif (task.result?.state === 'fail') failed = true\n\t})\n\n\tbeforeEach(({skip}) => {\n\t\tif (failed) skip()\n\t})\n\n\ttest('adds one NVMe device and boots VM', async () => {\n\t\tawait umbreld.vm.addNvme({slot: 1})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('detects NVMe device', async () => {\n\t\tconst devices = await umbreld.unauthenticatedClient.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(1)\n\t\texpect(devices[0].slot).toBe(1)\n\t\tfirstDeviceId = devices[0].id!\n\t\texpect(firstDeviceId).toBeDefined()\n\t})\n\n\ttest('registers user with storage RAID config (triggers reboot)', async () => {\n\t\tawait umbreld.signup({raidDevices: [firstDeviceId], raidType: 'storage'})\n\t})\n\n\ttest('waits for VM to come back up and logs in', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('reports correct RAID status in storage mode', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.status).toBe('ONLINE')\n\t\texpect(status.devices).toHaveLength(1)\n\t\texpect(status.devices![0].id).toBe(firstDeviceId)\n\t})\n\n\ttest('creates marker directory to verify data migration', async () => {\n\t\tawait umbreld.client.files.createDirectory.mutate({path: '/Home/migration-test-directory'})\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'migration-test-directory')).toBe(true)\n\t})\n\n\ttest('writes test data to ensure sync and rebuild take time', async () => {\n\t\t// Write 2GB of random data so sync/rebuild take long enough to capture progress\n\t\tawait umbreld.vm.ssh('dd if=/dev/urandom of=~/test-data.bin bs=1M count=2000')\n\t})\n\n\ttest('shuts down and adds second NVMe device', async () => {\n\t\tawait umbreld.vm.powerOff()\n\t\tawait umbreld.vm.addNvme({slot: 2})\n\t\tawait umbreld.vm.powerOn()\n\t})\n\n\ttest('logs in after adding second device', async () => {\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\t})\n\n\ttest('detects both NVMe devices', async () => {\n\t\tconst devices = await umbreld.client.hardware.internalStorage.getDevices.query()\n\t\texpect(devices).toHaveLength(2)\n\t\tconst secondDevice = devices.find((d) => d.slot === 2)\n\t\texpect(secondDevice).toBeDefined()\n\t\tsecondDeviceId = secondDevice!.id!\n\t\texpect(secondDeviceId).toBeDefined()\n\t})\n\n\ttest('still in storage mode with one device', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('storage')\n\t\texpect(status.devices).toHaveLength(1)\n\t})\n\n\ttest('receives transition progress (sync phase 0-50%) via WebSocket and status endpoint', async () => {\n\t\t// Subscribe to transition progress events before starting\n\t\tsyncSubscription = umbreld.subscribeToEvents<FailsafeTransitionStatus>('raid:failsafe-transition-progress')\n\n\t\t// Start transition without awaiting - we want to monitor progress while it runs\n\t\tconst transitionPromise = umbreld.client.hardware.raid.transitionToFailsafe.mutate({\n\t\t\tdevice: secondDeviceId,\n\t\t})\n\n\t\t// Wait for rebooting state at 50% (sync phase complete)\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\t// Collect status endpoint data on every poll\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\tif (status.failsafeTransitionStatus) {\n\t\t\t\t\ttransitionStatusCalls.push(status.failsafeTransitionStatus)\n\t\t\t\t}\n\n\t\t\t\tconst events = syncSubscription.collected\n\t\t\t\tconst lastEvent = events[events.length - 1]\n\t\t\t\treturn lastEvent?.state === 'rebooting' && lastEvent?.progress === 50\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\n\t\t// Wait for the mutation to complete\n\t\tconst result = await transitionPromise\n\t\texpect(result).toBe(true)\n\n\t\t// Unsubscribe since we're about to reboot\n\t\tsyncSubscription.unsubscribe()\n\n\t\tconst events = syncSubscription.collected\n\t\texpect(events.length).toBeGreaterThan(1)\n\n\t\t// Verify events have correct structure for sync phase\n\t\tfor (const event of events) {\n\t\t\texpect(['syncing', 'rebooting']).toContain(event.state)\n\t\t\texpect(event.progress).toBeGreaterThanOrEqual(0)\n\t\t\texpect(event.progress).toBeLessThanOrEqual(50)\n\t\t}\n\n\t\t// Verify progress only increased across events\n\t\tconst progressFromEvents = events.map((e) => e.progress)\n\t\tfor (let i = 1; i < progressFromEvents.length; i++) {\n\t\t\texpect(progressFromEvents[i]).toBeGreaterThanOrEqual(progressFromEvents[i - 1])\n\t\t}\n\n\t\t// Verify we have 0%, intermediate progress, and 50%\n\t\texpect(progressFromEvents).toContain(0)\n\t\texpect(progressFromEvents).toContain(50)\n\t\texpect(progressFromEvents.some((p) => p > 0 && p < 50)).toBe(true)\n\n\t\t// Verify final event is rebooting state at 50%\n\t\tconst lastEvent = events[events.length - 1]\n\t\texpect(lastEvent.state).toBe('rebooting')\n\t\texpect(lastEvent.progress).toBe(50)\n\n\t\t// Verify status endpoint calls for sync phase\n\t\tconst syncStatusCalls = transitionStatusCalls.filter((s) => s.progress <= 50)\n\t\texpect(syncStatusCalls.length).toBeGreaterThan(1)\n\n\t\t// Verify status calls have correct structure\n\t\tfor (const status of syncStatusCalls) {\n\t\t\texpect(['syncing', 'rebooting']).toContain(status.state)\n\t\t\texpect(status.progress).toBeGreaterThanOrEqual(0)\n\t\t\texpect(status.progress).toBeLessThanOrEqual(50)\n\t\t}\n\t})\n\n\ttest('waits for VM to come back up after transition and logs in', async () => {\n\t\t// After reboot, we need to wait for umbreld and re-login for WebSocket auth\n\t\tawait umbreld.waitForStartup({waitForUser: true})\n\t\tawait umbreld.login()\n\n\t\t// Subscribe to transition events immediately - rebuild is part of the transition (51-100%)\n\t\trebuildSubscription = umbreld.subscribeToEvents<FailsafeTransitionStatus>('raid:failsafe-transition-progress')\n\t})\n\n\ttest('waits for migration to complete (2 devices in array)', async () => {\n\t\t// umbreld completes the migration asynchronously on startup\n\t\t// Wait for both devices to appear in the array\n\t\tlet status: Awaited<ReturnType<typeof umbreld.client.hardware.raid.getStatus.query>>\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tstatus = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\t\tif (status.devices?.length === 2) return true\n\t\t\t\t} catch {}\n\t\t\t\tif (status?.failsafeTransitionStatus?.state === 'error') {\n\t\t\t\t\tthrow new Error(status.failsafeTransitionStatus.error)\n\t\t\t\t}\n\t\t\t\treturn false\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\texpect(status!.devices).toHaveLength(2)\n\t})\n\n\ttest('reports correct RAID status in failsafe mode after migration', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\texpect(status.exists).toBe(true)\n\t\texpect(status.raidType).toBe('failsafe')\n\t\t// Pool may be DEGRADED while rebuilding the old device into the array\n\t\texpect(['ONLINE', 'DEGRADED']).toContain(status.status)\n\t\texpect(status.devices).toHaveLength(2)\n\t\texpect(status.failsafeTransitionStatus?.state).not.toBe('error')\n\t})\n\n\ttest('has both devices in the array after migration', async () => {\n\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\tconst deviceIds = status.devices!.map((d) => d.id).sort()\n\t\texpect(deviceIds).toEqual([firstDeviceId, secondDeviceId].sort())\n\t})\n\n\ttest('receives transition progress (rebuild phase 51-100%) via WebSocket and status endpoint', async () => {\n\t\t// rebuildSubscription was set up earlier in 'waits for VM to come back up' test\n\t\t// so we capture events from the start of the rebuild phase\n\n\t\t// Poll status endpoint for transition progress while monitoring events\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\t// Collect status endpoint data on every poll\n\t\t\t\tconst status = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\tif (status.failsafeTransitionStatus) {\n\t\t\t\t\ttransitionStatusCalls.push(status.failsafeTransitionStatus)\n\t\t\t\t}\n\n\t\t\t\tif (status.failsafeTransitionStatus?.state === 'complete') {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t\t// Also consider complete if no transition status and pool is ONLINE\n\t\t\t\tif (!status.failsafeTransitionStatus && status.status === 'ONLINE') {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t\treturn false\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\n\t\t// Unsubscribe since transition is complete\n\t\trebuildSubscription.unsubscribe()\n\n\t\tconst events = rebuildSubscription.collected\n\n\t\t// Verify we received events via WebSocket\n\t\texpect(events.length).toBeGreaterThan(1)\n\n\t\t// Verify events have correct structure for rebuild phase\n\t\tfor (const event of events) {\n\t\t\texpect(['rebuilding', 'complete']).toContain(event.state)\n\t\t\texpect(event.progress).toBeGreaterThanOrEqual(50)\n\t\t\texpect(event.progress).toBeLessThanOrEqual(100)\n\t\t}\n\n\t\t// Verify progress only increased across events\n\t\tconst progressFromEvents = events.map((e) => e.progress)\n\t\tfor (let i = 1; i < progressFromEvents.length; i++) {\n\t\t\texpect(progressFromEvents[i]).toBeGreaterThanOrEqual(progressFromEvents[i - 1])\n\t\t}\n\n\t\t// Verify we captured intermediate progress (not just start/end state)\n\t\texpect(progressFromEvents.some((p) => p > 50 && p < 100)).toBe(true)\n\n\t\t// Verify status endpoint calls for rebuild phase\n\t\tconst rebuildStatusCalls = transitionStatusCalls.filter((s) => s.progress > 50)\n\t\texpect(rebuildStatusCalls.length).toBeGreaterThan(1)\n\n\t\t// Verify status calls have correct structure\n\t\tfor (const status of rebuildStatusCalls) {\n\t\t\texpect(['rebuilding', 'complete']).toContain(status.state)\n\t\t\texpect(status.progress).toBeGreaterThanOrEqual(50)\n\t\t\texpect(status.progress).toBeLessThanOrEqual(100)\n\t\t}\n\n\t\t// Verify final status shows completion\n\t\tconst lastStatus = transitionStatusCalls[transitionStatusCalls.length - 1]\n\t\texpect(lastStatus.progress).toBe(100)\n\t\texpect(lastStatus.state).toBe('complete')\n\t})\n\n\ttest('pool eventually enters ONLINE state', async () => {\n\t\tlet status: Awaited<ReturnType<typeof umbreld.client.hardware.raid.getStatus.query>>\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\tstatus = await umbreld.client.hardware.raid.getStatus.query()\n\t\t\t\treturn status.status === 'ONLINE'\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\texpect(status!.status).toBe('ONLINE')\n\t})\n\n\ttest('verifies marker directory was migrated correctly', async () => {\n\t\tconst listing = await umbreld.client.files.list.query({path: '/Home'})\n\t\texpect(listing.files.some((f) => f.name === 'migration-test-directory')).toBe(true)\n\t})\n\n\t// Unskip this test for extra debug info\n\ttest('log all events', async () => {\n\t\tconsole.log('Sync phase events:', syncSubscription.collected)\n\t\tconsole.log('Rebuild phase events:', rebuildSubscription.collected)\n\t\tconsole.log('All transition status calls:', transitionStatusCalls)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid.getRoundedDeviceSize.unit.test.ts",
    "content": "import {describe, test, expect} from 'vitest'\n\nimport {getRoundedDeviceSize} from './raid.js'\n\ndescribe('getRoundedDeviceSize', () => {\n\t// Under 1TB - should return unchanged\n\ttest('returns unchanged for sizes under 1TB', () => {\n\t\texpect(getRoundedDeviceSize(0)).toBe(0)\n\t\texpect(getRoundedDeviceSize(1)).toBe(1)\n\t\texpect(getRoundedDeviceSize(32_000_000_000)).toBe(32_000_000_000) // 32GB\n\t\texpect(getRoundedDeviceSize(64_000_000_000)).toBe(64_000_000_000) // 64GB\n\t\texpect(getRoundedDeviceSize(128_000_000_000)).toBe(128_000_000_000) // 128GB\n\t\texpect(getRoundedDeviceSize(256_000_000_000)).toBe(256_000_000_000) // 256GB\n\t\texpect(getRoundedDeviceSize(512_000_000_000)).toBe(512_000_000_000) // 512GB\n\t\texpect(getRoundedDeviceSize(999_999_999_999)).toBe(999_999_999_999) // Just under 1TB\n\t})\n\n\t// Exactly 1TB boundary\n\ttest('rounds down exactly 1TB to 1TB', () => {\n\t\texpect(getRoundedDeviceSize(1_000_000_000_000)).toBe(1_000_000_000_000)\n\t})\n\n\t// 1TB+ should round to nearest 250GB\n\ttest('rounds 1TB+ down to nearest 250GB', () => {\n\t\t// Just over 1TB - rounds to 1TB\n\t\texpect(getRoundedDeviceSize(1_000_000_000_001)).toBe(1_000_000_000_000)\n\t\texpect(getRoundedDeviceSize(1_100_000_000_000)).toBe(1_000_000_000_000)\n\n\t\t// 1.25TB exactly\n\t\texpect(getRoundedDeviceSize(1_250_000_000_000)).toBe(1_250_000_000_000)\n\n\t\t// Between 1.25TB and 1.5TB - rounds to 1.25TB\n\t\texpect(getRoundedDeviceSize(1_400_000_000_000)).toBe(1_250_000_000_000)\n\n\t\t// 1.5TB exactly\n\t\texpect(getRoundedDeviceSize(1_500_000_000_000)).toBe(1_500_000_000_000)\n\n\t\t// 2TB\n\t\texpect(getRoundedDeviceSize(2_000_000_000_000)).toBe(2_000_000_000_000)\n\t\texpect(getRoundedDeviceSize(2_100_000_000_000)).toBe(2_000_000_000_000)\n\t})\n\n\t// 3.8TB SSD - rounds down to 3.75TB (15 x 250GB)\n\ttest('rounds 3.8TB SSD size correctly', () => {\n\t\tconst SSD_3_8TB = 3_800_000_000_000 // 3.8TB\n\n\t\t// Rounds down to 3.75TB (15 x 250GB)\n\t\texpect(getRoundedDeviceSize(SSD_3_8TB)).toBe(3_750_000_000_000)\n\t})\n\n\t// Real-world 4TB SSD sizes\n\ttest('rounds real 4TB SSD sizes correctly', () => {\n\t\tconst PHISON_4TB = 4_096_805_658_624 // Larger Phison SSD\n\t\tconst SAMSUNG_4TB = 4_000_787_030_016 // Smaller Samsung SSD\n\t\tconst EXACT_4TB = 4_000_000_000_000 // Exactly 4TB\n\n\t\t// All should round down to 4TB (16 x 250GB)\n\t\texpect(getRoundedDeviceSize(PHISON_4TB)).toBe(4_000_000_000_000)\n\t\texpect(getRoundedDeviceSize(SAMSUNG_4TB)).toBe(4_000_000_000_000)\n\t\texpect(getRoundedDeviceSize(EXACT_4TB)).toBe(4_000_000_000_000)\n\t})\n\n\t// Hypothetical 2TB SSD sizes\n\ttest('rounds 2TB SSD sizes correctly', () => {\n\t\tconst LARGER_2TB = 2_048_000_000_000 // 2.048TB - rounds down to 2TB\n\t\tconst SMALLER_2TB = 2_000_500_000_000 // Just over 2TB - rounds down to 2TB\n\t\tconst EXACT_2TB = 2_000_000_000_000\n\n\t\t// All should round down to 2TB (8 x 250GB)\n\t\texpect(getRoundedDeviceSize(LARGER_2TB)).toBe(2_000_000_000_000)\n\t\texpect(getRoundedDeviceSize(SMALLER_2TB)).toBe(2_000_000_000_000)\n\t\texpect(getRoundedDeviceSize(EXACT_2TB)).toBe(2_000_000_000_000)\n\t})\n\n\t// Hypothetical 8TB SSD sizes\n\ttest('rounds 8TB SSD sizes correctly', () => {\n\t\tconst LARGER_8TB = 8_100_000_000_000 // 8.1TB - rounds down to 8TB\n\t\tconst EXACT_8TB = 8_000_000_000_000\n\n\t\t// All should round down to 8TB (32 x 250GB)\n\t\texpect(getRoundedDeviceSize(LARGER_8TB)).toBe(8_000_000_000_000)\n\t\texpect(getRoundedDeviceSize(EXACT_8TB)).toBe(8_000_000_000_000)\n\t})\n\n\t// Edge cases around 250GB boundaries\n\ttest('handles 250GB boundaries correctly', () => {\n\t\t// Just under 1.25TB - rounds to 1TB\n\t\texpect(getRoundedDeviceSize(1_249_999_999_999)).toBe(1_000_000_000_000)\n\n\t\t// Exactly 1.25TB\n\t\texpect(getRoundedDeviceSize(1_250_000_000_000)).toBe(1_250_000_000_000)\n\n\t\t// Just over 1.25TB - still rounds to 1.25TB\n\t\texpect(getRoundedDeviceSize(1_250_000_000_001)).toBe(1_250_000_000_000)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/raid.ts",
    "content": "import crypto from 'node:crypto'\nimport {setTimeout} from 'node:timers/promises'\n\nimport fse from 'fs-extra'\nimport {$} from 'execa'\nimport pRetry from 'p-retry'\n\nimport type Umbreld from '../../index.js'\nimport FileStore from '../utilities/file-store.js'\nimport {reboot} from '../system/system.js'\nimport {setSystemStatus} from '../system/routes.js'\nimport runEvery from '../utilities/run-every.js'\n\n// Get the size of a block device or partition in bytes\nasync function getDeviceSize(device: string): Promise<number> {\n\tconst {stdout} = await $`lsblk --output SIZE --bytes --nodeps --noheadings ${device}`\n\treturn parseInt(stdout.trim(), 10)\n}\n\n// Round device size down to nearest 250GB if over 1TB\n// This ensures drives of slightly different sizes can be used together in RAID\nexport function getRoundedDeviceSize(sizeInBytes: number): number {\n\tconst oneTerabyte = 1_000_000_000_000\n\tconst twoFiftyGigabytes = 250_000_000_000\n\tif (sizeInBytes >= oneTerabyte) {\n\t\treturn Math.floor(sizeInBytes / twoFiftyGigabytes) * twoFiftyGigabytes\n\t}\n\treturn sizeInBytes\n}\n\nexport type RaidType = 'storage' | 'failsafe'\n\nexport type ExpansionStatus = {\n\tstate: 'expanding' | 'finished' | 'canceled'\n\tprogress: number\n}\n\nexport type FailsafeTransitionStatus = {\n\tstate: 'syncing' | 'rebooting' | 'rebuilding' | 'complete' | 'error'\n\tprogress: number\n\terror?: string\n}\n\nexport type RebuildStatus = {\n\tstate: 'rebuilding' | 'finished' | 'canceled'\n\tprogress: number\n}\n\nexport type ReplaceStatus = {\n\tstate: 'rebuilding' | 'expanding' | 'finished' | 'canceled'\n\tprogress: number\n}\n\n// Types for zpool status --json --json-int --json-flat-vdevs output\ntype State = 'ONLINE' | 'DEGRADED' | 'FAULTED' | 'OFFLINE' | 'UNAVAIL' | 'REMOVED' | 'CANT_OPEN'\ntype Vdev = {\n\tvdev_type: 'root' | 'raidz' | 'mirror' | 'disk' | 'file'\n\tpath?: string\n\trep_dev_size?: number\n\tphys_space?: number\n\tslow_ios?: number\n\tname: string\n\tguid: number\n\tclass: string\n\tparent?: string\n\tstate: State\n\talloc_space: number\n\ttotal_space: number\n\tdef_space: number\n\tread_errors: number\n\twrite_errors: number\n\tchecksum_errors: number\n}\ntype ScanStats = {\n\tfunction: 'SCRUB' | 'RESILVER'\n\tstate: 'SCANNING' | 'FINISHED' | 'CANCELED'\n\tstart_time: number\n\tend_time: number\n\tto_examine: number\n\texamined: number\n\tskipped: number\n\tprocessed: number\n\terrors: number\n\tbytes_per_scan: number\n\tpass_start: number\n\tscrub_pause: number\n\tscrub_spent_paused: number\n\tissued_bytes_per_scan: number\n\tissued: number\n}\ntype RaidzExpandStats = {\n\tname: string\n\tstate: 'SCANNING' | 'FINISHED' | 'CANCELED'\n\texpanding_vdev: number\n\tstart_time: number\n\tend_time: number\n\tto_reflow: number\n\treflowed: number\n\twaiting_for_resilver: number\n}\ntype Pool = {\n\tname: string\n\tstate: State\n\tpool_guid: number\n\ttxg: number\n\tspa_version: number\n\tzpl_version: number\n\terror_count: number\n\tstatus?: string\n\taction?: string\n\tmsgid?: string\n\tmoreinfo?: string\n\tscan_stats?: ScanStats\n\traidz_expand_stats?: RaidzExpandStats\n\tvdevs: Record<string, Vdev>\n}\ntype ZpoolStatusOutput = {\n\toutput_version: {\n\t\tcommand: string\n\t\tvers_major: number\n\t\tvers_minor: number\n\t}\n\tpools: Record<string, Pool>\n}\n\ntype ConfigStore = {\n\tuser?: {\n\t\tname: string\n\t\thashedPassword?: string\n\t\tpassword?: string\n\t\tlanguage: string\n\t}\n\traid?: {\n\t\tpoolName: string\n\t\tstate: 'normal' | 'transitioning-to-failsafe'\n\t\tdevices: string[]\n\t\traidType: RaidType\n\t}\n}\n\nexport default class Raid {\n\t#umbreld: Umbreld\n\tlogger: Umbreld['logger']\n\tconfigStore: FileStore<ConfigStore>\n\tisTransitioningToFailsafe = false\n\tisReplacing = false\n\tfailsafeTransitionStatus?: FailsafeTransitionStatus\n\treplaceStatus?: ReplaceStatus\n\tinitialRaidSetupError?: Error\n\tpoolNameBase = 'umbrelos'\n\ttemporaryDevicePath = '/tmp/umbrelos-temporary-migration-device.img'\n\t#lastExpansionProgress = 0\n\t#lastRebuildProgress = 0\n\t#stopPoolMonitor?: () => void\n\t#lastEmittedExpansion?: ExpansionStatus\n\t#lastEmittedRebuild?: RebuildStatus\n\t#lastEmittedReplace?: ReplaceStatus\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(`hardware:${name.toLowerCase()}`)\n\n\t\t// Create a file store for the config file with hooks to handle read-only partition\n\t\tconst configPartition = '/run/rugix/mounts/config'\n\t\tconst configFile = `${configPartition}/umbrel.yaml`\n\t\tthis.configStore = new FileStore<ConfigStore>({\n\t\t\tfilePath: configFile,\n\t\t\tonBeforeWrite: () => $`mount -o remount,rw ${configPartition}`,\n\t\t\t// This occasionally fails with \"mount point is busy\" errors. I have no idea why because all writes are\n\t\t\t// queued so there should be no open file handles and remount should flush writes. Retrying with a delay\n\t\t\t// makes this extremely unlikely to ever fail but blocks the write queue. It's acceptable since config\n\t\t\t// writes are rare. On the tiny chance that 5 retries fail we just log the error and move on without failing\n\t\t\t// to avoid blocking the write queue. This means the config partition would be left in rw state which isn't ideal\n\t\t\t// but it's very unlikely to happen and less bad than killing the current operation which is probably quite\n\t\t\t// critical if it's touching the RAID config file like a ZFS dataset migration. The next boot or config udpate\n\t\t\t// should successfully remount the partition read-only.\n\t\t\tonAfterWrite: () =>\n\t\t\t\tpRetry(() => $`mount -o remount,ro ${configPartition}`, {\n\t\t\t\t\tretries: 5,\n\t\t\t\t\tfactor: 1.1,\n\t\t\t\t\tminTimeout: 100,\n\t\t\t\t}).catch((error) => {\n\t\t\t\t\tthis.logger.error('Failed to remount config partition read-only', error)\n\t\t\t\t}),\n\t\t})\n\t}\n\n\tasync hasConfigStore() {\n\t\treturn await fse.pathExists(this.configStore.filePath)\n\t}\n\n\t// Generate a unique pool name with random suffix to avoid collisions\n\t// when SSDs from other Umbrel installations are connected\n\tgeneratePoolName(): string {\n\t\tconst suffix = crypto.randomBytes(4).toString('hex')\n\t\treturn `${this.poolNameBase}-${suffix}`\n\t}\n\n\tasync start() {\n\t\tthis.logger.log('Starting RAID')\n\n\t\t// Start pool monitor to send realtime events\n\t\t// This must happen before any operations that trigger rebuild/expansion\n\t\t// so events are captured from the start\n\t\ttry {\n\t\t\tthis.#startPoolMonitor()\n\t\t} catch (error) {\n\t\t\tthis.logger.error('Failed to start pool monitor', error)\n\t\t}\n\n\t\t// Handle initial RAID setup after first boot with the new array\n\t\tawait this.handlePostBootRaidSetupProcess().catch((error) =>\n\t\t\tthis.logger.error('Failed to handle initial RAID setup boot', error),\n\t\t)\n\n\t\t// Check if we are currently transitioning to failsafe mode and complete the migration\n\t\tawait this.#completeFailsafeTransition().catch((error) => {\n\t\t\tthis.logger.error('Failed to complete FailSafe transition:', error)\n\t\t})\n\n\t\t// TODO: Monthly scrub\n\t}\n\n\tasync stop() {\n\t\tthis.logger.log('Stopping RAID')\n\t\tthis.#stopPoolMonitor?.()\n\t}\n\n\t#startPoolMonitor() {\n\t\tthis.#stopPoolMonitor = runEvery(\n\t\t\t'1 second',\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst pool = await this.getStatus()\n\n\t\t\t\t\t// Emit expansion progress events\n\t\t\t\t\tif (pool.expansion) {\n\t\t\t\t\t\tconst last = this.#lastEmittedExpansion\n\t\t\t\t\t\tif (last?.state !== pool.expansion.state || last?.progress !== pool.expansion.progress) {\n\t\t\t\t\t\t\tthis.#lastEmittedExpansion = pool.expansion\n\t\t\t\t\t\t\tthis.#umbreld.eventBus.emit('raid:expansion-progress', pool.expansion)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Emit rebuild progress events (for normal rebuilds, not failsafe transitions)\n\t\t\t\t\tif (pool.rebuild) {\n\t\t\t\t\t\tconst last = this.#lastEmittedRebuild\n\t\t\t\t\t\tif (last?.state !== pool.rebuild.state || last?.progress !== pool.rebuild.progress) {\n\t\t\t\t\t\t\tthis.#lastEmittedRebuild = pool.rebuild\n\t\t\t\t\t\t\tthis.#umbreld.eventBus.emit('raid:rebuild-progress', pool.rebuild)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Silently ignore errors during monitoring\n\t\t\t\t}\n\t\t\t},\n\t\t\t{runInstantly: true},\n\t\t)\n\t}\n\n\t// Get status of the main RAID pool with migration error if any\n\tasync getStatus() {\n\t\tconst name = await this.configStore.get('raid.poolName')\n\t\tconst status = await this.getPoolStatus(name)\n\n\t\treturn {\n\t\t\tname,\n\t\t\t...status,\n\t\t\treplace: this.replaceStatus,\n\t\t\tfailsafeTransitionStatus: this.failsafeTransitionStatus,\n\t\t\t// Supress degraded state when performing failsafe transition\n\t\t\tstatus:\n\t\t\t\tthis.failsafeTransitionStatus?.state === 'rebuilding' && status.status === 'DEGRADED'\n\t\t\t\t\t? 'ONLINE'\n\t\t\t\t\t: status.status,\n\t\t}\n\t}\n\n\t// Get status of a RAID pool\n\tasync getPoolStatus(poolName: string): Promise<{\n\t\texists: boolean\n\t\traidType?: RaidType\n\t\ttotalSpace?: number\n\t\tusableSpace?: number\n\t\tusedSpace?: number\n\t\tfreeSpace?: number\n\t\tstatus?: State\n\t\tdevices?: Array<{\n\t\t\tid: string\n\t\t\tstatus: State\n\t\t\treadErrors: number\n\t\t\twriteErrors: number\n\t\t\tchecksumErrors: number\n\t\t}>\n\t\texpansion?: ExpansionStatus\n\t\trebuild?: RebuildStatus\n\t}> {\n\t\t// Get pool status from ZFS\n\t\tlet pool\n\t\ttry {\n\t\t\tconst {stdout} = await $`zpool status --json --json-int --json-flat-vdevs ${poolName}`\n\t\t\tconst zpoolStatus = JSON.parse(stdout) as ZpoolStatusOutput\n\t\t\tpool = zpoolStatus.pools?.[poolName]\n\t\t} catch {}\n\t\tif (!pool) return {exists: false}\n\n\t\tconst vdevs = Object.values(pool.vdevs)\n\n\t\t// Determine RAID type from topology\n\t\tconst isRaidz = vdevs.some((v) => v.vdev_type === 'raidz')\n\t\tconst raidType = isRaidz ? 'failsafe' : 'storage'\n\n\t\t// Filter vdevs by type\n\t\tconst rootVdev = vdevs.find((v) => v.vdev_type === 'root')\n\t\tconst diskVdevs = vdevs.filter((v) => v.vdev_type === 'disk')\n\t\tconst fileVdevs = vdevs.filter((v) => v.vdev_type === 'file')\n\n\t\tif (!rootVdev) return {exists: false}\n\n\t\t// Parse expansion progress (only relevant for failsafe/raidz mode)\n\t\tlet expansion: ExpansionStatus | undefined\n\t\tif (pool.raidz_expand_stats) {\n\t\t\tconst stats = pool.raidz_expand_stats\n\t\t\tconst stateMap = {SCANNING: 'expanding', FINISHED: 'finished', CANCELED: 'canceled'} as const\n\t\t\tconst state = stateMap[stats.state]\n\n\t\t\tlet progress: number\n\t\t\tif (state === 'finished' || state === 'canceled') {\n\t\t\t\t// Reset tracker when expansion ends so next expansion starts from 0\n\t\t\t\tthis.#lastExpansionProgress = 0\n\t\t\t\tprogress = state === 'finished' ? 100 : 0\n\t\t\t} else {\n\t\t\t\t// We need to do some awkward handling here because stats.to_reflow keeps growing forever and messes up the calculations.\n\t\t\t\t// Cap progress at 99 while expanding due to overflow bugs\n\t\t\t\tconst rawProgress = stats.to_reflow > 0 ? Math.floor((stats.reflowed / stats.to_reflow) * 100) : 0\n\t\t\t\tconst cappedProgress = Math.min(rawProgress, 99)\n\t\t\t\t// Never let progress go backwards\n\t\t\t\tprogress = Math.max(cappedProgress, this.#lastExpansionProgress)\n\t\t\t\tthis.#lastExpansionProgress = progress\n\t\t\t}\n\n\t\t\texpansion = {state, progress}\n\t\t}\n\n\t\t// Parse rebuild progress (ZFS calls this \"resilver\")\n\t\tlet rebuild: RebuildStatus | undefined\n\t\tif (pool.scan_stats?.function === 'RESILVER') {\n\t\t\tconst stats = pool.scan_stats\n\t\t\tconst stateMap = {SCANNING: 'rebuilding', FINISHED: 'finished', CANCELED: 'canceled'} as const\n\t\t\tconst state = stateMap[stats.state]\n\n\t\t\tlet progress: number\n\t\t\tif (state === 'finished' || state === 'canceled') {\n\t\t\t\t// Reset tracker when rebuild ends so next rebuild starts from 0\n\t\t\t\tthis.#lastRebuildProgress = 0\n\t\t\t\tprogress = state === 'finished' ? 100 : 0\n\t\t\t} else {\n\t\t\t\t// Calculate progress from issued vs to_examine\n\t\t\t\tconst rawProgress = stats.to_examine > 0 ? Math.floor((stats.issued / stats.to_examine) * 100) : 0\n\t\t\t\tconst cappedProgress = Math.min(rawProgress, 99)\n\t\t\t\t// Never let progress go backwards\n\t\t\t\tprogress = Math.max(cappedProgress, this.#lastRebuildProgress)\n\t\t\t\tthis.#lastRebuildProgress = progress\n\t\t\t}\n\n\t\t\trebuild = {state, progress}\n\t\t}\n\n\t\tconst devices = diskVdevs.map((device) => ({\n\t\t\tid: device.path!.replace('/dev/disk/by-id/', '').replace(/-part\\d+$/, ''),\n\t\t\tsize: device.phys_space,\n\t\t\tstatus: device.state,\n\t\t\treadErrors: device.read_errors,\n\t\t\twriteErrors: device.write_errors,\n\t\t\tchecksumErrors: device.checksum_errors,\n\t\t}))\n\n\t\t// ZFS has an accounting bug where after raidz expansion, it uses the old parity ratio the array was created with\n\t\t// to calculate total usable space, used space and free space. Since most users will upgade SSDs one\n\t\t// at a time this means they'll start with a parity ratio of 50%. Adding a 3rd disk gives\n\t\t// them a parity ratio of 33%. Meaning upgrading from 2 1TB SSDs to 3 1TB SSDs they'd expect to go from\n\t\t// 1TB usable space and 1TB parity, to 2TB usable space and 2TB parity. However ZFS will report the old\n\t\t// parity ratio of 50% and incorrectly say there is 1.5TB usable space and 1.5TB parity. Even after adding more SSDs and increasing\n\t\t// parity ratio further, ZFS will continue to report 50%. It's very confusing and makes it appear as\n\t\t// though multiple TBs of storage are being lost. The used space and free space values are also incorrect\n\t\t// for the same reason. The expected amount of data can still be written, it's just a reporting issue.\n\t\t// - https://github.com/openzfs/zfs/issues/17784\n\t\t//\n\t\t// We fix this by calculating the usable space ourself by doing:\n\t\t//   smallest device size * (number of devices - 1 parity device)\n\t\t// We then take the total space and total raw allocated space (including parity overhead) reported by ZFS\n\t\t// and calculate the used percentage of the array. We then multiply the usable space by the used percentage to\n\t\t// get the actual used space.\n\t\t// This results in the expected usable space, used space and free space values after raidz expansion.\n\t\t//\n\t\t// One quirk of this approach is that we have the opposite accounting issue. Instead of treating all data\n\t\t// as using the old parity ratio, we treat all data as using the new parity ratio. When in fact there is a mix.\n\t\t// This means after expansion the used space will jump up as we're assuming that the raw data was using the new\n\t\t// higher parity ratio. So now total and available space are correct but used space is incorrect. This is much\n\t\t// less confusing. It will also correct itself over time. As those files are deleted or modified they'll be\n\t\t// re-written using the new parity ratio bringing down the used space.\n\t\t// For example starting with 100GB used space and upgrading from 2 devices to 3 will show ~150GB used space.\n\t\t// This effectively means the 100GB in the old inneficient parity ratio is taking up enough space to write 150GB\n\t\t// of data with the new parity ratio. Deleting this 100GB file frees up 150GB of space for new files.\n\t\tlet totalSpace = rootVdev.total_space\n\t\tlet usableSpace = rootVdev.def_space\n\t\tlet usedSpace = rootVdev.alloc_space\n\t\t// Only run this logic with more than 2 devices so we don't run it during the transition which does lots of\n\t\t// complex file vdev replace weirdness which makes the below calculations very complex. We don't need this\n\t\t// if we only have two devices anyway since ZFS default reporting is reliable in that case.\n\t\tif (raidType === 'failsafe' && diskVdevs.length > 2) {\n\t\t\t// Take file vdevs into account to handle the migration pool correctly which\n\t\t\t// has a file vdev. Otherwise we can't track FailSafe transition progress since we watch\n\t\t\t// the migration pools size to calculate snapshot sync progress.\n\t\t\tlet numberOfDevices = diskVdevs.length + fileVdevs.length\n\n\t\t\t// If we're currently replacing a device, don't count it twice. We just assume there's only ever one\n\t\t\t// replacement happening at a time to keep things simple.\n\t\t\tconst isReplacing = [...diskVdevs, ...fileVdevs].some((vdev) => vdev.parent?.includes('replacing'))\n\t\t\tif (isReplacing) numberOfDevices -= 1\n\n\t\t\t// Calculate usable space based on the smallest device size and accounting for a single parity device\n\t\t\tconst smallestDeviceSize = Math.min(...devices.map((d) => d.size).filter((size) => size !== undefined))\n\t\t\tusableSpace = smallestDeviceSize * (numberOfDevices - 1)\n\n\t\t\t// ZFS doesn't update total space until the end of a raidz1 expansion which breaks our calculations.\n\t\t\t// We need to calculate it as soon as we start a resize by summing all the devices.\n\t\t\t// We need to use phys_space because rep_dev_size can randomly change during operations.\n\t\t\t// However sometimes on missing devices phys_space doesn't exist but rep_dev_size does, and in those\n\t\t\t// situations it's stable so we can rely on it.\n\t\t\t// If we're replacing a device we skip the missing one so we don't count it twice.\n\t\t\ttotalSpace = [...diskVdevs, ...fileVdevs]\n\t\t\t\t.filter((vdev) => !(vdev.state === 'CANT_OPEN' && vdev.parent?.includes('replacing')))\n\t\t\t\t.reduce((sum, vdev) => sum + (vdev.phys_space || vdev.rep_dev_size || 0), 0)\n\n\t\t\t// Now we need to also fix usedSpace calculations otherwise we'll go negative\n\t\t\t// with the smaller usable space value.\n\t\t\t// The used space percentage is reliable from (alloc / total)\n\t\t\t// so we can multiply usableSpace by the percentage\n\t\t\tconst usedPercentage = rootVdev.alloc_space / totalSpace\n\t\t\tusedSpace = Math.ceil(usableSpace * usedPercentage)\n\n\t\t\t// TODO: Handle FailSafe replace with old devices online\n\n\t\t\t// TODO: Handle mixed size devices\n\t\t}\n\n\t\t// Handle failsafe mode with 2 disks (or 1 disk and 1 file) reporting double usage due to\n\t\t// the parity device being included in the total space.\n\t\tif (raidType === 'failsafe' && diskVdevs.length <= 2) usedSpace /= 2\n\n\t\treturn {\n\t\t\texists: true,\n\t\t\traidType,\n\t\t\ttotalSpace,\n\t\t\tusableSpace,\n\t\t\tusedSpace,\n\t\t\tfreeSpace: usableSpace - usedSpace,\n\t\t\tstatus: pool.state,\n\t\t\tdevices,\n\t\t\texpansion,\n\t\t\trebuild,\n\t\t}\n\t}\n\n\t// Trigger initial RAID setup boot process\n\tasync triggerInitialRaidSetupBootFlow(\n\t\traidDevices: string[],\n\t\traidType: RaidType,\n\t\tuser: {name: string; password: string; language: string},\n\t) {\n\t\t// Setup the RAID array\n\t\tawait this.setup(raidDevices, raidType)\n\n\t\t// Temporarily store the user setup details\n\t\t// We handle setting up the user on the next boot\n\t\tawait this.configStore.set('user', user)\n\n\t\t// Reboot the system into the RAID array\n\t\tsetTimeout(1000).then(() => reboot()) // Schedule in 1 second so the api response has time to be sent\n\n\t\treturn true\n\t}\n\n\t// Handle initial RAID setup after first boot with the new array\n\tasync handlePostBootRaidSetupProcess() {\n\t\t// Check if we're on the first boot after RAID setup\n\t\tconst raidConfigUser = await this.configStore.get('user')\n\t\tconst userExists = await this.#umbreld.user.exists()\n\t\tif (raidConfigUser?.name && raidConfigUser?.password && !userExists) {\n\t\t\tthis.logger.log('Detected first boot after RAID setup, creating user')\n\t\t\ttry {\n\t\t\t\t// Create the user which will also update user/hashedPassword in the RAID config\n\t\t\t\tawait this.#umbreld.user.register(raidConfigUser.name, raidConfigUser.password, raidConfigUser.language ?? 'en')\n\n\t\t\t\t// Wipe the plain text password from the RAID config\n\t\t\t\tawait this.configStore\n\t\t\t\t\t.delete('user.password')\n\t\t\t\t\t.catch((error) => this.logger.error('Failed to delete password from RAID config', error))\n\t\t\t} catch (error) {\n\t\t\t\tthis.logger.error('Failed to create user', error)\n\t\t\t\t// If this fails we save the error to return over the API to the UI\n\t\t\t\tthis.initialRaidSetupError = error as Error\n\t\t\t}\n\t\t}\n\t}\n\n\t// Check the status of the RAID setup boot process\n\tasync checkInitialRaidSetupStatus(): Promise<boolean> {\n\t\t// Throw error if we failed to create the user\n\t\tif (this.initialRaidSetupError) throw this.initialRaidSetupError\n\n\t\t// Return false if the RAID array doesn't exist yet\n\t\tconst pool = await this.getStatus()\n\t\tif (!pool.exists) return false\n\n\t\t// Return false if the user isn't created yet\n\t\tconst userExists = await this.#umbreld.user.exists()\n\t\tif (!userExists) return false\n\n\t\t// Return false if the app store hasn't attempted to complete it's initial sync yet\n\t\tif (!this.#umbreld.appStore.attemptedInitialAppStoreUpdate) return false\n\n\t\t// Initial RAID setup is complete\n\t\treturn true\n\t}\n\n\t// Check if RAID mount failed during boot\n\tasync checkRaidMountFailure(): Promise<boolean> {\n\t\treturn fse.pathExists('/run/rugix/mounts/data/.rugix/data-mount-error.log')\n\t}\n\n\t// Get details about why RAID mount failed by running a test import\n\tasync checkRaidMountFailureDevices(): Promise<Array<{name: string; isOk: boolean}>> {\n\t\tconst {stdout} = await $`zpool import -N`\n\t\tconst expectedDevices = ((await this.configStore.get('raid.devices')) ?? []) as string[]\n\n\t\treturn expectedDevices.map((device) => {\n\t\t\tconst name = device.replace('/dev/disk/by-id/', '')\n\t\t\tconst isOk = stdout.split('\\n').some((line) => line.includes(name) && line.includes('ONLINE'))\n\t\t\treturn {name, isOk}\n\t\t})\n\t}\n\n\t// Create GPT partition table and partitions on a device\n\tasync #partitionDevice(device: string): Promise<{statePartition: string; dataPartition: string}> {\n\t\tconst isDiskById = device.startsWith('/dev/disk/by-id/')\n\t\tif (!isDiskById) throw new Error('Must pass disk by id')\n\n\t\tthis.logger.log(`Wiping signatures from ${device}`)\n\t\tawait $`wipefs --all ${device}`\n\n\t\t// Create partition table and partitions using sgdisk\n\t\t// Partition 1: State partition (100MB)\n\t\t// Partition 2: Data partition (calculated size based on rounded device size)\n\t\tthis.logger.log(`Creating partition table on ${device}`)\n\t\tawait $`sgdisk --zap-all ${device}`\n\n\t\tconst oneMiB = 1024 * 1024\n\n\t\t// Add a 10MiB buffer to allow for partition table\n\t\tconst bufferSizeBytes = 10 * oneMiB // 10 MiB\n\n\t\t// Reserve 100MiB for state partition we may use in the future\n\t\tconst statePartitionSizeBytes = 100 * oneMiB // 100 MiB\n\n\t\t// Get device size and round down to nearest 250GB if over 1TB\n\t\t// This normalises device sizes. e.g sometimes 4000GB and 4096GB SSDs are sold as 4TB.\n\t\t// If we use their sizes directly then starting with a 4096 GB SSD and then trying to add\n\t\t// a 4000 GB SSD later will fails because ZFS will complain the new device is too small.\n\t\t// This is confusing for the user because they have what they think are two 4TB SSDs.\n\t\tconst deviceSize = await getDeviceSize(device)\n\t\tconst roundedDeviceSize = getRoundedDeviceSize(deviceSize)\n\n\t\t// Calculate data partition size all free space after state partition and buffer\n\t\tconst dataPartitionSizeBytes = roundedDeviceSize - statePartitionSizeBytes - bufferSizeBytes\n\n\t\t// Convert to MiB for sgdisk (M suffix = MiB)\n\t\tconst statePartitionSizeMiB = Math.floor(statePartitionSizeBytes / oneMiB)\n\t\tconst dataPartitionSizeMiB = Math.floor(dataPartitionSizeBytes / oneMiB)\n\n\t\tthis.logger.log(\n\t\t\t`Device size: ${deviceSize} bytes, rounded: ${roundedDeviceSize} bytes, data partition: ${dataPartitionSizeBytes} bytes (${dataPartitionSizeMiB} MiB)`,\n\t\t)\n\n\t\tthis.logger.log(`Creating state partition (${statePartitionSizeMiB} MiB) on ${device}`)\n\t\tawait $`sgdisk --new=1:0:+${statePartitionSizeMiB}M --change-name=1:umbrel-raid-state ${device}`\n\n\t\tthis.logger.log(`Creating data partition (${dataPartitionSizeMiB} MiB) on ${device}`)\n\t\tawait $`sgdisk --new=2:0:+${dataPartitionSizeMiB}M --change-name=2:umbrel-raid-data ${device}`\n\n\t\t// Determine partition naming convention\n\t\tconst statePartition = `${device}-part1`\n\t\tconst dataPartition = `${device}-part2`\n\n\t\t// Wait for partitions to appear\n\t\tthis.logger.log(`Waiting for partitions to appear on ${device}`)\n\t\tawait $`udevadm settle`\n\n\t\t// Check partitions actually exist\n\t\tconst partitionsExist = await Promise.all([fse.pathExists(statePartition), fse.pathExists(dataPartition)])\n\t\tif (!partitionsExist[0]) throw new Error(`State partition ${statePartition} does not exist`)\n\t\tif (!partitionsExist[1]) throw new Error(`Data partition ${dataPartition} does not exist`)\n\n\t\tthis.logger.log(`Successfully partitioned ${device}`)\n\t\treturn {statePartition, dataPartition}\n\t}\n\n\t// Create ZFS pool from data partitions\n\tasync #createPool(poolName: string, dataPartitions: string[], raidType: RaidType): Promise<void> {\n\t\t// Pool options (-o):\n\t\t//   ashift=12: 4K sectors (optimal for NVMe SSDs)\n\t\t//   autotrim=on: Enable automatic TRIM for SSDs\n\t\t//   autoexpand=on: Automatically expand pool when devices are replaced with larger ones\n\t\t//   cachefile=none: Don't write to /etc/zfs/zpool.cache since it won't exist before we've mounted the pool\n\t\t//   -m none: Don't mount the pool itself\n\t\tthis.logger.log(`Creating ZFS pool '${poolName}' (${raidType}) with partitions: ${dataPartitions.join(', ')}`)\n\t\tconst vdevType = raidType === 'failsafe' ? ['raidz1'] : []\n\t\tawait $`zpool create -f -o ashift=12 -o autotrim=on -o autoexpand=on -o cachefile=none -m none ${poolName} ${vdevType} ${dataPartitions}`\n\t\tthis.logger.log(`ZFS pool '${poolName}' created successfully`)\n\t}\n\n\t// Create the data dataset on a pool\n\tasync #createDataset(poolName: string): Promise<void> {\n\t\t// We use a hardcoded encryption password for now. This obviously doesn't provide any security.\n\t\t// However initialising encryption now means we can enable full disk encryption in the future\n\t\t// by simply updating the password to something secure without requiring an entire backup and restore\n\t\t// of all data into a new encrypted dataset.\n\t\t// Must be minimum 8 characters so we use umbrelumbrel.\n\t\tconst defaultEncryptionPassword = 'umbrelumbrel'\n\n\t\t// Dataset options (-o):\n\t\t//   encryption=aes-256-gcm: Enable encryption with AES-256-GCM\n\t\t//   keyformat=passphrase: Use a passphrase for the encryption key\n\t\t//   keylocation=prompt: Key will be provided via stdin\n\t\t//   mountpoint=legacy: We want to handle mounting manually\n\t\t//   compression=lz4: Fastest compression for minimal overhead\n\t\t//   atime=off: Disable access time updates (significantly reduces writes)\n\t\t//   xattr=sa: Store extended attributes in inodes for significant performance gains.\n\t\t//   acltype=posixacl: Enable POSIX ACLs for proper permission handling.\n\t\tthis.logger.log(`Creating data dataset on pool '${poolName}'`)\n\t\tawait $({\n\t\t\tinput: defaultEncryptionPassword,\n\t\t})`zfs create -o encryption=aes-256-gcm -o keyformat=passphrase -o keylocation=prompt -o mountpoint=legacy -o compression=lz4 -o atime=off -o xattr=sa -o acltype=posixacl ${poolName}/data`\n\t\tthis.logger.log(`Encrypted dataset created successfully`)\n\t}\n\n\t// Setup RAID array from a list of devices\n\t// This will:\n\t// 1. Partition each device with a state partition and data partition (remaining space)\n\t// 2. Create a ZFS pool from all data partitions\n\t// 3. Write RAID config to boot partition to signal the boot process to mount the array\n\tasync setup(deviceIds: string[], raidType: RaidType): Promise<boolean> {\n\t\tif (deviceIds.length === 0) throw new Error('At least one device is required')\n\t\tif (raidType === 'failsafe' && deviceIds.length < 2) throw new Error('Failsafe mode requires at least two devices')\n\n\t\tconst devices = deviceIds.map((id) => `/dev/disk/by-id/${id}`)\n\t\tfor (const device of devices) {\n\t\t\tif (!(await fse.pathExists(device))) throw new Error(`Device not found: ${device}`)\n\t\t}\n\t\tthis.logger.log(`Setting up RAID with ${devices.length} device(s): ${devices.join(', ')}`)\n\n\t\t// Generate a unique pool name for this installation\n\t\tconst poolName = this.generatePoolName()\n\t\tthis.logger.log(`Generated unique pool name: ${poolName}`)\n\n\t\t// Partition all devices concurrently and collect data partitions\n\t\tthis.logger.log(`Partitioning ${devices.length} device(s) concurrently`)\n\t\tconst partitionResults = await Promise.all(devices.map((device) => this.#partitionDevice(device)))\n\t\tconst dataPartitions = partitionResults.map((result) => result.dataPartition)\n\t\tthis.logger.log(`All devices partitioned successfully`)\n\n\t\t// Create ZFS pool and data dataset\n\t\tawait this.#createPool(poolName, dataPartitions, raidType)\n\t\tawait this.#createDataset(poolName)\n\n\t\t// Write RAID config to boot partition\n\t\tthis.logger.log(`Writing RAID config to config partition`)\n\t\tawait this.configStore.set('raid', {poolName, state: 'normal', raidType, devices})\n\n\t\tthis.logger.log('RAID setup complete')\n\t\treturn true\n\t}\n\n\t// Add a new device to an existing RAID array\n\t// This will:\n\t// 1. Partition the device with a state partition and data partition\n\t// 2. Add the data partition to the existing ZFS pool\n\t//    - For storage mode: adds as new top-level vdev (stripe)\n\t//    - For failsafe mode: expands the existing raidz1 vdev\n\t// 3. Update RAID config in boot partition\n\tasync addDevice(deviceId: string): Promise<boolean> {\n\t\t// Convert device ID to full path and verify it exists\n\t\tconst device = `/dev/disk/by-id/${deviceId}`\n\t\tconst exists = await fse.pathExists(device)\n\t\tif (!exists) throw new Error(`Device not found: ${device}`)\n\n\t\tthis.logger.log(`Adding device to RAID array: ${device}`)\n\n\t\t// Get the pool status\n\t\tconst pool = await this.getStatus()\n\t\tif (!pool.exists) throw new Error(\"RAID array doesn't exist\")\n\n\t\t// Check if device is already in the array\n\t\tconst poolDeviceIds = pool.devices?.map((d) => d.id) ?? []\n\t\tif (poolDeviceIds.includes(deviceId)) throw new Error('Cannot add a device that is already in the RAID array')\n\n\t\t// Partition the new device\n\t\tthis.logger.log(`Partitioning device: ${device}`)\n\t\tconst {dataPartition} = await this.#partitionDevice(device)\n\n\t\t// Add the data partition to the existing pool\n\t\tthis.logger.log(`Adding partition ${dataPartition} to pool '${pool.name}'`)\n\t\t// For failsafe mode, attach the new device to the existing raidz1 vdev\n\t\tif (pool.raidType === 'failsafe') await $`zpool attach -f ${pool.name} raidz1-0 ${dataPartition}`\n\t\t// For storage mode, add the new device as a new top-level vdev\n\t\telse if (pool.raidType === 'storage') await $`zpool add -f ${pool.name} ${dataPartition}`\n\n\t\t// Update config with new device\n\t\tconst updatedDevices = [...poolDeviceIds.map((id) => `/dev/disk/by-id/${id}`), device]\n\t\tthis.logger.log(`Updating RAID config with ${updatedDevices.length} device(s)`)\n\t\tawait this.configStore.set('raid.devices', updatedDevices)\n\n\t\tthis.logger.log(`Device ${device} added to RAID array successfully`)\n\t\treturn true\n\t}\n\n\t// Replace a device in the RAID array with a new device\n\t// This will:\n\t// 1. Partition the new device with a state partition and data partition\n\t// 2. Use zpool replace to swap the old device with the new one\n\t// 3. Update RAID config in boot partition\n\t// Works for both storage and failsafe modes. ZFS will resilver the new device.\n\tasync replaceDevice(oldDeviceId: string, newDeviceId: string): Promise<boolean> {\n\t\tconst oldDevice = `/dev/disk/by-id/${oldDeviceId}`\n\t\tconst newDevice = `/dev/disk/by-id/${newDeviceId}`\n\n\t\t// Verify new device exists\n\t\tif (!(await fse.pathExists(newDevice))) throw new Error(`New device not found: ${newDevice}`)\n\n\t\t// Get the pool status - this is the source of truth for what devices are in the pool\n\t\tconst pool = await this.getStatus()\n\t\tif (!pool.exists) throw new Error(\"RAID array doesn't exist\")\n\n\t\t// Verify old device is in the pool and new device is not\n\t\tconst poolDeviceIds = pool.devices?.map((d) => d.id) ?? []\n\t\tif (!poolDeviceIds.includes(oldDeviceId)) throw new Error(`Device ${oldDeviceId} is not in the RAID array`)\n\t\tif (poolDeviceIds.includes(newDeviceId))\n\t\t\tthrow new Error('Cannot replace with a device that is already in the RAID array')\n\n\t\tif (this.isReplacing) throw new Error('Already replacing device')\n\t\tthis.isReplacing = true\n\t\tthis.logger.log(`Replacing device ${oldDevice} with ${newDevice}`)\n\n\t\ttry {\n\t\t\t// Partition the new device\n\t\t\tthis.logger.log(`Partitioning new device: ${newDevice}`)\n\t\t\tconst {dataPartition: newDataPartition} = await this.#partitionDevice(newDevice)\n\n\t\t\t// Get the old data partition path\n\t\t\tconst oldDataPartition = `${oldDevice}-part2`\n\n\t\t\t// Replace the device in the pool\n\t\t\t// ZFS will automatically start resilvering the new device\n\t\t\tthis.logger.log(`Replacing ${oldDataPartition} with ${newDataPartition} in pool '${pool.name}'`)\n\t\t\tawait $`zpool replace -f ${pool.name} ${oldDataPartition} ${newDataPartition}`\n\n\t\t\t// Initialize replace status\n\t\t\tthis.replaceStatus = {state: 'rebuilding', progress: 0}\n\t\t\tthis.#umbreld.eventBus.emit('raid:replace-progress', this.replaceStatus)\n\n\t\t\t// Kick off non-blocking monitoring to avoid blocking the API response\n\t\t\tthis.logger.log('Monitoring replace progress...')\n\t\t\tPromise.resolve()\n\t\t\t\t.then(async () => {\n\t\t\t\t\t// Poll rebuild status until complete\n\t\t\t\t\twhile (true) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst status = await this.getPoolStatus(pool.name)\n\t\t\t\t\t\t\tif (status.rebuild) {\n\t\t\t\t\t\t\t\t// Cap progress at 99% until fully complete (state === 'finished')\n\t\t\t\t\t\t\t\tconst cappedProgress = status.rebuild.state === 'finished' ? 100 : Math.min(status.rebuild.progress, 99)\n\t\t\t\t\t\t\t\tif (cappedProgress > (this.replaceStatus?.progress ?? 0)) {\n\t\t\t\t\t\t\t\t\tthis.replaceStatus = {state: 'rebuilding', progress: cappedProgress}\n\t\t\t\t\t\t\t\t\tthis.logger.log(`Replace progress: ${cappedProgress}%`)\n\t\t\t\t\t\t\t\t\tthis.#umbreld.eventBus.emit('raid:replace-progress', this.replaceStatus)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tif (status.rebuild.state === 'finished') {\n\t\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t// No rebuild status - check if new device is in pool and online\n\t\t\t\t\t\t\t\t// This handles the case where resilver completes before first poll\n\t\t\t\t\t\t\t\tconst newDeviceInPool = status.devices?.some((d) => d.id === newDeviceId && d.status === 'ONLINE')\n\t\t\t\t\t\t\t\tif (newDeviceInPool) {\n\t\t\t\t\t\t\t\t\tthis.logger.log('No rebuild status but new device is online, considering complete')\n\t\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\tthis.logger.error('Error polling replace progress', error)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tawait setTimeout(1000)\n\t\t\t\t\t}\n\n\t\t\t\t\t// Expand the new device to its full size\n\t\t\t\t\t// This makes sure the new size is recognized if it's larger than the old size\n\t\t\t\t\tthis.logger.log('Resilver complete, expanding new device...')\n\t\t\t\t\tawait $`zpool online -e ${pool.name} ${newDataPartition}`.catch(() =>\n\t\t\t\t\t\tthis.logger.error('Error expanding new device'),\n\t\t\t\t\t)\n\n\t\t\t\t\t// Mark as finished\n\t\t\t\t\tthis.replaceStatus = {state: 'finished', progress: 100}\n\t\t\t\t\tthis.logger.log('Replace complete')\n\t\t\t\t\tthis.#umbreld.eventBus.emit('raid:replace-progress', this.replaceStatus)\n\t\t\t\t\tthis.isReplacing = false\n\t\t\t\t})\n\t\t\t\t.catch((error) => {\n\t\t\t\t\tthis.logger.error('Error monitoring replace progress', error)\n\t\t\t\t\tthis.isReplacing = false\n\t\t\t\t})\n\t\t} catch (error) {\n\t\t\tthis.isReplacing = false\n\t\t\tthrow error\n\t\t}\n\n\t\t// Update config with new device list\n\t\tconst currentDevices = (await this.configStore.get('raid.devices')) ?? []\n\t\tconst updatedDevices = currentDevices.map((d: string) => (d === oldDevice ? newDevice : d))\n\t\tthis.logger.log(`Updating RAID config with devices: ${updatedDevices.join(', ')}`)\n\t\tawait this.configStore.set('raid.devices', updatedDevices)\n\n\t\tthis.logger.log(`Device replacement initiated, resilvering in progress`)\n\t\treturn true\n\t}\n\n\t// Transition a single-disk storage array to a failsafe (raidz1) array\n\t// This creates a degraded raidz1 pool with the new disk and syncs data from the old pool\n\tasync transitionToFailsafe(newDeviceId: string): Promise<boolean> {\n\t\tconst newDevice = `/dev/disk/by-id/${newDeviceId}`\n\t\tif (!(await fse.pathExists(newDevice))) throw new Error(`Device not found: ${newDevice}`)\n\n\t\t// Verify we're in a state that can be migrated\n\t\tconst pool = await this.getStatus()\n\t\tif (!pool.exists) throw new Error('No RAID array exists')\n\t\tif (pool.raidType !== 'storage') throw new Error('Can only transition from storage mode')\n\t\tif (pool.devices?.length !== 1) throw new Error('Can only transition single-disk arrays')\n\n\t\t// Check if device is already in the array\n\t\tconst poolDeviceIds = pool.devices?.map((d) => d.id) ?? []\n\t\tif (poolDeviceIds.includes(newDeviceId))\n\t\t\tthrow new Error('Cannot transition with a device that is already in the RAID array')\n\n\t\t// Check if new device is at least as large as the current device\n\t\tconst currentDeviceId = pool.devices![0].id\n\t\tconst currentDevice = `/dev/disk/by-id/${currentDeviceId}`\n\t\tconst currentDeviceSize = await getDeviceSize(currentDevice)\n\t\tconst newDeviceSize = await getDeviceSize(newDevice)\n\t\tif (getRoundedDeviceSize(newDeviceSize) < getRoundedDeviceSize(currentDeviceSize))\n\t\t\tthrow new Error('Cannot transition to a device smaller than the current device')\n\n\t\tif (this.isTransitioningToFailsafe) throw new Error('Already transitioning to failsafe mode')\n\t\tthis.isTransitioningToFailsafe = true\n\n\t\tthis.logger.log(`Starting transition to failsafe mode with ${newDevice}`)\n\t\tconst migrationPoolName = `${pool.name}-migration`\n\t\ttry {\n\t\t\t// Partition the new device\n\t\t\tthis.logger.log(`Partitioning new device: ${newDevice}`)\n\t\t\tconst {dataPartition: newDataPartition} = await this.#partitionDevice(newDevice)\n\n\t\t\t// Get the size of the existing data partition for creating the temp file\n\t\t\tconst currentDeviceDataPartition = `${currentDevice}-part2`\n\t\t\tconst currentDeviceDataPartitionSize = await getDeviceSize(currentDeviceDataPartition)\n\n\t\t\t// Create a sparse temp file the same size as the current device partition\n\t\t\tthis.logger.log(\n\t\t\t\t`Creating sparse temp file: ${this.temporaryDevicePath} (${currentDeviceDataPartitionSize} bytes)`,\n\t\t\t)\n\t\t\tawait $`truncate -s ${currentDeviceDataPartitionSize} ${this.temporaryDevicePath}`\n\n\t\t\t// Create the migration pool as raidz1 with new partition + temp file\n\t\t\t// ZFS can use a file path directly without needing a loopback device\n\t\t\tawait this.#createPool(migrationPoolName, [newDataPartition, this.temporaryDevicePath], 'failsafe')\n\n\t\t\t// Remove the temp file from the migration pool (making it degraded)\n\t\t\tthis.logger.log(`Removing temp device from pool to create degraded raidz1`)\n\t\t\tawait $`zpool offline ${migrationPoolName} ${this.temporaryDevicePath}`\n\t\t\tawait fse.remove(this.temporaryDevicePath)\n\n\t\t\t// Create a snapshot of the active pool\n\t\t\tconst baseSnapshot = 'migration'\n\t\t\tthis.logger.log(`Creating snapshot: ${pool.name}@${baseSnapshot}`)\n\t\t\tawait $`zfs snapshot -r ${pool.name}@${baseSnapshot}`\n\n\t\t\t// Get the estimated size of the snapshot to send (must match flags used in actual send)\n\t\t\t// Using --raw to preserve encryption (sends encrypted blocks without needing key loaded)\n\t\t\tthis.logger.log('Estimating snapshot size...')\n\t\t\tconst sizeResult =\n\t\t\t\tawait $`zfs send --dryrun --raw --replicate --parsable --large-block --compressed ${pool.name}@${baseSnapshot}`\n\t\t\tconst sizeOutput = sizeResult.stderr || sizeResult.stdout\n\t\t\t// --parsable outputs \"size\\t<bytes>\" on the last line\n\t\t\tconst sizeMatch = sizeOutput.match(/^size\\s+(\\d+)/m)\n\t\t\tconst estimatedSize = sizeMatch ? parseInt(sizeMatch[1], 10) : 0\n\t\t\tthis.logger.log(`Estimated snapshot size: ${estimatedSize} bytes`)\n\n\t\t\t// Initialize transition status\n\t\t\tthis.failsafeTransitionStatus = {state: 'syncing', progress: 0}\n\t\t\tthis.#umbreld.eventBus.emit('raid:failsafe-transition-progress', this.failsafeTransitionStatus)\n\n\t\t\t// Kick off non-blocking data migration to avoid blocking the API response\n\t\t\tthis.logger.log('Starting async data migration...')\n\t\t\tPromise.resolve()\n\t\t\t\t.then(async () => {\n\t\t\t\t\t// Send the active pool snapshot to the migration pool\n\t\t\t\t\t// Using --raw to preserve encryption (sends encrypted blocks without needing key loaded)\n\t\t\t\t\tthis.logger.log(`Sending snapshot to migration pool (this may take a while)...`)\n\t\t\t\t\tconst sendProcess = $({\n\t\t\t\t\t\tshell: true,\n\t\t\t\t\t})`zfs send --raw --replicate --large-block --compressed ${pool.name}@${baseSnapshot} | zfs receive -Fu ${migrationPoolName}`\n\n\t\t\t\t\t// Poll progress while sending\n\t\t\t\t\tconst stopProgressMonitor = runEvery(\n\t\t\t\t\t\t'1 second',\n\t\t\t\t\t\tasync () => {\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tconst migrationStatus = await this.getPoolStatus(migrationPoolName)\n\t\t\t\t\t\t\t\tif (migrationStatus.exists && estimatedSize > 0) {\n\t\t\t\t\t\t\t\t\tconst usedSpace = migrationStatus.usedSpace ?? 0\n\t\t\t\t\t\t\t\t\t// Scale sync progress to 0-49% (first half of transition)\n\t\t\t\t\t\t\t\t\tconst rawProgress = Math.min(99, Math.floor((usedSpace / estimatedSize) * 100))\n\t\t\t\t\t\t\t\t\tconst progress = Math.floor((rawProgress / 100) * 49)\n\t\t\t\t\t\t\t\t\tif (this.failsafeTransitionStatus && progress > this.failsafeTransitionStatus.progress) {\n\t\t\t\t\t\t\t\t\t\tthis.logger.log(`Sync progress: ${progress}%`)\n\t\t\t\t\t\t\t\t\t\tthis.failsafeTransitionStatus = {state: 'syncing', progress}\n\t\t\t\t\t\t\t\t\t\tthis.#umbreld.eventBus.emit('raid:failsafe-transition-progress', this.failsafeTransitionStatus)\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t\t// Ignore errors during progress polling\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{runInstantly: true},\n\t\t\t\t\t)\n\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait sendProcess\n\t\t\t\t\t} finally {\n\t\t\t\t\t\tstopProgressMonitor()\n\t\t\t\t\t}\n\n\t\t\t\t\t// Mark RAID config state as transitioning to failsafe\n\t\t\t\t\t// This allows easy detection by the boot script\n\t\t\t\t\tthis.logger.log('Updating RAID config')\n\t\t\t\t\tawait this.configStore.set('raid.state', 'transitioning-to-failsafe')\n\n\t\t\t\t\t// Emit rebooting state at 50% (rebuild will complete the remaining 50%)\n\t\t\t\t\tthis.failsafeTransitionStatus = {state: 'rebooting', progress: 50}\n\t\t\t\t\tthis.#umbreld.eventBus.emit('raid:failsafe-transition-progress', this.failsafeTransitionStatus)\n\n\t\t\t\t\t// Set status and wait 11 seconds before rebooting so the UI has time to poll\n\t\t\t\t\t// and show the restarting state (UI polls every 10 seconds)\n\t\t\t\t\tthis.logger.log(`Initial sync complete, rebooting to complete migration`)\n\t\t\t\t\tsetSystemStatus('restarting')\n\t\t\t\t\tawait setTimeout(11_000)\n\t\t\t\t\treboot()\n\t\t\t\t})\n\t\t\t\t.catch(async (error) => {\n\t\t\t\t\t// Reset system status in case we set it to restarting before the error\n\t\t\t\t\tsetSystemStatus('running')\n\n\t\t\t\t\t// Emit error state\n\t\t\t\t\tthis.failsafeTransitionStatus = {state: 'error', progress: 0, error: (error as Error).message}\n\t\t\t\t\tthis.#umbreld.eventBus.emit('raid:failsafe-transition-progress', this.failsafeTransitionStatus)\n\n\t\t\t\t\t// Clean up on failure\n\t\t\t\t\tthis.logger.error(`Migration failed, cleaning up...`, error)\n\t\t\t\t\tawait $`zpool destroy ${migrationPoolName}`.catch(() => {})\n\t\t\t\t\tawait $`zfs destroy -r ${pool.name}@migration`.catch(() => {})\n\t\t\t\t\tawait fse.remove(this.temporaryDevicePath).catch(() => {})\n\t\t\t\t\tthis.isTransitioningToFailsafe = false\n\t\t\t\t})\n\n\t\t\treturn true\n\t\t} catch (error) {\n\t\t\t// Emit error state\n\t\t\tthis.failsafeTransitionStatus = {state: 'error', progress: 0, error: (error as Error).message}\n\t\t\tthis.#umbreld.eventBus.emit('raid:failsafe-transition-progress', this.failsafeTransitionStatus)\n\n\t\t\t// Clean up on failure\n\t\t\tthis.logger.error(`Migration setup failed, cleaning up...`)\n\t\t\tawait $`zpool destroy ${migrationPoolName}`.catch(() => {})\n\t\t\tawait $`zfs destroy -r ${pool.name}@migration`.catch(() => {})\n\t\t\tawait fse.remove(this.temporaryDevicePath).catch(() => {})\n\t\t\tthis.isTransitioningToFailsafe = false\n\t\t\tthrow error\n\t\t}\n\t}\n\n\t// We run this on boot to check if there's an in progress transition and complete it.\n\t// The boot script does the minimum: final sync and pool rename\n\t// We complete the migration here: destroy old pool, re-partition old device, add it to the new pool\n\tasync #completeFailsafeTransition(): Promise<void> {\n\t\t// Check config state first - this is the source of truth for transition status\n\t\tconst raidState = await this.configStore.get('raid.state')\n\t\tif (raidState !== 'transitioning-to-failsafe') return\n\n\t\tconst pool = await this.getStatus()\n\t\tconst previousPoolName = `${pool.name}-previous-migration`\n\n\t\t// Verify the previous pool exists (should always exist if config says transitioning)\n\t\tconst previousPool = await this.getPoolStatus(previousPoolName)\n\t\tif (!previousPool.exists) {\n\t\t\tthis.logger.error('Config indicates transition in progress but previous pool not found')\n\t\t\treturn\n\t\t}\n\n\t\tthis.logger.log('Failsafe transition detected, finishing off migration')\n\t\tthis.isTransitioningToFailsafe = true\n\n\t\t// Initialize transition status at 50% (sync phase complete, rebuild phase starting)\n\t\tthis.failsafeTransitionStatus = {state: 'rebuilding', progress: 50}\n\t\tthis.#umbreld.eventBus.emit('raid:failsafe-transition-progress', this.failsafeTransitionStatus)\n\n\t\ttry {\n\t\t\t// Get the old device from the previous pool\n\t\t\t// We just grab the first device since the pool should only have one\n\t\t\tconst oldDevice = previousPool.devices?.[0]?.id\n\t\t\tconst oldDevicePath = `/dev/disk/by-id/${oldDevice}`\n\t\t\tif (!oldDevice) throw new Error('Could not determine old device from previous migration pool')\n\t\t\tthis.logger.log(`Old device: ${oldDevice}`)\n\n\t\t\t// Destroy the old pool\n\t\t\tthis.logger.log('Destroying previous migration pool')\n\t\t\tawait $`zpool destroy ${previousPoolName}`\n\n\t\t\t// Partition the old device\n\t\t\tthis.logger.log(`Partitioning old device: ${oldDevice}`)\n\t\t\tconst {dataPartition: oldDataPartition} = await this.#partitionDevice(oldDevicePath)\n\n\t\t\t// Replace the temp device with the old device partition in the new pool\n\t\t\tthis.logger.log('Replacing temp device with old device in pool')\n\t\t\tawait $`zpool replace -f ${pool.name} ${this.temporaryDevicePath} ${oldDataPartition}`\n\n\t\t\t// Update config with new RAID configuration\n\t\t\tthis.logger.log('Updating RAID config')\n\t\t\tawait this.configStore.getWriteLock(async ({set}) => {\n\t\t\t\tconst pool = await this.getStatus()\n\t\t\t\tconst devices = pool.devices!.map((device) => `/dev/disk/by-id/${device.id}`)\n\t\t\t\tconst raid = await this.configStore.get('raid')\n\t\t\t\tawait set('raid', {\n\t\t\t\t\t...raid,\n\t\t\t\t\traidType: 'failsafe',\n\t\t\t\t\tdevices,\n\t\t\t\t\tstate: 'normal',\n\t\t\t\t})\n\t\t\t})\n\n\t\t\t// Monitor rebuild progress until complete\n\t\t\tthis.logger.log('Monitoring rebuild progress...')\n\t\t\twhile (true) {\n\t\t\t\ttry {\n\t\t\t\t\tconst status = await this.getPoolStatus(pool.name)\n\t\t\t\t\tif (status.rebuild) {\n\t\t\t\t\t\t// Scale rebuild progress (0-100) to transition progress (51-99)\n\t\t\t\t\t\tconst scaledProgress = 51 + Math.floor((status.rebuild.progress / 100) * 48)\n\t\t\t\t\t\tconst cappedProgress = Math.min(scaledProgress, 99)\n\t\t\t\t\t\tif (cappedProgress > (this.failsafeTransitionStatus?.progress ?? 0)) {\n\t\t\t\t\t\t\tthis.failsafeTransitionStatus = {state: 'rebuilding', progress: cappedProgress}\n\t\t\t\t\t\t\tthis.logger.log(`Rebuild progress: ${cappedProgress}%`)\n\t\t\t\t\t\t\tthis.#umbreld.eventBus.emit('raid:failsafe-transition-progress', this.failsafeTransitionStatus)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (status.rebuild.state === 'finished') {\n\t\t\t\t\t\t\tthis.failsafeTransitionStatus = {state: 'complete', progress: 100}\n\t\t\t\t\t\t\tthis.logger.log('Rebuild progress: 100%')\n\t\t\t\t\t\t\tthis.#umbreld.eventBus.emit('raid:failsafe-transition-progress', this.failsafeTransitionStatus)\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} catch (error) {\n\t\t\t\t\tthis.logger.error('Error polling rebuild progress', error)\n\t\t\t\t}\n\t\t\t\tawait setTimeout(1000)\n\t\t\t}\n\n\t\t\tthis.logger.log('Migration to failsafe mode complete')\n\t\t} catch (error) {\n\t\t\tthis.failsafeTransitionStatus = {state: 'error', progress: 0, error: (error as Error).message}\n\t\t\tthis.#umbreld.eventBus.emit('raid:failsafe-transition-progress', this.failsafeTransitionStatus)\n\t\t\tthrow error\n\t\t} finally {\n\t\t\t// Clean up leftover snapshots\n\t\t\tthis.logger.log('Cleaning up leftover snapshots')\n\t\t\tawait $`zfs destroy -r ${pool.name}@migration`.catch((error) =>\n\t\t\t\tthis.logger.error('Failed to destroy migration snapshot', error),\n\t\t\t)\n\t\t\tawait $`zfs destroy -r ${pool.name}@migration-final`.catch((error) =>\n\t\t\t\tthis.logger.error('Failed to destroy migration final snapshot', error),\n\t\t\t)\n\n\t\t\t// Reset state\n\t\t\tthis.isTransitioningToFailsafe = false\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/routes.ts",
    "content": "import {z} from 'zod'\n\nimport {router, privateProcedure, publicProcedureWhenNoUserExists, publicProcedure} from '../server/trpc/trpc.js'\n\nconst internalStorage = router({\n\t// Get internal storage devices (NVMe, HDD, eMMC, etc.)\n\tgetDevices: publicProcedureWhenNoUserExists.query(async ({ctx}) => ctx.umbreld.hardware.internalStorage.getDevices()),\n})\n\nconst raid = router({\n\t// Check status of initial RAID setup boot process\n\tcheckInitialRaidSetupStatus: publicProcedure.query(async ({ctx}) =>\n\t\tctx.umbreld.hardware.raid.checkInitialRaidSetupStatus(),\n\t),\n\n\t// Check if RAID mount failed during boot\n\tcheckRaidMountFailure: publicProcedure.query(async ({ctx}) => ctx.umbreld.hardware.raid.checkRaidMountFailure()),\n\n\t// Get details about why RAID mount failed\n\tcheckRaidMountFailureDevices: publicProcedureWhenNoUserExists.query(async ({ctx}) =>\n\t\tctx.umbreld.hardware.raid.checkRaidMountFailureDevices(),\n\t),\n\n\t// Get RAID pool status\n\tgetStatus: privateProcedure.query(async ({ctx}) => ctx.umbreld.hardware.raid.getStatus()),\n\n\t// Setup RAID array from a list of devices\n\t// TOOD: Remove this, just exposing for development and testing.\n\tsetup: privateProcedure\n\t\t.input(z.object({devices: z.array(z.string()), raidType: z.enum(['storage', 'failsafe'])}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.hardware.raid.setup(input.devices, input.raidType)),\n\n\t// Add a device to an existing RAID array\n\taddDevice: privateProcedure\n\t\t.input(z.object({device: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.hardware.raid.addDevice(input.device)),\n\n\t// Replace a device in an existing RAID array\n\treplaceDevice: privateProcedure\n\t\t.input(z.object({oldDevice: z.string(), newDevice: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.hardware.raid.replaceDevice(input.oldDevice, input.newDevice)),\n\n\t// Transition a single-disk storage array to a failsafe (raidz1) array\n\ttransitionToFailsafe: privateProcedure\n\t\t.input(z.object({device: z.string()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.hardware.raid.transitionToFailsafe(input.device)),\n})\n\nconst umbrelPro = router({\n\t// Check if running on Umbrel Pro hardware\n\tisUmbrelPro: privateProcedure.query(async ({ctx}) => ctx.umbreld.hardware.umbrelPro.isUmbrelPro()),\n\n\t// TODO: These are exposed in factory builds for hardware testing. Remove when we have a better solution.\n\n\t// LED control\n\tsetLedOff: privateProcedure.mutation(async ({ctx}) => ctx.umbreld.hardware.umbrelPro.setLedOff()),\n\tsetLedStatic: privateProcedure.mutation(async ({ctx}) => ctx.umbreld.hardware.umbrelPro.setLedStatic()),\n\tsetLedDefault: privateProcedure.mutation(async ({ctx}) => ctx.umbreld.hardware.umbrelPro.setLedDefault()),\n\tsetLedColor: privateProcedure\n\t\t.input(z.object({red: z.number(), green: z.number(), blue: z.number()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.hardware.umbrelPro.setLedColor(input)),\n\tsetLedWhite: privateProcedure.mutation(async ({ctx}) => ctx.umbreld.hardware.umbrelPro.setLedWhite()),\n\tsetLedBlinking: privateProcedure.mutation(async ({ctx}) => ctx.umbreld.hardware.umbrelPro.setLedBlinking()),\n\tsetLedBreathe: privateProcedure\n\t\t.input(z.object({duration: z.number().optional()}).optional())\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.hardware.umbrelPro.setLedBreathe(input?.duration)),\n\n\t// Fan control\n\tsetFanManagementEnabled: privateProcedure\n\t\t.input(z.object({enabled: z.boolean()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.hardware.umbrelPro.setFanManagementEnabled(input.enabled)),\n\tsetMinFanSpeed: privateProcedure\n\t\t.input(z.object({percent: z.number()}))\n\t\t.mutation(async ({ctx, input}) => ctx.umbreld.hardware.umbrelPro.setMinFanSpeed(input.percent)),\n\n\t// Reset boot flag\n\twasBootedViaResetButton: privateProcedure.query(async ({ctx}) =>\n\t\tctx.umbreld.hardware.umbrelPro.wasBootedViaResetButton(),\n\t),\n\tclearResetBootFlag: privateProcedure.mutation(async ({ctx}) => ctx.umbreld.hardware.umbrelPro.clearResetBootFlag()),\n})\n\nexport default router({\n\tinternalStorage,\n\traid,\n\tumbrelPro,\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/hardware/umbrel-pro.ts",
    "content": "import {open} from 'node:fs/promises'\nimport {setTimeout} from 'node:timers/promises'\n\nimport PQueue from 'p-queue'\n\nimport type Umbreld from '../../index.js'\nimport runEvery from '../utilities/run-every.js'\nimport {detectDevice} from '../system/system.js'\n\n// Embedded Controller (EC) Communication\n//\n// The EC is a microcontroller on the motherboard that handles low-level hardware\n// functions like fan control, power management, and LED control. We communicate\n// with it via x86 I/O ports using a simple request/response protocol:\n//\n// 1. Wait for EC input buffer to be empty (ready to receive)\n// 2. Send command byte (0x81 = write) to status/command port\n// 3. Wait for EC to process command\n// 4. Send register address to data port\n// 5. Wait for EC to process address\n// 6. Send data byte to data port\n//\n// All operations must be serialized to prevent corruption.\n\n// EC I/O port addresses\nconst EC_STATUS_COMMAND_PORT_ADDRESS = 0x66\nconst EC_DATA_PORT_ADDRESS = 0x62\n\n// EC status flag values\nconst EC_INPUT_BUFFER_FULL_VALUE = 0x02\n\n// Read a byte from an x86 I/O port via /dev/port\nasync function readPort(port: number): Promise<number> {\n\tconst fd = await open('/dev/port', 'r')\n\ttry {\n\t\tconst buffer = new Uint8Array(1)\n\t\tawait fd.read(buffer, 0, 1, port)\n\t\treturn buffer[0]\n\t} finally {\n\t\tawait fd.close()\n\t}\n}\n\n// Write a byte to an x86 I/O port via /dev/port\nasync function writePort(port: number, value: number): Promise<void> {\n\tconst fd = await open('/dev/port', 'r+')\n\ttry {\n\t\tconst buffer = new Uint8Array([value & 0xff])\n\t\tawait fd.write(buffer, 0, 1, port)\n\t} finally {\n\t\tawait fd.close()\n\t}\n}\n\n// Wait until EC is ready to receive data (input buffer clear)\nasync function waitForEcReady(): Promise<void> {\n\tfor (let i = 0; i < 20_000; i++) {\n\t\tconst status = await readPort(EC_STATUS_COMMAND_PORT_ADDRESS)\n\t\tif ((status & EC_INPUT_BUFFER_FULL_VALUE) === 0) return\n\t\tawait setTimeout(0)\n\t}\n\tthrow new Error('EC timeout waiting for input buffer to clear')\n}\n\n// Write a byte to an EC register address\nasync function writeEcRegister(register: number, value: number): Promise<void> {\n\tconst EC_WRITE_COMMAND_VALUE = 0x81\n\tawait waitForEcReady()\n\tawait writePort(EC_STATUS_COMMAND_PORT_ADDRESS, EC_WRITE_COMMAND_VALUE) // Send write command\n\tawait waitForEcReady()\n\tawait writePort(EC_DATA_PORT_ADDRESS, register) // Send register address\n\tawait waitForEcReady()\n\tawait writePort(EC_DATA_PORT_ADDRESS, value & 0xff) // Send data byte\n}\n\n// Read a byte from an EC register address\nasync function readEcRegister(register: number): Promise<number> {\n\tconst EC_READ_COMMAND_VALUE = 0x80\n\tawait waitForEcReady()\n\tawait writePort(EC_STATUS_COMMAND_PORT_ADDRESS, EC_READ_COMMAND_VALUE) // Send read command\n\tawait waitForEcReady()\n\tawait writePort(EC_DATA_PORT_ADDRESS, register) // Send register address\n\tawait waitForEcReady()\n\treturn readPort(EC_DATA_PORT_ADDRESS) // Read data byte\n}\n\nexport default class UmbrelPro {\n\t#umbreld: Umbreld\n\t#ecRegisterCommandQueue = new PQueue({concurrency: 1})\n\t#stopManagingFan?: () => void\n\t#lastFanSpeed?: number\n\tlogger: Umbreld['logger']\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(`hardware:${name.toLowerCase()}`)\n\t}\n\n\t// Canonical check for Umbrel Pro hardware\n\tasync isUmbrelPro(): Promise<boolean> {\n\t\tconst {productName} = await detectDevice()\n\t\treturn productName === 'Umbrel Pro'\n\t}\n\n\tasync start() {\n\t\t// Skip if not Umbrel Pro\n\t\tif (!(await this.isUmbrelPro())) return\n\n\t\tthis.logger.log('Starting Umbrel Pro')\n\n\t\t// Set light to constant white now we're booted up\n\t\tthis.logger.log('Setting LED to default state')\n\t\tawait this.setLedDefault().catch((error) => this.logger.error('Failed to set LED to default state', error))\n\n\t\t// Clear min fan speed\n\t\tthis.logger.log('Clearing min fan speed')\n\t\tawait this.setMinFanSpeed(0).catch((error) => this.logger.error('Failed to clear min fan speed', error))\n\n\t\t// Start fan management loop\n\t\tthis.logger.log('Starting fan speed management')\n\t\tthis.#stopManagingFan = runEvery('1 minute', () => this.#manageFanSpeed())\n\t}\n\n\tasync stop() {\n\t\tthis.logger.log('Stopping Umbrel Pro')\n\t\tthis.#stopManagingFan?.()\n\t}\n\n\t// EC utils\n\n\t// Write a byte to an EC register address (queued to prevent concurrent access)\n\tasync #writeEcRegister(register: number, value: number): Promise<void> {\n\t\tif (!(await this.isUmbrelPro())) throw new Error('Refusing to write EC register on non Umbrel Pro hardware')\n\t\treturn this.#ecRegisterCommandQueue.add(async () => writeEcRegister(register, value))\n\t}\n\n\t// Read a byte from an EC register address (queued to prevent concurrent access)\n\tasync #readEcRegister(register: number): Promise<number> {\n\t\tif (!(await this.isUmbrelPro())) throw new Error('Refusing to read EC register on non Umbrel Pro hardware')\n\t\treturn this.#ecRegisterCommandQueue.add(async () => readEcRegister(register)) as Promise<number>\n\t}\n\n\t// Automatic Fan Speed Management\n\t//\n\t// The Umbrel Pro has a single fan that cools both the CPU and all NVMe SSDs.\n\t// The EC firmware has its own fan curve for the CPU. We calculate fan curves\n\t// for each SSD in umbreld, take the highest value, and set that as the minimum\n\t// fan speed in the EC. The EC then uses whichever is higher - our SSD minimum or\n\t// its CPU curve.\n\t//\n\t// SSD fan curve (linear):\n\t//\n\t//   100% |           _______\n\t//        |         /\n\t//   Min  |       /\n\t//   Fan  |     /\n\t//  Speed |   /\n\t//     0% |_/\n\t//        +------------------\n\t//          |        |\n\t//         50°C    Warning\n\t//               Temperature\n\t//\n\t// - Below 50°C: Min fan speed 0% (EC controls baseline)\n\t// - 50°C to warning temp: Linear ramp from 0% to 100%\n\t// - Above warning temp: Min fan speed 100%\n\t//\n\t// Uses the hottest SSD's temperature. Warning temp comes from SSD\n\t// SMART data, defaulting to 70°C if not reported.\n\tasync #manageFanSpeed(): Promise<void> {\n\t\t// Fan speed constants\n\t\tconst FAN_MIN_TEMP = 50 // Temperature (C) at which fan starts ramping\n\t\tconst FAN_DEFAULT_WARNING_TEMP = 70 // Default warning temp if not reported by drive\n\n\t\ttry {\n\t\t\tconst devices = await this.#umbreld.hardware.internalStorage.getDevices()\n\n\t\t\t// Calculate required fan speed for each device\n\t\t\tconst deviceFanSpeeds = devices.map((device) => {\n\t\t\t\t// Skip devices without temperature data\n\t\t\t\tif (device.temperature === undefined) return {device, fanSpeed: 0}\n\n\t\t\t\t// Get warning temp from device or use default\n\t\t\t\tconst warningTemp = device.temperatureWarning ?? FAN_DEFAULT_WARNING_TEMP\n\n\t\t\t\t// Below min temp = 0% fan\n\t\t\t\tif (device.temperature <= FAN_MIN_TEMP) return {device, fanSpeed: 0}\n\n\t\t\t\t// At or above warning temp = 100% fan\n\t\t\t\tif (device.temperature >= warningTemp) return {device, fanSpeed: 100}\n\n\t\t\t\t// Linear interpolation between min temp and warning temp\n\t\t\t\tconst tempRange = warningTemp - FAN_MIN_TEMP\n\t\t\t\tconst tempAboveMin = device.temperature - FAN_MIN_TEMP\n\t\t\t\treturn {device, fanSpeed: Math.round((tempAboveMin / tempRange) * 100)}\n\t\t\t})\n\n\t\t\t// Find the device requiring the highest fan speed\n\t\t\tconst highest = deviceFanSpeeds.reduce((max, current) => (current.fanSpeed > max.fanSpeed ? current : max))\n\n\t\t\t// Apply hysteresis: increase immediately, but only decrease if 5% or more lower.\n\t\t\t// This prevents bouncing between a few percent repeatedly and reduces excessive EC writes.\n\t\t\t// Always allow returning to 0% so we can fully release control back to EC.\n\t\t\tconst lastFanSpeed = this.#lastFanSpeed ?? 0\n\t\t\tconst shouldIncrease = highest.fanSpeed > lastFanSpeed\n\t\t\tconst shouldDecrease = highest.fanSpeed <= lastFanSpeed - 5 || (highest.fanSpeed === 0 && lastFanSpeed !== 0)\n\t\t\tif (shouldIncrease || shouldDecrease) {\n\t\t\t\tawait this.setMinFanSpeed(highest.fanSpeed)\n\t\t\t\tthis.#lastFanSpeed = highest.fanSpeed\n\t\t\t\tthis.logger.log(\n\t\t\t\t\t`Min fan speed set to ${highest.fanSpeed}% (${highest.device.id} at ${highest.device.temperature}°C)`,\n\t\t\t\t)\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tthis.logger.error('Failed to manage fan speed', error)\n\t\t}\n\t}\n\n\t// Set minimum fan speed (0-100%)\n\tasync setMinFanSpeed(percent: number): Promise<void> {\n\t\tconst EC_MIN_FAN_SPEED_ENABLE_ADDRESS = 0x5e\n\t\tconst EC_MIN_FAN_SPEED_ADDRESS = 0x5f\n\n\t\t// Clamp to valid range\n\t\tconst clampedPercent = Math.max(0, Math.min(100, percent))\n\n\t\t// Convert 0-100% to 0-255 for EC\n\t\tconst fanSpeedValue = Math.round((clampedPercent / 100) * 255)\n\n\t\t// Enable minimum fan speed mode and set the value\n\t\tawait this.#writeEcRegister(EC_MIN_FAN_SPEED_ENABLE_ADDRESS, 1)\n\t\tawait this.#writeEcRegister(EC_MIN_FAN_SPEED_ADDRESS, fanSpeedValue)\n\t}\n\n\t// Enable or disable automatic fan management\n\tsetFanManagementEnabled(enabled: boolean) {\n\t\tif (enabled) {\n\t\t\tif (!this.#stopManagingFan) {\n\t\t\t\tthis.logger.log('Resuming automatic fan management')\n\t\t\t\tthis.#stopManagingFan = runEvery('1 minute', () => this.#manageFanSpeed())\n\t\t\t}\n\t\t} else {\n\t\t\tthis.logger.log('Pausing automatic fan management')\n\t\t\tthis.#stopManagingFan?.()\n\t\t\tthis.#stopManagingFan = undefined\n\t\t}\n\t}\n\n\t// LED Control\n\n\t// TODO: Set LED behaviour during umbrelOS operation.\n\n\tEC_LED_STATE_ADDRESS = 0x50\n\n\t// Turn off the LED\n\tasync setLedOff(): Promise<void> {\n\t\tconst LED_STATE_OFF_VALUE = 0\n\t\tawait this.#writeEcRegister(this.EC_LED_STATE_ADDRESS, LED_STATE_OFF_VALUE)\n\t}\n\n\t// Set the LED to be static\n\t// Note: If the LED was previously blinking or breathing, it will keep it's colour.\n\t// If the LED was previously off, it will need to then have it's color set along with being\n\t// set to static to beturned on.\n\tasync setLedStatic(): Promise<void> {\n\t\tconst LED_STATE_STATIC_VALUE = 1\n\t\tawait this.#writeEcRegister(this.EC_LED_STATE_ADDRESS, LED_STATE_STATIC_VALUE)\n\t}\n\n\t// Set LED color\n\tasync setLedColor({red, green, blue}: {red: number; green: number; blue: number}): Promise<void> {\n\t\tconst EC_LED_RED_ADDRESS = 0x51\n\t\tconst EC_LED_GREEN_ADDRESS = 0x59\n\t\tconst EC_LED_BLUE_ADDRESS = 0x55\n\n\t\t// Clamp to valid range\n\t\tred = Math.max(0, Math.min(255, Math.round(red)))\n\t\tgreen = Math.max(0, Math.min(255, Math.round(green)))\n\t\tblue = Math.max(0, Math.min(255, Math.round(blue)))\n\n\t\tawait this.#writeEcRegister(EC_LED_RED_ADDRESS, red)\n\t\tawait this.#writeEcRegister(EC_LED_GREEN_ADDRESS, green)\n\t\tawait this.#writeEcRegister(EC_LED_BLUE_ADDRESS, blue)\n\t}\n\n\t// Set LED to white\n\tasync setLedWhite(): Promise<void> {\n\t\t// We use these values to adjust for brighter LEDs.\n\t\t// 255 across all channels gives us a turquoise color.\n\t\tawait this.setLedColor({red: 255, green: 100, blue: 128})\n\t}\n\n\t// Set LED to default state (static white)\n\tasync setLedDefault(): Promise<void> {\n\t\tawait this.setLedStatic()\n\t\tawait this.setLedWhite()\n\t}\n\n\t// Set LED to blinking\n\tasync setLedBlinking(): Promise<void> {\n\t\tconst LED_STATE_BLINKING_VALUE = 2\n\t\tawait this.#writeEcRegister(this.EC_LED_STATE_ADDRESS, LED_STATE_BLINKING_VALUE)\n\t}\n\n\t// Set LED to breathing mode\n\t// duration: 0-19 (higher = longer breathing cycle)\n\tasync setLedBreathe(duration: number = 14): Promise<void> {\n\t\tconst EC_LED_BREATHING_DURATION_ADDRESS = 0x52\n\t\tconst LED_STATE_BREATHING_VALUE = 3\n\n\t\tconst clampedDuration = Math.max(0, Math.min(19, Math.round(duration)))\n\t\tawait this.#writeEcRegister(EC_LED_BREATHING_DURATION_ADDRESS, clampedDuration)\n\t\tawait this.#writeEcRegister(this.EC_LED_STATE_ADDRESS, LED_STATE_BREATHING_VALUE)\n\t}\n\n\t// Reset Boot Key Flag\n\t//\n\t// The EC sets a flag at register 0xA8 when the device is powered on via\n\t// the reset button. This allows software to detect if the reset button\n\t// was used for boot (e.g., for recovery mode or special boot options).\n\n\t// TODO: Handle reset boot flags and show recovery ui.\n\n\tEC_RESET_BOOT_FLAG_ADDRESS = 0xa8\n\n\t// Check if device was booted via reset button\n\tasync wasBootedViaResetButton(): Promise<boolean> {\n\t\tconst flag = await this.#readEcRegister(this.EC_RESET_BOOT_FLAG_ADDRESS)\n\t\treturn flag === 1\n\t}\n\n\t// Clear the reset boot flag (should be called on shutdown)\n\tasync clearResetBootFlag(): Promise<void> {\n\t\tawait this.#writeEcRegister(this.EC_RESET_BOOT_FLAG_ADDRESS, 0)\n\t}\n\n\t// Get hardware SSD slot number from PCIe Physical Slot Number\n\t//\n\t// The PCI bus address (pci-0000:01:00.0-nvme-1) and root port address (1c.0, 1d.0) are not stable identifiers.\n\t// They appear stable in many situations but change based on which slots are populated.\n\t//\n\t// Using a single SSD and testing each physical slot individually casuses the bus address to return\n\t// inconsistent values. And for the  root port address multiple physical slots share a root port\n\t// when not all slots are populated.\n\t// For example, physical slots 1 and 2 both appear on root port 1c.0 when only\n\t// one is occupied, we can't distinguish which physical slot the SSD is in. Same\n\t// for slots 3 and 4 on 1d.0.\n\t//\n\t// The PCIe Slot Number from lspci appears to uniquely identify each physical slot and has been\n\t// tested to be stable in many situations.\n\tgetSsdSlotFromPciSlotNumber(pciSlotNumber: number | undefined): number | undefined {\n\t\tif (pciSlotNumber === 12) return 1\n\t\tif (pciSlotNumber === 14) return 2\n\t\tif (pciSlotNumber === 4) return 3\n\t\tif (pciSlotNumber === 6) return 4\n\n\t\treturn undefined\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/is-umbrel-home.ts",
    "content": "import fse from 'fs-extra'\nimport systeminfo from 'systeminformation'\n\nexport default async function isUmbrelHome() {\n\t// This file exists in old versions of amd64 Umbrel OS builds due to the Docker build system.\n\t// It confuses the systeminfo library and makes it return the model as 'Docker Container'.\n\tawait fse.remove('/.dockerenv')\n\n\tconst {manufacturer, model} = await systeminfo.system()\n\n\treturn manufacturer === 'Umbrel, Inc.' && model === 'Umbrel Home'\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/jwt.ts",
    "content": "import jwt from 'jsonwebtoken'\n\nconst ONE_MINUTE = 60\nconst ONE_HOUR = 60 * ONE_MINUTE\nconst ONE_DAY = 24 * ONE_HOUR\nconst ONE_WEEK = 7 * ONE_DAY\n\nconst JWT_ALGORITHM = 'HS256'\n\ntype jwtPayload = {\n\tloggedIn: boolean\n}\n\nconst validateSecret = (secret: string) => {\n\tconst hexRegex = /^[0-9a-fA-F]+$/\n\tif (secret.length !== 64 || !hexRegex.test(secret)) {\n\t\tthrow new Error('Invalid JWT secret, expected 256bit hex string')\n\t}\n\n\treturn true\n}\n\nexport async function sign(secret: string) {\n\tvalidateSecret(secret)\n\tconst payload: jwtPayload = {loggedIn: true}\n\tconst token = jwt.sign(payload, secret, {expiresIn: ONE_WEEK, algorithm: JWT_ALGORITHM})\n\n\treturn token\n}\n\nexport async function verify(token: string, secret: string) {\n\tvalidateSecret(secret)\n\tconst payload = jwt.verify(token, secret, {algorithms: [JWT_ALGORITHM]}) as jwtPayload\n\n\tif (payload.loggedIn !== true) throw new Error('Invalid JWT')\n\n\treturn true\n}\n\n// TODO: Only used for legacy auth server verification, we'll want to refactor this.\n// We create a JWT with the same key but a different payload.\n// This token will be stored in a cookie so it can travel across ports/apps.\n// The main login JWT is stored in local storage so it doesn't get leaked to apps\n// on different ports. Since this JWT does not include the loggedIn payload,\n// if it's leaked to an app they can't use it make authenticated API requests.\n// This token only lets you through the app proxy and nothing else.\nexport async function signProxyToken(secret: string) {\n\tvalidateSecret(secret)\n\tconst payload = {proxyToken: true}\n\tconst token = jwt.sign(payload, secret, {expiresIn: ONE_WEEK, algorithm: JWT_ALGORITHM})\n\n\treturn token\n}\n\nexport async function verifyProxyToken(token: string, secret: string) {\n\tvalidateSecret(secret)\n\tconst payload = jwt.verify(token, secret, {algorithms: [JWT_ALGORITHM]}) as any\n\n\tif (payload.proxyToken !== true) throw new Error('Invalid JWT')\n\n\treturn true\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/migration/migration.ts",
    "content": "import path from 'node:path'\n\nimport {type Compose} from 'compose-spec-schema'\nimport checkDiskSpace from 'check-disk-space'\nimport drivelist from 'drivelist'\nimport fse from 'fs-extra'\nimport {execa} from 'execa'\nimport {globby} from 'globby'\nimport yaml from 'js-yaml'\nimport semver from 'semver'\n\nimport type Umbreld from '../../index.js'\n\nimport isUmbrelHome from '../is-umbrel-home.js'\nimport type {ProgressStatus} from '../apps/schema.js'\nimport {reboot} from '../system/system.js'\nimport {setSystemStatus} from '../system/routes.js'\n\nlet migrationStatus: ProgressStatus = {\n\trunning: false,\n\tprogress: 0,\n\tdescription: '',\n\terror: false,\n}\n\n// Update the migrationStatus global\nfunction updateMigrationStatus(properties: Partial<ProgressStatus>) {\n\tmigrationStatus = {...migrationStatus, ...properties}\n\tconsole.log(migrationStatus)\n}\n\n// Get the migrationStatus global\nexport function getMigrationStatus() {\n\treturn migrationStatus\n}\n\n// Convert bytes integer to GB float\nfunction bytesToGB(bytes: number) {\n\treturn (bytes / 1024 / 1024 / 1024).toFixed(1)\n}\n\n// Get a directory size in bytes\nasync function getDirectorySize(directoryPath: string) {\n\tlet totalSize = 0\n\tconst files = await fse.readdir(directoryPath, {withFileTypes: true})\n\n\t// Traverse entire directory structure and tally up the size of all files\n\tfor (const file of files) {\n\t\tif (file.isSymbolicLink()) {\n\t\t\tconst lstats = await fse.lstat(path.join(directoryPath, file.name))\n\t\t\ttotalSize += lstats.size\n\t\t} else if (file.isFile()) {\n\t\t\tconst stats = await fse.stat(path.join(directoryPath, file.name))\n\t\t\ttotalSize += stats.size\n\t\t} else if (file.isDirectory()) {\n\t\t\ttotalSize += await getDirectorySize(path.join(directoryPath, file.name))\n\t\t}\n\t}\n\n\treturn totalSize\n}\n\n// Enumerate attached USB devices and return a path to the first one that is an Umbrel install\n// Returns false if no Umbrel install is found\nexport async function findExternalUmbrelInstall() {\n\ttry {\n\t\t// Get all external drives\n\t\tconst drives = await drivelist.list()\n\t\tconst externalDrives = drives.filter((drive) => drive.isUSB && !drive.isSystem)\n\n\t\tfor (const drive of externalDrives) {\n\t\t\t// If the drive is not mounted, mount it\n\t\t\tif (drive.mountpoints.length === 0) {\n\t\t\t\tconst device = `${drive.device}1` // Mount the first partition\n\t\t\t\tconst mountPoint = path.join('/mnt', path.basename(device))\n\n\t\t\t\ttry {\n\t\t\t\t\tawait fse.ensureDir(mountPoint)\n\t\t\t\t\tawait execa('mount', ['--read-only', device, mountPoint])\n\t\t\t\t\tdrive.mountpoints.push({path: mountPoint} as drivelist.Mountpoint)\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// If there's an error don't bail, keep trying the rest of the drives\n\t\t\t\t\tconsole.error(`Error mounting drive: ${error}`)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check if the drive is an Umbrel install\n\t\t\tfor (const mountpoint of drive.mountpoints) {\n\t\t\t\tconst umbrelDotFile = path.join(mountpoint.path, 'umbrel/.umbrel')\n\n\t\t\t\t// This is an Umbrel install\n\t\t\t\tif (await fse.pathExists(umbrelDotFile)) {\n\t\t\t\t\treturn path.dirname(umbrelDotFile)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Swallow any errors and just return false\n\t} catch (error) {\n\t\tconsole.error(`Error finding external Umbrel install: ${error}`)\n\t}\n\n\treturn false\n}\n\n// Best effort cleanup operation to unmount all external USB devices\nexport async function unmountExternalDrives() {\n\ttry {\n\t\t// Get all external drives\n\t\tconst drives = await drivelist.list()\n\t\tconst externalDrives = drives.filter((drive) => drive.isUSB && !drive.isSystem)\n\n\t\tfor (const drive of externalDrives) {\n\t\t\tfor (const mountpoint of drive.mountpoints) {\n\t\t\t\ttry {\n\t\t\t\t\tawait execa('umount', [mountpoint.path])\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// If there's an error don't bail, keep unmounting the rest of the drives\n\t\t\t\t\tconsole.error(`Error unmounting drive: ${error}`)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// Silently fail, this is just a best effort cleanup operation, we never want\n\t\t// it to kill the migration process.\n\t}\n}\n\n// Run a series of checks and throw a descriptive error if any of them fail\nexport async function runPreMigrationChecks(\n\tcurrentInstall: string,\n\texternalUmbrelInstall: string,\n\tumbreld: Umbreld,\n\tonlyAllowUmbrelHardware = true,\n) {\n\t// Check we're running on Umbrel Home or Umbrel Pro hardware\n\tif (onlyAllowUmbrelHardware) {\n\t\tconst [isHome, isPro] = await Promise.all([isUmbrelHome(), umbreld.hardware.umbrelPro.isUmbrelPro()])\n\t\tif (!isHome && !isPro) {\n\t\t\tthrow new Error('This feature is only supported on Umbrel Home or Umbrel Pro hardware')\n\t\t}\n\t}\n\n\t// Check migration isn't already running\n\tif (migrationStatus.running) {\n\t\tthrow new Error('Migration is already running')\n\t}\n\n\t// Check we have an Umbrel install on an external SSD\n\tif (!externalUmbrelInstall) {\n\t\tthrow new Error('No drive found with an umbrelOS install')\n\t}\n\n\t// Check version\n\tlet externalVersion = 'unknown'\n\tif (await fse.exists(`${externalUmbrelInstall}/umbrel.yaml`)) {\n\t\t// >=1.0 install\n\t\tconst data = await fse.readFile(`${externalUmbrelInstall}/umbrel.yaml`, 'utf8')\n\t\tconst {version} = yaml.load(data) as {version: string}\n\t\texternalVersion = version\n\t} else if (await fse.exists(`${externalUmbrelInstall}/info.json`)) {\n\t\t// <=0.5.4 install\n\t\tconst {version} = await fse.readJson(`${externalUmbrelInstall}/info.json`)\n\t\texternalVersion = version\n\t}\n\n\t// Don't allow migrating in data more recent than the current install\n\tconst validVersionRange =\n\t\texternalVersion !== 'unknown' && semver.gte(umbreld.version, semver.coerce(externalVersion)!)\n\tif (!validVersionRange) {\n\t\tthrow new Error(`Cannot migrate umbrelOS ${externalVersion} data into an umbrelOS ${umbreld.version} install.`)\n\t}\n\n\t// Check enough storage is available\n\tconst temporaryData = `${currentInstall}/.temporary-migration`\n\tawait fse.remove(temporaryData)\n\t// TODO: check-disk-space typings are broken ('This expression is not callable.')\n\tconst {free} = await (checkDiskSpace as any)(currentInstall)\n\tconst buffer = 1024 * 1024 * 1024 // 1GB\n\tconst required = (await getDirectorySize(externalUmbrelInstall)) + buffer\n\tif (free < required) {\n\t\tthrow new Error(`Not enough storage available. ${bytesToGB(free)} GB free, ${bytesToGB(required)} GB required.`)\n\t}\n\n\treturn externalUmbrelInstall\n}\n\n// Safely migrate data from an external Umbrel install to the current one\nexport async function migrateData(currentInstall: string, externalUmbrelInstall: string, umbreld: Umbreld) {\n\tsetSystemStatus('migrating')\n\tupdateMigrationStatus({running: false, progress: 0, description: '', error: false})\n\n\tconst temporaryData = `${currentInstall}/.temporary-migration`\n\tconst finalData = `${currentInstall}/import`\n\n\t// Start migration\n\tupdateMigrationStatus({running: true, description: 'Copying data'})\n\n\ttry {\n\t\t// Copy over data dir from previous install to temp dir while preserving permissions\n\t\tawait fse.remove(temporaryData)\n\t\tconst rsync = execa('rsync', [\n\t\t\t'--info=progress2',\n\t\t\t'--archive',\n\t\t\t'--delete',\n\t\t\t`${externalUmbrelInstall}/`,\n\t\t\ttemporaryData,\n\t\t])\n\n\t\t// Update migration status with rsync progress\n\t\trsync.stdout!.on('data', (chunk) => {\n\t\t\tconst progressUpdate = chunk.toString().match(/.* (\\d*)% .*/)\n\t\t\tif (progressUpdate) {\n\t\t\t\tconst percent = Number.parseInt(progressUpdate[1], 10)\n\t\t\t\t// Show file copy percentage as 60% of total migration progress\n\t\t\t\t// @ts-expect-error Technically this should probably be Math.round\n\t\t\t\t// to avoid the type error but it works fine and I don't want to\n\t\t\t\t// update this and retest so ignore for now.\n\t\t\t\tconst progress = Number.parseInt(0.6 * percent, 10)\n\t\t\t\tif (progress > migrationStatus.progress) updateMigrationStatus({progress})\n\t\t\t}\n\t\t})\n\n\t\t// Wait for rsync to finish\n\t\tawait rsync\n\n\t\t// Pull app images\n\t\ttry {\n\t\t\tlet progress = migrationStatus.progress\n\t\t\tupdateMigrationStatus({description: 'Downloading apps'})\n\t\t\tconst files = await globby(`${temporaryData}/app-data/*/docker-compose.yml`)\n\t\t\tconst pulls = []\n\t\t\tconst dockerPull = async (image: string) => {\n\t\t\t\tawait execa('docker', ['pull', image])\n\t\t\t\t// Show docker pull progress as (60%-90%) of total migration progress\n\t\t\t\tprogress += 30 / pulls.length\n\t\t\t\t// @ts-expect-error Ignore type error with parseInt expecting string (as above)\n\t\t\t\tupdateMigrationStatus({progress: Number.parseInt(progress, 10)})\n\t\t\t}\n\n\t\t\tfor (const file of files) {\n\t\t\t\tconst data = await fse.readFile(file, 'utf8')\n\t\t\t\tconst compose = yaml.load(data) as Compose\n\n\t\t\t\tfor (const {image} of Object.values(compose.services!)) {\n\t\t\t\t\tif (image) {\n\t\t\t\t\t\tpulls.push(dockerPull(image))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tawait Promise.allSettled(pulls)\n\t\t} catch (error) {\n\t\t\t// We don't care about handling this, everything will be pulled in the start script.\n\t\t\t// This just gives us nicer progress reporting.\n\t\t\tconsole.error('Error processing docker-compose files:', error)\n\t\t}\n\n\t\t// Move data from temp migration dir to final migration dir\n\t\t// The main data dir will be replaced with this dir on the next reboot\n\t\tupdateMigrationStatus({progress: 92, description: 'Cleaning up'})\n\t\tawait fse.move(temporaryData, finalData, {overwrite: true})\n\t} catch (error) {\n\t\tconsole.error(error)\n\t\tsetSystemStatus('running')\n\t\tupdateMigrationStatus({running: false, progress: 0, description: '', error: 'Failed to migrate data'})\n\t\treturn\n\t}\n\n\tupdateMigrationStatus({progress: 95, description: 'Rebooting'})\n\tsetSystemStatus('restarting')\n\tawait umbreld.stop()\n\tawait reboot()\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/migration/routes.ts",
    "content": "import {router, privateProcedure, publicProcedureWhenNoUserExists} from '../server/trpc/trpc.js'\n\nimport {\n\tfindExternalUmbrelInstall,\n\trunPreMigrationChecks,\n\tmigrateData,\n\tgetMigrationStatus,\n\tunmountExternalDrives,\n} from './migration.js'\nimport isUmbrelHome from '../is-umbrel-home.js'\n\nexport default router({\n\tisUmbrelHome: privateProcedure.query(() => isUmbrelHome()),\n\t// TODO: Implement\n\tisMigratingFromUmbrelHome: privateProcedure.query(() => false),\n\n\tcanMigrate: privateProcedure.query(async ({ctx}) => {\n\t\tconst currentInstall = ctx.umbreld.dataDirectory\n\t\tconst externalUmbrelInstall = await findExternalUmbrelInstall()\n\t\tawait runPreMigrationChecks(currentInstall, externalUmbrelInstall as string, ctx.umbreld)\n\t\tawait unmountExternalDrives()\n\n\t\treturn true\n\t}),\n\n\t// TODO: Refactor this into a subscription\n\tmigrationStatus: publicProcedureWhenNoUserExists.query(() => getMigrationStatus()),\n\n\tmigrate: privateProcedure.mutation(async ({ctx}) => {\n\t\tconst currentInstall = ctx.umbreld.dataDirectory\n\t\tconst externalUmbrelInstall = await findExternalUmbrelInstall()\n\t\tawait runPreMigrationChecks(currentInstall, externalUmbrelInstall as string, ctx.umbreld)\n\n\t\tvoid migrateData(currentInstall, externalUmbrelInstall as string, ctx.umbreld)\n\n\t\treturn true\n\t}),\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/notifications/notifications.integration.test.ts",
    "content": "import {expect, beforeAll, afterAll, test} from 'vitest'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\n// The following tests are stateful and must be run in order\n\n// We sleep to allow time for fs events to be triggered and handled by the umbreld filewatcher\n\ntest.sequential('notifications.get() throws invalid error without auth token', async () => {\n\tawait expect(umbreld.client.notifications.get.query()).rejects.toThrow('Invalid token')\n})\n\ntest.sequential('login', async () => {\n\tawait expect(umbreld.registerAndLogin()).resolves.toBe(true)\n})\n\ntest.sequential('notifications.get() lists nothing on a fresh install', async () => {\n\tawait expect(umbreld.client.notifications.get.query()).resolves.toMatchObject([])\n})\n\ntest.sequential('notifications.add(notification) adds a notification', async () => {\n\tawait umbreld.instance.notifications.add('test notification')\n\tawait expect(umbreld.client.notifications.get.query()).resolves.toMatchObject(['test notification'])\n})\n\ntest.sequential('notifications.clear(notification) clears a notification', async () => {\n\tawait expect(umbreld.client.notifications.get.query()).resolves.toMatchObject(['test notification'])\n\tawait umbreld.client.notifications.clear.mutate('test notification')\n\tawait expect(umbreld.client.notifications.get.query()).resolves.toMatchObject([])\n})\n\ntest.sequential('notifications.add(notification) moves duplicate notifications to front', async () => {\n\t// Add numbered notifications\n\tawait umbreld.instance.notifications.add('notification-1')\n\tawait umbreld.instance.notifications.add('notification-2')\n\tawait umbreld.instance.notifications.add('notification-3')\n\n\t// Now add the first again to move it to the front\n\tawait umbreld.instance.notifications.add('notification-1')\n\n\tawait expect(umbreld.client.notifications.get.query()).resolves.toMatchObject([\n\t\t'notification-1',\n\t\t'notification-3',\n\t\t'notification-2',\n\t])\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/notifications/notifications.ts",
    "content": "import type Umbreld from '../../index.js'\n\nexport default class Notifications {\n\t#store: Umbreld['store']\n\tlogger: Umbreld['logger']\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#store = umbreld.store\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(name.toLowerCase())\n\t}\n\n\t// Get the user object from the store\n\tasync get() {\n\t\treturn (await this.#store.get('notifications')) || []\n\t}\n\n\tasync add(notification: string) {\n\t\tthis.logger.log(`Adding notification: ${notification}`)\n\t\tawait this.#store.getWriteLock(async ({set}) => {\n\t\t\t// Get all notifications\n\t\t\tlet notifications = await this.get()\n\n\t\t\t// Remove current one if it already exists so it's\n\t\t\t// moved to the front\n\t\t\tnotifications = notifications.filter((n) => n !== notification)\n\n\t\t\t// Add new notification\n\t\t\tnotifications.unshift(notification)\n\n\t\t\t// Save new notifications\n\t\t\tawait set('notifications', notifications)\n\t\t})\n\n\t\treturn true\n\t}\n\n\tasync clear(notification: string) {\n\t\tthis.logger.log(`Clearing notification: ${notification}`)\n\t\tawait this.#store.getWriteLock(async ({set}) => {\n\t\t\t// Get all notifications\n\t\t\tlet notifications = await this.get()\n\n\t\t\t// Remove current one if it already exists\n\t\t\tnotifications = notifications.filter((n) => n !== notification)\n\n\t\t\t// Save new notifications\n\t\t\tawait set('notifications', notifications)\n\t\t})\n\n\t\treturn true\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/notifications/routes.ts",
    "content": "import {z} from 'zod'\n\nimport {router, privateProcedure} from '../server/trpc/trpc.js'\n\nexport default router({\n\t// Gets all notifications\n\tget: privateProcedure.query(async ({ctx}) => ctx.umbreld.notifications.get()),\n\n\t// Removes a notification\n\tclear: privateProcedure.input(z.string()).mutation(async ({ctx, input}) => ctx.umbreld.notifications.clear(input)),\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/server/index.ts",
    "content": "import http from 'node:http'\nimport process from 'node:process'\nimport {promisify} from 'node:util'\nimport {fileURLToPath} from 'node:url'\nimport {dirname, join} from 'node:path'\nimport {createGzip} from 'node:zlib'\nimport {pipeline} from 'node:stream/promises'\n\nimport {$} from 'execa'\nimport express from 'express'\nimport cookieParser from 'cookie-parser'\nimport helmet from 'helmet'\n\nimport {WebSocketServer} from 'ws'\nimport {createProxyMiddleware} from 'http-proxy-middleware'\n\nimport getOrCreateFile from '../utilities/get-or-create-file.js'\nimport randomToken from '../utilities/random-token.js'\n\nimport type Umbreld from '../../index.js'\nimport * as jwt from '../jwt.js'\nimport {trpcExpressHandler, trpcWssHandler} from './trpc/index.js'\nimport createTerminalWebSocketHandler from './terminal-socket.js'\n\nimport fileApi from '../files/api.js'\n\nexport type ServerOptions = {umbreld: Umbreld}\n\nexport type ApiOptions = {\n\tpublicApi: express.Router\n\tprivateApi: express.Router\n\tumbreld: Umbreld\n}\n\n// Safely wrapps async request handlers in logic to catch errors and pass them to the errror handling middleware\nconst asyncHandler = (\n\thandler: (request: express.Request, response: express.Response, next: express.NextFunction) => Promise<any>,\n) =>\n\tfunction asyncHandlerWrapper(request: express.Request, response: express.Response, next: express.NextFunction) {\n\t\treturn Promise.resolve(handler(request, response, next)).catch(next)\n\t}\n\n// Iterate over all routes and wrap them in an async handler\nconst wrapHandlersWithAsyncHandler = (router: express.Router) => {\n\t// Loop over each layer of the router stack\n\tfor (const layer of router.stack) {\n\t\t// If we have a nested router, recursively wrap its handlers\n\t\tif (layer.name === 'router') wrapHandlersWithAsyncHandler(layer.handle)\n\t\t// If we have a route, wrap its handlers\n\t\telse if (layer.route) {\n\t\t\tfor (const routeLayer of layer.route.stack) routeLayer.handle = asyncHandler(routeLayer.handle)\n\t\t}\n\t}\n}\n\nclass Server {\n\tumbreld: Umbreld\n\tlogger: Umbreld['logger']\n\tport: number | undefined\n\tapp?: express.Express\n\tserver?: http.Server\n\twebSocketRouter = new Map<string, WebSocketServer>()\n\n\tconstructor({umbreld}: ServerOptions) {\n\t\tthis.umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(name.toLowerCase())\n\t}\n\n\tasync getJwtSecret() {\n\t\tconst jwtSecretPath = `${this.umbreld.dataDirectory}/secrets/jwt`\n\t\treturn getOrCreateFile(jwtSecretPath, randomToken(256))\n\t}\n\n\tasync signToken() {\n\t\treturn jwt.sign(await this.getJwtSecret())\n\t}\n\n\tasync signProxyToken() {\n\t\treturn jwt.signProxyToken(await this.getJwtSecret())\n\t}\n\n\tasync verifyToken(token: string) {\n\t\treturn jwt.verify(token, await this.getJwtSecret())\n\t}\n\n\tasync verifyProxyToken(token: string) {\n\t\treturn jwt.verifyProxyToken(token, await this.getJwtSecret())\n\t}\n\n\t// Creates an isolated WebSocket server and mounts it at a specific path\n\t// All WebSocket servers require a valid auth token to connect\n\tmountWebSocketServer(path: string, setupHandler: (wss: WebSocketServer) => void) {\n\t\t// Create the WebSocket server\n\t\tconst wss = new WebSocketServer({noServer: true})\n\n\t\t// Pass the WebSocket server to the setup handler so it can do whatever it needs\n\t\tsetupHandler(wss)\n\n\t\t// Add the WebSocket server to the router\n\t\tthis.webSocketRouter.set(path, wss)\n\t}\n\n\tasync start() {\n\t\t// Ensure the JWT secret exists\n\t\tawait this.getJwtSecret()\n\n\t\t// Create the handler and server\n\t\tthis.app = express()\n\t\tthis.server = http.createServer(this.app)\n\n\t\t// Don't timeout for slow uploads/downloads\n\t\t// TODO: Ideally we'd only remove timeout for authed upload/download\n\t\t// requests not globally to better protect against potential DoS attacks.\n\t\t// However Node.js only allows us to set the timeout globally. Risk is also\n\t\t// very low since this server is not exposed publically.\n\t\t// Looks like Bun supports per request timeout so if we move we could lock this\n\t\t// down a little tighter: https://bun.sh/docs/api/http#server-timeout-request-seconds-custom-request-timeouts\n\t\tthis.server.requestTimeout = 0\n\n\t\t// Setup cookie parser\n\t\tthis.app.use(cookieParser())\n\n\t\t// Security hardening, CSP\n\t\tthis.app.use(\n\t\t\thelmet.contentSecurityPolicy({\n\t\t\t\tdirectives: {\n\t\t\t\t\t// Allow inline scripts ONLY in development for vite dev server\n\t\t\t\t\tscriptSrc: this.umbreld.developmentMode ? [\"'self'\", \"'unsafe-inline'\"] : null,\n\t\t\t\t\t// Allow 3rd party app images (remove this if we serve them locally in the future)\n\t\t\t\t\t// Also allow blob: URLs for images being uploaded in Files (since their thumbnails don't exist yet)\n\t\t\t\t\timgSrc: ['*', 'blob:'],\n\t\t\t\t\t// Allow fetching data from our apps API (e.g., for Discover page in App Store)\n\t\t\t\t\tconnectSrc: [\"'self'\", 'https://apps.umbrel.com'],\n\t\t\t\t\t// Allow plain text access over the local network\n\t\t\t\t\tupgradeInsecureRequests: null,\n\t\t\t\t},\n\t\t\t}),\n\t\t)\n\t\tthis.app.use(helmet.referrerPolicy({policy: 'no-referrer'}))\n\t\tthis.app.disable('x-powered-by')\n\n\t\t// Attach the umbreld and logger instances so they're accessible to routes\n\t\tthis.app.set('umbreld', this.umbreld)\n\t\tthis.app.set('logger', this.logger)\n\n\t\t// Log requests\n\t\tthis.app.use((request, response, next) => {\n\t\t\tthis.logger.verbose(`${request.method} ${request.path}`)\n\t\t\tnext()\n\t\t})\n\n\t\t// Handle WebSocket upgrade requests\n\t\t// We add a single upgrade handler for all WebSocket servers and check\n\t\t// for their existence in a router so we can be sure we destroy the socket\n\t\t// immediately if a match isn't found instead of keeping it open. This prevents\n\t\t// slowloris style DoS attacks.\n\t\tthis.server?.on('upgrade', async (request, socket, head) => {\n\t\t\ttry {\n\t\t\t\t// Grab the path and search params from the request\n\t\t\t\tconst {pathname, searchParams} = new URL(`https://localhost${request.url}`)\n\n\t\t\t\t// See if we have a WebSocket server for this path in our router\n\t\t\t\tconst wss = this.webSocketRouter.get(pathname)\n\n\t\t\t\t// If this path isn't in the router stop and destroy the socket to prevent\n\t\t\t\t// DoS attacks.\n\t\t\t\tif (!wss) {\n\t\t\t\t\t// However we don't destroy the socket in development mode because\n\t\t\t\t\t// we want to allow WebSocket connections to be proxied through to\n\t\t\t\t\t// the vite HMR client.\n\t\t\t\t\tif (this.umbreld.developmentMode) return\n\n\t\t\t\t\tthrow new Error(`No WebSocket server mounted for ${pathname}`)\n\t\t\t\t}\n\n\t\t\t\t// Verify the auth token before doing anything\n\t\t\t\t// We require passing the token like this because it's unsafe to rely on cookies\n\t\t\t\t// since they get leaked to other apps running on different ports on the same hostname\n\t\t\t\t// due to relaxed browser sandboxing.\n\t\t\t\t// We can't set custom headers because that not allowed by the WebSocket browser spec.\n\t\t\t\tconst token = searchParams.get('token')\n\t\t\t\tif (await this.verifyToken(token!)) {\n\t\t\t\t\tthis.logger.verbose(`WS upgrade for ${pathname}`)\n\t\t\t\t\t// Upgrade connection to WebSocket and fire the connection handler\n\t\t\t\t\twss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request))\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tthis.logger.error(`Error upgrading websocket`, error)\n\t\t\t\tsocket.destroy()\n\t\t\t}\n\t\t})\n\n\t\t// This is needed for legacy reasons when 0.5.x users OTA update to 1.0.\n\t\t// 0.5.x polls this endpoint during update to know when it's completed.\n\t\tthis.app.get('/manager-api/v1/system/update-status', (request, response) => {\n\t\t\tresponse.json({state: 'success', progress: 100, description: '', updateTo: ''})\n\t\t})\n\n\t\t// Handle tRPC routes\n\t\tthis.app.use('/trpc', trpcExpressHandler)\n\t\tthis.mountWebSocketServer('/trpc', (wss) => {\n\t\t\ttrpcWssHandler({wss, umbreld: this.umbreld, logger: this.logger})\n\t\t})\n\n\t\t// Handle terminal WebSocket routes\n\t\tthis.mountWebSocketServer('/terminal', (wss) => {\n\t\t\tconst logger = this.logger.createChildLogger('terminal')\n\t\t\twss.on('connection', createTerminalWebSocketHandler({umbreld: this.umbreld, logger}))\n\t\t})\n\n\t\t// Handle API routes\n\t\tconst createApi = (registerApi: ({publicApi, privateApi, umbreld}: ApiOptions) => void) => {\n\t\t\t// Create public and private routers\n\t\t\tconst publicApi = express.Router()\n\t\t\tconst privateApi = express.Router()\n\t\t\tprivateApi.use(async (request, response, next) => {\n\t\t\t\tconst token = request?.cookies?.UMBREL_PROXY_TOKEN\n\t\t\t\tconst isValid = await this.verifyProxyToken(token).catch(() => false)\n\t\t\t\tif (!isValid) return response.status(401).json({error: 'unauthorized'})\n\n\t\t\t\tnext()\n\t\t\t})\n\n\t\t\t// Register API handlers\n\t\t\tregisterApi({publicApi, privateApi, umbreld: this.umbreld})\n\n\t\t\t// Mount the public and private on a single router\n\t\t\tconst api = express.Router()\n\t\t\tapi.use(publicApi)\n\t\t\tapi.use(privateApi)\n\n\t\t\treturn api\n\t\t}\n\t\tthis.app.use('/api/files', createApi(fileApi))\n\n\t\t// Handle log file downloads\n\t\tthis.app.get('/logs/', async (request, response) => {\n\t\t\t// Check the user is logged in\n\t\t\ttry {\n\t\t\t\t// We shouldn't really use the proxy token for this but it's\n\t\t\t\t// fine until we have subdomains and refactor to session cookies\n\t\t\t\tawait this.verifyProxyToken(request?.cookies?.UMBREL_PROXY_TOKEN)\n\t\t\t} catch (error) {\n\t\t\t\treturn response.status(401).send('Unauthorized')\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\t// Force the browser to treat the request as a file download\n\t\t\t\tresponse.set('Content-Disposition', `attachment;filename=umbrel-${Date.now()}.log.gz`)\n\t\t\t\tconst journal = $`journalctl`\n\t\t\t\tawait pipeline(journal.stdout!, createGzip(), response)\n\t\t\t} catch (error) {\n\t\t\t\tthis.logger.error(`Error streaming logs`, error)\n\t\t\t}\n\t\t})\n\n\t\t// If we have no API route hits then serve the ui at the root.\n\t\t// We proxy through to the ui dev server during development with\n\t\t// process.env.UMBREL_UI_PROXY otherwise in production we\n\t\t// statically serve the built ui.\n\t\tif (process.env.UMBREL_UI_PROXY) {\n\t\t\tthis.app.use(\n\t\t\t\t'/',\n\t\t\t\tcreateProxyMiddleware({\n\t\t\t\t\ttarget: process.env.UMBREL_UI_PROXY,\n\t\t\t\t\tws: true,\n\t\t\t\t\tlogProvider: () => ({\n\t\t\t\t\t\tlog: this.logger.verbose,\n\t\t\t\t\t\tdebug: this.logger.verbose,\n\t\t\t\t\t\tinfo: this.logger.verbose,\n\t\t\t\t\t\twarn: this.logger.verbose,\n\t\t\t\t\t\terror: this.logger.error,\n\t\t\t\t\t}),\n\t\t\t\t}),\n\t\t\t)\n\t\t} else {\n\t\t\tconst currentFilename = fileURLToPath(import.meta.url)\n\t\t\tconst currentDirname = dirname(currentFilename)\n\t\t\tconst uiPath = join(currentDirname, '../../../ui')\n\n\t\t\t// Built assets include a hash of the contents in the filename and\n\t\t\t// wallpapers do not ever change, so we can cache these aggressively\n\t\t\tconst cacheAggressively: express.RequestHandler = (_, response, next) => {\n\t\t\t\tconst approximatelyOneYearInSeconds = 365 * 24 * 60 * 60 // RFC 2616, 14.21\n\t\t\t\tresponse.set('Cache-Control', `public, max-age=${approximatelyOneYearInSeconds}, immutable`)\n\t\t\t\tnext()\n\t\t\t}\n\t\t\tthis.app.get('/assets/*', cacheAggressively)\n\t\t\tthis.app.get('/wallpapers/*', cacheAggressively)\n\n\t\t\t// Other files without a hash in their filename should revalidate based on\n\t\t\t// ETag and Last-Modified instead to force the browser to automatically\n\t\t\t// refresh their contents after an OTA update for example.\n\t\t\tconst staticOptions = {cacheControl: true, etag: true, lastModified: true, maxAge: 0}\n\t\t\tthis.app.use('/', express.static(uiPath, staticOptions))\n\n\t\t\t// SPA fallback: serve index.html for all unmatched routes\n\t\t\tthis.app.get('*', (request, response) => {\n\t\t\t\tresponse.sendFile(join(uiPath, 'index.html'), staticOptions)\n\t\t\t})\n\t\t}\n\n\t\t// All errors should be handled by their own middleware but if they aren't we'll catch\n\t\t// them here and log them.\n\t\tthis.app.use(\n\t\t\t(error: Error, request: express.Request, response: express.Response, next: express.NextFunction): void => {\n\t\t\t\tthis.logger.error(`${request.method} ${request.path}`, error)\n\t\t\t\tif (response.headersSent) return\n\t\t\t\tresponse.status(500).json({error: true})\n\t\t\t},\n\t\t)\n\n\t\t// Wrap all request handlers with a safe async handler\n\t\t// TODO: We can remove this if we move to express 5\n\t\twrapHandlersWithAsyncHandler(this.app._router)\n\n\t\t// Start the server\n\t\tconst listen = promisify(this.server.listen.bind(this.server)) as (port: number) => Promise<void>\n\t\tawait listen(this.umbreld.port)\n\t\tthis.port = (this.server.address() as any).port\n\t\tthis.logger.log(`Listening on port ${this.port}`)\n\n\t\treturn this\n\t}\n}\n\nexport default Server\n"
  },
  {
    "path": "packages/umbreld/source/modules/server/terminal-socket.ts",
    "content": "import type http from 'node:http'\n\nimport {$} from 'execa'\nimport pty, {IPty} from 'node-pty'\nimport {type WebSocket} from 'ws'\n\nimport type Umbreld from '../../index.js'\nimport type createLogger from '../utilities/logger.js'\n\nconst DEFAULT_SHELL_CONTAINERS: Record<string, string> = {\n\tbitcoin: 'bitcoind',\n\tlightning: 'lnd',\n\tordinals: 'ord',\n\tnextcloud: 'web',\n\t'core-lightning': 'lightningd',\n\t'home-assistant': 'server',\n\t'bitcoin-knots': 'bitcoind',\n\timmich: 'server',\n\tphotoprism: 'web',\n}\n\nexport default function createTerminalWebSocketHandler({\n\tumbreld,\n\tlogger,\n}: {\n\tumbreld: Umbreld\n\tlogger: ReturnType<typeof createLogger>\n}) {\n\treturn async function (ws: WebSocket, request: http.IncomingMessage) {\n\t\ttry {\n\t\t\tconst appId = new URL(`https://localhost/${request.url}`).searchParams.get('appId')\n\t\t\tconst cols = Number(new URL(`https://localhost/${request.url}`).searchParams.get('cols'))\n\t\t\tconst rows = Number(new URL(`https://localhost/${request.url}`).searchParams.get('rows'))\n\n\t\t\tlet ptyProcess: IPty\n\n\t\t\tif (appId) {\n\t\t\t\tconst app = await umbreld.apps.getApp(appId)\n\t\t\t\tconst [manifest, compose] = await Promise.all([app.readManifest(), app.readCompose()])\n\t\t\t\tlet container\n\n\t\t\t\t// If app has specified a default shell in it's manifest use that\n\t\t\t\tif (manifest.defaultShell) {\n\t\t\t\t\tcontainer = compose.services![manifest.defaultShell]?.container_name\n\t\t\t\t}\n\n\t\t\t\t// If we don't have a default container specified, use a predefined lookup\n\t\t\t\tif (!container) {\n\t\t\t\t\tcontainer = container = compose.services![DEFAULT_SHELL_CONTAINERS[app.id]]?.container_name\n\t\t\t\t}\n\n\t\t\t\t// If we still don't have a default container use the first container as a fallback\n\t\t\t\tif (!container) {\n\t\t\t\t\tcontainer = Object.values(compose.services!).filter((service) => service.image && service.container_name)[0]\n\t\t\t\t\t\t?.container_name as string\n\t\t\t\t}\n\n\t\t\t\t// Launch terminal with interactive docker shell\n\t\t\t\t// We set a consistent '$ ' prompt across different containers regardless of the shell environment (bash or sh)\n\t\t\t\t// by overriding any existing PS1 settings.\n\t\t\t\t// We prioritize bash for better feature support but fall back to sh if bash is not available.\n\t\t\t\t// We disable bashrc with `--norc` to make sure the prompt isn't overridden.\n\t\t\t\tptyProcess = pty.spawn(\n\t\t\t\t\t'docker',\n\t\t\t\t\t[\n\t\t\t\t\t\t'exec',\n\t\t\t\t\t\t'-it',\n\t\t\t\t\t\tcontainer,\n\t\t\t\t\t\t'/bin/sh',\n\t\t\t\t\t\t'-c',\n\t\t\t\t\t\t`\n\t\t\t\t\t\texport PS1='$ '\n\t\t\t\t\t\tif command -v bash >/dev/null 2>&1; then\n\t\t\t\t\t\t\texec bash --norc\n\t\t\t\t\t\telse\n\t\t\t\t\t\t\texec sh\n\t\t\t\t\t\tfi\n\t\t\t\t\t\t`,\n\t\t\t\t\t],\n\t\t\t\t\t{\n\t\t\t\t\t\tname: 'xterm-color',\n\t\t\t\t\t\tcols,\n\t\t\t\t\t\trows,\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t} else {\n\t\t\t\t// Get username of first non-root user on the system\n\t\t\t\tconst {stdout: username} = await $`id -nu 1000`\n\t\t\t\t// launch terminal with non-root user\n\t\t\t\tptyProcess = pty.spawn(\n\t\t\t\t\t'sudo',\n\t\t\t\t\t['--user', username, '--login', 'bash', '-c', 'if [ -f /etc/motd ]; then cat /etc/motd; fi; exec bash'],\n\t\t\t\t\t{\n\t\t\t\t\t\tname: 'xterm-color',\n\t\t\t\t\t\tcols,\n\t\t\t\t\t\trows,\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t}\n\t\t\t// Stream output from the shell to the WebSocket\n\t\t\tptyProcess.onData((data) => ws.send(data))\n\n\t\t\t// Stream input from the WebSocket to the shell\n\t\t\tws.on('message', (data) => ptyProcess.write(data.toString()))\n\n\t\t\t// Kill process when WebSocket is closed\n\t\t\tws.on('close', () => ptyProcess.kill())\n\t\t} catch (error) {\n\t\t\tlogger.error(`Terminal socket`, error)\n\t\t\tws?.close()\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/server/trpc/common.ts",
    "content": "// This must be in it's own file otherwise the frontend tries to import\n// loads of stuff from the backend and blows up.\n\n// Export the router type for use in clients in other packages\nexport type {AppRouter} from './index.js'\n\n// RPCs that MUST use HTTP (cookie/header semantics). Clients use this list for split-link routing.\nexport const httpOnlyPaths = [\n\t// sets cookie\n\t'user.login',\n\t// reads Authorization header\n\t'user.isLoggedIn',\n\t// renews cookie\n\t'user.renewToken',\n\t// clears cookie\n\t'user.logout',\n\t// system.status doesn't use cookies/headers, but the UI polls it across restarts to detect when umbreld is back online; we force HTTP to avoid WS reconnect handshake\n\t'system.status',\n] as const\n"
  },
  {
    "path": "packages/umbreld/source/modules/server/trpc/context.ts",
    "content": "import {type CreateExpressContextOptions} from '@trpc/server/adapters/express'\nimport type Umbreld from '../../../index.js'\n\nexport const createContextExpress = ({req, res}: CreateExpressContextOptions) => {\n\tconst umbreld = req.app.get('umbreld') as Umbreld\n\tconst logger = req.app.get('logger') as Umbreld['logger']\n\treturn {\n\t\t...createContext({umbreld, logger}),\n\t\ttransport: 'express' as const,\n\t\trequest: req,\n\t\tresponse: res,\n\t}\n}\n\nexport const createContextWss = ({umbreld, logger}: {umbreld: Umbreld; logger: Umbreld['logger']}) => {\n\treturn {\n\t\t...createContext({umbreld, logger}),\n\t\ttransport: 'ws' as const,\n\t}\n}\n\nconst createContext = ({umbreld, logger}: {umbreld: Umbreld; logger: Umbreld['logger']}) => {\n\tconst server = umbreld.server\n\tconst user = umbreld.user\n\tconst appStore = umbreld.appStore\n\tconst apps = umbreld.apps\n\treturn {\n\t\tumbreld,\n\t\tserver,\n\t\tuser,\n\t\tappStore,\n\t\tapps,\n\t\tlogger,\n\t\tdangerouslyBypassAuthentication: false,\n\t}\n}\n\n// Helper that flattens the resulting intersection so the IDE shows\n// a single object type instead of A & B & C …\ntype Simplify<T> = {[K in keyof T]: T[K]}\n\n/**\n * Merge two object types:\n * - Keys that exist in **both** A and B are **required** and their type is `A[K] | B[K]`\n * - Keys that exist in **only one** side become **optional**\n */\ntype Merge<A, B> = Simplify<\n\t// 1. keys in both → required, union of the two property types\n\t{[K in keyof A & keyof B]: A[K] | B[K]} & {[K in Exclude<keyof A, keyof B>]?: A[K]} & {\n\t\t// 2. keys only in A → optional // 3. keys only in B → optional\n\t\t[K in Exclude<keyof B, keyof A>]?: B[K]\n\t}\n>\n\n// Combined type that satisfies both the websocket and express contexts\ntype ContextWss = ReturnType<typeof createContextWss>\ntype ContextExpress = ReturnType<typeof createContextExpress>\nexport type Context = Merge<ContextWss, ContextExpress>\n"
  },
  {
    "path": "packages/umbreld/source/modules/server/trpc/index.ts",
    "content": "import {createExpressMiddleware} from '@trpc/server/adapters/express'\nimport {applyWSSHandler} from '@trpc/server/adapters/ws'\n\nimport {router} from './trpc.js'\nimport {createContextExpress, createContextWss} from './context.js'\nimport migration from '../../migration/routes.js'\nimport system from '../../system/routes.js'\nimport wifi from '../../system/wifi-routes.js'\nimport user from '../../user/routes.js'\nimport {appStore, apps} from '../../apps/routes.js'\nimport widget from '../../widgets/routes.js'\nimport files from '../../files/routes.js'\nimport hardware from '../../hardware/routes.js'\nimport notifications from '../../notifications/routes.js'\nimport eventBus from '../../event-bus/routes.js'\nimport backups from '../../backups/routes.js'\n\nimport {type WebSocketServer} from 'ws'\nimport type Umbreld from '../../../index.js'\n\nconst appRouter = router({\n\tmigration,\n\tsystem,\n\twifi,\n\tuser,\n\tappStore,\n\tapps,\n\twidget,\n\tfiles,\n\thardware,\n\tnotifications,\n\teventBus,\n\tbackups,\n})\n\nexport type AppRouter = typeof appRouter\n\nexport const trpcExpressHandler = createExpressMiddleware({\n\trouter: appRouter,\n\tcreateContext: createContextExpress,\n\tonError({error, ctx}) {\n\t\tctx?.logger.error(`${ctx?.request?.method} ${ctx?.request?.path}`, error)\n\t},\n})\n\nexport const trpcWssHandler = ({\n\twss,\n\tumbreld,\n\tlogger,\n}: {\n\twss: WebSocketServer\n\tumbreld: Umbreld\n\tlogger: Umbreld['logger']\n}) => {\n\treturn applyWSSHandler({\n\t\twss,\n\t\trouter: appRouter,\n\t\tcreateContext: () => createContextWss({umbreld, logger}),\n\t\tonError({error, ctx, path}) {\n\t\t\tlogger.error(`WS ${path}`, error)\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/server/trpc/is-authenticated.ts",
    "content": "import {TRPCError} from '@trpc/server'\n\nimport {type Context} from './context.js'\n\ntype MiddlewareOptions = {\n\tctx: Context\n\tnext: () => Promise<any>\n}\n\nexport const isAuthenticated = async ({ctx, next}: MiddlewareOptions) => {\n\tif (ctx.dangerouslyBypassAuthentication === true) return next()\n\n\t// Bypass authentication for websocket requests since auth is handled\n\t// on connection by express.\n\tif (ctx.transport === 'ws') return next()\n\n\ttry {\n\t\tconst token = ctx.request?.headers.authorization?.split(' ')[1]\n\t\tif (token === undefined) throw new Error('Missing token')\n\t\tawait ctx.server.verifyToken(token)\n\t} catch (error) {\n\t\tctx.logger.error('Failed to verify token', error)\n\t\tthrow new TRPCError({code: 'UNAUTHORIZED', message: 'Invalid token'})\n\t}\n\n\treturn next()\n}\n\nexport const isAuthenticatedIfUserExists = async ({ctx, next}: MiddlewareOptions) => {\n\t// Allow request through if user has not yet been registered\n\tconst userExists = await ctx.user.exists()\n\tif (!userExists) {\n\t\treturn next()\n\t}\n\n\t// If a user exists, follow usual authentication flow\n\treturn isAuthenticated({ctx, next})\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/server/trpc/trpc.ts",
    "content": "import {ZodError} from 'zod'\nimport {initTRPC} from '@trpc/server'\n\nimport {type Context} from './context.js'\nimport {isAuthenticated, isAuthenticatedIfUserExists} from './is-authenticated.js'\nimport {websocketLogger} from './websocket-logger.js'\n\nexport const t = initTRPC.context<Context>().create({\n\t// TODO: Add more context on why this is needed\n\t// https://trpc.io/docs/server/error-formatting#adding-custom-formatting\n\terrorFormatter(options) {\n\t\tconst {shape, error} = options\n\t\treturn {\n\t\t\t...shape,\n\t\t\tdata: {\n\t\t\t\t...shape.data,\n\t\t\t\tzodError: error.code === 'BAD_REQUEST' && error.cause instanceof ZodError ? error.cause.flatten() : null,\n\t\t\t},\n\t\t}\n\t},\n})\nexport const router = t.router\nconst baseProcedure = t.procedure.use(websocketLogger)\nexport const publicProcedure = baseProcedure\nexport const privateProcedure = baseProcedure.use(isAuthenticated)\n// Use this procedure type sparingly, it's for exposing endpoints that usually need authentication but\n// may need to be used before a user is registered when a token can't exist. We shouldn't use it for\n// everything because there could be edgecases where it gets applied like if the user file is corrupted.\nexport const publicProcedureWhenNoUserExists = baseProcedure.use(isAuthenticatedIfUserExists)\n"
  },
  {
    "path": "packages/umbreld/source/modules/server/trpc/websocket-logger.ts",
    "content": "import {TRPCError} from '@trpc/server'\n\nimport {type Context} from './context.js'\n\ntype MiddlewareOptions = {\n\tctx: Context\n\tpath: string\n\tnext: () => Promise<any>\n}\n\nexport const websocketLogger = async ({ctx, path, next}: MiddlewareOptions) => {\n\t// Skip this middleware for non-websocket requests\n\tif (ctx.transport !== 'ws') return next()\n\n\t// Log the RPC call\n\tctx.logger.verbose(`WS rpc ${path}`)\n\n\treturn next()\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/startup-migrations/index.ts",
    "content": "import fse from 'fs-extra'\nimport {z} from 'zod'\nimport yaml from 'js-yaml'\n\nimport type Umbreld from '../../index.js'\n\nimport {detectDevice, commitOsPartition} from '../system/system.js'\nimport {findExternalUmbrelInstall, runPreMigrationChecks, migrateData} from '../migration/migration.js'\n\nasync function readYaml(path: string) {\n\treturn yaml.load(await fse.readFile(path, 'utf8'))\n}\n\nasync function writeYaml(path: string, data: any) {\n\treturn fse.writeFile(path, yaml.dump(data))\n}\n\nclass Migration {\n\tumbreld: Umbreld\n\tlogger: Umbreld['logger']\n\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.umbreld = umbreld\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(name.toLowerCase())\n\t}\n\n\t// One off migration to complete the Mender to Rugix state migration\n\t// On the initial boot after a Mender to Rugix migration, the OS overlay path is a symbolic link.\n\t// This allows the system to boot and come online but some state management features are broken in this state.\n\t// An additional rugix commit and then reboot is required to complete the migration.\n\tasync finalizeMenderToRugixStateMigration() {\n\t\tconst osPersistentOverlayPath = '/data/umbrel-os'\n\n\t\tconst isSymbolicLink = (await fse.lstat(osPersistentOverlayPath)).isSymbolicLink()\n\t\t// TODO: Check with Maxi how safe this is. If there's any scenario where Rugix won't migrate the symlink overlay\n\t\t// into a real directory overlay then this can result in an infinite boot loop.\n\t\tif (!isSymbolicLink) return {reboot: false}\n\n\t\t// This should only ever happen once. We allow 3 attempts to handle edge cases or situations where some random failures happen.\n\t\t// If it fails consistently then we'll give up to prevent a boot loop.\n\t\tconst menderToRugixMigrationAttempt = (await this.umbreld.store.get('migration.menderToRugixAttempt')) || 0\n\t\tif (menderToRugixMigrationAttempt >= 3) {\n\t\t\tthis.logger.error('Mender to Rugix state migration has been attempted 5 times, giving up to prevent a boot loop!')\n\t\t\treturn {reboot: false}\n\t\t}\n\n\t\t// Increment the attempt count\n\t\tawait this.umbreld.store.set('migration.menderToRugixAttempt', menderToRugixMigrationAttempt + 1)\n\n\t\t// Finalize the migration by committing the OS partition and rebooting\n\t\tthis.logger.log(\n\t\t\t'OS overlay path is a symbolic link, committing and rebooting to complete Mender to Rugix state migration...',\n\t\t)\n\t\t// This should've already happened in umbreld.start() but we'll just explicitly do it again here to be sure.\n\t\tawait commitOsPartition(this.umbreld)\n\t\treturn {reboot: true}\n\t}\n\n\t// One off migration for legacy custom Linux install users\n\tasync migrateLegacyLinuxData() {\n\t\tconst {deviceId} = await detectDevice()\n\n\t\t// Only run this on unknown devices AKA not a Home or a Pi\n\t\tif (deviceId !== 'unknown') return\n\n\t\t// Don't do anything if a user has already been registered\n\t\tif (await this.umbreld.user.exists()) return\n\n\t\tthis.logger.log(\n\t\t\t'Unkown device booting for the first time, checking if we need to migrate legacy Linux install data...',\n\t\t)\n\n\t\tconst externalUmbrelInstall = await findExternalUmbrelInstall()\n\t\tif (!externalUmbrelInstall) {\n\t\t\tthis.logger.log('No legacy Linux install found, skipping migration')\n\t\t\treturn\n\t\t}\n\n\t\tthis.logger.log('Legacy Linux install found, migrating data...')\n\n\t\tconst currentInstall = this.umbreld.dataDirectory\n\t\tawait runPreMigrationChecks(currentInstall, externalUmbrelInstall as string, this.umbreld, false)\n\t\tawait this.umbreld.server.start()\n\t\tawait migrateData(currentInstall, externalUmbrelInstall as string, this.umbreld)\n\t\tthis.logger.log('Migration complete!')\n\t}\n\n\tasync activateImportedDataDirectory() {\n\t\tconst importData = `${this.umbreld.dataDirectory}/import`\n\t\tconst importDataExists = await fse.exists(importData)\n\t\tif (!importDataExists) return\n\t\tthis.logger.log('Found Umbrel data to import, activating...')\n\t\t// We have to move the import dir parrallel to the data dir and then overwrte.\n\t\t// This is because fse.move doesn't work if the source is a subdirectory of the destination.\n\t\t// This is fine to do on Umbrel Home because all of /home is on the large data partition.\n\t\t// On Rasperry Pi the data partition is small on the SD card and only the data dir on the\n\t\t// large external USB storage. We don't currently support data import on Pi so it's ok for now\n\t\t// but we'll need to handle this if we want to support it in the future.\n\t\tconst temporaryData = `${this.umbreld.dataDirectory}-import-temp`\n\t\tawait fse.move(importData, temporaryData, {overwrite: true})\n\t\tawait fse.move(temporaryData, this.umbreld.dataDirectory, {overwrite: true})\n\t}\n\n\tasync migrateLegacyData() {\n\t\t// Check for a legacy <1.0 Umbrel data directory\n\t\tconst userJsonPath = `${this.umbreld.dataDirectory}/db/user.json`\n\t\tconst userJsonExists = await fse.exists(userJsonPath)\n\t\tif (!userJsonExists) return\n\t\tthis.logger.log('Found legacy Umbrel data, migrating...')\n\n\t\t// Validate the data\n\t\tconst legacyDataSchema = z.object({\n\t\t\tname: z.string(),\n\t\t\tpassword: z.string(),\n\t\t\tinstalledApps: z.array(z.string()).optional(),\n\t\t\trepos: z.array(z.string()),\n\t\t\tremoteTorAccess: z.boolean().optional(),\n\t\t\totpUri: z.string().optional(),\n\t\t})\n\t\tconst legacyDataJson = await fse.readJson(userJsonPath)\n\t\tconst legacyData = legacyDataSchema.parse(legacyDataJson)\n\n\t\t// Migrate data\n\t\tawait this.umbreld.user.setName(legacyData.name)\n\t\tawait this.umbreld.user.setHashedPassword(legacyData.password)\n\t\tif (legacyData.otpUri) await this.umbreld.user.enable2fa(legacyData.otpUri)\n\t\tawait this.umbreld.store.set('appRepositories', legacyData.repos)\n\t\tif (legacyData.installedApps) await this.umbreld.store.set('apps', legacyData.installedApps)\n\t\tif (legacyData.remoteTorAccess) await this.umbreld.store.set('torEnabled', legacyData.remoteTorAccess)\n\n\t\t// Showcase widgets for migrating users\n\t\tawait this.umbreld.store.set('widgets', ['umbrel:memory', 'umbrel:system-stats', 'umbrel:storage'])\n\n\t\t// Ensure we have app repositories pulled otherwise there will be a race condition where\n\t\t// if an app gets started before the repo has completed it's initial pull on startup we'll\n\t\t// get the error `App with ID <appId> not found in any repository `\n\t\tawait this.umbreld.appStore.update()\n\n\t\t// Mark the legacy file as migrated\n\t\tawait fse.move(userJsonPath, `${userJsonPath}.migrated`)\n\n\t\t// Move the .env file so env vars don't get preserved\n\t\tconst envPath = `${this.umbreld.dataDirectory}/.env`\n\t\tawait fse.move(envPath, `${envPath}.migrated`)\n\t\tthis.logger.log('Migration successful')\n\t}\n\n\tasync migrateBackThatMacUpPort() {\n\t\t// Check if the Back That Mac Up app is installed\n\t\tconst isBackThatMacUpInstalled = ((await this.umbreld.store.get('apps')) || []).includes('back-that-mac-up')\n\t\tif (!isBackThatMacUpInstalled) return\n\n\t\t// Check if app has already been migrated\n\t\tconst composePath = `${this.umbreld.dataDirectory}/app-data/back-that-mac-up/docker-compose.yml`\n\t\tconst newSambaPortMapping = '1445:445'\n\t\tconst compose = (await readYaml(composePath)) as any\n\t\tif (compose.services.timemachine.ports[0] === newSambaPortMapping) return\n\t\tthis.logger.log('Old Back That Mac Up app found, migrating...')\n\n\t\t// Update the docker-compose.yml file to use the new samba port mapping\n\t\t// to avoid collisions with umbrelOS Samba port\n\t\tcompose.services.timemachine.ports = [newSambaPortMapping]\n\t\tawait writeYaml(composePath, compose)\n\t\tthis.logger.log('Back That Mac Up app migrated')\n\n\t\t// Add notification\n\t\tawait this.umbreld.notifications.add('migrated-back-that-mac-up')\n\t}\n\n\tasync migrateDownloadsDirectory() {\n\t\tconst legacyDownloadsPath = `${this.umbreld.dataDirectory}/data/storage/downloads`\n\t\tconst newDownloadsPath = `${this.umbreld.files.getBaseDirectory('/Home')}/Downloads`\n\t\tconst legacyDownloadsPathExists = await fse.exists(legacyDownloadsPath)\n\t\tconst newDownloadsPathHasData =\n\t\t\t(await fse.exists(newDownloadsPath)) && (await fse.readdir(newDownloadsPath)).length > 0\n\t\tif (!legacyDownloadsPathExists || newDownloadsPathHasData) return\n\t\tthis.logger.log('Found legacy Downloads directory, migrating...')\n\t\tawait fse.ensureDir(newDownloadsPath)\n\t\tawait fse.move(legacyDownloadsPath, newDownloadsPath, {overwrite: true})\n\t\tthis.logger.log('Downloads directory migrated')\n\t}\n\n\tasync start() {\n\t\tthis.logger.log('Checking if any migrations are needed...')\n\n\t\t// Ensure data directory exists\n\t\tawait fse.ensureDir(this.umbreld.dataDirectory)\n\n\t\t// Check for Mender to Rugix state migration and complete it if needed\n\t\ttry {\n\t\t\tconst {reboot} = await this.finalizeMenderToRugixStateMigration()\n\t\t\t// We don't want to continue with any other migrations\n\t\t\tif (reboot) return {reboot: true}\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to finalize Mender to Rugix state migration`, error)\n\t\t}\n\n\t\t// Check for a data directory to import\n\t\ttry {\n\t\t\tawait this.activateImportedDataDirectory()\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to activate imported Umbrel data`, error)\n\t\t}\n\n\t\t// Check for a legacy <1.0 Umbrel data directory and migrate to 1.0 format if found\n\t\ttry {\n\t\t\tawait this.migrateLegacyData()\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to migrate legacy data`, error)\n\t\t}\n\n\t\t// Check for first boot of an unknown device and migrate legacy Linux install data if it exists\n\t\ttry {\n\t\t\tawait this.migrateLegacyLinuxData()\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to migrate legacy Linux data`, error)\n\t\t}\n\n\t\t// Check for the Back That Mac Up app and migrate it if it exists\n\t\ttry {\n\t\t\tawait this.migrateBackThatMacUpPort()\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to migrate Back That Mac Up app`, error)\n\t\t}\n\n\t\t// Migrate Downloads directory to Home/Downloads\n\t\ttry {\n\t\t\tawait this.migrateDownloadsDirectory()\n\t\t} catch (error) {\n\t\t\tthis.logger.error(`Failed to migrate Downloads directory`, error)\n\t\t}\n\n\t\t// Write the current version to signal what version we've migrated up to.\n\t\t// This also serves as a read/write permission check on the first run.\n\t\tconst previousVersion = await this.umbreld.store.get('version')\n\t\tawait this.umbreld.store.set('version', this.umbreld.version)\n\n\t\t// Add notification if version changed\n\t\tif (previousVersion && previousVersion !== this.umbreld.version) {\n\t\t\tawait this.umbreld.notifications.add('umbrelos-updated').catch(() => {})\n\t\t}\n\n\t\tthis.logger.log('Migrations complete')\n\t\treturn {reboot: false}\n\t}\n}\n\nexport default Migration\n"
  },
  {
    "path": "packages/umbreld/source/modules/startup-migrations/startup-migrations.integration.test.ts",
    "content": "import {expect, beforeEach, afterEach, test} from 'vitest'\nimport yaml from 'js-yaml'\nimport fse from 'fs-extra'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nasync function readYaml(path: string) {\n\treturn yaml.load(await fse.readFile(path, 'utf8'))\n}\n\n// Fresh non-running umbreld instance for each test\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\nbeforeEach(async () => (umbreld = await createTestUmbreld({autoStart: false})))\nafterEach(() => umbreld.cleanup())\n\ntest('legacy downloads directory is migrated', async () => {\n\tconst dataDirectory = umbreld.instance.dataDirectory\n\tconst legacyDownloadsDirectory = `${dataDirectory}/data/storage/downloads`\n\tconst legacyDownloadsFile = `${legacyDownloadsDirectory}/bitcoin.pdf`\n\tconst newDownloadsDirectory = `${dataDirectory}/home/Downloads`\n\tconst newDownloadsFile = `${newDownloadsDirectory}/bitcoin.pdf`\n\n\t// Create legacy downloads data\n\tawait fse.ensureDir(legacyDownloadsDirectory)\n\tawait fse.writeFile(legacyDownloadsFile, 'Bitcoin: A Peer-to-Peer Electronic Cash System')\n\n\t// Ensure files exist at legacy path and not new path\n\tawait expect(fse.pathExists(legacyDownloadsDirectory)).resolves.toBe(true)\n\tawait expect(fse.pathExists(legacyDownloadsFile)).resolves.toBe(true)\n\tawait expect(fse.pathExists(newDownloadsDirectory)).resolves.toBe(false)\n\tawait expect(fse.pathExists(newDownloadsFile)).resolves.toBe(false)\n\n\t// Start umbreld\n\tawait umbreld.instance.start()\n\n\t// Ensure files are migrated to new path\n\tawait expect(fse.pathExists(legacyDownloadsDirectory)).resolves.toBe(false)\n\tawait expect(fse.pathExists(legacyDownloadsFile)).resolves.toBe(false)\n\tawait expect(fse.pathExists(newDownloadsDirectory)).resolves.toBe(true)\n\tawait expect(fse.pathExists(newDownloadsFile)).resolves.toBe(true)\n})\n\ntest('Back That Mac Up app port is migrated from 445 to 1445', async () => {\n\tconst {dataDirectory} = umbreld.instance\n\tconst appComposeFile = `${dataDirectory}/app-data/back-that-mac-up/docker-compose.yml`\n\n\t// Create app directory structure\n\tawait fse.ensureFile(appComposeFile)\n\n\t// Create docker-compose.yml with old port mapping\n\tconst oldComposeContent = {\n\t\tversion: '3.7',\n\t\tservices: {\n\t\t\ttimemachine: {\n\t\t\t\tports: ['445:445'],\n\t\t\t\trandom: 'property',\n\t\t\t},\n\t\t\trandom: 'property',\n\t\t},\n\t}\n\tawait fse.writeFile(appComposeFile, yaml.dump(oldComposeContent))\n\n\t// Mark app as installed in store\n\tawait umbreld.instance.store.set('apps', ['back-that-mac-up'])\n\n\t// Check the docker-compose.yml has the expected value\n\tawait expect(readYaml(appComposeFile)).resolves.toMatchObject(oldComposeContent)\n\n\t// Start umbreld\n\tawait umbreld.instance.start()\n\n\t// Check if the docker-compose.yml has been updated with the new port mapping\n\t// and all other values are the same\n\tawait expect(readYaml(appComposeFile)).resolves.toMatchObject({\n\t\tversion: '3.7',\n\t\tservices: {\n\t\t\ttimemachine: {\n\t\t\t\tports: ['1445:445'],\n\t\t\t\trandom: 'property',\n\t\t\t},\n\t\t\trandom: 'property',\n\t\t},\n\t})\n\n\t// Verify notification was created\n\tconst notifications = await umbreld.instance.notifications.get()\n\texpect(notifications.includes('migrated-back-that-mac-up')).toBe(true)\n})\n\ntest('first run writes version without adding a notification', async () => {\n\t// Ensure no version is set on first run\n\tconst versionBefore = await umbreld.instance.store.get('version')\n\texpect(versionBefore).toBeUndefined()\n\n\t// Start umbreld\n\tawait umbreld.instance.start()\n\n\t// Verify version is written to store\n\tconst versionAfter = await umbreld.instance.store.get('version')\n\texpect(versionAfter).toBe(umbreld.instance.version)\n\n\t// Verify no notification was created (first run)\n\tconst notifications = await umbreld.instance.notifications.get()\n\texpect(notifications.includes('umbrelos-updated')).toBe(false)\n})\n\ntest('OS update adds a notification', async () => {\n\tconst oldVersion = '1.4.2'\n\n\t// Set an old version in the store\n\tawait umbreld.instance.store.set('version', oldVersion)\n\n\t// Verify old version is set\n\tconst versionBefore = await umbreld.instance.store.get('version')\n\texpect(versionBefore).toBe(oldVersion)\n\n\t// Start umbreld\n\tawait umbreld.instance.start()\n\n\t// Verify version is updated to current version\n\tconst versionAfter = await umbreld.instance.store.get('version')\n\texpect(versionAfter).toBe(umbreld.instance.version)\n\texpect(versionAfter).not.toBe(oldVersion)\n\n\t// Verify notification was created\n\tconst notifications = await umbreld.instance.notifications.get()\n\texpect(notifications.includes('umbrelos-updated')).toBe(true)\n})\n\ntest('restarting with same version does not add a notification', async () => {\n\tconst currentVersion = umbreld.instance.version\n\n\t// Start umbreld\n\tawait umbreld.instance.start()\n\n\t// Verify version is written after first start\n\tconst versionAfterFirstStart = await umbreld.instance.store.get('version')\n\texpect(versionAfterFirstStart).toBe(currentVersion)\n\n\t// Stop umbreld\n\tawait umbreld.instance.stop()\n\n\t// Restart umbreld with the same version\n\tawait umbreld.instance.start()\n\n\t// Verify version remains the same\n\tconst versionAfterRestart = await umbreld.instance.store.get('version')\n\texpect(versionAfterRestart).toBe(currentVersion)\n\n\t// Verify no notification was created\n\tconst notifications = await umbreld.instance.notifications.get()\n\texpect(notifications.includes('umbrelos-updated')).toBe(false)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/system/factory-reset.ts",
    "content": "import path from 'node:path'\n\nimport {$} from 'execa'\nimport fse from 'fs-extra'\n\nimport type Umbreld from '../../index.js'\n\nconst BACKUP_PREFIX = 'umbrel-factory-reset'\n\n// Factory reset using Rugix Ctrl's state management. This triggers an immediate reboot.\n// We use the --backup flag which renames the old state directory instead of deleting\n// it during boot. This makes boot fast (mv is instant) and we clean up the old\n// state in the background after umbreld starts.\nexport async function performReset() {\n\tconst timestamp = Math.floor(Date.now() / 1000)\n\tawait $`rugix-ctrl state reset --backup --backup-name ${BACKUP_PREFIX}-${timestamp}`\n}\n\n// Clean up state backups from factory resets\nexport async function cleanupFactoryResetBackups(umbreld: Umbreld) {\n\tconst stateDir = '/run/rugix/mounts/data/state'\n\n\ttry {\n\t\tconst entries = await fse.readdir(stateDir)\n\n\t\t// Loop through all backups in case multiple exist\n\t\tfor (const entry of entries) {\n\t\t\tif (entry.startsWith(BACKUP_PREFIX)) {\n\t\t\t\tconst backupPath = path.join(stateDir, entry)\n\t\t\t\tumbreld.logger.log(`Cleaning up factory reset backup: ${entry}`)\n\t\t\t\tawait fse.remove(backupPath).catch((error) => umbreld.logger.error(`Failed to remove backup ${entry}`, error))\n\t\t\t}\n\t\t}\n\t} catch (error) {\n\t\tumbreld.logger.error('Failed to cleanup factory reset backups', error)\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/system/routes.ts",
    "content": "import os from 'node:os'\nimport {setTimeout} from 'node:timers/promises'\n\nimport {TRPCError} from '@trpc/server'\nimport {z} from 'zod'\nimport {$} from 'execa'\nimport fse from 'fs-extra'\nimport stripAnsi from 'strip-ansi'\n\nimport {performReset} from './factory-reset.js'\nimport {getUpdateStatus, performUpdate, getLatestRelease} from './update.js'\nimport {\n\tgetCpuTemperature,\n\tgetSystemDiskUsage,\n\tgetDiskUsage,\n\tgetMemoryUsage,\n\tgetCpuUsage,\n\treboot,\n\tshutdown,\n\tdetectDevice,\n\tgetSystemMemoryUsage,\n\tgetIpAddresses,\n\tsyncDns,\n} from './system.js'\n\nimport {privateProcedure, publicProcedure, publicProcedureWhenNoUserExists, router} from '../server/trpc/trpc.js'\n\ntype SystemStatus = 'running' | 'updating' | 'shutting-down' | 'restarting' | 'migrating' | 'resetting' | 'restoring'\nlet systemStatus: SystemStatus = 'running'\n\n// Quick hack so we can set system status from migration module until we refactor this\nexport function setSystemStatus(status: SystemStatus) {\n\tsystemStatus = status\n}\n\nexport default router({\n\tonline: publicProcedure.query(() => true),\n\tversion: publicProcedure.query(async ({ctx}) => {\n\t\treturn {\n\t\t\tversion: ctx.umbreld.version,\n\t\t\tname: ctx.umbreld.versionName,\n\t\t}\n\t}),\n\tstatus: publicProcedure.query(() => systemStatus),\n\tupdateStatus: privateProcedure.query(() => getUpdateStatus()),\n\tuptime: privateProcedure.query(() => os.uptime()),\n\tcheckUpdate: privateProcedure.query(async ({ctx}) => {\n\t\tlet {version, name, releaseNotes} = await getLatestRelease(ctx.umbreld)\n\t\t// v prefix is needed in the tag name for legacy reasons, remove it before comparing to local version\n\t\tconst available = version.replace('v', '') !== ctx.umbreld.version\n\t\treturn {available, version, name, releaseNotes}\n\t}),\n\tgetReleaseChannel: privateProcedure.query(async ({ctx}) => {\n\t\treturn (await ctx.umbreld.store.get('settings.releaseChannel')) || 'stable'\n\t}),\n\tsetReleaseChannel: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tchannel: z.enum(['stable', 'beta']),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => {\n\t\t\treturn ctx.umbreld.store.set('settings.releaseChannel', input.channel)\n\t\t}),\n\tisExternalDns: privateProcedure.query(async ({ctx}) => {\n\t\treturn await ctx.umbreld.store.get('settings.externalDns', true)\n\t}),\n\tsetExternalDns: privateProcedure.input(z.boolean()).mutation(async ({ctx, input}) => {\n\t\tconst previousExternalDns = await ctx.umbreld.store.get('settings.externalDns', true)\n\t\tif (previousExternalDns === input) return true\n\t\tawait ctx.umbreld.store.set('settings.externalDns', input)\n\t\ttry {\n\t\t\tconst success = await syncDns()\n\t\t\tif (!success) throw new Error('Failed to synchronize external DNS setting')\n\t\t\treturn true\n\t\t} catch (error) {\n\t\t\tawait ctx.umbreld.store.set('settings.externalDns', previousExternalDns)\n\t\t\tthrow error\n\t\t}\n\t}),\n\tupdate: privateProcedure.mutation(async ({ctx}) => {\n\t\tsystemStatus = 'updating'\n\t\tlet success = false\n\t\ttry {\n\t\t\tsuccess = await performUpdate(ctx.umbreld)\n\t\t\tif (success) {\n\t\t\t\tawait setTimeout(1000)\n\t\t\t\tawait ctx.umbreld.stop()\n\t\t\t\tawait reboot()\n\t\t\t}\n\t\t} finally {\n\t\t\tif (!success) systemStatus = 'running'\n\t\t}\n\t\treturn success\n\t}),\n\thiddenService: privateProcedure.query(async ({ctx}) => {\n\t\ttry {\n\t\t\treturn await fse.readFile(`${ctx.umbreld.dataDirectory}/tor/data/web/hostname`, 'utf-8')\n\t\t} catch (error) {\n\t\t\tctx.umbreld.logger.error(`Failed to read hidden service for ui`, error)\n\t\t\treturn ''\n\t\t}\n\t}),\n\t// Public during onboarding to show device-specific UI (Pro/Home images, video background)\n\tdevice: publicProcedureWhenNoUserExists.query(() => detectDevice()),\n\tcpuTemperature: privateProcedure.query(() => getCpuTemperature()),\n\tsystemDiskUsage: privateProcedure.query(({ctx}) => getSystemDiskUsage(ctx.umbreld)),\n\tdiskUsage: privateProcedure.query(({ctx}) => getDiskUsage(ctx.umbreld)),\n\tsystemMemoryUsage: privateProcedure.query(({ctx}) => getSystemMemoryUsage()),\n\tmemoryUsage: privateProcedure.query(({ctx}) => getMemoryUsage(ctx.umbreld)),\n\tcpuUsage: privateProcedure.query(({ctx}) => getCpuUsage(ctx.umbreld)),\n\tgetIpAddresses: privateProcedure.query(() => getIpAddresses()),\n\t// Public during onboarding and recovery mode so users can shut down during RAID setup or mount failure\n\tshutdown: publicProcedureWhenNoUserExists.mutation(async ({ctx}) => {\n\t\tsystemStatus = 'shutting-down'\n\t\tawait ctx.umbreld.stop()\n\t\tawait shutdown()\n\n\t\treturn true\n\t}),\n\t// Public during onboarding and recovery mode\n\trestart: publicProcedureWhenNoUserExists.mutation(async ({ctx}) => {\n\t\tsystemStatus = 'restarting'\n\t\tawait ctx.umbreld.stop()\n\t\tawait reboot()\n\n\t\treturn true\n\t}),\n\tlogs: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\ttype: z.enum(['umbrelos', 'system']),\n\t\t\t}),\n\t\t)\n\t\t.query(async ({input}) => {\n\t\t\tlet process\n\t\t\tif (input.type === 'umbrelos') {\n\t\t\t\tprocess = await $`journalctl --unit umbrel --unit umbreld-production --unit umbreld --unit ui --lines 1500`\n\t\t\t}\n\t\t\tif (input.type === 'system') {\n\t\t\t\tprocess = await $`journalctl --lines 1500`\n\t\t\t}\n\t\t\treturn stripAnsi(process!.stdout)\n\t\t}),\n\t//\n\t// Public during onboarding and recovery mode - password required unless in recovery mode\n\tfactoryReset: publicProcedureWhenNoUserExists\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tpassword: z.string().optional(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => {\n\t\t\t// Skip password validation in recovery mode (RAID mount failure) since user data is inaccessible\n\t\t\tconst raidMountFailure = await ctx.umbreld.hardware.raid.checkRaidMountFailure()\n\t\t\tif (!raidMountFailure) {\n\t\t\t\tif (!input.password || !(await ctx.user.validatePassword(input.password))) {\n\t\t\t\t\tthrow new TRPCError({code: 'UNAUTHORIZED', message: 'Invalid password'})\n\t\t\t\t}\n\t\t\t}\n\t\t\tsystemStatus = 'resetting'\n\t\t\ttry {\n\t\t\t\t// Wait for UI to poll status (polls every 10s) and see we're resetting\n\t\t\t\tawait setTimeout(11000)\n\t\t\t\t// Triggers an immediate reboot via rugix-ctrl\n\t\t\t\tawait performReset()\n\t\t\t} catch (error) {\n\t\t\t\tsystemStatus = 'running'\n\t\t\t\tthrow error\n\t\t\t}\n\t\t}),\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/system/system-widgets.ts",
    "content": "import prettyBytes from 'pretty-bytes'\n\nimport type Umbreld from '../../index.js'\nimport {getSystemDiskUsage, getSystemMemoryUsage, getCpuUsage} from './system.js'\n\nexport const systemWidgets = {\n\tstorage: async function (umbreld: Umbreld) {\n\t\tconst {size, totalUsed} = await getSystemDiskUsage(umbreld)\n\n\t\treturn {\n\t\t\ttype: 'text-with-progress',\n\t\t\tlink: '?dialog=live-usage&tab=storage',\n\t\t\trefresh: '30s',\n\t\t\ttitle: 'Storage',\n\t\t\ttext: prettyBytes(totalUsed),\n\t\t\tsubtext: `/ ${prettyBytes(size)}`,\n\t\t\tprogressLabel: `${prettyBytes(size - totalUsed)} left`,\n\t\t\tprogress: (totalUsed / size).toFixed(2),\n\t\t}\n\t},\n\tmemory: async function (umbreld: Umbreld) {\n\t\tconst {size, totalUsed} = await getSystemMemoryUsage()\n\n\t\treturn {\n\t\t\ttype: 'text-with-progress',\n\t\t\tlink: '?dialog=live-usage&tab=memory',\n\t\t\trefresh: '10s',\n\t\t\ttitle: 'Memory',\n\t\t\ttext: prettyBytes(totalUsed),\n\t\t\tsubtext: `/ ${prettyBytes(size)}`,\n\t\t\tprogressLabel: `${prettyBytes(size - totalUsed)} left`,\n\t\t\tprogress: (totalUsed / size).toFixed(2),\n\t\t}\n\t},\n\t'system-stats': async function (umbreld: Umbreld) {\n\t\tconst [cpuUsage, diskUsage, memoryUsage] = await Promise.all([\n\t\t\tgetCpuUsage(umbreld),\n\t\t\tgetSystemDiskUsage(umbreld),\n\t\t\tgetSystemMemoryUsage(),\n\t\t])\n\n\t\tconst {totalUsed: cpuTotalUsed} = cpuUsage\n\t\tconst {totalUsed: diskTotalUsed} = diskUsage\n\t\tconst {totalUsed: memoryTotalUsed} = memoryUsage\n\n\t\t// Formats CPU usage to avoid scientific notation for usage >= 99.5% (e.g., 1.0e+2%)\n\t\t// and sets upper limit to 100% because we are calculating usage as a % of total system, not % of a single thread\n\t\tconst formatCpuUsage = (usage: number) => {\n\t\t\tif (usage >= 99.5) return '100%'\n\t\t\treturn `${usage.toPrecision(2)}%`\n\t\t}\n\n\t\treturn {\n\t\t\ttype: 'three-stats',\n\t\t\tlink: '?dialog=live-usage',\n\t\t\trefresh: '10s',\n\t\t\titems: [\n\t\t\t\t{\n\t\t\t\t\ticon: 'system-widget-cpu',\n\t\t\t\t\tsubtext: 'CPU',\n\t\t\t\t\ttext: formatCpuUsage(cpuTotalUsed),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ticon: 'system-widget-memory',\n\t\t\t\t\tsubtext: 'Memory',\n\t\t\t\t\ttext: `${prettyBytes(memoryTotalUsed)}`,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\ticon: 'system-widget-storage',\n\t\t\t\t\tsubtext: 'Storage',\n\t\t\t\t\ttext: `${prettyBytes(diskTotalUsed)}`,\n\t\t\t\t},\n\t\t\t],\n\t\t}\n\t},\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/system/system.integration.test.ts",
    "content": "import {describe, expect, test} from 'vitest'\nimport systeminformation from 'systeminformation'\n\nimport system from './routes.js'\nimport Umbreld from '../../index.js'\n\nconst context = {\n\tumbreld: new Umbreld({dataDirectory: '/tmp'}),\n\tlogger: {error() {}},\n}\n\nconst router = system.createCaller({\n\t...context,\n\tdangerouslyBypassAuthentication: true,\n} as any)\nconst unAuthedRouter = system.createCaller(context as any)\n\ndescribe('cpuTemperature', async () => {\n\tconst {main} = await systeminformation.cpuTemperature()\n\tconst isCpuTemperatureSupported = typeof main === 'number'\n\n\t// Pick one of the following tests depending on what environmnet we're running on\n\ttest.skipIf(!isCpuTemperatureSupported)('should return cpu temperature', async () => {\n\t\texpect(await router.cpuTemperature()).toBeTypeOf('number')\n\t})\n\ttest.skipIf(isCpuTemperatureSupported)('should throw error if cpu temp is unsupported', async () => {\n\t\texpect(router.cpuTemperature).rejects.toThrow('Could not get CPU temperature')\n\t})\n\n\ttest('should be behind authentication', async () => {\n\t\texpect(unAuthedRouter.cpuTemperature()).rejects.toHaveProperty('code', 'UNAUTHORIZED')\n\t})\n})\n\ndescribe('getDiskUsage', () => {\n\ttest('should return disk usage', async () => {\n\t\tconst result = await router.diskUsage()\n\t\texpect(result.size).toBeTypeOf('number')\n\t\texpect(result.totalUsed).toBeTypeOf('number')\n\t\texpect(result.files).toBeTypeOf('number')\n\t})\n\n\ttest('should be behind authentication', async () => {\n\t\texpect(unAuthedRouter.diskUsage()).rejects.toHaveProperty('code', 'UNAUTHORIZED')\n\t})\n})\n\ndescribe('memoryUsage', () => {\n\ttest('should return memory usage', async () => {\n\t\tconst result = await router.memoryUsage()\n\t\texpect(result.size).toBeTypeOf('number')\n\t\texpect(result.totalUsed).toBeTypeOf('number')\n\t})\n\n\ttest('should be behind authentication', async () => {\n\t\texpect(unAuthedRouter.memoryUsage()).rejects.toHaveProperty('code', 'UNAUTHORIZED')\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/system/system.ts",
    "content": "import os from 'node:os'\nimport {isIPv4} from 'node:net'\nimport {setTimeout} from 'node:timers/promises'\n\nimport systemInformation from 'systeminformation'\nimport {$, type ExecaError} from 'execa'\nimport fse from 'fs-extra'\nimport PQueue from 'p-queue'\n\nimport type Umbreld from '../../index.js'\n\nimport getDirectorySize from '../utilities/get-directory-size.js'\n\nexport async function getCpuTemperature(): Promise<{\n\twarning: 'normal' | 'warm' | 'hot'\n\ttemperature: number\n}> {\n\t// Get CPU temperature\n\tconst cpuTemperature = await systemInformation.cpuTemperature()\n\tif (typeof cpuTemperature.main !== 'number') throw new Error('Could not get CPU temperature')\n\tconst temperature = cpuTemperature.main\n\n\t// Generic Intel thresholds\n\tlet temperatureThreshold = {warm: 90, hot: 95}\n\n\t// Raspberry Pi thresholds\n\tif (await isRaspberryPi()) temperatureThreshold = {warm: 80, hot: 85}\n\n\t// Set warning level based on temperature\n\tlet warning: 'normal' | 'warm' | 'hot' = 'normal'\n\tif (temperature >= temperatureThreshold.hot) warning = 'hot'\n\telse if (temperature >= temperatureThreshold.warm) warning = 'warm'\n\n\treturn {\n\t\twarning,\n\t\ttemperature,\n\t}\n}\n\ntype DiskUsage = {\n\tid: string\n\tused: number\n}\n\nexport async function getDiskUsageByPath(path: string): Promise<{size: number; totalUsed: number; available: number}> {\n\tif (typeof path !== 'string' || path === '') throw new Error('path must be a non-empty string')\n\n\t// Piggy back on df and get the result in bytes\n\tconst df = await $`df --output=size,used,avail --block-size=1 ${path}`\n\tconst [size, totalUsed, available] = df.stdout.split('\\n').slice(-1)[0].split(' ').map(Number)\n\n\treturn {size, totalUsed, available}\n}\n\nexport async function getSystemDiskUsage(\n\tumbreld: Umbreld,\n): Promise<{size: number; totalUsed: number; available: number}> {\n\t// TODO: Do this a cleaner way\n\tif (await umbreld.hardware.umbrelPro.isUmbrelPro()) {\n\t\tconst pool = await umbreld.hardware.raid.getStatus()\n\t\tif (pool.exists) {\n\t\t\treturn {\n\t\t\t\tsize: pool.usableSpace ?? 0,\n\t\t\t\ttotalUsed: pool.usedSpace ?? 0,\n\t\t\t\tavailable: pool.freeSpace ?? 0,\n\t\t\t}\n\t\t}\n\t}\n\treturn await getDiskUsageByPath(umbreld.dataDirectory)\n}\n\nexport async function getDiskUsage(\n\tumbreld: Umbreld,\n): Promise<{size: number; totalUsed: number; system: number; files: number; apps: DiskUsage[]}> {\n\tconst {size, totalUsed} = await getSystemDiskUsage(umbreld)\n\n\t// Get app disk usage\n\tconst apps = await Promise.all(\n\t\tumbreld.apps.instances.map(async (app) => ({\n\t\t\tid: app.id,\n\t\t\tused: await app.getDiskUsage(),\n\t\t})),\n\t)\n\tconst appsTotal = apps.reduce((total, app) => total + app.used, 0)\n\n\tconst filesTotalUsage = (\n\t\tawait Promise.all(\n\t\t\t[\n\t\t\t\tumbreld.files.getBaseDirectory('/Home'),\n\t\t\t\tumbreld.files.getBaseDirectory('/Trash'),\n\t\t\t\tumbreld.files.thumbnails.thumbnailDirectory,\n\t\t\t].map((directory) => getDirectorySize(directory).catch(() => 0)),\n\t\t)\n\t).reduce((total, usage) => total + usage, 0)\n\n\tconst minSystemUsage = 2 * 1024 * 1024 * 1024 // 2GB\n\n\treturn {\n\t\tsize,\n\t\ttotalUsed,\n\t\tsystem: Math.max(minSystemUsage, totalUsed - (appsTotal + filesTotalUsage)),\n\t\tfiles: filesTotalUsage,\n\t\tapps,\n\t}\n}\n\n// Returns a list of all processes and their memory usage\nasync function getProcessesMemory() {\n\t// Get a snapshot of system CPU and memory usage\n\tconst ps = await $`ps -Ao pid,pss --no-header`\n\n\t// Format snapshot data\n\tconst processes = ps.stdout.split('\\n').map((line) => {\n\t\t// Parse values\n\t\tconst [pid, pss] = line\n\t\t\t.trim()\n\t\t\t.split(/\\s+/)\n\t\t\t.map((value) => Number(value))\n\t\treturn {\n\t\t\tpid,\n\t\t\t// Convert proportional set size from kilobytes to bytes\n\t\t\tmemory: pss * 1000,\n\t\t}\n\t})\n\n\treturn processes\n}\n\ntype MemoryUsage = {\n\tid: string\n\tused: number\n}\n\nexport async function getSystemMemoryUsage(): Promise<{\n\tsize: number\n\ttotalUsed: number\n}> {\n\t// Get total memory size\n\tconst {total: size} = await systemInformation.mem()\n\n\t// Get a snapshot of system memory usage\n\tconst processes = await getProcessesMemory()\n\n\t// Calculate total memory used by all processes\n\tconst totalUsed = processes.reduce((total, process) => total + process.memory, 0)\n\n\treturn {\n\t\tsize,\n\t\ttotalUsed,\n\t}\n}\n\nexport async function getMemoryUsage(umbreld: Umbreld): Promise<{\n\tsize: number\n\ttotalUsed: number\n\tsystem: number\n\tapps: MemoryUsage[]\n}> {\n\t// Get a snapshot of system memory usage\n\tconst processes = await getProcessesMemory()\n\n\t// Get total and used memory size\n\tconst {size, totalUsed} = await getSystemMemoryUsage()\n\n\t// Calculate memory used by the processes owned by each app\n\tconst apps = await Promise.all(\n\t\tumbreld.apps.instances.map(async (app) => {\n\t\t\tlet appUsed = 0\n\t\t\ttry {\n\t\t\t\tconst appPids = await app.getPids()\n\t\t\t\tappUsed = processes\n\t\t\t\t\t.filter((process) => appPids.includes(process.pid))\n\t\t\t\t\t.reduce((total, process) => total + process.memory, 0)\n\t\t\t} catch (error) {\n\t\t\t\tumbreld.logger.error(`Error getting memory`, error)\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tid: app.id,\n\t\t\t\tused: appUsed,\n\t\t\t}\n\t\t}),\n\t)\n\n\t// Calculate memory used by the system (total - apps)\n\tconst appsTotal = apps.reduce((total, app) => total + app.used, 0)\n\tconst system = Math.max(0, totalUsed - appsTotal)\n\n\treturn {\n\t\tsize,\n\t\ttotalUsed,\n\t\tsystem,\n\t\tapps,\n\t}\n}\n\n// Returns a list of all processes and their cpu usage\nasync function getProcessesCpu() {\n\t// Get a snapshot of system CPU and memory usage\n\tconst top = await $`top --batch-mode --iterations 1`\n\n\t// Get lines\n\tconst lines = top.stdout.split('\\n').map((line) => line.trim().split(/\\s+/))\n\n\t// Find header and CPU column\n\tconst headerIndex = lines.findIndex((line) => line[0] === 'PID')\n\tconst cpuIndex = lines[headerIndex].findIndex((column) => column === '%CPU')\n\n\t// Get CPU threads\n\tconst threads = os.cpus().length\n\n\t// Ignore lines before the header\n\tconst processes = lines.slice(headerIndex + 1).map((line) => {\n\t\t// Parse values\n\t\treturn {\n\t\t\tpid: parseInt(line[0], 10),\n\t\t\t// Convert to % of total system not % of a single thread\n\t\t\tcpu: parseFloat(line[cpuIndex]) / threads,\n\t\t}\n\t})\n\n\treturn processes\n}\n\ntype CpuUsage = {\n\tid: string\n\tused: number\n}\n\nexport async function getCpuUsage(umbreld: Umbreld): Promise<{\n\tthreads: number\n\ttotalUsed: number\n\tsystem: number\n\tapps: CpuUsage[]\n}> {\n\t// Get a snapshot of system CPU usage\n\tconst processes = await getProcessesCpu()\n\n\t// Calculate total CPU used by all processes\n\tconst totalUsed = processes.reduce((total, process) => total + process.cpu, 0)\n\n\t// Calculate CPU used by the processes owned by each app\n\tconst apps = await Promise.all(\n\t\tumbreld.apps.instances.map(async (app) => {\n\t\t\tlet appUsed = 0\n\t\t\ttry {\n\t\t\t\tconst appPids = await app.getPids()\n\t\t\t\tappUsed = processes\n\t\t\t\t\t.filter((process) => appPids.includes(process.pid))\n\t\t\t\t\t.reduce((total, process) => total + process.cpu, 0)\n\t\t\t} catch (error) {\n\t\t\t\tumbreld.logger.error(`Error getting cpu`, error)\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tid: app.id,\n\t\t\t\tused: appUsed,\n\t\t\t}\n\t\t}),\n\t)\n\n\t// Calculate CPU used by the system (total - apps)\n\tconst appsTotal = apps.reduce((total, app) => total + app.used, 0)\n\tconst system = Math.max(0, totalUsed - appsTotal)\n\n\t// Get total CPU threads\n\tconst threads = os.cpus().length\n\n\treturn {\n\t\tthreads,\n\t\ttotalUsed,\n\t\tsystem,\n\t\tapps,\n\t}\n}\n\n// TODO: For powercycle methods we will probably want to handle cleanly stopping\n// as much Umbrel stuff as possible ourselves before handing over to the OS.\n// This will give us more control over the order of things terminating and allow\n// us to communicate shutdown progress with the user for as long as possible before\n// umbreld gets killed.\n\nexport async function shutdown(): Promise<boolean> {\n\tawait $`poweroff`\n\n\treturn true\n}\n\nexport async function reboot(): Promise<boolean> {\n\tawait $`reboot`\n\n\treturn true\n}\n\nexport async function commitOsPartition(umbreld: Umbreld): Promise<boolean> {\n\ttry {\n\t\tumbreld.logger.log('Committing OS partition...')\n\t\tawait $`rugix-ctrl system commit`\n\t\tumbreld.logger.log('Successfully commited to new OS partition.')\n\t\treturn true\n\t} catch (error) {\n\t\tumbreld.logger.error(`Failed to commit OS partition`, error)\n\t\treturn false\n\t}\n}\n\nexport async function detectDevice() {\n\tlet {manufacturer, model, serial, uuid, sku, version} = await systemInformation.system()\n\tlet productName = model\n\tmodel = sku\n\tlet device = productName // TODO: Maybe format this better in the future.\n\n\t// Used for update server\n\tlet deviceId = 'unknown'\n\n\tif (model === 'U130120') device = 'Umbrel Home (2023)'\n\tif (model === 'U130121') device = 'Umbrel Home (2024)'\n\tif (model === 'U130122') device = 'Umbrel Home (2025)'\n\tif (productName === 'Umbrel Home') deviceId = model\n\n\t// No year suffix for Umbrel Pro until if/when a newer model exists\n\tif (model === 'U4XN1') device = 'Umbrel Pro'\n\tif (productName === 'Umbrel Pro') deviceId = model\n\n\t// I haven't been able to find another way to reliably detect Pi hardware. Most existing\n\t// solutions don't actually detect Pi hardware but just detect Pi OS which we don't match.\n\t// e.g systemInformation includes Pi detection which fails here. Also there's no SMBIOS so\n\t// no values like manufacturer or model to check. I did notice the Raspberry Pi model is\n\t// appended to the output of `/proc/cpuinfo` so we can use that to detect Pi hardware.\n\ttry {\n\t\tconst cpuInfo = await fse.readFile('/proc/cpuinfo')\n\t\tif (cpuInfo.includes('Raspberry Pi ')) {\n\t\t\tmanufacturer = 'Raspberry Pi'\n\t\t\tproductName = 'Raspberry Pi'\n\t\t\tmodel = version\n\t\t\tif (cpuInfo.includes('Raspberry Pi 5 ')) {\n\t\t\t\tdevice = 'Raspberry Pi 5'\n\t\t\t\tdeviceId = 'pi-5'\n\t\t\t}\n\t\t\tif (cpuInfo.includes('Raspberry Pi 4 ')) {\n\t\t\t\tdevice = 'Raspberry Pi 4'\n\t\t\t\tdeviceId = 'pi-4'\n\t\t\t}\n\t\t}\n\t} catch (error) {\n\t\t// /proc/cpuinfo might not exist on some systems, do nothing.\n\t}\n\n\t// Blank out model and serial for non Umbrel devices\n\tif (productName !== 'Umbrel Home' && productName !== 'Umbrel Pro') {\n\t\tmodel = ''\n\t\tserial = ''\n\t}\n\n\treturn {deviceId, device, productName, manufacturer, model, serial, uuid}\n}\n\nexport async function isRaspberryPi() {\n\tconst {productName} = await detectDevice()\n\treturn productName === 'Raspberry Pi'\n}\n\nexport async function isUmbrelOS() {\n\treturn fse.exists('/umbrelOS')\n}\n\nexport async function setCpuGovernor(governor: string) {\n\tawait fse.writeFile('/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor', governor)\n}\n\nexport async function setupPiCpuGovernor(umbreld: Umbreld): Promise<void> {\n\ttry {\n\t\tif (await isRaspberryPi()) {\n\t\t\tawait setCpuGovernor('ondemand')\n\t\t\tumbreld.logger.log(`Set ondemand cpu governor`)\n\t\t}\n\t} catch (error) {\n\t\tumbreld.logger.error(`Failed to set ondemand cpu governor`, error)\n\t}\n}\n\nexport async function hasWifi() {\n\tconst {stdout} = await $`nmcli --terse --fields TYPE device status`\n\tconst networkDevices = stdout.split('\\n')\n\n\treturn networkDevices.includes('wifi')\n}\n\nexport async function getWifiNetworks() {\n\tconst listNetworks = await $`nmcli --terse --fields IN-USE,SSID,SECURITY,SIGNAL device wifi list`\n\n\t// Format into object\n\tconst networks = listNetworks.stdout.split('\\n').map((item: string) => {\n\t\tconst [inUse, ssid, security, signal] = item.split(':')\n\t\treturn {\n\t\t\tactive: inUse === '*',\n\t\t\tssid,\n\t\t\tauthenticated: !!security,\n\t\t\tsignal: parseInt(signal),\n\t\t}\n\t})\n\n\tconst filteredNetworks = networks\n\t\t// Remove duplicate and empty SSIDs\n\t\t.filter((network, index, list) => {\n\t\t\tif (network.ssid === '') return false\n\t\t\tconst indexOfFirstEntry = list.findIndex((item) => item.ssid === network.ssid)\n\t\t\treturn indexOfFirstEntry === index\n\t\t})\n\t\t// Reapply active status in case it got removed in filtering\n\t\t.map((network) => {\n\t\t\tnetwork.active = network.active || networks.some((item) => item.ssid === network.ssid && item.active)\n\t\t\treturn network\n\t\t})\n\t\t// Order by SSID\n\t\t.sort((a, b) => a.ssid.localeCompare(b.ssid))\n\n\treturn filteredNetworks\n}\n\nexport async function deleteWifiConnections({inactiveOnly = false}: {inactiveOnly?: boolean}) {\n\tconst connections = await $`nmcli --terse --fields UUID,TYPE,ACTIVE connection`\n\tfor (const connection of connections.stdout.split('\\n')) {\n\t\tconst [uuid, type, active] = connection.split(':')\n\t\t// Type will be something like '802-11-wireless'\n\t\tif (!type?.includes('wireless')) continue\n\t\tif (inactiveOnly && active === 'yes') continue\n\t\tawait $`nmcli connection delete ${uuid}`\n\t}\n}\n\nexport async function connectToWiFiNetwork({ssid, password}: {ssid: string; password?: string}) {\n\tlet connection\n\tif (password !== undefined) {\n\t\tconnection = $`nmcli device wifi connect ${ssid} password ${password}`\n\t} else {\n\t\tconnection = $`nmcli device wifi connect ${ssid}`\n\t}\n\n\ttry {\n\t\tawait connection\n\n\t\t// Destroy any inactive WiFi connections incase we just transitioned\n\t\t// from a previous wireless connection. We don't wanna leave that\n\t\t// conneciton in NetworkManager since it will be out of sync with umbreld.\n\t\ttry {\n\t\t\tawait deleteWifiConnections({inactiveOnly: true})\n\t\t} catch (error) {\n\t\t\tconsole.log(`Failed to cleanup WiFi connections: ${(error as Error).message}`)\n\t\t}\n\n\t\treturn true\n\t} catch (error) {\n\t\t// We destroy the failed WiFi connection if we fail to connect to the network.\n\t\t// This is so umbreld retains ownership of the network connection management.\n\t\t// Otherwise if this fails nmcli will remember the connection and try to reconnect\n\t\t// which umbreld is not aware of.\n\t\ttry {\n\t\t\tawait deleteWifiConnections({inactiveOnly: true})\n\t\t} catch (error) {\n\t\t\tconsole.log(`Failed to cleanup WiFi connections: ${(error as Error).message}`)\n\t\t}\n\n\t\tif (connection.exitCode === 10) throw new Error('Network not found')\n\t\tif (connection.exitCode === 1 || connection.exitCode === 4) throw new Error('Incorrect password')\n\t\tthrow new Error('Connection failed')\n\t}\n}\n\nexport async function restoreWiFi(umbreld: Umbreld): Promise<void> {\n\tconst wifiCredentials = await umbreld.store.get('settings.wifi')\n\tif (!wifiCredentials) return\n\n\twhile (true) {\n\t\tumbreld.logger.log(`Attempting to restore WiFi connection to ${wifiCredentials.ssid}...`)\n\t\ttry {\n\t\t\tawait connectToWiFiNetwork(wifiCredentials)\n\t\t\tumbreld.logger.log(`WiFi connection restored!`)\n\t\t\tbreak\n\t\t} catch (error) {\n\t\t\tumbreld.logger.error(`Failed to restore WiFi connection, retrying in 1 minute`, error)\n\t\t\tawait setTimeout(1000 * 60)\n\t\t}\n\t}\n}\n\n// Get IP addresses of the device\nexport function getIpAddresses(): string[] {\n\t// Known good interfaces:\n\t// - Umbrel Home 2024: enp1s0, wlo1 (predictable naming)\n\t// - Raspberry Pi 4/5: end0, wlan0 (custom naming)\n\t// - Docker Dev: eth0 (traditional naming)\n\tconst excludeInterfaceNames = [\n\t\t// Bridge interfaces\n\t\t/^br\\-/,\n\t\t// Known Docker-specific interfaces\n\t\t/^docker/,\n\t\t/^services/,\n\t\t// Virtual ethernet (pairs)\n\t\t/^veth/,\n\t\t// TODO: Tunnel interfaces?\n\t\t// /^tun/,\n\t]\n\t// Known good IPv4 ranges:\n\t// - Class A private: 10.0.0.0/8 := /^10\\./\n\t// - Class B private: 172.16.0.0/12 := /^172\\.(1[6-9]|2[0-9]|3[0-1])\\./\n\t// - Class C private: 192.168.0.0/16 := /^192\\.168\\./\n\tconst excludeAddressRanges = [\n\t\t// Local loopback (127.0.0.0/8)\n\t\t/^127\\./,\n\t\t// Non-routable APIPA (169.254.0.0/16), e.g. misconfigured DHCP\n\t\t/^169\\.254\\./,\n\t]\n\treturn (\n\t\tObject.entries(os.networkInterfaces())\n\t\t\t// Omit interfaces with excluded names\n\t\t\t.filter(([name]) => !excludeInterfaceNames.some((expression) => expression.test(name)))\n\t\t\t// Flatten interface map to an array of addresses\n\t\t\t.flatMap(([name, addresses = []]) => addresses.map((address) => ({name, ...address})))\n\t\t\t// Select valid non-loopback IPv4 addresses\n\t\t\t.filter((entry) => entry.family === 'IPv4' && !entry.internal && isIPv4(entry.address))\n\t\t\t// Omit addresses within excluded ranges\n\t\t\t.filter((entry) => !excludeAddressRanges.some((expression) => expression.test(entry.address)))\n\t\t\t// Return remaining addresses\n\t\t\t.map((entry) => entry.address)\n\t)\n}\n\nconst syncDnsQueue = new PQueue({concurrency: 1})\n\n// Update DNS configuration to match user settings\nexport async function syncDns() {\n\treturn await syncDnsQueue.add(async () => {\n\t\tconst {mtimeMs: mtimeBefore} = await fse.promises.stat('/etc/resolv.conf')\n\t\tawait $`systemctl restart umbrel-dns-sync`\n\t\tawait setTimeout(1000) // evade restart limits\n\t\tawait $`systemctl restart NetworkManager`\n\t\tlet retries = 2\n\t\tdo {\n\t\t\tawait setTimeout(1000)\n\t\t\tconst {mtimeMs: mtimeAfter} = await fse.promises.stat('/etc/resolv.conf')\n\t\t\tif (mtimeAfter > mtimeBefore) return true\n\t\t} while (retries--)\n\t\treturn false\n\t})\n}\n\n// Wait for Pi system time to be synced for up to the number of seconds passed in.\nexport async function waitForSystemTime(umbreld: Umbreld, timeout: number): Promise<void> {\n\ttry {\n\t\t// Only run on Pi\n\t\tif (!(await isRaspberryPi())) return\n\n\t\tumbreld.logger.log('Checking if system time is synced before continuing...')\n\t\tlet tries = 0\n\t\twhile (tries < timeout) {\n\t\t\ttries++\n\t\t\tconst timeStatus = await $`timedatectl status`\n\t\t\tconst isSynced = timeStatus.stdout.includes('System clock synchronized: yes')\n\t\t\tif (isSynced) {\n\t\t\t\tumbreld.logger.log('System time is synced. Continuing...')\n\t\t\t\treturn\n\t\t\t}\n\t\t\tumbreld.logger.log('System time is not currently synced, waiting...')\n\t\t\tawait setTimeout(1000)\n\t\t}\n\t\tumbreld.logger.error('System time is not synced but timeout was reached. Continuing...')\n\t} catch (error) {\n\t\tumbreld.logger.error(`Failed to check system time`, error)\n\t}\n}\n\nexport async function getHostname() {\n\tconst hostname = await fse.readFile('/etc/hostname', 'utf8')\n\treturn hostname.trim()\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/system/system.unit.test.ts",
    "content": "// TODO: Re-enable this, we temporarily disable TS here since we broke tests\n// and have since changed the API. We'll refactor these later.\n// eslint-disable-next-line @typescript-eslint/ban-ts-comment\n// @ts-nocheck\nimport {describe, afterEach, expect, test, vi} from 'vitest'\n\n// Mocks\nimport systemInformation from 'systeminformation'\nimport * as execa from 'execa'\n\nimport Umbreld from '../../index.js'\nimport {getCpuTemperature, getMemoryUsage, getDiskUsageByPath, shutdown, reboot} from './system.js'\n\nvi.mock('systeminformation')\nvi.mock('execa')\n\nafterEach(() => {\n\tvi.restoreAllMocks()\n})\n\ndescribe('getCpuTemperature', () => {\n\ttest('should return main cpu temperature when system supports it', async () => {\n\t\tvi.mocked(systemInformation.cpuTemperature).mockResolvedValue({main: 69} as any)\n\t\tvi.mocked(systemInformation.system).mockResolvedValue({\n\t\t\tmanufacturer: '',\n\t\t\tmodel: '',\n\t\t\tserial: '',\n\t\t\tuuid: '',\n\t\t\tsku: '',\n\t\t\tversion: '',\n\t\t} as any)\n\t\texpect(await getCpuTemperature()).toMatchObject({warning: 'normal', temperature: 69})\n\t})\n\n\ttest('should throw error when system does not support cpu temperature', async () => {\n\t\tvi.mocked(systemInformation.cpuTemperature).mockResolvedValue({main: null} as any)\n\t\texpect(getCpuTemperature()).rejects.toThrow('Could not get CPU temperature')\n\t})\n})\n\ndescribe('getDiskUsageByPath', () => {\n\ttest('should return disk usage for specified path', async () => {\n\t\tvi.mocked(execa.$).mockResolvedValue({\n\t\t\tstdout: `   1B-blocks         Used        Avail\n290821033984 126167117824 164653916160`,\n\t\t})\n\t\texpect(await getDiskUsageByPath('/tmp')).toMatchObject({\n\t\t\tsize: 290821033984,\n\t\t\ttotalUsed: 126167117824,\n\t\t\tavailable: 164653916160,\n\t\t})\n\t})\n})\n\ndescribe('getMemoryUsage', () => {\n\ttest('should return memory usage', async () => {\n\t\tconst umbreld = new Umbreld({dataDirectory: '/tmp'})\n\t\tvi.mocked(systemInformation.mem).mockResolvedValue({\n\t\t\ttotal: 69_420,\n\t\t\tactive: 420,\n\t\t} as any)\n\t\tvi.mocked(execa.$).mockResolvedValue({\n\t\t\tstdout: '1 0.420',\n\t\t})\n\t\texpect(await getMemoryUsage(umbreld)).toMatchObject({\n\t\t\tsize: 69_420,\n\t\t\ttotalUsed: 420,\n\t\t})\n\t})\n})\n\ndescribe('shutdown', () => {\n\ttest('should call execa.$ with \"poweroff\"', async () => {\n\t\texpect(await shutdown()).toBe(true)\n\t\texpect(execa.$).toHaveBeenCalledWith(['poweroff'])\n\t})\n\n\ttest('should throw error when \"poweroff\" command fails', async () => {\n\t\tvi.mocked(execa.$).mockRejectedValue(new Error('Failed'))\n\t\tawait expect(shutdown()).rejects.toThrow()\n\t})\n})\n\ndescribe('reboot', () => {\n\ttest('should call execa.$ with \"reboot\"', async () => {\n\t\texpect(await reboot()).toBe(true)\n\t\texpect(execa.$).toHaveBeenCalledWith(['reboot'])\n\t})\n\n\ttest('should throw error when \"shutdown\" command fails', async () => {\n\t\tvi.mocked(execa.$).mockRejectedValue(new Error('Failed'))\n\t\tawait expect(reboot()).rejects.toThrow()\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/system/update.ts",
    "content": "import {$} from 'execa'\nimport type {ProgressStatus} from '../apps/schema.js'\nimport {detectDevice, isUmbrelOS} from './system.js'\nimport Umbreld from '../../index.js'\n\ntype UpdateStatus = ProgressStatus\n\nlet updateStatus: UpdateStatus\nresetUpdateStatus()\n\nfunction resetUpdateStatus() {\n\tupdateStatus = {\n\t\trunning: false,\n\t\tprogress: 0,\n\t\tdescription: '',\n\t\terror: false,\n\t}\n}\n\nfunction setUpdateStatus(properties: Partial<UpdateStatus>) {\n\tupdateStatus = {...updateStatus, ...properties}\n}\n\nexport function getUpdateStatus() {\n\treturn updateStatus\n}\n\nexport async function getLatestRelease(umbreld: Umbreld) {\n\tlet deviceId = 'unknown'\n\ttry {\n\t\tdeviceId = (await detectDevice()).deviceId\n\t} catch (error) {\n\t\tumbreld.logger.error(`Failed to detect device type`, error)\n\t}\n\n\tlet platform = 'unknown'\n\ttry {\n\t\tif (await isUmbrelOS()) {\n\t\t\tplatform = 'umbrelOS'\n\t\t}\n\t} catch (error) {\n\t\tumbreld.logger.error(`Failed to detect platform`, error)\n\t}\n\n\tlet channel = 'stable'\n\ttry {\n\t\tchannel = (await umbreld.store.get('settings.releaseChannel')) || 'stable'\n\t} catch (error) {\n\t\tumbreld.logger.error(`Failed to get release channel`, error)\n\t}\n\n\tconst updateUrl = new URL('https://api.umbrel.com/latest-release')\n\t// Provide context to the update server about the underlying device and platform\n\t// so we can avoid the 1.0 update situation where we need to shim multiple update\n\t// mechanisms and error-out updates for unsupported platforms. This also helps\n\t// notifying users for critical security updates that are be relevant only to their specific\n\t// platform, and avoids notififying users of updates that aren't yet available for their\n\t// platform.\n\tupdateUrl.searchParams.set('version', umbreld.version)\n\tupdateUrl.searchParams.set('device', deviceId)\n\tupdateUrl.searchParams.set('platform', platform)\n\tupdateUrl.searchParams.set('channel', channel)\n\n\tconst result = await fetch(updateUrl, {\n\t\theaders: {'User-Agent': `umbrelOS ${umbreld.version}`},\n\t})\n\tconst data = await result.json()\n\treturn data as {version: string; name: string; releaseNotes: string; updateScript?: string}\n}\n\nexport async function performUpdate(umbreld: Umbreld) {\n\tsetUpdateStatus({running: true, progress: 0, description: 'Updating...', error: false})\n\n\ttry {\n\t\tconst {updateScript} = await getLatestRelease(umbreld)\n\n\t\tif (!updateScript) {\n\t\t\tsetUpdateStatus({error: 'No update script found'})\n\t\t\tthrow new Error('No update script found')\n\t\t}\n\n\t\tconst result = await fetch(updateScript, {\n\t\t\theaders: {'User-Agent': `umbrelOS ${umbreld.version}`},\n\t\t})\n\t\tconst updateSCriptContents = await result.text()\n\n\t\t// Exectute update script and report progress\n\t\tconst process = $`bash -c ${updateSCriptContents}`\n\t\tlet previousProgress = 0\n\t\tasync function handleUpdateScriptOutput(chunk: Buffer) {\n\t\t\tconst text = chunk.toString()\n\t\t\tconst lines = text.split('\\n')\n\t\t\tfor (const line of lines) {\n\t\t\t\t// Handle our custom status updates\n\t\t\t\tif (line.startsWith('umbrel-update: ')) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst status = JSON.parse(line.replace('umbrel-update: ', '')) as Partial<UpdateStatus>\n\t\t\t\t\t\tsetUpdateStatus(status)\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t// Don't kill update on JSON parse errors\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Handle rugix install progress\n\t\t\t\ttry {\n\t\t\t\t\tconst status = JSON.parse(line) as {event: 'UpdateProgress'; progress: number}\n\t\t\t\t\tif (status.event !== 'UpdateProgress' || typeof status.progress !== 'number') throw new Error('Invalid event')\n\n\t\t\t\t\t// Only log every integer progress increment to avoid spamming events\n\t\t\t\t\tconst progress = Math.floor(status.progress)\n\t\t\t\t\tif (progress > previousProgress) {\n\t\t\t\t\t\tpreviousProgress = progress\n\t\t\t\t\t\tumbreld.logger.log(`Update progress: ${progress}%`)\n\t\t\t\t\t\tsetUpdateStatus({progress})\n\t\t\t\t\t}\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// Ignore JSON parse errors\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tprocess.stdout?.on('data', (chunk) => handleUpdateScriptOutput(chunk))\n\t\tprocess.stderr?.on('data', (chunk) => handleUpdateScriptOutput(chunk))\n\n\t\t// Wait for script to complete and handle errors\n\t\tawait process\n\t} catch (error) {\n\t\t// Don't overwrite a useful error message reported by the update script\n\t\tif (!updateStatus.error) setUpdateStatus({error: 'Update failed'})\n\n\t\t// Reset the state back to running but leave the error message so ui polls\n\t\t// can differentiate between a successful update after reboot and a failed\n\t\t// update that didn't reboot.\n\t\tconst errorStatus = updateStatus.error\n\t\tresetUpdateStatus()\n\t\tsetUpdateStatus({error: errorStatus})\n\n\t\tumbreld.logger.error(`Update script failed`, error)\n\n\t\treturn false\n\t}\n\n\tsetUpdateStatus({running: false, progress: 100, description: 'Restarting...'})\n\n\treturn true\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/system/wifi-routes.ts",
    "content": "import {z} from 'zod'\nimport {privateProcedure, router} from '../server/trpc/trpc.js'\nimport {hasWifi, getWifiNetworks, connectToWiFiNetwork, deleteWifiConnections} from './system.js'\n\nexport default router({\n\tsupported: privateProcedure.query(() => hasWifi()),\n\n\tnetworks: privateProcedure.query(() => getWifiNetworks()),\n\n\tconnect: privateProcedure\n\t\t.input(z.object({ssid: z.string(), password: z.string().optional()}))\n\t\t.mutation(async ({input, ctx}) => {\n\t\t\tconst previousWifiCredentials = await ctx.umbreld.store.get('settings.wifi')\n\t\t\ttry {\n\t\t\t\tawait connectToWiFiNetwork({ssid: input.ssid, password: input.password})\n\t\t\t\tawait ctx.umbreld.store.set('settings.wifi', {ssid: input.ssid, password: input.password})\n\t\t\t} catch (error) {\n\t\t\t\t// Best effort attempt to restore previous credentials on failure\n\t\t\t\tif (previousWifiCredentials) {\n\t\t\t\t\tctx.umbreld.logger.error(`Failed to connect to WiFi network, attempting to restore previous credentials...`)\n\t\t\t\t\tconnectToWiFiNetwork(previousWifiCredentials).catch((error) => {\n\t\t\t\t\t\tctx.umbreld.logger.error(`Failed to restore previous WiFi connection`, error)\n\t\t\t\t\t})\n\t\t\t\t}\n\n\t\t\t\tthrow error\n\t\t\t}\n\t\t}),\n\n\tconnected: privateProcedure.query(async () => {\n\t\tconst networks = await getWifiNetworks()\n\t\tconst connection = networks.find((network) => network.active)\n\t\tif (!connection) return {status: 'disconnected'} as const\n\n\t\treturn {\n\t\t\tstatus: 'connected',\n\t\t\tssid: connection.ssid,\n\t\t\tsignal: connection.signal,\n\t\t\tauthenticated: connection.authenticated,\n\t\t} as const\n\t}),\n\n\tdisconnect: privateProcedure.mutation(async ({ctx}) => {\n\t\t// Nuke all WiFi connections\n\t\tawait deleteWifiConnections({inactiveOnly: false})\n\t\tawait ctx.umbreld.store.delete('settings.wifi')\n\n\t\treturn true\n\t}),\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/test-utilities/create-test-umbreld.ts",
    "content": "import path from 'node:path'\nimport {fileURLToPath} from 'node:url'\nimport {createTRPCProxyClient, httpBatchLink, createWSClient, wsLink, splitLink} from '@trpc/client'\nimport got from 'got'\nimport {CookieJar} from 'tough-cookie'\nimport {$} from 'execa'\nimport fse from 'fs-extra'\nimport getPort from 'get-port'\nimport pRetry from 'p-retry'\nimport pWaitFor from 'p-wait-for'\nimport {Client as SshClient} from 'ssh2'\n\nimport Umbreld from '../../index.js'\nimport type {AppRouter} from '../server/trpc/index.js'\nimport type {events} from '../event-bus/event-bus.js'\n\nimport temporaryDirectory from '../utilities/temporary-directory.js'\nimport runGitServer from './run-git-server.js'\n\n// Use the data/ directory in the umbreld package root for test temp files\n// This avoids filling up RAM-based tmpfs when running many tests in parallel\nconst currentDirectory = path.dirname(fileURLToPath(import.meta.url))\nconst testDataDirectory = path.resolve(currentDirectory, '../../../data')\n\nconst userCredentials = {\n\tname: 'satoshi',\n\tpassword: 'moneyprintergobrrr',\n}\n\nfunction createTestHelpers(port: number) {\n\tlet jwt = ''\n\tconst setJwt = (newJwt: string) => {\n\t\tjwt = newJwt\n\t}\n\n\t// Create WebSocket client for subscriptions\n\tconst wsClient = createWSClient({\n\t\turl: () => `ws://localhost:${port}/trpc?token=${jwt}`,\n\t\tretryDelayMs: () => 100,\n\t})\n\n\tconst client = createTRPCProxyClient<AppRouter>({\n\t\tlinks: [\n\t\t\tsplitLink({\n\t\t\t\tcondition: (op) => op.type === 'subscription',\n\t\t\t\ttrue: wsLink({client: wsClient}),\n\t\t\t\tfalse: httpBatchLink({\n\t\t\t\t\turl: `http://localhost:${port}/trpc`,\n\t\t\t\t\theaders: async () => ({\n\t\t\t\t\t\tAuthorization: `Bearer ${jwt}`,\n\t\t\t\t\t}),\n\t\t\t\t}),\n\t\t\t}),\n\t\t],\n\t})\n\n\tconst unauthenticatedClient = createTRPCProxyClient<AppRouter>({\n\t\tlinks: [\n\t\t\thttpBatchLink({\n\t\t\t\turl: `http://localhost:${port}/trpc`,\n\t\t\t}),\n\t\t],\n\t})\n\n\tconst unauthenticatedApi = got.extend({\n\t\tprefixUrl: `http://localhost:${port}/api`,\n\t\tretry: {limit: 0},\n\t\tresponseType: 'json',\n\t})\n\tconst cookieJar = new CookieJar()\n\tconst api = unauthenticatedApi.extend({cookieJar})\n\n\tasync function signup({raidDevices, raidType}: {raidDevices?: string[]; raidType?: 'storage' | 'failsafe'} = {}) {\n\t\tawait unauthenticatedClient.user.register.mutate({...userCredentials, raidDevices, raidType})\n\t}\n\n\tasync function login() {\n\t\t// Retry login to handle race condition where user exists but password isn't set yet\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst token = await client.user.login.mutate(userCredentials)\n\t\t\t\t\tsetJwt(token)\n\t\t\t\t\tawait api.post('../trpc/user.login', {json: userCredentials})\n\t\t\t\t\treturn true\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 1000, timeout: 600_000},\n\t\t)\n\t\treturn true\n\t}\n\n\tasync function registerAndLogin() {\n\t\tawait signup()\n\t\tawait login()\n\t\treturn true\n\t}\n\n\tasync function waitForStartup({waitForUser = false} = {}) {\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\ttry {\n\t\t\t\t\tconst exists = await unauthenticatedClient.user.exists.query()\n\t\t\t\t\treturn waitForUser ? exists : true\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 2000, timeout: 300_000},\n\t\t)\n\t}\n\n\t// Subscribe to events over WebSocket and collect them\n\tfunction subscribeToEvents<T>(event: (typeof events)[number]) {\n\t\tconst collected: T[] = []\n\t\tconst subscription = client.eventBus.listen.subscribe(\n\t\t\t{event},\n\t\t\t{\n\t\t\t\tonData: (data) => collected.push(data as T),\n\t\t\t\tonError: (error) => console.error(`Subscription error for ${event}:`, error),\n\t\t\t},\n\t\t)\n\t\treturn {\n\t\t\tcollected,\n\t\t\tunsubscribe: () => subscription.unsubscribe(),\n\t\t}\n\t}\n\n\treturn {\n\t\tclient,\n\t\tunauthenticatedClient,\n\t\tapi,\n\t\tunauthenticatedApi,\n\t\tsetJwt,\n\t\tsignup,\n\t\tlogin,\n\t\tregisterAndLogin,\n\t\twaitForStartup,\n\t\tsubscribeToEvents,\n\t}\n}\n\nexport default async function createTestUmbreld({autoLogin = false, autoStart = true} = {}) {\n\tconst directory = temporaryDirectory({parentDirectory: testDataDirectory})\n\tawait directory.createRoot()\n\n\tconst gitServer = await runGitServer()\n\n\tconst dataDirectory = await directory.create()\n\tconst umbreld = new Umbreld({\n\t\tdataDirectory,\n\t\tport: 0,\n\t\tlogLevel: 'silent',\n\t\tdefaultAppStoreRepo: gitServer.url,\n\t})\n\tif (autoStart) await umbreld.start()\n\n\tconst {\n\t\tclient,\n\t\tunauthenticatedClient,\n\t\tapi,\n\t\tunauthenticatedApi,\n\t\tsetJwt,\n\t\tsignup,\n\t\tlogin,\n\t\tregisterAndLogin,\n\t\twaitForStartup,\n\t\tsubscribeToEvents,\n\t} = createTestHelpers(umbreld.server.port!)\n\n\tasync function cleanup() {\n\t\tawait umbreld.stop()\n\t\tawait gitServer.close()\n\t\tawait directory.destroyRoot()\n\t}\n\n\tif (autoLogin) {\n\t\tawait signup()\n\t\tawait login()\n\t}\n\n\treturn {\n\t\tinstance: umbreld,\n\t\tclient,\n\t\tunauthenticatedClient,\n\t\tapi,\n\t\tunauthenticatedApi,\n\t\tsetJwt,\n\t\tsignup,\n\t\tlogin,\n\t\tregisterAndLogin,\n\t\twaitForStartup,\n\t\tsubscribeToEvents,\n\t\tcleanup,\n\t}\n}\n\nexport async function createTestVm() {\n\tconst vmScript = path.resolve(currentDirectory, '../../../../os/vm.sh')\n\tconst vmImagePath = path.resolve(currentDirectory, '../../../../os/build/umbrelos-amd64.img')\n\n\tif (!(await fse.pathExists(vmImagePath))) {\n\t\tthrow new Error(\n\t\t\t'No umbrelos image found. Build one with `npm run build:amd64` in packages/os or specify the image path.',\n\t\t)\n\t}\n\n\tconst directory = temporaryDirectory({parentDirectory: testDataDirectory})\n\tawait directory.createRoot()\n\tconst stateDir = await directory.create()\n\tconst env = {VM_STATE_DIR: stateDir}\n\n\tconst sshPort = await getPort()\n\tconst httpPort = await getPort()\n\n\tconst {\n\t\tclient,\n\t\tunauthenticatedClient,\n\t\tapi,\n\t\tunauthenticatedApi,\n\t\tsetJwt,\n\t\tsignup,\n\t\tlogin,\n\t\tregisterAndLogin,\n\t\twaitForStartup,\n\t\tsubscribeToEvents,\n\t} = createTestHelpers(httpPort)\n\n\tlet vmProcessPid: number | undefined\n\n\tasync function powerOn() {\n\t\tlet vmOutput = ''\n\t\tlet vmExited = false\n\n\t\tconst vmProcess = $({\n\t\t\tenv,\n\t\t\tdetached: true,\n\t\t})`${vmScript} boot ${vmImagePath} --ssh-port ${sshPort} --http-port ${httpPort}`\n\t\tvmProcessPid = vmProcess.pid\n\n\t\t// Capture output and track if process exits\n\t\tvmProcess.stdout?.on('data', (data: Buffer) => (vmOutput += data.toString()))\n\t\tvmProcess.stderr?.on('data', (data: Buffer) => (vmOutput += data.toString()))\n\t\tvmProcess.on('exit', () => (vmExited = true))\n\n\t\t// Unref so the process doesn't block Node from exiting\n\t\tvmProcess.unref()\n\n\t\tawait pWaitFor(\n\t\t\tasync () => {\n\t\t\t\t// Check if VM process died\n\t\t\t\tif (vmExited) {\n\t\t\t\t\tthrow new Error(`VM process exited unexpectedly:\\n${vmOutput}`)\n\t\t\t\t}\n\t\t\t\ttry {\n\t\t\t\t\tawait unauthenticatedClient.user.exists.query()\n\t\t\t\t\treturn true\n\t\t\t\t} catch {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{interval: 2000, timeout: 300_000},\n\t\t)\n\t}\n\n\tfunction isRunning() {\n\t\tif (!vmProcessPid) return false\n\t\ttry {\n\t\t\tprocess.kill(vmProcessPid, 0)\n\t\t\treturn true\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t}\n\n\tasync function powerOff() {\n\t\tif (!vmProcessPid) return\n\n\t\t// Try graceful shutdown via tRPC (triggers clean umbreld shutdown)\n\t\ttry {\n\t\t\tawait client.system.shutdown.mutate().catch(() => unauthenticatedClient.system.shutdown.mutate())\n\t\t\t// Wait up to 30 seconds for graceful shutdown\n\t\t\tawait pWaitFor(() => !isRunning(), {interval: 100, timeout: 30_000})\n\t\t\tvmProcessPid = undefined\n\t\t\treturn\n\t\t} catch {\n\t\t\t// Graceful shutdown failed, fall through to force kill\n\t\t}\n\n\t\t// Force kill if still running\n\t\ttry {\n\t\t\tprocess.kill(-vmProcessPid!, 'SIGTERM')\n\t\t\tconsole.log('VM did not shut down cleanly, sending SIGTERM to process')\n\t\t} catch {\n\t\t\t// Already dead\n\t\t}\n\n\t\t// Wait up to 10 seconds for force kill to take effect\n\t\tawait pWaitFor(() => !isRunning(), {interval: 100, timeout: 10_000})\n\t\tvmProcessPid = undefined\n\t}\n\n\tasync function addNvme({slot, size}: {slot: number; size?: string}) {\n\t\tif (size) {\n\t\t\tawait $({env})`${vmScript} nvme add ${slot} --size ${size}`\n\t\t} else {\n\t\t\tawait $({env})`${vmScript} nvme add ${slot}`\n\t\t}\n\t}\n\n\tasync function removeNvme({slot}: {slot: number}) {\n\t\tawait $({env})`${vmScript} nvme destroy ${slot}`\n\t}\n\n\tasync function disconnectNvme({slot}: {slot: number}) {\n\t\tawait $({env})`${vmScript} nvme disconnect ${slot}`\n\t}\n\n\tasync function connectNvme({slot}: {slot: number}) {\n\t\tawait $({env})`${vmScript} nvme connect ${slot}`\n\t}\n\n\tasync function moveNvme({fromSlot, toSlot}: {fromSlot: number; toSlot: number}) {\n\t\tawait $({env})`${vmScript} nvme move ${fromSlot} ${toSlot}`\n\t}\n\n\tasync function reflash() {\n\t\tawait $({env})`${vmScript} reflash`\n\t}\n\n\tasync function cleanup() {\n\t\tawait powerOff()\n\t\tawait directory.destroyRoot()\n\t}\n\n\tasync function waitForShutdown() {\n\t\tawait pWaitFor(() => !isRunning(), {interval: 100, timeout: 30_000})\n\t\tvmProcessPid = undefined\n\t}\n\n\tconst vm = {\n\t\tdataDirectory: '/data/umbrel',\n\t\tstateDir,\n\t\tsshPort,\n\t\thttpPort,\n\t\tget pid() {\n\t\t\treturn vmProcessPid\n\t\t},\n\t\tisRunning,\n\t\twaitForShutdown,\n\t\tpowerOn,\n\t\tpowerOff,\n\t\taddNvme,\n\t\tremoveNvme,\n\t\tdisconnectNvme,\n\t\tconnectNvme,\n\t\tmoveNvme,\n\t\treflash,\n\t\tasync ssh(command: string) {\n\t\t\tconst attemptSsh = (password: string) =>\n\t\t\t\tnew Promise<string>((resolve, reject) => {\n\t\t\t\t\tconst conn = new SshClient()\n\t\t\t\t\tconn.on('ready', () => {\n\t\t\t\t\t\tconn.exec(command, (err, stream) => {\n\t\t\t\t\t\t\tif (err) {\n\t\t\t\t\t\t\t\tconn.end()\n\t\t\t\t\t\t\t\treturn reject(err)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tlet stdout = ''\n\t\t\t\t\t\t\tstream.on('data', (data: Buffer) => (stdout += data.toString()))\n\t\t\t\t\t\t\tstream.stderr.on('data', () => {})\n\t\t\t\t\t\t\tstream.on('close', () => {\n\t\t\t\t\t\t\t\tconn.end()\n\t\t\t\t\t\t\t\tresolve(stdout)\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t})\n\t\t\t\t\t})\n\t\t\t\t\tconn.on('error', reject)\n\t\t\t\t\tconn.connect({\n\t\t\t\t\t\thost: 'localhost',\n\t\t\t\t\t\tport: sshPort,\n\t\t\t\t\t\tusername: 'umbrel',\n\t\t\t\t\t\tpassword,\n\t\t\t\t\t})\n\t\t\t\t})\n\n\t\t\t// Try default 'umbrel' password first, fall back to user password if already synced\n\t\t\t// Retry up to 10 times with 1 second wait between attempts\n\t\t\treturn pRetry(\n\t\t\t\tasync () => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\treturn await attemptSsh('umbrel')\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tif ((error as {level?: string}).level === 'client-authentication') {\n\t\t\t\t\t\t\treturn await attemptSsh(userCredentials.password)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthrow error\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t{retries: 10, minTimeout: 1000, maxTimeout: 1000},\n\t\t\t)\n\t\t},\n\t}\n\n\treturn {\n\t\tvm,\n\t\tclient,\n\t\tunauthenticatedClient,\n\t\tapi,\n\t\tunauthenticatedApi,\n\t\tsetJwt,\n\t\tsignup,\n\t\tlogin,\n\t\tregisterAndLogin,\n\t\twaitForStartup,\n\t\tsubscribeToEvents,\n\t\tcleanup,\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/test-utilities/fixtures/another-community-repo/another-sparkles-hello-world/docker-compose.yml",
    "content": "version: '3.7'\n\nservices:\n  app_proxy:\n    environment:\n      APP_HOST: sparkles-hello-world_server_1\n      APP_PORT: 3000\n\n  server:\n    image: getumbrel/community-app-store-hello-world:latest\n    user: '1000:1000'\n    init: true\n"
  },
  {
    "path": "packages/umbreld/source/modules/test-utilities/fixtures/another-community-repo/another-sparkles-hello-world/umbrel-app.yml",
    "content": "manifestVersion: 1\nid: another-sparkles-hello-world\nname: Hello World\ntagline: Replace this tagline with your app's tagline\nicon: https://svgur.com/i/mvA.svg\ncategory: Development\nversion: '1.0.0'\nport: 4000\ndescription: >-\n  Add your app's description here.\n\n\n  You can also add newlines!\n\ndeveloper: Umbrel\nwebsite: https://umbrel.com\nsubmitter: Umbrel\nsubmission: https://github.com/getumbrel/umbrel-hello-world-app\nrepo: https://github.com/getumbrel/umbrel-hello-world-app\nsupport: https://github.com/getumbrel/umbrel-hello-world-app/issues\ngallery:\n  - https://i.imgur.com/yyVG0Jb.jpeg\n  - https://i.imgur.com/yyVG0Jb.jpeg\n  - https://i.imgur.com/yyVG0Jb.jpeg\nreleaseNotes: >-\n  Add what's new in the latest version of your app here.\ndependencies: []\npath: ''\ndefaultUsername: ''\ndefaultPassword: ''\n"
  },
  {
    "path": "packages/umbreld/source/modules/test-utilities/fixtures/another-community-repo/umbrel-app-store.yml",
    "content": "id: 'another-sparkles'\nname: 'Another Sparkles'\n"
  },
  {
    "path": "packages/umbreld/source/modules/test-utilities/fixtures/community-repo/app-with-invalid-id/docker-compose.yml",
    "content": "version: '3.7'\n\nservices:\n  app_proxy:\n    environment:\n      APP_HOST: hello-world_server_1\n      APP_PORT: 3000\n\n  server:\n    image: getumbrel/community-app-store-hello-world:latest\n    user: '1000:1000'\n    init: true\n"
  },
  {
    "path": "packages/umbreld/source/modules/test-utilities/fixtures/community-repo/app-with-invalid-id/umbrel-app.yml",
    "content": "manifestVersion: 1\n# This app id isn't prefixed with the app repo id so it should be ignored by the registry\nid: invalid-id\nname: Hello World\ntagline: Replace this tagline with your app's tagline\nicon: https://svgur.com/i/mvA.svg\ncategory: Development\nversion: '1.0.0'\nport: 4000\ndescription: >-\n  Add your app's description here.\n\n\n  You can also add newlines!\n\ndeveloper: Umbrel\nwebsite: https://umbrel.com\nsubmitter: Umbrel\nsubmission: https://github.com/getumbrel/umbrel-hello-world-app\nrepo: https://github.com/getumbrel/umbrel-hello-world-app\nsupport: https://github.com/getumbrel/umbrel-hello-world-app/issues\ngallery:\n  - https://i.imgur.com/yyVG0Jb.jpeg\n  - https://i.imgur.com/yyVG0Jb.jpeg\n  - https://i.imgur.com/yyVG0Jb.jpeg\nreleaseNotes: >-\n  Add what's new in the latest version of your app here.\ndependencies: []\npath: ''\ndefaultUsername: ''\ndefaultPassword: ''\n"
  },
  {
    "path": "packages/umbreld/source/modules/test-utilities/fixtures/community-repo/app-with-invalid-manifest/docker-compose.yml",
    "content": "version: '3.7'\n\nservices:\n  app_proxy:\n    environment:\n      APP_HOST: app-with-invalid-manifest_server_1\n      APP_PORT: 3000\n\n  server:\n    image: getumbrel/community-app-store-hello-world:latest\n    user: '1000:1000'\n    init: true\n"
  },
  {
    "path": "packages/umbreld/source/modules/test-utilities/fixtures/community-repo/app-with-invalid-manifest/umbrel-app.yml",
    "content": "# Due to the invalid manifest it shouldn't show up in registry output\nmanifestVersion: 1\nid: app-with-invalid-manifest\n name: Invalid space here\n"
  },
  {
    "path": "packages/umbreld/source/modules/test-utilities/fixtures/community-repo/sparkles-hello-world/docker-compose.yml",
    "content": "version: '3.7'\n\nservices:\n  app_proxy:\n    environment:\n      APP_HOST: sparkles-hello-world_server_1\n      APP_PORT: 3000\n\n  server:\n    image: getumbrel/community-app-store-hello-world:latest\n    user: '1000:1000'\n    init: true\n"
  },
  {
    "path": "packages/umbreld/source/modules/test-utilities/fixtures/community-repo/sparkles-hello-world/umbrel-app.yml",
    "content": "manifestVersion: 1\nid: sparkles-hello-world\nname: Hello World\ntagline: Replace this tagline with your app's tagline\nicon: https://svgur.com/i/mvA.svg\ncategory: Development\nversion: '1.0.0'\nport: 4000\ndescription: >-\n  Add your app's description here.\n\n\n  You can also add newlines!\n\ndeveloper: Umbrel\nwebsite: https://umbrel.com\nsubmitter: Umbrel\nsubmission: https://github.com/getumbrel/umbrel-hello-world-app\nrepo: https://github.com/getumbrel/umbrel-hello-world-app\nsupport: https://github.com/getumbrel/umbrel-hello-world-app/issues\ngallery:\n  - https://i.imgur.com/yyVG0Jb.jpeg\n  - https://i.imgur.com/yyVG0Jb.jpeg\n  - https://i.imgur.com/yyVG0Jb.jpeg\nreleaseNotes: >-\n  Add what's new in the latest version of your app here.\ndependencies: []\npath: ''\ndefaultUsername: ''\ndefaultPassword: ''\nbackupIgnore:\n  - data\n  - logs\n  - cache\n"
  },
  {
    "path": "packages/umbreld/source/modules/test-utilities/fixtures/community-repo/umbrel-app-store.yml",
    "content": "id: 'sparkles'\nname: 'Sparkles'\n"
  },
  {
    "path": "packages/umbreld/source/modules/test-utilities/run-git-server.ts",
    "content": "import {fileURLToPath} from 'node:url'\nimport path from 'node:path'\nimport fse from 'fs-extra'\nimport {Git} from 'node-git-server'\nimport getPort from 'get-port'\nimport waitPort from 'wait-port'\nimport {$} from 'execa'\nimport temporaryDirectory from '../utilities/temporary-directory.js'\n\nconst currentDirectory = path.dirname(fileURLToPath(import.meta.url))\nconst directory = temporaryDirectory()\n\nexport default async function runGitServer() {\n\t// Create root dir to run git server\n\tconst gitServerDirectory = await directory.create()\n\n\t// Create subdirectory for the repo\n\tconst repoDirectory = `${gitServerDirectory}/umbrel-apps.git`\n\tawait fse.ensureDir(repoDirectory)\n\n\t// Copy in community repo skeleton fixture and commit it\n\tawait fse.copy(`${currentDirectory}/fixtures/community-repo`, repoDirectory)\n\tconst $$ = $({cwd: repoDirectory})\n\tawait $$`git init`\n\tawait $$`git add .`\n\tawait $$`git config user.name \"Your Name\"`\n\tawait $$`git config user.email \"you@example.com\"`\n\tawait $$`git commit -m ${'Initial commit'}`\n\n\t// Run git server and wait for it to come online\n\tconst repos = new Git(gitServerDirectory)\n\tconst port = await getPort()\n\trepos.listen(port)\n\tawait waitPort({host: 'localhost', port, output: 'silent'})\n\n\t// Return useful properties\n\treturn {\n\t\turl: `http://localhost:${port}/umbrel-apps.git`,\n\t\tdirectory: repoDirectory,\n\t\tasync addNewCommit() {\n\t\t\tawait $$`git commit --allow-empty -m ${'New commit'}`\n\t\t},\n\t\tasync close() {\n\t\t\tawait repos.close()\n\t\t\tawait directory.destroyRoot()\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/user/routes.ts",
    "content": "import {TRPCError} from '@trpc/server'\nimport {z} from 'zod'\n\nimport {router, publicProcedure, privateProcedure} from '../server/trpc/trpc.js'\nimport {detectDevice} from '../system/system.js'\nimport * as totp from '../utilities/totp.js'\n\nconst ONE_SECOND = 1000\nconst ONE_MINUTE = 60 * ONE_SECOND\nconst ONE_HOUR = 60 * ONE_MINUTE\nconst ONE_DAY = 24 * ONE_HOUR\nconst ONE_WEEK = 7 * ONE_DAY\n\n// Returns the default wallpaper based on device type\n// Pro/Home get forest wallpaper (22), others get classic (18)\nasync function getDefaultWallpaper(): Promise<string> {\n\tconst device = await detectDevice()\n\tif (device.productName === 'Umbrel Home' || device.productName === 'Umbrel Pro') {\n\t\treturn '22'\n\t}\n\treturn '18'\n}\n\nexport default router({\n\t// Registers a new user\n\tregister: publicProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tname: z.string(),\n\t\t\t\tpassword: z.string().min(6, 'Password must be at least 6 characters'),\n\t\t\t\tlanguage: z.string().optional().default('en'),\n\t\t\t\t// Currently unused\n\t\t\t\traidDevices: z.array(z.string()).optional(),\n\t\t\t\traidType: z.enum(['storage', 'failsafe']).optional(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => {\n\t\t\t// Check the user hasn't already signed up\n\t\t\tif (await ctx.user.exists()) {\n\t\t\t\tthrow new TRPCError({code: 'UNAUTHORIZED', message: 'Attempted to register when user is already registered'})\n\t\t\t}\n\n\t\t\t// If we're on Umbrel Pro, check we jave a valid raid setup config\n\t\t\tconst isUmbrelPro = await ctx.umbreld.hardware.umbrelPro.isUmbrelPro()\n\t\t\tconst hasRaidDetails = input.raidType && input.raidDevices && input.raidDevices.length > 0\n\t\t\tif (isUmbrelPro && !hasRaidDetails) {\n\t\t\t\tthrow new TRPCError({code: 'BAD_REQUEST', message: 'RAID devices are required for Umbrel Pro'})\n\t\t\t}\n\n\t\t\t// If we have a valid raid setup details, trigger the initial RAID setup boot process\n\t\t\t// We'll reboot into the RAID filesystem and then the RAID module will setup the user on the next boot\n\t\t\tif (hasRaidDetails) {\n\t\t\t\tawait ctx.umbreld.hardware.raid.triggerInitialRaidSetupBootFlow(input.raidDevices!, input.raidType!, {\n\t\t\t\t\tname: input.name,\n\t\t\t\t\tpassword: input.password,\n\t\t\t\t\tlanguage: input.language,\n\t\t\t\t})\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\t// Register new user\n\t\t\treturn ctx.user.register(input.name, input.password, input.language)\n\t\t}),\n\n\t// Public method to check if a user exists\n\texists: publicProcedure.query(async ({ctx}) => ctx.user.exists()),\n\n\t// Given valid credentials returns a token for a user\n\tlogin: publicProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\tpassword: z.string(),\n\t\t\t\ttotpToken: z.string().optional(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => {\n\t\t\tif (!(await ctx.user.validatePassword(input.password))) {\n\t\t\t\tthrow new TRPCError({code: 'UNAUTHORIZED', message: 'Incorrect password'})\n\t\t\t}\n\n\t\t\t// 2FA\n\t\t\tif (await ctx.user.is2faEnabled()) {\n\t\t\t\t// Check we have a token\n\t\t\t\tif (!input.totpToken) {\n\t\t\t\t\tthrow new TRPCError({code: 'UNAUTHORIZED', message: 'Missing 2FA code'})\n\t\t\t\t}\n\n\t\t\t\t// Verify the token\n\t\t\t\tif (!(await ctx.user.validate2faToken(input.totpToken))) {\n\t\t\t\t\tthrow new TRPCError({code: 'UNAUTHORIZED', message: 'Incorrect 2FA code'})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// At this point we have a valid login\n\n\t\t\t// Set proxy token cookie\n\t\t\tconst proxyToken = await ctx.server.signProxyToken()\n\t\t\tconst expires = new Date(Date.now() + ONE_WEEK)\n\t\t\tctx.response!.cookie('UMBREL_PROXY_TOKEN', proxyToken, {\n\t\t\t\thttpOnly: true,\n\t\t\t\texpires,\n\t\t\t\tsameSite: 'lax',\n\t\t\t})\n\n\t\t\t// Return API token\n\t\t\treturn ctx.server.signToken()\n\t\t}),\n\n\t// Checks if the request has a valid token\n\tisLoggedIn: publicProcedure.query(async ({ctx}) => {\n\t\ttry {\n\t\t\tconst token = ctx.request!.headers.authorization?.split(' ')[1]\n\t\t\tawait ctx.server.verifyToken(token!)\n\t\t\treturn true\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t}),\n\n\t// Returns a new token for a user\n\trenewToken: privateProcedure.mutation(async ({ctx}) => {\n\t\t// Renew proxy token cookie\n\t\tconst proxyToken = await ctx.server.signProxyToken()\n\t\tconst expires = new Date(Date.now() + ONE_WEEK)\n\t\tctx.response!.cookie('UMBREL_PROXY_TOKEN', proxyToken, {\n\t\t\thttpOnly: true,\n\t\t\texpires,\n\t\t\tsameSite: 'lax',\n\t\t})\n\n\t\t// Return API token\n\t\treturn ctx.server.signToken()\n\t}),\n\n\t// Deletes the proxy token cookie\n\t// The JWT needs to be deleted from the client side\n\tlogout: privateProcedure.mutation(async ({ctx}) => {\n\t\tctx.response!.clearCookie('UMBREL_PROXY_TOKEN')\n\n\t\t// Return API token\n\t\treturn true\n\t}),\n\n\t// Change the user's password\n\tchangePassword: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\toldPassword: z.string(),\n\t\t\t\tnewPassword: z.string().min(6, 'Password must be at least 6 characters'),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => {\n\t\t\t// Validate old password\n\t\t\tif (!(await ctx.user.validatePassword(input.oldPassword))) {\n\t\t\t\tthrow new TRPCError({code: 'UNAUTHORIZED', message: 'Incorrect password'})\n\t\t\t}\n\t\t\treturn ctx.user.setPassword(input.newPassword)\n\t\t}),\n\n\t// Generates a new random 2FA TOTP URI\n\tgenerateTotpUri: privateProcedure.query(async () => totp.generateUri('Umbrel', 'umbrel.local')),\n\n\t// Enables 2FA\n\tenable2fa: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\ttotpUri: z.string(),\n\t\t\t\ttotpToken: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => {\n\t\t\t// Check if 2FA is already enabled\n\t\t\tif (await ctx.user.is2faEnabled()) {\n\t\t\t\tthrow new TRPCError({code: 'UNAUTHORIZED', message: '2FA is already enabled'})\n\t\t\t}\n\n\t\t\t// Verify the token\n\t\t\tif (!totp.verify(input.totpUri, input.totpToken)) {\n\t\t\t\tthrow new TRPCError({code: 'UNAUTHORIZED', message: 'Incorrect 2FA code'})\n\t\t\t}\n\n\t\t\t// Save URI\n\t\t\treturn ctx.user.enable2fa(input.totpUri)\n\t\t}),\n\n\tis2faEnabled: publicProcedure.query(async ({ctx}) => ctx.user.is2faEnabled()),\n\n\t// Disables 2FA\n\tdisable2fa: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\ttotpToken: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => {\n\t\t\t// Check if 2FA is already enabled\n\t\t\tif (!(await ctx.user.is2faEnabled())) {\n\t\t\t\tthrow new TRPCError({code: 'UNAUTHORIZED', message: '2FA is not enabled'})\n\t\t\t}\n\n\t\t\t// Verify the token\n\t\t\tif (!(await ctx.user.validate2faToken(input.totpToken))) {\n\t\t\t\tthrow new TRPCError({code: 'UNAUTHORIZED', message: 'Incorrect 2FA code'})\n\t\t\t}\n\n\t\t\t// Delete the URI\n\t\t\treturn ctx.user.disable2fa()\n\t\t}),\n\n\t// Returns the current user\n\tget: privateProcedure.query(async ({ctx}) => {\n\t\tconst user = await ctx.user.get()\n\n\t\tif (user.wallpaper === undefined) {\n\t\t\tuser.wallpaper = await getDefaultWallpaper()\n\t\t}\n\n\t\t// Only return non sensitive data\n\t\treturn {\n\t\t\tname: user.name,\n\t\t\twallpaper: user.wallpaper,\n\t\t\tlanguage: user.language,\n\t\t\ttemperatureUnit: user.temperatureUnit,\n\t\t}\n\t}),\n\n\t// Sets whitelisted properties on the user object\n\tset: privateProcedure\n\t\t.input(\n\t\t\tz\n\t\t\t\t.object({\n\t\t\t\t\tname: z.string().optional(),\n\t\t\t\t\twallpaper: z.string().optional(),\n\t\t\t\t\tlanguage: z.string().optional(),\n\t\t\t\t\ttemperatureUnit: z.string().optional(),\n\t\t\t\t})\n\t\t\t\t.strict(),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => {\n\t\t\tif (input.name) await ctx.user.setName(input.name)\n\t\t\tif (input.wallpaper) await ctx.user.setWallpaper(input.wallpaper)\n\t\t\tif (input.language) await ctx.user.setLanguage(input.language)\n\t\t\tif (input.temperatureUnit) await ctx.user.setTemperatureUnit(input.temperatureUnit)\n\n\t\t\treturn true\n\t\t}),\n\n\t// Returns the users wallpaper\n\t// This endpoint is public so it can be shown on the login screen\n\twallpaper: publicProcedure.query(async ({ctx}) => {\n\t\tconst user = await ctx.user.get()\n\t\treturn user?.wallpaper ?? (await getDefaultWallpaper())\n\t}),\n\n\t// Returns the preferred language, if any\n\t// This endpoint is public so it can be used on the login screen\n\tlanguage: publicProcedure.query(async ({ctx}) => {\n\t\tconst user = await ctx.user.get()\n\t\treturn user?.language ?? null\n\t}),\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/user/user.integration.test.ts",
    "content": "import {expect, beforeAll, afterAll, test, vi} from 'vitest'\n\nimport * as totp from '../utilities/totp.js'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\n\nbeforeAll(async () => {\n\tumbreld = await createTestUmbreld()\n})\n\nafterAll(async () => {\n\tawait umbreld.cleanup()\n})\n\nconst testUserCredentials = {\n\tname: 'satoshi',\n\tpassword: 'moneyprintergobrrr',\n}\n\nconst testUserLanguage = 'ja'\n\nconst testTotpUri =\n\t'otpauth://totp/Umbrel?secret=63AU7PMWJX6EQJR6G3KTQFG5RDZ2UE3WVUMP3VFJWHSWJ7MMHTIQ&period=30&digits=6&algorithm=SHA1&issuer=umbrel.local'\n\n// The following tests are stateful and must be run in order\n\ntest.sequential('exists() returns false when no user is registered', async () => {\n\tawait expect(umbreld.client.user.exists.query()).resolves.toBe(false)\n})\n\ntest.sequential('login() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.user.login.mutate({password: testUserCredentials.password})).rejects.toThrow(\n\t\t'Incorrect password',\n\t)\n})\n\ntest.sequential('register() throws error if username is not supplied', async () => {\n\tawait expect(\n\t\tumbreld.client.user.register.mutate({\n\t\t\tpassword: testUserCredentials.password,\n\t\t} as any),\n\t).rejects.toThrow(/invalid_type.*name/s)\n})\n\ntest.sequential('register() throws error if password is not supplied', async () => {\n\tawait expect(\n\t\tumbreld.client.user.register.mutate({\n\t\t\tname: testUserCredentials.name,\n\t\t} as any),\n\t).rejects.toThrow(/invalid_type.*password/s)\n})\n\ntest.sequential('register() throws error if password is below min length', async () => {\n\tawait expect(\n\t\tumbreld.client.user.register.mutate({\n\t\t\tname: testUserCredentials.name,\n\t\t\tpassword: 'rekt',\n\t\t}),\n\t).rejects.toThrow('Password must be at least 6 characters')\n})\n\ntest.sequential('register() creates a user', async () => {\n\tawait expect(umbreld.client.user.register.mutate(testUserCredentials)).resolves.toBe(true)\n})\n\ntest.sequential('exists() returns true when a user is registered', async () => {\n\tawait expect(umbreld.client.user.exists.query()).resolves.toBe(true)\n})\n\ntest.sequential('register() throws an error if the user is already registered', async () => {\n\tawait expect(umbreld.client.user.register.mutate(testUserCredentials)).rejects.toThrow(\n\t\t'Attempted to register when user is already registered',\n\t)\n})\n\ntest.sequential('login() throws an error for invalid credentials', async () => {\n\tawait expect(umbreld.client.user.login.mutate({password: 'usdtothemoon'})).rejects.toThrow('Incorrect password')\n})\n\ntest.sequential('login() throws an error if password is not supplied', async () => {\n\tawait expect(umbreld.client.user.login.mutate({} as any)).rejects.toThrow(/invalid_type.*password/s)\n})\n\ntest.sequential(\"renewToken() throws if we're not logged in\", async () => {\n\tawait expect(umbreld.client.user.renewToken.mutate()).rejects.toThrow('Invalid token')\n})\n\ntest.sequential(\"isLoggedIn() returns false if we're not logged in\", async () => {\n\tawait expect(umbreld.client.user.isLoggedIn.query()).resolves.toBe(false)\n})\n\ntest.sequential('login() returns a token', async () => {\n\tconst token = await umbreld.client.user.login.mutate(testUserCredentials)\n\texpect(typeof token).toBe('string')\n\tumbreld.setJwt(token)\n})\n\ntest.sequential(\"renewToken() returns a new token when we're logged in\", async () => {\n\tconst token = await umbreld.client.user.renewToken.mutate()\n\texpect(typeof token).toBe('string')\n\tumbreld.setJwt(token)\n})\n\ntest.sequential(\"isLoggedIn() returns true when we're logged in\", async () => {\n\tawait expect(umbreld.client.user.isLoggedIn.query()).resolves.toBe(true)\n})\n\ntest.sequential('generateTotpUri() returns a 2FA URI', async () => {\n\tawait expect(umbreld.client.user.generateTotpUri.query()).resolves.toContain('otpauth://totp/Umbrel?secret=')\n})\n\ntest.sequential('generateTotpUri() returns a unique 2FA URI each time', async () => {\n\tconst firstUri = await umbreld.client.user.generateTotpUri.query()\n\tconst secondUri = await umbreld.client.user.generateTotpUri.query()\n\texpect(firstUri).not.toBe(secondUri)\n})\n\ntest.sequential('enable2fa() throws error on invalid token', async () => {\n\tconst totpUri = await umbreld.client.user.generateTotpUri.query()\n\tawait expect(\n\t\tumbreld.client.user.enable2fa.mutate({\n\t\t\ttotpToken: '1234356',\n\t\t\ttotpUri,\n\t\t}),\n\t).rejects.toThrow('Incorrect 2FA code')\n})\n\ntest.sequential('enable2fa() enables 2FA on login', async () => {\n\tconst totpToken = totp.generateToken(testTotpUri)\n\tawait expect(\n\t\tumbreld.client.user.enable2fa.mutate({\n\t\t\ttotpToken,\n\t\t\ttotpUri: testTotpUri,\n\t\t}),\n\t).resolves.toBe(true)\n})\n\ntest.sequential('login() requires 2FA token if enabled', async () => {\n\tawait expect(umbreld.client.user.login.mutate(testUserCredentials)).rejects.toThrow('Missing 2FA code')\n\n\tconst totpToken = totp.generateToken(testTotpUri)\n\tawait expect(\n\t\tumbreld.client.user.login.mutate({\n\t\t\t...testUserCredentials,\n\t\t\ttotpToken,\n\t\t}),\n\t).resolves.toBeTypeOf('string')\n})\n\ntest.sequential('disable2fa() throws error on invalid token', async () => {\n\tawait expect(\n\t\tumbreld.client.user.disable2fa.mutate({\n\t\t\ttotpToken: '000000',\n\t\t}),\n\t).rejects.toThrow('Incorrect 2FA code')\n})\n\ntest.sequential('disable2fa() disables 2fa on login', async () => {\n\tconst totpToken = totp.generateToken(testTotpUri)\n\tawait expect(\n\t\tumbreld.client.user.disable2fa.mutate({\n\t\t\ttotpToken,\n\t\t}),\n\t).resolves.toBe(true)\n\n\tawait expect(umbreld.client.user.login.mutate(testUserCredentials)).resolves.toBeTypeOf('string')\n})\n\ntest.sequential('get() returns user data', async () => {\n\tawait expect(umbreld.client.user.get.query()).resolves.toMatchObject({\n\t\tname: 'satoshi',\n\t\tlanguage: 'en',\n\t})\n})\n\ntest.sequential(\"set() sets the user's name\", async () => {\n\tawait expect(umbreld.client.user.set.mutate({name: 'Hal'})).resolves.toBe(true)\n\tawait expect(umbreld.client.user.get.query()).resolves.toMatchObject({name: 'Hal'})\n\n\t// Revert name change\n\tawait expect(umbreld.client.user.set.mutate({name: testUserCredentials.name})).resolves.toBe(true)\n\tawait expect(umbreld.client.user.get.query()).resolves.toMatchObject({name: testUserCredentials.name})\n})\n\ntest.sequential(\"set() sets the user's language\", async () => {\n\tawait expect(umbreld.client.user.set.mutate({language: testUserLanguage})).resolves.toBe(true)\n\tawait expect(umbreld.client.user.get.query()).resolves.toMatchObject({language: testUserLanguage})\n})\n\ntest.sequential(\"set() sets the user's wallpaper\", async () => {\n\tawait expect(umbreld.client.user.set.mutate({wallpaper: '1.jpg'})).resolves.toBe(true)\n\tawait expect(umbreld.client.user.get.query()).resolves.toMatchObject({wallpaper: '1.jpg'})\n\n\tawait expect(umbreld.client.user.set.mutate({wallpaper: '2.jpg'})).resolves.toBe(true)\n\tawait expect(umbreld.client.user.get.query()).resolves.toMatchObject({wallpaper: '2.jpg'})\n})\n\ntest.sequential('set() throws on unknown property', async () => {\n\t// @ts-expect-error Testing invalid arguments\n\tawait expect(umbreld.client.user.set.mutate({foo: 'bar'})).rejects.toThrow('unrecognized_keys')\n})\n\ntest.sequential('language() is publically available', async () => {\n\tawait expect(umbreld.unauthenticatedClient.user.language.query()).resolves.toBe(testUserLanguage)\n})\n\ntest.sequential(\"language() returns the user's language\", async () => {\n\tawait expect(umbreld.client.user.language.query()).resolves.toBe(testUserLanguage)\n})\n\ntest.sequential('changePassword() throws on inavlid oldPassword', async () => {\n\tawait expect(\n\t\tumbreld.client.user.changePassword.mutate({oldPassword: 'fiat4lyfe', newPassword: 'usdtothemoon'}),\n\t).rejects.toThrow('Incorrect password')\n})\n\ntest.sequential(\"changePassword() changes the user's password\", async () => {\n\tawait expect(\n\t\tumbreld.client.user.changePassword.mutate({oldPassword: testUserCredentials.password, newPassword: 'usdtothemoon'}),\n\t).resolves.toBe(true)\n\tawait expect(umbreld.client.user.login.mutate({password: 'usdtothemoon'})).resolves.toBeTypeOf('string')\n\n\t// Reset password\n\tawait expect(\n\t\tumbreld.client.user.changePassword.mutate({oldPassword: 'usdtothemoon', newPassword: testUserCredentials.password}),\n\t).resolves.toBe(true)\n\tawait expect(umbreld.client.user.login.mutate(testUserCredentials)).resolves.toBeTypeOf('string')\n})\n\n// NOTE: The test below will wipe the above state and create a new user\n// We need it to test registering a user with language\ntest.sequential('register() creates a new user with language', async () => {\n\t// Create fresh instance so we can register a new user\n\tawait umbreld.cleanup()\n\tumbreld = await createTestUmbreld()\n\n\t// Register a new user with language\n\tawait expect(umbreld.client.user.register.mutate({...testUserCredentials, language: testUserLanguage})).resolves.toBe(\n\t\ttrue,\n\t)\n\n\t// Set jwt\n\tconst token = await umbreld.client.user.login.mutate(testUserCredentials)\n\tumbreld.setJwt(token)\n\n\t// Check language is returned in user object\n\tawait expect(umbreld.client.user.get.query()).resolves.toMatchObject({language: testUserLanguage})\n\n\t// Check language is returned in public language endpoint\n\tawait expect(umbreld.client.user.language.query()).resolves.toBe(testUserLanguage)\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/user/user.ts",
    "content": "import bcrypt from 'bcryptjs'\nimport fse from 'fs-extra'\nimport {$} from 'execa'\n\nimport type Umbreld from '../../index.js'\n\nimport * as totp from '../utilities/totp.js'\n\nexport default class User {\n\t#store: Umbreld['store']\n\tlogger: Umbreld['logger']\n\t#umbreld: Umbreld\n\tconstructor(umbreld: Umbreld) {\n\t\tthis.#store = umbreld.store\n\t\tconst {name} = this.constructor\n\t\tthis.logger = umbreld.logger.createChildLogger(name.toLowerCase())\n\t\tthis.#umbreld = umbreld\n\t}\n\n\tasync start() {\n\t\tthis.logger.log('Starting user')\n\t}\n\n\tasync stop() {\n\t\tthis.logger.log('Stopping user')\n\t}\n\n\t// Get the user object from the store\n\tasync get() {\n\t\treturn this.#store.get('user')\n\t}\n\n\t// Check if a user exists\n\tasync exists() {\n\t\tconst user = await this.get()\n\t\treturn user !== undefined\n\t}\n\n\t// Set the users name\n\tasync setName(name: string) {\n\t\tif (await this.#umbreld.hardware.raid.hasConfigStore()) {\n\t\t\tawait this.#umbreld.hardware.raid.configStore.set('user.name', name)\n\t\t}\n\t\treturn await this.#store.set('user.name', name)\n\t}\n\n\t// Set the users wallpaper\n\tasync setWallpaper(wallpaper: string) {\n\t\treturn this.#store.set('user.wallpaper', wallpaper)\n\t}\n\n\t// Set the users password\n\tasync setPassword(password: string) {\n\t\t// Hash the password with the current recommended default\n\t\t// As of 2023: https://wiki.php.net/rfc/bcrypt_cost_2023\n\t\tconst saltRounds = 12\n\t\t// For historical reasons, bcrypt.js@2 produces $2a$ hashes unaffected\n\t\t// by the OpenBSD bug that led to incrementing the version to $2b$. When\n\t\t// verifying, it handles $2a$, $2b$ and $2y$ like OpenBSD $2b$.\n\t\tconst hashedPassword = (await bcrypt.hash(password, saltRounds)).replace(/^\\$2a\\$/, '$2b$')\n\t\tconst success = await this.setHashedPassword(hashedPassword)\n\t\tif (success) {\n\t\t\t// Also synchronize Linux system password\n\t\t\t// It's async but we don't need to wait for it to complete\n\t\t\tthis.syncSystemPassword()\n\t\t}\n\t\treturn success\n\t}\n\n\tasync syncSystemPassword() {\n\t\ttry {\n\t\t\tconst userFile = await fse.readFile('/etc/passwd', 'utf8')\n\t\t\tconst hasUmbrelSystemUser = userFile.split('\\n').some((line) => line.startsWith('umbrel:'))\n\t\t\tconst hashedPassword = (await this.#store.get('user.hashedPassword')) || ''\n\n\t\t\t// Only attempt this if there's an umbrel user and a password has been set\n\t\t\tif (hasUmbrelSystemUser && hashedPassword.length > 0) {\n\t\t\t\t// Sanity-check that the system supports bcrypt. We assume that a modern\n\t\t\t\t// distro that supports bcrypt in any capacity can handle $2b$ hashes and\n\t\t\t\t// that we are not coming into contact with actually bugged $2a$ hashes.\n\t\t\t\tconst {stdout} = await $`mkpasswd --method help`\n\t\t\t\tconst supportsBcrypt = /^bcrypt\\s/m.test(stdout)\n\t\t\t\tif (supportsBcrypt) {\n\t\t\t\t\tconst bcryptRegex = /^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$/\n\t\t\t\t\tif (bcryptRegex.test(hashedPassword)) {\n\t\t\t\t\t\tconst systemPassword = hashedPassword.replace(/^\\$2[ay]\\$/, '$2b$')\n\t\t\t\t\t\tawait $({input: `umbrel:${systemPassword}`})`chpasswd --encrypted`\n\t\t\t\t\t\tthis.logger.log(`Synced system password`)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.logger.error(`Failed to update system password: invalid password hash`)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tthis.logger.error(`Failed to update system password: bcrypt not supported`)\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error) {\n\t\t\t// If the system password update fails, log it but continue\n\t\t\tthis.logger.error(`Failed to update system password`, error)\n\t\t}\n\t}\n\n\t// Directly sets the hashed password value (only exposed for data migration)\n\tasync setHashedPassword(hashedPassword: string) {\n\t\tif (await this.#umbreld.hardware.raid.hasConfigStore()) {\n\t\t\tawait this.#umbreld.hardware.raid.configStore.set('user.hashedPassword', hashedPassword)\n\t\t}\n\t\treturn this.#store.set('user.hashedPassword', hashedPassword)\n\t}\n\n\t// Register a new user\n\tasync register(name: string, password: string, language: string) {\n\t\t// Check the user hasn't already signed up\n\t\tif (await this.exists()) {\n\t\t\tthrow new Error('Attempted to register when user is already registered')\n\t\t}\n\n\t\t// Save the user\n\t\tawait this.setName(name)\n\t\tawait this.setLanguage(language)\n\t\t// We can do this a cleaner way if we refactor widgets into a proper module\n\t\tawait this.#umbreld.store.set('widgets', ['umbrel:files-favorites', 'umbrel:storage', 'umbrel:system-stats'])\n\t\treturn this.setPassword(password)\n\t}\n\n\t// Validate a password against the stored hash\n\tasync validatePassword(password: string) {\n\t\t// Get hashed password\n\t\tconst hashedPassword = await this.#store.get('user.hashedPassword')\n\n\t\t// Validate credentials\n\t\tconst validPassword = hashedPassword && (await bcrypt.compare(password, hashedPassword))\n\n\t\treturn validPassword\n\t}\n\n\t// Check if 2FA is enabled\n\tasync is2faEnabled() {\n\t\treturn Boolean(await this.#store.get('user.totpUri'))\n\t}\n\n\t// Validate a 2FA token against the stored secret\n\tasync validate2faToken(token: string) {\n\t\tconst totpUri = await this.#store.get('user.totpUri')\n\t\treturn totp.verify(totpUri!, token)\n\t}\n\n\t// Enable 2FA\n\tasync enable2fa(totpUri: string) {\n\t\treturn this.#store.set('user.totpUri', totpUri)\n\t}\n\n\t// Disable 2FA\n\tasync disable2fa() {\n\t\treturn this.#store.delete('user.totpUri')\n\t}\n\n\t// Set language preference\n\tasync setLanguage(language: string) {\n\t\tif (await this.#umbreld.hardware.raid.hasConfigStore()) {\n\t\t\tawait this.#umbreld.hardware.raid.configStore.set('user.language', language)\n\t\t}\n\t\treturn this.#store.set('user.language', language)\n\t}\n\n\t// Set temperature unit preference\n\tasync setTemperatureUnit(temperatureUnit: string) {\n\t\treturn this.#store.set('user.temperatureUnit', temperatureUnit)\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/copy-with-progress.ts",
    "content": "import {execa} from 'execa'\nimport bytes from 'bytes'\n\nexport async function copyWithProgress(\n\tsource: string,\n\tdestination: string,\n\tonProgress?: (progress: {progress: number; bytesPerSecond: number; secondsRemaining?: number}) => void,\n) {\n\tconst rsyncExtraOptions = []\n\n\t// Force 100 KB/s for test suite\n\tif (process.env.UMBRELD_FORCE_100KBS_COPY === 'true') rsyncExtraOptions.push('--bwlimit=100')\n\n\t// Start rsync copy\n\tconst rsync = execa('rsync', [\n\t\t// Give detailed progress output we can easily parse.\n\t\t// Build the entire file list before starting the transfer instead of incrementally\n\t\t// for more accurate progress reporting.\n\t\t'--info=progress2',\n\t\t'--no-human-readable',\n\t\t'--no-inc-recursive',\n\t\t// Archive mode, recursive and preserve permissions etc\n\t\t'--archive',\n\t\t// Inplace mode, update files in place instead of temporary files with a random suffix\n\t\t// which confuses recents tracking.\n\t\t'--inplace',\n\t\t// Drop in extra options\n\t\t...rsyncExtraOptions,\n\t\t// Absolute source and target\n\t\tsource,\n\t\tdestination,\n\t])\n\n\t// Process output from rsync to handle copy progress\n\tif (onProgress) {\n\t\trsync.stdout!.on('data', (chunk) => {\n\t\t\t// Grab progress update from --info=progress2 output\n\t\t\tconst output = chunk.toString()\n\n\t\t\t// Check if we have a % update\n\t\t\tconst progressUpdate = output.match(/.* (\\d*)% .*/)\n\t\t\tif (progressUpdate) {\n\t\t\t\t// Parse values from rsync output\n\t\t\t\tconst values = output.trim().split(/\\s+/)\n\t\t\t\tconst bytesCopied = Number(values[0])\n\t\t\t\tconst percent = Number(values[1].replace('%', ''))\n\t\t\t\tconst bytesPerSecond = bytes.parse(values[2].replace('/s', '')) ?? 0\n\n\t\t\t\t// Calculate time remaining\n\t\t\t\tconst totalBytes = Math.round((bytesCopied / percent) * 100)\n\t\t\t\tlet secondsRemaining: number | undefined = Math.round((totalBytes - bytesCopied) / bytesPerSecond)\n\t\t\t\tif (secondsRemaining === Infinity) secondsRemaining = undefined\n\n\t\t\t\t// Emit the progress event\n\t\t\t\tonProgress({progress: percent, bytesPerSecond, secondsRemaining})\n\t\t\t}\n\t\t})\n\t}\n\n\t// Wait for rsync to finish and throw if rsync exits with a non-zero exit code\n\treturn rsync\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/dependencies.ts",
    "content": "/**\n * Ensure selected dependencies are filled in when given the app's dependencies\n * (where undefined = none) and a user's selection (where undefined = default).\n */\nexport const fillSelectedDependencies = (dependencies?: string[], selectedDependencies?: Record<string, string>) =>\n\tdependencies?.reduce(\n\t\t(accumulator, dependencyId) => {\n\t\t\taccumulator[dependencyId] = selectedDependencies?.[dependencyId] ?? dependencyId\n\t\t\treturn accumulator\n\t\t},\n\t\t{} as Record<string, string>,\n\t) ?? {}\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/docker-pull.ts",
    "content": "import Dockerode from 'dockerode'\n\nconst docker = new Dockerode()\n\nconst DOWNLOADING_PERCENT = 0.75\nconst EXTRACTING_PERCENT = 0.25\n\nexport async function pull(\n\timage: string,\n\tupdateProgress: (progress: number) => void,\n\thandleAlreadyDownloaded: () => void,\n) {\n\treturn new Promise((resolve, reject) => {\n\t\tdocker.pull(image, (error: Error, stream: NodeJS.ReadableStream) => {\n\t\t\tif (error) return reject(error)\n\n\t\t\tconst layerProgress: Record<string, number> = {}\n\n\t\t\tfunction progress() {\n\t\t\t\tconst totalProgress = Object.values(layerProgress).reduce((total, layer) => total + layer, 0)\n\t\t\t\treturn totalProgress / Object.keys(layerProgress).length\n\t\t\t}\n\n\t\t\tfunction onFinished(error: Error | null, output: any) {\n\t\t\t\tif (error) return reject(error)\n\n\t\t\t\tconst alreadyDownloaded = Object.entries(layerProgress).length === 0\n\t\t\t\tif (alreadyDownloaded) handleAlreadyDownloaded()\n\n\t\t\t\tupdateProgress(1)\n\t\t\t\tresolve(true)\n\t\t\t}\n\n\t\t\tfunction onProgress(event: any) {\n\t\t\t\tif (event.status === 'Pulling fs layer') {\n\t\t\t\t\tlayerProgress[event.id] = 0\n\t\t\t\t}\n\t\t\t\tif (event.status === 'Downloading') {\n\t\t\t\t\tconst downloadPercent = event.progressDetail.current / event.progressDetail.total\n\t\t\t\t\tlayerProgress[event.id] = downloadPercent * DOWNLOADING_PERCENT\n\t\t\t\t\tupdateProgress(progress())\n\t\t\t\t}\n\t\t\t\tif (event.status === 'Extracting') {\n\t\t\t\t\tconst extractPercent = event.progressDetail.current / event.progressDetail.total\n\t\t\t\t\tlayerProgress[event.id] = DOWNLOADING_PERCENT + extractPercent * EXTRACTING_PERCENT\n\t\t\t\t\tupdateProgress(progress())\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tdocker.modem.followProgress(stream, onFinished, onProgress)\n\t\t})\n\t})\n}\n\nexport async function pullAll(images: string[], updateProgress: (progress: number) => void) {\n\tlet lastTotalProgress = 0\n\tconst imageProgress: Record<string, number> = {}\n\tconst alreadyDownloadedImages: string[] = []\n\tfor (const image of images) {\n\t\timageProgress[image] = 0\n\t}\n\tawait Promise.all(\n\t\timages.map(async (image) =>\n\t\t\tpull(\n\t\t\t\timage,\n\t\t\t\t(progress) => {\n\t\t\t\t\tif (alreadyDownloadedImages.includes(image)) return\n\t\t\t\t\timageProgress[image] = progress\n\t\t\t\t\tconst totalProgress =\n\t\t\t\t\t\tObject.values(imageProgress).reduce((total, image) => total + image, 0) /\n\t\t\t\t\t\tObject.values(imageProgress).length\n\t\t\t\t\t// We need this because somehow progress can occasionally go backwards\n\t\t\t\t\t// I'm not sure why, maybe we aren't guaranteed to get the events in the\n\t\t\t\t\t// correct order?\n\t\t\t\t\tif (totalProgress > lastTotalProgress) {\n\t\t\t\t\t\tupdateProgress(totalProgress)\n\t\t\t\t\t\tlastTotalProgress = totalProgress\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t// Already downloaded so fix progress\n\t\t\t\t() => {\n\t\t\t\t\tdelete imageProgress[image]\n\t\t\t\t\talreadyDownloadedImages.push(image)\n\t\t\t\t},\n\t\t\t),\n\t\t),\n\t)\n\n\treturn true\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/file-store.integration.test.ts",
    "content": "import path from 'node:path'\nimport {describe, beforeAll, afterAll, expect, test} from 'vitest'\nimport fse from 'fs-extra'\n\nimport temporaryDirectory from './temporary-directory.js'\n\nimport FileStore from './file-store.js'\n\nconst directory = temporaryDirectory()\n\nbeforeAll(directory.createRoot)\nafterAll(directory.destroyRoot)\n\nconst createStore = async () => {\n\tconst filePath = path.join(await directory.create(), 'store.yaml')\n\t// Define a loose schema that will allow any key or value so\n\t// we don't need to define all the test store schemas\n\ttype LooseSchema = Record<string, any>\n\tconst store = new FileStore<LooseSchema>({filePath})\n\n\treturn store\n}\n\ndescribe('Filestore', () => {\n\ttest('FileStore is a class', () => {\n\t\texpect(FileStore).toBeTypeOf('function')\n\t\texpect(FileStore.toString().startsWith('class ')).toBe(true)\n\t})\n})\n\ndescribe('store.get()', () => {\n\ttest('can get a value', async () => {\n\t\tconst store = await createStore()\n\t\tawait store.set('one', 1)\n\t\texpect(await store.get('one')).toBe(1)\n\t})\n\n\ttest('can get a deep value with dot notation', async () => {\n\t\tconst store = await createStore()\n\t\tawait store.set('deep.one', 1)\n\t\texpect(await store.get('deep.one')).toBe(1)\n\t})\n\n\ttest('can get entire store', async () => {\n\t\tconst store = await createStore()\n\t\tawait store.set('one', 1)\n\t\tawait store.set('two', 2)\n\t\texpect(await store.get()).toEqual({\n\t\t\tone: 1,\n\t\t\ttwo: 2,\n\t\t})\n\t})\n\n\ttest(\"throws if it can't read the store file\", async () => {\n\t\tconst store = new FileStore({filePath: `/`})\n\t\texpect(store.get()).rejects.toThrow('EISDIR')\n\t})\n})\n\ndescribe('store.set()', () => {\n\ttest('can set a value', async () => {\n\t\tconst store = await createStore()\n\t\texpect(await store.set('one', 1)).toBe(true)\n\t})\n\n\ttest('can set a deep value with dot notation', async () => {\n\t\tconst store = await createStore()\n\t\texpect(await store.set('deep.one', 1)).toBe(true)\n\t\texpect(await store.get()).toStrictEqual({\n\t\t\tdeep: {\n\t\t\t\tone: 1,\n\t\t\t},\n\t\t})\n\t})\n\n\ttest('queues async writes', async () => {\n\t\tconst store = await createStore()\n\t\t// If there was no write queue these async writes would all overwrite eachother\n\t\tawait Promise.all([\n\t\t\tstore.set('one', 1),\n\t\t\tstore.set('two', 2),\n\t\t\tstore.set('three', 3),\n\t\t\tstore.set('four', 4),\n\t\t\tstore.set('five', 5),\n\t\t])\n\t\texpect(await store.get()).toStrictEqual({\n\t\t\tone: 1,\n\t\t\ttwo: 2,\n\t\t\tthree: 3,\n\t\t\tfour: 4,\n\t\t\tfive: 5,\n\t\t})\n\t})\n\n\ttest('throws on missing or invalid arguments', async () => {\n\t\tconst store = await createStore()\n\n\t\t// @ts-expect-error Testing invalid arguments\n\t\texpect(store.set()).rejects.toThrow('Invalid argument')\n\n\t\t// @ts-expect-error Testing invalid arguments\n\t\texpect(store.set('key')).rejects.toThrow('Invalid argument')\n\n\t\t// @ts-expect-error Testing invalid arguments\n\t\texpect(store.set(undefined, 'value')).rejects.toThrow('Invalid argument')\n\t})\n})\n\ndescribe('store.delete()', () => {\n\ttest('can delete a value', async () => {\n\t\tconst store = await createStore()\n\n\t\tawait store.set('one', 1)\n\t\tawait store.set('two', 2)\n\t\texpect(await store.get()).toStrictEqual({\n\t\t\tone: 1,\n\t\t\ttwo: 2,\n\t\t})\n\n\t\tawait store.delete('one')\n\t\texpect(await store.get()).toStrictEqual({\n\t\t\ttwo: 2,\n\t\t})\n\t})\n\n\ttest('can delete a deep value with dot notation', async () => {\n\t\tconst store = await createStore()\n\n\t\tawait store.set('deep.one', 1)\n\t\tawait store.set('deep.two', 2)\n\t\texpect(await store.get('deep')).toStrictEqual({\n\t\t\tone: 1,\n\t\t\ttwo: 2,\n\t\t})\n\n\t\tawait store.delete('deep.one')\n\t\texpect(await store.get('deep')).toStrictEqual({\n\t\t\ttwo: 2,\n\t\t})\n\t})\n})\n\ndescribe('store.getWriteLock()', () => {\n\ttest('allows custom control over write lock', async () => {\n\t\tconst store = await createStore()\n\t\tawait store.set('counter', 0)\n\n\t\tconst incrementWithWritelock = async () => {\n\t\t\treturn store.getWriteLock(async ({set}) => {\n\t\t\t\tlet counter = await store.get('counter')\n\t\t\t\tcounter++\n\t\t\t\tawait set('counter', counter)\n\t\t\t})\n\t\t}\n\n\t\tawait Promise.all([incrementWithWritelock(), incrementWithWritelock()])\n\n\t\texpect(await store.get('counter')).toBe(2)\n\t})\n\n\ttest('exposes expected methods', async (t) => {\n\t\tconst store = await createStore()\n\n\t\tawait store.getWriteLock(async (methods) => {\n\t\t\tawait methods.set('one', 1)\n\t\t\texpect(await methods.get('one')).toBe(1)\n\t\t\tawait methods.delete('one')\n\t\t\texpect(await methods.get('one')).toBe(undefined)\n\t\t})\n\t})\n})\n\nconst createFaultyStore = async () => {\n\tconst filePath = path.join(await directory.create(), 'store.yaml')\n\n\t// Create a faulty store where the store file is empty, in turn\n\t// deserializing as `undefined` if not explicitly handled\n\tawait fse.ensureFile(filePath)\n\n\ttype LooseSchema = Record<string, any>\n\tconst store = new FileStore<LooseSchema>({filePath})\n\n\treturn store\n}\n\ndescribe('Filestore', () => {\n\ttest('recovers from faulty store', async () => {\n\t\tconst store = await createFaultyStore()\n\t\texpect(await store.get()).toStrictEqual({})\n\n\t\tawait store.set('test', 123)\n\t\texpect(await store.get('test')).toStrictEqual(123)\n\t})\n})\n\ndescribe('write hooks', () => {\n\ttest('onBeforeWrite is called before writing', async () => {\n\t\tconst filePath = path.join(await directory.create(), 'store.yaml')\n\t\ttype LooseSchema = Record<string, any>\n\t\tlet fileExistedBeforeWrite: boolean | undefined\n\t\tconst store = new FileStore<LooseSchema>({\n\t\t\tfilePath,\n\t\t\tonBeforeWrite: async () => {\n\t\t\t\tfileExistedBeforeWrite = await fse.pathExists(filePath)\n\t\t\t},\n\t\t})\n\n\t\tawait store.set('one', 1)\n\n\t\texpect(fileExistedBeforeWrite).toBe(false)\n\t\texpect(await fse.pathExists(filePath)).toBe(true)\n\t})\n\n\ttest('onAfterWrite is called after writing', async () => {\n\t\tconst filePath = path.join(await directory.create(), 'store.yaml')\n\t\ttype LooseSchema = Record<string, any>\n\t\tlet valueInHook: number | undefined\n\t\tconst store = new FileStore<LooseSchema>({\n\t\t\tfilePath,\n\t\t\tonAfterWrite: async () => {\n\t\t\t\tvalueInHook = await store.get('one')\n\t\t\t},\n\t\t})\n\n\t\tawait store.set('one', 1)\n\n\t\texpect(valueInHook).toBe(1)\n\t})\n\n\ttest('both hooks are called in correct order', async () => {\n\t\tconst filePath = path.join(await directory.create(), 'store.yaml')\n\t\ttype LooseSchema = Record<string, any>\n\t\tlet fileExistedBeforeWrite: boolean | undefined\n\t\tlet valueAfterWrite: number | undefined\n\t\tconst store = new FileStore<LooseSchema>({\n\t\t\tfilePath,\n\t\t\tonBeforeWrite: async () => {\n\t\t\t\tfileExistedBeforeWrite = await fse.pathExists(filePath)\n\t\t\t},\n\t\t\tonAfterWrite: async () => {\n\t\t\t\tvalueAfterWrite = await store.get('one')\n\t\t\t},\n\t\t})\n\n\t\tawait store.set('one', 1)\n\n\t\texpect(fileExistedBeforeWrite).toBe(false)\n\t\texpect(valueAfterWrite).toBe(1)\n\t})\n\n\ttest('onAfterWrite is called even if write fails', async () => {\n\t\tconst filePath = '/nonexistent/directory/store.yaml'\n\t\ttype LooseSchema = Record<string, any>\n\t\tlet beforeCalled = false\n\t\tlet afterCalled = false\n\t\tconst store = new FileStore<LooseSchema>({\n\t\t\tfilePath,\n\t\t\tonBeforeWrite: async () => {\n\t\t\t\tbeforeCalled = true\n\t\t\t},\n\t\t\tonAfterWrite: async () => {\n\t\t\t\tafterCalled = true\n\t\t\t},\n\t\t})\n\n\t\tawait expect(store.set('one', 1)).rejects.toThrow()\n\t\texpect(beforeCalled).toBe(true)\n\t\texpect(afterCalled).toBe(true)\n\t})\n\n\ttest('hooks are called for each write operation', async () => {\n\t\tlet beforeCount = 0\n\t\tlet afterCount = 0\n\t\tconst filePath = path.join(await directory.create(), 'store.yaml')\n\t\ttype LooseSchema = Record<string, any>\n\t\tconst store = new FileStore<LooseSchema>({\n\t\t\tfilePath,\n\t\t\tonBeforeWrite: async () => {\n\t\t\t\tbeforeCount++\n\t\t\t},\n\t\t\tonAfterWrite: async () => {\n\t\t\t\tafterCount++\n\t\t\t},\n\t\t})\n\n\t\tawait store.set('one', 1)\n\t\tawait store.set('two', 2)\n\t\tawait store.delete('one')\n\n\t\texpect(beforeCount).toBe(3)\n\t\texpect(afterCount).toBe(3)\n\t})\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/file-store.ts",
    "content": "import * as fs from 'node:fs/promises'\nimport process from 'node:process'\n\nimport yaml from 'js-yaml'\nimport {getProperty, setProperty, deleteProperty} from 'dot-prop'\nimport PQueue from 'p-queue'\n\ntype DotProp<T, P extends string> = P extends `${infer K}.${infer R}`\n\t? K extends keyof T\n\t\t? DotProp<NonNullable<T[K]>, R>\n\t\t: never\n\t: P extends keyof T\n\t\t? NonNullable<T[P]>\n\t\t: never\n\ntype StorePath<T, P extends string> = DotProp<T, P> extends never ? 'The provided path does not exist in the store' : P\n\ntype Primitive = number | string | boolean | null | undefined\ntype Serializable = {\n\t[key: string]: Serializable | Serializable[] | Primitive | Primitive[]\n}\n\nexport default class FileStore<T extends Serializable> {\n\tfilePath: string\n\n\t#parser\n\t#writes = 0\n\t#writeQueue\n\t#onBeforeWrite?: () => Promise<void>\n\t#onAfterWrite?: () => Promise<void>\n\n\tconstructor({\n\t\tfilePath,\n\t\tonBeforeWrite,\n\t\tonAfterWrite,\n\t}: {\n\t\tfilePath: string\n\t\tonBeforeWrite?: () => Promise<any>\n\t\tonAfterWrite?: () => Promise<any>\n\t}) {\n\t\tthis.filePath = filePath\n\t\tthis.#onBeforeWrite = onBeforeWrite\n\t\tthis.#onAfterWrite = onAfterWrite\n\n\t\t// TODO: Allow configuring\n\t\tthis.#parser = {\n\t\t\tencode: yaml.dump,\n\t\t\tdecode: yaml.load,\n\t\t}\n\n\t\tthis.#writeQueue = new PQueue({concurrency: 1})\n\t}\n\n\tasync #read() {\n\t\t// Set default store value\n\t\tlet store = {} as T\n\n\t\ttry {\n\t\t\t// Attempt to read and parse the store file\n\t\t\tconst rawData = await fs.readFile(this.filePath, 'utf8')\n\t\t\tconst data = this.#parser.decode(rawData)\n\n\t\t\t// If we get a result, set the store value\n\t\t\tif (data) store = data as T\n\t\t} catch (error) {\n\t\t\t// Prevent errors if the file doesn't exist, we'll just use the default value\n\t\t\tif ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error\n\t\t}\n\n\t\t// Return the store\n\t\treturn store\n\t}\n\n\tasync #write(store: T): Promise<boolean> {\n\t\tconst rawData = this.#parser.encode(store)\n\n\t\t// Call pre-write hook if provided\n\t\tif (this.#onBeforeWrite) await this.#onBeforeWrite()\n\n\t\ttry {\n\t\t\t// Write atomically\n\t\t\tconst processId = Number(process.pid)\n\t\t\tconst temporaryFilePath = `${this.filePath}.${processId}.${this.#writes++}.tmp`\n\t\t\tawait fs.writeFile(temporaryFilePath, rawData, 'utf8')\n\t\t\tawait fs.rename(temporaryFilePath, this.filePath)\n\n\t\t\treturn true\n\t\t} finally {\n\t\t\t// Call post-write hook if provided\n\t\t\tif (this.#onAfterWrite) await this.#onAfterWrite()\n\t\t}\n\t}\n\n\tasync #set<P extends string>(property: StorePath<T, P>, value: DotProp<T, P>) {\n\t\tconst store = await this.#read()\n\t\tsetProperty(store as any, property as string, value)\n\n\t\treturn this.#write(store)\n\t}\n\n\tasync #delete<P extends string>(property: StorePath<T, P>): Promise<boolean> {\n\t\tconst store = await this.#read()\n\t\tdeleteProperty(store as any, property as string)\n\n\t\treturn this.#write(store)\n\t}\n\n\tasync get<P extends string>(property?: StorePath<T, P>, defaultValue?: DotProp<T, P>) {\n\t\tconst store = await this.#read()\n\n\t\treturn getProperty(store, property as string, defaultValue) as DotProp<T, P>\n\t}\n\n\tasync set<P extends string>(property: StorePath<T, P>, value: DotProp<T, P>): Promise<boolean> {\n\t\tif (typeof property !== 'string' || typeof value === 'undefined') {\n\t\t\tthrow new TypeError('Invalid argument')\n\t\t}\n\n\t\t// Add this write job to the queue\n\t\treturn this.#writeQueue.add(async () => this.#set(property, value))\n\t}\n\n\tasync delete<P extends string>(property: StorePath<T, P>): Promise<boolean> {\n\t\tif (typeof property !== 'string') throw new TypeError('Invalid argument')\n\n\t\t// Add this write job to the queue\n\t\treturn this.#writeQueue.add(async () => this.#delete(property))\n\t}\n\n\tasync getWriteLock(\n\t\tjob: (methods: {\n\t\t\tget: FileStore<T>['get']\n\t\t\tset: FileStore<T>['set']\n\t\t\tdelete: FileStore<T>['delete']\n\t\t}) => Promise<void>,\n\t): Promise<void> {\n\t\tconst nonLockedMethods = {\n\t\t\tget: this.get.bind(this),\n\t\t\tset: this.#set.bind(this),\n\t\t\tdelete: this.#delete.bind(this),\n\t\t}\n\n\t\treturn this.#writeQueue.add(async () => job(nonLockedMethods))\n\t}\n\n\t// TODO: Method to overwrite entire store\n\n\t// TODO: Method to register migration hook\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/get-directory-size.ts",
    "content": "import {$} from 'execa'\n\n// Get a directory size in bytes\nasync function getDirectorySize(directoryPath: string) {\n\tconst du = await $`du --summarize --bytes ${directoryPath}`\n\tconst totalSize = parseInt(du.stdout.split('\\t')[0], 10)\n\n\treturn totalSize\n}\n\nexport default getDirectorySize\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/get-or-create-file.ts",
    "content": "import fse from 'fs-extra'\n\nasync function getOrCreateFile(filePath: string, defaultValue: string) {\n\tlet contents\n\ttry {\n\t\tcontents = await fse.readFile(filePath, 'utf8')\n\t\t// eslint-disable-next-line unicorn/prefer-optional-catch-binding\n\t} catch (_) {\n\t\ttry {\n\t\t\tawait fse.ensureFile(filePath)\n\t\t\tawait fse.writeFile(filePath, defaultValue, 'utf8')\n\t\t\tcontents = await fse.readFile(filePath, 'utf8')\n\t\t\t// eslint-disable-next-line unicorn/prefer-optional-catch-binding\n\t\t} catch (_) {\n\t\t\tthrow new Error('Unable to create initial file')\n\t\t}\n\t}\n\n\treturn contents\n}\n\nexport default getOrCreateFile\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/logger.ts",
    "content": "import chalkTemplate from 'chalk-template'\n\nconst logLevels = ['silent', 'normal', 'verbose'] as const\n\nexport type LogLevel = (typeof logLevels)[number]\n\nfunction value(logLevel: LogLevel) {\n\treturn logLevels.indexOf(logLevel)\n}\n\nlet longestScope = 0\n\ntype LogOptions = {\n\tlogLevel?: LogLevel\n\terror?: any\n}\n\nfunction createLogger(scope: string, globalLogLevel: LogLevel = 'normal') {\n\tif (scope.length > longestScope) longestScope = scope.length\n\tconst log = (message = '', {logLevel, error}: LogOptions = {}) => {\n\t\tif (!logLevel) logLevel = 'normal'\n\t\tif (value(globalLogLevel) >= value(logLevel)) {\n\t\t\tscope = scope.padEnd(longestScope, ' ')\n\n\t\t\tconsole.log(chalkTemplate`{white {blue [${scope}]} ${message}}`)\n\t\t\tif (error) console.log(error)\n\t\t}\n\t}\n\n\treturn {\n\t\tlog: (message?: string) => log(message),\n\t\tverbose: (message: string) => log(chalkTemplate`{grey ${message}}`, {logLevel: 'verbose'}),\n\t\terror: (message: string, error?: any) => log(chalkTemplate`{red [error]} ${message}`, {error}),\n\t\tcreateChildLogger: (scope: string) => createLogger(scope, globalLogLevel),\n\t}\n}\n\nexport default createLogger\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/package-directory.ts",
    "content": "import path from 'node:path'\nimport {fileURLToPath} from 'node:url'\n\nimport fse from 'fs-extra'\n\n// Find the package root by walking up from the current file looking for package.json\nasync function findPackageDirectory(startPath: string): Promise<string> {\n\tlet currentPath = startPath\n\twhile (currentPath !== path.parse(currentPath).root) {\n\t\tif (await fse.pathExists(path.join(currentPath, 'package.json'))) {\n\t\t\treturn currentPath\n\t\t}\n\t\tcurrentPath = path.dirname(currentPath)\n\t}\n\tthrow new Error('Could not find package.json')\n}\n\nconst currentDirectory = path.dirname(fileURLToPath(import.meta.url))\nconst packageDirectory = await findPackageDirectory(currentDirectory)\n\nexport default packageDirectory\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/random-token.ts",
    "content": "import crypto from 'node:crypto'\n\nfunction randomToken(bitLength: number) {\n\treturn crypto.randomBytes(bitLength / 8).toString('hex')\n}\n\nexport default randomToken\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/regexp.ts",
    "content": "// Escape special RegExp literals\nexport function escapeSpecialRegExpLiterals(string: string) {\n\treturn string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/run-every.ts",
    "content": "import {setTimeout} from 'node:timers/promises'\nimport {performance} from 'node:perf_hooks'\n\nimport ms from 'ms'\n\nexport default function runEvery(interval: string, job: () => Promise<void>, options?: {runInstantly?: boolean}) {\n\toptions = {\n\t\trunInstantly: true,\n\t\t...options,\n\t}\n\tconst intervalMs = ms(interval)\n\tlet running = true\n\n\t// Define async loop function\n\tasync function start() {\n\t\t// If we aren't running the job instantly\n\t\t// wait for the first interval\n\t\tif (!options!.runInstantly) {\n\t\t\tawait setTimeout(intervalMs)\n\t\t}\n\n\t\t// Start loop\n\t\t// eslint-disable-next-line no-unmodified-loop-condition\n\t\twhile (running) {\n\t\t\t// Time and execute the job\n\t\t\tconst start = performance.now()\n\t\t\tawait job()\n\t\t\tconst end = performance.now()\n\n\t\t\t// Delay the next job by the interval minus the execution time\n\t\t\tconst executionTime = end - start\n\t\t\tconst delay = Math.max(intervalMs - executionTime, 0)\n\t\t\tawait setTimeout(delay)\n\t\t}\n\t}\n\n\t// Define a function to stop the loop\n\tfunction stop() {\n\t\trunning = false\n\t}\n\n\t// Kick off the loop\n\tstart()\n\n\t// Return the stop function\n\treturn stop\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/temporary-directory.ts",
    "content": "import os from 'node:os'\nimport path from 'node:path'\n\nimport fse from 'fs-extra'\n\nimport randomToken from './random-token.js'\n\nfunction temporaryDirectory({parentDirectory}: {parentDirectory?: string} = {}) {\n\tconst baseDirectory = parentDirectory ?? os.tmpdir()\n\tconst containingDirectory = path.join(baseDirectory, randomToken(128))\n\n\tconst createRoot = () => fse.ensureDir(containingDirectory)\n\tconst destroyRoot = () => fse.remove(containingDirectory)\n\n\tconst create = async () => {\n\t\tconst directory = path.join(containingDirectory, randomToken(128))\n\t\tawait fse.ensureDir(directory)\n\n\t\treturn directory\n\t}\n\n\treturn {createRoot, destroyRoot, create}\n}\n\nexport default temporaryDirectory\n"
  },
  {
    "path": "packages/umbreld/source/modules/utilities/totp.ts",
    "content": "import crypto from 'node:crypto'\nimport {URL} from 'node:url'\n\nimport {totp} from 'notp'\n// @ts-expect-error no @types/thirty-two available\nimport base32 from 'thirty-two'\n\nexport function generateUri(label: string, issuer: string) {\n\tconst secret = crypto.randomBytes(32)\n\tconst encodedSecret = base32.encode(secret).toString('utf8').replace(/=/g, '')\n\tconst uri = `otpauth://totp/${label}?secret=${encodedSecret}&period=30&digits=6&algorithm=SHA1&issuer=${issuer}`\n\n\treturn uri\n}\n\nexport function verify(uri: string, token: string) {\n\tconst parsedUri = new URL(uri)\n\tconst secret = base32.decode(parsedUri.searchParams.get('secret'))\n\tconst period = Number(parsedUri.searchParams.get('period'))\n\tconst isValid = totp.verify(token, secret, {window: 10, time: period})\n\n\treturn Boolean(isValid)\n}\n\n// Only used in tests\nexport function generateToken(uri: string) {\n\tconst parsedUri = new URL(uri)\n\tconst secret = base32.decode(parsedUri.searchParams.get('secret'))\n\tconst period = Number(parsedUri.searchParams.get('period'))\n\treturn totp.gen(secret, {time: period})\n}\n"
  },
  {
    "path": "packages/umbreld/source/modules/widgets/routes.ts",
    "content": "import z from 'zod'\nimport ms from 'ms'\n\nimport {router, privateProcedure} from '../server/trpc/trpc.js'\nimport {systemWidgets} from '../system/system-widgets.js'\nimport {filesWidgets} from '../files/widgets.js'\n\nconst MAX_ALLOWED_WIDGETS = 3\n\nconst umbrelWidgets = {...systemWidgets, ...filesWidgets}\n\n// Splits a widgetId into appId and widgetName\n// e.g., \"transmission:status\" => { appId: \"transmission\", widgetName: \"status\" }\nfunction splitWidgetId(widgetId: string) {\n\tconst [appId, widgetName] = widgetId.split(':')\n\n\treturn {appId, widgetName}\n}\n\nexport default router({\n\t// List enabled widgets\n\tenabled: privateProcedure.query(async ({ctx}) => {\n\t\tconst widgetIds = (await ctx.umbreld.store.get('widgets')) || []\n\n\t\treturn widgetIds\n\t}),\n\n\t// Enable widget\n\tenable: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\twidgetId: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => {\n\t\t\tconst {appId, widgetName} = splitWidgetId(input.widgetId)\n\n\t\t\t// Validate widget\n\t\t\tif (appId === 'umbrel') {\n\t\t\t\t// This is an Umbrel widget\n\t\t\t\tif (!(widgetName in umbrelWidgets)) throw new Error(`No widget named ${widgetName} found in Umbrel widgets`)\n\t\t\t} else {\n\t\t\t\t// This is an app widget\n\t\t\t\t// Throws an error if the widget doesn't exist\n\t\t\t\tawait ctx.apps.getApp(appId).getWidgetMetadata(widgetName)\n\t\t\t}\n\n\t\t\t// Save widget ID\n\t\t\tawait ctx.umbreld.store.getWriteLock(async ({get, set}) => {\n\t\t\t\tconst widgets = (await get('widgets')) || []\n\n\t\t\t\t// Check if widget is already active\n\t\t\t\tif (widgets.includes(input.widgetId)) throw new Error(`Widget ${input.widgetId} is already enabled`)\n\n\t\t\t\t// Check we don't have more than 3 widgets enabled\n\t\t\t\tif (widgets.length >= MAX_ALLOWED_WIDGETS)\n\t\t\t\t\tthrow new Error(`The maximum number of widgets (${MAX_ALLOWED_WIDGETS}) has already been enabled`)\n\n\t\t\t\twidgets.push(input.widgetId)\n\t\t\t\tawait set('widgets', widgets)\n\t\t\t})\n\n\t\t\treturn true\n\t\t}),\n\n\t// Disable widget\n\tdisable: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\twidgetId: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.mutation(async ({ctx, input}) => {\n\t\t\t// Remove widget ID\n\t\t\tawait ctx.umbreld.store.getWriteLock(async ({get, set}) => {\n\t\t\t\tconst widgets = await get('widgets')\n\n\t\t\t\t// Check if widget is currently enabled\n\t\t\t\tif (!widgets.includes(input.widgetId)) throw new Error(`Widget ${input.widgetId} is not enabled`)\n\n\t\t\t\t// Remove widget\n\t\t\t\tconst updatedWidgets = widgets.filter((widget) => widget !== input.widgetId)\n\t\t\t\tawait set('widgets', updatedWidgets)\n\t\t\t})\n\n\t\t\treturn true\n\t\t}),\n\n\t// Get live data for a widget\n\tdata: privateProcedure\n\t\t.input(\n\t\t\tz.object({\n\t\t\t\twidgetId: z.string(),\n\t\t\t}),\n\t\t)\n\t\t.query(async ({ctx, input}) => {\n\t\t\tconst {appId, widgetName} = splitWidgetId(input.widgetId)\n\t\t\tlet widgetData: {[key: string]: any}\n\n\t\t\tif (appId === 'umbrel') {\n\t\t\t\t// This is an Umbrel widget\n\t\t\t\tif (!(widgetName in umbrelWidgets)) throw new Error(`No widget named ${widgetName} found in Umbrel widgets`)\n\n\t\t\t\twidgetData = await umbrelWidgets[widgetName as keyof typeof umbrelWidgets](ctx.umbreld)\n\t\t\t} else {\n\t\t\t\t// This is an app widget\n\t\t\t\twidgetData = await ctx.apps.getApp(appId).getWidgetData(widgetName)\n\t\t\t}\n\n\t\t\t// Parse refresh time from human-readable string to milliseconds\n\t\t\twidgetData.refresh = ms(widgetData.refresh)\n\n\t\t\treturn widgetData\n\t\t}),\n})\n"
  },
  {
    "path": "packages/umbreld/source/modules/widgets/widget.integration.test.ts",
    "content": "// TODO: Re-enable this, we temporarily disable TS here since we broke tests\n// and have since changed the API. We'll refactor these later.\n// eslint-disable-next-line @typescript-eslint/ban-ts-comment\n// @ts-nocheck\nimport {expect, beforeAll, afterAll, test} from 'vitest'\n\nimport createTestUmbreld from '../test-utilities/create-test-umbreld.js'\nimport runGitServer from '../test-utilities/run-git-server.js'\n\nlet umbreld: Awaited<ReturnType<typeof createTestUmbreld>>\nlet communityAppStoreGitServer: Awaited<ReturnType<typeof runGitServer>>\n\nbeforeAll(async () => {\n\t;[umbreld, communityAppStoreGitServer] = await Promise.all([createTestUmbreld(), runGitServer()])\n})\n\nafterAll(async () => {\n\tawait Promise.all([communityAppStoreGitServer.close(), umbreld.cleanup()])\n})\n\n// The following tests are stateful and must be run in order\n\ntest.sequential('enabled() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.widget.enabled.query()).rejects.toThrow('Invalid token')\n})\n\ntest.sequential('enable() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.widget.enable.mutate({widgetId: 'umbrel:storage'})).rejects.toThrow('Invalid token')\n})\n\ntest.sequential('disable() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.widget.disable.mutate({widgetId: 'umbrel:storage'})).rejects.toThrow('Invalid token')\n})\n\ntest.sequential('data() throws invalid error when no user is registered', async () => {\n\tawait expect(umbreld.client.widget.data.query({widgetId: 'umbrel:storage'})).rejects.toThrow('Invalid token')\n})\n\ntest.sequential('login', async () => {\n\tawait expect(umbreld.registerAndLogin()).resolves.toBe(true)\n})\n\n// test.sequential('listAll() returns available widgets', async () => {\n// \tawait expect(umbreld.client.widget.listAll.query()).resolves.toStrictEqual([\n// \t\t{\n// \t\t\tid: 'umbrel:storage',\n// \t\t\ttype: 'stat-with-progress',\n// \t\t\trefresh: 1000 * 60 * 5,\n// \t\t\texample: {\n// \t\t\t\ttitle: 'Storage',\n// \t\t\t\tvalue: '256 GB',\n// \t\t\t\tprogressLabel: '1.75 TB left',\n// \t\t\t\tprogress: 0.25,\n// \t\t\t},\n// \t\t},\n// \t\t{\n// \t\t\tid: 'umbrel:memory',\n// \t\t\ttype: 'stat-with-progress',\n// \t\t\trefresh: 1000 * 10,\n// \t\t\texample: {\n// \t\t\t\ttitle: 'Memory',\n// \t\t\t\tvalue: '5.8 GB',\n// \t\t\t\tsubValue: '/16GB',\n// \t\t\t\tprogressLabel: '11.4 GB left',\n// \t\t\t\tprogress: 0.36,\n// \t\t\t},\n// \t\t},\n// \t])\n// })\n\ntest.sequential('enabled() returns default widgets', async () => {\n\tawait expect(umbreld.client.widget.enabled.query()).resolves.toStrictEqual([\n\t\t'umbrel:files-favorites',\n\t\t'umbrel:storage',\n\t\t'umbrel:system-stats',\n\t])\n})\n\ntest.sequential('disable() can disable default widgets', async () => {\n\tawait expect(umbreld.client.widget.disable.mutate({widgetId: 'umbrel:files-favorites'})).resolves.toStrictEqual(true)\n\tawait expect(umbreld.client.widget.disable.mutate({widgetId: 'umbrel:storage'})).resolves.toStrictEqual(true)\n\tawait expect(umbreld.client.widget.disable.mutate({widgetId: 'umbrel:system-stats'})).resolves.toStrictEqual(true)\n})\n\ntest.sequential('enabled() returns no widgets when none are enabled', async () => {\n\tawait expect(umbreld.client.widget.enabled.query()).resolves.toStrictEqual([])\n})\n\ntest.sequential('enable() enables a widget', async () => {\n\tawait expect(umbreld.client.widget.enable.mutate({widgetId: 'umbrel:storage'})).resolves.toStrictEqual(true)\n})\n\ntest.sequential('enabled() returns enabled widgets', async () => {\n\tawait expect(umbreld.client.widget.enabled.query()).resolves.toStrictEqual(['umbrel:storage'])\n})\n\ntest.sequential('data() returns live widget data', async () => {\n\tawait expect(umbreld.client.widget.data.query({widgetId: 'umbrel:storage'})).resolves.toMatchObject({\n\t\ttitle: 'Storage',\n\t\tlink: '?dialog=live-usage&tab=storage',\n\t\trefresh: 30000,\n\t\ttype: 'text-with-progress',\n\t})\n})\n\ntest.sequential('disable() disables a widget', async () => {\n\tawait expect(umbreld.client.widget.disable.mutate({widgetId: 'umbrel:storage'})).resolves.toStrictEqual(true)\n})\n\ntest.sequential('enabled() returns no widgets when they are all disabled', async () => {\n\tawait expect(umbreld.client.widget.enabled.query()).resolves.toStrictEqual([])\n})\n"
  },
  {
    "path": "packages/umbreld/trigger-change",
    "content": "Thu 15 Jan 2026 17:15:14 +07\n"
  },
  {
    "path": "packages/umbreld/tsconfig.json",
    "content": "{\n\t\"extends\": \"@tsconfig/node22/tsconfig.json\",\n\t\"compilerOptions\": {\n\t\t\"resolveJsonModule\": true\n\t}\n}\n"
  },
  {
    "path": "packages/umbreld/umbreld",
    "content": "#!/bin/env bash\n\n# We need to add this shim as the main umbreld entrypoint so we can set up the environment we need\n# like adding node_modules/.bin to the PATH so we have access to tsx.\n\n\n# Hook to run development mode\nif [[ -d \"/umbrel-dev\" ]]\nthen\n    echo \"Running in development mode\"\n    cd /umbrel-dev\n    exec npm run dev container-init\nfi\n\n# Find the project directory and follow symlinks if necessary\nproject_directory=\"$(dirname $(readlink -f \"${BASH_SOURCE[0]}\"))\"\n\n# Get the start script from package.json\nentrypoint=$(npm --prefix \"${project_directory}\" pkg get scripts.start)\n\n# Remove double quotes\nentrypoint=\"${entrypoint#\\\"}\"\nentrypoint=\"${entrypoint%\\\"}\"\n\n# Set up PATH so we can resolve tsx and local node\nexport PATH=\"${project_directory}/node_modules/.bin:${PATH}\"\n\n# Execute the entrypoint and pass through any arguments\nexec \"${project_directory}/${entrypoint}\" \"$@\"\n"
  },
  {
    "path": "scripts/data-export",
    "content": "#!/usr/bin/env bash\n\n# This script is exposed via: curl -sL https://export.umbrel.sh | sudo bash\n# Which resolves to this file at the 1.1.1 tag in this repo\n\nset -euo pipefail\n\n# This script will:\n# - Look for an internal Umbrel install\n# - Ask the user to confirm the location or enter a new one\n# - Exit and print error if no valid Umbrel install detected\n# - Look for USB storage device\n# - Ask the user to confirm the device or enter a new one\n# - Exit and print error if no valid USB storage device detected\n# - Check size of external storage is large enough for Umbrel install\n# - Check we have write permissions on external drive\n# - Stop Umbrel if it's running\n# - Copy Umbrel install to external drive\n\n# Bail if not running as root\ncheck_root() {\n  if [[ $UID != 0 ]]; then\n    echo \"This script must be run as root\"\n    exit 1\n  fi\n}\n\n# Check depndencies are installed\ncheck_dependencies () {\n  for cmd in \"$@\"; do\n    if ! command -v $cmd >/dev/null 2>&1; then\n      echo \"This script requires \\\"${cmd}\\\" to be installed\"\n      echo\n      echo \"You can try running: sudo apt-get install ${cmd}\"\n      exit 1\n    fi\n  done\n}\n\n# Interactively confirm a value with the user. The user can press enter to accept the default value\n# or enter a new value.\nconfirm_value_with_user() {\n    local prompt=\"${1}\"\n    local default_value=\"${2}\"\n    local user_input\n\n    # Prompt the user and get input\n    read -p \"$prompt \" user_input </dev/tty # We need to explicitly pipe /dev/tty in here because stdin might be the curl output\n\n    # If input is empty, return the default value\n    if [[ -z \"$user_input\" ]]; then\n        echo \"$default_value\"\n    else\n        echo \"$user_input\"\n    fi\n}\n\n# Grabs the Umbrel data directory from the systemd service file\nfind_umbrel_install() {\n    local service_file_path=\"/etc/systemd/system/umbrel-startup.service\"\n    if [[ ! -f \"${service_file_path}\" ]]\n    then\n        return\n    fi\n\n    local umbrel_install=$(cat \"${service_file_path}\" | grep '^ExecStart=')\n    umbrel_install=\"${umbrel_install#ExecStart=}\"\n    umbrel_install=\"${umbrel_install%/scripts/start}\"\n    echo \"${umbrel_install}\"\n}\n\n# Lists block devices for currently attached USB storage devices\n# We only return unmounted devices\nlist_usb_storage_devices() {\n    local devices=$(lsblk --output NAME,TRAN --json | jq -r '.blockdevices[] | select(.tran==\"usb\") | .name')\n    for device in $devices\n    do\n        echo \"/dev/${device}\"\n    done\n}\n\n# Returns the vendor and model name of a block device\nget_block_device_model() {\n  device=\"${1}\"\n  vendor=$(cat \"/sys/block/${device}/device/vendor\")\n  model=$(cat \"/sys/block/${device}/device/model\")\n\n  # We echo in a subshell without quotes to strip surrounding whitespace\n  echo \"$(echo $vendor) $(echo $model)\"\n}\n\n# Reutns the block device size in bytes\nget_block_device_size_bytes() {\n    local block_device=\"${1}\"\n    lsblk --nodeps --noheadings --output SIZE --bytes \"${block_device}\"\n}\n\n# Converts bytes to GB\nbytes_to_gb() {\n    echo $1 | awk '{printf \"%.1f\", $1 / 1024 / 1024 / 1024}'\n}\n\n# Wipes a block device and reformats it with a single EXT4 partition\nformat_block_device () {\n  device_path=\"${1}\"\n  partition_path=\"${device_path}1\"\n  wipefs -a \"${device_path}\"\n  parted --script \"${device_path}\" mklabel gpt\n  parted --script \"${device_path}\" mkpart primary ext4 0% 100%\n  # We need to run sync here to make sure the filesystem is reflecting the\n  # the latest changes in /dev/*\n  sync\n  mkfs.ext4 -F -L umbrel \"${partition_path}\"\n}\n\nmain() {\n    check_root\n\n    check_dependencies jq rsync lsblk wipefs parted mkfs.ext4\n\n    echo \"Searching for Umbrel installations...\"\n    local umbrel_install=$(find_umbrel_install)\n    if [[ ! -d \"${umbrel_install}/app-data\" ]]\n    then\n        echo \"No Umbrel installation automatically found\"\n        umbrel_install=\"\"\n    fi\n    echo\n    echo \"Please confirm your Umbrel installation directory.\"\n    if [[ -d \"${umbrel_install}/app-data\" ]]\n    then\n        echo \"  - If it is '${umbrel_install}', just press Enter.\"\n        echo \"  - If it is a different directory, type its full path and press Enter.\"\n    else\n        echo \"  - Type it's full path and press Enter.\"\n    fi\n    echo\n    umbrel_install=$(confirm_value_with_user \"Your Umbrel installation directory:\" \"${umbrel_install}\")\n\n    if [[ -d \"${umbrel_install}/app-data\" ]]\n    then\n        echo\n        echo \"Exporting your Umbrel data from: ${umbrel_install}\"\n        local install_size=$(du --human --max-depth 0 \"${umbrel_install}\" | awk '{print $1}')\n        echo \"Your Umbrel data (${install_size}), including the apps listed below, is ready to be exported to a USB storage device:\"\n        echo \"$(ls ${umbrel_install}/app-data)\" || true\n    else\n        echo \"Error: Umbrel installation not found\"\n        exit 1\n    fi\n\n    echo \"Searching for USB storage devices...\"\n    local usb_storage_devices=$(list_usb_storage_devices)\n    local largest_usb_size_bytes=\"0\"\n    local default_usb_device=\"\"\n    for block_device in $usb_storage_devices\n    do\n        local usb_name=$(get_block_device_model ${block_device#/dev/})\n        local usb_size=$(lsblk --nodeps --noheadings --output SIZE ${block_device})\n        local usb_size_bytes=$(get_block_device_size_bytes \"${block_device}\")\n        echo \"  - ${block_device} (${usb_name} ${usb_size})\"\n        if [[ $usb_size_bytes -gt $largest_usb_size_bytes ]]\n        then\n            largest_usb_size_bytes=\"${usb_size_bytes}\"\n            default_usb_device=\"${block_device}\"\n        fi\n    done\n\n    if [[ -z \"${default_usb_device}\" ]]\n    then\n        echo \"No USB devices automatically found\"\n    fi\n    echo\n    echo \"Please confirm the USB storage device where you want to export your Umbrel data.\"\n     if [[ ! -z \"${default_usb_device}\" ]]\n    then\n        local usb_name=$(get_block_device_model ${block_device#/dev/})\n        local usb_size=$(lsblk --nodeps --noheadings --output SIZE ${block_device})\n        echo \"  - If you'd like to use ${block_device} (${usb_name} ${usb_size}), just press Enter.\"\n        echo \"  - If you'd like to use a different USB storage device, type its path (eg. \"/dev/sdb\", without quotes) and press Enter.\"\n    else\n        echo \"  - Type the full path of your USB storage device (eg. \"/dev/sdb\", without quotes) and press Enter.\"\n    fi\n    echo\n    local usb_block_device=$(confirm_value_with_user \"USB storage device:\" \"${default_usb_device}\")\n\n    if [[ ! -b \"${usb_block_device}\" ]]\n    then\n        echo \"Error: \\\"${usb_block_device}\\\" ($(get_block_device_model ${usb_block_device#/dev/})) is not a valid storage device. Please make sure you've connected a compatible storage device like an external HDD or SSD.\"\n        exit 1\n    fi\n\n    echo \"Continuing with ${usb_block_device} ($(get_block_device_model ${usb_block_device#/dev/}))\"\n\n    local usb_size_bytes=$(get_block_device_size_bytes \"${usb_block_device}\")\n    local umbrel_install_size_bytes=$(du --bytes --max-depth 0 \"${umbrel_install}\" | awk '{print $1}')\n    local buffer_bytes=$(( 1024 * 1024 * 1024 )) # 1GB buffer\n    if [[ $usb_size_bytes -lt $(( umbrel_install_size_bytes + buffer_bytes )) ]]\n    then\n        echo \"Error: $(get_block_device_model ${usb_block_device#/dev/}) ($(bytes_to_gb $usb_size_bytes) GB) does not have enough space to store your Umbrel data ($(bytes_to_gb $umbrel_install_size_bytes) GB). Please connect a larger storage device and run this script again.\"\n        exit 1\n    fi\n\n    echo\n    echo \"WARNING: Continuing will format the USB storage device $(get_block_device_model ${usb_block_device#/dev/}) and erase any existing data on it.\"\n\n    local confirm_formatting=$(confirm_value_with_user \"Type \\\"y\\\" (without quotes) and press Enter to continue:\" \"\")\n    if [[ \"${confirm_formatting}\" != \"y\" ]]\n    then\n        echo \"Exiting now: did not receive \\\"y\\\" as the confirmation to continue.\"\n        echo \"To restart the process, simply re-run this script and select the correct USB storage device.\"\n        exit 1\n    fi\n\n    echo \"Formatting USB storage device $(get_block_device_model ${usb_block_device#/dev/})...\"\n    # Quickly attempt to unmount all partitions on the USB device\n    # This will throw errors but we don't care\n    umount \"${usb_block_device}\"* 2> /dev/null || true\n    sync\n    echo\n    format_block_device \"${usb_block_device}\"\n    echo\n\n    local usb_partition=\"${usb_block_device}1\"\n    echo \"Mounting ${usb_partition}...\"\n    local usb_mount_path=$(mktemp --directory --suffix -umbrel-usb-mount)\n    mount \"${usb_partition}\" \"${usb_mount_path}\"\n\n    # Make sure no matter what, this gets unmounted\n    trap \"umount ${usb_mount_path} 2> /dev/null || true\" EXIT\n\n    # Check we can write\n    local temporary_copy_path=\"${usb_mount_path}/umbrel-data-export-temporary-${RANDOM}-$(date +%s)\"\n    mkdir \"${temporary_copy_path}\"\n    if [[ ! -d \"${temporary_copy_path}\" ]]\n    then\n        echo \"Error: Could not write to the USB storage device $(get_block_device_model ${usb_block_device#/dev/}). Please re-connect a compatible USB storage device and run this script again.\"\n        exit 1\n    fi\n\n    # Stop Umbrel if it's running so we can safely copy data\n    echo \"Stopping Umbrel to prepare for data export...\"\n    echo\n    \"${umbrel_install}/scripts/stop\" || {\n        # If the stop script fails try heavy handedly stopping all Docker containers to ensure\n        docker stop $(docker ps -aq) || {\n            echo \"Error: Could not stop Umbrel\"\n            exit 1\n        }\n    }\n    echo\n\n    # Copy data\n    echo \"Exporting your Umbrel data to the USB storage device $(get_block_device_model ${usb_block_device#/dev/}), this may take a while...\"\n    local final_path=\"${usb_mount_path}/umbrel\"\n    rsync --archive --delete \"${umbrel_install}/\" \"${temporary_copy_path}\"\n    mv \"${temporary_copy_path}\" \"${final_path}\"\n\n    # Ensure fs caches are flushed and unmount\n    echo \"Export complete, unmounting USB storage device...\"\n    sync\n    umount ${usb_mount_path}\n\n    echo\n    echo \"Done! Your Umbrel data has been exported to your external USB storage device $(get_block_device_model ${usb_block_device#/dev/}).\"\n    echo\n    echo \"Next steps:\"\n    echo \"  1. Shutdown your device.\"\n    echo \"  2. Flash umbrelOS 1.1.1 on its internal storage.\"\n    echo \"  3. Boot up with the USB storage device $(get_block_device_model ${usb_block_device#/dev/}) connected to your device.\"\n    echo \"  4. Open http://umbrel.local\"\n    echo\n    echo \"For detailed instructions, visit:\"\n    echo \"  https://link.umbrel.com/linux-update\"\n}\n\nmain"
  },
  {
    "path": "scripts/install",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# This script is exposed via: curl -L https://umbrel.sh | bash\n# Which resolves to this file in the master branch of this repo\n\ncat << \"EOF\"\n\n                ,;###GGGGGGGGGGl#Sp\n             ,##GGGlW\"\"^'  '`\"\"%GGGG#S,\n           ,#GGG\"                  \"lGG#o\n          #GGl^                      '$GG#\n        ,#GGb         umbrelOS         \\GGG,\n        lGG\"                            \"GGG\n       #GGGlGGGl##p,,p##lGGl##p,,p###ll##GGGG\n      !GGGlW\"\"\"*GGGGGGG#\"\"\"\"WlGGGGG#W\"\"*WGGGGS\n       \"\"          \"^          '\"          \"\"\n\n--------------------------------------------------------------------------\n|                                                                        |\n|  With the release of umbrelOS 1.0, umbrelOS now needs to be installed  |\n|  directly as the operating system on your device's internal storage,   |\n|  not on top of an existing OS like Debian or Ubuntu. Check out our     |\n|  step-by-step guide at https://link.umbrel.com/install-umbrelos        |\n|                                                                        |\n--------------------------------------------------------------------------\n\nEOF\n"
  },
  {
    "path": "scripts/remote-builder",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# Remote builder\n# Syncs git-tracked files to a remote host and runs build/test commands there\n\n# Get current git branch name and sanitize it for use in directory name\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nGIT_BRANCH=\"$(git -C \"${SCRIPT_DIR}\" rev-parse --abbrev-ref HEAD 2>/dev/null || echo \"unknown\")\"\nSAFE_BRANCH=\"$(echo \"${GIT_BRANCH}\" | tr '/' '-' | tr -cd 'a-zA-Z0-9_-')\"\nREMOTE_DIR=\".umbrel-builder/${SAFE_BRANCH}\"\n\n# Subcommands that run on the remote host\nif [[ \"${1:-}\" == \"--build-on-host\" ]]; then\n    REMOTE_DIR_ARG=\"${2:-}\"\n    if [[ -z \"${REMOTE_DIR_ARG}\" ]]; then\n        echo \"Error: --build-on-host requires remote directory argument\" >&2\n        exit 1\n    fi\n    USER_HOME=\"$(getent passwd \"${SUDO_USER}\" | cut -d: -f6)\"\n    WORK_DIR=\"${USER_HOME}/${REMOTE_DIR_ARG}\"\n\n    # Disable spinners and fancy progress output\n    export CI=true\n\n    # Install dependencies if missing\n    echo \"Checking dependencies...\"\n    export DEBIAN_FRONTEND=noninteractive\n    command -v npm >/dev/null || (echo \"Installing npm...\" && apt-get update && apt-get install --yes nodejs npm)\n    command -v docker >/dev/null || (echo \"Installing docker...\" && apt-get update && apt-get install --yes docker.io)\n    command -v qemu-system-x86_64 >/dev/null || (echo \"Installing qemu...\" && apt-get update && apt-get install --yes qemu-system-x86)\n\n    # Install umbreld dev dependencies\n    echo \"\"\n    echo \"Installing umbreld dependencies...\"\n    cd \"${WORK_DIR}/packages/umbreld\" && npm install\n\n    # Build OS\n    echo \"\"\n    echo \"Building OS...\"\n    cd \"${WORK_DIR}\"\n    npm --prefix packages/os run build:amd64:rugix\n    rm -rf packages/os/rugix/build\n    exit 0\nfi\n\nif [[ \"${1:-}\" == \"--test-on-host\" ]]; then\n    REMOTE_DIR_ARG=\"${2:-}\"\n    if [[ -z \"${REMOTE_DIR_ARG}\" ]]; then\n        echo \"Error: --test-on-host requires remote directory argument\" >&2\n        exit 1\n    fi\n    shift 2\n    USER_HOME=\"$(getent passwd \"${SUDO_USER}\" | cut -d: -f6)\"\n    WORK_DIR=\"${USER_HOME}/${REMOTE_DIR_ARG}/packages/umbreld\"\n\n    # Disable spinners and fancy progress output\n    export CI=true\n\n    cd \"${WORK_DIR}\"\n    if [[ $# -eq 0 ]]; then\n        # No args: run test:vm\n        npm run test:vm\n    else\n        # Args provided: pass directly to test\n        npm run test -- \"$@\"\n    fi\n    exit 0\nfi\n\n# Main script - runs locally\nshow_usage() {\n    echo \"Usage: $0 <command> <ssh-host> [args]\"\n    echo \"\"\n    echo \"Commands:\"\n    echo \"  build <ssh-host>           Build the OS on the remote host\"\n    echo \"  test <ssh-host> [args]     Run tests on the remote host\"\n    echo \"                             No args: runs test:vm (all VM tests)\"\n    echo \"                             With args: passes args directly to 'npm run test'\"\n    echo \"\"\n    echo \"Examples:\"\n    echo \"  $0 build umbrel@192.168.1.27\"\n    echo \"  $0 test umbrel@192.168.1.27\"\n    echo \"  $0 test umbrel@192.168.1.27 vm.test -t 'factory reset'\"\n    echo \"  $0 test umbrel@192.168.1.27 unit.test\"\n    exit 1\n}\n\nif [[ $# -lt 2 ]]; then\n    show_usage\nfi\n\nCOMMAND=\"$1\"\nSSH_HOST=\"$2\"\nshift 2\n\nREPO_ROOT=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\n\nsync_files() {\n    echo \"Branch: ${GIT_BRANCH}\"\n    echo \"Syncing files to ${SSH_HOST}:~/${REMOTE_DIR}...\"\n\n    # Create the remote directory and ensure rsync is installed\n    ssh \"${SSH_HOST}\" \"mkdir -p ~/${REMOTE_DIR} && (command -v rsync >/dev/null || (sudo DEBIAN_FRONTEND=noninteractive apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install --yes rsync))\"\n\n    # Sync all files except:\n    # - .gitignore'd files (via --filter)\n    # - .git directory\n    # - node_modules (installed fresh on remote)\n    # - build artifacts (created on remote, may have root ownership)\n    # Exit code 23 means partial transfer (e.g. file vanished), which is fine\n    cd \"${REPO_ROOT}\"\n    rsync -avz --checksum --delete \\\n        --filter=':- .gitignore' \\\n        --exclude='.git' \\\n        --exclude='node_modules' \\\n        --exclude='packages/os/build' \\\n        --exclude='packages/os/rugix/.rugix' \\\n        . \"${SSH_HOST}:${REMOTE_DIR}/\" || [[ $? -eq 23 ]]\n}\n\ncase \"${COMMAND}\" in\n    build)\n        sync_files\n        echo \"\"\n        echo \"Building on remote host...\"\n        ssh -tt \"${SSH_HOST}\" \"sudo ~/${REMOTE_DIR}/scripts/remote-builder --build-on-host '${REMOTE_DIR}'\"\n        ;;\n    test)\n        sync_files\n        echo \"\"\n        echo \"Running tests on remote host...\"\n        if [[ $# -eq 0 ]]; then\n            ssh -tt \"${SSH_HOST}\" \"sudo ~/${REMOTE_DIR}/scripts/remote-builder --test-on-host '${REMOTE_DIR}'\"\n        else\n            # Properly escape args for SSH\n            ESCAPED_ARGS=\"\"\n            for arg in \"$@\"; do\n                ESCAPED_ARGS+=\" '${arg}'\"\n            done\n            ssh -tt \"${SSH_HOST}\" \"sudo ~/${REMOTE_DIR}/scripts/remote-builder --test-on-host '${REMOTE_DIR}'${ESCAPED_ARGS}\"\n        fi\n        ;;\n    *)\n        show_usage\n        ;;\nesac\n"
  },
  {
    "path": "scripts/umbrel-dev",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# The instance id is used to namespace the dev environment to allow for multiple instances to run\n# without conflicts. e.g:\n#   npm run dev start\n#   UMBREL_DEV_INSTANCE='apps' npm run dev start\n#\n# Will spin up two separate umbrel-dev instances accessible at:\n#   http://umbrel-dev.local\n#   http://umbrel-dev-apps.local\nINSTANCE_ID_PREFIX=\"umbrel-dev\"\nINSTANCE_ID=\"${INSTANCE_ID_PREFIX}${UMBREL_DEV_INSTANCE:+-$UMBREL_DEV_INSTANCE}\"\nINSTANCE_OPTIONS=\"${UMBREL_DEV_OPTIONS:-}\"\n\nPRODUCTION_MODE_FLAG_FILE=\"/umbrel-dev/.production-mode\"\n\nshow_help() {\n  cat << EOF\numbrel-dev\n\nAutomatically initialize and manage an umbrelOS development environment.\n\nUsage: npm run dev <command> [-- <args>]\n\nCommands:\n    help                      Show this help message\n    start                     Either start an existing dev environment or create and start a new one\n    logs                      Stream umbreld logs\n    shell                     Get a shell inside the running dev environment\n    exec -- <command>         Execute a command inside the running dev environment\n    exec:noninteractive       Execute a command without interactive mode (for CI)\n    client -- <rpc> [<args>]  Query the umbreld RPC server via a CLI client\n    rebuild                   Rebuild the operating system image from source and reboot the dev environment into it\n    recreate                  Recreate the dev environment using the existing operating system image\n    restart                   Restart the dev environment\n    stop                      Stop the dev environment\n    reset                     Reset the dev environment to a fresh state\n    destroy                   Destroy the dev environment\n    production-mode           Disables dev server, live reload and umbreld > ui proxy. Resembles close to production behaviour.\n    development-mode          Disables production mode.\n\nEnvironment Variables:\n    UMBREL_DEV_INSTANCE       The instance id of the dev environment. Allows running multiple instances of\n                              umbrel-dev in different namespaces.\n    UMBREL_DEV_OPTIONS        Optional custom parameters passed to 'docker run' when creating the dev environment.\n\nNote: umbrel-dev requires a Docker environment that exposes container IPs to the host. This is how Docker\nnatively works on Linux and can be done with OrbStack on macOS. On Windows this should work with WSL 2.\n\nEOF\n}\n\nbuild_os_image() {\n  docker buildx build --cache-from type=gha,scope=umbrelos-amd64 --cache-to type=gha,mode=max,scope=umbrelos-amd64 --load --file packages/os/umbrelos.Dockerfile --tag \"${INSTANCE_ID}\" .\n}\n\ncreate_instance() {\n  # --network host is used when running Docker within WSL, effectively undoing one\n  # level of encapsulation, with umbrelOS accessible at `wsl.exe hostname -i`.\n  if grep --quiet \"WSL\" /proc/sys/kernel/osrelease 2> /dev/null\n  then\n    INSTANCE_OPTIONS=\"${INSTANCE_OPTIONS:-\"--network host\"}\"\n  fi\n\n  # --privileged is needed for systemd to work inside the container.\n  #\n  # We mount a named volume namespaced to the instance id at /data to immitate\n  # the data partition of a physical install.\n  #\n  # We mount the monorepo inside the container at /umbrel-dev as readonly. We\n  # setup a writeable fs overlay later to allow the container to install dependencies\n  # without modifying the hosts source code dir.\n  #\n  # --label \"dev.orbstack.http-port=80\" stops OrbStack from trying to guess which port\n  # we're trying to expose which causes some weirdness since it often gets it wrong.\n  #\n  # --label \"dev.orbstack.domains=${INSTANCE_ID}.local\" makes the instance accessble at\n  # umbrel-dev.local on OrbStack installs.\n  #\n  # /sbin/init kicks of systemd as the container entrypoint.\n  docker run \\\n    --detach \\\n    --interactive \\\n    --tty \\\n    --privileged \\\n    --name \"${INSTANCE_ID}\" \\\n    --hostname \"${INSTANCE_ID}\" \\\n    --volume \"${INSTANCE_ID}:/data\" \\\n    --volume \"${PWD}:/umbrel-dev:ro\" \\\n    --label \"dev.orbstack.http-port=80\" \\\n    --label \"dev.orbstack.domains=${INSTANCE_ID}.local\" \\\n    ${INSTANCE_OPTIONS} \\\n    \"${INSTANCE_ID}\" \\\n    /sbin/init\n}\n\nstart_instance() {\n  docker start \"${INSTANCE_ID}\"\n}\n\nexec_in_instance() {\n  docker exec --interactive --tty \"${INSTANCE_ID}\" \"${@}\"\n}\n\nexec_in_instance_noninteractive() {\n  docker exec \"${INSTANCE_ID}\" \"${@}\"\n}\n\nstop_instance() {\n  # We first need to execute poweroff inside the instance so systemd gracefully stops services before we kill the container\n  exec_in_instance poweroff\n  docker stop \"${INSTANCE_ID}\"\n}\n\nrestart_instance() {\n  stop_instance\n  start_instance\n}\n\nremove_instance() {\n  docker rm --force \"${INSTANCE_ID}\"\n}\n\nremove_volume() {\n  docker volume rm \"${INSTANCE_ID}\"\n}\n\nget_instance_ip() {\n  if [[ \"$(docker inspect --format '{{ .HostConfig.NetworkMode }}' \"${INSTANCE_ID}\")\" = \"host\" ]]\n  then\n    hostname -I | awk '{print $1}'\n  else\n    docker inspect --format '{{ .NetworkSettings.IPAddress }}' \"${INSTANCE_ID}\"\n  fi\n}\n\n# Get the command\nif [ -z ${1+x} ]; then\n  command=\"\"\nelse\n  command=\"$1\"\nfi\n\nif [[ \"${command}\" = \"start\" ]] || [[ \"${command}\" = \"\" ]]\nthen\n  echo \"Starting umbrel-dev instance...\"\n  if ! start_instance > /dev/null\n  then\n    echo \"Instance not found, creating a new one...\"\n    if ! docker image inspect \"${INSTANCE_ID}\" > /dev/null\n    then\n      build_os_image\n    fi\n    create_instance\n  fi\n  echo\n  echo \"umbrel-dev instance is booting up...\"\n\n  # Stream systemd logs until boot has completed\n  docker logs --tail 100 --follow \"${INSTANCE_ID}\" 2> /dev/null &\n  logs_pid=$!\n  exec_in_instance systemctl is-active --wait multi-user.target > /dev/null|| true\n  sleep 2\n  kill \"${logs_pid}\" || true\n  wait\n\n  # Stream umbreld logs until web server is up\n  docker exec \"${INSTANCE_ID}\" journalctl --unit umbrel --follow --lines 100 --output cat 2> /dev/null &\n  logs_pid=$!\n  docker exec \"${INSTANCE_ID}\" curl --silent --retry 300 --retry-delay 1 --retry-connrefused http://localhost > /dev/null 2>&1 || true\n  sleep 0.1\n  kill \"${logs_pid}\" || true\n  wait\n\n  # Done!\n  cat << 'EOF'\n\n\n            ,;###GGGGGGGGGGl#Sp\n         ,##GGGlW\"\"^'  '`\"\"%GGGG#S,\n       ,#GGG\"                  \"lGG#o\n      #GGl^                      '$GG#\n    ,#GGb                          \\GGG,\n    lGG\"                            \"GGG\n   #GGGlGGGl##p,,p##lGGl##p,,p###ll##GGGG\n  !GGGlW\"\"\"*GGGGGGG#\"\"\"\"WlGGGGG#W\"\"*WGGGGS\n   \"\"          \"^          '\"          \"\"\n\nEOF\n  echo \"  Your umbrel-dev instance is ready at:\"\n  echo\n  echo \"    http://${INSTANCE_ID}.local\"\n  echo \"    http://$(get_instance_ip)\"\n\n  exit\nfi\n\nif [[ \"${command}\" = \"help\" ]]\nthen\n    show_help\n\n    exit\nfi\n\nif [[ \"${command}\" = \"shell\" ]]\nthen\n    exec_in_instance bash\n\n    exit\nfi\n\nif [[ \"${command}\" = \"exec\" ]]\nthen\n    shift\n    exec_in_instance \"${@}\"\n\n    exit\nfi\n\nif [[ \"${command}\" = \"exec:noninteractive\" ]]\nthen\n    shift\n    exec_in_instance_noninteractive \"${@}\"\n\n    exit\nfi\n\nif [[ \"${command}\" = \"logs\" ]]\nthen\n    exec_in_instance journalctl --unit umbrel --follow --lines 100 --output cat\n\n    exit\nfi\n\nif [[ \"${command}\" = \"client\" ]]\nthen\n    shift\n    exec_in_instance npm --prefix /umbrel-dev/packages/umbreld run start -- client ${@}\n\n    exit\nfi\n\nif [[ \"${command}\" = \"rebuild\" ]]\nthen\n    echo \"Rebuilding the operating system image from source...\"\n    build_os_image\n    echo \"Restarting the dev environment with the new image...\"\n    stop_instance || true\n    remove_instance || true\n    create_instance\n\n    exit\nfi\n\nif [[ \"${command}\" = \"recreate\" ]]\nthen\n    echo \"Recreating the dev environment with the existing image...\"\n    stop_instance || true\n    remove_instance || true\n    create_instance\n\n    exit\nfi\n\nif [[ \"${command}\" = \"destroy\" ]]\nthen\n    echo \"Destroying the dev environment...\"\n    remove_instance || true\n    remove_volume || true\n\n    exit\nfi\n\nif [[ \"${command}\" = \"reset\" ]]\nthen\n    echo \"Resetting the dev environment state...\"\n    stop_instance || true\n    remove_instance || true\n    remove_volume || true\n    create_instance\n\n    exit\nfi\n\nif [[ \"${command}\" = \"restart\" ]]\nthen\n    echo \"Restarting the dev environment...\"\n    restart_instance\n\n    exit\nfi\n\nif [[ \"${command}\" = \"production-mode\" ]]\nthen\n    echo \"Enabling production mode...\"\n    exec_in_instance touch \"${PRODUCTION_MODE_FLAG_FILE}\"\n    restart_instance\n\n    exit\nfi\n\nif [[ \"${command}\" = \"development-mode\" ]]\nthen\n    echo \"Disabling production mode...\"\n    exec_in_instance rm -f \"${PRODUCTION_MODE_FLAG_FILE}\"\n    restart_instance\n\n    exit\nfi\n\nif [[ \"${command}\" = \"stop\" ]]\nthen\n    echo \"Stopping the dev environment...\"\n    stop_instance\n\n    exit\nfi\n\n# This is a special command that runs directly inside the container to setup the environment\n# It is not intended to be run on the host machine!\nif [[ \"${command}\" = \"container-init\" ]]\nthen\n    # Check if this is the first boot\n    first_boot=false\n    if [[ ! -d \"/data/umbrel-dev-overlay\" ]]\n    then\n        first_boot=true\n    fi\n\n    # Setup fs overlay so we can write to the source code dir without modifying it on the host\n    echo \"Setting up fs overlay...\"\n    mkdir -p /data/umbrel-dev-overlay/upperdir\n    mkdir -p /data/umbrel-dev-overlay/workdir\n    mount -t overlay overlay -o lowerdir=/umbrel-dev,upperdir=/data/umbrel-dev-overlay/upperdir,workdir=/data/umbrel-dev-overlay/workdir /umbrel-dev || true\n\n    # If this is the first boot we should nuke node_modules if they exist so we get fresh Linux deps instead\n    # of trying to reuse deps installed from the host. (causes issues with macos native deps)\n    if [[ \"${first_boot}\" = true ]]\n    then\n        echo \"Nuking node_modules inherited from host...\"\n        rm -rf /umbrel-dev/packages/ui/node_modules || true\n        rm -rf /umbrel-dev/packages/umbreld/node_modules || true\n    fi\n\n    # Install dependencies\n    echo \"Installing dependencies...\"\n    npm --prefix /umbrel-dev/packages/umbreld install\n    npm --prefix /umbrel-dev/packages/ui install\n\n    # Check if we're in production mode\n    if [[ ! -f \"${PRODUCTION_MODE_FLAG_FILE}\" ]]\n    then\n        # Run umbreld and ui in development mode with live reload\n        echo \"Starting umbreld and ui...\"\n        npm --prefix /umbrel-dev/packages/umbreld run dev &\n        CHOKIDAR_USEPOLLING=true npm --prefix /umbrel-dev/packages/ui run dev &\n        wait\n    else\n        # Build static production ui bundle and serve from umbreld\n        echo \"Building production ui...\"\n        npm --prefix /umbrel-dev/packages/ui run build\n        rm -rf \"/umbrel-dev/packages/umbreld/ui\" || true\n        mv \"/umbrel-dev/packages/ui/dist\" \"/umbrel-dev/packages/umbreld/ui\"\n        echo \"Starting umbreld in production mode...\"\n        npm --prefix /umbrel-dev/packages/umbreld run dev:production-mode\n    fi\n\n    exit\nfi\n\nshow_help\nexit"
  },
  {
    "path": "scripts/update-script",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# This script is used to bootstrap the mender update process\n# The update server references it in the form:\n# https://raw.githubusercontent.com/getumbrel/umbrel/<tag>/scripts/update-script\n\nupdate_url=\"\"\ndownload_prefix=\"https://download.umbrel.com/release/1.6.1\"\n\n# This is a legacy mender install, migrate to rugix\nif command -v mender &> /dev/null\nthen\n\tif cat /var/lib/mender/device_type | grep --quiet 'device_type=raspberrypi'\n\tthen\n\t\tupdate_url=\"${download_prefix}/umbrelos-pi-legacy-migration.update\"\n\tfi\n\n\tif cat /var/lib/mender/device_type | grep --silent 'device_type=amd64'\n\tthen\n\t\tupdate_url=\"${download_prefix}/umbrelos-amd64-legacy-migration.update\"\n\tfi\n\n\t# Fix /etc/mender/artifact_info not existing in some OS builds\n\tif [[ ! -f /etc/mender/artifact_info ]]\n\tthen\n\t\techo \"artifact_name=umbrelOS\" > /etc/mender/artifact_info\n\tfi\n\n\tif [[ \"${update_url}\" == \"\" ]]\n\tthen\n\t\techo umbrel-update: '{\"error\": \"Unsupported device type\"}'\n\t\texit 1\n\tfi\n\n\t# Install mender update\n\tmender install \"${update_url}\"\t\n\n# This is a rugix install, update to the latest version\nelif command -v rugix-ctrl &> /dev/null\nthen\n\tboot_flow=$(rugix-ctrl system info 2> /dev/null| jq -r \".boot.bootFlow\")\n\n\t# This is a mender flashed device that has already been migrated to rugix\n\t# Provide it with an update artifact that supports the legacy mender partition layout and boot flow\n\tif [[ \"${boot_flow}\" == \"mender-grub\" ]]\n\tthen\n\t\tupdate_url=\"${download_prefix}/umbrelos-amd64-legacy.update\"\n\tfi\n\n\t# This a native rugix flashed amd64 device\n\tif [[ \"${boot_flow}\" == \"grub\" ]]\n\tthen\n\t\tupdate_url=\"${download_prefix}/umbrelos-amd64.update\"\n\tfi\n\n\t# This is a Raspberry Pi device\n\tif [[  \"${boot_flow}\" == \"tryboot\" ]]\n\tthen\n\t\tupdate_url=\"${download_prefix}/umbrelos-pi.update\"\n\tfi\n\n\tif [[ \"${update_url}\" == \"\" ]]\n\tthen\n\t\techo umbrel-update: '{\"error\": \"Unsupported boot flow\"}'\n\t\texit 1\n\tfi\n\n\trugix-ctrl update install --reboot deferred \"${update_url}\"\nelse\n\techo umbrel-update: '{\"error\": \"No supported update mechanism found\"}'\n\texit 1\nfi\n\n\n"
  }
]