Env: configure urls and help documents

Change-Id: I9522e074575e0c1e67735462ac4cc266ab1ebb8c
diff --git a/core/installer/app.go b/core/installer/app.go
index 11ca8cd..1626a1a 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -339,6 +339,16 @@
 	public: string
 	private: string
 }
+
+#HelpDocument: {
+    title: string
+    contents: string
+    children: [...#HelpDocument]
+}
+
+help: [...#HelpDocument] | *[]
+
+url: string | *""
 `
 
 type rendered struct {
@@ -347,8 +357,8 @@
 	Resources CueAppData
 	Ports     []PortForward
 	Data      CueAppData
+	URL       string
 	Help      []HelpDocument
-	Url       string
 	Icon      string
 }
 
@@ -633,7 +643,7 @@
 	if err != nil {
 		return rendered{}, err
 	}
-	ret.Url = url
+	ret.URL = url
 	icon, err := res.LookupPath(cue.ParsePath("icon")).String()
 	if err != nil {
 		return rendered{}, err
@@ -680,8 +690,9 @@
 			Release: release,
 			Values:  values,
 			Input:   derived,
+			URL:     ret.URL,
 			Help:    ret.Help,
-			Url:     ret.Url,
+			Icon:    ret.Icon,
 		},
 	}, nil
 }
@@ -719,6 +730,8 @@
 			Release: release,
 			Values:  values,
 			Input:   values,
+			URL:     ret.URL,
+			Help:    ret.Help,
 		},
 	}, nil
 }
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index 8ca42f2..56129f5 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -54,6 +54,7 @@
 }
 
 func (m *AppManager) FindAllInstances() ([]AppInstanceConfig, error) {
+	m.repoIO.Pull()
 	kust, err := soft.ReadKustomization(m.repoIO, filepath.Join(m.appDirRoot, "kustomization.yaml"))
 	if err != nil {
 		return nil, err
diff --git a/core/installer/app_repository.go b/core/installer/app_repository.go
index 5727e64..4f15b7d 100644
--- a/core/installer/app_repository.go
+++ b/core/installer/app_repository.go
@@ -47,6 +47,7 @@
 	"values-tmpl/headscale.cue",
 	"values-tmpl/launcher.cue",
 	"values-tmpl/env-dns.cue",
+	"values-tmpl/launcher.cue",
 }
 
 var infraAppConfigs = []string{
diff --git a/core/installer/cmd/launcher.go b/core/installer/cmd/launcher.go
index 832f159..e1707bf 100644
--- a/core/installer/cmd/launcher.go
+++ b/core/installer/cmd/launcher.go
@@ -2,21 +2,22 @@
 
 import (
 	"fmt"
+	"log"
 	"os"
 
 	"github.com/giolekva/pcloud/core/installer"
 	"github.com/giolekva/pcloud/core/installer/soft"
 	"github.com/giolekva/pcloud/core/installer/welcome"
+
 	"github.com/spf13/cobra"
 	"golang.org/x/crypto/ssh"
 )
 
 var launcherFlags struct {
-	logoutUrl  string
-	port       int
-	repoAddr   string
-	sshKey     string
-	appManager *installer.AppManager
+	logoutUrl string
+	port      int
+	repoAddr  string
+	sshKey    string
 }
 
 func launcherCmd() *cobra.Command {
@@ -54,11 +55,11 @@
 func launcherCmdRun(cmd *cobra.Command, args []string) error {
 	sshKey, err := os.ReadFile(launcherFlags.sshKey)
 	if err != nil {
-		return fmt.Errorf("failed reading ssh key: %v", err)
+		return err
 	}
 	signer, err := ssh.ParsePrivateKey(sshKey)
 	if err != nil {
-		return fmt.Errorf("failed parsing ssh private key: %v", err)
+		return err
 	}
 	addr, err := soft.ParseRepositoryAddress(launcherFlags.repoAddr)
 	if err != nil {
@@ -66,15 +67,16 @@
 	}
 	repo, err := soft.CloneRepository(addr, signer)
 	if err != nil {
-		return fmt.Errorf("failed cloning repository: %v", err)
+		return err
 	}
+	log.Println("Cloned repository")
 	repoIO, err := soft.NewRepoIO(repo, signer)
 	if err != nil {
-		return fmt.Errorf("failed initializing RepoIO: %v", err)
+		return err
 	}
 	appManager, err := installer.NewAppManager(repoIO, nil, "/apps")
 	if err != nil {
-		return fmt.Errorf("failed to create AppManager: %v", err)
+		return err
 	}
 	s, err := welcome.NewLauncherServer(
 		launcherFlags.port,
diff --git a/core/installer/cmd/rewrite.go b/core/installer/cmd/rewrite.go
index 8bb9173..c4bc7a8 100644
--- a/core/installer/cmd/rewrite.go
+++ b/core/installer/cmd/rewrite.go
@@ -60,7 +60,7 @@
 		return err
 	}
 	log.Println("Creating repository")
-	r := installer.NewInMemoryAppRepository(installer.CreateStoreApps())
+	r := installer.NewInMemoryAppRepository(installer.CreateAllApps())
 	mgr, err := installer.NewAppManager(repoIO, nil, "/apps")
 	if err != nil {
 		return err
diff --git a/core/installer/derived.go b/core/installer/derived.go
index bc7d7f8..c3483cf 100644
--- a/core/installer/derived.go
+++ b/core/installer/derived.go
@@ -2,6 +2,7 @@
 
 import (
 	"fmt"
+	"html/template"
 )
 
 type Release struct {
@@ -26,6 +27,9 @@
 	Release Release        `json:"release"`
 	Values  map[string]any `json:"values"`
 	Input   map[string]any `json:"input"`
+	URL     string         `json:"url"`
+	Help    []HelpDocument `json:"help"`
+	Icon    template.HTML  `json:"icon"`
 }
 
 type AppInstanceConfig struct {
@@ -35,9 +39,9 @@
 	Release Release        `json:"release"`
 	Values  map[string]any `json:"values"`
 	Input   map[string]any `json:"input"`
-	Icon    string         `json:"icon"`
+	URL     string         `json:"url"`
 	Help    []HelpDocument `json:"help"`
-	Url     string         `json:"url"`
+	Icon    string         `json:"icon"`
 }
 
 func (a AppInstanceConfig) InputToValues(schema Schema) map[string]any {
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index 91d0fd9..e71de44 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -277,6 +277,42 @@
 	)
 }
 
+func SetupLauncher(env installer.EnvConfig, st *state) Task {
+	t := newLeafTask("Setup", func() error {
+		user := fmt.Sprintf("%s-launcher", env.Id)
+		keys, err := installer.NewSSHKeyPair(user)
+		if err != nil {
+			return err
+		}
+		if err := st.ssClient.AddUser(user, keys.AuthorizedKey()); err != nil {
+			return err
+		}
+		if err := st.ssClient.AddReadWriteCollaborator("config", user); err != nil {
+			return err
+		}
+		app, err := installer.FindEnvApp(st.appsRepo, "launcher")
+		if err != nil {
+			return err
+		}
+		instanceId := app.Slug()
+		appDir := fmt.Sprintf("/apps/%s", instanceId)
+		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
+		if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+			"repoAddr":      st.ssClient.GetRepoAddress("config"),
+			"sshPrivateKey": string(keys.RawPrivateKey()),
+		}); err != nil {
+			return err
+		}
+		return nil
+	})
+	return newSequentialParentTask(
+		"Launcher",
+		false,
+		&t,
+		waitForAddr(st.httpClient, fmt.Sprintf("https://launcher.%s", env.Domain)),
+	)
+}
+
 func SetupHeadscale(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Setup", func() error {
 		app, err := installer.FindEnvApp(st.appsRepo, "headscale")
@@ -370,37 +406,6 @@
 	return &t
 }
 
-func SetupLauncher(env installer.EnvConfig, st *state) Task {
-	t := newLeafTask("Application Launcher", func() error {
-		user := fmt.Sprintf("%s-launcher", env.Id)
-		keys, err := installer.NewSSHKeyPair(user)
-		if err != nil {
-			return err
-		}
-		if err := st.ssClient.AddUser(user, keys.AuthorizedKey()); err != nil {
-			return err
-		}
-		if err := st.ssClient.AddReadWriteCollaborator("config", user); err != nil { //TODO(gio): add read only
-			return err
-		}
-		app, err := installer.FindEnvApp(st.appsRepo, "launcher") // TODO(giolekva): configure
-		if err != nil {
-			return err
-		}
-		instanceId := app.Name()
-		appDir := fmt.Sprintf("/apps/%s", instanceId)
-		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
-			"repoAddr":      st.ssClient.GetRepoAddress("config"),
-			"sshPrivateKey": string(keys.RawPrivateKey()),
-		}); err != nil {
-			return err
-		}
-		return nil
-	})
-	return &t
-}
-
 // TODO(gio-dns): remove
 type DNSSecKey struct {
 	Basename string `json:"basename,omitempty"`
diff --git a/core/installer/values-tmpl/appmanager.cue b/core/installer/values-tmpl/appmanager.cue
index b710c55..6e98d5e 100644
--- a/core/installer/values-tmpl/appmanager.cue
+++ b/core/installer/values-tmpl/appmanager.cue
@@ -10,10 +10,14 @@
 
 name: "App Manager"
 namespace: "appmanager"
+icon: "<svg xmlns='http://www.w3.org/2000/svg' width='50' height='50' viewBox='0 0 24 24'><path fill='currentColor' d='M19 6h-2c0-2.8-2.2-5-5-5S7 3.2 7 6H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2m-7-3c1.7 0 3 1.3 3 3H9c0-1.7 1.3-3 3-3m7 17H5V8h14zm-7-8c-1.7 0-3-1.3-3-3H7c0 2.8 2.2 5 5 5s5-2.2 5-5h-2c0 1.7-1.3 3-3 3'/></svg>"
 
 _subdomain: "apps"
 _httpPortName: "http"
 
+_domain: "\(_subdomain).\(networks.private.domain)"
+url: "https://\(_domain)"
+
 ingress: {
 	appmanager: {
 		auth: {
@@ -57,7 +61,7 @@
 			sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
 			ingress: {
 				className: networks.private.ingressClass
-				domain: "\(_subdomain).\(networks.private.domain)"
+				domain: _domain
 				certificateIssuer: ""
 			}
 			clusterRoleName: "\(global.id)-appmanager"
diff --git a/core/installer/values-tmpl/gerrit.cue b/core/installer/values-tmpl/gerrit.cue
index edfcf10..3cffb3b 100644
--- a/core/installer/values-tmpl/gerrit.cue
+++ b/core/installer/values-tmpl/gerrit.cue
@@ -6,6 +6,7 @@
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
+url: "https://\(_domain)"
 
 name: "Gerrit"
 namespace: "app-gerrit"
diff --git a/core/installer/values-tmpl/headscale.cue b/core/installer/values-tmpl/headscale.cue
index 08b61ef..60be1a2 100644
--- a/core/installer/values-tmpl/headscale.cue
+++ b/core/installer/values-tmpl/headscale.cue
@@ -96,3 +96,42 @@
 		}
 	}
 }
+
+help: [{
+	title: "Install"
+	contents: """
+	You can install Tailscale client on any of your personal devices running: macOS, iOS, Windows, Lonux or Android. Installer packages can be found at: [https://tailscale.com/download](https://tailscale.com/download). After installing the client application you need to configure it to use https://\(_domain) as a login URL, so you can login to the VPN network with your dodo: account. See "Configure Login URL" section below for more details.
+	"""
+	children: [{
+		title: "Widnows with MSI"
+		contents: "[https://tailscale.com/kb/1189/install-windows-msi](https://tailscale.com/kb/1189/install-windows-msi)"
+	}]
+}, {
+	title: "Configure Login URL"
+	contents: "After installing the client application you need to configure it to use https://\(_domain) as a login URL, so you can login to the VPN network with your dodo: account"
+	children: [{
+		title: "macOS"
+		contents: "[https://headscale.v1.dodo.cloud/apple](https://headscale.v1.dodo.cloud/apple)"
+	}, {
+		title: "iOS"
+		contents: "[https://headscale.v1.dodo.cloud/apple](https://headscale.v1.dodo.cloud/apple)"
+	}, {
+		title: "Windows"
+		contents: "[https://tailscale.com/kb/1318/windows-mdm](https://tailscale.com/kb/1318/windows-mdm)"
+	}, {
+		title: "Linux"
+		contents: "tailscale up --login-server https://\(_domain)"
+	}, {
+		title: "Android"
+		contents: """
+		After opening the app, the kebab menu icon (three dots) on the top bar on the right must be repeatedly opened and closed until the Change server option appears in the menu. This is where you can enter your headscale URL: https://\(_domain)
+
+		A screen recording of this process can be seen in the tailscale-android PR which implemented this functionality: [https://github.com/tailscale/tailscale-android/pull/55](https://github.com/tailscale/tailscale-android/pull/55)
+
+		After saving and restarting the app, selecting the regular Sign in option should open up the dodo: authentication page.
+		"""
+	}, {
+		title: "Command Line"
+		contents: "tailscale up --login-server https://\(_domain)"
+	}]
+}]
diff --git a/core/installer/values-tmpl/jellyfin.cue b/core/installer/values-tmpl/jellyfin.cue
index 9c59d20..2d5d45c 100644
--- a/core/installer/values-tmpl/jellyfin.cue
+++ b/core/installer/values-tmpl/jellyfin.cue
@@ -4,6 +4,7 @@
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
+url: "https://\(_domain)"
 
 name: "Jellyfin"
 namespace: "app-jellyfin"
diff --git a/core/installer/values-tmpl/jenkins.cue b/core/installer/values-tmpl/jenkins.cue
index f705b34..673e544 100644
--- a/core/installer/values-tmpl/jenkins.cue
+++ b/core/installer/values-tmpl/jenkins.cue
@@ -4,6 +4,7 @@
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
+url: "https://\(_domain)"
 
 name: "Jenkins"
 namespace: "app-jenkins"
diff --git a/core/installer/values-tmpl/launcher.cue b/core/installer/values-tmpl/launcher.cue
index 9761464..9c04e85 100644
--- a/core/installer/values-tmpl/launcher.cue
+++ b/core/installer/values-tmpl/launcher.cue
@@ -9,12 +9,13 @@
 
 _subdomain: "launcher"
 _domain: "\(_subdomain).\(networks.public.domain)"
+url: "https://\(_domain)"
 
 name: "Launcher"
-namespace: "core-installer-welcome-launcher"
+namespace: "launcher"
 readme: "App Launcher application will be installed on Private or Public network and be accessible at https://\(_domain)"
 description: "The application is a App launcher, designed to run all accessible applications. Can be configured to be reachable only from private network or publicly."
-icon: "<svg xmlns='http://www.w3.org/2000/svg' width='50' height='50' viewBox='0 0 24 24'><path fill='currentColor' d='M15.43 15.48c-1.1-.49-2.26-.73-3.43-.73c-1.18 0-2.33.25-3.43.73c-.23.1-.4.29-.49.52h7.85a.978.978 0 0 0-.5-.52m-2.49-6.69C12.86 8.33 12.47 8 12 8s-.86.33-.94.79l-.2 1.21h2.28z' opacity='0.3'/><path fill='currentColor' d='M10.27 12h3.46a1.5 1.5 0 0 0 1.48-1.75l-.3-1.79a2.951 2.951 0 0 0-5.82.01l-.3 1.79c-.15.91.55 1.74 1.48 1.74m.79-3.21c.08-.46.47-.79.94-.79s.86.33.94.79l.2 1.21h-2.28zm-9.4 2.32c-.13.26-.18.57-.1.88c.16.69.76 1.03 1.53 1h1.95c.83 0 1.51-.58 1.51-1.29c0-.14-.03-.27-.07-.4c-.01-.03-.01-.05.01-.08c.09-.16.14-.34.14-.53c0-.31-.14-.6-.36-.82c-.03-.03-.03-.06-.02-.1c.07-.2.07-.43.01-.65a1.12 1.12 0 0 0-.99-.74a.09.09 0 0 1-.07-.03C5.03 8.14 4.72 8 4.37 8c-.3 0-.57.1-.75.26c-.03.03-.06.03-.09.02a1.24 1.24 0 0 0-1.7 1.03c0 .02-.01.04-.03.06c-.29.26-.46.65-.41 1.05c.03.22.12.43.25.6c.03.02.03.06.02.09m14.58 2.54c-1.17-.52-2.61-.9-4.24-.9c-1.63 0-3.07.39-4.24.9A2.988 2.988 0 0 0 6 16.39V18h12v-1.61c0-1.18-.68-2.26-1.76-2.74M8.07 16a.96.96 0 0 1 .49-.52c1.1-.49 2.26-.73 3.43-.73c1.18 0 2.33.25 3.43.73c.23.1.4.29.49.52zm-6.85-1.42A2.01 2.01 0 0 0 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29c-.37-.06-.74-.1-1.13-.1c-.99 0-1.93.21-2.78.58m21.56 0A6.95 6.95 0 0 0 20 14c-.39 0-.76.04-1.13.1c.4.68.63 1.46.63 2.29V18H24v-1.57c0-.81-.48-1.53-1.22-1.85M22 11v-.5c0-1.1-.9-2-2-2h-2c-.42 0-.65.48-.39.81l.7.63c-.19.31-.31.67-.31 1.06c0 1.1.9 2 2 2s2-.9 2-2'/></svg>"
+icon: "<svg xmlns='http://www.w3.org/2000/svg' width='50' height='50' viewBox='0 0 48 48'><path fill='none' stroke='green' stroke-linecap='round' stroke-linejoin='round' d='M42.5 23.075L26.062 7.525a3 3 0 0 0-4.124 0L5.5 23.075m5.86 1.54v14.68a2 2 0 0 0 2 2h7.14v-9.5h7v9.5h7.14a2 2 0 0 0 2-2v-14.68'/></svg>"
 
 _httpPortName: "http"
 
@@ -33,7 +34,7 @@
 images: {
     launcher: {
         repository: "giolekva"
-        name: "launcher"
+        name: "pcloud-installer"
         tag: "latest"
         pullPolicy: "Always"
     }
@@ -63,6 +64,8 @@
             repoAddr: input.repoAddr
             sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
             logoutUrl: "https://accounts-ui.\(global.domain)/logout"
+			repoAddr: input.repoAddr
+			sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
         }
     }
 }
diff --git a/core/installer/values-tmpl/matrix.cue b/core/installer/values-tmpl/matrix.cue
index a236a16..4b4ca06 100644
--- a/core/installer/values-tmpl/matrix.cue
+++ b/core/installer/values-tmpl/matrix.cue
@@ -144,3 +144,11 @@
 		}
 	}
 }
+
+help: [{
+	title: "Client Applications"
+	contents: "You can connect to \(_domain) Matrix server with any of the official clients. We recommend using Element. You can use official Element Web application to chat within the browser. Platform native client applications can be downloaded from: [https://element.io/download](https://element.io/download). Follow **Custom Homeserver** section to login with your dodo: account."
+}, {
+	title: "Custom Homeserver"
+	contents: "Click **Sign in** button, edit **Homeserver** address and enter **\(input.network.domain)**, click **Continue**. Choose **Continue with PCloud** option and login to your dodo: account."
+}]
diff --git a/core/installer/values-tmpl/memberships.cue b/core/installer/values-tmpl/memberships.cue
index 53082f0..c0ffaeb 100644
--- a/core/installer/values-tmpl/memberships.cue
+++ b/core/installer/values-tmpl/memberships.cue
@@ -4,6 +4,7 @@
 
 _subdomain: "memberships"
 _domain: "\(_subdomain).\(global.privateDomain)"
+url: "https://\(_domain)"
 
 name: "Memberships"
 namespace: "core-auth-memberships"
diff --git a/core/installer/values-tmpl/open-project.cue b/core/installer/values-tmpl/open-project.cue
index 5428a0d..76f604b 100644
--- a/core/installer/values-tmpl/open-project.cue
+++ b/core/installer/values-tmpl/open-project.cue
@@ -4,6 +4,7 @@
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
+url: "https://\(_domain)"
 
 name: "OpenProject"
 namespace: "app-open-project"
diff --git a/core/installer/values-tmpl/penpot.cue b/core/installer/values-tmpl/penpot.cue
index d35ccc9..4a8e66a 100644
--- a/core/installer/values-tmpl/penpot.cue
+++ b/core/installer/values-tmpl/penpot.cue
@@ -4,6 +4,7 @@
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
+url: "https://\(_domain)"
 
 name: "Penpot"
 namespace: "app-penpot"
diff --git a/core/installer/values-tmpl/pihole.cue b/core/installer/values-tmpl/pihole.cue
index 37a44d5..d1b9097 100644
--- a/core/installer/values-tmpl/pihole.cue
+++ b/core/installer/values-tmpl/pihole.cue
@@ -5,6 +5,7 @@
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
+url: "https://\(_domain)"
 
 name: "Pi-hole"
 namespace: "app-pihole"
diff --git a/core/installer/values-tmpl/qbittorrent.cue b/core/installer/values-tmpl/qbittorrent.cue
index 2309d10..179a718 100644
--- a/core/installer/values-tmpl/qbittorrent.cue
+++ b/core/installer/values-tmpl/qbittorrent.cue
@@ -4,6 +4,7 @@
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
+url: "https://\(_domain)"
 
 name: "qBitorrent"
 namespace: "app-qbittorrent"
diff --git a/core/installer/values-tmpl/rpuppy.cue b/core/installer/values-tmpl/rpuppy.cue
index 25e176d..05f40c7 100644
--- a/core/installer/values-tmpl/rpuppy.cue
+++ b/core/installer/values-tmpl/rpuppy.cue
@@ -11,7 +11,6 @@
 readme: "rpuppy application will be installed on \(input.network.name) network and be accessible to any user on https://\(_domain)"
 description: "Delights users with randomly generate puppy pictures. Can be configured to be reachable only from private network or publicly."
 icon: "<svg xmlns='http://www.w3.org/2000/svg' width='50' height='50' viewBox='0 0 256 256'><path fill='currentColor' d='M100 140a8 8 0 1 1-8-8a8 8 0 0 1 8 8Zm64 8a8 8 0 1 0-8-8a8 8 0 0 0 8 8Zm64.94-9.11a12.12 12.12 0 0 1-5 1.11a11.83 11.83 0 0 1-9.35-4.62l-2.59-3.29V184a36 36 0 0 1-36 36H80a36 36 0 0 1-36-36v-51.91l-2.53 3.27A11.88 11.88 0 0 1 32.1 140a12.08 12.08 0 0 1-5-1.11a11.82 11.82 0 0 1-6.84-13.14l16.42-88a12 12 0 0 1 14.7-9.43h.16L104.58 44h46.84l53.08-15.6h.16a12 12 0 0 1 14.7 9.43l16.42 88a11.81 11.81 0 0 1-6.84 13.06ZM97.25 50.18L49.34 36.1a4.18 4.18 0 0 0-.92-.1a4 4 0 0 0-3.92 3.26l-16.42 88a4 4 0 0 0 7.08 3.22ZM204 121.75L150 52h-44l-54 69.75V184a28 28 0 0 0 28 28h44v-18.34l-14.83-14.83a4 4 0 0 1 5.66-5.66L128 186.34l13.17-13.17a4 4 0 0 1 5.66 5.66L132 193.66V212h44a28 28 0 0 0 28-28Zm23.92 5.48l-16.42-88a4 4 0 0 0-4.84-3.16l-47.91 14.11l62.11 80.28a4 4 0 0 0 7.06-3.23Z'/></svg>"
-url: _domain
 
 _httpPortName: "http"
 
@@ -60,3 +59,5 @@
 		}
 	}
 }
+
+url: "https://\(_domain)"
diff --git a/core/installer/values-tmpl/soft-serve.cue b/core/installer/values-tmpl/soft-serve.cue
index 3d34615..c31c855 100644
--- a/core/installer/values-tmpl/soft-serve.cue
+++ b/core/installer/values-tmpl/soft-serve.cue
@@ -4,6 +4,7 @@
 }
 
 _domain: "\(input.subdomain).\(global.privateDomain)"
+url: "https://\(_domain)"
 
 name: "Soft-Serve"
 namespace: "app-soft-serve"
diff --git a/core/installer/values-tmpl/url-shortener.cue b/core/installer/values-tmpl/url-shortener.cue
index 8e9a179..84a0edf 100644
--- a/core/installer/values-tmpl/url-shortener.cue
+++ b/core/installer/values-tmpl/url-shortener.cue
@@ -5,6 +5,7 @@
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
+url: "https://\(_domain)"
 
 name: "URL Shortener"
 namespace: "app-url-shortener"
diff --git a/core/installer/values-tmpl/vaultwarden.cue b/core/installer/values-tmpl/vaultwarden.cue
index 72c2ec4..072b5f2 100644
--- a/core/installer/values-tmpl/vaultwarden.cue
+++ b/core/installer/values-tmpl/vaultwarden.cue
@@ -4,6 +4,7 @@
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
+url: "https://\(_domain)"
 
 name: "Vaultwarden"
 namespace: "app-vaultwarden"
diff --git a/core/installer/values-tmpl/zot.cue b/core/installer/values-tmpl/zot.cue
index a8a7766..ac0674e 100644
--- a/core/installer/values-tmpl/zot.cue
+++ b/core/installer/values-tmpl/zot.cue
@@ -8,6 +8,7 @@
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
+url: "https://\(_domain)"
 
 name: "Zot"
 namespace: "app-zot"
diff --git a/core/installer/welcome/env_test.go b/core/installer/welcome/env_test.go
index ed7a2d3..0d61af8 100644
--- a/core/installer/welcome/env_test.go
+++ b/core/installer/welcome/env_test.go
@@ -264,6 +264,7 @@
 		"https://accounts-ui.test.t",
 		"https://welcome.test.t",
 		"https://memberships.p.test.t",
+		"https://launcher.test.t",
 		"https://headscale.test.t/apple",
 	}
 	for _, e := range expected {
@@ -271,7 +272,7 @@
 			t.Fatal(httpClient.counts)
 		}
 	}
-	if len(httpClient.counts) != 4 {
+	if len(httpClient.counts) != 5 {
 		t.Fatal(httpClient.counts)
 	}
 }
diff --git a/core/installer/welcome/launcher.go b/core/installer/welcome/launcher.go
index df4f863..7816dfc 100644
--- a/core/installer/welcome/launcher.go
+++ b/core/installer/welcome/launcher.go
@@ -32,6 +32,26 @@
 	AppManager *installer.AppManager
 }
 
+func (d *AppManagerDirectory) GetAllApps() ([]AppLauncherInfo, error) {
+	all, err := d.AppManager.FindAllInstances()
+	if err != nil {
+		return nil, err
+	}
+	ret := []AppLauncherInfo{}
+	for _, a := range all {
+		if a.URL == "" && len(a.Help) == 0 {
+			continue
+		}
+		ret = append(ret, AppLauncherInfo{
+			Name: a.AppId,
+			Icon: template.HTML(a.Icon),
+			Help: a.Help,
+			Url:  a.URL,
+		})
+	}
+	return ret, nil
+}
+
 type LauncherServer struct {
 	port         int
 	logoutUrl    string
@@ -77,24 +97,6 @@
 	return cleanName
 }
 
-func (d *AppManagerDirectory) GetAllApps() ([]AppLauncherInfo, error) {
-	allAppInstances, err := d.AppManager.FindAllInstances()
-	if err != nil {
-		return nil, err
-	}
-	var ret []AppLauncherInfo
-	for _, appInstance := range allAppInstances {
-		appLauncherInfo := AppLauncherInfo{
-			Name: appInstance.AppId,
-			Icon: template.HTML(appInstance.Icon),
-			Help: appInstance.Help,
-			Url:  appInstance.Url,
-		}
-		ret = append(ret, appLauncherInfo)
-	}
-	return ret, nil
-}
-
 func getLoggedInUser(r *http.Request) (string, error) {
 	if user := r.Header.Get("X-User"); user != "" {
 		return user, nil