fix(apps): stabilize saleor and netbird launch

This commit is contained in:
archipelago 2026-05-19 21:45:17 -04:00
parent 7b2f4cb05f
commit 92c58141af
19 changed files with 243 additions and 70 deletions

View File

@ -1,5 +1,14 @@
# Changelog
## 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.

View File

@ -76,9 +76,9 @@
"dockerImage": "ghcr.io/saleor/saleor:3.23",
"repoUrl": "https://github.com/saleor/saleor",
"containerConfig": {
"ports": ["9000:80", "8000:8000", "8025:8025", "16686:16686"],
"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 9000, API on 8000, Mailpit on 8025, and Jaeger on 16686. Supporting containers include PostgreSQL, Valkey, Celery worker, and services required by Saleor."
"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."
}
},
{

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.75-alpha"
version = "1.7.76-alpha"
dependencies = [
"anyhow",
"archipelago-container",

View File

@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.75-alpha"
version = "1.7.76-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]

View File

@ -1080,7 +1080,7 @@ pub(super) async fn get_app_config(
None,
),
"saleor" => (
vec!["9000:80".to_string(), "8000:8000".to_string()],
vec!["9010:80".to_string(), "8000:8000".to_string()],
vec!["/var/lib/archipelago/saleor:/app/media".to_string()],
vec![],
None,

View File

@ -167,6 +167,34 @@ async fn repair_netbird_unified_origin() {
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",
@ -200,32 +228,6 @@ async fn repair_netbird_unified_origin() {
])
.output()
.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;
}
async fn repair_saleor_network_aliases() {
@ -1736,8 +1738,9 @@ impl RpcHandler {
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_url = format!("http://{}:9000/", host_ip);
let dashboard_url = format!("http://{}:9010/", host_ip);
let api_url = format!("http://{}:8000/graphql/", host_ip);
let allowed_hosts = format!("localhost,127.0.0.1,api,saleor-api,{}", host_ip);
let database_url = format!("postgres://saleor:{}@db/saleor", db_pass);
@ -1797,8 +1800,6 @@ impl RpcHandler {
"--health-cmd=valkey-cli ping || exit 1",
"--health-interval=30s",
"--health-retries=3",
"-v",
"/var/lib/archipelago/saleor-cache:/data",
SALEOR_VALKEY_IMAGE,
]);
run_required_stack_command("saleor", "create cache", &mut cache_cmd).await?;
@ -1928,6 +1929,30 @@ impl RpcHandler {
}
}
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([
"-e",
"DJANGO_SUPERUSER_EMAIL=admin@example.com",
"-e",
&format!("DJANGO_SUPERUSER_PASSWORD={}", admin_pass),
SALEOR_API_IMAGE,
"python3",
"manage.py",
"createsuperuser",
"--noinput",
]);
run_required_stack_command("saleor", "create 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",
@ -2005,11 +2030,17 @@ impl RpcHandler {
"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",
"9000:80",
"9010:80",
"-e",
&format!("API_URL={}", api_url),
"-e",
@ -2128,6 +2159,11 @@ LETSENCRYPT_DOMAIN=none
listen 80;
server_name _;
# Route 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.
set $netbird_server http://169.254.1.2:8086;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -2135,14 +2171,14 @@ LETSENCRYPT_DOMAIN=none
proxy_http_version 1.1;
location ~ ^/(relay|ws-proxy/) {{
proxy_pass http://netbird-server:80;
proxy_pass $netbird_server;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1d;
}}
location ~ ^/(api|oauth2)(/|$) {{
proxy_pass http://netbird-server:80;
proxy_pass $netbird_server;
}}
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService)/ {{

View File

@ -147,7 +147,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
@ -497,7 +499,7 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
},
"saleor" => AppMetadata {
title: "Saleor".to_string(),
description: "Composable commerce platform with GraphQL API and dashboard".to_string(),
description: "Composable commerce platform with GraphQL API and dashboard. Admin email: admin@example.com; 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: "",
@ -688,6 +690,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) {

View File

@ -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,
};

View File

@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.75-alpha",
"version": "1.7.76-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.75-alpha",
"version": "1.7.76-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",

View File

@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.75-alpha",
"version": "1.7.76-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",

View File

@ -76,9 +76,9 @@
"dockerImage": "ghcr.io/saleor/saleor:3.23",
"repoUrl": "https://github.com/saleor/saleor",
"containerConfig": {
"ports": ["9000:80", "8000:8000", "8025:8025", "16686:16686"],
"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 9000, API on 8000, Mailpit on 8025, and Jaeger on 16686. Supporting containers include PostgreSQL, Valkey, Celery worker, and services required by Saleor."
"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."
}
},
{

View File

@ -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

View File

@ -1668,6 +1668,36 @@ html:has(body.video-background-active)::before {
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 {

View File

@ -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() {

View File

@ -33,12 +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"
@ -167,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>
@ -191,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'
@ -228,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 } })
}

View File

@ -35,12 +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"
@ -100,11 +115,6 @@
</div>
<!-- End Scrollable Apps Section -->
<MarketplaceFilterModal
:categories="categoriesWithApps"
:selected-category="selectedCategory"
@select="selectCategory"
/>
</div>
</template>
@ -113,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'
@ -123,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,
@ -170,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('')

View File

@ -14,7 +14,7 @@ export const APP_PORTS: Record<string, number> = {
'archy-electrs-ui': 50002,
'mempool-electrs': 50002,
'btcpay-server': 23000,
'saleor': 9000,
'saleor': 9010,
'lnd': 18083,
'archy-lnd-ui': 18083,
'mempool': 4080,
@ -100,7 +100,9 @@ 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 {
if (id === 'netbird' && runtimeUrl) return runtimeUrl
// External HTTPS apps
const ext = EXTERNAL_URLS[id]
if (ext) return ext

View File

@ -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',

View File

@ -180,6 +180,30 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- 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, and starts cleanly in rootless Podman with the nginx capabilities it needs.</p>
<p>NetBird API and OAuth routes now proxy through the stable host-published server port, so login/logout routes survive a netbird-server restart without 502s from stale Podman DNS.</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">