Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ docker-compose.override.yml

# OS
.DS_Store
*.tsbuildinfo
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Files written from a Mac over SMB, visible immediately in the browser.
- [Why sharing is opt-in](#why-sharing-is-opt-in)
- [How SMB sharing works under the hood](#how-smb-sharing-works-under-the-hood)
- [Connecting from your devices](#connecting-from-your-devices)
- [Apps launcher](#apps-launcher)
- [Backing up](#backing-up)
- [Migrating from CasaOS](#migrating-from-casaos)
- [Stack choice](#stack-choice)
Expand Down Expand Up @@ -80,6 +81,11 @@ exported, with one-click **Unshare** that genuinely revokes access.
Each is created with `nologin` and no home directory, so an SMB credential can
never become a shell login.

**Apps.** A Homer-style launcher page listing links to anything on your
network — other Docker Compose apps, your router's admin page, whatever. List
or grid view. There is deliberately no "add app" button in the UI — see
[Apps launcher](#apps-launcher).

---

## Quick start
Expand Down Expand Up @@ -377,6 +383,45 @@ older phone SMB clients.

---

## Apps launcher

A **Homer**-style launcher page in the sidebar: a list or grid of links to
anything on your network — other Docker Compose apps, your router's admin
page, a Proxmox host, whatever. This is not a Docker integration and doesn't
inspect what's actually running; it's just a bookmarks page with icons.

### There is no "add app" button, on purpose

Every other page in this dashboard treats configuration as UI state, but this
one is a plain YAML file at `./data/apps.yml` — the same host directory as
everything else persistent. That's deliberate: this list is exactly the kind
of thing you set up once and barely touch, and a flat file is easier to back
up, script, or hand-edit over SSH than a form ever needs to be.

```yaml
apps:
- name: Portainer
icon: "🐳"
url: http://192.168.1.10:9000
- name: Plex
icon: https://raw.githubusercontent.com/plexinc/pms-docker/master/plex.png
url: http://192.168.1.10:32400/web
- name: Router Admin
url: http://192.168.1.1
```

- `name` and `url` are required; an entry missing either is skipped (not
fatal to the rest of the file).
- `icon` is optional. Either an emoji/character, or a URL to an image —
anything starting with `http://` or `https://` is rendered as an `<img>`.
With no icon, a generic 🧩 is shown.
- The file is read fresh on every page load — **no restart needed**. Edit it,
refresh the browser.
- Invalid YAML is reported on the page itself, with the exact parser error
and the path it looked at, rather than silently showing nothing.

---

## Backing up

`./data` is the only thing here you cannot regenerate. It holds the share
Expand Down
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from . import auth
from .config import (ADMIN_PASSWORD, AUTH_ENABLED, DATA_DIR, HOST_PROC,
SMB_SHARES_DIR, STORAGE_ROOT)
from .routes import apps as apps_routes
from .routes import auth_routes, files, shares, system, users
from .services import hostproc, samba, smbusers

Expand All @@ -39,6 +40,7 @@
app.include_router(files.router)
app.include_router(shares.router)
app.include_router(users.router)
app.include_router(apps_routes.router)


@app.on_event("startup")
Expand Down
17 changes: 17 additions & 0 deletions backend/app/routes/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Local app launcher, configured entirely by a YAML file on disk."""
from __future__ import annotations

import asyncio

from fastapi import APIRouter, Depends

from ..auth import require_auth
from ..services import apps

router = APIRouter(prefix="/api/apps", tags=["apps"],
dependencies=[Depends(require_auth)])


@router.get("")
async def get_apps() -> dict:
return await asyncio.to_thread(apps.list_apps)
70 changes: 70 additions & 0 deletions backend/app/services/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""A local, YAML-configured app launcher, in the spirit of Homer.

Nothing here is added or edited through the UI on purpose -- the source of
truth is one file at DATA_DIR/apps.yml, edited directly on the host. It is
read fresh on every request rather than cached, so an edit takes effect on
the next browser refresh with no container restart needed.
"""
from __future__ import annotations

import logging
from typing import Any, Optional

import yaml

from ..config import DATA_DIR

log = logging.getLogger(__name__)

APPS_CONFIG = DATA_DIR / "apps.yml"

EXAMPLE = """\
apps:
- name: Portainer
icon: "\U0001F433"
url: http://192.168.1.10:9000
- name: Plex
icon: https://raw.githubusercontent.com/plexinc/pms-docker/master/plex.png
url: http://192.168.1.10:32400/web
"""


def _validate(entry: Any, index: int) -> Optional[dict[str, str]]:
if not isinstance(entry, dict):
log.warning("apps.yml entry %d is not a mapping, skipping", index)
return None
name = str(entry.get("name") or "").strip()
url = str(entry.get("url") or "").strip()
icon = str(entry.get("icon") or "").strip()
if not name or not url:
log.warning("apps.yml entry %d is missing name or url, skipping", index)
return None
return {"name": name, "url": url, "icon": icon}


def list_apps() -> dict[str, Any]:
if not APPS_CONFIG.exists():
return {"apps": [], "exists": False, "error": None,
"config_path": str(APPS_CONFIG), "example": EXAMPLE}

try:
raw = yaml.safe_load(APPS_CONFIG.read_text())
except yaml.YAMLError as exc:
return {"apps": [], "exists": True,
"error": f"apps.yml is not valid YAML: {exc}",
"config_path": str(APPS_CONFIG), "example": EXAMPLE}

entries = raw.get("apps") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return {"apps": [], "exists": True,
"error": "apps.yml must have a top-level 'apps:' list",
"config_path": str(APPS_CONFIG), "example": EXAMPLE}

apps: list[dict[str, str]] = []
for i, entry in enumerate(entries):
validated = _validate(entry, i)
if validated:
apps.append(validated)

return {"apps": apps, "exists": True, "error": None,
"config_path": str(APPS_CONFIG), "example": EXAMPLE}
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ python-multipart==0.0.20
pydantic==2.10.5
itsdangerous==2.2.0
passlib[bcrypt]==1.7.4
PyYAML==6.0.2
110 changes: 110 additions & 0 deletions backend/tests/test_apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""The apps.yml launcher: nothing here is editable through the UI, so the
file-parsing behavior IS the feature -- this is what actually decides
whether a person's homer-style config shows up correctly."""
import pytest

from app.services import apps


@pytest.fixture()
def config(tmp_path, monkeypatch):
path = tmp_path / "apps.yml"
monkeypatch.setattr(apps, "APPS_CONFIG", path)
return path


def test_no_file_yet_is_not_an_error(config):
result = apps.list_apps()
assert result["exists"] is False
assert result["error"] is None
assert result["apps"] == []


def test_a_normal_config_parses(config):
config.write_text("""
apps:
- name: Portainer
icon: "🐳"
url: http://192.168.1.10:9000
- name: Plex
icon: https://example.com/plex.png
url: http://192.168.1.10:32400/web
""")
result = apps.list_apps()
assert result["exists"] is True
assert result["error"] is None
assert result["apps"] == [
{"name": "Portainer", "icon": "🐳", "url": "http://192.168.1.10:9000"},
{"name": "Plex", "icon": "https://example.com/plex.png",
"url": "http://192.168.1.10:32400/web"},
]


def test_icon_is_optional(config):
config.write_text("apps:\n - name: Router\n url: http://192.168.1.1\n")
result = apps.list_apps()
assert result["apps"] == [{"name": "Router", "icon": "", "url": "http://192.168.1.1"}]


def test_an_entry_missing_url_is_skipped_not_fatal(config):
config.write_text("""
apps:
- name: Broken entry
- name: Good entry
url: http://192.168.1.20
""")
result = apps.list_apps()
assert result["error"] is None
assert [a["name"] for a in result["apps"]] == ["Good entry"]


def test_an_entry_missing_name_is_skipped_not_fatal(config):
config.write_text("""
apps:
- url: http://192.168.1.30
- name: Good entry
url: http://192.168.1.20
""")
result = apps.list_apps()
assert [a["name"] for a in result["apps"]] == ["Good entry"]


def test_a_non_mapping_entry_is_skipped_not_fatal(config):
config.write_text("apps:\n - just a string\n - name: Good\n url: http://x\n")
result = apps.list_apps()
assert [a["name"] for a in result["apps"]] == ["Good"]


def test_malformed_yaml_reports_an_error_rather_than_crashing(config):
config.write_text("apps: [this is not: valid: yaml")
result = apps.list_apps()
assert result["exists"] is True
assert result["apps"] == []
assert "not valid YAML" in result["error"]


def test_missing_top_level_apps_key_reports_an_error(config):
config.write_text("something_else:\n - foo\n")
result = apps.list_apps()
assert result["apps"] == []
assert "apps:" in result["error"]


def test_apps_key_that_is_not_a_list_reports_an_error(config):
config.write_text("apps: just a string\n")
result = apps.list_apps()
assert result["apps"] == []
assert result["error"] is not None


def test_empty_apps_list_is_not_an_error(config):
config.write_text("apps: []\n")
result = apps.list_apps()
assert result["error"] is None
assert result["apps"] == []


def test_names_and_urls_are_whitespace_trimmed(config):
config.write_text('apps:\n - name: " Spacey "\n url: " http://x "\n')
result = apps.list_apps()
assert result["apps"] == [{"name": "Spacey", "icon": "", "url": "http://x"}]
3 changes: 3 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { api } from './lib/api'
import { ToastHost } from './components/Toast'
import { Apps } from './pages/Apps'
import { Dashboard } from './pages/Dashboard'
import { Files } from './pages/Files'
import { Shares } from './pages/Shares'
Expand All @@ -15,6 +16,7 @@ const NAV = [
{ to: '/files', label: 'Files', icon: '🗂️', end: false },
{ to: '/shares', label: 'Shares', icon: '🔗', end: false },
{ to: '/users', label: 'Users', icon: '👥', end: false },
{ to: '/apps', label: 'Apps', icon: '🧩', end: false },
]

export default function App() {
Expand Down Expand Up @@ -114,6 +116,7 @@ export default function App() {
<Route path="/files/*" element={<Files onMenu={() => setMenuOpen(true)} />} />
<Route path="/shares" element={<Shares onMenu={() => setMenuOpen(true)} />} />
<Route path="/users" element={<Users onMenu={() => setMenuOpen(true)} />} />
<Route path="/apps" element={<Apps onMenu={() => setMenuOpen(true)} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,15 @@ export interface User {

// ---------------------------------------------------------------- endpoints

export interface AppLink { name: string; icon: string; url: string }
export interface AppsResponse {
apps: AppLink[]; exists: boolean; error: string | null
config_path: string; example: string
}

export const api = {
authStatus: () => get<{ auth_required: boolean; configured: boolean; authenticated: boolean }>('/api/auth/status'),
apps: () => get<AppsResponse>('/api/apps'),
login: (password: string) => post<{ authenticated: boolean }>('/api/auth/login', { password }),
logout: () => post<{ authenticated: boolean }>('/api/auth/logout'),

Expand Down
Loading
Loading