8 Commits

7 changed files with 180 additions and 44 deletions

25
CHANGELOG.md Normal file
View File

@@ -0,0 +1,25 @@
# Changelog
All notable changes to this repository will be documented in this file.
This project uses the following sections:
- Added
- Changed
- Fixed
- Removed
- Security
Version bumps and release sections will be created only when explicitly requested
(e.g. "make a new version" / "make v0.2.0 of Bahmcloud Store").
---
## [Unreleased]
### Added
- Changelog file for the Bahmcloud Store repository.
### Notes
- This repository contains the Home Assistant custom component (store UI + API).
- The Supervisor add-on (installer) is maintained in a separate repository.
- Public store index (`store.yaml`) is maintained in a separate repository.

View File

@@ -1,3 +1,3 @@
# bahmcloud_store # bahmcloud_store
Bahmcloud Store für installing costum_components to Homeassistant Bahmcloud Store for installing costum_components to Homeassistant

View File

@@ -1,29 +1,86 @@
async function load() { async function apiGet() {
const r = await fetch("/api/bahmcloud_store", { credentials: "same-origin" }); const r = await fetch("/api/bahmcloud_store", { credentials: "same-origin" });
const data = await r.json(); return await r.json();
}
async function apiPost(payload) {
const r = await fetch("/api/bahmcloud_store", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify(payload),
});
return await r.json();
}
function el(tag, attrs = {}, children = []) {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") n.className = v;
else if (k === "onclick") n.onclick = v;
else n.setAttribute(k, v);
}
for (const c of children) {
n.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
}
return n;
}
function card(pkg) {
const installedBadge = el("span", { class: "badge" }, [pkg.installed ? "Installed" : "Not installed"]);
const title = el("div", {}, [
el("strong", {}, [pkg.name]),
el("div", { class: "muted" }, [pkg.repo]),
]);
const ver = el("div", { class: "muted" }, [
`Installed: ${pkg.installed_version || "-"} | Latest: ${pkg.latest_version || "-"}`
]);
const btnInstall = el("button", {
onclick: async () => {
btnInstall.disabled = true;
btnInstall.textContent = "Working...";
await apiPost({ op: "install", package_id: pkg.id });
await load();
}
}, [pkg.installed ? "Reinstall" : "Install"]);
const btnUpdate = el("button", {
onclick: async () => {
btnUpdate.disabled = true;
btnUpdate.textContent = "Working...";
await apiPost({ op: "update", package_id: pkg.id });
await load();
}
}, ["Update"]);
// Update-Button nur wenn installiert
btnUpdate.disabled = !pkg.installed;
const actions = el("div", { class: "actions" }, [btnInstall, btnUpdate]);
return el("div", { class: "card" }, [
el("div", { class: "row" }, [title, installedBadge]),
ver,
actions
]);
}
async function load() {
const status = document.getElementById("status");
const list = document.getElementById("list"); const list = document.getElementById("list");
status.textContent = "Loading...";
list.innerHTML = ""; list.innerHTML = "";
data.packages.forEach(p => { const data = await apiGet();
const div = document.createElement("div"); status.textContent = `Store: ${data.store_url}`;
div.className = "card";
div.innerHTML = ` for (const pkg of data.packages) {
<div class="row"> list.appendChild(card(pkg));
<div> }
<b>${p.name}</b>
<div class="muted">${p.repo}</div>
</div>
<div class="badge">${p.installed ? "Installed" : "Not installed"}</div>
</div>
<div class="muted">
Installed: ${p.installed_version || "-"} |
Latest: ${p.latest_version || "-"}
${p.release_url ? `| <a href="${p.release_url}" target="_blank">Release</a>` : ""}
</div>
`;
list.appendChild(div);
});
} }
document.getElementById("refresh").onclick = load; document.getElementById("refresh").onclick = load;

View File

@@ -9,12 +9,18 @@
<div class="wrap"> <div class="wrap">
<h1>Bahmcloud Store</h1> <h1>Bahmcloud Store</h1>
<p class="muted"> <p class="muted">
Installation & Updates laufen manuell über <b>Einstellungen → System → Updates</b>.<br/> Installation erfolgt hier im Store (Buttons).<br/>
Diese Seite zeigt nur die Paketliste aus store.yaml (auto-refresh). Updates erscheinen danach zusätzlich unter <b>Einstellungen → System → Updates</b>.
</p> </p>
<button id="refresh">Refresh</button>
<div class="actions">
<button id="refresh">Refresh</button>
</div>
<div id="status" class="muted"></div>
<div id="list"></div> <div id="list"></div>
</div> </div>
<script src="/api/bahmcloud_store_static/app.js"></script> <script src="/api/bahmcloud_store_static/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,8 +1,10 @@
body { font-family: system-ui, sans-serif; margin:0; } body { font-family: system-ui, sans-serif; margin:0; }
.wrap { padding: 16px; max-width: 1000px; margin: 0 auto; } .wrap { padding: 16px; max-width: 1000px; margin: 0 auto; }
.card { border: 1px solid #ddd; border-radius: 10px; padding: 12px; margin: 10px 0; } .card { border: 1px solid #ddd; border-radius: 10px; padding: 12px; margin: 10px 0; }
.row { display:flex; justify-content:space-between; gap: 12px; } .row { display:flex; justify-content:space-between; gap: 12px; align-items: flex-start; }
.badge { border: 1px solid #bbb; border-radius: 999px; padding: 2px 8px; font-size: 12px; } .badge { border: 1px solid #bbb; border-radius: 999px; padding: 2px 8px; font-size: 12px; height: fit-content; }
.muted { color: #666; font-size: 13px; } .muted { color: #666; font-size: 13px; margin-top: 4px; }
.actions { display:flex; gap: 8px; margin-top: 10px; }
button { padding: 8px 12px; cursor:pointer; } button { padding: 8px 12px; cursor:pointer; }
button[disabled] { opacity: 0.6; cursor: not-allowed; }
a { color: inherit; } a { color: inherit; }

View File

@@ -35,7 +35,7 @@ class StoreConfig:
class Package: class Package:
id: str id: str
name: str name: str
type: str type: str # "integration" | "store"
domain: str domain: str
repo: str repo: str
owner: str owner: str
@@ -76,12 +76,19 @@ class BahmcloudStore:
u = urlparse(repo_url.rstrip("/")) u = urlparse(repo_url.rstrip("/"))
return f"{u.scheme}://{u.netloc}" return f"{u.scheme}://{u.netloc}"
@staticmethod
def _raw_manifest_url(repo: str, branch: str, source_path: str) -> str:
# Example:
# https://git.bahmcloud.de/bahmcloud/easy_proxmox/raw/branch/main/custom_components/easy_proxmox/manifest.json
return f"{repo.rstrip('/')}/raw/branch/{branch}/{source_path.rstrip('/')}/manifest.json"
async def _fetch_latest_version(self, pkg: Package) -> tuple[str | None, str | None]: async def _fetch_latest_version(self, pkg: Package) -> tuple[str | None, str | None]:
""" """
Returns (latest_version, release_url) Returns (latest_version, release_url)
Strategy: Strategy:
1) releases/latest -> tag_name 1) releases/latest -> tag_name
2) tags?limit=1 -> first tag name 2) tags?limit=1 -> first tag name
3) fallback: read manifest.json from repo (version field)
""" """
session = async_get_clientsession(self.hass) session = async_get_clientsession(self.hass)
base = self._base_from_repo(pkg.repo) base = self._base_from_repo(pkg.repo)
@@ -94,7 +101,8 @@ class BahmcloudStore:
data = await resp.json() data = await resp.json()
tag = data.get("tag_name") tag = data.get("tag_name")
html_url = data.get("html_url") html_url = data.get("html_url")
return (str(tag) if tag else None, str(html_url) if html_url else None) if tag:
return (str(tag), str(html_url) if html_url else None)
except Exception: except Exception:
pass pass
@@ -106,7 +114,21 @@ class BahmcloudStore:
tags = await resp.json() tags = await resp.json()
if tags and isinstance(tags, list): if tags and isinstance(tags, list):
name = tags[0].get("name") name = tags[0].get("name")
return (str(name) if name else None, None) if name:
return (str(name), None)
except Exception:
pass
# 3) fallback: manifest.json version from repo
try:
manifest_url = self._raw_manifest_url(pkg.repo, pkg.branch, pkg.source_path)
async with session.get(manifest_url, timeout=20) as resp:
if resp.status == 200:
text = await resp.text()
data = json.loads(text)
ver = data.get("version")
if ver:
return (str(ver), None)
except Exception: except Exception:
pass pass
@@ -198,6 +220,9 @@ class BahmcloudStore:
shutil.rmtree(target) shutil.rmtree(target)
shutil.copytree(src, target) shutil.copytree(src, target)
# Nach Installation: Entities neu aufbauen (damit es als Update auftaucht)
self.signal_entities_updated()
persistent_notification.async_create( persistent_notification.async_create(
self.hass, self.hass,
( (
@@ -228,7 +253,7 @@ class BahmcloudStore:
async def register_http_views(self) -> None: async def register_http_views(self) -> None:
"""Register HTTP views for static panel assets and JSON API.""" """Register HTTP views for static panel assets and JSON API."""
self.hass.http.register_view(_StaticView()) self.hass.http.register_view(_StaticView())
self.hass.http.register_view(_APIListView(self)) self.hass.http.register_view(_APIView(self))
class _StaticView(HomeAssistantView): class _StaticView(HomeAssistantView):
@@ -236,12 +261,6 @@ class _StaticView(HomeAssistantView):
IMPORTANT: IMPORTANT:
Custom Panel JS modules are loaded WITHOUT Authorization headers. Custom Panel JS modules are loaded WITHOUT Authorization headers.
Therefore static panel assets must be publicly accessible (no auth). Therefore static panel assets must be publicly accessible (no auth).
Serves:
/api/bahmcloud_store_static/index.html
/api/bahmcloud_store_static/panel.js
/api/bahmcloud_store_static/app.js
/api/bahmcloud_store_static/styles.css
""" """
requires_auth = False requires_auth = False
name = "bahmcloud_store_static" name = "bahmcloud_store_static"
@@ -254,7 +273,6 @@ class _StaticView(HomeAssistantView):
f = (base / path).resolve() f = (base / path).resolve()
# Prevent path traversal
if not str(f).startswith(str(base)) or not f.exists() or not f.is_file(): if not str(f).startswith(str(base)) or not f.exists() or not f.is_file():
return web.Response(status=404, text="Not found") return web.Response(status=404, text="Not found")
@@ -269,10 +287,11 @@ class _StaticView(HomeAssistantView):
return web.Response(body=f.read_bytes(), content_type="application/octet-stream") return web.Response(body=f.read_bytes(), content_type="application/octet-stream")
class _APIListView(HomeAssistantView): class _APIView(HomeAssistantView):
""" """
Store API MUST stay protected. Auth-protected API:
UI loads data via fetch() with HA auth handled by frontend. GET /api/bahmcloud_store -> list packages
POST /api/bahmcloud_store {op:...} -> install/update a package
""" """
requires_auth = True requires_auth = True
name = "bahmcloud_store_api" name = "bahmcloud_store_api"
@@ -300,3 +319,20 @@ class _APIListView(HomeAssistantView):
} }
) )
return self.json({"packages": items, "store_url": self.store.config.store_url}) return self.json({"packages": items, "store_url": self.store.config.store_url})
async def post(self, request):
data = await request.json()
op = data.get("op")
package_id = data.get("package_id")
if op not in ("install", "update"):
return self.json({"error": "unknown op"}, status_code=400)
if not package_id:
return self.json({"error": "package_id missing"}, status_code=400)
pkg = self.store.packages.get(package_id)
if not pkg:
return self.json({"error": "unknown package_id"}, status_code=404)
await self.store.install_from_zip(pkg)
return self.json({"ok": True})

View File

@@ -17,16 +17,26 @@ async def async_setup_platform(
store: BahmcloudStore = hass.data[DOMAIN] store: BahmcloudStore = hass.data[DOMAIN]
entities: dict[str, BahmcloudPackageUpdate] = {} entities: dict[str, BahmcloudPackageUpdate] = {}
def should_have_update_entity(pkg: Package) -> bool:
# Store selbst immer als Update
if pkg.type == "store":
return True
# Andere Pakete nur, wenn installiert
return store.is_installed(pkg.domain)
def rebuild_entities() -> None: def rebuild_entities() -> None:
# create entities for any new package in store.yaml # Create entities for packages that qualify
for pkg in store.packages.values(): for pkg in store.packages.values():
if not should_have_update_entity(pkg):
continue
uid = f"{DOMAIN}:{pkg.id}" uid = f"{DOMAIN}:{pkg.id}"
if uid not in entities: if uid not in entities:
ent = BahmcloudPackageUpdate(store, pkg.id) ent = BahmcloudPackageUpdate(store, pkg.id)
entities[uid] = ent entities[uid] = ent
async_add_entities([ent], update_before_add=True) async_add_entities([ent], update_before_add=True)
# refresh states # Refresh states
for ent in entities.values(): for ent in entities.values():
ent.async_write_ha_state() ent.async_write_ha_state()