├── .gitattributes
├── .github
└── workflows
│ ├── ci.yml
│ ├── create-release-on-tag.yml
│ └── update-translations-in-pr.yml
├── .gitignore
├── .prettierrc.js
├── .umbrel
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── containers
├── app-auth
│ ├── .dockerignore
│ ├── .gitignore
│ ├── Dockerfile
│ ├── README.md
│ ├── bin
│ │ └── www
│ ├── middleware
│ │ ├── handle_error.js
│ │ └── validate_token.js
│ ├── package.json
│ ├── routes
│ │ └── auth.js
│ ├── test
│ │ ├── docker-compose.yml
│ │ ├── fixtures
│ │ │ └── app-data
│ │ │ │ └── mempool
│ │ │ │ └── umbrel-app.yml
│ │ ├── global.js
│ │ ├── test.sh
│ │ └── utils
│ │ │ └── hmac.js
│ ├── utils
│ │ ├── app.js
│ │ ├── const.js
│ │ ├── dashboard.js
│ │ ├── express.js
│ │ ├── hmac.js
│ │ ├── host_resolution.js
│ │ ├── manager.js
│ │ ├── safe_handler.js
│ │ └── token.js
│ ├── views
│ │ └── pages
│ │ │ └── redirect.ejs
│ └── yarn.lock
├── app-proxy
│ ├── .dockerignore
│ ├── .gitignore
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ ├── README.md
│ ├── bin
│ │ └── www
│ ├── middleware
│ │ └── handle_error.js
│ ├── package.json
│ ├── routes
│ │ └── umbrel.js
│ ├── test
│ │ ├── .gitignore
│ │ ├── docker-compose.app1.yml
│ │ ├── docker-compose.app2.yml
│ │ ├── docker-compose.bleskomat.yml
│ │ ├── docker-compose.error.yml
│ │ ├── docker-compose.mempool.yml
│ │ ├── docker-compose.nextcloud.yml
│ │ ├── docker-compose.proxy.yml
│ │ ├── docker-compose.proxyhttps.yaml
│ │ ├── docker-compose.sse.yml
│ │ ├── docker-compose.suredbits.yml
│ │ ├── docker-compose.ws.yml
│ │ ├── docker-compose.yml
│ │ ├── fixtures
│ │ │ └── mempool-umbrel-app.yml
│ │ ├── global.js
│ │ ├── sse-test-server
│ │ │ ├── .dockerignore
│ │ │ ├── Dockerfile
│ │ │ ├── bin
│ │ │ │ └── www
│ │ │ ├── package.json
│ │ │ └── yarn.lock
│ │ ├── test.sh
│ │ ├── test
│ │ │ └── Caddyfile-https
│ │ └── utils
│ │ │ ├── express.js
│ │ │ └── tor.js
│ ├── utils
│ │ ├── const.js
│ │ ├── express.js
│ │ ├── hmac.js
│ │ ├── manager.js
│ │ ├── proxy.js
│ │ ├── safe_handler.js
│ │ ├── token.js
│ │ └── tor.js
│ ├── views
│ │ └── pages
│ │ │ └── error.ejs
│ └── yarn.lock
└── tor
│ ├── Dockerfile
│ ├── README.md
│ └── test
│ ├── .gitignore
│ ├── docker-compose.entrypoint.yml
│ ├── docker-compose.yml
│ ├── entrypoint.sh
│ ├── test-entrypoint.sh
│ ├── test.sh
│ └── torrc
├── info.json
├── package-lock.json
├── package.json
├── packages
├── os
│ ├── .gitignore
│ ├── build-steps
│ │ ├── initialize.sh
│ │ ├── setup-raspberrypi.sh
│ │ └── setup-raspberrypi
│ │ │ ├── cmdline.txt
│ │ │ ├── config.txt
│ │ │ ├── raspberrypi.gpg.key
│ │ │ └── raspberrypi.list
│ ├── build.sh
│ ├── builder.Dockerfile
│ ├── mender.cfg
│ ├── overlay-amd64
│ │ └── etc
│ │ │ └── repart.d
│ │ │ ├── 1-esp.conf
│ │ │ ├── 2-root-a.conf
│ │ │ ├── 3-root-b.conf
│ │ │ └── 4-data.conf
│ ├── overlay-arm64
│ │ ├── etc
│ │ │ ├── rugpi
│ │ │ │ └── ctrl.toml
│ │ │ └── systemd
│ │ │ │ └── system
│ │ │ │ ├── multi-user.target.wants
│ │ │ │ └── umbrel-external-storage.service
│ │ │ │ └── umbrel-external-storage.service
│ │ └── opt
│ │ │ └── umbrel-external-storage
│ │ │ └── umbrel-external-storage
│ ├── overlay-common
│ │ ├── etc
│ │ │ ├── NetworkManager
│ │ │ │ ├── NetworkManager.conf
│ │ │ │ └── conf.d
│ │ │ │ │ └── 10-cloudflaredns.conf
│ │ │ ├── acpi
│ │ │ │ ├── events
│ │ │ │ │ └── power-button
│ │ │ │ └── power-button.sh
│ │ │ ├── fstab
│ │ │ ├── hostname
│ │ │ ├── hosts
│ │ │ ├── issue
│ │ │ ├── mender
│ │ │ │ └── artifact_info
│ │ │ ├── motd
│ │ │ ├── sudoers.d
│ │ │ │ └── umbrel
│ │ │ ├── sudoers.lecture
│ │ │ └── systemd
│ │ │ │ ├── logind.conf.d
│ │ │ │ ├── lid-switch.conf
│ │ │ │ └── power-button.conf
│ │ │ │ ├── system
│ │ │ │ ├── multi-user.target.wants
│ │ │ │ │ ├── umbrel-dns-sync.service
│ │ │ │ │ ├── umbrel-ssh-host-key-hydration.service
│ │ │ │ │ ├── umbrel-tty-message.service
│ │ │ │ │ └── umbrel.service
│ │ │ │ ├── umbrel-dns-sync.service
│ │ │ │ ├── umbrel-ssh-host-key-hydration.service
│ │ │ │ ├── umbrel-tty-message.service
│ │ │ │ └── umbrel.service
│ │ │ │ └── timesyncd.conf.d
│ │ │ │ └── cloudflare.conf
│ │ ├── opt
│ │ │ ├── umbrel-dns-sync
│ │ │ │ └── umbrel-dns-sync
│ │ │ ├── umbrel-ssh-host-key-hydration
│ │ │ │ └── umbrel-ssh-host-key-hydration
│ │ │ └── umbrel-tty-message
│ │ │ │ └── umbrel-tty-message
│ │ └── umbrelOS
│ ├── package-lock.json
│ ├── package.json
│ ├── rugpi
│ │ ├── .gitignore
│ │ ├── layers
│ │ │ ├── umbrelos-base.toml
│ │ │ └── umbrelos-rugpi.toml
│ │ ├── recipes
│ │ │ ├── fix-overlay
│ │ │ │ ├── files
│ │ │ │ │ ├── .gitignore
│ │ │ │ │ └── .gitkeep
│ │ │ │ ├── recipe.toml
│ │ │ │ └── steps
│ │ │ │ │ └── 00-install.sh
│ │ │ ├── mender-update-module
│ │ │ │ ├── files
│ │ │ │ │ ├── reboot
│ │ │ │ │ ├── rugpi-image
│ │ │ │ │ └── rugpi-reboot-override.conf
│ │ │ │ ├── recipe.toml
│ │ │ │ └── steps
│ │ │ │ │ ├── 00-packages
│ │ │ │ │ └── 01-install.sh
│ │ │ ├── patch-reboot
│ │ │ │ ├── files
│ │ │ │ │ └── reboot
│ │ │ │ ├── recipe.toml
│ │ │ │ └── steps
│ │ │ │ │ └── 00-install.sh
│ │ │ ├── setup-rugpi
│ │ │ │ ├── files
│ │ │ │ │ └── state-data.toml
│ │ │ │ ├── recipe.toml
│ │ │ │ └── steps
│ │ │ │ │ └── 00-install.sh
│ │ │ ├── umbrelos-cleanup
│ │ │ │ ├── recipe.toml
│ │ │ │ └── steps
│ │ │ │ │ └── 00-install.sh
│ │ │ └── umbrelos-prepare
│ │ │ │ ├── recipe.toml
│ │ │ │ └── steps
│ │ │ │ └── 00-install.sh
│ │ ├── rugpi-bakery.toml
│ │ └── run-bakery
│ ├── umbrelos.Dockerfile
│ └── usb-installer
│ │ ├── .gitignore
│ │ ├── build.sh
│ │ ├── builder.Dockerfile
│ │ ├── overlay
│ │ ├── etc
│ │ │ └── systemd
│ │ │ │ └── system
│ │ │ │ └── custom-tty.service
│ │ └── opt
│ │ │ └── custom-tty
│ │ ├── run.sh
│ │ └── usb-installer.Dockerfile
├── ui
│ ├── .cursorignore
│ ├── .dockerignore
│ ├── .eslintrc.cjs
│ ├── .github
│ │ └── workflows
│ │ │ └── playwright.yml
│ ├── .gitignore
│ ├── .nvmrc
│ ├── .prettierignore
│ ├── .prettierrc.js
│ ├── .vscode
│ │ ├── extensions.json
│ │ └── settings.json
│ ├── Dockerfile
│ ├── README.md
│ ├── app-auth
│ │ ├── README.md
│ │ ├── index.html
│ │ ├── src
│ │ │ ├── login-with-umbrel.tsx
│ │ │ └── main.tsx
│ │ └── vite.config.ts
│ ├── components.json
│ ├── index.html
│ ├── package.json
│ ├── playwright.config.ts
│ ├── pnpm-lock.yaml
│ ├── postcss.config.js
│ ├── public
│ │ ├── favicon
│ │ │ ├── android-chrome-192x192.png
│ │ │ ├── android-chrome-512x512.png
│ │ │ ├── apple-touch-icon.png
│ │ │ ├── favicon-16x16.png
│ │ │ ├── favicon-32x32.png
│ │ │ └── favicon.ico
│ │ ├── figma-exports
│ │ │ ├── app-icon-placeholder.svg
│ │ │ ├── dock-app-store.png
│ │ │ ├── dock-files.png
│ │ │ ├── dock-home.png
│ │ │ ├── dock-live-usage.png
│ │ │ ├── dock-preview.png
│ │ │ ├── dock-settings.png
│ │ │ ├── dock-widgets.png
│ │ │ ├── migrate-raspberrypi-umbrel-home.png
│ │ │ ├── migrate-umbrel-home-umbrel-home.png
│ │ │ ├── system-docker.svg
│ │ │ ├── system-generic-device.svg
│ │ │ ├── system-pi.svg
│ │ │ ├── system-umbrel-home.png
│ │ │ ├── system-widget-cpu.svg
│ │ │ ├── system-widget-memory.svg
│ │ │ ├── system-widget-storage.svg
│ │ │ ├── system-widget-temperature.svg
│ │ │ ├── umbrel-app.svg
│ │ │ ├── umbrel-home-certifications.svg
│ │ │ ├── umbrel-home-device-info-grain.png
│ │ │ └── umbrel-ios.png
│ │ ├── locales
│ │ │ ├── de.json
│ │ │ ├── en.json
│ │ │ ├── es.json
│ │ │ ├── fr.json
│ │ │ ├── hu.json
│ │ │ ├── it.json
│ │ │ ├── ja.json
│ │ │ ├── ko.json
│ │ │ ├── nl.json
│ │ │ ├── pt.json
│ │ │ ├── tr.json
│ │ │ └── uk.json
│ │ ├── site.webmanifest
│ │ └── wallpapers
│ │ │ ├── 1.jpg
│ │ │ ├── 10.jpg
│ │ │ ├── 11.jpg
│ │ │ ├── 12.jpg
│ │ │ ├── 13.jpg
│ │ │ ├── 14.jpg
│ │ │ ├── 15.jpg
│ │ │ ├── 16.jpg
│ │ │ ├── 17.jpg
│ │ │ ├── 18.jpg
│ │ │ ├── 19.jpg
│ │ │ ├── 2.jpg
│ │ │ ├── 20.jpg
│ │ │ ├── 21.jpg
│ │ │ ├── 3.jpg
│ │ │ ├── 4.jpg
│ │ │ ├── 5.jpg
│ │ │ ├── 6.jpg
│ │ │ ├── 7.jpg
│ │ │ ├── 8.jpg
│ │ │ ├── 9.jpg
│ │ │ ├── generated-small
│ │ │ ├── 1.jpg
│ │ │ ├── 10.jpg
│ │ │ ├── 11.jpg
│ │ │ ├── 12.jpg
│ │ │ ├── 13.jpg
│ │ │ ├── 14.jpg
│ │ │ ├── 15.jpg
│ │ │ ├── 16.jpg
│ │ │ ├── 17.jpg
│ │ │ ├── 18.jpg
│ │ │ ├── 19.jpg
│ │ │ ├── 2.jpg
│ │ │ ├── 20.jpg
│ │ │ ├── 21.jpg
│ │ │ ├── 3.jpg
│ │ │ ├── 4.jpg
│ │ │ ├── 5.jpg
│ │ │ ├── 6.jpg
│ │ │ ├── 7.jpg
│ │ │ ├── 8.jpg
│ │ │ └── 9.jpg
│ │ │ └── generated-thumbs
│ │ │ ├── 1.jpg
│ │ │ ├── 10.jpg
│ │ │ ├── 11.jpg
│ │ │ ├── 12.jpg
│ │ │ ├── 13.jpg
│ │ │ ├── 14.jpg
│ │ │ ├── 15.jpg
│ │ │ ├── 16.jpg
│ │ │ ├── 17.jpg
│ │ │ ├── 18.jpg
│ │ │ ├── 19.jpg
│ │ │ ├── 2.jpg
│ │ │ ├── 20.jpg
│ │ │ ├── 21.jpg
│ │ │ ├── 3.jpg
│ │ │ ├── 4.jpg
│ │ │ ├── 5.jpg
│ │ │ ├── 6.jpg
│ │ │ ├── 7.jpg
│ │ │ ├── 8.jpg
│ │ │ └── 9.jpg
│ ├── resize-wallpapers.sh
│ ├── run.mjs
│ ├── src
│ │ ├── assets
│ │ │ ├── README.md
│ │ │ ├── caret-right.tsx
│ │ │ ├── chevron-down.tsx
│ │ │ ├── tor-icon.tsx
│ │ │ ├── tor-icon2.tsx
│ │ │ ├── umbrel-logo.tsx
│ │ │ └── widget-check-icon.tsx
│ │ ├── components
│ │ │ ├── app-icon.tsx
│ │ │ ├── cmdk-providers.tsx
│ │ │ ├── cmdk.tsx
│ │ │ ├── darken-layer.tsx
│ │ │ ├── fade-scroller
│ │ │ │ ├── index.css
│ │ │ │ └── index.tsx
│ │ │ ├── iframe-checker.tsx
│ │ │ ├── install-button-connected.tsx
│ │ │ ├── install-button.tsx
│ │ │ ├── markdown.tsx
│ │ │ ├── progress-button.tsx
│ │ │ ├── reload-page-button.tsx
│ │ │ └── ui
│ │ │ │ ├── alert.tsx
│ │ │ │ ├── animated-number.tsx
│ │ │ │ ├── arc.tsx
│ │ │ │ ├── button-link.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── copy-button.tsx
│ │ │ │ ├── copyable-field.tsx
│ │ │ │ ├── cover-message.tsx
│ │ │ │ ├── debug-only.tsx
│ │ │ │ ├── dialog-close-button.tsx
│ │ │ │ ├── error-boundary-card-fallback.tsx
│ │ │ │ ├── error-boundary-component-fallback.tsx
│ │ │ │ ├── error-boundary-page-fallback.tsx
│ │ │ │ ├── fade-in-img.tsx
│ │ │ │ ├── generic-error-text.tsx
│ │ │ │ ├── icon-button-link.tsx
│ │ │ │ ├── icon-button.tsx
│ │ │ │ ├── icon.tsx
│ │ │ │ ├── immersive-dialog.tsx
│ │ │ │ ├── list.tsx
│ │ │ │ ├── loading.tsx
│ │ │ │ ├── notification-badge.tsx
│ │ │ │ ├── numbered-list.tsx
│ │ │ │ ├── pin-input.tsx
│ │ │ │ ├── segmented-control.tsx
│ │ │ │ └── toast.tsx
│ │ ├── constants
│ │ │ ├── index.ts
│ │ │ └── links.ts
│ │ ├── features
│ │ │ └── files
│ │ │ │ ├── assets
│ │ │ │ ├── add-folder-icon.tsx
│ │ │ │ ├── apps-icon.tsx
│ │ │ │ ├── caret-right.tsx
│ │ │ │ ├── chevron-left.tsx
│ │ │ │ ├── chevron-right.tsx
│ │ │ │ ├── copy-icon.tsx
│ │ │ │ ├── cursor-text-icon.tsx
│ │ │ │ ├── empty-folder-icon.tsx
│ │ │ │ ├── external-storage-icon.tsx
│ │ │ │ ├── file-items-thumbnails
│ │ │ │ │ ├── ai.svg
│ │ │ │ │ ├── audio.svg
│ │ │ │ │ ├── csv.svg
│ │ │ │ │ ├── dmg.svg
│ │ │ │ │ ├── docx.svg
│ │ │ │ │ ├── ebook.svg
│ │ │ │ │ ├── exe.svg
│ │ │ │ │ ├── image.svg
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── iso.svg
│ │ │ │ │ ├── pdf.svg
│ │ │ │ │ ├── ppt.svg
│ │ │ │ │ ├── psd.svg
│ │ │ │ │ ├── txt.svg
│ │ │ │ │ ├── unknown.svg
│ │ │ │ │ ├── video.svg
│ │ │ │ │ └── zip.svg
│ │ │ │ ├── flame-icon.tsx
│ │ │ │ ├── grid-layout-icon.tsx
│ │ │ │ ├── home-icon.tsx
│ │ │ │ ├── list-layout-icon.tsx
│ │ │ │ ├── recents-icon.tsx
│ │ │ │ ├── search-icon.tsx
│ │ │ │ ├── shared-folder-badge.tsx
│ │ │ │ ├── sharing-info-platforms
│ │ │ │ │ ├── ios.png
│ │ │ │ │ ├── macos.png
│ │ │ │ │ └── windows.png
│ │ │ │ └── trash-icon.tsx
│ │ │ │ ├── cmdk-search-provider.tsx
│ │ │ │ ├── components
│ │ │ │ ├── dialogs
│ │ │ │ │ ├── external-storage-unsupported-dialog
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── permanently-delete-confirmation-dialog
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ └── share-info-dialog
│ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ ├── platform-instructions
│ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ ├── inline-copyable-field.tsx
│ │ │ │ │ │ ├── instruction.tsx
│ │ │ │ │ │ ├── ios-instructions.tsx
│ │ │ │ │ │ ├── macos-instructions.tsx
│ │ │ │ │ │ └── windows-instructions.tsx
│ │ │ │ │ │ ├── platform-selector.tsx
│ │ │ │ │ │ └── share-toggle.tsx
│ │ │ │ ├── file-viewer
│ │ │ │ │ ├── audio-viewer
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── downloader
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── image-viewer
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── pdf-viewer
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── video-viewer
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ └── viewer-wrapper.tsx
│ │ │ │ ├── files-dnd-wrapper
│ │ │ │ │ ├── files-dnd-overlay.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── floating-islands
│ │ │ │ │ ├── audio-island
│ │ │ │ │ │ ├── equalizer.tsx
│ │ │ │ │ │ ├── expanded.tsx
│ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ └── minimized.tsx
│ │ │ │ │ ├── operations-island
│ │ │ │ │ │ ├── expanded.tsx
│ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ └── minimized.tsx
│ │ │ │ │ └── uploading-island
│ │ │ │ │ │ ├── expanded.tsx
│ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ └── minimized.tsx
│ │ │ │ ├── listing
│ │ │ │ │ ├── actions-bar
│ │ │ │ │ │ ├── actions-bar-context.tsx
│ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ ├── mobile-actions.tsx
│ │ │ │ │ │ ├── navigation-controls.tsx
│ │ │ │ │ │ ├── path-bar
│ │ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ │ ├── path-bar-desktop.tsx
│ │ │ │ │ │ │ ├── path-bar-mobile.tsx
│ │ │ │ │ │ │ └── path-input.tsx
│ │ │ │ │ │ ├── search-input.tsx
│ │ │ │ │ │ ├── sort-dropdown.tsx
│ │ │ │ │ │ └── view-toggle.tsx
│ │ │ │ │ ├── apps-listing
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── directory-listing
│ │ │ │ │ │ ├── empty-state.tsx
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── file-item
│ │ │ │ │ │ ├── circular-progress.tsx
│ │ │ │ │ │ ├── editable-name.tsx
│ │ │ │ │ │ ├── icons-view-file-item.tsx
│ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ ├── list-view-file-item.css
│ │ │ │ │ │ └── list-view-file-item.tsx
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── listing-and-file-item-context-menu.tsx
│ │ │ │ │ ├── listing-body.tsx
│ │ │ │ │ ├── marquee-selection.tsx
│ │ │ │ │ ├── recents-listing
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── search-listing
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── trash-listing
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ └── virtualized-list.tsx
│ │ │ │ ├── shared
│ │ │ │ │ ├── circular-progress.tsx
│ │ │ │ │ ├── drag-and-drop.tsx
│ │ │ │ │ ├── file-item-icon
│ │ │ │ │ │ ├── animated-folder-icon.tsx
│ │ │ │ │ │ ├── embedded-overlay-icons.tsx
│ │ │ │ │ │ ├── folder-icon.tsx
│ │ │ │ │ │ ├── index.tsx
│ │ │ │ │ │ └── unknown-file-thumbnail.tsx
│ │ │ │ │ ├── file-upload-drop-zone.tsx
│ │ │ │ │ └── upload-input.tsx
│ │ │ │ └── sidebar
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ ├── mobile-sidebar-wrapper.tsx
│ │ │ │ │ ├── sidebar-apps.tsx
│ │ │ │ │ ├── sidebar-external-storage-item.tsx
│ │ │ │ │ ├── sidebar-external-storage.tsx
│ │ │ │ │ ├── sidebar-favorites.tsx
│ │ │ │ │ ├── sidebar-home.tsx
│ │ │ │ │ ├── sidebar-item.tsx
│ │ │ │ │ ├── sidebar-recents.tsx
│ │ │ │ │ ├── sidebar-shares.tsx
│ │ │ │ │ └── sidebar-trash.tsx
│ │ │ │ ├── constants.ts
│ │ │ │ ├── hooks
│ │ │ │ ├── use-drag-and-drop.ts
│ │ │ │ ├── use-external-storage.ts
│ │ │ │ ├── use-favorites.ts
│ │ │ │ ├── use-files-keyboard-shortcuts.ts
│ │ │ │ ├── use-files-operations.ts
│ │ │ │ ├── use-home-directory-name.ts
│ │ │ │ ├── use-is-touch-device.ts
│ │ │ │ ├── use-item-click.ts
│ │ │ │ ├── use-list-directory.ts
│ │ │ │ ├── use-list-recents.ts
│ │ │ │ ├── use-navigate.ts
│ │ │ │ ├── use-new-folder.ts
│ │ │ │ ├── use-preferences.ts
│ │ │ │ ├── use-search-files.ts
│ │ │ │ └── use-shares.ts
│ │ │ │ ├── index.tsx
│ │ │ │ ├── routes.tsx
│ │ │ │ ├── store
│ │ │ │ ├── slices
│ │ │ │ │ ├── clipboard-slice.ts
│ │ │ │ │ ├── drag-and-drop-slice.ts
│ │ │ │ │ ├── file-viewer-slice.ts
│ │ │ │ │ ├── new-folder-slice.ts
│ │ │ │ │ ├── rename-slice.ts
│ │ │ │ │ └── selection-slice.ts
│ │ │ │ └── use-files-store.ts
│ │ │ │ ├── types.ts
│ │ │ │ ├── utils
│ │ │ │ ├── format-filesystem-date.ts
│ │ │ │ ├── format-filesystem-name.ts
│ │ │ │ ├── format-filesystem-size.ts
│ │ │ │ ├── get-item-key.ts
│ │ │ │ ├── is-directory-an-external-drive-partition.ts
│ │ │ │ └── sort-filesystem-items.ts
│ │ │ │ └── widgets.tsx
│ │ ├── hooks
│ │ │ ├── use-2fa.ts
│ │ │ ├── use-app-install.ts
│ │ │ ├── use-apps-with-updates.ts
│ │ │ ├── use-auto-height-animation.tsx
│ │ │ ├── use-color-thief.ts
│ │ │ ├── use-cpu-temperature.ts
│ │ │ ├── use-cpu.ts
│ │ │ ├── use-debug-install-random-apps.ts
│ │ │ ├── use-demo-progress.ts
│ │ │ ├── use-device-info.ts
│ │ │ ├── use-disk.ts
│ │ │ ├── use-is-externaldns.ts
│ │ │ ├── use-is-mobile.ts
│ │ │ ├── use-is-umbrel-home.tsx
│ │ │ ├── use-language.ts
│ │ │ ├── use-launch-app.ts
│ │ │ ├── use-local-storage2.ts
│ │ │ ├── use-memory.ts
│ │ │ ├── use-notifications.ts
│ │ │ ├── use-password.ts
│ │ │ ├── use-query-params.ts
│ │ │ ├── use-scroll-restoration.ts
│ │ │ ├── use-settings-notification-count.ts
│ │ │ ├── use-software-update.ts
│ │ │ ├── use-temperature-unit.ts
│ │ │ ├── use-tor-enabled.ts
│ │ │ ├── use-update-all-apps.ts
│ │ │ ├── use-user-name.ts
│ │ │ ├── use-version.ts
│ │ │ └── use-widgets.ts
│ │ ├── index.css
│ │ ├── init.tsx
│ │ ├── layouts
│ │ │ ├── README.md
│ │ │ ├── app-store.tsx
│ │ │ ├── bare
│ │ │ │ ├── bare-page.tsx
│ │ │ │ ├── bare.tsx
│ │ │ │ └── shared.tsx
│ │ │ ├── demo-layout.tsx
│ │ │ ├── desktop.tsx
│ │ │ └── sheet.tsx
│ │ ├── main.tsx
│ │ ├── modules
│ │ │ ├── app-store
│ │ │ │ ├── app-page
│ │ │ │ │ ├── about-section.tsx
│ │ │ │ │ ├── app-content.tsx
│ │ │ │ │ ├── app-settings-dialog.tsx
│ │ │ │ │ ├── default-credentials-dialog.tsx
│ │ │ │ │ ├── dependencies.tsx
│ │ │ │ │ ├── get-recommendations.ts
│ │ │ │ │ ├── info-section.tsx
│ │ │ │ │ ├── recommendations-section.tsx
│ │ │ │ │ ├── release-notes-section.tsx
│ │ │ │ │ ├── settings-section.tsx
│ │ │ │ │ ├── shared.tsx
│ │ │ │ │ └── top-header.tsx
│ │ │ │ ├── app-store-nav.tsx
│ │ │ │ ├── community-app-store-dialog.tsx
│ │ │ │ ├── constants.ts
│ │ │ │ ├── discover
│ │ │ │ │ ├── apps-grid-section.tsx
│ │ │ │ │ ├── apps-row-section.tsx
│ │ │ │ │ └── apps-three-column-section.tsx
│ │ │ │ ├── gallery-section.tsx
│ │ │ │ ├── os-update-required.tsx
│ │ │ │ ├── select-dependencies-dialog.tsx
│ │ │ │ ├── shared.tsx
│ │ │ │ ├── updates-button.tsx
│ │ │ │ ├── updates-dialog.tsx
│ │ │ │ └── utils.ts
│ │ │ ├── auth
│ │ │ │ ├── ensure-backend-available.tsx
│ │ │ │ ├── ensure-logged-in.tsx
│ │ │ │ ├── ensure-user-exists.tsx
│ │ │ │ ├── redirects.tsx
│ │ │ │ ├── shared.ts
│ │ │ │ └── use-auth.tsx
│ │ │ ├── bare
│ │ │ │ ├── alert.tsx
│ │ │ │ ├── failed-layout.tsx
│ │ │ │ ├── progress-layout.tsx
│ │ │ │ ├── progress.tsx
│ │ │ │ ├── shared.tsx
│ │ │ │ └── success-layout.tsx
│ │ │ ├── community-app-store
│ │ │ │ └── community-badge.tsx
│ │ │ ├── desktop
│ │ │ │ ├── app-grid
│ │ │ │ │ ├── app-grid.tsx
│ │ │ │ │ ├── app-pagination-utils.tsx
│ │ │ │ │ └── paginator.tsx
│ │ │ │ ├── app-icon.tsx
│ │ │ │ ├── blur-below-dock.tsx
│ │ │ │ ├── desktop-content.tsx
│ │ │ │ ├── desktop-context-menu.tsx
│ │ │ │ ├── desktop-misc.tsx
│ │ │ │ ├── desktop-preview-basic.tsx
│ │ │ │ ├── desktop-preview.tsx
│ │ │ │ ├── dock-item.tsx
│ │ │ │ ├── dock.tsx
│ │ │ │ ├── greeting-message.ts
│ │ │ │ ├── header.tsx
│ │ │ │ ├── install-first-app.tsx
│ │ │ │ ├── logout-dialog.tsx
│ │ │ │ ├── uninstall-confirmation-dialog.tsx
│ │ │ │ └── uninstall-these-first-dialog.tsx
│ │ │ ├── floating-island
│ │ │ │ ├── bare-island.tsx
│ │ │ │ └── container.tsx
│ │ │ ├── immersive-picker
│ │ │ │ └── index.tsx
│ │ │ ├── migrate
│ │ │ │ ├── migrate-image.tsx
│ │ │ │ └── migrate-inner.tsx
│ │ │ ├── sheet-top-fixed.tsx
│ │ │ ├── widgets
│ │ │ │ ├── four-stats-widget.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── list-emoji-widget.tsx
│ │ │ │ ├── list-widget.tsx
│ │ │ │ ├── shared
│ │ │ │ │ ├── backdrop-blur-context.tsx
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── shared.tsx
│ │ │ │ │ ├── stat-text.tsx
│ │ │ │ │ ├── tabler-icon.tsx
│ │ │ │ │ └── widget-wrapper.tsx
│ │ │ │ ├── text-with-buttons-widget.tsx
│ │ │ │ ├── text-with-progress-widget.tsx
│ │ │ │ ├── three-stats-widget.tsx
│ │ │ │ └── two-stats-with-guage-widget.tsx
│ │ │ └── wifi
│ │ │ │ ├── desktop-wifi-button-connected.tsx
│ │ │ │ ├── icon.tsx
│ │ │ │ ├── wifi-drawer-or-dialog.tsx
│ │ │ │ ├── wifi-item-content.tsx
│ │ │ │ └── wifi-list-row-connected-description.tsx
│ │ ├── providers
│ │ │ ├── apps.tsx
│ │ │ ├── available-apps.tsx
│ │ │ ├── confirmation
│ │ │ │ ├── confirmation-context.tsx
│ │ │ │ ├── confirmation-provider.tsx
│ │ │ │ ├── generic-confirmation-dialog.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── use-confirmation.ts
│ │ │ ├── global-files.tsx
│ │ │ ├── global-system-state
│ │ │ │ ├── index.tsx
│ │ │ │ ├── migrate.tsx
│ │ │ │ ├── reset.tsx
│ │ │ │ ├── restart.tsx
│ │ │ │ ├── shutdown.tsx
│ │ │ │ └── update.tsx
│ │ │ ├── language.tsx
│ │ │ ├── prefetch.tsx
│ │ │ ├── sheet-sticky-header.tsx
│ │ │ └── wallpaper.tsx
│ │ ├── router.tsx
│ │ ├── routes
│ │ │ ├── README.md
│ │ │ ├── app-store
│ │ │ │ ├── app-page
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── category-page.tsx
│ │ │ │ ├── discover.tsx
│ │ │ │ └── use-discover-query.tsx
│ │ │ ├── community-app-store
│ │ │ │ ├── app-page
│ │ │ │ │ └── index.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── edit-widgets
│ │ │ │ ├── index.tsx
│ │ │ │ └── widget-selector.tsx
│ │ │ ├── factory-reset
│ │ │ │ ├── _components
│ │ │ │ │ ├── confirm-with-password.tsx
│ │ │ │ │ ├── misc.tsx
│ │ │ │ │ ├── review-data.tsx
│ │ │ │ │ └── success.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── live-usage.tsx
│ │ │ ├── login.tsx
│ │ │ ├── not-found.tsx
│ │ │ ├── notifications.tsx
│ │ │ ├── onboarding
│ │ │ │ ├── account-created.tsx
│ │ │ │ ├── create-account.tsx
│ │ │ │ └── index.tsx
│ │ │ └── settings
│ │ │ │ ├── 2fa-disable.tsx
│ │ │ │ ├── 2fa-enable.tsx
│ │ │ │ ├── 2fa.tsx
│ │ │ │ ├── _components
│ │ │ │ ├── app-store-preferences-content.tsx
│ │ │ │ ├── cpu-card-content.tsx
│ │ │ │ ├── cpu-temperature-card-content.tsx
│ │ │ │ ├── device-info-content.tsx
│ │ │ │ ├── device-info-umbrel-home.tsx
│ │ │ │ ├── language-dropdown.tsx
│ │ │ │ ├── list-row.tsx
│ │ │ │ ├── memory-card-content.tsx
│ │ │ │ ├── no-forgot-password-message.tsx
│ │ │ │ ├── progress-card-content.tsx
│ │ │ │ ├── settings-content-mobile.tsx
│ │ │ │ ├── settings-content.tsx
│ │ │ │ ├── settings-summary.tsx
│ │ │ │ ├── shared.tsx
│ │ │ │ ├── software-update-list-row.tsx
│ │ │ │ ├── storage-card-content.tsx
│ │ │ │ └── wallpaper-picker.tsx
│ │ │ │ ├── advanced.tsx
│ │ │ │ ├── app-store-preferences.tsx
│ │ │ │ ├── change-name.tsx
│ │ │ │ ├── change-password.tsx
│ │ │ │ ├── device-info.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── migration-assistant.tsx
│ │ │ │ ├── mobile
│ │ │ │ ├── account.tsx
│ │ │ │ ├── app-store-preferences.tsx
│ │ │ │ ├── device-info.tsx
│ │ │ │ ├── language.tsx
│ │ │ │ ├── software-update.tsx
│ │ │ │ ├── start-migration-drawer-or-dialog.tsx
│ │ │ │ ├── tor.tsx
│ │ │ │ └── wallpaper.tsx
│ │ │ │ ├── restart.tsx
│ │ │ │ ├── shutdown.tsx
│ │ │ │ ├── software-update-confirm.tsx
│ │ │ │ ├── terminal
│ │ │ │ ├── _shared.tsx
│ │ │ │ ├── app.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── umbrelos.tsx
│ │ │ │ ├── tor.tsx
│ │ │ │ ├── troubleshoot
│ │ │ │ ├── _shared.tsx
│ │ │ │ ├── app.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── umbrelos.tsx
│ │ │ │ ├── wifi-unsupported.tsx
│ │ │ │ └── wifi.tsx
│ │ ├── shadcn-components
│ │ │ └── ui
│ │ │ │ ├── alert-dialog.tsx
│ │ │ │ ├── alert.tsx
│ │ │ │ ├── badge.tsx
│ │ │ │ ├── button-styles.css
│ │ │ │ ├── button.tsx
│ │ │ │ ├── checkbox.tsx
│ │ │ │ ├── command.tsx
│ │ │ │ ├── context-menu.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── drawer.tsx
│ │ │ │ ├── dropdown-menu.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── label.tsx
│ │ │ │ ├── pagination.tsx
│ │ │ │ ├── popover.tsx
│ │ │ │ ├── progress.tsx
│ │ │ │ ├── radio-group.tsx
│ │ │ │ ├── scroll-area.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ ├── shared
│ │ │ │ ├── dialog.ts
│ │ │ │ └── menu.ts
│ │ │ │ ├── sheet-scroll-area.tsx
│ │ │ │ ├── sheet.tsx
│ │ │ │ ├── switch.tsx
│ │ │ │ ├── table.tsx
│ │ │ │ ├── tabs.tsx
│ │ │ │ └── tooltip.tsx
│ │ ├── shadcn-lib
│ │ │ └── utils.ts
│ │ ├── trpc
│ │ │ ├── loading-indicator.tsx
│ │ │ ├── trpc-provider.tsx
│ │ │ └── trpc.ts
│ │ ├── types.d.ts
│ │ └── utils
│ │ │ ├── call-every-interval.ts
│ │ │ ├── date-time.ts
│ │ │ ├── dialog.ts
│ │ │ ├── element-classes.ts
│ │ │ ├── i18n.ts
│ │ │ ├── language.ts
│ │ │ ├── logs.ts
│ │ │ ├── misc.ts
│ │ │ ├── number.ts
│ │ │ ├── pretty-bytes.ts
│ │ │ ├── search.ts
│ │ │ ├── seconds-to-eta.ts
│ │ │ ├── system.ts
│ │ │ ├── temperature.ts
│ │ │ ├── tw.ts
│ │ │ └── wifi.ts
│ ├── stories
│ │ ├── .env.example
│ │ ├── README.md
│ │ ├── index.html
│ │ ├── src
│ │ │ ├── components
│ │ │ │ ├── index.tsx
│ │ │ │ ├── story-links.tsx
│ │ │ │ └── wallpaper-dropdown.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── main.tsx
│ │ │ ├── router.tsx
│ │ │ └── routes
│ │ │ │ ├── demo
│ │ │ │ ├── one.tsx
│ │ │ │ └── two.tsx
│ │ │ │ ├── login-test.tsx
│ │ │ │ └── stories
│ │ │ │ ├── app-store.tsx
│ │ │ │ ├── cmdk.tsx
│ │ │ │ ├── color-thief.tsx
│ │ │ │ ├── cover.tsx
│ │ │ │ ├── desktop.tsx
│ │ │ │ ├── dialogs.tsx
│ │ │ │ ├── error.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── markdown.tsx
│ │ │ │ ├── migrate.tsx
│ │ │ │ ├── misc.tsx
│ │ │ │ ├── settings.tsx
│ │ │ │ ├── sheet.tsx
│ │ │ │ ├── tailwind.tsx
│ │ │ │ ├── trpc.tsx
│ │ │ │ ├── widgets.tsx
│ │ │ │ └── wifi.tsx
│ │ └── vite.config.ts
│ ├── tailwind.config.ts
│ ├── tests-examples
│ │ └── demo-todo-app.spec.ts
│ ├── tests
│ │ ├── example.spec.ts
│ │ ├── happy-path.spec.ts
│ │ └── misc.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ ├── update-translations.js
│ └── vite.config.ts
└── umbreld
│ ├── .gitignore
│ ├── .prettierignore
│ ├── package-lock.json
│ ├── package.json
│ ├── scripts
│ └── validate-manifests.ts
│ ├── source
│ ├── cli.ts
│ ├── constants.ts
│ ├── index.ts
│ └── modules
│ │ ├── apps
│ │ ├── app-repository.integration.test.ts
│ │ ├── app-repository.ts
│ │ ├── app-store.ts
│ │ ├── app.ts
│ │ ├── apps.ts
│ │ ├── legacy-compat
│ │ │ ├── app-environment.ts
│ │ │ ├── app-script
│ │ │ ├── app-script.ts
│ │ │ ├── bin
│ │ │ │ ├── bitcoin-cli
│ │ │ │ └── lncli
│ │ │ ├── docker-compose.app_proxy.yml
│ │ │ ├── docker-compose.common.yml
│ │ │ ├── docker-compose.tor.yml
│ │ │ ├── docker-compose.yml
│ │ │ ├── tor-entrypoint.sh
│ │ │ ├── tor-proxy-torrc
│ │ │ └── tor-server-torrc
│ │ └── schema.ts
│ │ ├── blacklist-uas
│ │ └── blacklist-uas.ts
│ │ ├── cli-client.ts
│ │ ├── dbus
│ │ └── dbus.ts
│ │ ├── development.ts
│ │ ├── event-bus
│ │ ├── event-bus.ts
│ │ └── routes.ts
│ │ ├── factory-reset.ts
│ │ ├── files
│ │ ├── api.download.integration.test.ts
│ │ ├── api.thumbnail.integration.test.ts
│ │ ├── api.ts
│ │ ├── api.upload.integration.test.ts
│ │ ├── api.view.integration.test.ts
│ │ ├── archive.integration.test.ts
│ │ ├── archive.ts
│ │ ├── external-storage.integration.test.ts
│ │ ├── external-storage.ts
│ │ ├── favorites.integration.test.ts
│ │ ├── favorites.ts
│ │ ├── files.copy.integration.test.ts
│ │ ├── files.createDirectory.integration.test.ts
│ │ ├── files.delete.test.ts
│ │ ├── files.emptyTrash.test.ts
│ │ ├── files.list.integration.test.ts
│ │ ├── files.move.integration.test.ts
│ │ ├── files.operationProgress.test.ts
│ │ ├── files.preferences.integration.test.ts
│ │ ├── files.rename.integration.test.ts
│ │ ├── files.restore.test.ts
│ │ ├── files.trash.test.ts
│ │ ├── files.ts
│ │ ├── fixtures
│ │ │ └── thumbnails
│ │ │ │ ├── master-lossless-image.png
│ │ │ │ ├── master-lossless-video.mkv
│ │ │ │ └── multipage-pdf.pdf
│ │ ├── recents.test.ts
│ │ ├── recents.ts
│ │ ├── routes.ts
│ │ ├── samba.integration.test.ts
│ │ ├── samba.ts
│ │ ├── search.integration.test.ts
│ │ ├── search.ts
│ │ ├── thumbnails.integration.test.ts
│ │ ├── thumbnails.ts
│ │ ├── watcher.ts
│ │ └── widgets.ts
│ │ ├── is-umbrel-home.ts
│ │ ├── jwt.ts
│ │ ├── migration.ts
│ │ ├── migration
│ │ ├── index.ts
│ │ └── migration.integration.test.ts
│ │ ├── notifications
│ │ ├── notifications.integration.test.ts
│ │ ├── notifications.ts
│ │ └── routes.ts
│ │ ├── server
│ │ ├── index.ts
│ │ ├── terminal-socket.ts
│ │ └── trpc
│ │ │ ├── common.ts
│ │ │ ├── context.ts
│ │ │ ├── index.ts
│ │ │ ├── is-authenticated.ts
│ │ │ ├── routes
│ │ │ ├── app-store.integration.test.ts
│ │ │ ├── app-store.ts
│ │ │ ├── apps.integration.test.ts
│ │ │ ├── apps.ts
│ │ │ ├── migration.ts
│ │ │ ├── system.integration.test.ts
│ │ │ ├── system.ts
│ │ │ ├── user.integration.test.ts
│ │ │ ├── user.ts
│ │ │ ├── widget.integration.test.ts
│ │ │ ├── widget.ts
│ │ │ └── wifi.ts
│ │ │ ├── trpc.ts
│ │ │ └── websocket-logger.ts
│ │ ├── system-widgets.ts
│ │ ├── system.ts
│ │ ├── system.unit.test.ts
│ │ ├── test-utilities
│ │ ├── create-test-umbreld.ts
│ │ ├── fixtures
│ │ │ ├── another-community-repo
│ │ │ │ ├── another-sparkles-hello-world
│ │ │ │ │ ├── docker-compose.yml
│ │ │ │ │ └── umbrel-app.yml
│ │ │ │ └── umbrel-app-store.yml
│ │ │ └── community-repo
│ │ │ │ ├── app-with-invalid-id
│ │ │ │ ├── docker-compose.yml
│ │ │ │ └── umbrel-app.yml
│ │ │ │ ├── app-with-invalid-manifest
│ │ │ │ ├── docker-compose.yml
│ │ │ │ └── umbrel-app.yml
│ │ │ │ ├── sparkles-hello-world
│ │ │ │ ├── docker-compose.yml
│ │ │ │ └── umbrel-app.yml
│ │ │ │ └── umbrel-app-store.yml
│ │ └── run-git-server.ts
│ │ ├── update.ts
│ │ ├── user.ts
│ │ └── utilities
│ │ ├── dependencies.ts
│ │ ├── docker-pull.ts
│ │ ├── file-store.integration.test.ts
│ │ ├── file-store.ts
│ │ ├── get-directory-size.ts
│ │ ├── get-or-create-file.ts
│ │ ├── logger.ts
│ │ ├── random-token.ts
│ │ ├── regexp.ts
│ │ ├── run-every.ts
│ │ ├── temporary-directory.ts
│ │ └── totp.ts
│ ├── tsconfig.json
│ └── umbreld
└── scripts
├── data-export
├── install
├── umbrel-dev
└── update-script
/.gitattributes:
--------------------------------------------------------------------------------
1 | # On Windows, Git defaults to checkout Windows-style and commit Unix-style.
2 | # As such, line endings of bash scripts are converted from LF to CRLF, with
3 | # the effect that when mounting the checkout into a Linux container, the
4 | # bash scripts can't execute because bash does not handle CR. To mitigate,
5 | # conservatively force ALL line endings to be retained.
6 | * -text
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore node_modules anywhere they may be
2 |
3 | node_modules
4 |
5 | # Ignore all the bash stuff
6 |
7 | .bash_history
8 | .bash_logout
9 | .bashrc
10 | .profile
11 | .ssh
12 | .viminfo
13 | .DS_Store
14 |
15 | # Python bytecode
16 | __pycache__
17 | *.py[cod]
18 |
19 | # umbrel-dev
20 | docker-compose.override.yml
21 |
22 | # Files and data directories created by services
23 | # that we shouldn't accidently commit
24 |
25 | *.dat
26 | *.log
27 | *.cookie
28 | *.pid
29 | *.env
30 | bitcoin/*
31 | db/*
32 | electrs/*
33 | nginx/*
34 | events/signals/*
35 | lnd/*
36 | logs/*
37 | statuses/*
38 | tor/*
39 | app-data/*
40 | data/
41 |
42 | # Commit these files
43 |
44 | !statuses/update-status.json
45 |
46 | # Commit these empty directories
47 |
48 | !db/.gitkeep
49 | !events/signals/.gitkeep
50 | !lnd/.gitkeep
51 | !logs/.gitkeep
52 | !tor/data/.gitkeep
53 | !tor/run/.gitkeep
54 | .umbrel-dev
55 | jwt
56 | ./bin
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('prettier').Config}
3 | */
4 | export default {
5 | "printWidth": 120,
6 | "semi": false,
7 | "useTabs": true,
8 | "trailingComma": "all",
9 | "singleQuote": true,
10 | "bracketSpacing": false,
11 | "jsxSingleQuote": true,
12 | }
13 |
--------------------------------------------------------------------------------
/.umbrel:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/.umbrel
--------------------------------------------------------------------------------
/containers/app-auth/.dockerignore:
--------------------------------------------------------------------------------
1 | Dockerfile
2 | node_modules
3 | .git
4 | .github
5 | test
6 | dist
7 | *.log
--------------------------------------------------------------------------------
/containers/app-auth/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 | # Log files
6 | npm-debug.log*
7 | yarn-debug.log*
8 | yarn-error.log*
9 |
10 | # Editor directories and files
11 | .idea
12 | .vscode
13 | *.suo
14 | *.ntvs*
15 | *.njsproj
16 | *.sln
17 | *.sw?
18 |
19 | # Local dev env
20 | .env.development
21 |
22 | # Local todo file
23 | .todo
24 |
--------------------------------------------------------------------------------
/containers/app-auth/bin/www:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const cookieParser = require("cookie-parser");
4 | const express = require('express');
5 | const { StatusCodes } = require('http-status-codes');
6 |
7 | const authRoutes = require('../routes/auth.js');
8 |
9 | const handleErrorMiddleware = require('../middleware/handle_error.js');
10 | const CONSTANTS = require('../utils/const.js');
11 |
12 | const app = express();
13 |
14 | app.disable('x-powered-by');
15 | app.set('view engine', 'ejs');
16 |
17 | app.use(cookieParser(CONSTANTS.UMBREL_AUTH_SECRET));
18 | app.use('/', authRoutes);
19 |
20 | app.use(handleErrorMiddleware);
21 | app.use((req, res) => {
22 | res.status(StatusCodes.NOT_FOUND).json();
23 | });
24 |
25 | app.listen(CONSTANTS.PORT, () => {
26 | console.log(`Listening on port: ${CONSTANTS.PORT}`);
27 | });
--------------------------------------------------------------------------------
/containers/app-auth/middleware/handle_error.js:
--------------------------------------------------------------------------------
1 | function handleError(error, req, res, next) {
2 | var statusCode = error.statusCode || 500;
3 | var route = req.url || '';
4 | var message = error.message || '';
5 |
6 | res.status(statusCode).json(message);
7 | }
8 |
9 | module.exports = handleError;
10 |
--------------------------------------------------------------------------------
/containers/app-auth/test/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | auth:
5 | image: getumbrel/app-auth1
6 | user: "1000:1000"
7 | build:
8 | context: ..
9 | dockerfile: Dockerfile.dev
10 | ports:
11 | - "2001:2000"
12 | environment:
13 | PORT: 2000
14 | UMBREL_AUTH_SECRET: umbrel
15 | MANAGER_IP: $MANAGER_IP
16 | MANAGER_PORT: 3006
17 | volumes:
18 | - ..:/app
19 | - ./fixtures/tor/data:/var/lib/tor:ro
20 | - ./fixtures/app-data:/app-data:ro
21 |
22 | networks:
23 | default:
24 | external:
25 | name: umbrel_main_network
--------------------------------------------------------------------------------
/containers/app-auth/test/global.js:
--------------------------------------------------------------------------------
1 | const chai = require('chai');
2 | const chaiHttp = require('chai-http');
3 |
4 | chai.use(chaiHttp);
5 | chai.should();
6 |
7 | global.expect = chai.expect;
8 | global.assert = chai.assert;
9 |
10 | before(() => {
11 |
12 | });
13 |
14 | global.reset = () => {
15 |
16 | };
17 |
18 | after(() => {
19 |
20 | });
21 |
--------------------------------------------------------------------------------
/containers/app-auth/test/test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | export MANAGER_IP="10.21.21.4"
4 |
5 | docker-compose up
--------------------------------------------------------------------------------
/containers/app-auth/test/utils/hmac.js:
--------------------------------------------------------------------------------
1 | const hmac = require("../../utils/hmac.js");
2 |
3 | describe('hmac', () => {
4 | it('should sign the message', () => {
5 | assert.equal("4oCtD/Y2Xfb8J/tvCw9mrsRmMekbirseumiW4JrFahI=", hmac.sign("hello world", "my-secret-123"));
6 | assert.equal("qENA22U3zGY2ZhBPh9Tes+Fjt/SS8pjvL6d2Z0ZMTJk=", hmac.sign("https://xkcd.com/386/", "my-secret-123"));
7 |
8 | assert.equal("+fSwPHNg4EtsNpso1Iope7g3A7pPbTZubygel/9WdYc=", hmac.sign("https://xkcd.com/386/", "another-secret"));
9 | });
10 |
11 | it('should verify the signature for a message', () => {
12 | assert.isTrue(hmac.verify("https://xkcd.com/386/", "my-secret-123", "qENA22U3zGY2ZhBPh9Tes+Fjt/SS8pjvL6d2Z0ZMTJk="));
13 | assert.isTrue(hmac.verify("https://xkcd.com/386/", "another-secret", "+fSwPHNg4EtsNpso1Iope7g3A7pPbTZubygel/9WdYc="));
14 |
15 | assert.isFalse(hmac.verify("https://xkcd.com/386/something/random", "another-secret", "+fSwPHNg4EtsNpso1Iope7g3A7pPbTZubygel/9WdYc="));
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/containers/app-auth/utils/app.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs').promises;
2 | const path = require('path');
3 | const yaml = require('js-yaml');
4 |
5 | const CONSTANTS = require('./const.js');
6 |
7 | async function getBasicInfo(app){
8 | try {
9 | const manifestFile = path.join(CONSTANTS.APP_DATA_PATH, app, 'umbrel-app.yml');
10 | const manifestYaml = await fs.readFile(manifestFile, "utf-8");
11 | const manifest = yaml.load(manifestYaml, 'utf8');
12 |
13 | return {
14 | id: manifest.id,
15 | name: manifest.name
16 | };
17 | } catch(e) {
18 | throw new Error("App not found");
19 | }
20 | }
21 |
22 | // App IDs are only allowed
23 | // Alpha-numeric characters with hyphens
24 | function sanitiseId(appId){
25 | return appId.replace(/[^a-zA-Z0-9-]/g, "");
26 | }
27 |
28 | module.exports = {
29 | getBasicInfo,
30 | sanitiseId
31 | };
--------------------------------------------------------------------------------
/containers/app-auth/utils/const.js:
--------------------------------------------------------------------------------
1 | function readFromEnvOrTerminate(key) {
2 | const value = process.env[key];
3 |
4 | if(typeof(value) !== "string" || value.trim().length === 0) {
5 | console.error(`The env. variable '${key}' is not set. Terminating...`);
6 |
7 | process.exit(0);
8 | }
9 |
10 | return value;
11 | }
12 |
13 | module.exports = Object.freeze({
14 | UMBREL_COOKIE_NAME: "UMBREL_SESSION",
15 |
16 | LOG_LEVEL: process.env.LOG_LEVEL || "info",
17 |
18 | PORT: parseInt(process.env.PORT) || 2000,
19 |
20 | UMBREL_AUTH_SECRET: readFromEnvOrTerminate("UMBREL_AUTH_SECRET"),
21 |
22 | TOR_PATH: process.env.TOR_PATH || "/var/lib/tor",
23 | APP_DATA_PATH: process.env.APP_DATA_PATH || "/app-data",
24 |
25 | MANAGER_IP: readFromEnvOrTerminate("MANAGER_IP"),
26 | MANAGER_PORT: parseInt(readFromEnvOrTerminate("MANAGER_PORT")),
27 |
28 | DASHBOARD_IP: readFromEnvOrTerminate("DASHBOARD_IP"),
29 | DASHBOARD_PORT: parseInt(readFromEnvOrTerminate("DASHBOARD_PORT")),
30 | });
--------------------------------------------------------------------------------
/containers/app-auth/utils/dashboard.js:
--------------------------------------------------------------------------------
1 | const axios = require('axios');
2 | const package = require('../package.json');
3 |
4 | const CONSTANTS = require('./const.js');
5 |
6 | const axiosInstance = axios.create({
7 | baseURL: `http://${CONSTANTS.DASHBOARD_IP}:${CONSTANTS.DASHBOARD_PORT}`,
8 | headers: {
9 | common: {
10 | "User-Agent": `${package.name}/${package.version}`
11 | }
12 | }
13 | });
14 |
15 | const wallpaper = {
16 | get: async function(filename) {
17 | return axiosInstance({
18 | method: 'GET',
19 | url: `/wallpapers/${filename}`,
20 | responseType: 'stream'
21 | });
22 | }
23 | };
24 |
25 | module.exports = {
26 | wallpaper
27 | };
--------------------------------------------------------------------------------
/containers/app-auth/utils/express.js:
--------------------------------------------------------------------------------
1 | function getQueryParam(req, key) {
2 | const value = req.query[key];
3 |
4 | if(typeof(value) !== "string" || value.trim().length == 0) {
5 | throw new Error(`'${key}' is missing`);
6 | }
7 |
8 | return value;
9 | }
10 |
11 | module.exports = {
12 | getQueryParam
13 | };
--------------------------------------------------------------------------------
/containers/app-auth/utils/hmac.js:
--------------------------------------------------------------------------------
1 | const crypto = require('crypto');
2 |
3 | function sign(input, secret) {
4 | return crypto
5 | .createHmac('sha256', secret)
6 | .update(input)
7 | .digest('base64');
8 | };
9 |
10 | function verify(input, secret, signature){
11 | const inputSignature = Buffer.from( sign(input, secret) );
12 | const testSignature = Buffer.from( signature );
13 |
14 | return inputSignature.length === testSignature.length && crypto.timingSafeEqual(inputSignature, testSignature);
15 | };
16 |
17 | module.exports = {
18 | sign,
19 | verify
20 | };
--------------------------------------------------------------------------------
/containers/app-auth/utils/manager.js:
--------------------------------------------------------------------------------
1 | const axios = require('axios');
2 | const package = require('../package.json');
3 |
4 | const CONSTANTS = require('./const.js');
5 |
6 | const axiosInstance = axios.create({
7 | baseURL: `http://${CONSTANTS.MANAGER_IP}:${CONSTANTS.MANAGER_PORT}`,
8 | headers: {
9 | common: {
10 | "User-Agent": `${package.name}/${package.version}`
11 | }
12 | }
13 | });
14 |
15 | const account = {
16 | login: async function(body) {
17 | return axiosInstance.post('/v1/account/login', body);
18 | },
19 | token: async function(token) {
20 | return axiosInstance.get('/v1/account/token', {
21 | params: {
22 | token
23 | }
24 | });
25 | },
26 | wallpaper: async function(token) {
27 | return axiosInstance.get('/v1/account/wallpaper');
28 | }
29 | };
30 |
31 | module.exports = {
32 | account
33 | };
--------------------------------------------------------------------------------
/containers/app-auth/utils/safe_handler.js:
--------------------------------------------------------------------------------
1 | // this safe handler is used to wrap our api methods
2 | // so that we always fallback and return an exception if there is an error
3 | // inside of an async function
4 | // Mostly copied from vault/server/utils/safeHandler.js
5 | function safeHandler(handler) {
6 | return async (req, res, next) => {
7 | try {
8 | return await handler(req, res, next);
9 | } catch (err) {
10 | return next(err);
11 | }
12 | };
13 | }
14 |
15 | module.exports = safeHandler;
16 |
--------------------------------------------------------------------------------
/containers/app-auth/utils/token.js:
--------------------------------------------------------------------------------
1 | const jwt = require("jsonwebtoken");
2 |
3 | const JWT_ALGORITHM = "HS256";
4 |
5 | const secret = process.env.JWT_SECRET;
6 |
7 | function validate(token) {
8 | if (typeof token !== "string") return false;
9 |
10 | console.log(`Validating token: ${token.substr(0, 12)} ...`);
11 |
12 | const payload = jwt.verify(token, secret, {
13 | algorithms: [JWT_ALGORITHM],
14 | });
15 |
16 | return payload.proxyToken === true;
17 | }
18 |
19 | module.exports = {
20 | validate,
21 | };
22 |
--------------------------------------------------------------------------------
/containers/app-auth/views/pages/redirect.ejs:
--------------------------------------------------------------------------------
1 | <!DOCTYPE html>
2 | <html>
3 | <head>
4 | <meta charset="utf-8">
5 | <meta name="viewport" content="width=device-width, initial-scale=1">
6 | <title>Redirecting...</title>
7 | </head>
8 | <body onload="document.forms[0].submit();">
9 | <form method="POST" action="<%- url %>">
10 | <% for (var key in params ) { %>
11 | <input type="hidden" name="<%- key %>" value="<%- params[key] %>">
12 | <% } %>
13 | </form>
14 | </body>
15 | </html>
--------------------------------------------------------------------------------
/containers/app-proxy/.dockerignore:
--------------------------------------------------------------------------------
1 | Dockerfile
2 | node_modules
3 | .git
4 | .github
5 | test
--------------------------------------------------------------------------------
/containers/app-proxy/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 | # Log files
6 | npm-debug.log*
7 | yarn-debug.log*
8 | yarn-error.log*
9 |
10 | # Editor directories and files
11 | .idea
12 | .vscode
13 | *.suo
14 | *.ntvs*
15 | *.njsproj
16 | *.sln
17 | *.sw?
18 |
19 | # Local dev env
20 | .env.development
21 |
22 | # Local todo file
23 | .todo
24 |
--------------------------------------------------------------------------------
/containers/app-proxy/Dockerfile:
--------------------------------------------------------------------------------
1 | # Build Stage
2 | FROM node:16-buster-slim AS umbrel-app-proxy-builder
3 |
4 | # Create app directory
5 | WORKDIR /app
6 |
7 | # Copy 'yarn.lock' and 'package.json'
8 | COPY yarn.lock package.json ./
9 |
10 | # Install dependencies
11 | RUN yarn install --production
12 |
13 | # Copy project files and folders to the current working directory (i.e. '/app')
14 | COPY . .
15 |
16 | # Final image
17 | FROM node:16-buster-slim AS umbrel-app-proxy
18 |
19 | # Copy built code from build stage to '/app' directory
20 | COPY --from=umbrel-app-proxy-builder /app /app
21 |
22 | # Change directory to '/app'
23 | WORKDIR /app
24 |
25 | CMD [ "yarn", "start" ]
26 |
--------------------------------------------------------------------------------
/containers/app-proxy/Dockerfile.dev:
--------------------------------------------------------------------------------
1 | FROM node:16-buster-slim
2 |
3 | # make the 'app' folder the current working directory
4 | WORKDIR /app
5 |
6 | ENTRYPOINT ["bash"]
7 | CMD ["-c", "yarn && yarn start"]
--------------------------------------------------------------------------------
/containers/app-proxy/middleware/handle_error.js:
--------------------------------------------------------------------------------
1 | function handleError(error, req, res, next) {
2 | var statusCode = error.statusCode || 500;
3 | var route = req.url || '';
4 | var message = error.message || '';
5 |
6 | res.status(statusCode).json(message);
7 | }
8 |
9 | module.exports = handleError;
10 |
--------------------------------------------------------------------------------
/containers/app-proxy/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "app-proxy",
3 | "version": "0.0.1",
4 | "private": true,
5 | "scripts": {
6 | "lint": "eslint",
7 | "start": "node ./bin/www",
8 | "test": "mocha 'test/**/*.js'",
9 | "coverage": "nyc --all mocha 'test/**/*.js'",
10 | "postcoverage": "codecov",
11 | "build": "docker buildx build --platform linux/amd64,linux/arm64 --tag getumbrel/app-proxy ."
12 | },
13 | "dependencies": {
14 | "axios": "^0.26.1",
15 | "cookie-parser": "^1.4.6",
16 | "dotenv": "^16.0.0",
17 | "ejs": "^3.1.6",
18 | "express": "^4.17.3",
19 | "express-validator": "^6.14.0",
20 | "http-proxy-middleware": "^2.0.4",
21 | "http-status-codes": "^2.2.0",
22 | "js-yaml": "^4.1.0",
23 | "jsonwebtoken": "^9.0.2",
24 | "wait-port": "^0.2.9"
25 | },
26 | "devDependencies": {
27 | "babel-eslint": "^10.1.0",
28 | "chai": "^4.1.2",
29 | "chai-http": "^4.2.0",
30 | "codecov": "^3.7.1",
31 | "eslint": "^7.0.0",
32 | "mocha": "^7.1.2",
33 | "node-mocks-http": "^1.11.0",
34 | "nyc": "15.0.1"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/containers/app-proxy/test/.gitignore:
--------------------------------------------------------------------------------
1 | apps/
--------------------------------------------------------------------------------
/containers/app-proxy/test/docker-compose.app1.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | app_proxy:
5 | environment:
6 | APP_HOST: nginxdemo
7 | APP_PORT: 80
8 | PROXY_AUTH_WHITELIST: "*"
9 | PROXY_AUTH_BLACKLIST: "/admin/*,/admin2/*"
10 | nginxdemo:
11 | image: nginxdemos/hello
--------------------------------------------------------------------------------
/containers/app-proxy/test/docker-compose.app2.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | app_proxy:
5 | environment:
6 | APP_HOST: frontend
7 | APP_PORT: 8888
8 | frontend:
9 | image: mendhak/http-https-echo
10 | environment:
11 | HTTP_PORT: 8888
--------------------------------------------------------------------------------
/containers/app-proxy/test/docker-compose.error.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | app_proxy:
5 | environment:
6 | APP_HOST: app_wrong
7 | APP_PORT: 80
8 | PROXY_AUTH_WHITELIST: "*"
9 | app:
10 | image: nginxdemos/hello
--------------------------------------------------------------------------------
/containers/app-proxy/test/docker-compose.proxy.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | caddy:
5 | image: caddy:2.5.1
6 | command: caddy reverse-proxy --from :4007 --to app_proxy:4000
7 | ports:
8 | - "4007:4007"
9 |
10 | app_proxy:
11 | environment:
12 | APP_HOST: frontend
13 | APP_PORT: 8888
14 | PROXY_AUTH_WHITELIST: "*"
15 | PROXY_TRUST_UPSTREAM: "true"
16 |
17 | frontend:
18 | image: mendhak/http-https-echo
19 | environment:
20 | HTTP_PORT: 8888
--------------------------------------------------------------------------------
/containers/app-proxy/test/docker-compose.proxyhttps.yaml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | caddy:
5 | image: caddy:2.5.1
6 | volumes:
7 | - "./test/Caddyfile-https:/etc/caddy/Caddyfile"
8 | ports:
9 | - "4007:4007"
10 |
11 | app_proxy:
12 | environment:
13 | APP_HOST: frontend
14 | APP_PORT: 8888
15 | PROXY_AUTH_WHITELIST: "*"
16 | PROXY_TRUST_UPSTREAM: "true"
17 |
18 | frontend:
19 | image: mendhak/http-https-echo
20 | environment:
21 | HTTP_PORT: 8888
--------------------------------------------------------------------------------
/containers/app-proxy/test/docker-compose.sse.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | app_proxy:
5 | environment:
6 | APP_HOST: sse_server
7 | APP_PORT: 80
8 | PROXY_AUTH_WHITELIST: "*"
9 | sse_server:
10 | image: getumbrel/sse-test-server
11 | build: ./sse-test-server
--------------------------------------------------------------------------------
/containers/app-proxy/test/docker-compose.ws.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | app_proxy:
5 | environment:
6 | APP_HOST: ws_server
7 | APP_PORT: 8010
8 | ws_server:
9 | image: ksdn117/web-socket-test
--------------------------------------------------------------------------------
/containers/app-proxy/test/global.js:
--------------------------------------------------------------------------------
1 | const chai = require('chai');
2 | const chaiHttp = require('chai-http');
3 |
4 | chai.use(chaiHttp);
5 | chai.should();
6 |
7 | global.expect = chai.expect;
8 | global.assert = chai.assert;
9 |
10 | before(() => {
11 |
12 | });
13 |
14 | global.reset = () => {
15 |
16 | };
17 |
18 | after(() => {
19 |
20 | });
21 |
--------------------------------------------------------------------------------
/containers/app-proxy/test/sse-test-server/.dockerignore:
--------------------------------------------------------------------------------
1 | Dockerfile
2 | node_modules
3 | .git
4 | .github
5 |
--------------------------------------------------------------------------------
/containers/app-proxy/test/sse-test-server/Dockerfile:
--------------------------------------------------------------------------------
1 | # Build Stage
2 | FROM node:16-buster-slim AS umbrel-sse-test-server-builder
3 |
4 | # Create app directory
5 | WORKDIR /app
6 |
7 | # Copy 'yarn.lock' and 'package.json'
8 | COPY yarn.lock package.json ./
9 |
10 | # Install dependencies
11 | RUN yarn install --production
12 |
13 | # Copy project files and folders to the current working directory (i.e. '/app')
14 | COPY . .
15 |
16 | # Final image
17 | FROM node:16-buster-slim AS umbrel-sse-test-server
18 |
19 | # Copy built code from build stage to '/app' directory
20 | COPY --from=umbrel-sse-test-server-builder /app /app
21 |
22 | # Change directory to '/app'
23 | WORKDIR /app
24 |
25 | CMD [ "yarn", "start" ]
26 |
--------------------------------------------------------------------------------
/containers/app-proxy/test/sse-test-server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "umbrel-sse-test-server",
3 | "version": "0.0.1",
4 | "private": true,
5 | "scripts": {
6 | "start": "node ./bin/www"
7 | },
8 | "dependencies": {
9 | "express": "^4.17.3"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/containers/app-proxy/test/test/Caddyfile-https:
--------------------------------------------------------------------------------
1 | https://umbrel-dev.local:4007 {
2 | reverse_proxy app_proxy:4000
3 | tls internal
4 | }
--------------------------------------------------------------------------------
/containers/app-proxy/test/utils/tor.js:
--------------------------------------------------------------------------------
1 | const tor = require("../../utils/tor.js");
2 |
3 | describe('tor', () => {
4 | it('should return the auth HS url', async () => {
5 | const url = await tor.authHsUrl();
6 |
7 | assert.equal("the-auth-hs-url.onion", url);
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/containers/app-proxy/utils/express.js:
--------------------------------------------------------------------------------
1 | function removeCookie(req, cookieName) {
2 | const allCookies = req.headers.cookie || "";
3 |
4 | // Split on '; ' (where space is optional)
5 | // More details re http cookie delimter:
6 | // https://www.rfc-editor.org/rfc/rfc6265#section-4.2.1
7 | const cookiePairs = allCookies.split(/; */g).filter(pair => pair.length > 0);
8 |
9 | // Filter out cookie and re-join
10 | // to build http cookie string
11 | // (using cookie delimiter)
12 | return cookiePairs.filter(pair => ! pair.startsWith(`${cookieName}=`)).join("; ");
13 | }
14 |
15 | module.exports = {
16 | removeCookie
17 | };
--------------------------------------------------------------------------------
/containers/app-proxy/utils/hmac.js:
--------------------------------------------------------------------------------
1 | const crypto = require('crypto');
2 |
3 | function sign(input, secret) {
4 | return crypto
5 | .createHmac('sha256', secret)
6 | .update(input)
7 | .digest('base64');
8 | };
9 |
10 | function verify(input, secret, signature){
11 | const inputSignature = Buffer.from( sign(input, secret) );
12 | const testSignature = Buffer.from( signature );
13 |
14 | return inputSignature.length === testSignature.length && crypto.timingSafeEqual(inputSignature, testSignature);
15 | };
16 |
17 | module.exports = {
18 | sign,
19 | verify
20 | };
--------------------------------------------------------------------------------
/containers/app-proxy/utils/manager.js:
--------------------------------------------------------------------------------
1 | const axios = require('axios');
2 | const package = require('../package.json');
3 |
4 | const CONSTANTS = require('./const.js');
5 |
6 | const axiosInstance = axios.create({
7 | baseURL: `http://${CONSTANTS.MANAGER_IP}:${CONSTANTS.MANAGER_PORT}`,
8 | headers: {
9 | common: {
10 | "User-Agent": `${package.name}/${package.version}`
11 | }
12 | }
13 | });
14 |
15 | const account = {
16 | login: async function(body) {
17 | return axiosInstance.post('/v1/account/login', body);
18 | },
19 | token: async function(token) {
20 | return axiosInstance.get('/v1/account/token', {
21 | params: {
22 | token
23 | }
24 | });
25 | }
26 | };
27 |
28 | module.exports = {
29 | account
30 | };
--------------------------------------------------------------------------------
/containers/app-proxy/utils/safe_handler.js:
--------------------------------------------------------------------------------
1 | // this safe handler is used to wrap our api methods
2 | // so that we always fallback and return an exception if there is an error
3 | // inside of an async function
4 | // Mostly copied from vault/server/utils/safeHandler.js
5 | function safeHandler(handler) {
6 | return async (req, res, next) => {
7 | try {
8 | return await handler(req, res, next);
9 | } catch (err) {
10 | return next(err);
11 | }
12 | };
13 | }
14 |
15 | module.exports = safeHandler;
16 |
--------------------------------------------------------------------------------
/containers/app-proxy/utils/token.js:
--------------------------------------------------------------------------------
1 | const jwt = require("jsonwebtoken");
2 |
3 | const JWT_ALGORITHM = "HS256";
4 |
5 | const secret = process.env.JWT_SECRET;
6 |
7 | function validate(token) {
8 | const payload = jwt.verify(token, secret, {
9 | algorithms: [JWT_ALGORITHM],
10 | });
11 |
12 | return payload.proxyToken === true;
13 | }
14 |
15 | module.exports = {
16 | validate,
17 | };
18 |
--------------------------------------------------------------------------------
/containers/app-proxy/utils/tor.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs").promises;
2 |
3 | const CONSTANTS = require("./const.js");
4 |
5 | async function authHsUrl() {
6 | // Here is technically a race condition
7 | // As the auth hs url may not yet be generated
8 | try {
9 | return (
10 | await fs.readFile(CONSTANTS.UMBREL_AUTH_HIDDEN_SERVICE_FILE, "utf-8")
11 | ).trim();
12 | } catch (e) {
13 | return "not-yet-generated.onion";
14 | }
15 | }
16 |
17 | module.exports = {
18 | authHsUrl,
19 | };
20 |
--------------------------------------------------------------------------------
/containers/tor/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/getumbrel/umbrel-tor)
2 |
3 | [](https://github.com/getumbrel/umbrel-tor/actions?query=workflow%3A"Docker+build+on+push")
4 | [](https://hub.docker.com/repository/registry-1.docker.io/getumbrel/tor/tags?page=1)
5 |
6 |
7 | # ☂️ Tor
8 |
9 | A simple Docker image for Tor
10 |
11 | ## 🛠 Build Tor Docker image
12 |
13 | ### Build
14 | ```sh
15 | docker build -t getumbrel/tor .
16 | ```
17 |
18 | ### Run
19 | ```sh
20 | docker run --rm -u 1000:1000 -e HOME=/tmp getumbrel/tor
21 | ```
--------------------------------------------------------------------------------
/containers/tor/test/.gitignore:
--------------------------------------------------------------------------------
1 | data
--------------------------------------------------------------------------------
/containers/tor/test/docker-compose.entrypoint.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | web:
5 | image: mendhak/http-https-echo
6 | environment:
7 | HTTP_PORT: 8888
8 |
9 | tor:
10 | image: getumbrel/tor
11 | build: ..
12 | user: 1000:1000
13 | environment:
14 | HOME: /tmp
15 | HS_DIR: "web2"
16 | HS_VIRTUAL_PORT: "80"
17 | HS_HOST: "web"
18 | HS_PORT: "8888"
19 | entrypoint: /umbrel/entrypoint.sh
20 | volumes:
21 | - ./data:/data
22 | - ./entrypoint.sh:/umbrel/entrypoint.sh
23 |
--------------------------------------------------------------------------------
/containers/tor/test/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | web:
5 | image: mendhak/http-https-echo
6 | environment:
7 | HTTP_PORT: 8888
8 |
9 | tor:
10 | image: getumbrel/tor
11 | build: ..
12 | user: 1000:1000
13 | environment:
14 | HOME: /tmp
15 | volumes:
16 | - ./torrc:/etc/tor/torrc
17 | - ./data:/data
--------------------------------------------------------------------------------
/containers/tor/test/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | TORRC_PATH="/tmp/torrc"
4 |
5 | echo "HiddenServiceDir /data/${HS_DIR}" > "${TORRC_PATH}"
6 | echo "HiddenServicePort ${HS_VIRTUAL_PORT} ${HS_HOST}:${HS_PORT}" >> "${TORRC_PATH}"
7 |
8 | tor -f "${TORRC_PATH}"
--------------------------------------------------------------------------------
/containers/tor/test/test-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | docker-compose -f docker-compose.entrypoint.yml up --detach web
4 | docker-compose -f docker-compose.entrypoint.yml up --detach tor
5 |
6 | echo
7 | echo "Hostname:"
8 | cat ./data/web2/hostname
9 |
--------------------------------------------------------------------------------
/containers/tor/test/test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | docker-compose up --detach web
4 | docker-compose up --detach tor
5 |
6 | echo
7 | echo "Hostname:"
8 | cat ./data/web/hostname
--------------------------------------------------------------------------------
/containers/tor/test/torrc:
--------------------------------------------------------------------------------
1 | HiddenServiceDir /data/web
2 | HiddenServicePort 80 web:8888
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "umbrel",
3 | "lockfileVersion": 3,
4 | "requires": true,
5 | "packages": {}
6 | }
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module",
3 | "scripts": {
4 | "dev": "./scripts/umbrel-dev",
5 | "dev:help": "npm run dev help"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/packages/os/.gitignore:
--------------------------------------------------------------------------------
1 | build
2 |
--------------------------------------------------------------------------------
/packages/os/build-steps/initialize.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euo pipefail
4 |
5 | rm /etc/apt/sources.list.d/debian.sources
6 |
7 | cat >/etc/apt/sources.list <<EOF
8 | deb http://deb.debian.org/debian bookworm main non-free-firmware
9 | deb-src http://deb.debian.org/debian bookworm main non-free-firmware
10 | deb http://deb.debian.org/debian-security bookworm-security main non-free-firmware
11 | deb-src http://deb.debian.org/debian-security bookworm-security main non-free-firmware
12 | deb http://deb.debian.org/debian bookworm-updates main non-free-firmware
13 | deb-src http://deb.debian.org/debian bookworm-updates main non-free-firmware
14 | EOF
15 |
16 | apt-get update --yes
17 |
18 | # Install systemd
19 | #
20 | # We do this here as the Rasperry Pi setup requires Systemd. Without it, it will not
21 | # realize that it runs inside Docker and complain about missing mountpoints during
22 | # the installation.
23 | apt-get install --yes systemd-sysv
--------------------------------------------------------------------------------
/packages/os/build-steps/setup-raspberrypi/cmdline.txt:
--------------------------------------------------------------------------------
1 | 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
--------------------------------------------------------------------------------
/packages/os/build-steps/setup-raspberrypi/config.txt:
--------------------------------------------------------------------------------
1 | # Enable DRM VC4 V3D driver.
2 | #
3 | # MX: This has been enabled by default and is required for 3D graphics
4 | # hardware acceleration. We just leave it enabled.
5 | dtoverlay=vc4-kms-v3d
6 | max_framebuffers=2
7 |
8 | # We want to run the processor in its 64-bit mode.
9 | arm_64bit=1
10 |
11 | # Enable NVMe interface
12 | # This is not needed if booting with NVMe so potentially
13 | # we only want to support that and don't need this.
14 | dtparam=nvme
15 |
16 | # This may improve NVMe performance but is not stable:
17 | # > The Raspberry Pi 5 is not certified for Gen 3.0 speeds. PCIe Gen 3.0 connections may be unstable.
18 | # - https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#pcie-gen-3-0
19 | # dtparam=pciex1_gen=3
--------------------------------------------------------------------------------
/packages/os/build-steps/setup-raspberrypi/raspberrypi.list:
--------------------------------------------------------------------------------
1 | deb http://archive.raspberrypi.com/debian/ RELEASE main
2 | # Uncomment line below then 'apt-get update' to enable 'apt-get source'
3 | #deb-src http://archive.raspberrypi.com/debian/ RELEASE main
--------------------------------------------------------------------------------
/packages/os/builder.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM debian:bullseye
2 |
3 | RUN apt-get -y update
4 |
5 | # Install os image builder deps
6 | RUN apt-get -y install fdisk gdisk qemu-utils dosfstools tree
7 |
8 | # Install mender-convert
9 | RUN apt-get -y install git
10 | RUN git clone -b 4.0.1 https://github.com/mendersoftware/mender-convert.git /mender
11 | RUN apt-get install -y sudo gdisk $(cat /mender/requirements-deb.txt)
12 | RUN wget -q -O /usr/bin/mender-artifact https://downloads.mender.io/mender-artifact/3.10.0/linux/mender-artifact
13 | RUN chmod +x /usr/bin/mender-artifact
--------------------------------------------------------------------------------
/packages/os/overlay-amd64/etc/repart.d/1-esp.conf:
--------------------------------------------------------------------------------
1 | [Partition]
2 | Type=esp
3 | SizeMinBytes=200M
4 | SizeMaxBytes=200M
--------------------------------------------------------------------------------
/packages/os/overlay-amd64/etc/repart.d/2-root-a.conf:
--------------------------------------------------------------------------------
1 | [Partition]
2 | Type=linux-generic
3 | SizeMinBytes=10G
4 | SizeMaxBytes=10G
--------------------------------------------------------------------------------
/packages/os/overlay-amd64/etc/repart.d/3-root-b.conf:
--------------------------------------------------------------------------------
1 | [Partition]
2 | Type=linux-generic
3 | SizeMinBytes=10G
4 | SizeMaxBytes=10G
--------------------------------------------------------------------------------
/packages/os/overlay-amd64/etc/repart.d/4-data.conf:
--------------------------------------------------------------------------------
1 | [Partition]
2 | Type=linux-generic
3 |
--------------------------------------------------------------------------------
/packages/os/overlay-arm64/etc/rugpi/ctrl.toml:
--------------------------------------------------------------------------------
1 | # The size of the system partitions.
2 | system_size = "5G"
3 | # Persist the writeable overlay of the root filesystem.
4 | overlay = "persist"
5 |
--------------------------------------------------------------------------------
/packages/os/overlay-arm64/etc/systemd/system/multi-user.target.wants/umbrel-external-storage.service:
--------------------------------------------------------------------------------
1 | ../umbrel-external-storage.service
--------------------------------------------------------------------------------
/packages/os/overlay-arm64/etc/systemd/system/umbrel-external-storage.service:
--------------------------------------------------------------------------------
1 | # Umbrel External Storage Mounter
2 | # Installed at /etc/systemd/system/umbrel-external-storage.service
3 |
4 | [Unit]
5 | Description=External Storage Mounter
6 | Before=docker.service umbrel.service
7 |
8 | [Service]
9 | Type=oneshot
10 | Restart=no
11 | ExecStart=/opt/umbrel-external-storage/umbrel-external-storage
12 | TimeoutStartSec=45min
13 | User=root
14 | Group=root
15 | StandardOutput=syslog
16 | StandardError=syslog
17 | SyslogIdentifier=external storage mounter
18 | RemainAfterExit=yes
19 |
20 | [Install]
21 | WantedBy=multi-user.target
22 |
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/NetworkManager/NetworkManager.conf:
--------------------------------------------------------------------------------
1 | [main]
2 | plugins=ifupdown,keyfile
3 |
4 | [ifupdown]
5 | managed=false
6 |
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/NetworkManager/conf.d/10-cloudflaredns.conf:
--------------------------------------------------------------------------------
1 | # This is important, we use Cloudflare for DNS because some users have routers that provide
2 | # unreliable DNS that results in Docker errors when pulling like:
3 | # 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
4 | [global-dns-domain-*]
5 | servers=1.1.1.1,1.0.0.1
6 |
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/acpi/events/power-button:
--------------------------------------------------------------------------------
1 | event=button/power.*PBTN
2 | action=systemd-cat -t umbrel-power-button /etc/acpi/power-button.sh &
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/fstab:
--------------------------------------------------------------------------------
1 | # <device> <dir> <type> <options> <dump> <fsck>
2 | / /mnt/root none bind 0 0
3 | /data/umbrel-os/var/log /var/log none bind 0 0
4 | /data/umbrel-os/var/lib/docker /var/lib/docker none bind 0 0
5 | /data/umbrel-os/home /home none bind 0 0
6 | /data/umbrel-os/var/lib/systemd/timesync /var/lib/systemd/timesync none bind 0 0
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/hostname:
--------------------------------------------------------------------------------
1 | umbrel
2 |
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/hosts:
--------------------------------------------------------------------------------
1 | 127.0.0.1 umbrel
2 | 127.0.0.1 localhost
3 |
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/issue:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/os/overlay-common/etc/issue
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/mender/artifact_info:
--------------------------------------------------------------------------------
1 | artifact_name=umbrelOS
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/motd:
--------------------------------------------------------------------------------
1 |
2 |
3 | ,;###GGGGGGGGGGl#Sp
4 | ,##GGGlW""^' '`""%GGGG#S,
5 | ,#GGG" "lGG#o
6 | #GGl^ '$GG#
7 | ,#GGb \GGG,
8 | lGG" "GGG
9 | #GGGlGGGl##p,,p##lGGl##p,,p###ll##GGGG
10 | !GGGlW"""*GGGGGGG#""""WlGGGGG#W""*WGGGGS
11 | "" "^ '" ""
12 |
13 | - Warning ---------------------------------------------------------------------
14 | | Terminal access is only enabled for debugging purposes. Any modifications |
15 | | made to the umbrelOS system will not be persisted between software updates. |
16 | | For use-cases where you want to run custom software in a Linux environment, |
17 | | consider using the Portainer app available in the Umbrel App Store. |
18 | -------------------------------------------------------------------------------
19 |
20 |
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/sudoers.d/umbrel:
--------------------------------------------------------------------------------
1 | # Remove the silly outdated warning from sudo the first time it's used:
2 | #
3 | # We trust you have received the usual lecture from the local System
4 | # Administrator. It usually boils down to these three things:
5 | #
6 | # #1) Respect the privacy of others.
7 | # #2) Think before you type.
8 | # #3) With great power comes great responsibility.
9 |
10 | Defaults lecture_file = /etc/sudoers.lecture
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/sudoers.lecture:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/os/overlay-common/etc/sudoers.lecture
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/systemd/logind.conf.d/lid-switch.conf:
--------------------------------------------------------------------------------
1 | # Ignore lid switch events to allow users running umbrelOS on laptops to keep the device on when the lid is closed.
2 | # 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.
3 |
4 | [Login]
5 | HandleLidSwitch=ignore
6 | # HandleLidSwitchExternalPower=ignore (optional, HandleLidSwitch is used if not specified)
7 | # HandleLidSwitchDocked=ignore (default is set to ignore)
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/systemd/logind.conf.d/power-button.conf:
--------------------------------------------------------------------------------
1 | # We want logind to ignore the power button press events.
2 | # We will register custom acpi event handlers to handle this
3 | # ourselves.
4 |
5 | [Login]
6 | HandlePowerKey=ignore
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/systemd/system/multi-user.target.wants/umbrel-dns-sync.service:
--------------------------------------------------------------------------------
1 | ../umbrel-dns-sync.service
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/systemd/system/multi-user.target.wants/umbrel-ssh-host-key-hydration.service:
--------------------------------------------------------------------------------
1 | ../umbrel-ssh-host-key-hydration.service
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/systemd/system/multi-user.target.wants/umbrel-tty-message.service:
--------------------------------------------------------------------------------
1 | ../umbrel-tty-message.service
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/systemd/system/multi-user.target.wants/umbrel.service:
--------------------------------------------------------------------------------
1 | ../umbrel.service
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/systemd/system/umbrel-dns-sync.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Synchronize DNS configuration before starting NetworkManager
3 | Before=NetworkManager.service
4 |
5 | [Service]
6 | ExecStart=bash /opt/umbrel-dns-sync/umbrel-dns-sync
7 | Type=oneshot
8 |
9 | [Install]
10 | WantedBy=multi-user.target
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/systemd/system/umbrel-ssh-host-key-hydration.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Hydrate SSH Host Keys
3 | Before=ssh.service
4 |
5 | [Service]
6 | Type=oneshot
7 | ExecStart=/opt/umbrel-ssh-host-key-hydration/umbrel-ssh-host-key-hydration
8 |
9 | [Install]
10 | WantedBy=multi-user.target
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/systemd/system/umbrel-tty-message.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Display Umbrel access information on TTY
3 | After=umbrel.service
4 |
5 | [Service]
6 | ExecStart=/opt/umbrel-tty-message/umbrel-tty-message
7 | Type=oneshot
8 |
9 | [Install]
10 | WantedBy=multi-user.target
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/systemd/system/umbrel.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Umbrel daemon
3 | After=network-online.target docker.service
4 |
5 | [Service]
6 | TimeoutStopSec=15min
7 | ExecStart=umbreld --data-directory=/home/umbrel/umbrel
8 | Restart=always
9 | # This prevents us hitting restart rate limits and ensures we keep restarting
10 | # indefinitely.
11 | StartLimitInterval=0
12 |
13 | [Install]
14 | WantedBy=multi-user.target
15 |
--------------------------------------------------------------------------------
/packages/os/overlay-common/etc/systemd/timesyncd.conf.d/cloudflare.conf:
--------------------------------------------------------------------------------
1 | # We default to Cloudflare for NTP because some users have issues
2 | # connecting to the default Debian ntp pool. If Cloudflare fails
3 | # the Debian ntp pool is still used as a fallback.
4 |
5 | [Time]
6 | NTP=time.cloudflare.com
--------------------------------------------------------------------------------
/packages/os/overlay-common/opt/umbrel-dns-sync/umbrel-dns-sync:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | UMBREL_YAML=/home/umbrel/umbrel/umbrel.yaml
4 |
5 | CLOUDFLARE_CONF=/etc/NetworkManager/conf.d/10-cloudflaredns.conf
6 | CLOUDFLARE_CONF_DISABLED=/etc/NetworkManager/conf.d/10-cloudflaredns.conf.disabled
7 |
8 | # Use CloudFlare DNS unless the setting is explicitly set to `false`
9 | EXTERNAL_DNS=$(yq eval ".settings.externalDns != false" "$UMBREL_YAML" 2>/dev/null || echo "true")
10 |
11 | if [[ "$EXTERNAL_DNS" == "false" ]]; then
12 | if [[ -f "$CLOUDFLARE_CONF" ]]; then
13 | mv -f "$CLOUDFLARE_CONF" "$CLOUDFLARE_CONF_DISABLED" || {
14 | echo "Failed to move $CLOUDFLARE_CONF to $CLOUDFLARE_CONF_DISABLED"
15 | exit 1
16 | }
17 | fi
18 | else
19 | if [[ ! -f "$CLOUDFLARE_CONF" ]]; then
20 | mv -f "$CLOUDFLARE_CONF_DISABLED" "$CLOUDFLARE_CONF" || {
21 | echo "Failed to move $CLOUDFLARE_CONF_DISABLED to $CLOUDFLARE_CONF"
22 | exit 1
23 | }
24 | fi
25 | fi
26 |
--------------------------------------------------------------------------------
/packages/os/overlay-common/opt/umbrel-ssh-host-key-hydration/umbrel-ssh-host-key-hydration:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euo pipefail
4 |
5 | SSH_STATE_DIR=${SSH_STATE_DIR:-"/data/ssh"}
6 |
7 | if [ ! -f "${SSH_STATE_DIR}"/ssh_host_rsa_key ]; then
8 | rm -f /etc/ssh/ssh_host_*_key*
9 | ssh-keygen -A
10 |
11 | # Copy the keys to the data partition.
12 | mkdir -p "${SSH_STATE_DIR}"
13 | cp /etc/ssh/ssh_host_*_key* "${SSH_STATE_DIR}"
14 | fi
15 |
16 | # Restore the keys from the data partition.
17 | cp "${SSH_STATE_DIR}"/ssh_host_*_key* /etc/ssh/
--------------------------------------------------------------------------------
/packages/os/overlay-common/umbrelOS:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/os/overlay-common/umbrelOS
--------------------------------------------------------------------------------
/packages/os/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "build": "./build.sh",
4 | "build:amd64": "SKIP_ARM64=true npm run build",
5 | "build:amd64:usb-installer": "cd usb-installer && ./run.sh",
6 | "build:arm64": "SKIP_AMD64=true npm run build",
7 | "build:pi5": "SKIP_AMD64=true SKIP_PI4=true npm run build"
8 | },
9 | "devDependencies": {
10 | "opentimestamps": "^0.4.9"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/os/rugpi/.gitignore:
--------------------------------------------------------------------------------
1 | /.rugpi
2 | /build
3 |
--------------------------------------------------------------------------------
/packages/os/rugpi/layers/umbrelos-base.toml:
--------------------------------------------------------------------------------
1 | url="file:///build/umbrelos-base.tar"
2 |
--------------------------------------------------------------------------------
/packages/os/rugpi/layers/umbrelos-rugpi.toml:
--------------------------------------------------------------------------------
1 | parent = "umbrelos-base"
2 |
3 | recipes = [
4 | # Prepare umbrelOS base image for Rugpi.
5 | "umbrelos-prepare",
6 | # Install and configure Rugpi.
7 | "setup-rugpi",
8 | # Install the Rugpi mender update module.
9 | "mender-update-module",
10 | # Fix `/etc/hostname` and `/etc/hosts`.
11 | "fix-overlay",
12 | # Patch `/usr/sbin/reboot` to reboot to the spare partion after an update.
13 | "patch-reboot",
14 | ]
15 |
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/fix-overlay/files/.gitignore:
--------------------------------------------------------------------------------
1 | hostname
2 | hosts
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/fix-overlay/files/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/os/rugpi/recipes/fix-overlay/files/.gitkeep
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/fix-overlay/recipe.toml:
--------------------------------------------------------------------------------
1 | description = "fix `/etc/hostname` and `/etc/hosts`"
2 |
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/fix-overlay/steps/00-install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euo pipefail
4 |
5 | install -m 644 "${RECIPE_DIR}/files/hostname" "/etc/"
6 | install -m 644 "${RECIPE_DIR}/files/hosts" "/etc/"
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/mender-update-module/files/reboot:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # Copyright 2023-2024 Silitics GmbH <info@silitics.com>
4 | #
5 | # This file is part of Rugpi (https://rugpi.io).
6 | #
7 | # SPDX-License-Identifier: MIT OR Apache-2.0
8 | #
9 | #
10 | # Why do we need this file?
11 | # =========================
12 | # Unfortunately, we cannot tell Mender to let Rugpi do the reboot itself. Instead, we
13 | # inject this shell script as the `reboot` binary. When Mender reboots, it will use this
14 | # script and we can then handle the reboot via Rugpi here.
15 |
16 | set -euo pipefail
17 |
18 | if [ -f "/run/rugpi/.mender-reboot-spare" ]; then
19 | exec /usr/sbin/reboot "0 tryboot"
20 | else
21 | exec /usr/sbin/reboot "$@"
22 | fi
23 |
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/mender-update-module/files/rugpi-reboot-override.conf:
--------------------------------------------------------------------------------
1 | [Service]
2 | Environment="PATH=/usr/lib/rugpi-mender/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/mender-update-module/recipe.toml:
--------------------------------------------------------------------------------
1 | description = "install Rugpi update module for Mender"
2 |
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/mender-update-module/steps/00-packages:
--------------------------------------------------------------------------------
1 | python3
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/mender-update-module/steps/01-install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euo pipefail
4 |
5 | mkdir -p /data/mender
6 | echo "device_type=raspberrypi" >/data/mender/device_type
7 |
8 | install -D -m 755 "${RECIPE_DIR}/files/reboot" \
9 | -t /usr/lib/rugpi-mender/bin
10 |
11 | install -D -m 755 "${RECIPE_DIR}/files/rugpi-image" \
12 | -t /usr/share/mender/modules/v3
13 |
14 | install -D -m 644 "${RECIPE_DIR}/files/rugpi-reboot-override.conf" \
15 | -t /etc/systemd/system/mender-client.service.d
16 |
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/patch-reboot/files/reboot:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # Copyright 2023-2024 Silitics GmbH <info@silitics.com>
4 | #
5 | # This file is part of Rugpi (https://rugpi.io).
6 | #
7 | # SPDX-License-Identifier: MIT OR Apache-2.0
8 | #
9 | #
10 | # Why do we need this file?
11 | # =========================
12 | # When rebooting after an update, we need to make sure to boot into the spare partition.
13 |
14 | set -euo pipefail
15 |
16 | if [ -f "/run/rugpi/.mender-reboot-spare" ]; then
17 | exec -a /usr/sbin/reboot /bin/systemctl "0 tryboot"
18 | else
19 | exec -a /usr/sbin/reboot /bin/systemctl "$@"
20 | fi
21 |
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/patch-reboot/recipe.toml:
--------------------------------------------------------------------------------
1 | description = "globally patch the `reboot` binary"
2 |
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/patch-reboot/steps/00-install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euo pipefail
4 |
5 | install -D -m 755 "${RECIPE_DIR}/files/reboot" \
6 | -t /usr/sbin
7 |
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/setup-rugpi/files/state-data.toml:
--------------------------------------------------------------------------------
1 | [[persist]]
2 | directory = "/data"
3 |
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/setup-rugpi/recipe.toml:
--------------------------------------------------------------------------------
1 | description = "setup Rugpi for umbrelOS"
2 |
3 | dependencies = [
4 | "core/rugpi-ctrl",
5 | ]
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/setup-rugpi/steps/00-install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euo pipefail
4 |
5 | apt-get install -y fdisk parted
6 |
7 | install -D -m 644 "${RECIPE_DIR}/files/state-data.toml" \
8 | "/etc/rugpi/state/data.toml"
9 |
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/umbrelos-cleanup/recipe.toml:
--------------------------------------------------------------------------------
1 | description = "cleanup and restore original umbrelOS configuration"
2 | priority = -800_000
3 |
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/umbrelos-cleanup/steps/00-install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euo pipefail
4 |
5 | mv /etc/resolv.conf.original /etc/resolv.conf
6 | rm -rf /var/log
7 |
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/umbrelos-prepare/recipe.toml:
--------------------------------------------------------------------------------
1 | description = "prepare umbrelOS base image for Rugpi"
2 | priority = 800_000
3 | dependencies = ["umbrelos-cleanup"]
4 |
--------------------------------------------------------------------------------
/packages/os/rugpi/recipes/umbrelos-prepare/steps/00-install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euo pipefail
4 |
5 | mv /etc/resolv.conf /etc/resolv.conf.original
6 | echo "nameserver 1.1.1.1" > /etc/resolv.conf
7 |
8 | mkdir -p /var/log/apt
9 |
10 | # SystemD uses this file to detect that it runs in Docker. This will prevent `reboot`
11 | # from working as it should and may also lead to a bunch of other problems.
12 | rm -f /.dockerenv
13 |
--------------------------------------------------------------------------------
/packages/os/rugpi/rugpi-bakery.toml:
--------------------------------------------------------------------------------
1 | [images.tryboot]
2 | layer = "umbrelos-rugpi"
3 | include_firmware = "none"
4 |
5 | [images.pi4]
6 | layer = "umbrelos-rugpi"
7 | include_firmware = "pi4"
8 |
--------------------------------------------------------------------------------
/packages/os/rugpi/run-bakery:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -euo pipefail
4 |
5 | DOCKER=${DOCKER:-"docker"}
6 | DOCKER_FLAGS=${DOCKER_FLAGS:-""}
7 |
8 | RUGPI_DEV=${RUGPI_DEV:-"false"}
9 |
10 | if [ "${RUGPI_DEV}" = "false" ]; then
11 | DOCKER_FLAGS="${DOCKER_FLAGS} --pull always"
12 | RUGPI_VERSION=${RUGPI_VERSION:-"v0.6"}
13 | else
14 | RUGPI_VERSION=${RUGPI_VERSION:-"dev"}
15 | fi
16 |
17 | RUGPI_BAKERY_IMAGE=${RUGPI_BAKERY_IMAGE:-"ghcr.io/silitics/rugpi-bakery:${RUGPI_VERSION}"}
18 |
19 | if [ -t 0 ] && [ -t 1 ]; then
20 | DOCKER_FLAGS="${DOCKER_FLAGS} -it"
21 | fi
22 |
23 | exec $DOCKER run --rm --privileged \
24 | $DOCKER_FLAGS \
25 | -v "$(pwd)":/project \
26 | -v /dev:/dev \
27 | "${RUGPI_BAKERY_IMAGE}" \
28 | "$@"
29 |
--------------------------------------------------------------------------------
/packages/os/usb-installer/.gitignore:
--------------------------------------------------------------------------------
1 | build
2 |
--------------------------------------------------------------------------------
/packages/os/usb-installer/build.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 |
4 | rootfs_dir="/tmp/rootfs"
5 | iso_image="/tmp/umbrelos-amd64-usb-installer.iso"
6 |
7 | echo "Creating directories for ISO image..."
8 | mkdir -p "${rootfs_dir}/boot/grub"
9 |
10 | echo "Extracting rootfs..."
11 | tar -xf /data/build/rootfs.tar --directory "${rootfs_dir}"
12 |
13 | echo "Creating grub.cfg..."
14 | cat > "${rootfs_dir}/boot/grub/grub.cfg" <<EOF
15 | set default=0
16 | set timeout=0
17 |
18 | set gfxmode=auto
19 | insmod all_video
20 | insmod gfxterm
21 | terminal_output gfxterm
22 |
23 | menuentry "umbrelOS" {
24 | linux /vmlinuz root=LABEL=UMBRELINSTALLER ro quiet loglevel=0
25 | initrd /initrd.img
26 | }
27 | EOF
28 |
29 | echo "Creating ISO image..."
30 | grub-mkrescue -o "${iso_image}" -volid "UMBRELINSTALLER" "${rootfs_dir}" -- -hfsplus off
31 |
32 | echo "Copying to ./build/..."
33 | mv "${iso_image}" /data/build/
34 |
35 | echo "Done!"
--------------------------------------------------------------------------------
/packages/os/usb-installer/builder.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM debian:bookworm
2 |
3 | RUN apt-get -y update
4 | RUN apt-get -y install grub-common grub-efi xorriso mtools
--------------------------------------------------------------------------------
/packages/os/usb-installer/overlay/etc/systemd/system/custom-tty.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Custom TTY
3 | After=multi-user.target
4 |
5 | [Service]
6 | ExecStart=/opt/custom-tty
7 | StandardInput=tty
8 | StandardOutput=tty
9 | StandardError=tty
10 | TTYPath=/dev/tty1
11 | Restart=on-failure
12 |
13 | [Install]
14 | WantedBy=multi-user.target
15 |
--------------------------------------------------------------------------------
/packages/os/usb-installer/run.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 |
4 | mkdir -p build
5 | docker build -f usb-installer.Dockerfile --platform linux/amd64 -t usb-installer ../
6 | docker export -o build/rootfs.tar $(docker run -d usb-installer /bin/true)
7 | docker build -f builder.Dockerfile --platform linux/amd64 -t usb-installer:builder .
8 | docker run --entrypoint /data/build.sh -v $PWD:/data --privileged --platform linux/amd64 usb-installer:builder
9 |
10 | # Test CD-ROM boot (used by VMs)
11 | # qemu-system-x86_64 -net nic -net user -machine accel=tcg -m 2048 -bios ~/Downloads/OVMF.bin -cdrom umbrelos-amd64-usb-installer.iso
12 |
13 | # Test USB boot (used by physical machines)
14 | # 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
--------------------------------------------------------------------------------
/packages/ui/.cursorignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | dist-ssr
4 | public/generated-tabler-icons
--------------------------------------------------------------------------------
/packages/ui/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .git
3 | .dockerignore
4 | Dockerfile
5 | README.md
6 | npm-debug.log
--------------------------------------------------------------------------------
/packages/ui/.github/workflows/playwright.yml:
--------------------------------------------------------------------------------
1 | name: Playwright Tests
2 | on:
3 | push:
4 | branches: [main, master]
5 | pull_request:
6 | branches: [main, master]
7 | jobs:
8 | test:
9 | timeout-minutes: 60
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - uses: actions/setup-node@v3
14 | with:
15 | node-version: 18
16 | - name: Install dependencies
17 | run: npm install -g pnpm && pnpm install
18 | - name: Install Playwright Browsers
19 | run: pnpm exec playwright install --with-deps
20 | - name: Run Playwright tests
21 | run: pnpm exec playwright test
22 | - uses: actions/upload-artifact@v3
23 | if: always()
24 | with:
25 | name: playwright-report
26 | path: playwright-report/
27 | retention-days: 30
28 |
--------------------------------------------------------------------------------
/packages/ui/.gitignore:
--------------------------------------------------------------------------------
1 | # custom
2 | public/generated-tabler-icons
3 | todo.md
4 |
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | pnpm-debug.log*
12 | lerna-debug.log*
13 |
14 | node_modules
15 | dist
16 | dist-app-auth
17 | dist-ssr
18 | *.local
19 |
20 | # Editor directories and files
21 | !.vscode/extensions.json
22 | .idea
23 | .DS_Store
24 | *.suo
25 | *.ntvs*
26 | *.njsproj
27 | *.sln
28 | *.sw?
29 | /test-results/
30 | /playwright-report/
31 | /blob-report/
32 | /playwright/.cache/
33 |
34 | # UI uses pnpm instead of npm
35 | package-lock.json
36 |
--------------------------------------------------------------------------------
/packages/ui/.nvmrc:
--------------------------------------------------------------------------------
1 | 18
2 |
--------------------------------------------------------------------------------
/packages/ui/.prettierignore:
--------------------------------------------------------------------------------
1 | pnpm-lock.yaml
2 | node_modules
3 | public/locales/*.json
--------------------------------------------------------------------------------
/packages/ui/.prettierrc.js:
--------------------------------------------------------------------------------
1 | import baseConfig from '../../.prettierrc.js'
2 |
3 | /**
4 | * @type {import('prettier').Config & import("@ianvs/prettier-plugin-sort-imports").PluginConfig}
5 | */
6 | export default {
7 | ...baseConfig,
8 | plugins: [
9 | ...(baseConfig.plugins || []),
10 | '@ianvs/prettier-plugin-sort-imports',
11 | 'prettier-plugin-css-order',
12 | 'prettier-plugin-style-order',
13 | 'prettier-plugin-tailwindcss', // must come last
14 | ],
15 | // Empty string to separate groups
16 | importOrder: ['<THIRD_PARTY_MODULES>', '', '^@/', '', '^[../]', '^[./]'],
17 | importOrderParserPlugins: ['typescript', 'jsx'],
18 | importOrderTypeScriptVersion: '4.4.0',
19 | }
20 |
--------------------------------------------------------------------------------
/packages/ui/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "eamodio.gitlens",
5 | "esbenp.prettier-vscode",
6 | "yoavbls.pretty-ts-errors",
7 | "bradlc.vscode-tailwindcss",
8 | "mikestead.dotenv",
9 | "naumovs.color-highlight",
10 | "steoates.autoimport",
11 | "formulahendry.auto-rename-tag",
12 | "lokalise.i18n-ally"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/packages/ui/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18.19.1-buster-slim
2 |
3 | # Install pnpm
4 | RUN npm install -g pnpm
5 |
6 | # Set the working directory
7 | WORKDIR /app
8 |
9 | # Copy the package.json and package-lock.json
10 | COPY packages/ui/package.json ./
11 | COPY packages/ui/pnpm-lock.yaml ./
12 |
13 | # Install the dependencies
14 | RUN pnpm install
15 |
16 | # Copy the rest of the files
17 | COPY packages/ui/ .
18 |
19 | # Build the app
20 | RUN pnpm run app-auth:build
21 |
22 | # Expose the port
23 | EXPOSE 2003
24 |
25 | # Start the app
26 | CMD ["pnpm", "run", "app-auth:start"]
--------------------------------------------------------------------------------
/packages/ui/app-auth/README.md:
--------------------------------------------------------------------------------
1 | # Local testing
2 |
3 | Make sure umbreld is running
4 |
5 | ```
6 | cd packages/umbreld
7 | npm run dev
8 | ```
9 |
10 | Then in another terminal
11 |
12 | ```
13 | cd packages/ui
14 | pnpm run app-auth:dev
15 | ```
16 |
17 | Go to `localhost:3001` and make sure you're logged out.
18 |
19 | Then, assuming `transmission` is installed and running, open:
20 | http://localhost:2001/app-auth/?origin=host&app=transmission&path=%2Ftransmission%2Fweb%2F
21 |
22 | In production, it would be:
23 | http://localhost:2000/?origin=host&app=transmission&path=%2Ftransmission%2Fweb%2F
24 |
25 | Login with password and 2fa should work and you should be redirected to the right page.
26 |
--------------------------------------------------------------------------------
/packages/ui/app-auth/index.html:
--------------------------------------------------------------------------------
1 | <!doctype html>
2 | <html class="h-full min-h-full">
3 | <head>
4 | <meta charset="UTF-8" />
5 | <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
6 | <meta name="theme-color" content="#000000" />
7 | <meta name="robots" content="noindex, nofollow" />
8 | <meta name="referrer" content="no-referrer" />
9 |
10 | <link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
11 | <link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
12 | <link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
13 | <link rel="manifest" href="/site.webmanifest" />
14 |
15 | <title>Umbrel</title>
16 | </head>
17 | <body style="background: black; color: white" class="h-full min-h-full">
18 | <noscript>
19 | <h1>umbrelOS</h1>
20 | <p>You need to enable JavaScript to run this app.</p>
21 | </noscript>
22 | <div id="root" class="h-full min-h-full"></div>
23 | <script type="module" src="/app-auth/src/main.tsx"></script>
24 | </body>
25 | </html>
26 |
--------------------------------------------------------------------------------
/packages/ui/app-auth/src/main.tsx:
--------------------------------------------------------------------------------
1 | import {BrowserRouter} from 'react-router-dom'
2 |
3 | import {init} from '../../src/init'
4 | import LoginWithUmbrel from './login-with-umbrel'
5 |
6 | init(
7 | // NOTE: not putting `GlobalSystemStateProvider` here because we don't care.
8 | // It doesn't matter for the auth page
9 | <BrowserRouter>
10 | <LoginWithUmbrel />
11 | </BrowserRouter>,
12 | )
13 |
--------------------------------------------------------------------------------
/packages/ui/app-auth/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import react from '@vitejs/plugin-react-swc'
3 | import {defineConfig} from 'vite'
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | server: {
9 | proxy: {
10 | '/v1': 'http://localhost:2000',
11 | },
12 | },
13 | resolve: {
14 | alias: {
15 | '@/': `${path.resolve(__dirname, '../src')}/`,
16 | },
17 | },
18 | build: {
19 | rollupOptions: {
20 | input: {
21 | index: path.resolve(__dirname, 'index.html'),
22 | },
23 | output: {
24 | minifyInternalExports: true,
25 | },
26 | },
27 | outDir: 'dist-app-auth',
28 | },
29 | })
30 |
--------------------------------------------------------------------------------
/packages/ui/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/index.css",
9 | "baseColor": "neutral",
10 | "cssVariables": false
11 | },
12 | "aliases": {
13 | "components": "@/shadcn-components",
14 | "utils": "@/shadcn-lib/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/ui/index.html:
--------------------------------------------------------------------------------
1 | <!doctype html>
2 | <html class="h-full min-h-full">
3 | <head>
4 | <meta charset="UTF-8" />
5 | <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
6 | <meta name="theme-color" content="#000000" />
7 | <meta name="robots" content="noindex, nofollow" />
8 | <meta name="referrer" content="no-referrer" />
9 |
10 | <link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
11 | <link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
12 | <link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
13 | <link rel="manifest" href="/site.webmanifest" />
14 |
15 | <title>Umbrel</title>
16 | </head>
17 | <body style="background: black; color: white" class="h-full min-h-full">
18 | <noscript>
19 | <h1>umbrelOS</h1>
20 | <p>You need to enable JavaScript to run this app.</p>
21 | </noscript>
22 | <div id="root" class="h-full min-h-full"></div>
23 | <script type="module" src="/src/main.tsx"></script>
24 | </body>
25 | </html>
26 |
--------------------------------------------------------------------------------
/packages/ui/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/packages/ui/public/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/packages/ui/public/favicon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/favicon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/packages/ui/public/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/packages/ui/public/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/packages/ui/public/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/packages/ui/public/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/favicon/favicon.ico
--------------------------------------------------------------------------------
/packages/ui/public/figma-exports/dock-app-store.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/figma-exports/dock-app-store.png
--------------------------------------------------------------------------------
/packages/ui/public/figma-exports/dock-files.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/figma-exports/dock-files.png
--------------------------------------------------------------------------------
/packages/ui/public/figma-exports/dock-home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/figma-exports/dock-home.png
--------------------------------------------------------------------------------
/packages/ui/public/figma-exports/dock-live-usage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/figma-exports/dock-live-usage.png
--------------------------------------------------------------------------------
/packages/ui/public/figma-exports/dock-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/figma-exports/dock-preview.png
--------------------------------------------------------------------------------
/packages/ui/public/figma-exports/dock-settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/figma-exports/dock-settings.png
--------------------------------------------------------------------------------
/packages/ui/public/figma-exports/dock-widgets.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/figma-exports/dock-widgets.png
--------------------------------------------------------------------------------
/packages/ui/public/figma-exports/migrate-raspberrypi-umbrel-home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/figma-exports/migrate-raspberrypi-umbrel-home.png
--------------------------------------------------------------------------------
/packages/ui/public/figma-exports/migrate-umbrel-home-umbrel-home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/figma-exports/migrate-umbrel-home-umbrel-home.png
--------------------------------------------------------------------------------
/packages/ui/public/figma-exports/system-umbrel-home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/figma-exports/system-umbrel-home.png
--------------------------------------------------------------------------------
/packages/ui/public/figma-exports/umbrel-home-device-info-grain.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/figma-exports/umbrel-home-device-info-grain.png
--------------------------------------------------------------------------------
/packages/ui/public/figma-exports/umbrel-ios.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/figma-exports/umbrel-ios.png
--------------------------------------------------------------------------------
/packages/ui/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/favicon/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/favicon/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#000000",
17 | "background_color": "#000000",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/1.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/10.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/10.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/11.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/11.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/12.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/12.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/13.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/13.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/14.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/14.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/15.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/15.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/16.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/16.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/17.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/17.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/18.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/18.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/19.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/19.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/2.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/20.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/20.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/21.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/21.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/3.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/4.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/5.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/5.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/6.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/6.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/7.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/7.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/8.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/8.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/9.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/9.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/1.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/10.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/10.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/11.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/11.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/12.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/12.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/13.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/13.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/14.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/14.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/15.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/15.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/16.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/16.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/17.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/17.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/18.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/18.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/19.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/19.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/2.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/20.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/20.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/21.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/21.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/3.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/4.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/5.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/5.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/6.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/6.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/7.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/7.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/8.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/8.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-small/9.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-small/9.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/1.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/10.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/10.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/11.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/11.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/12.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/12.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/13.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/13.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/14.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/14.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/15.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/15.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/16.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/16.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/17.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/17.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/18.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/18.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/19.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/19.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/2.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/20.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/20.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/21.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/21.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/3.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/4.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/5.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/5.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/6.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/6.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/7.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/7.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/8.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/8.jpg
--------------------------------------------------------------------------------
/packages/ui/public/wallpapers/generated-thumbs/9.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/public/wallpapers/generated-thumbs/9.jpg
--------------------------------------------------------------------------------
/packages/ui/resize-wallpapers.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Directory containing original wallpapers
4 | WALLPAPERS_DIR="public/wallpapers"
5 |
6 | # Directory to store thumbnails
7 | THUMBS_DIR="${WALLPAPERS_DIR}/generated-thumbs"
8 | SMALL_DIR="${WALLPAPERS_DIR}/generated-small"
9 |
10 | # Check if GraphicsMagick is installed
11 | if ! command -v gm &> /dev/null
12 | then
13 | echo "GraphicsMagick is not installed. Please install it to use this script."
14 | exit 1
15 | fi
16 |
17 | # Create thumbnails directory if it doesn't exist
18 | mkdir -p "$THUMBS_DIR"
19 | mkdir -p "$SMALL_DIR"
20 |
21 | # Resize images
22 | for img in "${WALLPAPERS_DIR}"/*.jpg; do
23 | # Skip if directory is empty
24 | [ -e "$img" ] || continue
25 |
26 | # Get filename without path
27 | filename=$(basename "$img")
28 |
29 | # Resize (only width specified, height automatically adjusted) and save in thumbs directory using GraphicsMagick
30 | # gm convert "$img" -resize 200 "${THUMBS_DIR}/${filename}"
31 | gm convert "$img" -resize 800 "${SMALL_DIR}/${filename}"
32 | done
33 |
34 | echo "Thumbnail creation complete."
35 |
--------------------------------------------------------------------------------
/packages/ui/run.mjs:
--------------------------------------------------------------------------------
1 | import {execSync, spawn} from 'child_process'
2 | import process from 'process'
3 |
4 | console.log('running...')
5 |
6 | execSync('rm -rf data && mkdir data', {cwd: '../umbreld'})
7 | // exec('npm run dev')
8 |
9 | const child = spawn('npm', ['run', 'dev'], {cwd: '../umbreld'})
10 |
11 | child.on('error', console.error)
12 | child.stdout.on('data', (data) => {
13 | console.log(data.toString())
14 | if (data.toString().includes('Repositories initialised!')) {
15 | // resolve()
16 | console.log('Initialized')
17 | process.kill(child.pid, 'SIGINT')
18 | process.exit(0)
19 | }
20 | })
21 |
22 | // process.kill(child.pid, 'SIGINT')
23 |
24 | // console.log('Umbrel is running on http://localhost:3001')
25 |
26 | // process.exit(0)
27 |
--------------------------------------------------------------------------------
/packages/ui/src/assets/README.md:
--------------------------------------------------------------------------------
1 | Default exports expected here
2 |
--------------------------------------------------------------------------------
/packages/ui/src/assets/caret-right.tsx:
--------------------------------------------------------------------------------
1 | const SvgComponent = ({className}: {className?: string}) => (
2 | <svg xmlns='http://www.w3.org/2000/svg' width={27} height={26} fill='none' className={className}>
3 | <g clipPath='url(#a)'>
4 | <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' />
5 | </g>
6 | <defs>
7 | <clipPath id='a'>
8 | <path fill='currentColor' d='M.7.18h25.6v25.6H.7z' />
9 | </clipPath>
10 | </defs>
11 | </svg>
12 | )
13 | export default SvgComponent
14 |
--------------------------------------------------------------------------------
/packages/ui/src/assets/chevron-down.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Most icons have a box around them. This one's bounding box matches the icon.
3 | */
4 | export function ChevronDown() {
5 | return (
6 | <svg width='6' height='5' viewBox='0 0 6 5' fill='none' xmlns='http://www.w3.org/2000/svg'>
7 | <path
8 | d='M5.29688 0.789062L6 1.49219L3 4.49219L0 1.49219L0.703125 0.789062L3 3.08594L5.29688 0.789062Z'
9 | fill='currentColor'
10 | />
11 | </svg>
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/packages/ui/src/assets/tor-icon.tsx:
--------------------------------------------------------------------------------
1 | import {SVGProps} from 'react'
2 |
3 | export const TorIcon = (props: SVGProps<SVGSVGElement>) => (
4 | <svg xmlns='http://www.w3.org/2000/svg' width='20' height='21' fill='none' viewBox='0 0 20 21' {...props}>
5 | <g
6 | stroke='#8347FF'
7 | strokeLinecap='round'
8 | strokeLinejoin='round'
9 | strokeWidth='1.563'
10 | clipPath='url(#clip0_1086_32066)'
11 | >
12 | <path d='M10 18.256a7.5 7.5 0 100-15 7.5 7.5 0 000 15z'></path>
13 | <path d='M10.851 6.013v0c5.873.51 5.873 9.098 0 9.608v0'></path>
14 | <path d='M10.736 7.954v0c3.055.599 3.062 4.986 0 5.548v0'></path>
15 | </g>
16 | <defs>
17 | <clipPath id='clip0_1086_32066'>
18 | <path fill='#fff' d='M0 0H20V20H0z' transform='translate(0 .756)'></path>
19 | </clipPath>
20 | </defs>
21 | </svg>
22 | )
23 |
--------------------------------------------------------------------------------
/packages/ui/src/assets/tor-icon2.tsx:
--------------------------------------------------------------------------------
1 | import {SVGProps} from 'react'
2 |
3 | export const TorIcon2 = (props: SVGProps<SVGSVGElement>) => (
4 | <svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' viewBox='0 0 24 24' {...props}>
5 | <g
6 | stroke='currentColor'
7 | strokeLinecap='round'
8 | strokeLinejoin='round'
9 | strokeWidth='1.5'
10 | clipPath='url(#clip0_98_797)'
11 | >
12 | <path d='M12 21a9 9 0 100-18 9 9 0 000 18z'></path>
13 | <path d='M13.021 6.308v0c7.048.613 7.048 10.917 0 11.53v0'></path>
14 | <path d='M12.883 8.637v0c3.667.718 3.675 5.983 0 6.657v0'></path>
15 | </g>
16 | <defs>
17 | <clipPath id='clip0_98_797'>
18 | <path fill='currentColor' d='M0 0H24V24H0z'></path>
19 | </clipPath>
20 | </defs>
21 | </svg>
22 | )
23 |
--------------------------------------------------------------------------------
/packages/ui/src/assets/widget-check-icon.tsx:
--------------------------------------------------------------------------------
1 | import {SVGProps} from 'react'
2 |
3 | export const WidgetCheckIcon = (props: SVGProps<SVGSVGElement>) => (
4 | <svg xmlns='http://www.w3.org/2000/svg' width={26} height={26} fill='none' {...props}>
5 | <path
6 | fill='currentColor'
7 | d='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'
8 | />
9 | </svg>
10 | )
11 |
--------------------------------------------------------------------------------
/packages/ui/src/components/darken-layer.tsx:
--------------------------------------------------------------------------------
1 | import {cn} from '@/shadcn-lib/utils'
2 |
3 | /**
4 | * Put a darken layer over the page
5 | */
6 | export function DarkenLayer({className}: {className?: string}) {
7 | return <div className={cn('fixed inset-0 bg-black/50 contrast-more:bg-black', className)} />
8 | }
9 |
--------------------------------------------------------------------------------
/packages/ui/src/components/fade-scroller/index.css:
--------------------------------------------------------------------------------
1 | @property --distance1 {
2 | syntax: '<length>'; /* <- defined as type length for the transition to work */
3 | initial-value: 0;
4 | inherits: false;
5 | }
6 |
7 | @property --distance2 {
8 | syntax: '<length>'; /* <- defined as type length for the transition to work */
9 | initial-value: 0;
10 | inherits: false;
11 | }
12 |
13 | .umbrel-fade-scroller-x,
14 | .umbrel-fade-scroller-y {
15 | /* 8ms animates in 5 frames assuming 60fps */
16 | transition:
17 | --distance1 80ms ease-out,
18 | --distance2 80ms ease-out;
19 | }
20 |
21 | .umbrel-fade-scroller-y {
22 | mask-image: linear-gradient(to bottom, transparent, red var(--distance1) calc(100% - var(--distance2)), transparent);
23 | }
24 |
25 | .umbrel-fade-scroller-x {
26 | mask-image: linear-gradient(to right, transparent, red var(--distance1) calc(100% - var(--distance2)), transparent);
27 | }
28 |
--------------------------------------------------------------------------------
/packages/ui/src/components/iframe-checker.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export function IframeChecker({children}: {children: React.ReactNode}) {
4 | const isIframe = window.self !== window.top
5 |
6 | if (isIframe) {
7 | return <div className='grid h-screen w-full place-items-center'>umbrelOS cannot be embedded in an iframe.</div>
8 | }
9 | return <>{children}</>
10 | }
11 |
--------------------------------------------------------------------------------
/packages/ui/src/components/reload-page-button.tsx:
--------------------------------------------------------------------------------
1 | import {Button} from '@/shadcn-components/ui/button'
2 | import {t} from '@/utils/i18n'
3 |
4 | export function ReloadPageButton() {
5 | return (
6 | <Button variant='secondary' size='sm' onClick={() => window.location.reload()}>
7 | {t('retry')}
8 | </Button>
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/animated-number.tsx:
--------------------------------------------------------------------------------
1 | import {animate} from 'framer-motion'
2 | import {useEffect, useRef} from 'react'
3 | import {usePrevious} from 'react-use'
4 |
5 | type CounterProps = {
6 | to: number
7 | }
8 |
9 | export function AnimatedNumber({to}: CounterProps) {
10 | const nodeRef = useRef<HTMLSpanElement>(null)
11 | const from = usePrevious(to) ?? to
12 |
13 | useEffect(() => {
14 | const node = nodeRef.current
15 |
16 | if (!node) {
17 | return
18 | }
19 |
20 | if (to === Infinity || to === -Infinity || isNaN(to)) {
21 | node.textContent = to.toString()
22 | return
23 | }
24 |
25 | const controls = animate(from, to, {
26 | duration: 0.3,
27 | ease: 'easeInOut',
28 | onUpdate(value) {
29 | node.textContent = value.toFixed(0)
30 | },
31 | })
32 |
33 | return () => controls.stop()
34 | }, [from, to])
35 |
36 | return <span className='tabular-nums' ref={nodeRef} />
37 | }
38 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/button-link.tsx:
--------------------------------------------------------------------------------
1 | import {type VariantProps} from 'class-variance-authority'
2 | import * as React from 'react'
3 | import {AnchorHTMLAttributes, ForwardRefExoticComponent, ReactNode, RefAttributes} from 'react'
4 | import {Link, LinkProps} from 'react-router-dom'
5 |
6 | import {buttonVariants} from '@/shadcn-components/ui/button'
7 | import {cn} from '@/shadcn-lib/utils'
8 |
9 | type CustomProps = VariantProps<typeof buttonVariants>
10 |
11 | // Stolen from `next/link` node_modules/next/dist/client/link.d.ts and modified to add custom props
12 | type LinkType = ForwardRefExoticComponent<
13 | Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> &
14 | LinkProps & {
15 | children?: ReactNode
16 | } & RefAttributes<HTMLAnchorElement> &
17 | CustomProps
18 | >
19 |
20 | const ButtonLink: LinkType = React.forwardRef(({className, variant, text, size, ...props}, ref) => {
21 | return <Link className={cn(buttonVariants({variant, size, text, className}))} ref={ref} {...props} />
22 | })
23 | ButtonLink.displayName = 'ButtonLink'
24 |
25 | export {ButtonLink}
26 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import {HtmlHTMLAttributes} from 'react'
2 |
3 | import {cn} from '@/shadcn-lib/utils'
4 | import {tw} from '@/utils/tw'
5 |
6 | export function Card({
7 | children,
8 | className,
9 | ...props
10 | }: {children?: React.ReactNode; className?: string} & HtmlHTMLAttributes<HTMLDivElement>) {
11 | return (
12 | <div className={cn(cardClass, className)} {...props}>
13 | {children}
14 | </div>
15 | )
16 | }
17 |
18 | export const cardClass = tw`rounded-12 bg-white/5 px-3 py-4 max-lg:min-h-[95px] lg:p-6`
19 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/debug-only.tsx:
--------------------------------------------------------------------------------
1 | import {IS_DEV} from '@/utils/misc'
2 |
3 | export function DebugOnly({children}: {children: React.ReactNode}) {
4 | if (IS_DEV) {
5 | return (
6 | <div className='relative border border-dotted border-white/50 p-2'>
7 | {children}
8 | <div className='absolute left-0 top-0 select-none bg-destructive2 px-0.5 text-[8px]'>development only</div>
9 | </div>
10 | )
11 | }
12 | return null
13 | }
14 |
15 | export function DebugOnlyBare({children}: {children: React.ReactNode}) {
16 | if (IS_DEV) {
17 | return <>{children}</>
18 | }
19 | return null
20 | }
21 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/dialog-close-button.tsx:
--------------------------------------------------------------------------------
1 | import * as DialogPrimitive from '@radix-ui/react-dialog'
2 | import {RiCloseCircleFill} from 'react-icons/ri'
3 |
4 | import {cn} from '@/shadcn-lib/utils'
5 | import {dialogHeaderCircleButtonClass} from '@/utils/element-classes'
6 | import {t} from '@/utils/i18n'
7 |
8 | export const DialogCloseButton = ({className}: {className?: React.ReactNode}) => (
9 | <DialogPrimitive.Close className={cn(dialogHeaderCircleButtonClass, className)}>
10 | <RiCloseCircleFill className='h-5 w-5 lg:h-6 lg:w-6' />
11 | <span className='sr-only'>{t('close')}</span>
12 | </DialogPrimitive.Close>
13 | )
14 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/error-boundary-card-fallback.tsx:
--------------------------------------------------------------------------------
1 | import {Card} from '@/components/ui/card'
2 | import {GenericErrorText} from '@/components/ui/generic-error-text'
3 |
4 | /**
5 | * Used for larger areas like the settings page, dialog content, etc.
6 | */
7 | export function ErrorBoundaryCardFallback() {
8 | return (
9 | // Wrap div to prevent flex parent from sizing this element inappropriately
10 | <Card className='grid w-full place-items-center animate-in fade-in zoom-in-150 md:h-60'>
11 | <GenericErrorText />
12 | </Card>
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/error-boundary-component-fallback.tsx:
--------------------------------------------------------------------------------
1 | import {GenericErrorText} from '@/components/ui/generic-error-text'
2 | import {Badge} from '@/shadcn-components/ui/badge'
3 |
4 | /**
5 | * Used for when we can replace the error with text. EX: buttons, page content
6 | */
7 | export function ErrorBoundaryComponentFallback() {
8 | return (
9 | // Wrap div to prevent flex parent from sizing this element inappropriately
10 | <div>
11 | <Badge variant={'default'}>
12 | <GenericErrorText />
13 | </Badge>
14 | </div>
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/fade-in-img.tsx:
--------------------------------------------------------------------------------
1 | import {useState} from 'react'
2 |
3 | import {cn} from '@/shadcn-lib/utils'
4 |
5 | export function FadeInImg({src, alt, className, ...props}: React.ImgHTMLAttributes<HTMLImageElement>) {
6 | const [loaded, setLoaded] = useState(false)
7 |
8 | return (
9 | <img
10 | src={src}
11 | alt={alt}
12 | className={cn('transition-opacity duration-500 fill-mode-both', loaded ? 'opacity-100' : 'opacity-0', className)}
13 | onLoad={() => {
14 | setLoaded(true)
15 | }}
16 | {...props}
17 | />
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/generic-error-text.tsx:
--------------------------------------------------------------------------------
1 | import {t} from '@/utils/i18n'
2 |
3 | export function GenericErrorText() {
4 | return <div className='font-semibold text-destructive2-lightest'>{t('something-went-wrong')}</div>
5 | }
6 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/icon-button.tsx:
--------------------------------------------------------------------------------
1 | import {type VariantProps} from 'class-variance-authority'
2 | import * as React from 'react'
3 |
4 | import {buttonVariants} from '@/shadcn-components/ui/button'
5 | import {cn} from '@/shadcn-lib/utils'
6 |
7 | import {Icon, IconTypes} from './icon'
8 |
9 | export interface ButtonProps
10 | extends React.ButtonHTMLAttributes<HTMLButtonElement>,
11 | VariantProps<typeof buttonVariants> {
12 | icon: IconTypes
13 | }
14 |
15 | const IconButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
16 | ({className, variant, text, size, icon, children, ...props}, ref) => {
17 | // No children for icon-only buttons
18 | const children2 = size === 'icon-only' ? null : children
19 |
20 | return (
21 | <button className={cn(buttonVariants({variant, size, text, className}))} ref={ref} {...props}>
22 | <Icon component={icon} size={size} />
23 | {children2}
24 | </button>
25 | )
26 | },
27 | )
28 | IconButton.displayName = 'IconButton'
29 |
30 | export {IconButton}
31 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/notification-badge.tsx:
--------------------------------------------------------------------------------
1 | export function NotificationBadge({count}: {count: number}) {
2 | return (
3 | // min-w so it's a circle when count is below 10
4 | <div className='absolute -right-1 -top-1 flex h-[17px] min-w-[17px] select-none items-center justify-center rounded-full bg-red-600/80 px-1 text-[11px] font-bold shadow-md shadow-red-800/50 animate-in zoom-in'>
5 | {count}
6 | </div>
7 | )
8 | }
9 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/numbered-list.tsx:
--------------------------------------------------------------------------------
1 | import {ReactNode} from 'react'
2 |
3 | export const NumberedList = ({children}: {children: ReactNode}) => {
4 | return <ol className='ml-7 list-none divide-y divide-white/5 text-15'>{children}</ol>
5 | }
6 |
7 | export const NumberedListItem = ({children}: {children: ReactNode}) => {
8 | return (
9 | <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)]'>
10 | {children}
11 | </li>
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/packages/ui/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | import {t} from '@/utils/i18n'
2 |
3 | export const UNKNOWN = () => t('unknown')
4 | // This is an en dash (U+2013)
5 | export const LOADING_DASH = '–'
6 |
7 | export const SETTINGS_SYSTEM_CARDS_ID = 'settings-system-cards'
8 |
9 | const hostEnvironments = ['umbrel-home', 'raspberry-pi', 'docker-container', 'unknown'] as const
10 | export type UmbrelHostEnvironment = (typeof hostEnvironments)[number]
11 |
12 | export const hostEnvironmentMap = {
13 | 'umbrel-home': {
14 | icon: '/figma-exports/system-umbrel-home.png',
15 | },
16 | 'raspberry-pi': {
17 | icon: '/figma-exports/system-pi.svg',
18 | },
19 | 'docker-container': {
20 | icon: '/figma-exports/system-docker.svg',
21 | },
22 | unknown: {
23 | icon: '/figma-exports/system-generic-device.svg',
24 | },
25 | } satisfies Record<
26 | UmbrelHostEnvironment,
27 | {
28 | icon?: string
29 | }
30 | >
31 |
--------------------------------------------------------------------------------
/packages/ui/src/constants/links.ts:
--------------------------------------------------------------------------------
1 | export const links = {
2 | support: 'https://umbrel.com/support',
3 | legal: {
4 | privacy: 'https://umbrel.com/legal/privacy',
5 | tos: 'https://umbrel.com/legal/umbrelos/tos',
6 | },
7 | }
8 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/assets/caret-right.tsx:
--------------------------------------------------------------------------------
1 | import {SVGProps} from 'react'
2 |
3 | export const CaretRightIcon = (props: SVGProps<SVGSVGElement>) => (
4 | <svg width='3' height='6' viewBox='0 0 3 6' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
5 | <path d='M3 3L0 6L0 0L3 3Z' fill='currentColor' />
6 | </svg>
7 | )
8 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/assets/chevron-left.tsx:
--------------------------------------------------------------------------------
1 | import {SVGProps} from 'react'
2 |
3 | export const ChevronLeftIcon = (props: SVGProps<SVGSVGElement>) => (
4 | <svg width='21' height='20' viewBox='0 0 21 20' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
5 | <g clipPath='url(#clip0_681_3294)'>
6 | <path
7 | d='M13 5L8 10L13 15'
8 | stroke='currentColor'
9 | strokeWidth='1.5625'
10 | strokeLinecap='round'
11 | strokeLinejoin='round'
12 | />
13 | </g>
14 | <defs>
15 | <clipPath id='clip0_681_3294'>
16 | <rect width='20' height='20' fill='currentColor' transform='translate(0.5)' />
17 | </clipPath>
18 | </defs>
19 | </svg>
20 | )
21 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/assets/chevron-right.tsx:
--------------------------------------------------------------------------------
1 | import {SVGProps} from 'react'
2 |
3 | export const ChevronRightIcon = (props: SVGProps<SVGSVGElement>) => (
4 | <svg width='21' height='20' viewBox='0 0 21 20' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
5 | <g clipPath='url(#clip0_681_3296)'>
6 | <path
7 | d='M8 5L13 10L8 15'
8 | stroke='currentColor'
9 | strokeWidth='1.5625'
10 | strokeLinecap='round'
11 | strokeLinejoin='round'
12 | />
13 | </g>
14 | <defs>
15 | <clipPath id='clip0_681_3296'>
16 | <rect width='20' height='20' fill='currentColor' transform='translate(0.5)' />
17 | </clipPath>
18 | </defs>
19 | </svg>
20 | )
21 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/assets/sharing-info-platforms/ios.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/src/features/files/assets/sharing-info-platforms/ios.png
--------------------------------------------------------------------------------
/packages/ui/src/features/files/assets/sharing-info-platforms/macos.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/src/features/files/assets/sharing-info-platforms/macos.png
--------------------------------------------------------------------------------
/packages/ui/src/features/files/assets/sharing-info-platforms/windows.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/ui/src/features/files/assets/sharing-info-platforms/windows.png
--------------------------------------------------------------------------------
/packages/ui/src/features/files/components/dialogs/share-info-dialog/platform-instructions/instruction.tsx:
--------------------------------------------------------------------------------
1 | import {type ReactNode} from 'react'
2 |
3 | export function InstructionContainer({children}: {children: ReactNode}) {
4 | return <div className='divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>{children}</div>
5 | }
6 | export function InstructionItem({children}: {children: ReactNode}) {
7 | return (
8 | <div className='flex items-center justify-between gap-3 p-3 text-12 font-medium -tracking-3'>
9 | <span>{children}</span>
10 | </div>
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/components/dialogs/share-info-dialog/share-toggle.tsx:
--------------------------------------------------------------------------------
1 | import {Switch} from '@/shadcn-components/ui/switch'
2 | import {t} from '@/utils/i18n'
3 |
4 | interface ShareToggleProps {
5 | name: string
6 | isShared: boolean
7 | isLoading: boolean
8 | onToggle: (checked: boolean) => void
9 | }
10 |
11 | export function ShareToggle({name, isShared, isLoading, onToggle}: ShareToggleProps) {
12 | return (
13 | <div className='divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6'>
14 | <div className='flex items-center justify-between gap-3 p-3 text-12 font-medium -tracking-3'>
15 | <span className='text-14'>{t('files-share.toggle', {name})}</span>
16 | <Switch checked={isShared} onCheckedChange={onToggle} disabled={isLoading} />
17 | </div>
18 | </div>
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/components/file-viewer/audio-viewer/index.tsx:
--------------------------------------------------------------------------------
1 | import {useEffect} from 'react'
2 |
3 | import {FileSystemItem} from '@/features/files/types'
4 | import {useGlobalFiles} from '@/providers/global-files'
5 |
6 | interface AudioViewerProps {
7 | item: FileSystemItem
8 | }
9 |
10 | export const AudioViewer: React.FC<AudioViewerProps> = ({item}) => {
11 | const {setAudio} = useGlobalFiles()
12 |
13 | // Set the audio file in the global files provider
14 | // so it can auto-render the audio player island
15 | // we don't need to clean up because the island has a close button
16 | // and should be persisted across route changes, different file previews, etc
17 | useEffect(() => {
18 | setAudio({
19 | path: item.path,
20 | name: item.name,
21 | })
22 | }, [item, setAudio])
23 |
24 | return null
25 | }
26 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/components/file-viewer/image-viewer/index.tsx:
--------------------------------------------------------------------------------
1 | import {ViewerWrapper} from '@/features/files/components/file-viewer/viewer-wrapper'
2 | import {FileSystemItem} from '@/features/files/types'
3 |
4 | interface ImageViewerProps {
5 | item: FileSystemItem
6 | }
7 |
8 | export default function ImageViewer({item}: ImageViewerProps) {
9 | const previewUrl = `/api/files/view?path=${encodeURIComponent(item.path)}`
10 |
11 | return (
12 | <ViewerWrapper>
13 | <img
14 | src={previewUrl}
15 | alt={item.name}
16 | className='absolute left-1/2 top-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'
17 | />
18 | </ViewerWrapper>
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/components/floating-islands/uploading-island/index.tsx:
--------------------------------------------------------------------------------
1 | import {ExpandedContent} from '@/features/files/components/floating-islands/uploading-island/expanded'
2 | import {MinimizedContent} from '@/features/files/components/floating-islands/uploading-island/minimized'
3 | import {Island, IslandExpanded, IslandMinimized} from '@/modules/floating-island/bare-island'
4 |
5 | export function UploadingIsland() {
6 | return (
7 | <Island id='uploading-island' nonDismissable>
8 | <IslandMinimized>
9 | <MinimizedContent />
10 | </IslandMinimized>
11 | <IslandExpanded>
12 | <ExpandedContent />
13 | </IslandExpanded>
14 | </Island>
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/components/floating-islands/uploading-island/minimized.tsx:
--------------------------------------------------------------------------------
1 | import {RiArrowUpLine} from 'react-icons/ri'
2 |
3 | import {CircularProgress} from '@/features/files/components/shared/circular-progress'
4 | import {useGlobalFiles} from '@/providers/global-files'
5 |
6 | export function MinimizedContent() {
7 | const {uploadingItems, uploadStats} = useGlobalFiles()
8 |
9 | return (
10 | <div className='flex h-full w-full items-center gap-2 px-2'>
11 | <CircularProgress progress={uploadStats.totalProgress}>
12 | <RiArrowUpLine className='h-3 w-3 text-white/60' />
13 | </CircularProgress>
14 | <div className='min-w-0 flex-1'>
15 | <span className='block truncate text-center text-xs text-white/90'>
16 | {uploadingItems.length} item{uploadingItems.length > 1 ? 's' : ''}
17 | </span>
18 | </div>
19 | <div className='flex flex-shrink-0 items-center gap-2'>
20 | <span className='text-xs text-white/60'>{uploadStats.eta}</span>
21 | </div>
22 | </div>
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/components/shared/upload-input.tsx:
--------------------------------------------------------------------------------
1 | import {forwardRef} from 'react'
2 |
3 | import {useNavigate} from '@/features/files/hooks/use-navigate'
4 | import {useGlobalFiles} from '@/providers/global-files'
5 |
6 | export const UploadInput = forwardRef<HTMLInputElement>((_, ref) => {
7 | const {startUpload} = useGlobalFiles()
8 | const {currentPath} = useNavigate()
9 |
10 | const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
11 | if (e.target.files && e.target.files.length > 0) {
12 | startUpload(e.target.files, currentPath)
13 | e.target.value = ''
14 | }
15 | }
16 | return <input type='file' ref={ref} style={{display: 'none'}} multiple accept='*' onChange={handleFileChange} />
17 | })
18 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/components/sidebar/sidebar-apps.tsx:
--------------------------------------------------------------------------------
1 | import {useLocation, useNavigate} from 'react-router-dom'
2 |
3 | import {SidebarItem} from '@/features/files/components/sidebar/sidebar-item'
4 | import {APPS_PATH, BASE_ROUTE_PATH} from '@/features/files/constants'
5 | import {t} from '@/utils/i18n'
6 |
7 | export function SidebarApps() {
8 | const navigate = useNavigate()
9 | const {pathname} = useLocation()
10 |
11 | return (
12 | <SidebarItem
13 | item={{
14 | name: t('files-sidebar.apps'),
15 | path: APPS_PATH,
16 | type: 'directory',
17 | }}
18 | isActive={pathname === `${BASE_ROUTE_PATH}${APPS_PATH}`}
19 | onClick={() => navigate(`${BASE_ROUTE_PATH}${APPS_PATH}`)}
20 | />
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/components/sidebar/sidebar-recents.tsx:
--------------------------------------------------------------------------------
1 | import {useLocation, useNavigate} from 'react-router-dom'
2 |
3 | import {SidebarItem} from '@/features/files/components/sidebar/sidebar-item'
4 | import {BASE_ROUTE_PATH, RECENTS_PATH} from '@/features/files/constants'
5 | import {t} from '@/utils/i18n'
6 |
7 | export function SidebarRecents() {
8 | const navigate = useNavigate()
9 | const {pathname} = useLocation()
10 |
11 | return (
12 | <SidebarItem
13 | item={{
14 | name: t('files-sidebar.recents'),
15 | path: RECENTS_PATH,
16 | type: 'directory',
17 | }}
18 | isActive={pathname === `${BASE_ROUTE_PATH}${RECENTS_PATH}`}
19 | onClick={() => navigate(`${BASE_ROUTE_PATH}${RECENTS_PATH}`)}
20 | />
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/hooks/use-home-directory-name.ts:
--------------------------------------------------------------------------------
1 | import {trpcReact} from '@/trpc/trpc'
2 | import {firstNameFromFullName} from '@/utils/misc'
3 |
4 | export function useHomeDirectoryName() {
5 | const userQuery = trpcReact.user.get.useQuery()
6 | const userName = userQuery.data?.name
7 | return userName ? `${firstNameFromFullName(userName)}'s Umbrel` : 'My Umbrel'
8 | }
9 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/hooks/use-is-touch-device.ts:
--------------------------------------------------------------------------------
1 | import {useMedia} from 'react-use'
2 |
3 | export function useIsTouchDevice() {
4 | const isHoverNone = useMedia('(hover: none)')
5 | return isHoverNone
6 | }
7 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/store/slices/drag-and-drop-slice.ts:
--------------------------------------------------------------------------------
1 | import {StateCreator} from 'zustand'
2 |
3 | import {ClipboardSlice} from '@/features/files/store/slices/clipboard-slice'
4 | import {FileViewerSlice} from '@/features/files/store/slices/file-viewer-slice'
5 | import {NewFolderSlice} from '@/features/files/store/slices/new-folder-slice'
6 | import {SelectionSlice} from '@/features/files/store/slices/selection-slice'
7 | import {FileSystemItem} from '@/features/files/types'
8 |
9 | export interface DragAndDropSlice {
10 | draggedItems: FileSystemItem[]
11 | setDraggedItems: (items: FileSystemItem[]) => void
12 | clearDraggedItems: () => void
13 | }
14 |
15 | export const createDragAndDropSlice: StateCreator<
16 | DragAndDropSlice & SelectionSlice & ClipboardSlice & NewFolderSlice & FileViewerSlice,
17 | [],
18 | [],
19 | DragAndDropSlice
20 | > = (set) => ({
21 | draggedItems: [],
22 | setDraggedItems: (items) => set({draggedItems: items}),
23 | clearDraggedItems: () => set({draggedItems: []}),
24 | })
25 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/store/slices/file-viewer-slice.ts:
--------------------------------------------------------------------------------
1 | import {StateCreator} from 'zustand'
2 |
3 | import {ClipboardSlice} from '@/features/files/store/slices/clipboard-slice'
4 | import {DragAndDropSlice} from '@/features/files/store/slices/drag-and-drop-slice'
5 | import {NewFolderSlice} from '@/features/files/store/slices/new-folder-slice'
6 | import {SelectionSlice} from '@/features/files/store/slices/selection-slice'
7 | import {FileSystemItem} from '@/features/files/types'
8 |
9 | export interface FileViewerSlice {
10 | viewerItem: FileSystemItem | null
11 | setViewerItem: (item: FileSystemItem | null) => void
12 | }
13 |
14 | export const createFileViewerSlice: StateCreator<
15 | FileViewerSlice & SelectionSlice & ClipboardSlice & NewFolderSlice & DragAndDropSlice,
16 | [],
17 | [],
18 | FileViewerSlice
19 | > = (set) => ({
20 | viewerItem: null,
21 | setViewerItem: (item) => set({viewerItem: item}),
22 | })
23 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/store/slices/new-folder-slice.ts:
--------------------------------------------------------------------------------
1 | import {StateCreator} from 'zustand'
2 |
3 | import {ClipboardSlice} from '@/features/files/store/slices/clipboard-slice'
4 | import {DragAndDropSlice} from '@/features/files/store/slices/drag-and-drop-slice'
5 | import {FileViewerSlice} from '@/features/files/store/slices/file-viewer-slice'
6 | import {SelectionSlice} from '@/features/files/store/slices/selection-slice'
7 | import type {FileSystemItem} from '@/features/files/types'
8 |
9 | export interface NewFolderSlice {
10 | newFolder: (FileSystemItem & {isNew: boolean}) | null
11 |
12 | setNewFolder: (newFolder: (FileSystemItem & {isNew: boolean}) | null) => void
13 | }
14 |
15 | export const createNewFolderSlice: StateCreator<
16 | NewFolderSlice & SelectionSlice & ClipboardSlice & DragAndDropSlice & FileViewerSlice,
17 | [],
18 | [],
19 | NewFolderSlice
20 | > = (set) => ({
21 | newFolder: null,
22 |
23 | setNewFolder: (newFolder) => {
24 | set({
25 | newFolder,
26 | })
27 | },
28 | })
29 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/store/slices/rename-slice.ts:
--------------------------------------------------------------------------------
1 | import {StateCreator} from 'zustand'
2 |
3 | import {ClipboardSlice} from '@/features/files/store/slices/clipboard-slice'
4 | import {DragAndDropSlice} from '@/features/files/store/slices/drag-and-drop-slice'
5 | import {FileViewerSlice} from '@/features/files/store/slices/file-viewer-slice'
6 | import {NewFolderSlice} from '@/features/files/store/slices/new-folder-slice'
7 | import {SelectionSlice} from '@/features/files/store/slices/selection-slice'
8 |
9 | export interface RenameSlice {
10 | // Path of the file/folder that is currently being renamed.
11 | renamingItemPath: string | null
12 | setRenamingItemPath: (path: string | null) => void
13 | }
14 |
15 | export const createRenameSlice: StateCreator<
16 | SelectionSlice & ClipboardSlice & NewFolderSlice & DragAndDropSlice & FileViewerSlice & RenameSlice,
17 | [],
18 | [],
19 | RenameSlice
20 | > = (set) => ({
21 | renamingItemPath: null,
22 | setRenamingItemPath: (path) => set({renamingItemPath: path}),
23 | })
24 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/utils/format-filesystem-size.ts:
--------------------------------------------------------------------------------
1 | import prettyBytes from 'pretty-bytes'
2 |
3 | export function formatFilesystemSize(size: number | undefined | null): string {
4 | if (!size) return '-'
5 | return 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
6 | }
7 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/utils/get-item-key.ts:
--------------------------------------------------------------------------------
1 | import type {FileSystemItem} from '@/features/files/types'
2 |
3 | /**
4 | * Generates a unique key for a file system item
5 | * Takes into account uploading status for items that are being uploaded
6 | *
7 | * @param item The file system item
8 | * @returns A unique string key
9 | */
10 | export function getItemKey(item: FileSystemItem): string {
11 | const isUploading = 'isUploading' in item && item.isUploading
12 | return `${item.path}${isUploading ? '-uploading' : ''}`
13 | }
14 |
--------------------------------------------------------------------------------
/packages/ui/src/features/files/utils/is-directory-an-external-drive-partition.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Checks if a given path represents an external drive partition.
3 | * Valid path example: "/External/usb1"
4 | * @param path The file system path to check
5 | * @returns boolean indicating if the path is an external drive partition
6 | */
7 | export const isDirectoryAnExternalDrivePartition = (path: string): boolean => {
8 | // Path must start with /External and have exactly one more segment (the partition name)
9 | return path.startsWith('/External/') && path.split('/').filter(Boolean).length === 2
10 | }
11 |
--------------------------------------------------------------------------------
/packages/ui/src/hooks/use-apps-with-updates.ts:
--------------------------------------------------------------------------------
1 | import {useApps} from '@/providers/apps'
2 | import {useAllAvailableApps} from '@/providers/available-apps'
3 |
4 | export function useAppsWithUpdates() {
5 | const apps = useApps()
6 | const availableApps = useAllAvailableApps()
7 |
8 | // NOTE: a parent should have the apps loaded before we get here, but don't wanna assume
9 | if (apps.isLoading || availableApps.isLoading) {
10 | return {
11 | appsWithUpdates: [],
12 | isLoading: true,
13 | } as const
14 | }
15 |
16 | const appsWithUpdates = (apps.userApps ?? [])
17 | .filter((app) => {
18 | const availableApp = availableApps.appsKeyed[app.id]
19 | return availableApp && availableApp.version !== app.version
20 | })
21 | .map((app) => availableApps.appsKeyed[app.id])
22 |
23 | return {appsWithUpdates, isLoading: apps.isLoading || availableApps.isLoading} as const
24 | }
25 |
--------------------------------------------------------------------------------
/packages/ui/src/hooks/use-auto-height-animation.tsx:
--------------------------------------------------------------------------------
1 | import {AnimationControls, useAnimation} from 'framer-motion'
2 | import {useLayoutEffect, useRef} from 'react'
3 |
4 | export function useAutoHeightAnimation(deps: any[]): [AnimationControls, React.RefObject<HTMLDivElement>] {
5 | const controls = useAnimation()
6 | const ref = useRef<HTMLDivElement>(null)
7 | const height = useRef<number | null>(null)
8 |
9 | useLayoutEffect(() => {
10 | if (!ref.current) return
11 | ref.current.style.height = 'auto'
12 | const newHeight = ref.current.offsetHeight
13 |
14 | //console.log( newHeight )
15 | if (height.current !== null) {
16 | controls.set({height: height.current})
17 | controls.start({height: newHeight, opacity: 1})
18 | }
19 |
20 | height.current = newHeight
21 | // eslint-disable-next-line react-hooks/exhaustive-deps
22 | }, [ref, controls, ...deps])
23 |
24 | return [controls, ref]
25 | }
26 |
--------------------------------------------------------------------------------
/packages/ui/src/hooks/use-cpu-temperature.ts:
--------------------------------------------------------------------------------
1 | import {trpcReact} from '@/trpc/trpc'
2 |
3 | export function useCpuTemperature() {
4 | const cpuTemperatureQ = trpcReact.system.cpuTemperature.useQuery(undefined, {
5 | // Sometimes we won't be able to get CPU temperature, so prevent retry
6 | retry: false,
7 | // We do want refetching to happen on a schedule though
8 | refetchInterval: 5_000,
9 | })
10 |
11 | const temperature = cpuTemperatureQ.data?.temperature
12 | const warning = cpuTemperatureQ.data?.warning
13 |
14 | return {
15 | temperature,
16 | warning,
17 | isLoading: cpuTemperatureQ.isLoading,
18 | error: cpuTemperatureQ.error,
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/ui/src/hooks/use-debug-install-random-apps.ts:
--------------------------------------------------------------------------------
1 | import {shuffle} from 'remeda'
2 |
3 | import {useAvailableApps} from '@/providers/available-apps'
4 | import {trpcReact} from '@/trpc/trpc'
5 |
6 | export function useDebugInstallRandomApps() {
7 | const apps = useAvailableApps()
8 |
9 | const installMut = trpcReact.apps.install.useMutation({
10 | onSuccess: () => {
11 | window.location.reload()
12 | },
13 | })
14 |
15 | const handleInstallABunch = () => {
16 | const toInstall = shuffle(apps?.apps ?? []).slice(0, 20) ?? []
17 | toInstall.map((app) => installMut.mutate({appId: app.id}))
18 | }
19 |
20 | return handleInstallABunch
21 | }
22 |
--------------------------------------------------------------------------------
/packages/ui/src/hooks/use-is-externaldns.ts:
--------------------------------------------------------------------------------
1 | import {toast} from '@/components/ui/toast'
2 | import {trpcReact} from '@/trpc/trpc'
3 |
4 | export function useIsExternalDns({onSuccess}: {onSuccess?: (enabled: boolean) => void} = {}) {
5 | const externalDnsQ = trpcReact.system.isExternalDns.useQuery()
6 | const isChecked = externalDnsQ.data === true
7 |
8 | const externalDnsMut = trpcReact.system.setExternalDns.useMutation({
9 | onSuccess: (enabled) => {
10 | externalDnsQ.refetch()
11 | onSuccess?.(enabled)
12 | },
13 | onError: (err) => {
14 | toast.error(err.message)
15 | },
16 | })
17 |
18 | const change = (checked: boolean) => {
19 | externalDnsMut.mutate(checked)
20 | }
21 |
22 | const isLoading = externalDnsMut.isPending || externalDnsQ.isLoading
23 |
24 | return {isChecked, change, isLoading}
25 | }
26 |
--------------------------------------------------------------------------------
/packages/ui/src/hooks/use-is-mobile.ts:
--------------------------------------------------------------------------------
1 | import {useBreakpoint} from '@/utils/tw'
2 |
3 | // Made for the '/settings' page, but probably useful elsewhere.
4 | export function useIsMobile() {
5 | const breakpoint = useBreakpoint()
6 | const isMobile = breakpoint === 'sm' || breakpoint === 'md'
7 |
8 | return isMobile
9 | }
10 |
11 | export function useIsSmallMobile() {
12 | const breakpoint = useBreakpoint()
13 | const isMobile = breakpoint === 'sm'
14 |
15 | return isMobile
16 | }
17 |
--------------------------------------------------------------------------------
/packages/ui/src/hooks/use-is-umbrel-home.tsx:
--------------------------------------------------------------------------------
1 | import {trpcReact} from '@/trpc/trpc'
2 |
3 | export function useIsUmbrelHome() {
4 | const isUmbrelHomeQ = trpcReact.migration.isUmbrelHome.useQuery()
5 | const isUmbrelHome = !!isUmbrelHomeQ.data
6 | return {
7 | isUmbrelHome,
8 | isLoading: isUmbrelHomeQ.isLoading,
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/packages/ui/src/hooks/use-local-storage2.ts:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from 'react'
2 | import {useLocalStorage} from 'react-use'
3 |
4 | /**
5 | * Just like `useLocalStorage`, but a few differences:
6 | * - The key is prefixed with `UMBREL_`
7 | * - Uses an effect to prevent ssr mismatch
8 | * Why: https://github.com/streamich/react-use/issues/702
9 | */
10 | export function useLocalStorage2<TT>(key: string, defaultValue?: TT) {
11 | const [s2, setS2] = useState<TT | undefined>(undefined)
12 | const [s, ss] = useLocalStorage('UMBREL_' + key, defaultValue)
13 |
14 | useEffect(() => {
15 | setS2(s)
16 | }, [s])
17 |
18 | return [s2, ss] as const
19 | }
20 |
--------------------------------------------------------------------------------
/packages/ui/src/hooks/use-tor-enabled.ts:
--------------------------------------------------------------------------------
1 | import {toast} from '@/components/ui/toast'
2 | import {trpcReact} from '@/trpc/trpc'
3 |
4 | export function useTorEnabled({onSuccess}: {onSuccess?: (enabled: boolean) => void} = {}) {
5 | const utils = trpcReact.useUtils()
6 |
7 | const torEnabledQ = trpcReact.apps.getTorEnabled.useQuery()
8 |
9 | const setMut = trpcReact.apps.setTorEnabled.useMutation({
10 | onSuccess: (enabled) => {
11 | utils.apps.getTorEnabled.invalidate()
12 | onSuccess?.(enabled)
13 | },
14 | onError: (err) => {
15 | toast.error(err.message)
16 | },
17 | })
18 |
19 | return {
20 | enabled: torEnabledQ.data,
21 | setEnabled: (enabled: boolean) => setMut.mutate(enabled),
22 | isLoading: torEnabledQ.isLoading || setMut.isPending,
23 | isMutLoading: setMut.isPending,
24 | isError: setMut.isError,
25 | error: setMut.error,
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/ui/src/hooks/use-version.ts:
--------------------------------------------------------------------------------
1 | import {trpcReact} from '@/trpc/trpc'
2 |
3 | export function useVersion() {
4 | const {isLoading, data} = trpcReact.system.version.useQuery()
5 | if (isLoading || !data)
6 | return {isLoading: true, version: undefined, name: undefined, manifestVersion: undefined} as const
7 | return {
8 | isLoading: false,
9 | ...data,
10 | } as const
11 | }
12 |
--------------------------------------------------------------------------------
/packages/ui/src/layouts/README.md:
--------------------------------------------------------------------------------
1 | These are components that have an <Outlet />
2 |
--------------------------------------------------------------------------------
/packages/ui/src/layouts/bare/bare-page.tsx:
--------------------------------------------------------------------------------
1 | import {DarkenLayer} from '@/components/darken-layer'
2 | import {Wallpaper} from '@/providers/wallpaper'
3 |
4 | export function BarePage({children}: {children: React.ReactNode}) {
5 | return (
6 | <>
7 | <Wallpaper stayBlurred />
8 | <DarkenLayer />
9 | <div className='relative flex min-h-[100dvh] flex-col items-center justify-between p-5'>{children}</div>
10 | </>
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/packages/ui/src/layouts/bare/bare.tsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react'
2 | import {Outlet} from 'react-router-dom'
3 |
4 | import {BarePage} from '@/layouts/bare/bare-page'
5 |
6 | export function BareLayout() {
7 | return (
8 | <BarePage>
9 | <Suspense>
10 | <Outlet />
11 | </Suspense>
12 | </BarePage>
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/packages/ui/src/layouts/demo-layout.tsx:
--------------------------------------------------------------------------------
1 | import {Suspense} from 'react'
2 | import {Outlet} from 'react-router-dom'
3 |
4 | export function Demo() {
5 | return (
6 | <Suspense>
7 | <Outlet />
8 | </Suspense>
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/app-store/app-page/about-section.tsx:
--------------------------------------------------------------------------------
1 | import {cn} from '@/shadcn-lib/utils'
2 | import {RegistryApp} from '@/trpc/trpc'
3 | import {t} from '@/utils/i18n'
4 |
5 | import {cardClass, cardTitleClass, ReadMoreMarkdownSection} from './shared'
6 |
7 | export const AboutSection = ({app}: {app: RegistryApp}) => (
8 | <div className={cn(cardClass, 'gap-2.5')}>
9 | <h2 className={cardTitleClass}>{t('app-page.section.about')}</h2>
10 | {/* Adding key to reset state when updating content */}
11 | <ReadMoreMarkdownSection key={app.description}>{app.description}</ReadMoreMarkdownSection>
12 | </div>
13 | )
14 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/app-store/app-page/get-recommendations.ts:
--------------------------------------------------------------------------------
1 | import {sample} from 'remeda'
2 |
3 | import {RegistryApp} from '@/trpc/trpc'
4 |
5 | export function getRecommendationsFor(apps: RegistryApp[], appId: string) {
6 | const {category} = apps.find((app) => app.id === appId)!
7 |
8 | // Filter apps by the same category, excluding the current app
9 | const categoryApps = apps.filter((app) => app.category === category && app.id !== appId)
10 |
11 | // Sample 4 apps from the same category
12 | return sample(categoryApps, 4)
13 | }
14 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/app-store/app-page/release-notes-section.tsx:
--------------------------------------------------------------------------------
1 | import {cn} from '@/shadcn-lib/utils'
2 | import {RegistryApp} from '@/trpc/trpc'
3 | import {t} from '@/utils/i18n'
4 |
5 | import {cardClass, cardTitleClass, ReadMoreMarkdownSection} from './shared'
6 |
7 | export const ReleaseNotesSection = ({app}: {app: RegistryApp}) => (
8 | <>
9 | {app.releaseNotes && (
10 | <div className={cn(cardClass, 'gap-2.5')}>
11 | <h2 className={cardTitleClass}>{t('app-page.section.release-notes.title')}</h2>
12 | <h3 className='text-16 font-semibold'>{t('app-page.section.release-notes.version', {version: app.version})}</h3>
13 | {/* Adding key to reset state when updating content */}
14 | <ReadMoreMarkdownSection key={app.releaseNotes}>{app.releaseNotes}</ReadMoreMarkdownSection>
15 | </div>
16 | )}
17 | </>
18 | )
19 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/app-store/utils.ts:
--------------------------------------------------------------------------------
1 | import {RegistryApp, trpcClient} from '@/trpc/trpc'
2 | import {preloadImage} from '@/utils/misc'
3 |
4 | const alreadyPreloadedFirstFewGalleryImages = new Set<string>()
5 |
6 | export function preloadFirstFewGalleryImages(app: RegistryApp) {
7 | if (alreadyPreloadedFirstFewGalleryImages.has(app.id)) return
8 | alreadyPreloadedFirstFewGalleryImages.add(app.id)
9 | app.gallery.slice(0, 3).map(preloadImage)
10 | }
11 |
12 | export async function getAppStoreAppFromInstalledApp(appId: string) {
13 | const installedApps = await trpcClient.apps.list.query()
14 | const installedApp = installedApps.find((app) => app.id === appId)
15 |
16 | if (!installedApp) return null
17 |
18 | const availableApps = await trpcClient.appStore.registry.query()
19 | const availableAppsFlat = availableApps.flatMap((group) =>
20 | group.apps.map((app) => ({...app, registryId: group.meta.id})),
21 | )
22 | const appStoreApp = availableAppsFlat.find((app) => app.id === installedApp.id)
23 |
24 | return appStoreApp
25 | }
26 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/auth/ensure-backend-available.tsx:
--------------------------------------------------------------------------------
1 | import {BareCoverMessage} from '@/components/ui/cover-message'
2 | import {trpcReact} from '@/trpc/trpc'
3 | import {t} from '@/utils/i18n'
4 |
5 | export function EnsureBackendAvailable({children}: {children: React.ReactNode}) {
6 | // 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.
7 | const getQuery = trpcReact.system.online.useQuery(undefined, {
8 | retry: false,
9 | })
10 |
11 | if (getQuery.isLoading) {
12 | return <BareCoverMessage delayed>{t('trpc.checking-backend')}</BareCoverMessage>
13 | }
14 |
15 | if (getQuery.error) {
16 | return <BareCoverMessage>{t('trpc.backend-unavailable')}</BareCoverMessage>
17 | }
18 |
19 | return children
20 | }
21 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/auth/shared.ts:
--------------------------------------------------------------------------------
1 | import {trpcClient} from '@/trpc/trpc'
2 | import {callEveryInterval} from '@/utils/call-every-interval'
3 | import {MS_PER_HOUR} from '@/utils/date-time'
4 |
5 | export const JWT_LOCAL_STORAGE_KEY = 'jwt'
6 | export const JWT_REFRESH_LOCAL_STORAGE_KEY = 'jwt-last-refreshed'
7 |
8 | export function initTokenRenewal() {
9 | callEveryInterval(
10 | JWT_REFRESH_LOCAL_STORAGE_KEY,
11 | async () => {
12 | const token = await trpcClient.user.renewToken.mutate()
13 | localStorage.setItem(JWT_LOCAL_STORAGE_KEY, token)
14 | },
15 | MS_PER_HOUR,
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/bare/alert.tsx:
--------------------------------------------------------------------------------
1 | import {TbAlertTriangleFilled} from 'react-icons/tb'
2 |
3 | import {cn} from '@/shadcn-lib/utils'
4 |
5 | export function Alert({children, className}: {children: React.ReactNode; className?: string}) {
6 | return (
7 | <div
8 | className={cn(
9 | 'text-normal flex items-center gap-1.5 rounded-full bg-white/10 px-3 py-2 text-14 -tracking-2',
10 | className,
11 | )}
12 | >
13 | <TbAlertTriangleFilled className='h-5 w-5 shrink-0' />
14 | <span>{children}</span>
15 | </div>
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/bare/progress.tsx:
--------------------------------------------------------------------------------
1 | import * as ProgressPrimitive from '@radix-ui/react-progress'
2 | import {ReactNode} from 'react'
3 | import {isNil} from 'remeda'
4 |
5 | import {cn} from '@/shadcn-lib/utils'
6 |
7 | export function Progress({value, children}: {value?: number; children?: ReactNode}) {
8 | return (
9 | <div className='flex w-full flex-col items-center gap-5'>
10 | <ProgressPrimitive.Root
11 | className={cn(
12 | 'relative h-1.5 w-full overflow-hidden rounded-full bg-white/10 sm:w-[80%]',
13 | isNil(value) && 'umbrel-bouncing-gradient',
14 | )}
15 | >
16 | <ProgressPrimitive.Indicator
17 | className='h-full w-full flex-1 rounded-full bg-white transition-all duration-700'
18 | style={{transform: `translateX(-${100 - (value || 0)}%)`}}
19 | />
20 | </ProgressPrimitive.Root>
21 | {children && <span className='text-15 font-medium leading-none -tracking-2 opacity-80'>{children}</span>}
22 | </div>
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/bare/shared.tsx:
--------------------------------------------------------------------------------
1 | import UmbrelLogo from '@/assets/umbrel-logo'
2 | import {tw} from '@/utils/tw'
3 |
4 | export const bareContainerClass = tw`mt-[10vh] flex-1 flex h-full max-w-full flex-col items-center sm:w-auto`
5 | export const bareTitleClass = tw`sm:text-36 text-24 font-bold -tracking-2`
6 | export const bareTextClass = tw`text-center text-15 font-medium leading-tight -tracking-2 text-white/80`
7 |
8 | export const BareLogoTitle = ({children}: {children: React.ReactNode}) => (
9 | <div className='flex flex-col items-center gap-4'>
10 | <UmbrelLogo />
11 | <h1 className={bareTitleClass}>{children}</h1>
12 | </div>
13 | )
14 |
15 | export const BareSpacer = () => <div className='pt-[50px]' />
16 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/bare/success-layout.tsx:
--------------------------------------------------------------------------------
1 | import {Link, To} from 'react-router-dom'
2 |
3 | import {buttonClass} from '@/layouts/bare/shared'
4 | import {bareContainerClass, BareLogoTitle, BareSpacer, bareTextClass} from '@/modules/bare/shared'
5 | import {cn} from '@/shadcn-lib/utils'
6 |
7 | export function SuccessLayout({
8 | title,
9 | description,
10 | buttonText,
11 | to,
12 | buttonOnClick,
13 | }: {
14 | title: string
15 | description: string
16 | buttonText: string
17 | to?: To
18 | buttonOnClick?: () => void
19 | }) {
20 | return (
21 | <div className={cn(bareContainerClass, 'h-auto w-auto duration-1000 animate-in fade-in zoom-in-95')}>
22 | <BareLogoTitle>{title}</BareLogoTitle>
23 | <p className={cn(bareTextClass, 'w-[80%] sm:w-[55%]')}>{description}</p>
24 | <BareSpacer />
25 | {to && (
26 | <Link to={to} className={buttonClass} onClick={buttonOnClick}>
27 | {buttonText}
28 | </Link>
29 | )}
30 | {!to && (
31 | <button className={buttonClass} onClick={buttonOnClick}>
32 | {buttonText}
33 | </button>
34 | )}
35 | </div>
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/community-app-store/community-badge.tsx:
--------------------------------------------------------------------------------
1 | import {Badge} from '@/shadcn-components/ui/badge'
2 | import {t} from '@/utils/i18n'
3 |
4 | export function CommunityBadge({className}: {className?: string}) {
5 | return (
6 | <Badge variant='primary' className={className}>
7 | {t('community-app-store')}
8 | </Badge>
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/desktop/blur-below-dock.tsx:
--------------------------------------------------------------------------------
1 | export const BlurBelowDock = () => (
2 | // Using 200px because we don't want to intersect the app icons
3 | <div
4 | className='pointer-events-none fixed inset-0 top-0 backdrop-blur-2xl duration-500 animate-in fade-in fill-mode-both'
5 | style={{
6 | background: '#00000044',
7 | WebkitMaskImage: 'linear-gradient(transparent calc(100% - 200px), black calc(100% - 30px))',
8 | }}
9 | />
10 | )
11 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/desktop/greeting-message.ts:
--------------------------------------------------------------------------------
1 | import {t} from '@/utils/i18n'
2 | import {firstNameFromFullName} from '@/utils/misc'
3 |
4 | export function greetingMessage(name: string) {
5 | const firstName = firstNameFromFullName(name)
6 |
7 | const greetingMap = {
8 | morning: t('desktop.greeting.morning', {name: firstName}),
9 | afternoon: t('desktop.greeting.afternoon', {name: firstName}),
10 | evening: t('desktop.greeting.evening', {name: firstName}),
11 | }
12 |
13 | return greetingMap[getPartofDay()] + '.'
14 | }
15 |
16 | function getPartofDay() {
17 | const today = new Date()
18 | const curHr = today.getHours()
19 |
20 | if (curHr < 12) {
21 | return 'morning'
22 | } else if (curHr < 18) {
23 | return 'afternoon'
24 | } else {
25 | return 'evening'
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/desktop/header.tsx:
--------------------------------------------------------------------------------
1 | import UmbrelLogo from '@/assets/umbrel-logo'
2 | import {greetingMessage} from '@/modules/desktop/greeting-message'
3 | import {cn} from '@/shadcn-lib/utils'
4 |
5 | export function Header({userName}: {userName: string}) {
6 | const name = userName
7 | // Always rendering the entire component to avoid layout thrashing
8 | return (
9 | <div className={cn('relative z-10', name ? 'duration-300 animate-in fade-in slide-in-from-bottom-8' : 'invisible')}>
10 | <div className='flex flex-col items-center gap-3 px-4 md:gap-4'>
11 | <UmbrelLogo
12 | className='w-[73px] md:w-auto'
13 | // Need to remove `view-transition-name` because it causes the logo to
14 | // briefly appear over the sheets between page transitions
15 | ref={(ref) => {
16 | ref?.style?.removeProperty('view-transition-name')
17 | }}
18 | />
19 | <h1 className='text-center text-19 font-bold md:text-5xl'>{greetingMessage(name)}</h1>
20 | </div>
21 | </div>
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/migrate/migrate-image.tsx:
--------------------------------------------------------------------------------
1 | import {FadeInImg} from '@/components/ui/fade-in-img'
2 | import {trpcReact} from '@/trpc/trpc'
3 |
4 | const FROM_RASPBERRY_PI_URL = '/figma-exports/migrate-raspberrypi-umbrel-home.png'
5 | const FROM_UMBREL_URL = '/figma-exports/migrate-umbrel-home-umbrel-home.png'
6 |
7 | export function MigrateImage() {
8 | const isMigrationFromUmbrelQ = trpcReact.migration.isMigratingFromUmbrelHome.useQuery()
9 |
10 | const url = isMigrationFromUmbrelQ.data ? FROM_UMBREL_URL : FROM_RASPBERRY_PI_URL
11 |
12 | return <FadeInImg src={url} width={111} height={104} alt='' />
13 | }
14 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/sheet-top-fixed.tsx:
--------------------------------------------------------------------------------
1 | import {Portal} from '@radix-ui/react-portal'
2 | import {ReactNode} from 'react'
3 |
4 | const SHEET_FIXED_ID = 'sheet-fixed-id'
5 |
6 | export function SheetFixedTarget() {
7 | return <div id={SHEET_FIXED_ID} />
8 | }
9 | export function SheetFixedContent({children}: {children: ReactNode}) {
10 | return <Portal container={document.getElementById(SHEET_FIXED_ID)}>{children}</Portal>
11 | }
12 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/widgets/shared/backdrop-blur-context.tsx:
--------------------------------------------------------------------------------
1 | import {createContext} from 'react'
2 |
3 | type Variant = 'with-backdrop-blur' | 'default'
4 | export const BackdropBlurVariantContext = createContext<Variant>('with-backdrop-blur')
5 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/widgets/shared/stat-text.tsx:
--------------------------------------------------------------------------------
1 | import {LOADING_DASH} from '@/constants'
2 |
3 | import {widgetTextCva} from './shared'
4 |
5 | export function StatText({title, value, valueSub}: {title?: string; value?: string; valueSub?: string}) {
6 | return (
7 | // tabular-nums to prevent the numbers from jumping around, especially when showing live data
8 | <div className='flex flex-col gap-1 tabular-nums sm:gap-2'>
9 | {title && <div className={widgetTextCva({opacity: 'secondary'})}>{title}</div>}
10 | <div className='flex min-w-0 items-end gap-1 text-12 font-semibold leading-none -tracking-3 opacity-80 sm:text-24'>
11 | <span className='min-w-0 truncate'>{value ?? LOADING_DASH}</span>
12 | <span className='min-w-0 flex-1 truncate text-13 font-bold opacity-[45%]'>{valueSub}</span>
13 | </div>
14 | </div>
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/widgets/shared/widget-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import {ReactNode} from 'react'
2 |
3 | import {cn} from '@/shadcn-lib/utils'
4 |
5 | export function WidgetWrapper({label, children}: {label: string; children?: ReactNode}) {
6 | return (
7 | <div
8 | className={cn(
9 | 'flex w-[var(--widget-w)] flex-col items-center justify-between',
10 | label && 'h-[var(--widget-labeled-h)]',
11 | )}
12 | >
13 | {children}
14 | {label && (
15 | <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'>
16 | {label}
17 | </div>
18 | )}
19 | </div>
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/wifi/desktop-wifi-button-connected.tsx:
--------------------------------------------------------------------------------
1 | import {Link} from 'react-router-dom'
2 |
3 | import {WifiIcon2} from '@/modules/wifi/icon'
4 | import {cn} from '@/shadcn-lib/utils'
5 | import {trpcReact} from '@/trpc/trpc'
6 | import {signalToBars} from '@/utils/wifi'
7 |
8 | export function DesktopWifiButtonConnected({className}: {className?: string}) {
9 | const wifiQ = trpcReact.wifi.connected.useQuery()
10 |
11 | if (wifiQ.isLoading || wifiQ.data?.status !== 'connected') return null
12 |
13 | return (
14 | <Link
15 | className={cn(
16 | 'rounded-6 outline-none ring-white/20 transition-[background,shadow] animate-in fade-in focus-visible:bg-white/6 focus-visible:ring-2 focus-visible:backdrop-blur-sm',
17 | className,
18 | )}
19 | to='/settings/wifi'
20 | >
21 | <WifiIcon2 bars={signalToBars(wifiQ.data?.signal ?? 0)} className='size-9' />
22 | </Link>
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/packages/ui/src/modules/wifi/wifi-list-row-connected-description.tsx:
--------------------------------------------------------------------------------
1 | import {UNKNOWN} from '@/constants'
2 | import {WifiNetwork} from '@/trpc/trpc'
3 | import {signalToBars} from '@/utils/wifi'
4 |
5 | import {LockIcon, WifiIcon2} from './icon'
6 |
7 | export function WifiListRowConnectedDescription({network}: {network: Partial<WifiNetwork>}) {
8 | return (
9 | // `h-3` prevents height from being different between the `disconnected` and `connected` states
10 | <span className='flex h-3 items-center gap-1'>
11 | <WifiIcon2 bars={signalToBars(network.signal ?? 0)} className='size-4' />
12 | {network.ssid ?? UNKNOWN()}
13 | {network.authenticated && <LockIcon />}
14 | </span>
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/packages/ui/src/providers/confirmation/confirmation-context.tsx:
--------------------------------------------------------------------------------
1 | import {createContext} from 'react'
2 |
3 | import type {ConfirmationContextType} from '@/providers/confirmation/types'
4 |
5 | // Create the context with a default value
6 | export const ConfirmationContext = createContext<ConfirmationContextType | undefined>(undefined)
7 |
--------------------------------------------------------------------------------
/packages/ui/src/providers/confirmation/index.ts:
--------------------------------------------------------------------------------
1 | // TODO: Use this everywhere instead of repetitive dialogs for simple confirmations (eg. restart, logout, download file, etc)
2 |
3 | export * from '@/providers/confirmation/types'
4 | export * from '@/providers/confirmation/confirmation-context'
5 | export * from '@/providers/confirmation/confirmation-provider'
6 | export * from '@/providers/confirmation/use-confirmation'
7 | export * from '@/providers/confirmation/generic-confirmation-dialog'
8 |
--------------------------------------------------------------------------------
/packages/ui/src/providers/confirmation/use-confirmation.ts:
--------------------------------------------------------------------------------
1 | import {useContext} from 'react'
2 |
3 | import {ConfirmationContext} from '@/providers/confirmation/confirmation-context'
4 | import type {ConfirmationOptions, ConfirmationResult} from '@/providers/confirmation/types'
5 |
6 | export const useConfirmation = () => {
7 | const context = useContext(ConfirmationContext)
8 |
9 | if (!context) {
10 | throw new Error('useConfirmation must be used within a ConfirmationProvider')
11 | }
12 |
13 | const confirm = (options: ConfirmationOptions): Promise<ConfirmationResult> => {
14 | return new Promise((resolve, reject) => {
15 | context.requestConfirmation(options, resolve, reject)
16 | })
17 | }
18 |
19 | return confirm
20 | }
21 |
--------------------------------------------------------------------------------
/packages/ui/src/providers/global-system-state/restart.tsx:
--------------------------------------------------------------------------------
1 | import {CoverMessage, CoverMessageParagraph} from '@/components/ui/cover-message'
2 | import {Loading} from '@/components/ui/loading'
3 | import {trpcReact} from '@/trpc/trpc'
4 | import {t} from '@/utils/i18n'
5 |
6 | export function useRestart({onMutate, onSuccess}: {onMutate?: () => void; onSuccess?: (didWork: boolean) => void}) {
7 | const restartMut = trpcReact.system.restart.useMutation({
8 | onMutate,
9 | onSuccess,
10 | })
11 | const restart = restartMut.mutate
12 |
13 | return restart
14 | }
15 |
16 | export function RestartingCover() {
17 | return (
18 | <CoverMessage>
19 | <Loading>{t('restart.restarting')}</Loading>
20 | <CoverMessageParagraph>{t('restart.restarting-message')}</CoverMessageParagraph>
21 | </CoverMessage>
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/packages/ui/src/providers/global-system-state/shutdown.tsx:
--------------------------------------------------------------------------------
1 | import {CoverMessage, CoverMessageParagraph} from '@/components/ui/cover-message'
2 | import {Loading} from '@/components/ui/loading'
3 | import {trpcReact} from '@/trpc/trpc'
4 | import {t} from '@/utils/i18n'
5 |
6 | export function useShutdown({onMutate, onSuccess}: {onMutate?: () => void; onSuccess?: (didWork: boolean) => void}) {
7 | const shutdownMut = trpcReact.system.shutdown.useMutation({
8 | onMutate,
9 | onSuccess,
10 | })
11 | const shutdown = shutdownMut.mutate
12 |
13 | return shutdown
14 | }
15 |
16 | export function ShuttingDownCover() {
17 | return (
18 | <CoverMessage>
19 | <Loading>{t('shut-down.shutting-down')}</Loading>
20 | <CoverMessageParagraph>{t('shut-down.shutting-down-message')}</CoverMessageParagraph>
21 | </CoverMessage>
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/packages/ui/src/providers/language.tsx:
--------------------------------------------------------------------------------
1 | import i18next from 'i18next'
2 | import {arrayIncludes} from 'ts-extras'
3 |
4 | import {trpcReact} from '@/trpc/trpc'
5 | import {supportedLanguageCodes} from '@/utils/language'
6 |
7 | export function RemoteLanguageInjector() {
8 | const languageQ = trpcReact.user.language.useQuery()
9 |
10 | const activeLanguage = i18next.language
11 | const preferredLanguage = languageQ.data
12 |
13 | if (arrayIncludes(supportedLanguageCodes, preferredLanguage)) {
14 | // Reconfigure i18n and reload the page when the preferred language
15 | // changed on the backend and now differs from the active language
16 | localStorage.setItem('i18nextLng', preferredLanguage)
17 | if (preferredLanguage !== activeLanguage) {
18 | window.location.reload()
19 | }
20 | }
21 |
22 | return null
23 | }
24 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/README.md:
--------------------------------------------------------------------------------
1 | Non-route files and folder should be prefixed with an underscore. This is to prevent confusion.
2 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/app-store/use-discover-query.tsx:
--------------------------------------------------------------------------------
1 | import {useQuery} from '@tanstack/react-query'
2 |
3 | import {Categoryish} from '@/modules/app-store/constants'
4 |
5 | export type Banner = {
6 | id: string
7 | image: string
8 | }
9 |
10 | export type Section = {
11 | type: string
12 | heading: string
13 | subheading: string
14 | apps: string[]
15 | textLocation?: 'left' | 'right' | undefined
16 | description?: string
17 | category?: Categoryish
18 | }
19 |
20 | export type DiscoverData = {
21 | banners: Banner[]
22 | sections: Section[]
23 | }
24 |
25 | export function useDiscoverQuery() {
26 | const discoverQ = useQuery<{data: DiscoverData}>({
27 | queryKey: ['app-store', 'discover'],
28 | queryFn: () => fetch('https://apps.umbrel.com/api/v2/umbrelos/app-store/discover').then((res) => res.json()),
29 | })
30 |
31 | return {...discoverQ, data: discoverQ.data?.data}
32 | }
33 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/edit-widgets/index.tsx:
--------------------------------------------------------------------------------
1 | import {useState} from 'react'
2 | import {useNavigate} from 'react-router-dom'
3 |
4 | import {useApps} from '@/providers/apps'
5 | import {afterDelayedClose} from '@/utils/dialog'
6 | import {t} from '@/utils/i18n'
7 |
8 | import {WidgetSelector} from './widget-selector'
9 |
10 | export default function EditWidgetsPage() {
11 | const navigate = useNavigate()
12 | const [open, setOpen] = useState(true)
13 |
14 | const {userApps, isLoading: isUserAppsLoading} = useApps()
15 | const hasInstalledApps = !isUserAppsLoading && (userApps ?? []).length > 0
16 |
17 | if (!hasInstalledApps) {
18 | return (
19 | <div className='absolute inset-0 grid h-full w-full place-items-center'>
20 | <div className='drop-shadow-desktop-label'>{t('widgets.install-an-app-before-using-widgets')}</div>
21 | </div>
22 | )
23 | }
24 |
25 | return (
26 | <WidgetSelector
27 | open={open}
28 | onOpenChange={(open) => {
29 | setOpen(open)
30 | afterDelayedClose(() => navigate('/'))(open)
31 | }}
32 | />
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/factory-reset/_components/misc.tsx:
--------------------------------------------------------------------------------
1 | import {t} from '@/utils/i18n'
2 |
3 | // In a function because otherwise translation won't always work
4 | // Could also put into a hook or component
5 | export const title = () => t('factory-reset')
6 | export const description = () => t('factory-reset-description')
7 |
8 | export const backPath = '/settings'
9 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/factory-reset/_components/success.tsx:
--------------------------------------------------------------------------------
1 | import {Link} from 'react-router-dom'
2 |
3 | import {buttonClass} from '@/layouts/bare/shared'
4 | import {bareContainerClass, BareLogoTitle, BareSpacer, bareTextClass} from '@/modules/bare/shared'
5 | import {cn} from '@/shadcn-lib/utils'
6 | import {t} from '@/utils/i18n'
7 |
8 | export function Success() {
9 | const title = t('factory-reset.success.title')
10 | return (
11 | <div className={bareContainerClass}>
12 | <BareLogoTitle>{title}</BareLogoTitle>
13 | <p className={cn(bareTextClass, 'w-[80%] sm:w-[55%]')}>{t('factory-reset.success.description')}</p>
14 | <BareSpacer />
15 | {/* Skip client side routing and reload to refresh state */}
16 | <Link reloadDocument to='/' className={buttonClass}>
17 | {t('continue')}
18 | </Link>
19 | </div>
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/settings/2fa.tsx:
--------------------------------------------------------------------------------
1 | import {useState} from 'react'
2 |
3 | import {use2fa} from '@/hooks/use-2fa'
4 | import TwoFactorDisableDialog from '@/routes/settings/2fa-disable'
5 | import TwoFactorEnableDialog from '@/routes/settings/2fa-enable'
6 |
7 | export function TwoFactorDialog() {
8 | const {isEnabled} = use2fa()
9 |
10 | // Need to do this because when the child component `isEnabled` changes, the other dialog will appear for a split second before the dialog closes
11 | const [mountEnabled] = useState(isEnabled)
12 |
13 | if (mountEnabled) {
14 | return <TwoFactorDisableDialog />
15 | } else {
16 | return <TwoFactorEnableDialog />
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/settings/_components/cpu-card-content.tsx:
--------------------------------------------------------------------------------
1 | import {useCpuForUi} from '@/hooks/use-cpu'
2 | import {t} from '@/utils/i18n'
3 |
4 | import {ProgressStatCardContent} from './progress-card-content'
5 |
6 | export function CpuCardContent() {
7 | const {value, secondaryValue, progress} = useCpuForUi()
8 |
9 | return <ProgressStatCardContent title={t('cpu')} value={value} secondaryValue={secondaryValue} progress={progress} />
10 | }
11 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/settings/_components/memory-card-content.tsx:
--------------------------------------------------------------------------------
1 | import {useSystemMemoryForUi} from '@/hooks/use-memory'
2 | import {t} from '@/utils/i18n'
3 |
4 | import {ProgressStatCardContent} from './progress-card-content'
5 | import {cardErrorClass} from './shared'
6 |
7 | export function MemoryCardContent() {
8 | const {value, valueSub, secondaryValue, progress, isMemoryLow} = useSystemMemoryForUi()
9 |
10 | return (
11 | <ProgressStatCardContent
12 | title={t('memory')}
13 | value={value}
14 | valueSub={valueSub}
15 | secondaryValue={secondaryValue}
16 | progress={progress}
17 | afterChildren={isMemoryLow && <span className={cardErrorClass}>{t('memory.low')}</span>}
18 | />
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/settings/_components/no-forgot-password-message.tsx:
--------------------------------------------------------------------------------
1 | export function NoForgotPasswordMessage() {
2 | return (
3 | <p className='text-12 font-normal leading-tight -tracking-2 text-white/40'>
4 | There is no ‘Forgot password’ option, so we recommend you write down your password physically somewhere, in case
5 | you forget.
6 | {/* Add this back when we do password strength checking */}
7 | {/* 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 */}
8 | {/* <Trans i18nKey='no-forgot-password-message' components={{em: <em className='not-italic text-success-light' />}} /> */}
9 | </p>
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/settings/_components/storage-card-content.tsx:
--------------------------------------------------------------------------------
1 | import {useSystemDiskForUi} from '@/hooks/use-disk'
2 | import {t} from '@/utils/i18n'
3 |
4 | import {ProgressStatCardContent} from './progress-card-content'
5 | import {cardErrorClass} from './shared'
6 |
7 | export function StorageCardContent() {
8 | const {value, valueSub, secondaryValue, progress, isDiskLow, isDiskFull} = useSystemDiskForUi()
9 |
10 | return (
11 | <ProgressStatCardContent
12 | title={t('storage')}
13 | value={value}
14 | valueSub={valueSub}
15 | secondaryValue={secondaryValue}
16 | progress={progress}
17 | afterChildren={
18 | <>
19 | {isDiskLow && <span className={cardErrorClass}>{t('storage.low')}</span>}
20 | {isDiskFull && <span className={cardErrorClass}>{t('storage.full')}</span>}
21 | </>
22 | }
23 | />
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/settings/app-store-preferences.tsx:
--------------------------------------------------------------------------------
1 | import {Dialog, DialogContent, DialogHeader, DialogPortal, DialogTitle} from '@/shadcn-components/ui/dialog'
2 | import {useDialogOpenProps} from '@/utils/dialog'
3 | import {t} from '@/utils/i18n'
4 |
5 | import {AppStorePreferencesContent} from './_components/app-store-preferences-content'
6 |
7 | export default function AppStorePreferencesDialog() {
8 | const dialogProps = useDialogOpenProps('app-store-preferences')
9 |
10 | return (
11 | <Dialog {...dialogProps}>
12 | <DialogPortal>
13 | <DialogContent className='p-0'>
14 | <div className='umbrel-dialog-fade-scroller space-y-6 overflow-y-auto px-5 py-6'>
15 | <DialogHeader>
16 | <DialogTitle>{t('app-store.title')}</DialogTitle>
17 | </DialogHeader>
18 | <AppStorePreferencesContent />
19 | </div>
20 | </DialogContent>
21 | </DialogPortal>
22 | </Dialog>
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/settings/mobile/app-store-preferences.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Drawer,
3 | DrawerContent,
4 | DrawerDescription,
5 | DrawerHeader,
6 | DrawerScroller,
7 | DrawerTitle,
8 | } from '@/shadcn-components/ui/drawer'
9 | import {useDialogOpenProps} from '@/utils/dialog'
10 | import {t} from '@/utils/i18n'
11 |
12 | import {AppStorePreferencesContent} from '../_components/app-store-preferences-content'
13 |
14 | export function AppStorePreferencesDrawer() {
15 | const title = t('settings.app-store-preferences.title')
16 | const dialogProps = useDialogOpenProps('app-store-preferences')
17 |
18 | return (
19 | <Drawer {...dialogProps}>
20 | <DrawerContent fullHeight withScroll>
21 | <DrawerHeader>
22 | <DrawerTitle>{title}</DrawerTitle>
23 | <DrawerDescription>{t('app-store.description')}</DrawerDescription>
24 | </DrawerHeader>
25 | <DrawerScroller>
26 | <div className='flex flex-col gap-5'>
27 | <AppStorePreferencesContent />
28 | </div>
29 | </DrawerScroller>
30 | </DrawerContent>
31 | </Drawer>
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/settings/terminal/umbrelos.tsx:
--------------------------------------------------------------------------------
1 | import {ImmersivePickerDialogContent} from '@/modules/immersive-picker'
2 | import {TerminalTitleBackLink, XTermTerminal} from '@/routes/settings/terminal/_shared'
3 |
4 | export default function UmbrelOs() {
5 | return (
6 | <ImmersivePickerDialogContent>
7 | <div className='flex w-full flex-wrap items-center justify-between'>
8 | <TerminalTitleBackLink />
9 | </div>
10 | <XTermTerminal />
11 | </ImmersivePickerDialogContent>
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/packages/ui/src/routes/settings/wifi.tsx:
--------------------------------------------------------------------------------
1 | import {WifiDrawerOrDialog, WifiDrawerOrDialogContent} from '@/modules/wifi/wifi-drawer-or-dialog'
2 | import {useSettingsDialogProps} from '@/routes/settings/_components/shared'
3 |
4 | export default function Wifi() {
5 | const dialogProps = useSettingsDialogProps()
6 |
7 | return (
8 | <WifiDrawerOrDialog {...dialogProps}>
9 | <WifiDrawerOrDialogContent />
10 | </WifiDrawerOrDialog>
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/packages/ui/src/shadcn-components/ui/button-styles.css:
--------------------------------------------------------------------------------
1 | .umbrel-button:active {
2 | scale: 0.97;
3 | }
4 |
--------------------------------------------------------------------------------
/packages/ui/src/shadcn-components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as LabelPrimitive from '@radix-ui/react-label'
2 | import {cva, type VariantProps} from 'class-variance-authority'
3 | import * as React from 'react'
4 |
5 | import {cn} from '@/shadcn-lib/utils'
6 |
7 | const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70')
8 |
9 | const Label = React.forwardRef<
10 | React.ElementRef<typeof LabelPrimitive.Root>,
11 | React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
12 | >(({className, ...props}, ref) => (
13 | <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
14 | ))
15 | Label.displayName = LabelPrimitive.Root.displayName
16 |
17 | export {Label}
18 |
--------------------------------------------------------------------------------
/packages/ui/src/shadcn-components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as SeparatorPrimitive from '@radix-ui/react-separator'
2 | import * as React from 'react'
3 |
4 | import {cn} from '@/shadcn-lib/utils'
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef<typeof SeparatorPrimitive.Root>,
8 | React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
9 | >(({className, orientation = 'horizontal', decorative = true, ...props}, ref) => (
10 | <SeparatorPrimitive.Root
11 | ref={ref}
12 | decorative={decorative}
13 | orientation={orientation}
14 | className={cn(
15 | 'shrink-0 from-transparent via-white/10 to-transparent',
16 | orientation === 'horizontal' ? 'h-[1px] w-full bg-gradient-to-r' : 'h-full w-[1px] bg-gradient-to-b',
17 | className,
18 | )}
19 | {...props}
20 | />
21 | ))
22 | Separator.displayName = SeparatorPrimitive.Root.displayName
23 |
24 | export {Separator}
25 |
--------------------------------------------------------------------------------
/packages/ui/src/shadcn-lib/utils.ts:
--------------------------------------------------------------------------------
1 | import {clsx, type ClassValue} from 'clsx'
2 | import {extendTailwindMerge} from 'tailwind-merge'
3 |
4 | const num = (classPart: string) => /^\d+$/.test(classPart)
5 |
6 | const customTwMerge = extendTailwindMerge({
7 | classGroups: {
8 | // Without this, styles like text-12 don't work properly with other text-* styles
9 | 'font-size': [{text: ['base', num]}],
10 | // Allows cn('rounded-12', 'rounded-20') to cause the 20 to override the 12
11 | 'border-radius': [{rounded: ['base', num]}],
12 | 'border-width': [{border: ['hpx']}],
13 | },
14 | })
15 |
16 | export function cn(...inputs: ClassValue[]) {
17 | return customTwMerge(clsx(inputs))
18 | }
19 |
--------------------------------------------------------------------------------
/packages/ui/src/trpc/loading-indicator.tsx:
--------------------------------------------------------------------------------
1 | import {Portal} from '@radix-ui/react-portal'
2 | import {useIsFetching, useIsMutating} from '@tanstack/react-query'
3 | import {TbLoader} from 'react-icons/tb'
4 |
5 | export function LoadingIndicator() {
6 | const isFetching = useIsFetching()
7 | const isMutating = useIsMutating()
8 |
9 | if (!isFetching && !isMutating) {
10 | return null
11 | }
12 |
13 | return (
14 | <Portal>
15 | <div className='fixed bottom-1.5 left-1.5 z-50'>
16 | <TbLoader className='white h-3 w-3 animate-spin opacity-50 shadow-sm' />
17 | </div>
18 | </Portal>
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/packages/ui/src/trpc/trpc-provider.tsx:
--------------------------------------------------------------------------------
1 | import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
2 | import {useState} from 'react'
3 |
4 | import {MS_PER_MINUTE} from '@/utils/date-time'
5 | import {IS_DEV} from '@/utils/misc'
6 |
7 | import {LoadingIndicator} from './loading-indicator'
8 | import {links, trpcReact} from './trpc'
9 |
10 | export const TrpcProvider: React.FC<{children: React.ReactNode}> = ({children}) => {
11 | const [queryClient] = useState(
12 | () =>
13 | new QueryClient({
14 | defaultOptions: {queries: {staleTime: MS_PER_MINUTE}},
15 | }),
16 | )
17 |
18 | const [trpcClient] = useState(() => trpcReact.createClient({links}))
19 |
20 | return (
21 | <trpcReact.Provider client={trpcClient} queryClient={queryClient}>
22 | <QueryClientProvider client={queryClient}>
23 | {children}
24 | {IS_DEV && <LoadingIndicator />}
25 | </QueryClientProvider>
26 | </trpcReact.Provider>
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/element-classes.ts:
--------------------------------------------------------------------------------
1 | import {tw} from '@/utils/tw'
2 |
3 | export const linkClass = tw`transition-colors text-brand-lighter hover:text-brand hover:underline underline-offset-4 decoration-brand/30 outline-none focus:underline focus:text-brand`
4 |
5 | export const dialogHeaderCircleButtonClass = tw`rounded-full outline-none opacity-30 hover:opacity-40`
6 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/language.ts:
--------------------------------------------------------------------------------
1 | import {map} from 'remeda'
2 |
3 | export const languages = [
4 | {name: 'English', code: 'en'},
5 | {name: 'Deutsch', code: 'de'},
6 | {name: 'Español', code: 'es'},
7 | {name: 'Français', code: 'fr'},
8 | {name: 'Italiano', code: 'it'},
9 | {name: '한국어', code: 'ko'},
10 | {name: 'Magyar', code: 'hu'},
11 | {name: 'Nederlands', code: 'nl'},
12 | {name: 'Português', code: 'pt'},
13 | {name: 'Українська', code: 'uk'},
14 | {name: 'Türkçe', code: 'tr'},
15 | {name: '日本語', code: 'ja'},
16 | ] as const
17 |
18 | export const supportedLanguageCodes = map(languages, (entry) => entry.code)
19 |
20 | export type SupportedLanguageCode = (typeof supportedLanguageCodes)[number]
21 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/number.ts:
--------------------------------------------------------------------------------
1 | import i18next from 'i18next'
2 |
3 | export function formatNumberI18n({n, showDecimals = true}: {n: number; showDecimals?: boolean}) {
4 | return new Intl.NumberFormat(i18next.language || 'en-US', {
5 | minimumFractionDigits: showDecimals ? 2 : 0,
6 | maximumFractionDigits: showDecimals ? 2 : 0,
7 | }).format(n)
8 | }
9 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/pretty-bytes.ts:
--------------------------------------------------------------------------------
1 | import i18next from 'i18next'
2 | import prettyBytes from 'pretty-bytes'
3 |
4 | import {LOADING_DASH} from '@/constants'
5 |
6 | export function maybePrettyBytes(n: number | undefined | null) {
7 | if (n === null) return LOADING_DASH
8 | if (n === undefined) return LOADING_DASH
9 | // TODO: pass in locale
10 | return prettyBytes(n, {locale: i18next.language})
11 | }
12 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/search.ts:
--------------------------------------------------------------------------------
1 | import Fuse from 'fuse.js'
2 |
3 | const fuseOptions = {
4 | // https://www.fusejs.io/api/options.html
5 | isCaseSensitive: false,
6 | includeScore: false,
7 | includeMatches: false,
8 | minMatchCharLength: 2,
9 | shouldSort: true,
10 | findAllMatches: false,
11 | location: 0,
12 | threshold: 0.3,
13 | distance: 100,
14 | ignoreLocation: false,
15 | useExtendedSearch: false,
16 | ignoreFieldNorm: false,
17 | fieldNormWeight: 1,
18 | }
19 |
20 | export type SearchKey = {
21 | name: string
22 | weight: number
23 | }
24 |
25 | export function createSearch<T>(items: T[], keys: SearchKey[]) {
26 | const fuse = new Fuse<T>(items, {
27 | ...fuseOptions,
28 | keys,
29 | })
30 | return (pattern: string, limit = 60) => {
31 | const normalizedPattern = pattern.trim().replace(/\s+/g, ' ')
32 | const results = fuse.search(normalizedPattern, {limit})
33 | return results.map((result) => result.item)
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/seconds-to-eta.ts:
--------------------------------------------------------------------------------
1 | export function secondsToEta(seconds: number | null | undefined): string {
2 | if (seconds == null || seconds <= 0 || !Number.isFinite(seconds)) {
3 | return '-'
4 | }
5 |
6 | if (seconds < 60) {
7 | return `${Math.round(seconds)}s`
8 | }
9 |
10 | if (seconds < 3600) {
11 | return `${Math.round(seconds / 60)}m`
12 | }
13 |
14 | const hours = Math.floor(seconds / 3600)
15 | const minutes = Math.round((seconds % 3600) / 60)
16 | return `${hours}hr ${minutes}m`
17 | }
18 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/temperature.ts:
--------------------------------------------------------------------------------
1 | import {LOADING_DASH} from '@/constants'
2 | import {t} from '@/utils/i18n'
3 |
4 | export function celciusToFahrenheit(temperatureInCelcius?: number) {
5 | if (temperatureInCelcius === undefined) return undefined
6 | return Math.round((temperatureInCelcius * 9) / 5 + 32)
7 | }
8 |
9 | export function temperatureWarningToColor(warning?: string) {
10 | if (warning === undefined) return '#CCCCCC'
11 |
12 | if (warning === 'warm') {
13 | return '#E6E953'
14 | }
15 | if (warning === 'hot') {
16 | return '#F45252'
17 | }
18 | return '#96F16B'
19 | }
20 |
21 | export function temperatureWarningToMessage(warning?: string) {
22 | if (warning === undefined) return LOADING_DASH
23 |
24 | if (warning === 'normal') {
25 | return t('temperature.normal')
26 | }
27 | if (warning === 'warm') {
28 | return t('temperature.warm')
29 | }
30 | if (warning === 'hot') {
31 | return t('temperature.dangerously-hot')
32 | }
33 | return t('temperature.normal')
34 | }
35 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/tw.ts:
--------------------------------------------------------------------------------
1 | import {createBreakpoint} from 'react-use'
2 |
3 | /** Pairs with `.vscode/settings.json` to provide intellisense for tailwind classes:
4 | * ```json
5 | * "tailwindCSS.experimental.classRegex": [
6 | * "tw`([^`]*)`"
7 | * ]
8 | * ```
9 | */
10 | export const tw = (strings: TemplateStringsArray) => strings.join('')
11 |
12 | export const screens = {
13 | sm: 640,
14 | md: 768,
15 | lg: 1024,
16 | xl: 1280,
17 | '2xl': 1400,
18 | }
19 |
20 | export const useBreakpoint = createBreakpoint(screens)
21 |
--------------------------------------------------------------------------------
/packages/ui/src/utils/wifi.ts:
--------------------------------------------------------------------------------
1 | export function signalToBars(signal: number) {
2 | const bars = Math.ceil(signal / 25)
3 | return bars
4 | }
5 |
--------------------------------------------------------------------------------
/packages/ui/stories/.env.example:
--------------------------------------------------------------------------------
1 | VITE_PROXY_BACKEND=http://umbrel-dev.local
--------------------------------------------------------------------------------
/packages/ui/stories/README.md:
--------------------------------------------------------------------------------
1 | # Stories
2 |
3 | ## With backend
4 |
5 | Create a `.env` file in `/packages/ui/stories` from the root of the repo. Add `VITE_PROXY_BACKEND=http://umbrel-dev.local`
6 |
7 | Run the stories:
8 |
9 | ```sh
10 | pnpm run stories
11 | ```
12 |
13 | NOTE: many of the stories don't require a backend.
14 |
--------------------------------------------------------------------------------
/packages/ui/stories/index.html:
--------------------------------------------------------------------------------
1 | <!doctype html>
2 | <html class="h-full min-h-full">
3 | <head>
4 | <meta charset="UTF-8" />
5 | <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
6 | <meta name="theme-color" content="#000000" />
7 | <meta name="robots" content="noindex, nofollow" />
8 | <meta name="referrer" content="no-referrer" />
9 |
10 | <link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
11 | <link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
12 | <link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
13 | <link rel="manifest" href="/site.webmanifest" />
14 |
15 | <title>Stories</title>
16 | </head>
17 | <body style="background: black; color: white" class="h-full min-h-full">
18 | <div id="root" class="h-full min-h-full"></div>
19 | <script type="module" src="/src/main.tsx"></script>
20 | </body>
21 | </html>
22 |
--------------------------------------------------------------------------------
/packages/ui/stories/src/components/index.tsx:
--------------------------------------------------------------------------------
1 | import {ReactNode} from 'react'
2 |
3 | export const H1 = ({children}: {children: ReactNode}) => <h1 className='text-3xl font-bold'>{children}</h1>
4 | export const H2 = ({children}: {children: ReactNode}) => (
5 | <h2 className='w-full border-t border-white/50 pt-1 text-2xl'>{children}</h2>
6 | )
7 | export const H3 = ({children}: {children: ReactNode}) => <h3 className='text-xl font-bold'>{children}</h3>
8 |
--------------------------------------------------------------------------------
/packages/ui/stories/src/components/wallpaper-dropdown.tsx:
--------------------------------------------------------------------------------
1 | import {TbPhoto} from 'react-icons/tb'
2 |
3 | import {ChevronDown} from '@/assets/chevron-down'
4 | import {IconButton} from '@/components/ui/icon-button'
5 | import {WallpaperPicker} from '@/routes/settings/_components/wallpaper-picker'
6 | import {DropdownMenu, DropdownMenuContent, DropdownMenuTrigger} from '@/shadcn-components/ui/dropdown-menu'
7 |
8 | export function WallpaperDropdown() {
9 | return (
10 | <DropdownMenu>
11 | <DropdownMenuTrigger asChild>
12 | <IconButton icon={TbPhoto}>
13 | Wallpaper
14 | <ChevronDown />
15 | </IconButton>
16 | </DropdownMenuTrigger>
17 | <DropdownMenuContent>
18 | <WallpaperPicker maxW={300} />
19 | </DropdownMenuContent>
20 | </DropdownMenu>
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/packages/ui/stories/src/routes/demo/two.tsx:
--------------------------------------------------------------------------------
1 | import {Link} from 'react-router-dom'
2 |
3 | export default function Two() {
4 | return (
5 | <div>
6 | <h1 className='text-3xl font-bold text-blue-500 underline'>Two</h1>
7 | <Link to='/one' unstable_viewTransition>
8 | to Index
9 | </Link>
10 | <div
11 | id='box'
12 | className='relative left-10 top-10 h-64 w-64 bg-blue-500'
13 | style={{
14 | viewTransitionName: 'box',
15 | }}
16 | ></div>
17 | </div>
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/packages/ui/stories/src/routes/stories/cmdk.tsx:
--------------------------------------------------------------------------------
1 | import {H1} from '@stories/components'
2 |
3 | import {CmdkMenu, CmdkProvider, useCmdkOpen} from '@/components/cmdk'
4 | import {Search} from '@/modules/desktop/desktop-misc'
5 | import {AppsProvider} from '@/providers/apps'
6 | import {Wallpaper} from '@/providers/wallpaper'
7 |
8 | export default function CmdkStory() {
9 | return (
10 | <CmdkProvider>
11 | <Inner />
12 | </CmdkProvider>
13 | )
14 | }
15 |
16 | function Inner() {
17 | const {setOpen} = useCmdkOpen()
18 |
19 | return (
20 | <div className='relative z-0'>
21 | <Wallpaper />
22 | <div className='relative flex flex-col items-center gap-4 p-4'>
23 | <H1>CMDK</H1>
24 | <Search onClick={() => setOpen(true)} />
25 | </div>
26 | <AppsProvider>
27 | <CmdkMenu />
28 | </AppsProvider>
29 | </div>
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/packages/ui/stories/src/routes/stories/error.tsx:
--------------------------------------------------------------------------------
1 | import {useErrorBoundary} from 'react-error-boundary'
2 |
3 | import {Button} from '@/shadcn-components/ui/button'
4 |
5 | export default function ErrorStory() {
6 | const {showBoundary} = useErrorBoundary()
7 |
8 | return (
9 | <div>
10 | <Button
11 | variant='primary'
12 | onClick={() => {
13 | showBoundary(new Error('Error thrown from button'))
14 | }}
15 | >
16 | Throw error
17 | </Button>
18 | <Button
19 | variant='primary'
20 | onClick={() => {
21 | showBoundary(
22 | new Error(
23 | 'Sit Lorem occaecat dolore ad reprehenderit sit reprehenderit. Quis aliquip irure tempor esse laborum aute quis incididunt consectetur sunt commodo enim pariatur.',
24 | ),
25 | )
26 | }}
27 | >
28 | Throw long error
29 | </Button>
30 | </div>
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/packages/ui/stories/src/routes/stories/migrate.tsx:
--------------------------------------------------------------------------------
1 | import {toast} from 'sonner'
2 |
3 | import {useDemoMigrateProgress} from '@/hooks/use-demo-progress'
4 | import {MigrateInner} from '@/modules/migrate/migrate-inner'
5 |
6 | export default function MigrateStory() {
7 | return (
8 | <>
9 | <MigrateStory1 />
10 | <MigrateStory2 />
11 | </>
12 | )
13 | }
14 |
15 | export function MigrateStory1() {
16 | return <MigrateInner progress={0} message='Starting...' isRunning={false} />
17 | }
18 |
19 | export function MigrateStory2() {
20 | const {progress} = useDemoMigrateProgress({
21 | onSuccess: () => {
22 | toast.success('Migration successful')
23 | },
24 | onFail: () => {
25 | toast.error('Migration failed')
26 | },
27 | })
28 |
29 | return <MigrateInner progress={progress} message='Migrating...' isRunning={true} />
30 | }
31 |
--------------------------------------------------------------------------------
/packages/ui/stories/src/routes/stories/sheet.tsx:
--------------------------------------------------------------------------------
1 | import {Wallpaper} from '@/providers/wallpaper'
2 | import {Sheet, SheetContent} from '@/shadcn-components/ui/sheet'
3 |
4 | export default function SheetStory() {
5 | return (
6 | <>
7 | <Wallpaper />
8 | <Sheet defaultOpen>
9 | <SheetContent className='mx-auto h-[calc(100dvh-16px)] max-w-[1320px] pb-6 lg:h-[calc(100dvh-60px)] lg:w-[calc(100vw-60px-60px)]'>
10 | <div className='umbrel-dialog-fade-scroller flex h-full flex-col gap-5 overflow-y-auto pt-12 md:px-8'>
11 | Hello
12 | </div>
13 | </SheetContent>
14 | </Sheet>
15 | </>
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/packages/ui/stories/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import react from '@vitejs/plugin-react-swc'
3 | import {defineConfig, loadEnv} from 'vite'
4 |
5 | export default defineConfig(({mode}) => {
6 | // Load env file based on `mode` in the current working directory.
7 | // Set the third parameter to '' to load all env regardless of the `VITE_` prefix.
8 | const env = loadEnv(mode, path.resolve(__dirname), '')
9 |
10 | console.log(env.VITE_PROXY_BACKEND)
11 |
12 | return {
13 | plugins: [react()],
14 | root: 'stories',
15 | publicDir: '../public',
16 |
17 | resolve: {
18 | alias: {
19 | '@/': `${path.resolve(__dirname, '../src')}/`,
20 | '@stories/': `${path.resolve(__dirname, './src')}/`,
21 | },
22 | },
23 |
24 | build: {
25 | outDir: '../dist',
26 | },
27 |
28 | // Proxy requests to the backend server.
29 | // This is useful when running stories locally and want to connect to a remote backend.
30 | server: {
31 | proxy: {
32 | '/trpc': env.VITE_PROXY_BACKEND,
33 | },
34 | },
35 | }
36 | })
37 |
--------------------------------------------------------------------------------
/packages/ui/tests/example.spec.ts:
--------------------------------------------------------------------------------
1 | // import { test, expect } from '@playwright/test';
2 |
3 | // test('has title', async ({ page }) => {
4 | // await page.goto('https://playwright.dev/');
5 |
6 | // // Expect a title "to contain" a substring.
7 | // await expect(page).toHaveTitle(/Playwright/);
8 | // });
9 |
10 | // test('get started link', async ({ page }) => {
11 | // await page.goto('https://playwright.dev/');
12 |
13 | // // Click the get started link.
14 | // await page.getByRole('link', { name: 'Get started' }).click();
15 |
16 | // // Expects page to have a heading with the name of Installation.
17 | // await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
18 | // });
19 |
--------------------------------------------------------------------------------
/packages/ui/tests/misc.ts:
--------------------------------------------------------------------------------
1 | import {execSync, spawn} from 'child_process'
2 | import process from 'process'
3 |
4 | export function resetUmbreldAndStart(): Promise<() => void> {
5 | console.log('running...')
6 |
7 | execSync('cd ../umbreld && rm -rf data && mkdir data')
8 |
9 | const child = spawn('npm', ['run', 'dev'], {cwd: '../umbreld'})
10 |
11 | return new Promise((resolve, reject) => {
12 | child.on('error', reject)
13 | child.stdout.on('data', (data) => {
14 | console.log(data.toString())
15 | if (data.toString().includes('Repositories initialised!')) {
16 | const stop = () => child.pid && process.kill(child.pid, 'SIGINT')
17 | resolve(stop)
18 | console.log('Initialized')
19 | // process.exit(0)
20 | }
21 | })
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/packages/ui/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/umbreld/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | .DS_Store
4 | data
5 | ui
--------------------------------------------------------------------------------
/packages/umbreld/.prettierignore:
--------------------------------------------------------------------------------
1 | # .gitignore contents
2 | node_modules
3 | coverage
4 | .DS_Store
5 | data
6 |
7 | # Prevent prettier erroring on intentionally invalid YAML test case
8 | source/modules/test-utilities/fixtures/community-repo/app-with-invalid-manifest/umbrel-app.yml
--------------------------------------------------------------------------------
/packages/umbreld/source/constants.ts:
--------------------------------------------------------------------------------
1 | /** Official app repository of the Umbrel App Store */
2 | export const UMBREL_APP_STORE_REPO = 'https://github.com/getumbrel/umbrel-apps.git'
3 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/apps/legacy-compat/bin/bitcoin-cli:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | echo
4 | echo " *** Deprecation notice ***"
5 | echo " In a future version of Umbrel, 'bitcoin-cli' will be removed."
6 | echo
7 |
8 | result=$(docker exec -it bitcoin_bitcoind_1 bitcoin-cli "$@")
9 |
10 | # We need to echo with quotes to preserve output formatting
11 | echo "$result"
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/apps/legacy-compat/bin/lncli:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -euo pipefail
4 |
5 | UMBREL_ROOT="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
6 |
7 | echo
8 | echo " *** Deprecation notice ***"
9 | echo " In a future version of Umbrel, 'lncli' will be removed."
10 | echo
11 |
12 | result=$(docker exec -it lightning_lnd_1 lncli "$@")
13 |
14 | # We need to echo with quotes to preserve output formatting
15 | echo "$result"
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/apps/legacy-compat/docker-compose.app_proxy.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | app_proxy:
5 | image: getumbrel/app-proxy:1.0.0@sha256:49eb600c4667c4b948055e33171b42a509b7e0894a77e0ca40df8284c77b52fb
6 | # build: ../../../../../../containers/app-proxy
7 | user: '1000:1000'
8 | restart: on-failure
9 | hostname: $APP_PROXY_HOSTNAME
10 | ports:
11 | - '${APP_PROXY_PORT}:${APP_PROXY_PORT}'
12 | volumes:
13 | - '${APP_MANIFEST_FILE}:/extra/umbrel-app.yml:ro'
14 | - '${TOR_DATA_DIR}:/var/lib/tor:ro'
15 | - '${APP_DATA_DIR}:/data:ro'
16 | environment:
17 | LOG_LEVEL: info
18 | PROXY_PORT: $APP_PROXY_PORT
19 | PROXY_AUTH_ADD: 'true'
20 | PROXY_AUTH_WHITELIST:
21 | PROXY_AUTH_BLACKLIST:
22 | APP_HOST:
23 | APP_PORT:
24 | AUTH_SERVICE_PORT: $AUTH_PORT
25 | UMBREL_AUTH_SECRET: $UMBREL_AUTH_SECRET
26 | MANAGER_IP: $MANAGER_IP
27 | MANAGER_PORT: 3006
28 | JWT_SECRET: $JWT_SECRET
29 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/apps/legacy-compat/docker-compose.common.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | networks:
4 | default:
5 | external:
6 | name: umbrel_main_network
7 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/apps/legacy-compat/docker-compose.tor.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | tor_server:
5 | image: getumbrel/tor:0.4.7.8@sha256:2ace83f22501f58857fa9b403009f595137fa2e7986c4fda79d82a8119072b6a
6 | user: '1000:1000'
7 | restart: on-failure
8 | volumes:
9 | - ${TOR_ENTRYPOINT_SCRIPT}:/umbrel/entrypoint.sh
10 | - ${TOR_DATA_DIR}:/data
11 | environment:
12 | HOME: '/tmp'
13 | HS_DIR: '${TOR_HS_APP_DIR}'
14 | HS_PORTS: '${TOR_HS_PORTS}'
15 | entrypoint: '/umbrel/entrypoint.sh'
16 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/apps/legacy-compat/tor-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | TORRC_PATH="/tmp/torrc"
4 |
5 | echo "HiddenServiceDir ${HS_DIR}" > "${TORRC_PATH}"
6 |
7 | # Loop through all ports we want to expose
8 | # On this hidden service
9 | for service in $HS_PORTS
10 | do
11 | virtual_port=$(echo $service | cut -d : -f 1)
12 | source_host=$(echo $service | cut -d : -f 2)
13 | source_port=$(echo $service | cut -d : -f 3)
14 | echo "HiddenServicePort ${virtual_port} ${source_host}:${source_port}" >> "${TORRC_PATH}"
15 | done
16 |
17 | tor -f "${TORRC_PATH}"
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/apps/legacy-compat/tor-proxy-torrc:
--------------------------------------------------------------------------------
1 | # Warning: it's not recommended to modify these files directly. Any
2 | # modifications you make can break the functionality of your umbrel. These files
3 | # are automatically reset with every Umbrel update.
4 |
5 | # Bind only to "10.21.21.11" which is the tor IP within the container
6 | SocksPort 10.21.21.11:9050
7 | ControlPort 10.21.21.11:29051
8 |
9 | HashedControlPassword 16:158FBE422B1A9D996073BE2B9EC38852C70CE12362CA016F8F6859C426
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/apps/legacy-compat/tor-server-torrc:
--------------------------------------------------------------------------------
1 | # Warning: it's not recommended to modify these files directly. Any
2 |
3 | # modifications you make can break the functionality of your umbrel. These files
4 |
5 | # are automatically reset with every Umbrel update.
6 |
7 | # Bind only to "10.21.21.11" which is the tor IP within the container
8 |
9 | SocksPort 10.21.21.11:9050
10 | ControlPort 10.21.21.11:29051
11 |
12 | HashedControlPassword 16:158FBE422B1A9D996073BE2B9EC38852C70CE12362CA016F8F6859C426
13 |
14 | # Umbrel
15 |
16 | # Dashboard Hidden Service
17 |
18 | HiddenServiceDir /data/web
19 | HiddenServicePort 80 172.17.0.1:80
20 |
21 | # Auth Hidden Service
22 |
23 | HiddenServiceDir /data/auth
24 | HiddenServicePort 80 10.21.21.6:2000
25 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/files/fixtures/thumbnails/master-lossless-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/umbreld/source/modules/files/fixtures/thumbnails/master-lossless-image.png
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/files/fixtures/thumbnails/master-lossless-video.mkv:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/umbreld/source/modules/files/fixtures/thumbnails/master-lossless-video.mkv
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/files/fixtures/thumbnails/multipage-pdf.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getumbrel/umbrel/93a4b1801d1141252e73ff759a4ef52d038fff92/packages/umbreld/source/modules/files/fixtures/thumbnails/multipage-pdf.pdf
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/files/widgets.ts:
--------------------------------------------------------------------------------
1 | import type Umbreld from '../../index.js'
2 |
3 | export const filesWidgets = {
4 | 'files-recents': async function (umbreld: Umbreld) {
5 | const recentFiles = await umbreld.files.recents.get()
6 |
7 | return {
8 | type: 'files-list',
9 | link: '/files/Recents',
10 | refresh: '5s',
11 | items: recentFiles.slice(0, 3),
12 | noItemsText: 'files-widgets.recents.no-items-text',
13 | }
14 | },
15 |
16 | 'files-favorites': async function (umbreld: Umbreld) {
17 | const favorites = await umbreld.files.favorites.listFavorites()
18 |
19 | return {
20 | type: 'files-grid',
21 | refresh: '30s',
22 | paths: favorites.slice(0, 4),
23 | noItemsText: 'files-widgets.favorites.no-items-text',
24 | }
25 | },
26 | } as const
27 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/is-umbrel-home.ts:
--------------------------------------------------------------------------------
1 | import fse from 'fs-extra'
2 | import systeminfo from 'systeminformation'
3 |
4 | export default async function isUmbrelHome() {
5 | // This file exists in old versions of amd64 Umbrel OS builds due to the Docker build system.
6 | // It confuses the systeminfo library and makes it return the model as 'Docker Container'.
7 | await fse.remove('/.dockerenv')
8 |
9 | const {manufacturer, model} = await systeminfo.system()
10 |
11 | return manufacturer === 'Umbrel, Inc.' && model === 'Umbrel Home'
12 | }
13 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/notifications/routes.ts:
--------------------------------------------------------------------------------
1 | import {z} from 'zod'
2 |
3 | import {router, privateProcedure} from '../server/trpc/trpc.js'
4 |
5 | export default router({
6 | // Gets all notifications
7 | get: privateProcedure.query(async ({ctx}) => ctx.umbreld.notifications.get()),
8 |
9 | // Removes a notification
10 | clear: privateProcedure.input(z.string()).mutation(async ({ctx, input}) => ctx.umbreld.notifications.clear(input)),
11 | })
12 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/server/trpc/routes/app-store.ts:
--------------------------------------------------------------------------------
1 | import z from 'zod'
2 |
3 | import {router, privateProcedure} from '../trpc.js'
4 |
5 | export default router({
6 | // Returns the app store registry
7 | registry: privateProcedure.query(async ({ctx}) => ctx.appStore.registry()),
8 |
9 | // Add a repository to the app store
10 | addRepository: privateProcedure
11 | .input(
12 | z.object({
13 | url: z.string(),
14 | }),
15 | )
16 | .mutation(async ({ctx, input}) => ctx.appStore.addRepository(input.url)),
17 |
18 | // Remove a repository to the app store
19 | removeRepository: privateProcedure
20 | .input(
21 | z.object({
22 | url: z.string(),
23 | }),
24 | )
25 | .mutation(async ({ctx, input}) => ctx.appStore.removeRepository(input.url)),
26 | })
27 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/server/trpc/websocket-logger.ts:
--------------------------------------------------------------------------------
1 | import {TRPCError} from '@trpc/server'
2 |
3 | import {type Context} from './context.js'
4 |
5 | type MiddlewareOptions = {
6 | ctx: Context
7 | path: string
8 | next: () => Promise<any>
9 | }
10 |
11 | export const websocketLogger = async ({ctx, path, next}: MiddlewareOptions) => {
12 | // Skip this middleware for non-websocket requests
13 | if (ctx.transport !== 'ws') return next()
14 |
15 | // Log the RPC call
16 | ctx.logger.verbose(`WS rpc ${path}`)
17 |
18 | return next()
19 | }
20 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/test-utilities/fixtures/another-community-repo/another-sparkles-hello-world/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | app_proxy:
5 | environment:
6 | APP_HOST: sparkles-hello-world_server_1
7 | APP_PORT: 3000
8 |
9 | server:
10 | image: getumbrel/community-app-store-hello-world:latest
11 | user: '1000:1000'
12 | init: true
13 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/test-utilities/fixtures/another-community-repo/another-sparkles-hello-world/umbrel-app.yml:
--------------------------------------------------------------------------------
1 | manifestVersion: 1
2 | id: another-sparkles-hello-world
3 | name: Hello World
4 | tagline: Replace this tagline with your app's tagline
5 | icon: https://svgur.com/i/mvA.svg
6 | category: Development
7 | version: '1.0.0'
8 | port: 4000
9 | description: >-
10 | Add your app's description here.
11 |
12 |
13 | You can also add newlines!
14 |
15 | developer: Umbrel
16 | website: https://umbrel.com
17 | submitter: Umbrel
18 | submission: https://github.com/getumbrel/umbrel-hello-world-app
19 | repo: https://github.com/getumbrel/umbrel-hello-world-app
20 | support: https://github.com/getumbrel/umbrel-hello-world-app/issues
21 | gallery:
22 | - https://i.imgur.com/yyVG0Jb.jpeg
23 | - https://i.imgur.com/yyVG0Jb.jpeg
24 | - https://i.imgur.com/yyVG0Jb.jpeg
25 | releaseNotes: >-
26 | Add what's new in the latest version of your app here.
27 | dependencies: []
28 | path: ''
29 | defaultUsername: ''
30 | defaultPassword: ''
31 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/test-utilities/fixtures/another-community-repo/umbrel-app-store.yml:
--------------------------------------------------------------------------------
1 | id: 'another-sparkles'
2 | name: 'Another Sparkles'
3 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/test-utilities/fixtures/community-repo/app-with-invalid-id/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | app_proxy:
5 | environment:
6 | APP_HOST: hello-world_server_1
7 | APP_PORT: 3000
8 |
9 | server:
10 | image: getumbrel/community-app-store-hello-world:latest
11 | user: '1000:1000'
12 | init: true
13 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/test-utilities/fixtures/community-repo/app-with-invalid-id/umbrel-app.yml:
--------------------------------------------------------------------------------
1 | manifestVersion: 1
2 | # This app id isn't prefixed with the app repo id so it should be ignored by the registry
3 | id: invalid-id
4 | name: Hello World
5 | tagline: Replace this tagline with your app's tagline
6 | icon: https://svgur.com/i/mvA.svg
7 | category: Development
8 | version: '1.0.0'
9 | port: 4000
10 | description: >-
11 | Add your app's description here.
12 |
13 |
14 | You can also add newlines!
15 |
16 | developer: Umbrel
17 | website: https://umbrel.com
18 | submitter: Umbrel
19 | submission: https://github.com/getumbrel/umbrel-hello-world-app
20 | repo: https://github.com/getumbrel/umbrel-hello-world-app
21 | support: https://github.com/getumbrel/umbrel-hello-world-app/issues
22 | gallery:
23 | - https://i.imgur.com/yyVG0Jb.jpeg
24 | - https://i.imgur.com/yyVG0Jb.jpeg
25 | - https://i.imgur.com/yyVG0Jb.jpeg
26 | releaseNotes: >-
27 | Add what's new in the latest version of your app here.
28 | dependencies: []
29 | path: ''
30 | defaultUsername: ''
31 | defaultPassword: ''
32 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/test-utilities/fixtures/community-repo/app-with-invalid-manifest/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | app_proxy:
5 | environment:
6 | APP_HOST: app-with-invalid-manifest_server_1
7 | APP_PORT: 3000
8 |
9 | server:
10 | image: getumbrel/community-app-store-hello-world:latest
11 | user: '1000:1000'
12 | init: true
13 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/test-utilities/fixtures/community-repo/app-with-invalid-manifest/umbrel-app.yml:
--------------------------------------------------------------------------------
1 | # Due to the invalid manifest it shouldn't show up in registry output
2 | manifestVersion: 1
3 | id: app-with-invalid-manifest
4 | name: Invalid space here
5 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/test-utilities/fixtures/community-repo/sparkles-hello-world/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | app_proxy:
5 | environment:
6 | APP_HOST: sparkles-hello-world_server_1
7 | APP_PORT: 3000
8 |
9 | server:
10 | image: getumbrel/community-app-store-hello-world:latest
11 | user: '1000:1000'
12 | init: true
13 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/test-utilities/fixtures/community-repo/sparkles-hello-world/umbrel-app.yml:
--------------------------------------------------------------------------------
1 | manifestVersion: 1
2 | id: sparkles-hello-world
3 | name: Hello World
4 | tagline: Replace this tagline with your app's tagline
5 | icon: https://svgur.com/i/mvA.svg
6 | category: Development
7 | version: '1.0.0'
8 | port: 4000
9 | description: >-
10 | Add your app's description here.
11 |
12 |
13 | You can also add newlines!
14 |
15 | developer: Umbrel
16 | website: https://umbrel.com
17 | submitter: Umbrel
18 | submission: https://github.com/getumbrel/umbrel-hello-world-app
19 | repo: https://github.com/getumbrel/umbrel-hello-world-app
20 | support: https://github.com/getumbrel/umbrel-hello-world-app/issues
21 | gallery:
22 | - https://i.imgur.com/yyVG0Jb.jpeg
23 | - https://i.imgur.com/yyVG0Jb.jpeg
24 | - https://i.imgur.com/yyVG0Jb.jpeg
25 | releaseNotes: >-
26 | Add what's new in the latest version of your app here.
27 | dependencies: []
28 | path: ''
29 | defaultUsername: ''
30 | defaultPassword: ''
31 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/test-utilities/fixtures/community-repo/umbrel-app-store.yml:
--------------------------------------------------------------------------------
1 | id: 'sparkles'
2 | name: 'Sparkles'
3 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/utilities/dependencies.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Ensure selected dependencies are filled in when given the app's dependencies
3 | * (where undefined = none) and a user's selection (where undefined = default).
4 | */
5 | export const fillSelectedDependencies = (dependencies?: string[], selectedDependencies?: Record<string, string>) =>
6 | dependencies?.reduce(
7 | (accumulator, dependencyId) => {
8 | accumulator[dependencyId] = selectedDependencies?.[dependencyId] ?? dependencyId
9 | return accumulator
10 | },
11 | {} as Record<string, string>,
12 | ) ?? {}
13 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/utilities/get-directory-size.ts:
--------------------------------------------------------------------------------
1 | import {$} from 'execa'
2 |
3 | // Get a directory size in bytes
4 | async function getDirectorySize(directoryPath: string) {
5 | const du = await
du --summarize --bytes ${directoryPath}`
6 | const totalSize = parseInt(du.stdout.split('\t')[0], 10)
7 |
8 | return totalSize
9 | }
10 |
11 | export default getDirectorySize
12 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/utilities/get-or-create-file.ts:
--------------------------------------------------------------------------------
1 | import fse from 'fs-extra'
2 |
3 | async function getOrCreateFile(filePath: string, defaultValue: string) {
4 | let contents
5 | try {
6 | contents = await fse.readFile(filePath, 'utf8')
7 | // eslint-disable-next-line unicorn/prefer-optional-catch-binding
8 | } catch (_) {
9 | try {
10 | await fse.ensureFile(filePath)
11 | await fse.writeFile(filePath, defaultValue, 'utf8')
12 | contents = await fse.readFile(filePath, 'utf8')
13 | // eslint-disable-next-line unicorn/prefer-optional-catch-binding
14 | } catch (_) {
15 | throw new Error('Unable to create initial file')
16 | }
17 | }
18 |
19 | return contents
20 | }
21 |
22 | export default getOrCreateFile
23 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/utilities/random-token.ts:
--------------------------------------------------------------------------------
1 | import crypto from 'node:crypto'
2 |
3 | function randomToken(bitLength: number) {
4 | return crypto.randomBytes(bitLength / 8).toString('hex')
5 | }
6 |
7 | export default randomToken
8 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/utilities/regexp.ts:
--------------------------------------------------------------------------------
1 | // Escape special RegExp literals
2 | export function escapeSpecialRegExpLiterals(string: string) {
3 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\
amp;')
4 | }
5 |
--------------------------------------------------------------------------------
/packages/umbreld/source/modules/utilities/temporary-directory.ts:
--------------------------------------------------------------------------------
1 | import os from 'node:os'
2 | import path from 'node:path'
3 |
4 | import fse from 'fs-extra'
5 |
6 | import randomToken from './random-token.js'
7 |
8 | function temporaryDirectory() {
9 | const containingDirectory = path.join(os.tmpdir(), randomToken(128))
10 |
11 | const createRoot = () => fse.ensureDir(containingDirectory)
12 | const destroyRoot = () => fse.remove(containingDirectory)
13 |
14 | const create = async () => {
15 | const directory = path.join(containingDirectory, randomToken(128))
16 | await fse.ensureDir(directory)
17 |
18 | return directory
19 | }
20 |
21 | return {createRoot, destroyRoot, create}
22 | }
23 |
24 | export default temporaryDirectory
25 |
--------------------------------------------------------------------------------
/packages/umbreld/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/node22/tsconfig.json",
3 | "compilerOptions": {
4 | "resolveJsonModule": true
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/packages/umbreld/umbreld:
--------------------------------------------------------------------------------
1 | #!/bin/env bash
2 |
3 | # We need to add this shim as the main umbreld entrypoint so we can set up the environment we need
4 | # like adding node_modules/.bin to the PATH so we have access to tsx.
5 |
6 |
7 | # Hook to run development mode
8 | if [[ -d "/umbrel-dev" ]]
9 | then
10 | echo "Running in development mode"
11 | cd /umbrel-dev
12 | exec npm run dev container-init
13 | fi
14 |
15 | # Find the project directory and follow symlinks if necessary
16 | project_directory="$(dirname $(readlink -f "${BASH_SOURCE[0]}"))"
17 |
18 | # Get the start script from package.json
19 | entrypoint=$(npm --prefix "${project_directory}" pkg get scripts.start)
20 |
21 | # Remove double quotes
22 | entrypoint="${entrypoint#\"}"
23 | entrypoint="${entrypoint%\"}"
24 |
25 | # Set up PATH so we can resolve tsx and local node
26 | export PATH="${project_directory}/node_modules/.bin:${PATH}"
27 |
28 | # Execute the entrypoint and pass through any arguments
29 | exec "${project_directory}/${entrypoint}" "$@"
30 |
--------------------------------------------------------------------------------
/scripts/update-script:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 |
4 | # This script is used to bootstrap the mender update process
5 | # The update server references it in the form:
6 | # https://raw.githubusercontent.com/getumbrel/umbrel/<tag>/scripts/update-script
7 |
8 | update_url=""
9 |
10 | if ! command -v mender &> /dev/null
11 | then
12 | echo umbrel-update: '{"error": "Mender not installed"}'
13 | exit 1
14 | fi
15 |
16 | if cat /var/lib/mender/device_type | grep --quiet 'device_type=raspberrypi'
17 | then
18 | update_url="https://download.umbrel.com/release/1.4.2/umbrelos-pi.update"
19 | fi
20 |
21 | if cat /var/lib/mender/device_type | grep --silent 'device_type=amd64'
22 | then
23 | update_url="https://download.umbrel.com/release/1.4.2/umbrelos-amd64.update"
24 | fi
25 |
26 | # Fix /etc/mender/artifact_info not existing in some OS builds
27 | if [[ ! -f /etc/mender/artifact_info ]]
28 | then
29 | echo "artifact_name=umbrelOS" > /etc/mender/artifact_info
30 | fi
31 |
32 | mender install "${update_url}"
--------------------------------------------------------------------------------