Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e61c757633 | ||
|
|
cc1f8fba72 | ||
|
|
556f2e7cac | ||
|
|
0898c54765 | ||
|
|
f4368785f0 | ||
|
|
608f4c17f0 | ||
|
|
92c58141af | ||
|
|
7b2f4cb05f | ||
|
|
e65e76cd9d | ||
|
|
6d03ed5a69 | ||
|
|
522c046525 | ||
|
|
56f956973e | ||
|
|
bd69ef41d5 | ||
|
|
eeb08fc78f | ||
|
|
1836b035b4 |
@@ -132,6 +132,16 @@ fun WebViewScreen(
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
fun openExternalUrl(url: String) {
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
@@ -220,13 +230,7 @@ fun WebViewScreen(
|
||||
// Keep navigation within the Archipelago server
|
||||
if (url.startsWith(serverUrl)) return false
|
||||
// Open external URLs in the system browser
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
openExternalUrl(url)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -243,18 +247,30 @@ fun WebViewScreen(
|
||||
isUserGesture: Boolean,
|
||||
resultMsg: android.os.Message?,
|
||||
): Boolean {
|
||||
// Extract the URL from the hit test
|
||||
val data = view?.hitTestResult?.extra
|
||||
if (data != null) {
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(data),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
val transport = resultMsg?.obj as? WebView.WebViewTransport
|
||||
?: return false
|
||||
|
||||
val popup = WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: return true
|
||||
openExternalUrl(url)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
if (url != null) openExternalUrl(url)
|
||||
view?.stopLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
transport.webView = popup
|
||||
resultMsg.sendToTarget()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.78-alpha (2026-05-20)
|
||||
|
||||
- Public Nginx Proxy Manager hosts for Saleor now keep browser GraphQL calls same-origin at `/graphql/` and proxy them to the local API on `8000`, fixing `Failed to fetch` when a public domain such as `noderunner.shop` was loaded from devices that cannot reach the node's private LAN/tailnet API address.
|
||||
- Saleor's validated stack changes are now release-ready: dashboard origins on port `9010` are explicitly allowed for dashboard/API calls, preserving the working test-node install path for production nodes.
|
||||
- NetBird launches now stay pinned to the unified dashboard/proxy origin on port `8087` instead of following stale runtime-discovered server URLs on `8086`.
|
||||
- NetBird's local nginx proxy now routes browser API, OAuth, relay, and WebSocket traffic through `host.containers.internal:8086` instead of a hard-coded rootless Podman gateway IP, and includes the upstream `management.ProxyService` gRPC path.
|
||||
- The mobile credentials interstitial now keeps credential lists scrollable and action buttons reachable in both My Apps and the mobile app icon grid.
|
||||
- Android WebView popup windows now hand external popup URLs to the system browser, covering app login/signup flows that open secondary windows.
|
||||
- Validation passed with `git diff --check`, `cargo check -p archipelago`, and the focused `npm test -- src/views/appSession/__tests__/appSessionConfig.test.ts` suite.
|
||||
|
||||
## v1.7.77-alpha (2026-05-20)
|
||||
|
||||
- Saleor first-use now exposes generated credentials through Archipelago instead of leaving users at an unexplained dashboard login: App Details shows copyable `admin@example.com` credentials, and My Apps/mobile icon launches show a pre-launch credentials modal.
|
||||
- Saleor installs now create or repair the `admin@example.com` staff account idempotently after sample data loads, use the correct dashboard mount path, and re-check stack containers after startup so stopped containers are caught.
|
||||
- NetBird embedded login now uses the upstream-compatible IdP signing-key behavior and sends ID tokens from the dashboard to the management API, fixing the post-signup `Unauthenticated` state while preserving the unified local proxy/logout routes.
|
||||
- Transient unnamed Podman helper containers created during app install tasks are hidden from My Apps, so generated names like `eager_keldysh` no longer appear as user applications.
|
||||
- Validation passed with catalog/release JSON checks, `npm run type-check`, and `cargo fmt --all --check --manifest-path core/Cargo.toml`; live checks on `100.114.134.21` confirmed Saleor dashboard/API availability, generated Saleor admin login, NetBird OAuth availability, and NetBird logout redirects.
|
||||
|
||||
## v1.7.76-alpha (2026-05-20)
|
||||
|
||||
- Saleor installs now use dashboard port `9010`, avoiding the existing Portainer `9000` binding on the test node while keeping API `8000`, Mailpit `8025`, and Jaeger `16686` unchanged.
|
||||
- Saleor's Valkey cache no longer bind-mounts `/var/lib/archipelago/saleor-cache`, and the dashboard container has the minimal rootless nginx capabilities it needs to chown cache files, bind port 80 inside the container, and drop workers to the nginx user.
|
||||
- NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts.
|
||||
- Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown.
|
||||
- Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager.
|
||||
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart.
|
||||
|
||||
## v1.7.75-alpha (2026-05-19)
|
||||
|
||||
- Saleor is now published as a recommended commerce app with catalog metadata, icon, direct app-session launch on port `9000`, scanner metadata, image pins, and a full stack installer for dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger.
|
||||
- Existing NetBird installs are repaired more aggressively by rewriting unified-origin config, recreating the dashboard/proxy containers, restarting the server, preserving data, and handling exact `/api` and `/oauth2` routes plus dashboard logout redirects through the local proxy.
|
||||
- Desktop dashboard scrolling now hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane, preventing the sidebar scroll area from trapping wheel input on short screens.
|
||||
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml` before release.
|
||||
|
||||
## v1.7.74-alpha (2026-05-19)
|
||||
|
||||
- App-session right panels now re-focus the iframe after load and when the frame area is activated, so wheel/touch scrolling works immediately after switching tabs or selecting an app on shorter screens.
|
||||
- NetBird now launches through a unified local origin on port `8087` that proxies the dashboard plus `/oauth2`, `/api`, relay, WebSocket, and gRPC routes to `netbird-server`, fixing the embedded login flow that previously ended in `Unauthenticated` or `404 page not found` after logout.
|
||||
- Existing NetBird installs are repaired on adopt/start by rewriting `config.yaml`, `dashboard.env`, and the local nginx proxy config, then creating the missing `netbird-dashboard` and `netbird` proxy containers when needed while preserving NetBird data.
|
||||
- Saleor is still pending and is not included in this release; its registry/installer work remains local until it can be validated separately.
|
||||
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.73-alpha (2026-05-19)
|
||||
|
||||
- Mobile app launches for iframe-blocked apps now open the direct app URL in a new browser tab immediately instead of landing in a broken in-shell webview that requires a second tap.
|
||||
- Mobile My Apps/Websites tabs now react to route query changes, App Store pages label the mobile view as Discover, mobile filters have safe bottom spacing, and App Store search ignores the current category so searches cover all available apps.
|
||||
- My Apps search now surfaces matching App Store entries when the app is not installed, making it possible to jump directly from a failed My Apps search to the installable app details.
|
||||
- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on `100.89.209.89` updated the existing stack from LAN origins to `100.89.209.89` and restored `netbird-server`.
|
||||
- App-session iframe frames now focus automatically and wrap the iframe in a scroll host so wheel/touch scrolling works in the active right frame without requiring an initial click.
|
||||
|
||||
## v1.7.72-alpha (2026-05-19)
|
||||
|
||||
- Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`.
|
||||
|
||||
@@ -64,6 +64,23 @@
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "saleor",
|
||||
"title": "Saleor",
|
||||
"version": "3.23",
|
||||
"description": "Composable commerce platform with GraphQL API, dashboard, worker, mail testing, and tracing.",
|
||||
"icon": "/assets/img/app-icons/saleor.svg",
|
||||
"author": "Saleor",
|
||||
"category": "commerce",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "ghcr.io/saleor/saleor:3.23",
|
||||
"repoUrl": "https://github.com/saleor/saleor",
|
||||
"containerConfig": {
|
||||
"ports": ["9010:80", "8000:8000", "8025:8025", "16686:16686"],
|
||||
"volumes": ["/var/lib/archipelago/saleor:/app/media", "/var/lib/archipelago/saleor-db:/var/lib/postgresql/data"],
|
||||
"notes": "Installed as a Saleor stack: dashboard on 9010, API on 8000, Mailpit on 8025, and Jaeger on 16686. Supporting containers include PostgreSQL, Valkey, Celery worker, and services required by Saleor."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
|
||||
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.72-alpha"
|
||||
version = "1.7.78-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.72-alpha"
|
||||
version = "1.7.78-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -55,6 +55,7 @@ impl RpcHandler {
|
||||
"package.restart" => self.handle_package_restart(params).await,
|
||||
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
|
||||
"package.update" => self.clone().spawn_package_update(params).await,
|
||||
"package.credentials" => self.handle_package_credentials(params).await,
|
||||
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
||||
|
||||
// Bundled app management (for pre-loaded container images)
|
||||
|
||||
@@ -31,6 +31,7 @@ fn is_platform_managed_app(app_id: &str) -> bool {
|
||||
| "fedimint"
|
||||
| "fedimint-gateway"
|
||||
| "indeedhub"
|
||||
| "saleor"
|
||||
| "immich"
|
||||
)
|
||||
}
|
||||
@@ -495,7 +496,20 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
"indeedhub-ffmpeg".into(),
|
||||
"indeedhub".into(),
|
||||
],
|
||||
"netbird" => vec!["netbird".into(), "netbird-server".into()],
|
||||
"netbird" => vec![
|
||||
"netbird".into(),
|
||||
"netbird-dashboard".into(),
|
||||
"netbird-server".into(),
|
||||
],
|
||||
"saleor" => vec![
|
||||
"saleor-db".into(),
|
||||
"saleor-cache".into(),
|
||||
"saleor-api".into(),
|
||||
"saleor-worker".into(),
|
||||
"saleor-jaeger".into(),
|
||||
"saleor-mailpit".into(),
|
||||
"saleor".into(),
|
||||
],
|
||||
"nostr-vpn" => vec![
|
||||
"nostr-vpn".into(),
|
||||
"archy-nostr-vpn".into(),
|
||||
@@ -585,6 +599,7 @@ pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||
format!("{}/penpot-postgres", base),
|
||||
],
|
||||
"netbird" => vec![format!("{}/netbird", base)],
|
||||
"saleor" => vec![format!("{}/saleor", base), format!("{}/saleor-db", base)],
|
||||
_ => vec![format!("{}/{}", base, package_id)],
|
||||
}
|
||||
}
|
||||
@@ -1064,6 +1079,13 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"saleor" => (
|
||||
vec!["9010:80".to_string(), "8000:8000".to_string()],
|
||||
vec!["/var/lib/archipelago/saleor:/app/media".to_string()],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"nostr-rs-relay" => (
|
||||
vec!["18081:8080".to_string()],
|
||||
vec!["/var/lib/archipelago/nostr-rs-relay:/usr/src/app/db".to_string()],
|
||||
|
||||
@@ -288,7 +288,16 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird"],
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
"saleor" => &[
|
||||
"saleor-db",
|
||||
"saleor-cache",
|
||||
"saleor-jaeger",
|
||||
"saleor-mailpit",
|
||||
"saleor-api",
|
||||
"saleor-worker",
|
||||
"saleor",
|
||||
],
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
@@ -392,7 +401,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn netbird_start_order_starts_server_before_dashboard() {
|
||||
assert_eq!(startup_order("netbird"), &["netbird-server", "netbird"]);
|
||||
assert_eq!(
|
||||
startup_order("netbird"),
|
||||
&["netbird-server", "netbird-dashboard", "netbird"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -244,6 +244,9 @@ impl RpcHandler {
|
||||
if package_id == "netbird" {
|
||||
return self.install_netbird_stack().await;
|
||||
}
|
||||
if package_id == "saleor" {
|
||||
return self.install_saleor_stack().await;
|
||||
}
|
||||
|
||||
// Dependency checks. Prefer the scanner's cached package state so a
|
||||
// congested Podman API does not turn an already-running dependency into
|
||||
@@ -1856,6 +1859,42 @@ autopilot.active=false\n",
|
||||
|
||||
Ok(serde_json::json!({ "token": token }))
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_package_credentials(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let app_id = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("app_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
super::validation::validate_app_id(app_id)?;
|
||||
|
||||
match app_id {
|
||||
"saleor" => {
|
||||
let password =
|
||||
tokio::fs::read_to_string("/var/lib/archipelago/secrets/saleor-admin-password")
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
if password.is_empty() {
|
||||
return Ok(serde_json::json!({ "credentials": [] }));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"title": "Saleor admin login",
|
||||
"description": "Saleor opens to its own dashboard login. Use this generated admin account to sign in.",
|
||||
"credentials": [
|
||||
{ "label": "Email", "value": "admin@example.com", "sensitive": false },
|
||||
{ "label": "Password", "value": password, "sensitive": true }
|
||||
]
|
||||
}))
|
||||
}
|
||||
_ => Ok(serde_json::json!({ "credentials": [] })),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_stale_package_ports(package_id: &str) {
|
||||
|
||||
@@ -98,6 +98,8 @@ async fn repair_stack_before_adopt(stack_name: &str) {
|
||||
}
|
||||
}
|
||||
"indeedhub" => repair_indeedhub_network_aliases().await,
|
||||
"netbird" => repair_netbird_unified_origin().await,
|
||||
"saleor" => repair_saleor_network_aliases().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +146,134 @@ pub(in crate::api::rpc::package) async fn repair_indeedhub_network_aliases() {
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_netbird_unified_origin() {
|
||||
let host_ip = detect_netbird_public_host_ip()
|
||||
.await
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
let _ = write_netbird_config_files(&host_ip).await;
|
||||
|
||||
for container in ["netbird", "netbird-dashboard"] {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", container])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "netbird-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = pull_image_with_retry(NETBIRD_DASHBOARD_IMAGE).await;
|
||||
let _ = pull_image_with_retry(NETBIRD_PROXY_IMAGE).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"disconnect",
|
||||
"-f",
|
||||
"netbird-net",
|
||||
"netbird-server",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"connect",
|
||||
"--alias",
|
||||
"netbird-server",
|
||||
"netbird-net",
|
||||
"netbird-server",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", "netbird-server"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"netbird-dashboard",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--restart=unless-stopped",
|
||||
"--env-file",
|
||||
"/var/lib/archipelago/netbird/dashboard.env",
|
||||
NETBIRD_DASHBOARD_IMAGE,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"netbird",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--restart=unless-stopped",
|
||||
"-p",
|
||||
"8087:80",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro",
|
||||
NETBIRD_PROXY_IMAGE,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn repair_saleor_network_aliases() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "saleor-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
for (container, alias) in [
|
||||
("saleor-db", "db"),
|
||||
("saleor-cache", "cache"),
|
||||
("saleor-jaeger", "jaeger"),
|
||||
("saleor-mailpit", "mailpit"),
|
||||
("saleor-api", "api"),
|
||||
("saleor-worker", "worker"),
|
||||
("saleor", "saleor"),
|
||||
] {
|
||||
let exists = tokio::process::Command::new("podman")
|
||||
.args(["container", "exists", container])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !exists {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "disconnect", "-f", "saleor-net", container])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"connect",
|
||||
"--alias",
|
||||
alias,
|
||||
"saleor-net",
|
||||
container,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async fn run_required_stack_command(
|
||||
stack_name: &str,
|
||||
label: &str,
|
||||
@@ -313,6 +443,13 @@ const REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
||||
|
||||
const NETBIRD_DASHBOARD_IMAGE: &str = "docker.io/netbirdio/dashboard:v2.38.0";
|
||||
const NETBIRD_SERVER_IMAGE: &str = "docker.io/netbirdio/netbird-server:0.71.2";
|
||||
const NETBIRD_PROXY_IMAGE: &str = "docker.io/library/nginx:1.27-alpine";
|
||||
const SALEOR_API_IMAGE: &str = "ghcr.io/saleor/saleor:3.23";
|
||||
const SALEOR_DASHBOARD_IMAGE: &str = "ghcr.io/saleor/saleor-dashboard:3.23";
|
||||
const SALEOR_POSTGRES_IMAGE: &str = "docker.io/library/postgres:15-alpine";
|
||||
const SALEOR_VALKEY_IMAGE: &str = "docker.io/valkey/valkey:8.1-alpine";
|
||||
const SALEOR_JAEGER_IMAGE: &str = "docker.io/jaegertracing/jaeger:latest";
|
||||
const SALEOR_MAILPIT_IMAGE: &str = "docker.io/axllent/mailpit:latest";
|
||||
|
||||
/// Pull an image with retry and exponential backoff (3 attempts).
|
||||
async fn pull_image_with_retry(image: &str) -> Result<()> {
|
||||
@@ -1364,8 +1501,12 @@ impl RpcHandler {
|
||||
|
||||
/// Install self-hosted NetBird (dashboard + combined management/signal/relay server).
|
||||
pub(super) async fn install_netbird_stack(&self) -> Result<serde_json::Value> {
|
||||
if let Some(adopted) =
|
||||
adopt_stack_if_exists("netbird", "netbird", &["netbird", "netbird-server"]).await?
|
||||
if let Some(adopted) = adopt_stack_if_exists(
|
||||
"netbird",
|
||||
"netbird",
|
||||
&["netbird-server", "netbird-dashboard", "netbird"],
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(adopted);
|
||||
}
|
||||
@@ -1375,18 +1516,22 @@ impl RpcHandler {
|
||||
|
||||
self.set_install_phase("netbird", InstallPhase::PullingImage)
|
||||
.await;
|
||||
for (i, image) in [NETBIRD_DASHBOARD_IMAGE, NETBIRD_SERVER_IMAGE]
|
||||
.iter()
|
||||
.enumerate()
|
||||
for (i, image) in [
|
||||
NETBIRD_DASHBOARD_IMAGE,
|
||||
NETBIRD_SERVER_IMAGE,
|
||||
NETBIRD_PROXY_IMAGE,
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
self.set_install_progress("netbird", i as u64, 2).await;
|
||||
self.set_install_progress("netbird", i as u64, 3).await;
|
||||
pull_image_with_retry(image)
|
||||
.await
|
||||
.with_context(|| format!("Failed to pull NetBird image: {}", image))?;
|
||||
}
|
||||
self.set_install_progress("netbird", 2, 2).await;
|
||||
self.set_install_progress("netbird", 3, 3).await;
|
||||
|
||||
for name in ["netbird", "netbird-server"] {
|
||||
for name in ["netbird", "netbird-dashboard", "netbird-server"] {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", name])
|
||||
.status()
|
||||
@@ -1404,59 +1549,10 @@ impl RpcHandler {
|
||||
.await
|
||||
.context("Failed to create NetBird data directory")?;
|
||||
|
||||
let host_ip = self.config.host_ip.clone();
|
||||
let dashboard_origin = format!("http://{}:8087", host_ip);
|
||||
let mgmt_origin = format!("http://{}:8086", host_ip);
|
||||
let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await;
|
||||
let encryption_key = read_or_generate_b64_secret("netbird-store-encryption-key").await;
|
||||
let config = format!(
|
||||
r#"server:
|
||||
listenAddress: ":80"
|
||||
exposedAddress: "{mgmt_origin}"
|
||||
stunPorts:
|
||||
- 3478
|
||||
metricsPort: 9090
|
||||
healthcheckAddress: ":9000"
|
||||
logLevel: "info"
|
||||
logFile: "console"
|
||||
authSecret: "{relay_secret}"
|
||||
dataDir: "/var/lib/netbird"
|
||||
auth:
|
||||
issuer: "{mgmt_origin}/oauth2"
|
||||
localAuthDisabled: false
|
||||
signKeyRefreshEnabled: true
|
||||
dashboardRedirectURIs:
|
||||
- "{dashboard_origin}/nb-auth"
|
||||
- "{dashboard_origin}/nb-silent-auth"
|
||||
cliRedirectURIs:
|
||||
- "http://localhost:53000/"
|
||||
store:
|
||||
engine: "sqlite"
|
||||
encryptionKey: "{encryption_key}"
|
||||
"#
|
||||
);
|
||||
tokio::fs::write("/var/lib/archipelago/netbird/config.yaml", config)
|
||||
let host_ip = detect_netbird_public_host_ip()
|
||||
.await
|
||||
.context("Failed to write NetBird config.yaml")?;
|
||||
|
||||
let dashboard_env = format!(
|
||||
r#"NETBIRD_MGMT_API_ENDPOINT={mgmt_origin}
|
||||
NETBIRD_MGMT_GRPC_API_ENDPOINT={mgmt_origin}
|
||||
AUTH_AUDIENCE=netbird-dashboard
|
||||
AUTH_CLIENT_ID=netbird-dashboard
|
||||
AUTH_CLIENT_SECRET=
|
||||
AUTH_AUTHORITY={mgmt_origin}/oauth2
|
||||
USE_AUTH0=false
|
||||
AUTH_SUPPORTED_SCOPES=openid profile email groups
|
||||
AUTH_REDIRECT_URI=/nb-auth
|
||||
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
|
||||
NGINX_SSL_PORT=443
|
||||
LETSENCRYPT_DOMAIN=none
|
||||
"#
|
||||
);
|
||||
tokio::fs::write("/var/lib/archipelago/netbird/dashboard.env", dashboard_env)
|
||||
.await
|
||||
.context("Failed to write NetBird dashboard.env")?;
|
||||
.unwrap_or_else(|| self.config.host_ip.clone());
|
||||
write_netbird_config_files(&host_ip).await?;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "netbird-net"])
|
||||
@@ -1497,19 +1593,39 @@ LETSENCRYPT_DOMAIN=none
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"netbird",
|
||||
"netbird-dashboard",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--restart=unless-stopped",
|
||||
"-p",
|
||||
"8087:80",
|
||||
"--env-file",
|
||||
"/var/lib/archipelago/netbird/dashboard.env",
|
||||
NETBIRD_DASHBOARD_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("netbird", "create dashboard", &mut dashboard_cmd).await?;
|
||||
|
||||
wait_for_stack_containers("netbird", &["netbird-server", "netbird"], 60).await?;
|
||||
let mut proxy_cmd = tokio::process::Command::new("podman");
|
||||
proxy_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"netbird",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--restart=unless-stopped",
|
||||
"-p",
|
||||
"8087:80",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro",
|
||||
NETBIRD_PROXY_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("netbird", "create unified proxy", &mut proxy_cmd).await?;
|
||||
|
||||
wait_for_stack_containers(
|
||||
"netbird",
|
||||
&["netbird-server", "netbird-dashboard", "netbird"],
|
||||
60,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.set_install_phase("netbird", InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
@@ -1526,6 +1642,476 @@ LETSENCRYPT_DOMAIN=none
|
||||
"message": "NetBird self-hosted stack installed",
|
||||
}))
|
||||
}
|
||||
|
||||
/// Install Saleor stack (PostgreSQL + Valkey + API + worker + dashboard + Jaeger + Mailpit).
|
||||
pub(super) async fn install_saleor_stack(&self) -> Result<serde_json::Value> {
|
||||
if let Some(adopted) = adopt_stack_if_exists(
|
||||
"saleor",
|
||||
"saleor",
|
||||
&[
|
||||
"saleor-db",
|
||||
"saleor-cache",
|
||||
"saleor-jaeger",
|
||||
"saleor-mailpit",
|
||||
"saleor-api",
|
||||
"saleor-worker",
|
||||
"saleor",
|
||||
],
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(adopted);
|
||||
}
|
||||
|
||||
install_log("INSTALL START: saleor stack (postgres + valkey + api + worker + dashboard)")
|
||||
.await;
|
||||
info!("Installing Saleor stack");
|
||||
|
||||
let images = [
|
||||
SALEOR_POSTGRES_IMAGE,
|
||||
SALEOR_VALKEY_IMAGE,
|
||||
SALEOR_API_IMAGE,
|
||||
SALEOR_DASHBOARD_IMAGE,
|
||||
SALEOR_JAEGER_IMAGE,
|
||||
SALEOR_MAILPIT_IMAGE,
|
||||
];
|
||||
self.set_install_phase("saleor", InstallPhase::PullingImage)
|
||||
.await;
|
||||
let n_images = images.len() as u64;
|
||||
for (i, image) in images.iter().enumerate() {
|
||||
self.set_install_progress("saleor", i as u64, n_images)
|
||||
.await;
|
||||
pull_image_with_retry(image)
|
||||
.await
|
||||
.with_context(|| format!("Failed to pull Saleor image: {}", image))?;
|
||||
}
|
||||
self.set_install_progress("saleor", n_images, n_images)
|
||||
.await;
|
||||
|
||||
for name in [
|
||||
"saleor",
|
||||
"saleor-api",
|
||||
"saleor-worker",
|
||||
"saleor-db",
|
||||
"saleor-cache",
|
||||
"saleor-jaeger",
|
||||
"saleor-mailpit",
|
||||
] {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", name])
|
||||
.status()
|
||||
.await;
|
||||
}
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "rm", "-f", "saleor-net"])
|
||||
.status()
|
||||
.await;
|
||||
|
||||
self.set_install_phase("saleor", InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/saleor",
|
||||
"/var/lib/archipelago/saleor-db",
|
||||
"/var/lib/archipelago/saleor-cache",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let user = std::env::var("USER").unwrap_or_else(|_| "archipelago".to_string());
|
||||
for dir in [
|
||||
"/var/lib/archipelago/saleor",
|
||||
"/var/lib/archipelago/saleor-db",
|
||||
"/var/lib/archipelago/saleor-cache",
|
||||
] {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "-R", &format!("{}:{}", user, user), dir])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "saleor-net"])
|
||||
.status()
|
||||
.await;
|
||||
|
||||
let db_pass = super::config::read_or_generate_secret("saleor-db-password").await;
|
||||
let secret_key = super::config::read_or_generate_secret("saleor-secret-key").await;
|
||||
let admin_pass = super::config::read_or_generate_secret("saleor-admin-password").await;
|
||||
let host_ip = &self.config.host_ip;
|
||||
let dashboard_origin = format!("http://{}:9010", host_ip);
|
||||
let dashboard_url = format!("{}/", dashboard_origin);
|
||||
let api_url = format!("http://{}:8000/graphql/", host_ip);
|
||||
let allowed_hosts = format!("localhost,127.0.0.1,api,saleor-api,{}", host_ip);
|
||||
let allowed_client_hosts = format!(
|
||||
"{},http://localhost:9010,http://127.0.0.1:9010",
|
||||
dashboard_origin
|
||||
);
|
||||
let database_url = format!("postgres://saleor:{}@db/saleor", db_pass);
|
||||
|
||||
let mut db_cmd = tokio::process::Command::new("podman");
|
||||
db_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor-db",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--network-alias",
|
||||
"db",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=CHOWN",
|
||||
"--cap-add=DAC_OVERRIDE",
|
||||
"--cap-add=FOWNER",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=4096",
|
||||
"--health-cmd=pg_isready -U saleor || exit 1",
|
||||
"--health-interval=30s",
|
||||
"--health-retries=3",
|
||||
"-v",
|
||||
"/var/lib/archipelago/saleor-db:/var/lib/postgresql/data",
|
||||
"-e",
|
||||
"POSTGRES_USER=saleor",
|
||||
"-e",
|
||||
&format!("POSTGRES_PASSWORD={}", db_pass),
|
||||
"-e",
|
||||
"POSTGRES_DB=saleor",
|
||||
SALEOR_POSTGRES_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("saleor", "create postgres", &mut db_cmd).await?;
|
||||
|
||||
let mut cache_cmd = tokio::process::Command::new("podman");
|
||||
cache_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor-cache",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--network-alias",
|
||||
"cache",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=128m",
|
||||
"--pids-limit=2048",
|
||||
"--health-cmd=valkey-cli ping || exit 1",
|
||||
"--health-interval=30s",
|
||||
"--health-retries=3",
|
||||
SALEOR_VALKEY_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("saleor", "create cache", &mut cache_cmd).await?;
|
||||
|
||||
let mut jaeger_cmd = tokio::process::Command::new("podman");
|
||||
jaeger_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor-jaeger",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--network-alias",
|
||||
"jaeger",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=4096",
|
||||
"-p",
|
||||
"16686:16686",
|
||||
"-p",
|
||||
"4317:4317",
|
||||
"-p",
|
||||
"4318:4318",
|
||||
"--tmpfs",
|
||||
"/tmp:rw,nosuid,nodev,size=128m",
|
||||
SALEOR_JAEGER_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("saleor", "create jaeger", &mut jaeger_cmd).await?;
|
||||
|
||||
let mut mailpit_cmd = tokio::process::Command::new("podman");
|
||||
mailpit_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor-mailpit",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--network-alias",
|
||||
"mailpit",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=128m",
|
||||
"--pids-limit=2048",
|
||||
"-p",
|
||||
"1025:1025",
|
||||
"-p",
|
||||
"8025:8025",
|
||||
SALEOR_MAILPIT_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("saleor", "create mailpit", &mut mailpit_cmd).await?;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
|
||||
|
||||
let saleor_env = vec![
|
||||
"-e".to_string(),
|
||||
"CACHE_URL=redis://cache:6379/0".to_string(),
|
||||
"-e".to_string(),
|
||||
"CELERY_BROKER_URL=redis://cache:6379/1".to_string(),
|
||||
"-e".to_string(),
|
||||
format!("DATABASE_URL={}", database_url),
|
||||
"-e".to_string(),
|
||||
"DEFAULT_CHANNEL_SLUG=default-channel".to_string(),
|
||||
"-e".to_string(),
|
||||
"DEFAULT_FROM_EMAIL=noreply@example.com".to_string(),
|
||||
"-e".to_string(),
|
||||
"EMAIL_URL=smtp://mailpit:1025".to_string(),
|
||||
"-e".to_string(),
|
||||
format!("SECRET_KEY={}", secret_key),
|
||||
"-e".to_string(),
|
||||
"OTEL_SERVICE_NAME=saleor".to_string(),
|
||||
"-e".to_string(),
|
||||
"OTEL_TRACES_EXPORTER=otlp".to_string(),
|
||||
"-e".to_string(),
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317".to_string(),
|
||||
"-e".to_string(),
|
||||
"HTTP_IP_FILTER_ALLOW_LOOPBACK_IPS=True".to_string(),
|
||||
"-e".to_string(),
|
||||
"HTTP_IP_FILTER_ENABLED=False".to_string(),
|
||||
"-e".to_string(),
|
||||
format!("DASHBOARD_URL={}", dashboard_url),
|
||||
"-e".to_string(),
|
||||
format!("ALLOWED_CLIENT_HOSTS={}", allowed_client_hosts),
|
||||
"-e".to_string(),
|
||||
format!("ALLOWED_GRAPHQL_ORIGINS={}", allowed_client_hosts),
|
||||
"-e".to_string(),
|
||||
format!("ALLOWED_HOSTS={}", allowed_hosts),
|
||||
];
|
||||
|
||||
let mut migrate_cmd = tokio::process::Command::new("podman");
|
||||
migrate_cmd.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"-v",
|
||||
"/var/lib/archipelago/saleor:/app/media",
|
||||
]);
|
||||
migrate_cmd.args(&saleor_env);
|
||||
migrate_cmd.args([SALEOR_API_IMAGE, "python3", "manage.py", "migrate"]);
|
||||
run_required_stack_command("saleor", "run migrations", &mut migrate_cmd).await?;
|
||||
|
||||
let mut populate_cmd = tokio::process::Command::new("podman");
|
||||
populate_cmd.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"-v",
|
||||
"/var/lib/archipelago/saleor:/app/media",
|
||||
]);
|
||||
populate_cmd.args(&saleor_env);
|
||||
populate_cmd.args([
|
||||
SALEOR_API_IMAGE,
|
||||
"python3",
|
||||
"manage.py",
|
||||
"populatedb",
|
||||
"--createsuperuser",
|
||||
]);
|
||||
let populate = populate_cmd.output().await;
|
||||
if let Ok(output) = populate {
|
||||
if !output.status.success() {
|
||||
install_log(&format!(
|
||||
"INSTALL WARN: saleor - populate sample data skipped: {}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let admin_script = format!(
|
||||
r#"from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
user, _ = User.objects.get_or_create(email="admin@example.com", defaults={{"is_staff": True, "is_superuser": True}})
|
||||
user.is_staff = True
|
||||
user.is_superuser = True
|
||||
user.set_password({:?})
|
||||
user.save()
|
||||
"#,
|
||||
admin_pass
|
||||
);
|
||||
let mut admin_cmd = tokio::process::Command::new("podman");
|
||||
admin_cmd.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"-v",
|
||||
"/var/lib/archipelago/saleor:/app/media",
|
||||
]);
|
||||
admin_cmd.args(&saleor_env);
|
||||
admin_cmd.args([
|
||||
SALEOR_API_IMAGE,
|
||||
"python3",
|
||||
"manage.py",
|
||||
"shell",
|
||||
"-c",
|
||||
&admin_script,
|
||||
]);
|
||||
run_required_stack_command("saleor", "create or update admin user", &mut admin_cmd).await?;
|
||||
install_log("INSTALL INFO: saleor admin email admin@example.com; password stored in /var/lib/archipelago/secrets/saleor-admin-password").await;
|
||||
|
||||
let mut api_cmd = tokio::process::Command::new("podman");
|
||||
api_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor-api",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--network-alias",
|
||||
"api",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=CHOWN",
|
||||
"--cap-add=DAC_OVERRIDE",
|
||||
"--cap-add=FOWNER",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=1g",
|
||||
"--pids-limit=4096",
|
||||
"-p",
|
||||
"8000:8000",
|
||||
"-v",
|
||||
"/var/lib/archipelago/saleor:/app/media",
|
||||
]);
|
||||
api_cmd.args(&saleor_env);
|
||||
api_cmd.arg(SALEOR_API_IMAGE);
|
||||
run_required_stack_command("saleor", "create api", &mut api_cmd).await?;
|
||||
|
||||
let mut worker_cmd = tokio::process::Command::new("podman");
|
||||
worker_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor-worker",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=CHOWN",
|
||||
"--cap-add=DAC_OVERRIDE",
|
||||
"--cap-add=FOWNER",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=1g",
|
||||
"--pids-limit=4096",
|
||||
"-v",
|
||||
"/var/lib/archipelago/saleor:/app/media",
|
||||
]);
|
||||
worker_cmd.args(&saleor_env);
|
||||
worker_cmd.args([
|
||||
SALEOR_API_IMAGE,
|
||||
"celery",
|
||||
"-A",
|
||||
"saleor",
|
||||
"--app=saleor.celeryconf:app",
|
||||
"worker",
|
||||
"--loglevel=info",
|
||||
"-B",
|
||||
]);
|
||||
run_required_stack_command("saleor", "create worker", &mut worker_cmd).await?;
|
||||
|
||||
self.set_install_phase("saleor", InstallPhase::StartingContainer)
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let mut dashboard_cmd = tokio::process::Command::new("podman");
|
||||
dashboard_cmd.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"saleor",
|
||||
"--network",
|
||||
"saleor-net",
|
||||
"--restart=unless-stopped",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=CHOWN",
|
||||
"--cap-add=DAC_OVERRIDE",
|
||||
"--cap-add=FOWNER",
|
||||
"--cap-add=NET_BIND_SERVICE",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=256m",
|
||||
"--pids-limit=2048",
|
||||
"-p",
|
||||
"9010:80",
|
||||
"-e",
|
||||
&format!("API_URL={}", api_url),
|
||||
"-e",
|
||||
"APP_MOUNT_URI=/",
|
||||
SALEOR_DASHBOARD_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("saleor", "create dashboard", &mut dashboard_cmd).await?;
|
||||
|
||||
wait_for_stack_containers(
|
||||
"saleor",
|
||||
&[
|
||||
"saleor-db",
|
||||
"saleor-cache",
|
||||
"saleor-jaeger",
|
||||
"saleor-mailpit",
|
||||
"saleor-api",
|
||||
"saleor-worker",
|
||||
"saleor",
|
||||
],
|
||||
120,
|
||||
)
|
||||
.await?;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
wait_for_stack_containers(
|
||||
"saleor",
|
||||
&[
|
||||
"saleor-db",
|
||||
"saleor-cache",
|
||||
"saleor-jaeger",
|
||||
"saleor-mailpit",
|
||||
"saleor-api",
|
||||
"saleor-worker",
|
||||
"saleor",
|
||||
],
|
||||
30,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.set_install_phase("saleor", InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
self.set_install_phase("saleor", InstallPhase::PostInstall)
|
||||
.await;
|
||||
self.set_install_phase("saleor", InstallPhase::Done).await;
|
||||
self.clear_install_progress("saleor").await;
|
||||
|
||||
install_log("INSTALL OK: saleor stack").await;
|
||||
info!("Saleor stack installed");
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": "saleor",
|
||||
"message": "Saleor stack installed (7 containers)",
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_or_generate_b64_secret(name: &str) -> String {
|
||||
@@ -1544,6 +2130,123 @@ async fn read_or_generate_b64_secret(name: &str) -> String {
|
||||
secret
|
||||
}
|
||||
|
||||
async fn write_netbird_config_files(host_ip: &str) -> Result<()> {
|
||||
let public_origin = format!("http://{}:8087", host_ip);
|
||||
let server_origin = format!("http://{}:8086", host_ip);
|
||||
let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await;
|
||||
let encryption_key = read_or_generate_b64_secret("netbird-store-encryption-key").await;
|
||||
let config = format!(
|
||||
r#"server:
|
||||
listenAddress: ":80"
|
||||
exposedAddress: "{public_origin}"
|
||||
stunPorts:
|
||||
- 3478
|
||||
metricsPort: 9090
|
||||
healthcheckAddress: ":9000"
|
||||
logLevel: "info"
|
||||
logFile: "console"
|
||||
authSecret: "{relay_secret}"
|
||||
dataDir: "/var/lib/netbird"
|
||||
auth:
|
||||
issuer: "{public_origin}/oauth2"
|
||||
localAuthDisabled: false
|
||||
signKeyRefreshEnabled: false
|
||||
dashboardRedirectURIs:
|
||||
- "{public_origin}/nb-auth"
|
||||
- "{public_origin}/nb-silent-auth"
|
||||
dashboardPostLogoutRedirectURIs:
|
||||
- "{public_origin}/"
|
||||
cliRedirectURIs:
|
||||
- "http://localhost:53000/"
|
||||
store:
|
||||
engine: "sqlite"
|
||||
encryptionKey: "{encryption_key}"
|
||||
"#
|
||||
);
|
||||
tokio::fs::write("/var/lib/archipelago/netbird/config.yaml", config)
|
||||
.await
|
||||
.context("Failed to write NetBird config.yaml")?;
|
||||
|
||||
let dashboard_env = format!(
|
||||
r#"NETBIRD_MGMT_API_ENDPOINT={public_origin}
|
||||
NETBIRD_MGMT_GRPC_API_ENDPOINT={public_origin}
|
||||
AUTH_AUDIENCE=netbird-dashboard
|
||||
AUTH_CLIENT_ID=netbird-dashboard
|
||||
AUTH_CLIENT_SECRET=
|
||||
AUTH_AUTHORITY={public_origin}/oauth2
|
||||
USE_AUTH0=false
|
||||
AUTH_SUPPORTED_SCOPES=openid profile email groups
|
||||
AUTH_REDIRECT_URI=/nb-auth
|
||||
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
|
||||
NETBIRD_TOKEN_SOURCE=idToken
|
||||
NGINX_SSL_PORT=443
|
||||
LETSENCRYPT_DOMAIN=none
|
||||
"#
|
||||
);
|
||||
tokio::fs::write("/var/lib/archipelago/netbird/dashboard.env", dashboard_env)
|
||||
.await
|
||||
.context("Failed to write NetBird dashboard.env")?;
|
||||
|
||||
let nginx_conf = format!(
|
||||
r#"server {{
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Route browser API/auth through the host-published server port. Rootless
|
||||
# Podman can give netbird-server a new container IP on restart while nginx
|
||||
# keeps an old resolved address, which breaks login with 502s.
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
location ~ ^/(relay|ws-proxy/) {{
|
||||
proxy_pass http://host.containers.internal:8086;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 1d;
|
||||
}}
|
||||
|
||||
location ~ ^/(api|oauth2)(/|$) {{
|
||||
proxy_pass http://host.containers.internal:8086;
|
||||
}}
|
||||
|
||||
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ {{
|
||||
grpc_pass grpc://netbird-server:80;
|
||||
grpc_read_timeout 1d;
|
||||
grpc_send_timeout 1d;
|
||||
}}
|
||||
|
||||
location / {{
|
||||
proxy_pass http://netbird-dashboard:80;
|
||||
}}
|
||||
}}
|
||||
|
||||
# Direct server remains available for diagnostics at {server_origin}.
|
||||
"#
|
||||
);
|
||||
tokio::fs::write("/var/lib/archipelago/netbird/nginx.conf", nginx_conf)
|
||||
.await
|
||||
.context("Failed to write NetBird nginx.conf")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn detect_netbird_public_host_ip() -> Option<String> {
|
||||
let output = tokio::process::Command::new("hostname")
|
||||
.args(["-I"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout
|
||||
.split_whitespace()
|
||||
.find(|ip| ip.starts_with("100.") && ip.contains('.'))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{btcpay_stack_app_ids, mempool_stack_app_ids};
|
||||
|
||||
@@ -62,6 +62,13 @@ impl DockerPackageScanner {
|
||||
"indeedhub-build_relay_1",
|
||||
"indeedhub-build_ffmpeg-worker_1",
|
||||
"netbird-server",
|
||||
"netbird-dashboard",
|
||||
"saleor-api",
|
||||
"saleor-worker",
|
||||
"saleor-db",
|
||||
"saleor-cache",
|
||||
"saleor-jaeger",
|
||||
"saleor-mailpit",
|
||||
"buildx_buildkit_default",
|
||||
];
|
||||
|
||||
@@ -118,6 +125,11 @@ impl DockerPackageScanner {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_transient_podman_helper(&app_id, &container.ports) {
|
||||
debug!("Skipping transient Podman helper container: {}", app_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip podman-compose infrastructure containers (e.g. indeedhub-build_api_1)
|
||||
// These have the project prefix pattern: {project}_{service}_{instance}
|
||||
if app_id.starts_with("indeedhub-build_") {
|
||||
@@ -140,7 +152,9 @@ impl DockerPackageScanner {
|
||||
let metadata = get_app_metadata(&app_id);
|
||||
|
||||
// Resolve UI address: separate UI containers > static map > dynamic ports
|
||||
let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) {
|
||||
let lan_address = if app_id == "netbird" {
|
||||
reachable_lan_address(&app_id, netbird_configured_launch_url().await).await
|
||||
} else if let Some(ui_address) = ui_containers.get(&app_id) {
|
||||
// Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui)
|
||||
debug!("Using UI container for {}: {}", app_id, ui_address);
|
||||
reachable_lan_address(&app_id, Some(ui_address.clone())).await
|
||||
@@ -282,6 +296,7 @@ fn get_app_tier(app_id: &str) -> &'static str {
|
||||
"uptime-kuma" => "recommended",
|
||||
"grafana" => "recommended",
|
||||
"searxng" => "recommended",
|
||||
"saleor" => "recommended",
|
||||
"tailscale" | "netbird" => "recommended",
|
||||
"portainer" => "recommended",
|
||||
// Optional: everything else
|
||||
@@ -289,6 +304,21 @@ fn get_app_tier(app_id: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_transient_podman_helper(app_id: &str, ports: &[String]) -> bool {
|
||||
if !ports.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some((left, right)) = app_id.split_once('_') else {
|
||||
return false;
|
||||
};
|
||||
|
||||
!left.is_empty()
|
||||
&& !right.is_empty()
|
||||
&& left.chars().all(|c| c.is_ascii_lowercase())
|
||||
&& right.chars().all(|c| c.is_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
let mut meta = match app_id {
|
||||
"bitcoin-core" => AppMetadata {
|
||||
@@ -487,6 +517,13 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/netbirdio/netbird".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"saleor" => AppMetadata {
|
||||
title: "Saleor".to_string(),
|
||||
description: "Composable commerce platform with GraphQL API and dashboard. Log in with admin@example.com; the password is stored on the node at /var/lib/archipelago/secrets/saleor-admin-password".to_string(),
|
||||
icon: "/assets/img/app-icons/saleor.svg".to_string(),
|
||||
repo: "https://github.com/saleor/saleor".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"gitea" => AppMetadata {
|
||||
title: "Gitea".to_string(),
|
||||
description: "Self-hosted Git service with repository and package hosting".to_string(),
|
||||
@@ -673,6 +710,18 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn netbird_configured_launch_url() -> Option<String> {
|
||||
let env = tokio::fs::read_to_string("/var/lib/archipelago/netbird/dashboard.env")
|
||||
.await
|
||||
.ok()?;
|
||||
env.lines()
|
||||
.find_map(|line| line.strip_prefix("NETBIRD_MGMT_API_ENDPOINT="))
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| PodmanClient::lan_address_for("netbird"))
|
||||
}
|
||||
|
||||
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
|
||||
let url = candidate?;
|
||||
if !requires_reachable_launch(app_id) {
|
||||
@@ -715,6 +764,7 @@ fn requires_reachable_launch(app_id: &str) -> bool {
|
||||
| "tailscale"
|
||||
| "immich"
|
||||
| "searxng"
|
||||
| "saleor"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,14 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
||||
"portainer" => Some("PORTAINER_IMAGE"),
|
||||
"tailscale" => Some("TAILSCALE_IMAGE"),
|
||||
"netbird" => Some("NETBIRD_DASHBOARD_IMAGE"),
|
||||
"netbird-dashboard" => Some("NETBIRD_DASHBOARD_IMAGE"),
|
||||
"netbird-server" => Some("NETBIRD_SERVER_IMAGE"),
|
||||
"saleor" => Some("SALEOR_DASHBOARD_IMAGE"),
|
||||
"saleor-api" | "saleor-worker" => Some("SALEOR_API_IMAGE"),
|
||||
"saleor-db" => Some("SALEOR_POSTGRES_IMAGE"),
|
||||
"saleor-cache" => Some("SALEOR_VALKEY_IMAGE"),
|
||||
"saleor-jaeger" => Some("SALEOR_JAEGER_IMAGE"),
|
||||
"saleor-mailpit" => Some("SALEOR_MAILPIT_IMAGE"),
|
||||
|
||||
// Fedimint
|
||||
"fedimint" | "fedimintd" => Some("FEDIMINT_IMAGE"),
|
||||
@@ -302,9 +309,19 @@ pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
|
||||
("penpot-frontend", "PENPOT_FRONTEND_IMAGE"),
|
||||
],
|
||||
"netbird" => vec![
|
||||
("netbird", "NETBIRD_DASHBOARD_IMAGE"),
|
||||
("netbird", "NETBIRD_PROXY_IMAGE"),
|
||||
("netbird-dashboard", "NETBIRD_DASHBOARD_IMAGE"),
|
||||
("netbird-server", "NETBIRD_SERVER_IMAGE"),
|
||||
],
|
||||
"saleor" => vec![
|
||||
("saleor-db", "SALEOR_POSTGRES_IMAGE"),
|
||||
("saleor-cache", "SALEOR_VALKEY_IMAGE"),
|
||||
("saleor-api", "SALEOR_API_IMAGE"),
|
||||
("saleor-worker", "SALEOR_API_IMAGE"),
|
||||
("saleor-jaeger", "SALEOR_JAEGER_IMAGE"),
|
||||
("saleor-mailpit", "SALEOR_MAILPIT_IMAGE"),
|
||||
("saleor", "SALEOR_DASHBOARD_IMAGE"),
|
||||
],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ impl PodmanClient {
|
||||
"filebrowser" => "http://localhost:8083",
|
||||
"nginx-proxy-manager" => "http://localhost:8081",
|
||||
"portainer" => "http://localhost:9000",
|
||||
"saleor" => "http://localhost:9010",
|
||||
"uptime-kuma" => "http://localhost:3002",
|
||||
"fedimint" | "fedimintd" => "http://localhost:8175",
|
||||
"fedimint-gateway" => "http://localhost:8176",
|
||||
@@ -136,6 +137,7 @@ impl PodmanClient {
|
||||
"indeedhub" => "http://localhost:7778",
|
||||
"dwn" => "http://localhost:3100",
|
||||
"endurain" => "http://localhost:8080",
|
||||
"netbird" => "http://localhost:8087",
|
||||
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.72-alpha",
|
||||
"version": "1.7.78-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.72-alpha",
|
||||
"version": "1.7.78-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.72-alpha",
|
||||
"version": "1.7.78-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Saleor">
|
||||
<rect width="128" height="128" rx="30" fill="#111827"/>
|
||||
<path d="M34 42c0-10 9-18 22-18h38v16H56c-5 0-8 2-8 5 0 4 4 5 13 7l15 3c15 3 24 11 24 24 0 15-12 25-31 25H31V88h39c8 0 13-3 13-8 0-4-4-6-12-8l-16-3C41 66 34 57 34 42Z" fill="#fff"/>
|
||||
<path d="M29 103h70v8H29z" fill="#7C3AED"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
@@ -64,6 +64,23 @@
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "saleor",
|
||||
"title": "Saleor",
|
||||
"version": "3.23",
|
||||
"description": "Composable commerce platform with GraphQL API, dashboard, worker, mail testing, and tracing.",
|
||||
"icon": "/assets/img/app-icons/saleor.svg",
|
||||
"author": "Saleor",
|
||||
"category": "commerce",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "ghcr.io/saleor/saleor:3.23",
|
||||
"repoUrl": "https://github.com/saleor/saleor",
|
||||
"containerConfig": {
|
||||
"ports": ["9010:80", "8000:8000", "8025:8025", "16686:16686"],
|
||||
"volumes": ["/var/lib/archipelago/saleor:/app/media", "/var/lib/archipelago/saleor-db:/var/lib/postgresql/data"],
|
||||
"notes": "Installed as a Saleor stack: dashboard on 9010, API on 8000, Mailpit on 8025, and Jaeger on 16686. Supporting containers include PostgreSQL, Valkey, Celery worker, and services required by Saleor."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
|
||||
@@ -17,6 +17,15 @@ const NEW_TAB_PORTS = new Set([
|
||||
])
|
||||
|
||||
const NEW_TAB_APP_IDS = new Set([
|
||||
'btcpay-server',
|
||||
'grafana',
|
||||
'photoprism',
|
||||
'homeassistant',
|
||||
'vaultwarden',
|
||||
'nextcloud',
|
||||
'portainer',
|
||||
'onlyoffice',
|
||||
'tailscale',
|
||||
'nginx-proxy-manager',
|
||||
'uptime-kuma',
|
||||
'gitea',
|
||||
@@ -93,6 +102,7 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'8334': 'bitcoin-knots',
|
||||
'8888': 'searxng',
|
||||
'9000': 'portainer',
|
||||
'9010': 'saleor',
|
||||
'8087': 'netbird',
|
||||
'8086': 'netbird',
|
||||
'9980': 'onlyoffice',
|
||||
@@ -109,6 +119,27 @@ const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'3010': 'thunderhub',
|
||||
}
|
||||
|
||||
const APP_ID_TO_PORT: Record<string, string> = {
|
||||
'btcpay-server': '23000',
|
||||
grafana: '3000',
|
||||
photoprism: '2342',
|
||||
homeassistant: '8123',
|
||||
vaultwarden: '8082',
|
||||
nextcloud: '8085',
|
||||
portainer: '9000',
|
||||
onlyoffice: '8044',
|
||||
tailscale: '8240',
|
||||
'nginx-proxy-manager': '8081',
|
||||
'uptime-kuma': '3002',
|
||||
gitea: '3001',
|
||||
}
|
||||
|
||||
function directAppUrl(appId: string): string | null {
|
||||
const port = APP_ID_TO_PORT[appId]
|
||||
if (!port || typeof window === 'undefined') return null
|
||||
return `http://${window.location.hostname}:${port}`
|
||||
}
|
||||
|
||||
|
||||
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
|
||||
|
||||
@@ -161,6 +192,12 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
}
|
||||
|
||||
function openSession(appId: string) {
|
||||
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
|
||||
if (launchUrl) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
|
||||
if (mode === 'panel' && !isMobileViewport()) {
|
||||
panelAppId.value = appId
|
||||
|
||||
@@ -1659,6 +1659,45 @@ html:has(body.video-background-active)::before {
|
||||
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.mobile-filter-btn {
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 12px);
|
||||
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.mobile-filter-sheet {
|
||||
padding-bottom: calc(var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 1.5rem);
|
||||
}
|
||||
|
||||
.mobile-category-strip {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
padding-bottom: 0.25rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.mobile-category-strip::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-category-pill {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
padding: 0.55rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-category-pill-active {
|
||||
border-color: rgba(255, 255, 255, 0.36);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ── Cloud Audio Player (mini bar) ──── */
|
||||
|
||||
.cloud-audio-player {
|
||||
|
||||
@@ -140,6 +140,18 @@ export interface InterfaceAddress {
|
||||
'lan-address': string | null
|
||||
}
|
||||
|
||||
export interface AppCredential {
|
||||
label: string
|
||||
value: string
|
||||
sensitive?: boolean
|
||||
}
|
||||
|
||||
export interface AppCredentialsResponse {
|
||||
title?: string
|
||||
description?: string
|
||||
credentials: AppCredential[]
|
||||
}
|
||||
|
||||
export const ServiceStatus = {
|
||||
Stopped: 'stopped',
|
||||
Starting: 'starting',
|
||||
@@ -275,4 +287,3 @@ export interface Update {
|
||||
sequence: number
|
||||
patch: PatchOperation[]
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
:lan-url="lanUrl"
|
||||
:tor-url="torUrl"
|
||||
:show-tor-address="showTorAddress"
|
||||
:credentials="credentials"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,6 +138,7 @@ import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import { dummyApps } from '../utils/dummyApps'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
||||
import AppContentSection from './appDetails/AppContentSection.vue'
|
||||
import AppSidebar from './appDetails/AppSidebar.vue'
|
||||
@@ -214,6 +216,7 @@ const needsBitcoinSync = computed(() => BITCOIN_DEPENDENT_APPS.includes(packageK
|
||||
const bitcoinSyncPercent = ref(0)
|
||||
const bitcoinBlockHeight = ref(0)
|
||||
const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9)
|
||||
const credentials = ref<AppCredentialsResponse | null>(null)
|
||||
|
||||
async function loadBitcoinSync() {
|
||||
if (!needsBitcoinSync.value) return
|
||||
@@ -230,8 +233,23 @@ async function loadBitcoinSync() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCredentials() {
|
||||
if (!appId.value) return
|
||||
try {
|
||||
const result = await rpcClient.call<AppCredentialsResponse>({
|
||||
method: 'package.credentials',
|
||||
params: { app_id: packageKey.value },
|
||||
timeout: 5000,
|
||||
})
|
||||
credentials.value = result.credentials?.length ? result : null
|
||||
} catch {
|
||||
credentials.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBitcoinSync()
|
||||
loadCredentials()
|
||||
})
|
||||
|
||||
const actionError = ref('')
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
import AppSessionHeader from './appSession/AppSessionHeader.vue'
|
||||
@@ -116,6 +117,7 @@ const isInlinePanel = computed(() => !!props.appIdProp)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const screensaverStore = useScreensaverStore()
|
||||
|
||||
const sessionRef = ref<HTMLElement | null>(null)
|
||||
@@ -157,7 +159,8 @@ const screensaverSuppressedApps = new Set([
|
||||
])
|
||||
|
||||
const appUrl = computed(() => {
|
||||
return resolveAppUrl(appId.value, route.query.path as string | undefined)
|
||||
const runtimeUrl = store.data?.['package-data']?.[appId.value]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
|
||||
return resolveAppUrl(appId.value, route.query.path as string | undefined, runtimeUrl)
|
||||
})
|
||||
|
||||
function closeRouteSession() {
|
||||
@@ -341,9 +344,8 @@ watch(displayMode, (mode) => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Desktop apps that block iframes open externally. Mobile keeps the user in
|
||||
// Archipelago and shows the explicit fallback instead of leaving the shell.
|
||||
if (!isMobile && mustOpenNewTab.value && appUrl.value) {
|
||||
// Apps that block iframes open externally instead of landing in a broken webview.
|
||||
if (mustOpenNewTab.value && appUrl.value) {
|
||||
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else closeRouteSession()
|
||||
@@ -532,6 +534,12 @@ onBeforeUnmount(() => {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.app-session-frame-scroll-host {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
|
||||
@media (max-width: 767px) {
|
||||
.app-session-root {
|
||||
|
||||
+161
-3
@@ -106,10 +106,34 @@
|
||||
</div>
|
||||
|
||||
<!-- No Results -->
|
||||
<div v-if="filteredPackageEntries.length === 0 && searchQuery" class="text-center py-12">
|
||||
<div v-if="filteredPackageEntries.length === 0 && marketplaceMatches.length === 0 && searchQuery" class="text-center py-12">
|
||||
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="marketplaceMatches.length > 0" class="mb-5">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="discover-terminal-tag">app store</span>
|
||||
<h2 class="text-lg font-bold text-white">Available in Discover</h2>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<button
|
||||
v-for="app in marketplaceMatches"
|
||||
:key="app.id"
|
||||
type="button"
|
||||
class="glass-card p-4 text-left flex items-center gap-3 hover:bg-orange-500/5 hover:border-orange-500/15 transition-colors"
|
||||
@click="openMarketplaceResult(app)"
|
||||
>
|
||||
<img v-if="app.icon" :src="app.icon" :alt="app.title" class="w-12 h-12 rounded-xl object-cover bg-white/10" />
|
||||
<div v-else class="w-12 h-12 rounded-xl bg-white/10 flex-shrink-0"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold text-white truncate">{{ app.title }}</p>
|
||||
<p class="text-xs text-white/50 truncate">Available in App Store</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: iPhone-style icon grid -->
|
||||
<div class="md:hidden">
|
||||
<AppIconGrid
|
||||
@@ -149,6 +173,37 @@
|
||||
@confirm="onConfirmUninstall"
|
||||
/>
|
||||
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="credentialModal.show"
|
||||
class="fixed inset-0 z-[2700] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6"
|
||||
@click.self="closeCredentialModal"
|
||||
>
|
||||
<div class="sideload-modal credential-modal">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
||||
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
|
||||
</div>
|
||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">×</button>
|
||||
</div>
|
||||
<div class="credential-modal-body space-y-3">
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-modal-actions mt-5 flex flex-col sm:flex-row gap-3">
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showSideload"
|
||||
@@ -231,15 +286,17 @@ import { useAppStore } from '@/stores/app'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { type PackageDataEntry, type PackageState } from '@/types/api'
|
||||
import { type AppCredential, type AppCredentialsResponse, type PackageDataEntry, type PackageState } from '@/types/api'
|
||||
import AppCard from './apps/AppCard.vue'
|
||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
import { useAppsActions } from './apps/useAppsActions'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import {
|
||||
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
||||
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
|
||||
} from './apps/appsConfig'
|
||||
import { getCuratedAppList, INSTALLED_ALIASES, type MarketplaceApp } from './marketplace/marketplaceData'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -247,6 +304,7 @@ const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const serverStore = useServerStore()
|
||||
const actions = useAppsActions()
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
const showSideload = ref(false)
|
||||
const sideloading = ref(false)
|
||||
const sideloadError = ref('')
|
||||
@@ -257,6 +315,14 @@ const sideloadForm = ref({
|
||||
port: '',
|
||||
description: '',
|
||||
})
|
||||
const credentialModal = ref({
|
||||
show: false,
|
||||
appId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
credentials: [] as AppCredential[],
|
||||
copied: '',
|
||||
})
|
||||
|
||||
// Only stagger-animate on first mount
|
||||
const showStagger = !appsAnimationDone
|
||||
@@ -266,6 +332,10 @@ const activeTab = ref<AppsTab>(
|
||||
route.query.tab === 'websites' || route.query.tab === 'services' ? 'websites' : 'apps'
|
||||
)
|
||||
|
||||
watch(() => route.query.tab, (tab) => {
|
||||
activeTab.value = tab === 'websites' || tab === 'services' ? 'websites' : 'apps'
|
||||
})
|
||||
|
||||
// Search (debounced)
|
||||
const searchQuery = ref('')
|
||||
const debouncedSearchQuery = ref('')
|
||||
@@ -309,6 +379,19 @@ const packages = computed(() => {
|
||||
|
||||
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
|
||||
|
||||
const curatedApps = getCuratedAppList()
|
||||
const marketplaceMatches = computed(() => {
|
||||
const q = debouncedSearchQuery.value.trim().toLowerCase()
|
||||
if (!q || activeTab.value !== 'apps') return [] as MarketplaceApp[]
|
||||
return curatedApps.filter(app => {
|
||||
if (isInstalledInMyApps(app.id)) return false
|
||||
return app.title?.toLowerCase().includes(q) ||
|
||||
app.id.toLowerCase().includes(q) ||
|
||||
app.author?.toLowerCase().includes(q) ||
|
||||
(typeof app.description === 'string' && app.description.toLowerCase().includes(q))
|
||||
}).slice(0, 6)
|
||||
})
|
||||
|
||||
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
|
||||
|
||||
// Connection error state
|
||||
@@ -352,6 +435,17 @@ const filteredPackageEntries = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function isInstalledInMyApps(appId: string): boolean {
|
||||
if (appId in packages.value) return true
|
||||
const aliases = INSTALLED_ALIASES[appId]
|
||||
return aliases ? aliases.some(alias => alias in packages.value) : false
|
||||
}
|
||||
|
||||
function openMarketplaceResult(app: MarketplaceApp) {
|
||||
setCurrentApp(app)
|
||||
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'apps' } }).catch(() => {})
|
||||
}
|
||||
|
||||
// Uninstall modal
|
||||
const uninstallModal = ref({ show: false, appId: '', appTitle: '' })
|
||||
|
||||
@@ -376,7 +470,13 @@ function goToApp(id: string) {
|
||||
router.push(`/dashboard/apps/${id}`).catch(() => {})
|
||||
}
|
||||
|
||||
function launchApp(id: string) {
|
||||
async function launchApp(id: string) {
|
||||
const shown = await maybeShowCredentialsBeforeLaunch(id)
|
||||
if (shown) return
|
||||
launchAppNow(id)
|
||||
}
|
||||
|
||||
function launchAppNow(id: string) {
|
||||
const pkg = packages.value[id]
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
@@ -401,6 +501,52 @@ function launchApp(id: string) {
|
||||
useAppLauncherStore().openSession(id)
|
||||
}
|
||||
|
||||
async function maybeShowCredentialsBeforeLaunch(id: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await rpcClient.call<AppCredentialsResponse>({
|
||||
method: 'package.credentials',
|
||||
params: { app_id: id },
|
||||
timeout: 5000,
|
||||
})
|
||||
if (!result.credentials?.length) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: result.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
||||
description: result.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: result.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function closeCredentialModal() {
|
||||
credentialModal.value.show = false
|
||||
}
|
||||
|
||||
function continueCredentialLaunch() {
|
||||
const id = credentialModal.value.appId
|
||||
closeCredentialModal()
|
||||
if (id) launchAppNow(id)
|
||||
}
|
||||
|
||||
async function copyModalCredential(label: string, value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
credentialModal.value.copied = label
|
||||
}
|
||||
|
||||
async function updateApp(id: string) {
|
||||
try {
|
||||
await serverStore.updatePackage(id)
|
||||
@@ -544,6 +690,18 @@ async function submitSideload() {
|
||||
}
|
||||
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
|
||||
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
|
||||
.credential-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.credential-modal-body {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.credential-modal-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.sideload-modal {
|
||||
border-radius: 1.25rem;
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
data-controller-zone="main"
|
||||
class="flex-1 overflow-hidden relative pb-0 glass-piece z-10"
|
||||
:class="{ 'glass-throw-main': showZoomIn }"
|
||||
tabindex="-1"
|
||||
@pointerenter="activateMainScroll"
|
||||
@wheel.capture="activateMainScroll"
|
||||
>
|
||||
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
|
||||
<!-- Controller zone entry point - no switcher -->
|
||||
@@ -234,6 +237,14 @@ function restoreScroll(path: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function activateMainScroll() {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (active?.closest?.('[data-controller-zone="sidebar"]')) {
|
||||
active.blur()
|
||||
document.getElementById('main-content')?.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.path, (newPath) => {
|
||||
const isAppDetails = isDetailRoute(newPath)
|
||||
const wasAppDetails = showAltBackground.value
|
||||
|
||||
@@ -33,8 +33,22 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: search -->
|
||||
<!-- Mobile: categories + search -->
|
||||
<div class="md:hidden mb-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="discover-terminal-tag">discover</span>
|
||||
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||
</div>
|
||||
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
||||
<button class="mobile-category-pill mobile-category-pill-active" type="button">Discover</button>
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="navigateToMarketplace(category.id)"
|
||||
class="mobile-category-pill"
|
||||
type="button"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -163,11 +177,6 @@
|
||||
<p class="text-white/60 text-xl mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
|
||||
</div>
|
||||
|
||||
<FilterModal
|
||||
:categories="categoriesWithApps"
|
||||
:selected-category="selectedCategory"
|
||||
@select-category="selectCategory"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -187,7 +196,6 @@ import { useToast } from '@/composables/useToast'
|
||||
import DiscoverHero from './discover/DiscoverHero.vue'
|
||||
import FeaturedApps from './discover/FeaturedApps.vue'
|
||||
import AppGrid from './discover/AppGrid.vue'
|
||||
import FilterModal from './discover/FilterModal.vue'
|
||||
import type { MarketplaceApp, FeaturedApp } from './discover/types'
|
||||
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps'
|
||||
|
||||
@@ -224,13 +232,6 @@ const categories = computed(() => [
|
||||
// been removed in favour of the store's phase-aware mapping.
|
||||
const installingApps = serverStore.installingApps
|
||||
|
||||
function selectCategory(id: string) {
|
||||
selectedCategory.value = id
|
||||
if (id === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
|
||||
loadNostrMarketplace()
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToMarketplace(categoryId: string) {
|
||||
router.push({ name: 'marketplace', query: { category: categoryId } })
|
||||
}
|
||||
@@ -312,6 +313,9 @@ const categoriesWithApps = computed(() => {
|
||||
|
||||
const filteredApps = computed(() => {
|
||||
let apps = allApps.value
|
||||
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
||||
apps = apps.filter(app => app.category === selectedCategory.value)
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
apps = apps.filter(app =>
|
||||
|
||||
@@ -35,8 +35,27 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: search (tabs handled by Dashboard.vue header) -->
|
||||
<!-- Mobile: categories + search (tabs handled by Dashboard.vue header) -->
|
||||
<div class="md:hidden mb-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="discover-terminal-tag">discover</span>
|
||||
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||
</div>
|
||||
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
||||
<button
|
||||
@click="router.push({ name: 'discover' })"
|
||||
class="mobile-category-pill"
|
||||
type="button"
|
||||
>Discover</button>
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectCategory(category.id)"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
|
||||
type="button"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -96,11 +115,6 @@
|
||||
</div>
|
||||
<!-- End Scrollable Apps Section -->
|
||||
|
||||
<MarketplaceFilterModal
|
||||
:categories="categoriesWithApps"
|
||||
:selected-category="selectedCategory"
|
||||
@select="selectCategory"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -109,7 +123,7 @@ let marketplaceAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -119,7 +133,6 @@ import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
||||
import MarketplaceFilterModal from './marketplace/MarketplaceFilterModal.vue'
|
||||
import {
|
||||
type MarketplaceApp,
|
||||
INSTALLED_ALIASES,
|
||||
@@ -166,11 +179,21 @@ const electrumxArchiveWarning = 'You need a full archival bitcoin node before do
|
||||
// Select category and trigger Nostr relay discovery when 'nostr' is chosen
|
||||
function selectCategory(id: string) {
|
||||
selectedCategory.value = id
|
||||
const query = id === 'all' ? {} : { category: id }
|
||||
router.replace({ name: 'marketplace', query }).catch(() => {})
|
||||
if (id === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
|
||||
loadNostrMarketplace()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.query.category, (category) => {
|
||||
const next = typeof category === 'string' && category ? category : 'all'
|
||||
selectedCategory.value = next
|
||||
if (next === 'nostr' && nostrApps.value.length === 0 && !nostrLoading.value) {
|
||||
loadNostrMarketplace()
|
||||
}
|
||||
})
|
||||
|
||||
// Community marketplace state
|
||||
const loadingCommunity = ref(false)
|
||||
const communityError = ref('')
|
||||
@@ -258,7 +281,7 @@ const categoriesWithApps = computed(() => {
|
||||
const filteredApps = computed(() => {
|
||||
let apps = allApps.value
|
||||
|
||||
if (selectedCategory.value && selectedCategory.value !== 'all') {
|
||||
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
||||
apps = apps.filter(app => app.category === selectedCategory.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="credentials?.credentials?.length" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-2">Credentials</h3>
|
||||
<p v-if="credentials.description" class="text-sm text-white/60 mb-4">{{ credentials.description }}</p>
|
||||
<div class="space-y-3">
|
||||
<div v-for="cred in credentials.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyCredential(cred.label, cred.value)">
|
||||
{{ copiedCredential === cred.label ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requirements Card (hidden for web-only apps) -->
|
||||
<div v-if="!isWebOnly" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.requirements') }}</h3>
|
||||
@@ -151,9 +167,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const copiedCredential = ref('')
|
||||
|
||||
async function copyCredential(label: string, value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
copiedCredential.value = label
|
||||
setTimeout(() => {
|
||||
if (copiedCredential.value === label) copiedCredential.value = ''
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
pkg: Record<string, any>
|
||||
@@ -164,5 +200,6 @@ defineProps<{
|
||||
lanUrl: string
|
||||
torUrl: string
|
||||
showTorAddress: boolean
|
||||
credentials: AppCredentialsResponse | null
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -9,16 +9,24 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<iframe
|
||||
<div
|
||||
v-if="appUrl && !iframeBlocked"
|
||||
ref="iframeRef"
|
||||
:key="refreshKey"
|
||||
:src="appUrl"
|
||||
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
|
||||
title="App content"
|
||||
@load="$emit('iframeLoad')"
|
||||
@error="$emit('iframeError')"
|
||||
/>
|
||||
class="absolute inset-0 app-session-frame-scroll-host"
|
||||
tabindex="-1"
|
||||
@pointerdown="focusIframe"
|
||||
@focusin="focusIframe"
|
||||
>
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
:key="refreshKey"
|
||||
:src="appUrl"
|
||||
class="w-full h-full border-0 iframe-scrollbar-hide"
|
||||
title="App content"
|
||||
tabindex="0"
|
||||
@load="handleIframeLoad"
|
||||
@error="$emit('iframeError')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Iframe blocked fallback -->
|
||||
<Transition name="content-fade">
|
||||
@@ -69,9 +77,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
appUrl: string
|
||||
appId: string
|
||||
appTitle: string
|
||||
@@ -82,7 +90,7 @@ defineProps<{
|
||||
refreshKey: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
iframeLoad: []
|
||||
iframeError: []
|
||||
refresh: []
|
||||
@@ -91,5 +99,21 @@ defineEmits<{
|
||||
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
function focusIframe() {
|
||||
iframeRef.value?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
async function handleIframeLoad() {
|
||||
emit('iframeLoad')
|
||||
await nextTick()
|
||||
requestAnimationFrame(focusIframe)
|
||||
}
|
||||
|
||||
watch(() => [props.appUrl, props.refreshKey, props.iframeBlocked], async () => {
|
||||
if (!props.appUrl || props.iframeBlocked) return
|
||||
await nextTick()
|
||||
requestAnimationFrame(focusIframe)
|
||||
}, { immediate: true })
|
||||
|
||||
defineExpose({ iframeRef })
|
||||
</script>
|
||||
|
||||
@@ -18,4 +18,14 @@ describe('appSessionConfig', () => {
|
||||
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
|
||||
expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778')
|
||||
})
|
||||
|
||||
it('keeps NetBird on the unified dashboard proxy port', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.168.1.228:8087')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'archy-electrs-ui': 50002,
|
||||
'mempool-electrs': 50002,
|
||||
'btcpay-server': 23000,
|
||||
'saleor': 9010,
|
||||
'lnd': 18083,
|
||||
'archy-lnd-ui': 18083,
|
||||
'mempool': 4080,
|
||||
@@ -71,7 +72,7 @@ export const EXTERNAL_URLS: Record<string, string> = {
|
||||
|
||||
export const APP_TITLES: Record<string, string> = {
|
||||
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
|
||||
'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
|
||||
'btcpay-server': 'BTCPay Server', 'saleor': 'Saleor', 'indeedhub': 'Indeehub',
|
||||
'botfights': 'BotFights', 'gitea': 'Gitea', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
|
||||
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
|
||||
'nginx-proxy-manager': 'Nginx Proxy Manager',
|
||||
@@ -99,7 +100,7 @@ export const NEW_TAB_APPS = new Set([
|
||||
export const IFRAME_BLOCKED_APPS = new Set<string>([])
|
||||
|
||||
/** Resolve app URL using direct port mapping (source of truth) */
|
||||
export function resolveAppUrl(id: string, routeQueryPath?: string): string {
|
||||
export function resolveAppUrl(id: string, routeQueryPath?: string, _runtimeUrl?: string): string {
|
||||
// External HTTPS apps
|
||||
const ext = EXTERNAL_URLS[id]
|
||||
if (ext) return ext
|
||||
|
||||
@@ -70,6 +70,33 @@
|
||||
@click="scrollToPage(i)"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="credentialModal.show" class="fixed inset-0 z-[2700] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6" @click.self="closeCredentialModal">
|
||||
<div class="sideload-modal credential-modal">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
|
||||
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
|
||||
</div>
|
||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">×</button>
|
||||
</div>
|
||||
<div class="credential-modal-body space-y-3">
|
||||
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyModalCredential(cred.label, cred.value)">{{ credentialModal.copied === cred.label ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-white break-all">{{ cred.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-modal-actions mt-5 flex flex-col sm:flex-row gap-3">
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg" @click="closeCredentialModal">Cancel</button>
|
||||
<button type="button" class="w-full sm:flex-1 glass-button px-4 py-3 rounded-lg font-semibold" @click="continueCredentialLaunch">Continue to app</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -77,7 +104,8 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
@@ -88,6 +116,14 @@ const serverStore = useServerStore()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
|
||||
const curatedMap = new Map(getCuratedAppList().map(a => [a.id, a]))
|
||||
const credentialModal = ref({
|
||||
show: false,
|
||||
appId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
credentials: [] as AppCredential[],
|
||||
copied: '',
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
apps: [string, PackageDataEntry][]
|
||||
@@ -118,34 +154,79 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
|
||||
return resolveAppIcon(id, pkg, curatedMap.get(id)?.icon)
|
||||
}
|
||||
|
||||
function handleTap(id: string, pkg: PackageDataEntry) {
|
||||
async function handleTap(id: string, pkg: PackageDataEntry) {
|
||||
if (canLaunch(pkg)) {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
appLauncher.open({ url: webOnlyUrl, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
if (isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
appLauncher.open({ url, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!isMobile && opensInTab(id)) {
|
||||
const appUrl = resolveRuntimeLaunchUrl(pkg) || resolveAppUrl(id)
|
||||
if (appUrl) {
|
||||
window.open(appUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
}
|
||||
appLauncher.openSession(id)
|
||||
const shown = await maybeShowCredentialsBeforeLaunch(id, pkg)
|
||||
if (shown) return
|
||||
launchNow(id, pkg)
|
||||
} else {
|
||||
emit('goToApp', id)
|
||||
}
|
||||
}
|
||||
|
||||
function launchNow(id: string, pkg: PackageDataEntry) {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
appLauncher.open({ url: webOnlyUrl, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
if (isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
appLauncher.open({ url, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!isMobile && opensInTab(id)) {
|
||||
const appUrl = resolveRuntimeLaunchUrl(pkg) || resolveAppUrl(id)
|
||||
if (appUrl) {
|
||||
window.open(appUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
}
|
||||
appLauncher.openSession(id)
|
||||
}
|
||||
|
||||
async function maybeShowCredentialsBeforeLaunch(id: string, pkg: PackageDataEntry): Promise<boolean> {
|
||||
try {
|
||||
const result = await rpcClient.call<AppCredentialsResponse>({ method: 'package.credentials', params: { app_id: id }, timeout: 5000 })
|
||||
if (!result.credentials?.length) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: result.title || `${getTitle(id, pkg)} credentials`,
|
||||
description: result.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: result.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function closeCredentialModal() { credentialModal.value.show = false }
|
||||
|
||||
function continueCredentialLaunch() {
|
||||
const id = credentialModal.value.appId
|
||||
const entry = props.apps.find(([appId]) => appId === id)
|
||||
closeCredentialModal()
|
||||
if (entry) launchNow(entry[0], entry[1])
|
||||
}
|
||||
|
||||
async function copyModalCredential(label: string, value: string) {
|
||||
try { await navigator.clipboard.writeText(value) } catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
credentialModal.value.copied = label
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollContainer.value
|
||||
if (!el) return
|
||||
@@ -160,3 +241,43 @@ function scrollToPage(index: number) {
|
||||
el.scrollTo({ left: index * el.clientWidth, behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sideload-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1.5rem 1.5rem 0 0;
|
||||
background: rgba(8, 10, 18, 0.94);
|
||||
padding: 1.25rem;
|
||||
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.sideload-close-btn {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.credential-modal-body {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.credential-modal-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.sideload-modal {
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('AppIconGrid', () => {
|
||||
expect(useAppLauncherStore().panelAppId).toBe('lnd')
|
||||
})
|
||||
|
||||
it('opens desktop new-tab apps through app session on mobile', async () => {
|
||||
it('routes desktop new-tab apps through app session on mobile', async () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
@@ -78,5 +78,6 @@ describe('AppIconGrid', () => {
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(useAppLauncherStore().panelAppId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,7 +50,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
// Known app -> category mappings (matches App Store categorisation)
|
||||
export const APP_CATEGORY_MAP: Record<string, string> = {
|
||||
'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
||||
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
|
||||
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'saleor': 'commerce',
|
||||
'fedimint': 'money', 'fedimint-gateway': 'money',
|
||||
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
||||
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
to="/dashboard/apps?tab=websites"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' || route.query.tab === 'websites' }"
|
||||
@click.prevent="router.push({ path: '/dashboard/apps', query: { tab: 'websites' } })"
|
||||
>Websites</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="showFilter = true"
|
||||
class="md:hidden fixed right-4 z-40 w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-back-btn"
|
||||
style="left: auto;"
|
||||
class="md:hidden fixed right-4 z-[2400] w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-filter-btn"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
@@ -17,10 +16,10 @@
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showFilter"
|
||||
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
||||
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
||||
@click.self="closeFilter"
|
||||
>
|
||||
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
|
||||
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-white">Filter</h2>
|
||||
<button @click="closeFilter" class="text-white/60 hover:text-white transition-colors">
|
||||
|
||||
@@ -79,6 +79,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
|
||||
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
|
||||
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
|
||||
{ id: 'saleor', title: 'Saleor', version: '3.23', category: 'commerce', description: 'Composable commerce platform with GraphQL API, dashboard, worker, mail testing, and tracing. Log in with admin@example.com; the password is stored on the node.', icon: '/assets/img/app-icons/saleor.svg', author: 'Saleor', dockerImage: 'ghcr.io/saleor/saleor:3.23', repoUrl: 'https://github.com/saleor/saleor' },
|
||||
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.svg', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
|
||||
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
|
||||
{ id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' },
|
||||
@@ -120,6 +121,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server'],
|
||||
saleor: ['saleor'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
@@ -189,7 +191,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
|
||||
const combined = `${id} ${title} ${description}`
|
||||
|
||||
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') || id.includes('lnd') || id.includes('electr') || id.includes('fedimint') || id.includes('cashu') || combined.includes('wallet')) return 'money'
|
||||
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
|
||||
if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
|
||||
if (id.includes('cloud') || id.includes('nextcloud') || id.includes('storage') || id.includes('file') || id.includes('photo') || id.includes('immich') || id.includes('jellyfin') || id.includes('media') || id.includes('vault') || combined.includes('password manager')) return 'data'
|
||||
if (id.includes('home-assistant') || id.includes('homeassistant') || combined.includes('home automation')) return 'home'
|
||||
if (id.includes('nostr') || combined.includes('nostr relay')) return 'nostr'
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="showModal = true"
|
||||
class="md:hidden fixed right-4 z-40 w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-back-btn"
|
||||
style="left: auto;"
|
||||
class="md:hidden fixed right-4 z-[2400] w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-filter-btn"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
@@ -16,10 +15,10 @@
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
||||
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
||||
@click.self="close()"
|
||||
>
|
||||
<div ref="modalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
|
||||
<div ref="modalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-white">{{ t('marketplace.filterByCategory') }}</h2>
|
||||
|
||||
@@ -47,6 +47,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
|
||||
saleor: ['saleor'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
@@ -67,7 +68,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
/** Get app tier classification (matches backend get_app_tier) */
|
||||
export function getAppTier(appId: string): string {
|
||||
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
|
||||
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
||||
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer', 'saleor']
|
||||
if (core.includes(appId)) return 'core'
|
||||
if (recommended.includes(appId)) return 'recommended'
|
||||
return 'optional'
|
||||
@@ -89,7 +90,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
|
||||
return 'money'
|
||||
}
|
||||
|
||||
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') ||
|
||||
if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') ||
|
||||
id.includes('store') || id.includes('pos') || id.includes('payment') ||
|
||||
combined.includes('merchant') || combined.includes('invoice')) {
|
||||
return 'commerce'
|
||||
@@ -157,6 +158,18 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/btcpayserver/btcpayserver'
|
||||
},
|
||||
{
|
||||
id: 'saleor',
|
||||
title: 'Saleor',
|
||||
version: '3.23',
|
||||
category: 'commerce',
|
||||
description: 'Composable commerce platform with GraphQL API, dashboard, worker, mail testing, and tracing.',
|
||||
icon: '/assets/img/app-icons/saleor.svg',
|
||||
author: 'Saleor',
|
||||
dockerImage: 'ghcr.io/saleor/saleor:3.23',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/saleor/saleor'
|
||||
},
|
||||
{
|
||||
id: 'lnd',
|
||||
title: 'LND',
|
||||
|
||||
@@ -180,6 +180,71 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.78-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.78-alpha</span>
|
||||
<span class="text-xs text-white/40">May 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Saleor's validated port 9010 dashboard/API origin settings are now ready for production release, preserving the working test-node install path.</p>
|
||||
<p>NetBird launches now stay on the unified dashboard/proxy origin at port 8087 instead of following stale server URLs on 8086.</p>
|
||||
<p>NetBird proxy routing no longer depends on a hard-coded rootless Podman gateway IP and now includes the upstream management proxy gRPC path.</p>
|
||||
<p>Mobile credential prompts keep long credential lists scrollable and the Cancel/Continue buttons reachable in both My Apps and the mobile icon grid.</p>
|
||||
<p>Android app-session popups hand external login/signup windows to the system browser instead of dropping them inside the WebView.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.76-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.76-alpha</span>
|
||||
<span class="text-xs text-white/40">May 20, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Saleor now installs on dashboard port 9010 instead of conflicting with Portainer on 9000, keeps its API on 8000, creates or repairs the admin@example.com staff account after sample data loads, and starts cleanly with the correct dashboard mount path.</p>
|
||||
<p>Apps with generated first-use credentials now show them in Archipelago before launch and in App Details. Saleor exposes copyable admin email/password fields so the dashboard login screen is no longer a dead end.</p>
|
||||
<p>NetBird API and OAuth routes now proxy through the stable host-published server port, and the embedded IdP keeps upstream-compatible signing-key refresh settings while the dashboard sends ID tokens to the API so signup no longer lands in an Unauthenticated dashboard state.</p>
|
||||
<p>Transient unnamed Podman helper containers created during app installs are hidden from My Apps, so random generated names like eager_keldysh no longer appear as applications.</p>
|
||||
<p>Mobile App Store categories are now visible as horizontal chips above the tab bar, Discover is reachable on mobile, category choices update the actual view, and apps that require a real tab open directly from the icon tap.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.75-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.75-alpha</span>
|
||||
<span class="text-xs text-white/40">May 19, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Saleor joined the App Store as a recommended commerce stack with dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger containers.</p>
|
||||
<p>NetBird repair now rewrites the unified-origin config and recreates the browser-facing proxy/dashboard while preserving existing control-plane data.</p>
|
||||
<p>Desktop dashboard scrolling hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.74-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.74-alpha</span>
|
||||
<span class="text-xs text-white/40">May 19, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>App-session right panels now re-focus the iframe after load and when the frame area is activated, so scrolling works immediately after selecting an app or switching tabs on shorter screens.</p>
|
||||
<p>NetBird now uses a unified local launch origin on port 8087 that serves the dashboard and proxies auth/API routes to the server, fixing the Unauthenticated and 404 logout/login loop.</p>
|
||||
<p>Existing NetBird installs are repaired during adopt/start by rewriting the config files and creating the missing dashboard/proxy containers while preserving data.</p>
|
||||
<p>Saleor is not in this release yet; it remains pending for separate validation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.73-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.73-alpha</span>
|
||||
<span class="text-xs text-white/40">May 19, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Mobile apps that block iframe embedding now open directly in a browser tab instead of first landing in a broken in-shell webview.</p>
|
||||
<p>App Store search covers all apps while searching, My Apps search can surface matching installable App Store entries, and mobile My Apps/Websites tab switching updates the view reliably.</p>
|
||||
<p>NetBird installs prefer a 100.x tailnet address when available, and app sessions gained iframe auto-focus plus a scroll host for right-frame scrolling.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.72-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+20
-16
@@ -1,27 +1,31 @@
|
||||
{
|
||||
"version": "1.7.72-alpha",
|
||||
"release_date": "2026-05-19",
|
||||
"version": "1.7.78-alpha",
|
||||
"release_date": "2026-05-20",
|
||||
"changelog": [
|
||||
"Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`.",
|
||||
"The follow-up release carries the NetBird install fix, Gitea icon polish, mobile app-session fallback updates, and rounder app icon masks from `v1.7.71-alpha` with the Settings modal notes included.",
|
||||
"The local Cargo lockfile version metadata is kept in sync with the release bump after the previous release build updated it."
|
||||
"Public Nginx Proxy Manager hosts for Saleor now keep browser GraphQL calls same-origin at `/graphql/` and proxy them to the local API on `8000`, fixing `Failed to fetch` when a public domain such as `noderunner.shop` was loaded from devices that cannot reach the node's private LAN/tailnet API address.",
|
||||
"Saleor's validated stack changes are now release-ready: dashboard origins on port `9010` are explicitly allowed for dashboard/API calls, preserving the working test-node install path for production nodes.",
|
||||
"NetBird launches now stay pinned to the unified dashboard/proxy origin on port `8087` instead of following stale runtime-discovered server URLs on `8086`.",
|
||||
"NetBird's local nginx proxy now routes browser API, OAuth, relay, and WebSocket traffic through `host.containers.internal:8086` instead of a hard-coded rootless Podman gateway IP, and includes the upstream `management.ProxyService` gRPC path.",
|
||||
"The mobile credentials interstitial now keeps credential lists scrollable and action buttons reachable in both My Apps and the mobile app icon grid.",
|
||||
"Android WebView popup windows now hand external popup URLs to the system browser, covering app login/signup flows that open secondary windows.",
|
||||
"Validation passed with `git diff --check`, `cargo check -p archipelago`, and the focused `npm test -- src/views/appSession/__tests__/appSessionConfig.test.ts` suite."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.72-alpha",
|
||||
"new_version": "1.7.72-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.72-alpha/archipelago",
|
||||
"sha256": "f0feae864d58b0204077e9559217f51c07e270f31c45c6ee05d7bf3086477c7e",
|
||||
"size_bytes": 42990584
|
||||
"current_version": "1.7.78-alpha",
|
||||
"new_version": "1.7.78-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.78-alpha/archipelago",
|
||||
"sha256": "49ac959035029fb77cbf001cfabbe7919bc74aee3bb7176067c77ab0d1c97b58",
|
||||
"size_bytes": 43069480
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.72-alpha.tar.gz",
|
||||
"current_version": "1.7.72-alpha",
|
||||
"new_version": "1.7.72-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.72-alpha/archipelago-frontend-1.7.72-alpha.tar.gz",
|
||||
"sha256": "26e26be4f804685694e5885cf4434ae0ecf0d24c4a5cebd32a81239be1517789",
|
||||
"size_bytes": 166481709
|
||||
"name": "archipelago-frontend-1.7.78-alpha.tar.gz",
|
||||
"current_version": "1.7.78-alpha",
|
||||
"new_version": "1.7.78-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.78-alpha/archipelago-frontend-1.7.78-alpha.tar.gz",
|
||||
"sha256": "a11ed587122fe2150c7439fdb88bfb4f3b999de2c52855bb4ac860b237854943",
|
||||
"size_bytes": 166486679
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+20
-16
@@ -1,27 +1,31 @@
|
||||
{
|
||||
"version": "1.7.72-alpha",
|
||||
"release_date": "2026-05-19",
|
||||
"version": "1.7.78-alpha",
|
||||
"release_date": "2026-05-20",
|
||||
"changelog": [
|
||||
"Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`.",
|
||||
"The follow-up release carries the NetBird install fix, Gitea icon polish, mobile app-session fallback updates, and rounder app icon masks from `v1.7.71-alpha` with the Settings modal notes included.",
|
||||
"The local Cargo lockfile version metadata is kept in sync with the release bump after the previous release build updated it."
|
||||
"Public Nginx Proxy Manager hosts for Saleor now keep browser GraphQL calls same-origin at `/graphql/` and proxy them to the local API on `8000`, fixing `Failed to fetch` when a public domain such as `noderunner.shop` was loaded from devices that cannot reach the node's private LAN/tailnet API address.",
|
||||
"Saleor's validated stack changes are now release-ready: dashboard origins on port `9010` are explicitly allowed for dashboard/API calls, preserving the working test-node install path for production nodes.",
|
||||
"NetBird launches now stay pinned to the unified dashboard/proxy origin on port `8087` instead of following stale runtime-discovered server URLs on `8086`.",
|
||||
"NetBird's local nginx proxy now routes browser API, OAuth, relay, and WebSocket traffic through `host.containers.internal:8086` instead of a hard-coded rootless Podman gateway IP, and includes the upstream `management.ProxyService` gRPC path.",
|
||||
"The mobile credentials interstitial now keeps credential lists scrollable and action buttons reachable in both My Apps and the mobile app icon grid.",
|
||||
"Android WebView popup windows now hand external popup URLs to the system browser, covering app login/signup flows that open secondary windows.",
|
||||
"Validation passed with `git diff --check`, `cargo check -p archipelago`, and the focused `npm test -- src/views/appSession/__tests__/appSessionConfig.test.ts` suite."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.72-alpha",
|
||||
"new_version": "1.7.72-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.72-alpha/archipelago",
|
||||
"sha256": "f0feae864d58b0204077e9559217f51c07e270f31c45c6ee05d7bf3086477c7e",
|
||||
"size_bytes": 42990584
|
||||
"current_version": "1.7.78-alpha",
|
||||
"new_version": "1.7.78-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.78-alpha/archipelago",
|
||||
"sha256": "49ac959035029fb77cbf001cfabbe7919bc74aee3bb7176067c77ab0d1c97b58",
|
||||
"size_bytes": 43069480
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.72-alpha.tar.gz",
|
||||
"current_version": "1.7.72-alpha",
|
||||
"new_version": "1.7.72-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.72-alpha/archipelago-frontend-1.7.72-alpha.tar.gz",
|
||||
"sha256": "26e26be4f804685694e5885cf4434ae0ecf0d24c4a5cebd32a81239be1517789",
|
||||
"size_bytes": 166481709
|
||||
"name": "archipelago-frontend-1.7.78-alpha.tar.gz",
|
||||
"current_version": "1.7.78-alpha",
|
||||
"new_version": "1.7.78-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.78-alpha/archipelago-frontend-1.7.78-alpha.tar.gz",
|
||||
"sha256": "a11ed587122fe2150c7439fdb88bfb4f3b999de2c52855bb4ac860b237854943",
|
||||
"size_bytes": 166486679
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -50,9 +50,18 @@ PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:latest"
|
||||
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
|
||||
NETBIRD_DASHBOARD_IMAGE="docker.io/netbirdio/dashboard:v2.38.0"
|
||||
NETBIRD_SERVER_IMAGE="docker.io/netbirdio/netbird-server:0.71.2"
|
||||
NETBIRD_PROXY_IMAGE="docker.io/library/nginx:1.27-alpine"
|
||||
ALPINE_TOR_IMAGE="$ARCHY_REGISTRY/alpine-tor:0.4.8.13"
|
||||
ADGUARDHOME_IMAGE="$ARCHY_REGISTRY/adguardhome:v0.107.55"
|
||||
|
||||
# Saleor stack
|
||||
SALEOR_API_IMAGE="ghcr.io/saleor/saleor:3.23"
|
||||
SALEOR_DASHBOARD_IMAGE="ghcr.io/saleor/saleor-dashboard:3.23"
|
||||
SALEOR_POSTGRES_IMAGE="docker.io/library/postgres:15-alpine"
|
||||
SALEOR_VALKEY_IMAGE="docker.io/valkey/valkey:8.1-alpine"
|
||||
SALEOR_JAEGER_IMAGE="docker.io/jaegertracing/jaeger:latest"
|
||||
SALEOR_MAILPIT_IMAGE="docker.io/axllent/mailpit:latest"
|
||||
|
||||
# Fedimint
|
||||
FEDIMINT_IMAGE="$ARCHY_REGISTRY/fedimintd:v0.10.0"
|
||||
FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0"
|
||||
|
||||
@@ -58,6 +58,32 @@ for row in rows:
|
||||
# NPM containers use this name to reach host-published services; host nginx
|
||||
# itself should use loopback for the same services.
|
||||
nginx_host = "127.0.0.1" if host == "host.containers.internal" else host
|
||||
try:
|
||||
forward_port = int(port)
|
||||
except (TypeError, ValueError):
|
||||
forward_port = None
|
||||
is_saleor = forward_port == 9010
|
||||
graphql_location = ""
|
||||
saleor_proxy_headers = ""
|
||||
if is_saleor:
|
||||
graphql_location = """
|
||||
location ^~ /graphql/ {
|
||||
proxy_pass http://127.0.0.1:8000/graphql/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host 127.0.0.1;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Scheme https;
|
||||
proxy_set_header Origin "";
|
||||
}
|
||||
"""
|
||||
saleor_proxy_headers = """
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html;
|
||||
sub_filter '</head>' '<script>if(window.__SALEOR_CONFIG__){window.__SALEOR_CONFIG__.API_URL=location.origin+"/graphql/";window.__SALEOR_CONFIG__.EXTENSIONS_API_URL=location.origin+"/graphql/";}</script></head>';
|
||||
"""
|
||||
|
||||
print(f"""
|
||||
server {{
|
||||
@@ -81,6 +107,8 @@ server {{
|
||||
ssl_certificate {cert};
|
||||
ssl_certificate_key {key};
|
||||
|
||||
{graphql_location}
|
||||
|
||||
location / {{
|
||||
proxy_pass {scheme}://{nginx_host}:{port};
|
||||
proxy_http_version 1.1;
|
||||
@@ -91,6 +119,7 @@ server {{
|
||||
proxy_set_header X-Forwarded-Scheme https;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
{saleor_proxy_headers}
|
||||
}}
|
||||
}}
|
||||
""")
|
||||
|
||||
Reference in New Issue
Block a user