diff --git a/packages/ui/public/locales/de.json b/packages/ui/public/locales/de.json index 732b793312..d21cb6e195 100644 --- a/packages/ui/public/locales/de.json +++ b/packages/ui/public/locales/de.json @@ -16,6 +16,7 @@ "app-only-over-tor": "{{app}} kann nur über Tor verwendet werden. Bitte greife über einen Tor-Browser auf dein Umbrel zu deiner Remote-Zugriffs-URL (Einstellungen > Remote Tor-Zugang) zu, um diese App zu öffnen.", "app-page.section.about": "Über", "app-page.section.credentials.title": "Standardanmeldeinformationen", + "app-page.section.dependencies.n-alternatives": "{{count}} Alternativen ansehen", "app-page.section.info.compatibility": "Kompatibilität", "app-page.section.info.compatibility-compatible": "Kompatibel", "app-page.section.info.compatibility-not-compatible": "Nicht kompatibel", @@ -32,6 +33,9 @@ "app-page.section.requires": "Benötigt", "app-picker.search": "Suchen...", "app-picker.select-app": "App auswählen...", + "app-settings.connected-to": "{{appName}} ist mit diesen Apps verbunden", + "app-settings.save-changes": "Änderungen speichern", + "app-settings.title": "Einstellungen", "app-store.browse-category-apps": "{{category}} Apps durchsuchen", "app-store.category.ai": "KI", "app-store.category.all": "Alle Apps", @@ -59,6 +63,7 @@ "app-updates.updates-available-count_other": "{{count}} Aktualisierungen verfügbar", "app-updates.updating": "Aktualisiert...", "app.install": "Installieren", + "app.installed": "Installiert", "app.installing": "Installiert", "app.offline": "Nicht in Betrieb", "app.open": "Öffnen", @@ -140,6 +145,7 @@ "default-credentials.title": "Anmeldeinformationen für {{app}}", "default-credentials.username": "Standardbenutzername", "desktop.app.context.go-to-store-page": "Im App Store anzeigen", + "desktop.app.context.settings": "Einstellungen", "desktop.app.context.show-default-credentials": "Standardanmeldeinformationen anzeigen", "desktop.app.context.uninstall": "Deinstallieren", "desktop.context-menu.change-wallpaper": "Hintergrundbild ändern", @@ -185,9 +191,8 @@ "factory-reset.success.description": "Alle deine Apps, App-Daten und Kontodaten wurden von deinem Gerät gelöscht und das System wurde auf den Werkszustand zurückgesetzt.", "factory-reset.success.title": "Zurücksetzen erfolgreich", "hello": "Hallo", - "install-first.description_one": "Installiere diese App, um {{app}} zu installieren.", - "install-first.description_other": "Installiere zuerst diese Apps, um {{app}} zu installieren.", - "install-first.title": "{{app}} benötigt Zugriff auf", + "install-first.install-app": "Installiere {{app}}", + "install-first.title": "{{app}} benötigt diese Apps", "install-your-first-app": "Installiere deine erste App", "language": "Sprache", "language-description": "Deine bevorzugte umbrelOS Sprache", diff --git a/packages/ui/public/locales/en.json b/packages/ui/public/locales/en.json index 1c31145be2..06593d8643 100644 --- a/packages/ui/public/locales/en.json +++ b/packages/ui/public/locales/en.json @@ -16,6 +16,7 @@ "app-only-over-tor": "{{app}} can only be used over Tor. Please access your Umbrel in a Tor browser on your remote access URL (Settings > Remote Tor access) to open this app.", "app-page.section.about": "About", "app-page.section.credentials.title": "Default credentials", + "app-page.section.dependencies.n-alternatives": "See {{count}} alternatives", "app-page.section.info.compatibility": "Compatibility", "app-page.section.info.compatibility-compatible": "Compatible", "app-page.section.info.compatibility-not-compatible": "Not compatible", @@ -32,6 +33,9 @@ "app-page.section.requires": "Requires", "app-picker.search": "Search...", "app-picker.select-app": "Select app...", + "app-settings.connected-to": "{{appName}} is connected to these apps", + "app-settings.save-changes": "Save changes", + "app-settings.title": "Settings", "app-store.browse-category-apps": "Browse {{category}} apps", "app-store.category.ai": "AI", "app-store.category.all": "All apps", @@ -59,6 +63,7 @@ "app-updates.updates-available-count_other": "{{count}} updates available", "app-updates.updating": "Updating...", "app.install": "Install", + "app.installed": "Installed", "app.installing": "Installing", "app.offline": "Not running", "app.open": "Open", @@ -140,6 +145,7 @@ "default-credentials.title": "Credentials for {{app}}", "default-credentials.username": "Default username", "desktop.app.context.go-to-store-page": "View in App Store", + "desktop.app.context.settings": "Settings", "desktop.app.context.show-default-credentials": "Show default credentials", "desktop.app.context.uninstall": "Uninstall", "desktop.context-menu.change-wallpaper": "Change wallpaper", @@ -185,9 +191,8 @@ "factory-reset.success.description": "All your apps, app data, and account data have been deleted from your device and the system has been reset to factory state.", "factory-reset.success.title": "Reset successful", "hello": "Hello", - "install-first.description_one": "Install this app to install {{app}}.", - "install-first.description_other": "Install these apps first to install {{app}}.", - "install-first.title": "{{app}} requires access to", + "install-first.install-app": "Install {{app}}", + "install-first.title": "{{app}} requires these apps", "install-your-first-app": "Install your first app", "language": "Language", "language-description": "Your preferred umbrelOS language", diff --git a/packages/ui/public/locales/es.json b/packages/ui/public/locales/es.json index b304e261d8..f7c8225ac1 100644 --- a/packages/ui/public/locales/es.json +++ b/packages/ui/public/locales/es.json @@ -16,6 +16,7 @@ "app-only-over-tor": "{{app}} solo se puede usar sobre Tor. Por favor, accede a tu Umbrel en un navegador Tor en tu URL de acceso remoto (Configuración > Acceso remoto Tor) para abrir esta aplicación.", "app-page.section.about": "Acerca de", "app-page.section.credentials.title": "Credenciales predeterminadas", + "app-page.section.dependencies.n-alternatives": "Ver {{count}} alternativas", "app-page.section.info.compatibility": "Compatibilidad", "app-page.section.info.compatibility-compatible": "Compatible", "app-page.section.info.compatibility-not-compatible": "No compatible", @@ -32,6 +33,9 @@ "app-page.section.requires": "Requiere", "app-picker.search": "Buscar...", "app-picker.select-app": "Seleccionar aplicación...", + "app-settings.connected-to": "{{appName}} está conectado a estas aplicaciones", + "app-settings.save-changes": "Guardar cambios", + "app-settings.title": "Configuración", "app-store.browse-category-apps": "Explorar aplicaciones de {{category}}", "app-store.category.ai": "IA", "app-store.category.all": "Todas las aplicaciones", @@ -59,6 +63,7 @@ "app-updates.updates-available-count_other": "{{count}} actualizaciones disponibles", "app-updates.updating": "Actualizando...", "app.install": "Instalar", + "app.installed": "Instalado", "app.installing": "Instalando", "app.offline": "No en ejecución", "app.open": "Abrir", @@ -140,6 +145,7 @@ "default-credentials.title": "Credenciales para {{app}}", "default-credentials.username": "Nombre de usuario por defecto", "desktop.app.context.go-to-store-page": "Ver en la Tienda de Aplicaciones", + "desktop.app.context.settings": "Configuración", "desktop.app.context.show-default-credentials": "Mostrar credenciales predeterminadas", "desktop.app.context.uninstall": "Desinstalar", "desktop.context-menu.change-wallpaper": "Cambiar fondo de pantalla", @@ -185,9 +191,8 @@ "factory-reset.success.description": "Todas tus aplicaciones, datos de aplicaciones y datos de cuenta han sido eliminados de tu dispositivo y el sistema se ha restablecido a su estado de fábrica.", "factory-reset.success.title": "Restablecimiento exitoso", "hello": "Hola", - "install-first.description_one": "Instala esta aplicación para instalar {{app}}.", - "install-first.description_other": "Instala estas aplicaciones primero para instalar {{app}}.", - "install-first.title": "{{app}} requiere acceso a", + "install-first.install-app": "Instalar {{app}}", + "install-first.title": "{{app}} requiere estas aplicaciones", "install-your-first-app": "Instala tu primera aplicación", "language": "Idioma", "language-description": "Tu idioma preferido para umbrelOS", diff --git a/packages/ui/public/locales/fr.json b/packages/ui/public/locales/fr.json index fea2d0f3e2..c03fdf73da 100644 --- a/packages/ui/public/locales/fr.json +++ b/packages/ui/public/locales/fr.json @@ -16,6 +16,7 @@ "app-only-over-tor": "{{app}} ne peut être utilisée que via Tor. Veuillez accéder à votre Umbrel dans un navigateur Tor sur votre URL d'accès à distance (Paramètres > Accès Tor à distance) pour ouvrir cette application.", "app-page.section.about": "À propos", "app-page.section.credentials.title": "Identifiants par défaut", + "app-page.section.dependencies.n-alternatives": "Voir {{count}} alternatives", "app-page.section.info.compatibility": "Compatibilité", "app-page.section.info.compatibility-compatible": "Compatible", "app-page.section.info.compatibility-not-compatible": "Non compatible", @@ -32,6 +33,9 @@ "app-page.section.requires": "Nécessite", "app-picker.search": "Rechercher...", "app-picker.select-app": "Sélectionner une application...", + "app-settings.connected-to": "{{appName}} est connecté à ces applications", + "app-settings.save-changes": "Enregistrer les modifications", + "app-settings.title": "Réglages", "app-store.browse-category-apps": "Parcourir les applications {{category}}", "app-store.category.ai": "IA", "app-store.category.all": "Toutes les applications", @@ -59,6 +63,7 @@ "app-updates.updates-available-count_other": "{{count}} mises à jour disponibles", "app-updates.updating": "Mise à jour...", "app.install": "Installer", + "app.installed": "Installé", "app.installing": "Installation", "app.offline": "Non en cours d'exécution", "app.open": "Ouvrir", @@ -140,6 +145,7 @@ "default-credentials.title": "Identifiants pour {{app}}", "default-credentials.username": "Nom d'utilisateur par défaut", "desktop.app.context.go-to-store-page": "Voir dans l'App Store", + "desktop.app.context.settings": "Réglages", "desktop.app.context.show-default-credentials": "Afficher les identifiants par défaut", "desktop.app.context.uninstall": "Désinstaller", "desktop.context-menu.change-wallpaper": "Changer de fond d'écran", @@ -185,9 +191,8 @@ "factory-reset.success.description": "Toutes vos applications, données d'applications, et données de compte ont été supprimées de votre appareil et le système a été réinitialisé aux paramètres d'usine.", "factory-reset.success.title": "Réinitialisation réussie", "hello": "Bonjour", - "install-first.description_one": "Installez cette application pour installer {{app}}.", - "install-first.description_other": "Installez d'abord ces applications pour installer {{app}}.", - "install-first.title": "{{app}} nécessite l'accès à", + "install-first.install-app": "Installez {{app}}", + "install-first.title": "{{app}} nécessite ces applications", "install-your-first-app": "Installez votre première application", "language": "Langue", "language-description": "Votre langue préférée pour umbrelOS", diff --git a/packages/ui/public/locales/hu.json b/packages/ui/public/locales/hu.json index 5629a345ea..0bfcc233e6 100644 --- a/packages/ui/public/locales/hu.json +++ b/packages/ui/public/locales/hu.json @@ -16,6 +16,7 @@ "app-only-over-tor": "A {{app}} csak Tor-on keresztül használható. Kérjük, az alkalmazás megnyitásához lépj be az Umbrel-be egy Tor böngészőben a távoli hozzáférési URL-en (Beállítások > Távoli Tor hozzáférés).", "app-page.section.about": "Rólunk", "app-page.section.credentials.title": "Alapértelmezett hitelesítési adatok", + "app-page.section.dependencies.n-alternatives": "Lásd a {{count}} alternatívát", "app-page.section.info.compatibility": "Kompatibilitás", "app-page.section.info.compatibility-compatible": "Kompatibilis", "app-page.section.info.compatibility-not-compatible": "Nem kompatibilis", @@ -32,6 +33,9 @@ "app-page.section.requires": "Követelmények", "app-picker.search": "Keresés...", "app-picker.select-app": "Válassz alkalmazást...", + "app-settings.connected-to": "{{appName}} ezekhez az alkalmazásokhoz csatlakozik", + "app-settings.save-changes": "Változtatások mentése", + "app-settings.title": "Beállítások", "app-store.browse-category-apps": "Böngészd a(z) {{category}} alkalmazásokat", "app-store.category.ai": "AI", "app-store.category.all": "Összes alkalmazás", @@ -59,6 +63,7 @@ "app-updates.updates-available-count_other": "{{count}} frissítés elérhető", "app-updates.updating": "Frissítés...", "app.install": "Telepítés", + "app.installed": "Telepítve", "app.installing": "Telepítés folyamatban", "app.offline": "Nem fut", "app.open": "Megnyitás", @@ -140,6 +145,7 @@ "default-credentials.title": "{{app}} hitelesítési adatai", "default-credentials.username": "Alapértelmezett felhasználónév", "desktop.app.context.go-to-store-page": "Megtekintés az Alkalmazásboltban", + "desktop.app.context.settings": "Beállítások", "desktop.app.context.show-default-credentials": "Alapértelmezett hitelesítési adatok megjelenítése", "desktop.app.context.uninstall": "Eltávolítás", "desktop.context-menu.change-wallpaper": "Háttérkép megváltoztatása", @@ -185,9 +191,8 @@ "factory-reset.success.description": "Az összes alkalmazás, alkalmazásadat és fiókadat törlésre került az eszközödről, és a rendszer visszaállt gyári állapotába.", "factory-reset.success.title": "Sikeres visszaállítás", "hello": "Helló", - "install-first.description_one": "Telepítsd ezt az alkalmazást a(z) {{app}} telepítéséhez.", - "install-first.description_other": "Telepítsd ezeket az alkalmazásokat a(z) {{app}} telepítéséhez.", - "install-first.title": "{{app}} hozzáférést igényel", + "install-first.install-app": "Telepítsd a(z) {{app}} alkalmazást", + "install-first.title": "{{app}} a következő alkalmazásokat igényli", "install-your-first-app": "Telepítsd az első alkalmazásod", "language": "Nyelv", "language-description": "Az általad preferált umbrelOS nyelv", diff --git a/packages/ui/public/locales/it.json b/packages/ui/public/locales/it.json index 6d0fadd018..51b495fdf9 100644 --- a/packages/ui/public/locales/it.json +++ b/packages/ui/public/locales/it.json @@ -16,6 +16,7 @@ "app-only-over-tor": "{{app}} può essere utilizzata solo tramite Tor. Accedi al tuo Umbrel in un browser Tor all'URL di accesso remoto (Impostazioni > Accesso remoto Tor) per aprire questa app.", "app-page.section.about": "Informazioni", "app-page.section.credentials.title": "Credenziali predefinite", + "app-page.section.dependencies.n-alternatives": "Vedi {{count}} alternative", "app-page.section.info.compatibility": "Compatibilità", "app-page.section.info.compatibility-compatible": "Compatibile", "app-page.section.info.compatibility-not-compatible": "Non compatibile", @@ -32,6 +33,9 @@ "app-page.section.requires": "Richiede", "app-picker.search": "Cerca...", "app-picker.select-app": "Seleziona app...", + "app-settings.connected-to": "{{appName}} è connesso a queste app", + "app-settings.save-changes": "Salva modifiche", + "app-settings.title": "Impostazioni", "app-store.browse-category-apps": "Esplora le app {{category}}", "app-store.category.ai": "AI", "app-store.category.all": "Tutte le app", @@ -59,6 +63,7 @@ "app-updates.updates-available-count_other": "{{count}} aggiornamenti disponibili", "app-updates.updating": "Aggiornamento in corso...", "app.install": "Installa", + "app.installed": "Installata", "app.installing": "Installazione", "app.offline": "Non in esecuzione", "app.open": "Apri", @@ -140,6 +145,7 @@ "default-credentials.title": "Credenziali per {{app}}", "default-credentials.username": "Nome utente predefinito", "desktop.app.context.go-to-store-page": "Visualizza in App Store", + "desktop.app.context.settings": "Impostazioni", "desktop.app.context.show-default-credentials": "Mostra credenziali predefinite", "desktop.app.context.uninstall": "Disinstalla", "desktop.context-menu.change-wallpaper": "Cambia sfondo", @@ -185,9 +191,8 @@ "factory-reset.success.description": "Tutte le tue app, i dati delle app e i dati dell'account sono stati eliminati dal tuo dispositivo e il sistema è stato ripristinato allo stato di fabbrica.", "factory-reset.success.title": "Reset riuscito", "hello": "Ciao", - "install-first.description_one": "Installa questa app per installare {{app}}.", - "install-first.description_other": "Installa prima queste app per installare {{app}}.", - "install-first.title": "{{app}} richiede l'accesso a", + "install-first.install-app": "Installa {{app}}", + "install-first.title": "{{app}} richiede queste app", "install-your-first-app": "Installa la tua prima app", "language": "Lingua", "language-description": "La tua lingua preferita per umbrelOS", diff --git a/packages/ui/public/locales/ja.json b/packages/ui/public/locales/ja.json index b97eda9f0f..adbedc5913 100644 --- a/packages/ui/public/locales/ja.json +++ b/packages/ui/public/locales/ja.json @@ -16,6 +16,7 @@ "app-only-over-tor": "{{app}}はTor経由でのみ使用できます。このアプリを開くには、TorブラウザでリモートアクセスURL(設定 > リモートTorアクセス)にアクセスしてください。", "app-page.section.about": "約", "app-page.section.credentials.title": "デフォルト資格情報", + "app-page.section.dependencies.n-alternatives": "{{count}} つの代替案を表示", "app-page.section.info.compatibility": "互換性", "app-page.section.info.compatibility-compatible": "互換あり", "app-page.section.info.compatibility-not-compatible": "互換性がありません", @@ -32,6 +33,9 @@ "app-page.section.requires": "必要条件", "app-picker.search": "検索...", "app-picker.select-app": "アプリを選択...", + "app-settings.connected-to": "{{appName}}はこれらのアプリと接続されています", + "app-settings.save-changes": "変更を保存", + "app-settings.title": "設定", "app-store.browse-category-apps": "{{category}}アプリを閲覧", "app-store.category.ai": "AI", "app-store.category.all": "すべてのアプリ", @@ -59,6 +63,7 @@ "app-updates.updates-available-count_other": "{{count}}個のアップデートが利用可能", "app-updates.updating": "更新中...", "app.install": "インストール", + "app.installed": "インストール済み", "app.installing": "インストール中", "app.offline": "実行されていません", "app.open": "開く", @@ -140,6 +145,7 @@ "default-credentials.title": "{{app}}の資格情報", "default-credentials.username": "デフォルトユーザー名", "desktop.app.context.go-to-store-page": "App Storeで表示", + "desktop.app.context.settings": "設定", "desktop.app.context.show-default-credentials": "デフォルトの資格情報を表示", "desktop.app.context.uninstall": "アンインストール", "desktop.context-menu.change-wallpaper": "壁紙を変更", @@ -185,9 +191,8 @@ "factory-reset.success.description": "すべてのアプリ、アプリデータ、アカウントデータがデバイスから削除され、システムが工場出荷時の状態にリセットされました。", "factory-reset.success.title": "リセット成功", "hello": "こんにちは", - "install-first.description_one": "このアプリをインストールするためには、まず{{app}}をインストールしてください。", - "install-first.description_other": "{{app}}をインストールするには、まずこれらのアプリをインストールしてください。", - "install-first.title": "{{app}}のインストールにはアクセスが必要", + "install-first.install-app": "{{app}}をインストール", + "install-first.title": "{{app}}はこれらのアプリを必要とします", "install-your-first-app": "最初のアプリをインストール", "language": "言語", "language-description": "あなたが好むumbrelOSの言語", diff --git a/packages/ui/public/locales/nl.json b/packages/ui/public/locales/nl.json index a0da123201..abe2349601 100644 --- a/packages/ui/public/locales/nl.json +++ b/packages/ui/public/locales/nl.json @@ -16,6 +16,7 @@ "app-only-over-tor": "{{app}} kan alleen worden gebruikt over Tor. Gebruik een Tor-browser op je externe toegangs-URL (Instellingen > Toegang op afstand via Tor) om deze app te openen.", "app-page.section.about": "Over", "app-page.section.credentials.title": "Standaard inloggegevens", + "app-page.section.dependencies.n-alternatives": "Zie {{count}} alternatieven", "app-page.section.info.compatibility": "Compatibiliteit", "app-page.section.info.compatibility-compatible": "Compatibel", "app-page.section.info.compatibility-not-compatible": "Niet compatibel", @@ -32,6 +33,9 @@ "app-page.section.requires": "Vereist", "app-picker.search": "Zoeken...", "app-picker.select-app": "Selecteer app...", + "app-settings.connected-to": "{{appName}} is verbonden met deze apps", + "app-settings.save-changes": "Wijzigingen opslaan", + "app-settings.title": "Instellingen", "app-store.browse-category-apps": "Blader door {{category}} apps", "app-store.category.ai": "AI", "app-store.category.all": "Alle apps", @@ -59,6 +63,7 @@ "app-updates.updates-available-count_other": "{{count}} updates beschikbaar", "app-updates.updating": "Updaten...", "app.install": "Installeren", + "app.installed": "Geïnstalleerd", "app.installing": "Installeren", "app.offline": "Niet actief", "app.open": "Open", @@ -140,6 +145,7 @@ "default-credentials.title": "Inloggegevens voor {{app}}", "default-credentials.username": "Standaard gebruikersnaam", "desktop.app.context.go-to-store-page": "Bekijk in App Store", + "desktop.app.context.settings": "Instellingen", "desktop.app.context.show-default-credentials": "Standaard inloggegevens tonen", "desktop.app.context.uninstall": "Deïnstalleren", "desktop.context-menu.change-wallpaper": "Achtergrond wijzigen", @@ -185,9 +191,8 @@ "factory-reset.success.description": "Al je apps, app-gegevens en accountgegevens zijn van je apparaat verwijderd en het systeem is teruggezet naar de fabrieksinstellingen.", "factory-reset.success.title": "Reset succesvol", "hello": "Hallo", - "install-first.description_one": "Installeer deze app om {{app}} te installeren.", - "install-first.description_other": "Installeer deze apps eerst om {{app}} te installeren.", - "install-first.title": "{{app}} vereist toegang tot", + "install-first.install-app": "Installeer {{app}}", + "install-first.title": "{{app}} vereist deze apps", "install-your-first-app": "Installeer je eerste app", "language": "Taal", "language-description": "Je voorkeurstaal voor umbrelOS", diff --git a/packages/ui/public/locales/pt.json b/packages/ui/public/locales/pt.json index c5e0053d5e..ce13326149 100644 --- a/packages/ui/public/locales/pt.json +++ b/packages/ui/public/locales/pt.json @@ -16,6 +16,7 @@ "app-only-over-tor": "{{app}} só pode ser usado através do Tor. Por favor, acesse seu Umbrel em um navegador Tor no seu URL de acesso remoto (Configurações > Acesso remoto Tor) para abrir este aplicativo.", "app-page.section.about": "Sobre", "app-page.section.credentials.title": "Credenciais padrão", + "app-page.section.dependencies.n-alternatives": "Ver {{count}} alternativas", "app-page.section.info.compatibility": "Compatibilidade", "app-page.section.info.compatibility-compatible": "Compatível", "app-page.section.info.compatibility-not-compatible": "Não compatível", @@ -32,6 +33,9 @@ "app-page.section.requires": "Requer", "app-picker.search": "Pesquisar...", "app-picker.select-app": "Selecionar aplicativo...", + "app-settings.connected-to": "{{appName}} está conectado a estes aplicativos", + "app-settings.save-changes": "Salvar alterações", + "app-settings.title": "Configurações", "app-store.browse-category-apps": "Navegar por aplicativos {{category}}", "app-store.category.ai": "IA", "app-store.category.all": "Todos os aplicativos", @@ -59,6 +63,7 @@ "app-updates.updates-available-count_other": "{{count}} atualizações disponíveis", "app-updates.updating": "Atualizando...", "app.install": "Instalar", + "app.installed": "Instalado", "app.installing": "Instalando", "app.offline": "Não está em execução", "app.open": "Abrir", @@ -140,6 +145,7 @@ "default-credentials.title": "Credenciais para {{app}}", "default-credentials.username": "Nome de usuário padrão", "desktop.app.context.go-to-store-page": "Ver na App Store", + "desktop.app.context.settings": "Configurações", "desktop.app.context.show-default-credentials": "Mostrar credenciais padrão", "desktop.app.context.uninstall": "Desinstalar", "desktop.context-menu.change-wallpaper": "Mudar papel de parede", @@ -185,9 +191,8 @@ "factory-reset.success.description": "Todos os seus aplicativos, dados dos aplicativos e dados da conta foram excluídos do seu dispositivo e o sistema foi redefinido para o estado de fábrica.", "factory-reset.success.title": "Reset bem-sucedido", "hello": "Olá", - "install-first.description_one": "Instale este aplicativo para instalar {{app}}.", - "install-first.description_other": "Instale esses aplicativos primeiro para instalar {{app}}.", - "install-first.title": "{{app}} requer acesso a", + "install-first.install-app": "Instalar {{app}}", + "install-first.title": "{{app}} requer estes aplicativos", "install-your-first-app": "Instale seu primeiro aplicativo", "language": "Idioma", "language-description": "Seu idioma preferido do umbrelOS", diff --git a/packages/ui/public/locales/tr.json b/packages/ui/public/locales/tr.json index f7d74efe83..7fd956b3c5 100644 --- a/packages/ui/public/locales/tr.json +++ b/packages/ui/public/locales/tr.json @@ -16,6 +16,7 @@ "app-only-over-tor": "{{app}} yalnızca Tor üzerinden kullanılabilir. Bu uygulamayı açmak için uzaktan erişim URL'inizde (Ayarlar > Uzaktan Tor erişimi) bir Tor tarayıcısında Umbrel'inize erişin.", "app-page.section.about": "Hakkında", "app-page.section.credentials.title": "Varsayılan kimlik bilgileri", + "app-page.section.dependencies.n-alternatives": "{{count}} alternatiflere bak", "app-page.section.info.compatibility": "Uyumluluk", "app-page.section.info.compatibility-compatible": "Uyumlu", "app-page.section.info.compatibility-not-compatible": "Uyumlu değil", @@ -32,6 +33,9 @@ "app-page.section.requires": "Gereksinimler", "app-picker.search": "Ara...", "app-picker.select-app": "Uygulama seç...", + "app-settings.connected-to": "{{appName}} bu uygulamalara bağlı", + "app-settings.save-changes": "Değişiklikleri kaydet", + "app-settings.title": "Ayarlar", "app-store.browse-category-apps": "{{category}} uygulamalarına göz at", "app-store.category.ai": "Yapay Zeka", "app-store.category.all": "Tüm uygulamalar", @@ -59,6 +63,7 @@ "app-updates.updates-available-count_other": "{{count}} güncelleme mevcut", "app-updates.updating": "Güncelleniyor...", "app.install": "Yükle", + "app.installed": "Yüklendi", "app.installing": "Yükleniyor", "app.offline": "Çalışmıyor", "app.open": "Aç", @@ -140,6 +145,7 @@ "default-credentials.title": "{{app}} için kimlik bilgileri", "default-credentials.username": "Varsayılan kullanıcı adı", "desktop.app.context.go-to-store-page": "App Store'da görüntüle", + "desktop.app.context.settings": "Ayarlar", "desktop.app.context.show-default-credentials": "Varsayılan kimlik bilgilerini göster", "desktop.app.context.uninstall": "Kaldır", "desktop.context-menu.change-wallpaper": "Duvar kağıdını değiştir", @@ -185,9 +191,8 @@ "factory-reset.success.description": "Tüm uygulamalarınız, uygulama verileriniz ve hesap verileriniz cihazınızdan silinmiş ve sistem fabrika ayarlarına sıfırlanmıştır.", "factory-reset.success.title": "Sıfırlama başarılı", "hello": "Merhaba", - "install-first.description_one": "{{app}}'i yüklemek için bu uygulamayı yükleyin.", - "install-first.description_other": "{{app}}'i yüklemek için önce bu uygulamaları yükleyin.", - "install-first.title": "{{app}}'in erişmesi gerekenler", + "install-first.install-app": "{{app}} yükle", + "install-first.title": "{{app}} bu uygulamalara ihtiyaç duyuyor", "install-your-first-app": "İlk uygulamanızı yükleyin", "language": "Dil", "language-description": "Tercih ettiğiniz umbrelOS dili", diff --git a/packages/ui/public/locales/uk.json b/packages/ui/public/locales/uk.json index 07e9d4951e..4287cbe755 100644 --- a/packages/ui/public/locales/uk.json +++ b/packages/ui/public/locales/uk.json @@ -16,6 +16,7 @@ "app-only-over-tor": "{{app}} можна використовувати тільки через Tor. Доступ до вашого Umbrel за допомогою Tor браузера за віддаленою URL-адресою (Налаштування > Віддалений доступ Tor), щоб відкрити цю програму.", "app-page.section.about": "Про", "app-page.section.credentials.title": "Стандартні облікові дані", + "app-page.section.dependencies.n-alternatives": "Переглянути {{count}} альтернатив", "app-page.section.info.compatibility": "Сумісність", "app-page.section.info.compatibility-compatible": "Сумісний", "app-page.section.info.compatibility-not-compatible": "Не сумісний", @@ -32,6 +33,9 @@ "app-page.section.requires": "Вимагає", "app-picker.search": "Пошук...", "app-picker.select-app": "Виберіть програму...", + "app-settings.connected-to": "{{appName}} підключено до цих додатків", + "app-settings.save-changes": "Зберегти зміни", + "app-settings.title": "Налаштування", "app-store.browse-category-apps": "Перегляд програм у категорії {{category}}", "app-store.category.ai": "Штучний інтелект", "app-store.category.all": "Всі програми", @@ -59,6 +63,7 @@ "app-updates.updates-available-count_other": "Доступно {{count}} оновлень", "app-updates.updating": "Оновлення...", "app.install": "Встановити", + "app.installed": "Встановлено", "app.installing": "Встановлення", "app.offline": "Не працює", "app.open": "Відкрити", @@ -140,6 +145,7 @@ "default-credentials.title": "Облікові дані для {{app}}", "default-credentials.username": "Ім'я користувача за замовчуванням", "desktop.app.context.go-to-store-page": "Переглянути в App Store", + "desktop.app.context.settings": "Налаштування", "desktop.app.context.show-default-credentials": "Показати облікові дані за замовчуванням", "desktop.app.context.uninstall": "Видалити", "desktop.context-menu.change-wallpaper": "Змінити фон", @@ -185,9 +191,8 @@ "factory-reset.success.description": "Всі ваші програми, дані програм та дані облікового запису було видалено з вашого пристрою, а система скинута до заводського стану.", "factory-reset.success.title": "Скидання успішне", "hello": "Привіт", - "install-first.description_one": "Встановіть цю програму, щоб встановити {{app}}.", - "install-first.description_other": "Спочатку встановіть ці програми, щоб встановити {{app}}.", - "install-first.title": "{{app}} вимагає доступу до", + "install-first.install-app": "Встановити {{app}}", + "install-first.title": "{{app}} вимагає ці додатки", "install-your-first-app": "Встановіть свою першу програму", "language": "Мова", "language-description": "Ваша бажана мова umbrelOS", diff --git a/packages/ui/src/components/cmdk.tsx b/packages/ui/src/components/cmdk.tsx index 74e3eab78f..9ecf612f46 100644 --- a/packages/ui/src/components/cmdk.tsx +++ b/packages/ui/src/components/cmdk.tsx @@ -74,6 +74,8 @@ function CmdkContent() { const userQ = trpcReact.user.get.useQuery() const launchApp = useLaunchApp() const debugInstallRandomApps = useDebugInstallRandomApps() + // We only show installed community apps here, effectively limiting available + // apps to those present in the official app store const availableApps = useAvailableApps() const isLoading = userQ.isLoading || availableApps.isLoading || userApps.isLoading diff --git a/packages/ui/src/components/install-button-connected.tsx b/packages/ui/src/components/install-button-connected.tsx index a0df8d5e97..48eeb13c1f 100644 --- a/packages/ui/src/components/install-button-connected.tsx +++ b/packages/ui/src/components/install-button-connected.tsx @@ -1,5 +1,5 @@ import prettyBytes from 'pretty-bytes' -import {useState} from 'react' +import {forwardRef, useImperativeHandle, useState} from 'react' import {useTimeout} from 'react-use' import semver from 'semver' import {arrayIncludes} from 'ts-extras' @@ -7,98 +7,155 @@ import {arrayIncludes} from 'ts-extras' import {useAppInstall} from '@/hooks/use-app-install' import {useLaunchApp} from '@/hooks/use-launch-app' import {useVersion} from '@/hooks/use-version' -import {AppPermissionsDialog} from '@/modules/app-store/app-permissions-dialog' -import {UMBREL_APP_STORE_ID} from '@/modules/app-store/constants' -import {InstallTheseFirstDialog} from '@/modules/app-store/install-these-first-dialog' import {OSUpdateRequiredDialog} from '@/modules/app-store/os-update-required' +import {SelectDependenciesDialog} from '@/modules/app-store/select-dependencies-dialog' import {useApps} from '@/providers/apps' +import {useAllAvailableApps} from '@/providers/available-apps' import {installedStates, RegistryApp} from '@/trpc/trpc' import {InstallButton} from './install-button' -export function InstallButtonConnected({ - app, - registryId = UMBREL_APP_STORE_ID, -}: { - app: RegistryApp - registryId?: string -}) { - const appInstall = useAppInstall(app.id) - const [showDepsDialog, setShowDepsDialog] = useState(false) - const [showAppPermissionsDialog, setShowAppPermissionsDialog] = useState(false) - const [showOSUpdateRequiredDialog, setShowOSUpdateRequiredDialog] = useState(false) - const {userAppsKeyed, isLoading} = useApps() - const openApp = useLaunchApp() - const os = useVersion() - const [show] = useTimeout(400) - - if (!show() || isLoading || !userAppsKeyed || os.isLoading) { - return ( - +export const InstallButtonConnected = forwardRef( + ( + { + app, + }: { + app: RegistryApp + }, + ref, + ) => { + const appInstall = useAppInstall(app.id) + const {apps} = useAllAvailableApps() + const [showDepsDialog, setShowDepsDialog] = useState(false) + const [showOSUpdateRequiredDialog, setShowOSUpdateRequiredDialog] = useState(false) + const {userAppsKeyed, isLoading} = useApps() + const openApp = useLaunchApp() + const [selections, setSelections] = useState({} as Record) + const os = useVersion() + const [show] = useTimeout(400) + const [highlightDependency, setHighlightDependency] = useState(undefined) + + useImperativeHandle(ref, () => ({ + triggerInstall(highlightDependency?: string) { + setHighlightDependency(highlightDependency) + triggerInstall() + }, + })) + + if (!show() || isLoading || !userAppsKeyed || !apps || os.isLoading) { + return ( + + ) + } + + const isInstalled = (appId: string) => arrayIncludes(installedStates, userAppsKeyed[appId]?.state) + + const selectAlternative = (dependencyId: string, appId: string | undefined) => { + if (appId) selections[dependencyId] = appId + else delete selections[dependencyId] + setSelections({...selections}) + } + + const getAppsImplementing = (dependencyId: string) => + apps + // Filter out community apps that aren't installed + .filter((registryApp) => { + const isCommunityApp = registryApp.appStoreId !== 'umbrel-app-store' + return !isCommunityApp || userAppsKeyed[registryApp.id] + }) + // Prefer installed app over registry app + .map((registryApp) => userAppsKeyed[registryApp.id] ?? registryApp) + .filter((applicableApp) => applicableApp.implements?.includes(dependencyId)) + .map((implementingApp) => implementingApp.id) + + // Obtain possible alternatives for each dependency. Groups alternatives for + // each dependency into a two dimensional array, where each item references + // both the original dependency and the alterantive app. First item always is + // the original dependency. + // [ + // [{dependencyId, appId: dependencyId}, {dependencyId, appId: implementingId}], + // [{dependencyId, appId: dependencyId}], + // ] + const dependencies = (app.dependencies ?? []).map((dependencyId) => + [dependencyId, ...getAppsImplementing(dependencyId)].map((appId) => ({ + dependencyId, + appId, + })), + ) + + // Auto-select the first installed alternative, naturally preferring the original + // app when it is installed as well. + dependencies.forEach((alternatives) => { + alternatives.forEach(({dependencyId, appId}) => { + if (!selections[dependencyId] && isInstalled(appId)) { + selectAlternative(dependencyId, appId) + } + }) + }) + + // TODO: Also check if app is ready? `&& userAppsKeyed[dep].state === 'ready'` + // Will want to mark apps as in progress so we don't show that an app needs to be installed first + const areAllAlternativesSelectedAndInstalled = dependencies.every((alternatives) => + alternatives.some((app) => selections[app.dependencyId] === app.appId && isInstalled(app.appId)), ) - } - // Uninstalled deps, or deps in the middle of something (like install or update) - // TODO: Also check if app is ready? `&& userAppsKeyed[dep].state === 'ready'` - // Will want to mark apps as in progress so we don't show that an app needs to be installed first - const deps = app.dependencies ?? [] - const areDepsAllInstalled = deps.every((dep) => arrayIncludes(installedStates, userAppsKeyed[dep]?.state)) - - const compatible = semver.lte(app.manifestVersion, os.version) - - const install = () => { - if (!compatible) { - setShowOSUpdateRequiredDialog(true) - return + + const compatible = semver.lte(app.manifestVersion, os.version) + + const install = () => { + if (!compatible) { + setShowOSUpdateRequiredDialog(true) + return + } + if (dependencies.length > 0) { + return setShowDepsDialog(true) + } + appInstall.install() } - if (deps.length > 0) { - if (areDepsAllInstalled) { - setShowAppPermissionsDialog(true) - } else { - setShowDepsDialog(true) + + function triggerInstall() { + install() + } + + const verifyInstall = (selectedDeps: Record) => { + // Currently always the case because AppPermissionsDialog checks + if (areAllAlternativesSelectedAndInstalled) { + appInstall.install(selectedDeps) } - return } - appInstall.install() - } - - return ( - <> - openApp(app.id)} - /> - - appInstall.install()} - /> - - - ) -} + + return ( + <> + openApp(app.id)} + /> + + + + ) + }, +) diff --git a/packages/ui/src/hooks/use-app-install.ts b/packages/ui/src/hooks/use-app-install.ts index 5604ddd540..7c809a39ec 100644 --- a/packages/ui/src/hooks/use-app-install.ts +++ b/packages/ui/src/hooks/use-app-install.ts @@ -1,7 +1,6 @@ import {useMutation} from '@tanstack/react-query' import {useEffect} from 'react' import {useInterval, usePrevious} from 'react-use' -import {uniq} from 'remeda' import {toast} from 'sonner' import {arrayIncludes} from 'ts-extras' @@ -54,15 +53,17 @@ export function useAppInstall(id: string) { ctx.user.get.invalidate() } - const makeOptimisticOnMutate = (optimisticState: (typeof pollStates)[number], onMutate?: () => void) => () => { + const makeOptimisticOnMutate = (optimisticState: (typeof pollStates)[number]) => () => { // Optimistic because actions do not return until complete // see: https://create.t3.gg/en/usage/trpc#optimistic-updates ctx.apps.state.cancel() ctx.apps.state.setData({appId: id}, {state: optimisticState, progress: 0}) - onMutate?.() - // TODO: The interval below starts ticking now, so the app's state will be - // first updated in 2000ms. Should we refactor the backend to set the state, - // return early and run the action asynchronously to make sure instead? + + // Make sure apps list reflects the change in time. This is necessary + // because a request to, say, install an app does not return until the + // action is complete. TODO: Refactor the backend to set the state, return + // early and run the actual action asynchronously. + setTimeout(() => ctx.apps.list.invalidate(), 2000) } const startMut = trpcReact.apps.start.useMutation({ @@ -74,12 +75,7 @@ export function useAppInstall(id: string) { onSettled: refreshAppStates, }) const installMut = trpcReact.apps.install.useMutation({ - onMutate: makeOptimisticOnMutate('installing', () => { - // When there are no apps yet, this component is not guaranteed to remain - // referenced, so the interval below might not execute. At the expense of - // redundancy, make sure that the refresh happens in any case. - setTimeout(refreshAppStates, 2000) - }), + onMutate: makeOptimisticOnMutate('installing'), onSettled: refreshAppStates, }) const uninstallMut = trpcReact.apps.uninstall.useMutation({ @@ -109,16 +105,14 @@ export function useAppInstall(id: string) { const start = async () => startMut.mutate({appId: id}) const stop = async () => stopMut.mutate({appId: id}) - const install = async () => installMut.mutate({appId: id}) + const install = async (alternatives?: Record) => { + return installMut.mutate({appId: id, alternatives}) + } const getAppsToUninstallFirst = async () => { - const appsToUninstallFirst = await getRequiredBy(id) + const appsToUninstallFirst = await trpcClient.apps.dependents.query(id) // We expect to have an array, even if it's empty if (!appsToUninstallFirst) throw new Error(t('apps.uninstall.failed-to-get-required-apps')) - if (appsToUninstallFirst.length > 0) { - // TODO: clean up logic around multiple registries so we don't need to use `uniq`? - return uniq(appsToUninstallFirst.map((app) => app.id)) - } - return [] + return appsToUninstallFirst } const uninstall = async () => { const uninstallTheseFirst = await getAppsToUninstallFirst() @@ -143,21 +137,3 @@ export function useAppInstall(id: string) { state, } as const } - -async function getRequiredBy(targetAppId: string) { - // "installed" really means they're user apps, because they can be in other states - const installedApps = await trpcClient.apps.list.query() - - const availableApps = await trpcClient.appStore.registry.query() - // Flatted apps from all registries - const availableAppsFlat = availableApps.flatMap((group) => group.apps) - // Filter out non-installed apps - const availableAppsFlatAndInstalled = availableAppsFlat.filter((app) => - installedApps.find((userApp) => userApp.id === app.id), - ) - - // Look in array to see if `targetAppId` is a dependency of any of the apps - const requiredByApps = availableAppsFlatAndInstalled.filter((app) => app.dependencies?.includes(targetAppId)) - - return requiredByApps -} diff --git a/packages/ui/src/layouts/desktop.tsx b/packages/ui/src/layouts/desktop.tsx index e25f709ca1..08577814dd 100644 --- a/packages/ui/src/layouts/desktop.tsx +++ b/packages/ui/src/layouts/desktop.tsx @@ -1,6 +1,7 @@ import {useEffect} from 'react' import {useCmdkOpen} from '@/components/cmdk' +import {AppSettingsDialog} from '@/modules/app-store/app-page/app-settings-dialog' import {DefaultCredentialsDialog} from '@/modules/app-store/app-page/default-credentials-dialog' import {DesktopContent} from '@/modules/desktop/desktop-content' import {InstallFirstApp} from '@/modules/desktop/install-first-app' @@ -54,6 +55,7 @@ function DesktopPage() { + ) } diff --git a/packages/ui/src/modules/app-store/app-page/app-content.tsx b/packages/ui/src/modules/app-store/app-page/app-content.tsx index 0d67312f10..375eb47845 100644 --- a/packages/ui/src/modules/app-store/app-page/app-content.tsx +++ b/packages/ui/src/modules/app-store/app-page/app-content.tsx @@ -18,11 +18,13 @@ export function AppContent({ app, userApp, recommendedApps = [], + showDependencies, }: { app: RegistryApp /** When the user initiates an install, we now have a user app, even before install */ userApp?: UserApp recommendedApps?: RegistryApp[] + showDependencies?: (dependencyId?: string) => void }) { const hasDependencies = app.dependencies && app.dependencies.length > 0 return ( @@ -39,7 +41,7 @@ export function AppContent({
{userApp && } - {hasDependencies && } + {hasDependencies && } {!isEmpty(recommendedApps) && }
@@ -48,7 +50,7 @@ export function AppContent({ {userApp && } - {hasDependencies && } + {hasDependencies && } {!isEmpty(recommendedApps) && } diff --git a/packages/ui/src/modules/app-store/app-page/app-settings-dialog.tsx b/packages/ui/src/modules/app-store/app-page/app-settings-dialog.tsx new file mode 100644 index 0000000000..4dbb9a58ea --- /dev/null +++ b/packages/ui/src/modules/app-store/app-page/app-settings-dialog.tsx @@ -0,0 +1,190 @@ +import {Close, DialogDescription} from '@radix-ui/react-dialog' +import {useMemo, useState} from 'react' +import {arrayIncludes} from 'ts-extras' + +import {AppIcon} from '@/components/app-icon' +import {appStateToString} from '@/components/cmdk' +import {useQueryParams} from '@/hooks/use-query-params' +import {useApps, useUserApp} from '@/providers/apps' +import {useAllAvailableApps} from '@/providers/available-apps' +import {Button} from '@/shadcn-components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogPortal, + DialogTitle, +} from '@/shadcn-components/ui/dialog' +import {installedStates, progressStates, RegistryApp, trpcReact, UserApp} from '@/trpc/trpc' +import {useDialogOpenProps} from '@/utils/dialog' +import {t} from '@/utils/i18n' + +import {SelectDependencies} from '../select-dependencies-dialog' + +export function AppSettingsDialog() { + const {params} = useQueryParams() + const appId = params.get('app-settings-for') + const dependencyId = params.get('app-settings-dependency') ?? undefined + + const {isLoading, app} = useUserApp(appId) + const {userApps, userAppsKeyed} = useApps() + const {apps: availableApps} = useAllAvailableApps() + + if (isLoading || !app || !userApps || !userAppsKeyed || !availableApps) { + return null + } + + return ( + + ) +} + +function areSelectionsEqual(a?: Record, b?: Record) { + if (!a || !b || a === b) return true + const keys1 = Object.keys(a) + const keys2 = Object.keys(b) + if (keys1.length !== keys2.length) return false + for (const key of keys1) { + if (b[key] !== a[key]) return false + } + return true +} + +function AppSettingsDialogForApp({ + app, + userApps, + userAppsKeyed, + availableApps, + openDependency, +}: { + app: UserApp + userApps: UserApp[] + userAppsKeyed: Record + availableApps: RegistryApp[] + openDependency?: string +}) { + const dialogProps = useDialogOpenProps('app-settings') + const [selectedDependencies, setSelectedDependencies] = useState>( + app.selectedDependencies ?? {}, + ) + const [hadChanges, setHadChanges] = useState(false) + const ctx = trpcReact.useContext() + const setSelectedDependenciesMut = trpcReact.apps.setSelectedDependencies.useMutation({ + onSuccess() { + // Invalidate this app's state + ctx.apps.state.invalidate({appId: app.id}) + // Invalidate list of apps on desktop + ctx.apps.list.invalidate() + }, + }) + + const getAppsImplementing = (dependencyId: string) => + availableApps + // Filter out community apps that aren't installed + .filter((registryApp) => { + const isCommunityApp = registryApp.appStoreId !== 'umbrel-app-store' + return !isCommunityApp || userAppsKeyed[registryApp.id] + }) + // Prefer installed app over registry app + .map((registryApp) => userAppsKeyed?.[registryApp.id] ?? registryApp) + .filter((applicableApp) => applicableApp.implements?.includes(dependencyId)) + .map((implementingApp) => implementingApp.id) + + const dependencies = useMemo( + () => + (app.dependencies ?? []).map((dependencyId) => + [dependencyId, ...getAppsImplementing(dependencyId)].map((appId) => ({ + dependencyId, + appId, + })), + ), + [app.dependencies], + ) + + const areAllDependenciesInstalled = dependencies.every((alternatives) => + alternatives.some((alternative) => + userApps.some( + (installedApp) => + installedApp.id === selectedDependencies[alternative.dependencyId] && + arrayIncludes(installedStates, installedApp.state), + ), + ), + ) + + function onSelectionChange(selectedDependencies: Record) { + setSelectedDependencies(selectedDependencies) + if (!areSelectionsEqual(app.selectedDependencies, selectedDependencies)) { + setHadChanges(true) + } + } + + function onSubmit() { + if (areAllDependenciesInstalled) { + setSelectedDependenciesMut.mutate({ + appId: app.id, + dependencies: selectedDependencies, + }) + } + } + + const inProgress = arrayIncludes(progressStates, app.state) + const hasChanges = !areSelectionsEqual(app.selectedDependencies, selectedDependencies) + + return ( + + + { + // `preventDefault` to prevent focus on first input + e.preventDefault() + }} + > + + + + {t('app-settings.title')} + + + + {t('app-settings.connected-to', {appName: app.name})} + + {dependencies.length ? ( + dialogProps.onOpenChange(false)} + highlightDependency={openDependency} + /> + ) : ( + t('app-settings.no-dependencies') + )} + {hadChanges && ( + + + + + + + + + )} + + + + ) +} diff --git a/packages/ui/src/modules/app-store/app-page/dependencies.tsx b/packages/ui/src/modules/app-store/app-page/dependencies.tsx index 10918f8980..37596f9ad5 100644 --- a/packages/ui/src/modules/app-store/app-page/dependencies.tsx +++ b/packages/ui/src/modules/app-store/app-page/dependencies.tsx @@ -1,40 +1,94 @@ +import {Fragment} from 'react' import {TbCircleCheckFilled} from 'react-icons/tb' import {Link} from 'react-router-dom' +import {arrayIncludes} from 'ts-extras' import {AppIcon} from '@/components/app-icon' import {Loading} from '@/components/ui/loading' import {useApps} from '@/providers/apps' -import {useAvailableApps} from '@/providers/available-apps' -import {RegistryApp} from '@/trpc/trpc' +import {useAllAvailableApps} from '@/providers/available-apps' +import {cn} from '@/shadcn-lib/utils' +import {installedStates, RegistryApp} from '@/trpc/trpc' import {t} from '@/utils/i18n' import {cardClass, cardTitleClass} from './shared' -export const DependenciesSection = ({app}: {app: RegistryApp}) => { - const {appsKeyed, isLoading: isLoadingAvailableApps} = useAvailableApps() - const {userApps, isLoading: isLoadingUserApps} = useApps() +export const DependenciesSection = ({ + app, + showDependencies, +}: { + app: RegistryApp + showDependencies?: (dependencyId?: string) => void +}) => { + const {apps, appsKeyed, isLoading: isLoadingAvailableApps} = useAllAvailableApps() + const {userAppsKeyed, isLoading: isLoadingUserApps} = useApps() if (isLoadingAvailableApps || isLoadingUserApps) return return (

{t('app-page.section.requires')}

- {app.dependencies?.map((dep) => ( - app.id === dep)} /> - ))} + {app.dependencies?.map((dependencyId) => { + const dependencyUserApp = userAppsKeyed?.[dependencyId] + const numberOfAlternativeApps = apps + // Filter out community apps that aren't installed + .filter((registryApp) => { + const isCommunityApp = registryApp.appStoreId !== 'umbrel-app-store' + return !isCommunityApp || userAppsKeyed?.[registryApp.id] + }) + // Prefer installed app's implements over registry app's + .filter((registryApp) => + (userAppsKeyed?.[registryApp.id] ?? registryApp).implements?.includes(dependencyId), + ).length + return ( + + + + ) + })}
) } -const Dependency = ({app, installed = false}: {app: RegistryApp; installed: boolean}) => ( - // TODO: link to community app store if needed - - -
{app.name}
- {installed ? ( - - ) : ( - {t('app.view')} - )} - -) +const Dependency = ({ + app, + installed = false, + numberOfAlternativeApps = 0, + showDependencies, +}: { + app: RegistryApp + installed: boolean + numberOfAlternativeApps: number + showDependencies?: (dependencyId?: string) => void +}) => { + return ( +
+ + + +
+ +

{app.name}

+ {installed && } + + {numberOfAlternativeApps > 0 && ( +
showDependencies?.(app.id)} + > + {t('app-page.section.dependencies.n-alternatives', {count: numberOfAlternativeApps + /* app itself */ 1})} +
+ )} +
+
+ ) +} diff --git a/packages/ui/src/modules/app-store/app-permissions-dialog.tsx b/packages/ui/src/modules/app-store/app-permissions-dialog.tsx deleted file mode 100644 index 4b969f7d32..0000000000 --- a/packages/ui/src/modules/app-store/app-permissions-dialog.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import {Close} from '@radix-ui/react-dialog' - -import {AppWithName} from '@/modules/app-store/shared' -import {useApps} from '@/providers/apps' -import {useAllAvailableApps} from '@/providers/available-apps' -import {Button} from '@/shadcn-components/ui/button' -import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/shadcn-components/ui/dialog' -import {t} from '@/utils/i18n' - -export function AppPermissionsDialog({ - appId, - open, - onOpenChange, - appsUsed, - onNext, -}: { - appId: string - open: boolean - onOpenChange: (open: boolean) => void - registryId?: string - appsUsed: string[] - onNext: () => void -}) { - const availableApps = useAllAvailableApps() - const userApps = useApps() - const app = availableApps.appsKeyed?.[appId] - - if (userApps.isLoading) return null - if (availableApps.isLoading) return null - if (!app) throw new Error('App not found') - - const appName = app?.name - const appPermissions = appsUsed.map((id) => availableApps.appsKeyed?.[id]) - - return ( - - - - {t('install-first.title', {app: appName})} - -
- {appPermissions.map((app) => ( - - ))} -
- - - - - - - - - {/* */} -
-
- ) -} diff --git a/packages/ui/src/modules/app-store/install-these-first-dialog.tsx b/packages/ui/src/modules/app-store/install-these-first-dialog.tsx deleted file mode 100644 index 459b16c077..0000000000 --- a/packages/ui/src/modules/app-store/install-these-first-dialog.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import {Close} from '@radix-ui/react-dialog' -import {TbCircleCheckFilled} from 'react-icons/tb' -import {Link} from 'react-router-dom' -import {arrayIncludes} from 'ts-extras' - -import {appStateToString} from '@/components/cmdk' -import {AppWithName} from '@/modules/app-store/shared' -import {useApps} from '@/providers/apps' -import {useAllAvailableApps} from '@/providers/available-apps' -import {Button} from '@/shadcn-components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/shadcn-components/ui/dialog' -import {AppState, installedStates} from '@/trpc/trpc' -import {t} from '@/utils/i18n' - -export function InstallTheseFirstDialog({ - open, - onOpenChange, - appId, - - dependencies, -}: { - open: boolean - onOpenChange: (open: boolean) => void - appId: string - - dependencies: string[] -}) { - const availableApps = useAllAvailableApps() - const userApps = useApps() - const app = availableApps.appsKeyed?.[appId] - - if (userApps.isLoading) return null - if (availableApps.isLoading) return null - if (!app) throw new Error('App not found') - - const appName = app?.name - const allDepApps = dependencies.map((id) => availableApps.appsKeyed?.[id]) - - return ( - - - - {t('install-first.title', {app: appName})} - -
- {allDepApps.map((app) => ( - onOpenChange(false)} - /> - } - /> - ))} -
- - {t('install-first.description', {app: appName, count: allDepApps.length})} - - - - - - - {/* */} -
-
- ) -} - -function AppStateText({appId, appState, onClick}: {appId: string; appState: AppState; onClick?: () => void}) { - if (arrayIncludes(installedStates, appState)) { - return - } - - switch (appState) { - case 'not-installed': - return ( - // TODO: link to community app store if needed using `getAppStoreAppFromInstalledApp` - - {t('app.install')} - - ) - default: - return
{appStateToString(appState) + '...'}
- } -} diff --git a/packages/ui/src/modules/app-store/select-dependencies-dialog.tsx b/packages/ui/src/modules/app-store/select-dependencies-dialog.tsx new file mode 100644 index 0000000000..5e36d27d1b --- /dev/null +++ b/packages/ui/src/modules/app-store/select-dependencies-dialog.tsx @@ -0,0 +1,289 @@ +import {Close} from '@radix-ui/react-dialog' +import {SetStateAction, useEffect, useState} from 'react' +import {arrayIncludes} from 'ts-extras' + +import {ChevronDown} from '@/assets/chevron-down' +import {AppIcon} from '@/components/app-icon' +import {appStateToString} from '@/components/cmdk' +import {ButtonLink} from '@/components/ui/button-link' +import {useApps} from '@/providers/apps' +import {useAllAvailableApps} from '@/providers/available-apps' +import {Button} from '@/shadcn-components/ui/button' +import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/shadcn-components/ui/dialog' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/shadcn-components/ui/dropdown-menu' +import {ScrollArea} from '@/shadcn-components/ui/scroll-area' +import {cn} from '@/shadcn-lib/utils' +import {AppState, installedStates, RegistryApp} from '@/trpc/trpc' +import {t} from '@/utils/i18n' +import {tw} from '@/utils/tw' + +export function SelectDependenciesDialog({ + open, + onOpenChange, + appId, + dependencies, + onNext, + highlightDependency, +}: { + open: boolean + onOpenChange: (open: boolean) => void + appId: string + dependencies: {dependencyId: string; appId: string}[][] + onNext: (selectedDeps: Record) => void + highlightDependency?: string +}) { + const availableApps = useAllAvailableApps() + const {isLoading, userApps, userAppsKeyed} = useApps() + const [selectedDependencies, setSelectedDependencies] = useState>({}) + + // Try user app first in case the app was installed at some point but is not + // present in an app store anymore, for example because a community app store + // has been removed. UserApp and RegistryApp share the necessary properties. + const registryApp = availableApps.appsKeyed?.[appId] + const userApp = userAppsKeyed?.[appId] + const app = userApp ?? registryApp + if (!app) throw new Error('App not found') + + if (isLoading || !userApps || !userAppsKeyed || availableApps.isLoading) return null + + const appName = app?.name + + const areAllDependenciesInstalled = dependencies.every((alternatives) => + alternatives.some((alternative) => + Object.values(userAppsKeyed).some( + (installedApp) => + installedApp.id === selectedDependencies[alternative.dependencyId] && + arrayIncludes(installedStates, installedApp.state), + ), + ), + ) + + return ( + + { + // `preventDefault` to prevent focus on first input + e.preventDefault() + }} + > + + {t('install-first.title', {app: appName})} + + onOpenChange(false)} + highlightDependency={highlightDependency} + /> + + + + + + + + + + + + + ) +} + +// Reusable dependencies selection +export function SelectDependencies({ + dependencies, + selectedDependencies, + setSelectedDependencies, + onInstallClick, + highlightDependency, +}: { + dependencies: {dependencyId: string; appId: string}[][] + selectedDependencies: Record + setSelectedDependencies: (selectedDependencies: Record) => void + onInstallClick: () => void + highlightDependency?: string +}) { + const {apps, appsKeyed} = useAllAvailableApps() + const {isLoading, userApps, userAppsKeyed} = useApps() + const [openDropdowns, setOpenDropdowns] = useState>({}) + + if (isLoading || !userApps || !userAppsKeyed || !apps || !appsKeyed) return null + + const reifiedDependencies = dependencies.map((alternatives) => + alternatives.map(({dependencyId, appId}) => ({ + dependencyId, + app: appsKeyed[appId], + })), + ) + + // Pre-select installed apps or main alternatives + useEffect(() => { + const newSelectedDependencies: Record = { + ...selectedDependencies, + } + reifiedDependencies.forEach((alternatives) => { + const dependencyId = alternatives[0].dependencyId + if (newSelectedDependencies[dependencyId]) return + const installedOrInstallingApp = alternatives.find(({app}) => { + const userApp = userAppsKeyed?.[app.id] + return userApp && (arrayIncludes(installedStates, userApp.state) || userApp.state === 'installing') + }) + newSelectedDependencies[dependencyId] = installedOrInstallingApp + ? installedOrInstallingApp.app.id + : alternatives[0].app.id + }) + setSelectedDependencies(newSelectedDependencies) + }, [dependencies]) + + const selectDependency = (dependencyId: string, appId: string) => { + const newSelectedDependencies = { + ...selectedDependencies, + [dependencyId]: appId, + } + setSelectedDependencies(newSelectedDependencies) + } + + return ( +
+ {reifiedDependencies.map((alternatives) => { + const {dependencyId, app} = alternatives[0] + const hasAlternatives = alternatives.length > 1 + + if (!hasAlternatives) { + // If no alternatives, just show the app name and state + return ( +
+ + {app.icon && } + {app.name} + + +
+ ) + } + + // If has alternatives, show dropdown + return ( +
+ + +
+ ) + })} +
+ ) +} + +const listClass = tw`divide-y divide-white/6 overflow-hidden rounded-12 bg-white/6` +const listItemClass = tw`flex items-center pl-3 pr-4 h-[50px] text-[14px] font-medium -tracking-3 justify-between` +const listItemClassWithDropdown = tw`flex items-center pl-3 pr-4 h-[60px] text-[14px] font-medium -tracking-3 justify-between` + +function DependencyStateText({appId, appState, onClick}: {appId: string; appState: AppState; onClick?: () => void}) { + const buttonClass = 'w-[70px]' // Fixed width for both buttons + + if (arrayIncludes(installedStates, appState)) { + return ( + + ) + } + + if (appState === 'not-installed') { + return ( + // TODO: link to community app store if needed using `getAppStoreAppFromInstalledApp` + + {t('app.install')} + + ) + } + + return {appStateToString(appState) + '...'} +} + +function DependencyDropdown({ + dependencyId, + selectedApp, + alternatives, + openDropdowns, + setOpenDropdowns, + onSelectDependency, + highlightDependency, +}: { + dependencyId: string + selectedApp?: RegistryApp + alternatives: {dependencyId: string; app: RegistryApp}[] + openDropdowns: Record + setOpenDropdowns: (value: SetStateAction>) => void + onSelectDependency: (dependencyId: string, appId: string) => void + highlightDependency?: string +}) { + const onOpenChange = (open: boolean) => setOpenDropdowns((prev) => ({...prev, [dependencyId]: open})) + return ( + + + + + + + {alternatives.map(({app}) => ( + { + onSelectDependency(dependencyId, app.id) + onOpenChange(false) + }} + className='flex gap-2' + > + + {app.name} + + ))} + + + + ) +} diff --git a/packages/ui/src/modules/app-store/shared.tsx b/packages/ui/src/modules/app-store/shared.tsx index b5585b3244..582f11e8b9 100644 --- a/packages/ui/src/modules/app-store/shared.tsx +++ b/packages/ui/src/modules/app-store/shared.tsx @@ -54,13 +54,15 @@ export function AppWithName({ icon, appName, childrenRight, + className, }: { icon: string appName: ReactNode childrenRight?: ReactNode + className?: string }) { return ( -
+

{appName}

{childrenRight} diff --git a/packages/ui/src/modules/desktop/app-icon.tsx b/packages/ui/src/modules/desktop/app-icon.tsx index 87eff427d1..cac3d663d6 100644 --- a/packages/ui/src/modules/desktop/app-icon.tsx +++ b/packages/ui/src/modules/desktop/app-icon.tsx @@ -203,7 +203,6 @@ export function AppIconConnected({appId}: {appId: string}) { /> - {userApp.app.credentials && (userApp.app.credentials.defaultUsername || userApp.app.credentials.defaultPassword) && ( @@ -214,6 +213,12 @@ export function AppIconConnected({appId}: {appId: string}) { )} {!inProgress && ( <> + {/* App settings only cover dependencies currently */} + {!!userApp.app.dependencies?.length && ( + + {t('desktop.app.context.settings')} + + )} {appInstall.state !== 'stopped' ? ( {t('stop')} ) : ( @@ -223,11 +228,14 @@ export function AppIconConnected({appId}: {appId: string}) { navigate(`/settings/troubleshoot/app/${appId}`)}> {t('troubleshoot')} - - {t('desktop.app.context.uninstall')} - )} + + {!inProgress && ( + + {t('desktop.app.context.uninstall')} + + )} {toUninstallFirstIds.length > 0 && ( @@ -252,7 +260,7 @@ export function AppIconConnected({appId}: {appId: string}) { } } -function ContextMenuItemLink({appId}: {appId: string}) { +function ContextMenuItemLinkToAppStore({appId}: {appId: string}) { const navigate = useNavigate() return ( diff --git a/packages/ui/src/modules/desktop/desktop-context-menu.tsx b/packages/ui/src/modules/desktop/desktop-context-menu.tsx index 1eba334f36..4d6f2c7d14 100644 --- a/packages/ui/src/modules/desktop/desktop-context-menu.tsx +++ b/packages/ui/src/modules/desktop/desktop-context-menu.tsx @@ -14,12 +14,13 @@ export function DesktopContextMenu({children}: {children: React.ReactNode}) { const [show, setShow] = useState(false) const contentRef = useRef(null) const anchorRef = useRef(null) - const {addLinkSearchParams} = useQueryParams() + const {params, addLinkSearchParams} = useQueryParams() + const isShowingDialog = params.get('dialog') !== null return ( <> - {children} + {children} {t('desktop.context-menu.edit-widgets')} diff --git a/packages/ui/src/routes/app-store/app-page/index.tsx b/packages/ui/src/routes/app-store/app-page/index.tsx index 4008c7a253..024285e770 100644 --- a/packages/ui/src/routes/app-store/app-page/index.tsx +++ b/packages/ui/src/routes/app-store/app-page/index.tsx @@ -1,5 +1,6 @@ +import {useMemo, useRef} from 'react' import {ErrorBoundary} from 'react-error-boundary' -import {useParams} from 'react-router-dom' +import {useNavigate, useParams} from 'react-router-dom' import {InstallButtonConnected} from '@/components/install-button-connected' import {ErrorBoundaryCardFallback} from '@/components/ui/error-boundary-card-fallback' @@ -11,10 +12,13 @@ import {appPageWrapperClass} from '@/modules/app-store/app-page/shared' import {TopHeader} from '@/modules/app-store/app-page/top-header' import {useApps} from '@/providers/apps' import {useAvailableApp, useAvailableApps} from '@/providers/available-apps' +import {useLinkToDialog} from '@/utils/dialog' export default function AppPage() { const {appId} = useParams() const {app, isLoading} = useAvailableApp(appId) + const linkToDialog = useLinkToDialog() + const navigate = useNavigate() const {apps, isLoading: isLoadingApps} = useAvailableApps() const {userAppsKeyed, isLoading: isLoadingUserApps} = useApps() @@ -24,7 +28,21 @@ export default function AppPage() { const userApp = userAppsKeyed?.[app.id] - const recommendedApps = getRecommendationsFor(apps, app.id) + const installButtonRef = useRef<{triggerInstall: (highlightDependency?: string) => void}>(null) + const recommendedApps = useMemo(() => getRecommendationsFor(apps, app.id), []) + + const showDependencies = (dependencyId?: string) => { + const userApp = userAppsKeyed?.[app.id] + if (userApp) { + // Show app settings dialog when app is installed + const params = {for: app.id} as Record + if (dependencyId) params.dependency = dependencyId + navigate(linkToDialog('app-settings', params)) + } else if (installButtonRef.current) { + // Otherwise show app install dialog + installButtonRef.current.triggerInstall(dependencyId) + } + } return (
@@ -33,13 +51,13 @@ export default function AppPage() { childrenRight={
- +
} /> - +
) diff --git a/packages/ui/src/routes/community-app-store/app-page/index.tsx b/packages/ui/src/routes/community-app-store/app-page/index.tsx index f89cc1c608..300477288d 100644 --- a/packages/ui/src/routes/community-app-store/app-page/index.tsx +++ b/packages/ui/src/routes/community-app-store/app-page/index.tsx @@ -30,7 +30,7 @@ export default function CommunityAppPage() { app={app} childrenRight={ - + } /> diff --git a/packages/ui/src/utils/dialog.ts b/packages/ui/src/utils/dialog.ts index e29d274b0c..f8e4682a2e 100644 --- a/packages/ui/src/utils/dialog.ts +++ b/packages/ui/src/utils/dialog.ts @@ -9,7 +9,7 @@ import {sleep} from '@/utils/misc' export const EXIT_DURATION_MS = 200 export type GlobalDialogKey = 'logout' | 'live-usage' -export type AppStoreDialogKey = 'updates' | 'add-community-store' | 'default-credentials' +export type AppStoreDialogKey = 'updates' | 'add-community-store' | 'default-credentials' | 'app-settings' export type DialogKey = GlobalDialogKey | AppStoreDialogKey | SettingsDialogKey // TODO: make dialog query params typesafe diff --git a/packages/ui/stories/src/routes/stories/app-store.tsx b/packages/ui/stories/src/routes/stories/app-store.tsx index e0619b8759..9501fa0308 100644 --- a/packages/ui/stories/src/routes/stories/app-store.tsx +++ b/packages/ui/stories/src/routes/stories/app-store.tsx @@ -10,10 +10,9 @@ import {ProgressButton} from '@/components/progress-button' import {GenericErrorText} from '@/components/ui/generic-error-text' import {Loading} from '@/components/ui/loading' import {useDemoInstallProgress} from '@/hooks/use-demo-progress' -import {AppPermissionsDialog} from '@/modules/app-store/app-permissions-dialog' import {AppStoreNav} from '@/modules/app-store/app-store-nav' import {AppGallerySection, AppsGallerySection} from '@/modules/app-store/gallery-section' -import {InstallTheseFirstDialog} from '@/modules/app-store/install-these-first-dialog' +import {SelectDependenciesDialog} from '@/modules/app-store/select-dependencies-dialog' import {UpdatesDialog} from '@/modules/app-store/updates-dialog' import {AppsProvider} from '@/providers/apps' import {AvailableAppsProvider, useAvailableApps} from '@/providers/available-apps' @@ -71,7 +70,6 @@ function Inner() { - - - - ) -} - -function InstallFirst2Example() { - const [open, setOpen] = useState(false) - return ( - <> - - {}} /> ) } -function AppPermissionsExample() { +function InstallFirst2Example() { const [open, setOpen] = useState(false) return ( <> - alert('next')} + onNext={() => {}} /> ) diff --git a/packages/ui/stories/src/routes/stories/desktop.tsx b/packages/ui/stories/src/routes/stories/desktop.tsx index ee12559fff..ff3841c635 100644 --- a/packages/ui/stories/src/routes/stories/desktop.tsx +++ b/packages/ui/stories/src/routes/stories/desktop.tsx @@ -47,7 +47,7 @@ function InstallExample() { return (
- +
diff --git a/packages/umbreld/source/modules/apps/app-repository.integration.test.ts b/packages/umbreld/source/modules/apps/app-repository.integration.test.ts index 4bdd678e47..4a6f32bda8 100644 --- a/packages/umbreld/source/modules/apps/app-repository.integration.test.ts +++ b/packages/umbreld/source/modules/apps/app-repository.integration.test.ts @@ -162,6 +162,7 @@ describe('appRepository.readRegistry()', () => { }, apps: [ { + appStoreId: 'sparkles', manifestVersion: '1.0.0', id: 'sparkles-hello-world', name: 'Hello World', diff --git a/packages/umbreld/source/modules/apps/app-repository.ts b/packages/umbreld/source/modules/apps/app-repository.ts index 7eb3ec60f2..6fbc33c140 100644 --- a/packages/umbreld/source/modules/apps/app-repository.ts +++ b/packages/umbreld/source/modules/apps/app-repository.ts @@ -173,9 +173,10 @@ export default class AppRepository { .filter((app) => app.disabled !== true) // Filter out invalid IDs .filter((app) => meta.id === 'umbrel-app-store' || app.id.startsWith(meta.id)) - // Add icons + // Add icons and hydrate app store id .map((app) => ({ ...app, + appStoreId: meta.id, gallery: meta.id === 'umbrel-app-store' ? app.gallery.map((file) => `https://getumbrel.github.io/umbrel-apps-gallery/${app.id}/${file}`) diff --git a/packages/umbreld/source/modules/apps/app.ts b/packages/umbreld/source/modules/apps/app.ts index e5f5ffb72f..791ef11cdf 100644 --- a/packages/umbreld/source/modules/apps/app.ts +++ b/packages/umbreld/source/modules/apps/app.ts @@ -353,4 +353,37 @@ export default class App { } } } + + // Get the app's dependencies with selected dependencies applied + async getDependencies() { + const [{dependencies}, selectedDependencies] = await Promise.all([ + this.readManifest(), + this.getSelectedDependencies(), + ]) + return dependencies?.map((dependencyId) => selectedDependencies?.[dependencyId] ?? dependencyId) ?? [] + } + + // Get the app's selected dependencies + async getSelectedDependencies() { + return this.store.get('dependencies') + } + + // Set the app's selected dependencies + async setSelectedDependencies(selectedDependencies: Record) { + const {dependencies} = await this.readManifest() + const selections = (dependencies ?? []).reduce( + (selections, dependencyId) => { + selections[dependencyId] = selectedDependencies[dependencyId] ?? dependencyId + return selections + }, + {} as Record, + ) + const success = await this.store.set('dependencies', selections) + if (success) { + this.restart().catch((error) => { + this.logger.error(`Failed to restart '${this.id}': ${error.message}`) + }) + } + return success + } } diff --git a/packages/umbreld/source/modules/apps/apps.ts b/packages/umbreld/source/modules/apps/apps.ts index 5bca1aa334..e06cfe0d6e 100644 --- a/packages/umbreld/source/modules/apps/apps.ts +++ b/packages/umbreld/source/modules/apps/apps.ts @@ -9,6 +9,8 @@ import semver from 'semver' import randomToken from '../../modules/utilities/random-token.js' import type Umbreld from '../../index.js' import appEnvironment from './legacy-compat/app-environment.js' + +import type {AppSettings} from './schema.js' import App, {readManifestInDirectory} from './app.js' import type {AppManifest} from './schema.js' @@ -202,7 +204,7 @@ export default class Apps { return app } - async install(appId: string) { + async install(appId: string, alternatives?: AppSettings['dependencies']) { if (await this.isInstalled(appId)) throw new Error(`App ${appId} is already installed`) this.logger.log(`Installing app ${appId}`) @@ -233,6 +235,7 @@ export default class Apps { // Save reference to app instance const app = new App(this.#umbreld, appId) + app.store.set('dependencies', alternatives || {}) this.instances.push(app) // Complete the install process via the app script @@ -259,11 +262,9 @@ export default class Apps { } async uninstall(appId: string) { - // If we can't read a manifest for any reason just skip that app, don't abort the uninstall - let installedManifests = await Promise.all(this.instances.map((app) => app.readManifest().catch(() => null))) - installedManifests = installedManifests.filter((manifest) => manifest !== null) - const isDependency = installedManifests.some((manifest) => manifest!.dependencies?.includes(appId)) - + // If we can't read an app's dependencies for any reason just skip that app, don't abort the uninstall + const allDependencies = await Promise.all(this.instances.map((app) => app.getDependencies().catch(() => null))) + const isDependency = allDependencies.some((dependencies) => dependencies?.includes(appId)) if (isDependency) throw new Error(`App ${appId} is a dependency of another app and cannot be uninstalled`) const app = this.getApp(appId) @@ -344,6 +345,21 @@ export default class Apps { return this.#umbreld.store.get('torEnabled') } + async setSelectedDependencies(appId: string, dependencies: Record) { + const app = this.getApp(appId) + return app.setSelectedDependencies(dependencies) + } + + async getDependents(appId: string) { + const allDependencies = await Promise.all( + this.instances.map(async (app) => ({ + id: app.id, + dependencies: await app.getDependencies(), + })), + ) + return allDependencies.filter(({dependencies}) => dependencies.includes(appId)).map(({id}) => id) + } + async setHideCredentialsBeforeOpen(appId: string, value: boolean) { const app = this.getApp(appId) return app.store.set('hideCredentialsBeforeOpen', value) diff --git a/packages/umbreld/source/modules/apps/legacy-compat/app-script b/packages/umbreld/source/modules/apps/legacy-compat/app-script index 244cb0d688..78572995df 100755 --- a/packages/umbreld/source/modules/apps/legacy-compat/app-script +++ b/packages/umbreld/source/modules/apps/legacy-compat/app-script @@ -71,8 +71,14 @@ list_installed_apps() { list_dependencies_of() { local app="$1" local app_data_dir="${UMBREL_ROOT}/app-data/${app}" - local app_yaml_file="${app_data_dir}/umbrel-app.yml" - yq e '.dependencies[]' "${app_yaml_file}" 2> /dev/null || true + local app_manifest_file="${app_data_dir}/umbrel-app.yml" + local app_settings_file="${app_data_dir}/settings.yml" + + # Get the app's dependencies and substitute with alternatives if present + local dependencies=$(yq e '.dependencies[]' "${app_manifest_file}" 2> /dev/null || true) + for dep in $dependencies; do + yq e ".dependencies.${dep} // \"${dep}\"" "${app_settings_file}" 2> /dev/null || echo "${dep}" + done } list_transitive_dependencies_of() { diff --git a/packages/umbreld/source/modules/apps/schema.ts b/packages/umbreld/source/modules/apps/schema.ts index 1c049b3f01..d1f28511b5 100644 --- a/packages/umbreld/source/modules/apps/schema.ts +++ b/packages/umbreld/source/modules/apps/schema.ts @@ -35,14 +35,15 @@ export const AppManifestSchema = z.object({ version: z.string(), port: z.number().int(), description: z.string(), - developer: z.string(), website: z.string().url(), - submitter: z.string(), - submission: z.string().url(), + // TODO: one developer/submitter is an integer + developer: z.union([z.string(), z.number()]).optional(), + submitter: z.union([z.string(), z.number()]).optional(), + submission: z.string().url().optional(), // TODO: some apps have an empty repo string repo: z.union([z.string().url(), z.string().length(0)]).optional(), support: z.string(), - gallery: z.array(z.string()).min(3), + gallery: z.array(z.string()), releaseNotes: z.string().optional(), dependencies: z.array(z.string()).optional(), permissions: z.array(z.string()).optional(), @@ -57,6 +58,7 @@ export const AppManifestSchema = z.object({ // TODO: Define this type widgets: z.array(z.any()).optional(), defaultShell: z.string().optional(), + implements: z.array(z.string()).optional(), }) export type AppManifest = z.infer @@ -84,13 +86,19 @@ export function validateManifest(parsed: unknown): AppManifest { throw new Error('invalid manifest') } parsed.manifestVersion = tryNormalizeVersion(parsed.manifestVersion) + // TODO (apps refactor): switch to semantic versions? // parsed.version = tryNormalizeVersion(parsed.version) - return AppManifestSchema.parse(parsed) + + // TODO (apps refactor): enable schema validation + // return AppManifestSchema.parse(parsed) + + return parsed as AppManifest } export const AppSettingsSchema = z.object({ hideCredentialsBeforeOpen: z.boolean().optional(), + dependencies: z.record(z.string()).optional(), }) export type AppSettings = z.infer diff --git a/packages/umbreld/source/modules/server/trpc/routes/app-store.integration.test.ts b/packages/umbreld/source/modules/server/trpc/routes/app-store.integration.test.ts index 5ebf1869dc..7fba799b0f 100644 --- a/packages/umbreld/source/modules/server/trpc/routes/app-store.integration.test.ts +++ b/packages/umbreld/source/modules/server/trpc/routes/app-store.integration.test.ts @@ -46,6 +46,7 @@ test.sequential('registry() returns app registry', async () => { }, apps: [ { + appStoreId: 'sparkles', manifestVersion: '1.0.0', id: 'sparkles-hello-world', name: 'Hello World', @@ -91,6 +92,7 @@ test.sequential('registry() returns both app repositories in registry', async () }, apps: [ { + appStoreId: 'sparkles', manifestVersion: '1.0.0', id: 'sparkles-hello-world', name: 'Hello World', @@ -127,6 +129,7 @@ test.sequential('registry() returns both app repositories in registry', async () }, apps: [ { + appStoreId: 'sparkles', manifestVersion: '1.0.0', id: 'sparkles-hello-world', name: 'Hello World', @@ -180,6 +183,7 @@ test.sequential('registry() no longer returns an app repository that has been re }, apps: [ { + appStoreId: 'sparkles', manifestVersion: '1.0.0', id: 'sparkles-hello-world', name: 'Hello World', diff --git a/packages/umbreld/source/modules/server/trpc/routes/apps.ts b/packages/umbreld/source/modules/server/trpc/routes/apps.ts index f8def8a618..07bfc1c062 100644 --- a/packages/umbreld/source/modules/server/trpc/routes/apps.ts +++ b/packages/umbreld/source/modules/server/trpc/routes/apps.ts @@ -11,19 +11,23 @@ export default router({ const appData = await Promise.all( apps.map(async (app) => { try { - let { - name, - version, - icon, - port, - path, - widgets, - defaultUsername, - defaultPassword, - deterministicPassword, - dependencies, - torOnly, - } = await app.readManifest() + let [ + { + name, + version, + icon, + port, + path, + widgets, + defaultUsername, + defaultPassword, + deterministicPassword, + dependencies, + implements: implements_, + torOnly, + }, + selectedDependencies, + ] = await Promise.all([app.readManifest(), app.getSelectedDependencies()]) const hiddenService = torEnabled ? await app.readHiddenService() : '' if (deterministicPassword) { @@ -47,6 +51,8 @@ export default router({ hiddenService, widgets, dependencies, + selectedDependencies, + implements: implements_, torOnly, } } catch (error) { @@ -66,9 +72,10 @@ export default router({ .input( z.object({ appId: z.string(), + alternatives: z.record(z.string()).optional(), }), ) - .mutation(async ({ctx, input}) => ctx.apps.install(input.appId)), + .mutation(async ({ctx, input}) => ctx.apps.install(input.appId, input.alternatives)), // Get state // Temporarily used for polling the state of app mutations until we implement subscriptions @@ -161,6 +168,17 @@ export default router({ setTorEnabled: privateProcedure.input(z.boolean()).mutation(({ctx, input}) => ctx.apps.setTorEnabled(input)), getTorEnabled: privateProcedure.query(({ctx}) => ctx.apps.getTorEnabled()), + setSelectedDependencies: privateProcedure + .input( + z.object({ + appId: z.string(), + dependencies: z.record(z.string()), + }), + ) + .mutation(async ({ctx, input}) => ctx.apps.setSelectedDependencies(input.appId, input.dependencies)), + + dependents: privateProcedure.input(z.string()).query(async ({ctx, input}) => ctx.apps.getDependents(input)), + hideCredentialsBeforeOpen: privateProcedure .input( z.object({