chore: release 26.7-alpha
Some checks failed
Deploy site / deploy (push) Waiting to run
Build ISO / build-iso (push) Has been cancelled
CI / lint (push) Successful in 1m26s
CI / test (push) Successful in 1m18s
CI / validate-json (push) Successful in 52s
CI / markdown-links (push) Successful in 27s
Release / release (push) Has been cancelled

Ships the open_url manifest field + the Open button in /apps and on
the landing page, replacing the fileshare-only hardcoded deep-link
with a generalised {host}-templated URL. Fileshare seed manifest
bumps to 0.1.2; the furtka-apps catalog release that goes with this
adds matching open_url values for fileshare + uptime-kuma.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-20 15:44:01 +02:00
parent 018f2e20b0
commit 5d8ac63d9f
11 changed files with 74 additions and 14 deletions

View file

@ -7,6 +7,16 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased] ## [Unreleased]
## [26.7-alpha] - 2026-04-20
### Added
- **Manifest `open_url` field + Open button in `/apps` and on the landing page.** Apps declare a URL template (e.g. `smb://{host}/files` for fileshare, `http://{host}:3001/` for Uptime Kuma); the UI substitutes `{host}` with the current browser's hostname at render time so the link follows however the user reached Furtka (furtka.local, raw IP, a future reverse-proxy hostname). The landing page's hardcoded `if app.name === 'fileshare'` special-case is gone — any app with an `open_url` in its manifest now gets a proper "Open" link. The core seed `apps/fileshare/manifest.json` bumps to v0.1.2 to carry it.
### Changed
- `.btn` CSS class introduced so an `<a>` rendered-as-button lines up with its `<button>` siblings in `.buttons`. Needed because "Open" is a real link (middle-click, copy URL, screen readers) and HTML doesn't let `<button>` carry `href`.
## [26.6-alpha] - 2026-04-20 ## [26.6-alpha] - 2026-04-20
### Added ### Added
@ -114,7 +124,8 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
- **Containers:** Docker + Compose - **Containers:** Docker + Compose
- **License:** AGPL-3.0 - **License:** AGPL-3.0
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.6-alpha...HEAD [Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.7-alpha...HEAD
[26.7-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.7-alpha
[26.6-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.6-alpha [26.6-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.6-alpha
[26.5-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.5-alpha [26.5-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.5-alpha
[26.4-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.4-alpha [26.4-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.4-alpha

View file

@ -1,12 +1,13 @@
{ {
"name": "fileshare", "name": "fileshare",
"display_name": "Network Files", "display_name": "Network Files",
"version": "0.1.1", "version": "0.1.2",
"description": "SMB share for Mac, Windows, Linux and Android devices on the LAN.", "description": "SMB share for Mac, Windows, Linux and Android devices on the LAN.",
"description_long": "Alle Geräte im WLAN sehen einen gemeinsamen Ordner. Funktioniert mit Windows, Mac, Linux und Android. Verbinden zu smb://furtka.local — Anmeldung mit dem hier gesetzten Benutzernamen und Passwort.", "description_long": "Alle Geräte im WLAN sehen einen gemeinsamen Ordner. Funktioniert mit Windows, Mac, Linux und Android. Verbinden zu smb://furtka.local — Anmeldung mit dem hier gesetzten Benutzernamen und Passwort.",
"volumes": ["files"], "volumes": ["files"],
"ports": [445, 139], "ports": [445, 139],
"icon": "icon.svg", "icon": "icon.svg",
"open_url": "smb://{host}/files",
"settings": [ "settings": [
{ {
"name": "SMB_USER", "name": "SMB_USER",

View file

@ -92,11 +92,15 @@
} }
function primaryAction(app) { function primaryAction(app) {
// Only fileshare has a direct "open" link today. Future apps with // open_url is a manifest-declared template with a `{host}`
// HTTP endpoints would surface a URL here; everything else falls // placeholder — substituted against the current browser's
// back to the /apps manage page. // hostname so smb://host/files and http://host:3001/ both
if (app.name === 'fileshare' && HOSTNAME) { // follow however the user reached Furtka (furtka.local, raw
return { href: `smb://${HOSTNAME}.local/files`, label: 'Open files' }; // IP, a future reverse-proxy hostname). Apps without a
// frontend fall back to /apps for management.
if (app.open_url) {
const host = HOSTNAME || location.hostname;
return { href: app.open_url.replace('{host}', host), label: 'Open' };
} }
return { href: '/apps', label: 'Manage →' }; return { href: '/apps', label: 'Manage →' };
} }

View file

@ -198,7 +198,7 @@ h2 {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
} }
button { button, .btn {
background: var(--accent); background: var(--accent);
border: none; border: none;
color: var(--bg); color: var(--bg);
@ -209,15 +209,21 @@ button {
white-space: nowrap; white-space: nowrap;
font-size: 0.9rem; font-size: 0.9rem;
font-family: inherit; font-family: inherit;
/* Anchor rendered-as-button: strip underline + keep the button's
rectangular hit area. `display: inline-flex` so an <a class="btn">
lines up vertically with its <button> siblings in .buttons. */
text-decoration: none;
display: inline-flex;
align-items: center;
} }
button.secondary { button.secondary, .btn.secondary {
background: var(--card); background: var(--card);
color: var(--fg); color: var(--fg);
border: 1px solid var(--border); border: 1px solid var(--border);
} }
button.danger { background: var(--danger); color: #fff; } button.danger { background: var(--danger); color: #fff; }
button:disabled { opacity: 0.5; cursor: wait; } button:disabled { opacity: 0.5; cursor: wait; }
button:focus-visible { outline: none; box-shadow: var(--ring); } button:focus-visible, .btn:focus-visible { outline: none; box-shadow: var(--ring); }
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; } .empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }
.catalog-row { .catalog-row {
display: flex; display: flex;

View file

@ -248,6 +248,14 @@ async function refresh() {
document.getElementById('installed').innerHTML = installed.length document.getElementById('installed').innerHTML = installed.length
? installed.map(a => { ? installed.map(a => {
const hasSettings = a.has_settings; const hasSettings = a.has_settings;
const openHref = a.open_url ? a.open_url.replace('{host}', location.hostname) : '';
// Plain <a> rendered as a button so it behaves like a real link
// (middle-click, right-click "copy link", screen readers) instead
// of a JS onclick. Most installed apps will want this fileshare
// deep-links to smb://, Kuma to http://host:3001/.
const openBtn = openHref
? `<a class="btn" href="${esc(openHref)}" target="_blank" rel="noopener">Open</a>`
: '';
return ` return `
<div class="app"> <div class="app">
<div class="left"> <div class="left">
@ -258,6 +266,7 @@ async function refresh() {
</div> </div>
</div> </div>
<div class="buttons"> <div class="buttons">
${openBtn}
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''} ${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''}
<button class="secondary" data-op="update" data-name="${esc(a.name)}">Update</button> <button class="secondary" data-op="update" data-name="${esc(a.name)}">Update</button>
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button> <button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
@ -387,6 +396,9 @@ def _manifest_summary(m, app_dir=None):
"icon": m.icon, "icon": m.icon,
"icon_svg": _read_icon_svg(app_dir, m.icon), "icon_svg": _read_icon_svg(app_dir, m.icon),
"has_settings": bool(m.settings), "has_settings": bool(m.settings),
# Optional template URL with `{host}` placeholder; frontend
# substitutes against location.hostname at render time.
"open_url": m.open_url,
} }

View file

@ -42,6 +42,12 @@ class Manifest:
icon: str icon: str
description_long: str = "" description_long: str = ""
settings: tuple[Setting, ...] = field(default_factory=tuple) settings: tuple[Setting, ...] = field(default_factory=tuple)
# Optional "Open" link for the landing page + installed-app row.
# `{host}` is substituted with the current browser hostname at render
# time so the URL follows whatever the user typed to reach Furtka —
# furtka.local, a raw IP, a future reverse-proxy hostname. Apps with
# no frontend (CLI-only, background workers) leave this empty.
open_url: str = ""
def volume_name(self, short: str) -> str: def volume_name(self, short: str) -> str:
# Namespace volume names so two apps can each declare e.g. "data" # Namespace volume names so two apps can each declare e.g. "data"
@ -127,6 +133,10 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
settings = _parse_settings(raw.get("settings"), path) settings = _parse_settings(raw.get("settings"), path)
open_url_raw = raw.get("open_url", "")
if not isinstance(open_url_raw, str):
raise ManifestError(f"{path}: open_url must be a string if set")
return Manifest( return Manifest(
name=name, name=name,
display_name=str(raw["display_name"]), display_name=str(raw["display_name"]),
@ -137,4 +147,5 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
icon=str(raw["icon"]), icon=str(raw["icon"]),
description_long=str(raw.get("description_long", "")), description_long=str(raw.get("description_long", "")),
settings=settings, settings=settings,
open_url=open_url_raw,
) )

View file

@ -1,6 +1,6 @@
[project] [project]
name = "furtka" name = "furtka"
version = "26.6-alpha" version = "26.7-alpha"
description = "Open-source home server OS — simple enough for everyone." description = "Open-source home server OS — simple enough for everyone."
requires-python = ">=3.11" requires-python = ">=3.11"
readme = "README.md" readme = "README.md"

View file

@ -95,6 +95,21 @@ def test_settings_optional_default_empty(tmp_path):
m = load_manifest(path) m = load_manifest(path)
assert m.settings == () assert m.settings == ()
assert m.description_long == "" assert m.description_long == ""
assert m.open_url == ""
def test_open_url_stored_when_present(tmp_path):
payload = dict(VALID_MANIFEST, open_url="smb://{host}/files")
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert m.open_url == "smb://{host}/files"
def test_open_url_non_string_rejected(tmp_path):
payload = dict(VALID_MANIFEST, open_url=42)
path = _write_app(tmp_path, "fileshare", payload)
with pytest.raises(ManifestError, match="open_url"):
load_manifest(path)
def test_settings_parsed(tmp_path): def test_settings_parsed(tmp_path):

View file

@ -1,7 +1,7 @@
--- ---
title: "Furtka" title: "Furtka"
description: "Offenes Heimserver-Betriebssystem — einfach genug für alle." description: "Offenes Heimserver-Betriebssystem — einfach genug für alle."
status: "<span class=\"mono\">26.6-alpha</span> — in Arbeit" status: "<span class=\"mono\">26.7-alpha</span> — in Arbeit"
--- ---
**Furtka** ist ein offenes Heimserver-Betriebssystem. **Furtka** ist ein offenes Heimserver-Betriebssystem.

View file

@ -1,7 +1,7 @@
--- ---
title: "Furtka" title: "Furtka"
description: "Open-source home server OS — simple enough for everyone." description: "Open-source home server OS — simple enough for everyone."
status: "<span class=\"mono\">26.6-alpha</span> — work in progress" status: "<span class=\"mono\">26.7-alpha</span> — work in progress"
--- ---
**Furtka** is an open-source home server OS. **Furtka** is an open-source home server OS.

View file

@ -6,7 +6,7 @@ enableRobotsTXT = true
[params] [params]
description = "Open-source home server OS — simple enough for everyone." description = "Open-source home server OS — simple enough for everyone."
version = "26.6-alpha" version = "26.7-alpha"
contactEmail = "hallo@furtka.org" contactEmail = "hallo@furtka.org"
[markup.goldmark.renderer] [markup.goldmark.renderer]