Initial Argus web catalog
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
.codex-backups/
|
||||
.codex-temp/
|
||||
Data/*.db
|
||||
Data/*.db-shm
|
||||
Data/*.db-wal
|
||||
Data/Packages/
|
||||
Data/DataProtection-Keys/
|
||||
.env
|
||||
.env.server*
|
||||
.env.*.local
|
||||
@@ -0,0 +1 @@
|
||||
ARGUS_DATA_PATH=/srv/argus-data
|
||||
@@ -0,0 +1,63 @@
|
||||
###############################################################################
|
||||
# Set default behavior to automatically normalize line endings.
|
||||
###############################################################################
|
||||
* text=auto
|
||||
|
||||
###############################################################################
|
||||
# Set default behavior for command prompt diff.
|
||||
#
|
||||
# This is need for earlier builds of msysgit that does not have it on by
|
||||
# default for csharp files.
|
||||
# Note: This is only used by command line
|
||||
###############################################################################
|
||||
#*.cs diff=csharp
|
||||
|
||||
###############################################################################
|
||||
# Set the merge driver for project and solution files
|
||||
#
|
||||
# Merging from the command prompt will add diff markers to the files if there
|
||||
# are conflicts (Merging from VS is not affected by the settings below, in VS
|
||||
# the diff markers are never inserted). Diff markers may cause the following
|
||||
# file extensions to fail to load in VS. An alternative would be to treat
|
||||
# these files as binary and thus will always conflict and require user
|
||||
# intervention with every merge. To do so, just uncomment the entries below
|
||||
###############################################################################
|
||||
#*.sln merge=binary
|
||||
#*.csproj merge=binary
|
||||
#*.vbproj merge=binary
|
||||
#*.vcxproj merge=binary
|
||||
#*.vcproj merge=binary
|
||||
#*.dbproj merge=binary
|
||||
#*.fsproj merge=binary
|
||||
#*.lsproj merge=binary
|
||||
#*.wixproj merge=binary
|
||||
#*.modelproj merge=binary
|
||||
#*.sqlproj merge=binary
|
||||
#*.wwaproj merge=binary
|
||||
|
||||
###############################################################################
|
||||
# behavior for image files
|
||||
#
|
||||
# image files are treated as binary by default.
|
||||
###############################################################################
|
||||
#*.jpg binary
|
||||
#*.png binary
|
||||
#*.gif binary
|
||||
|
||||
###############################################################################
|
||||
# diff behavior for common document formats
|
||||
#
|
||||
# Convert binary document formats to text before diffing them. This feature
|
||||
# is only available from the command line. Turn it on by uncommenting the
|
||||
# entries below.
|
||||
###############################################################################
|
||||
#*.doc diff=astextplain
|
||||
#*.DOC diff=astextplain
|
||||
#*.docx diff=astextplain
|
||||
#*.DOCX diff=astextplain
|
||||
#*.dot diff=astextplain
|
||||
#*.DOT diff=astextplain
|
||||
#*.pdf diff=astextplain
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
.codex-backups/
|
||||
.codex-temp/
|
||||
|
||||
Data/Packages/
|
||||
Data/DataProtection-Keys/
|
||||
Data/*.db
|
||||
Data/*.db-shm
|
||||
Data/*.db-wal
|
||||
.env
|
||||
.env.server
|
||||
.env.*.local
|
||||
@@ -0,0 +1,343 @@
|
||||
# Argus Publication And Update Integration
|
||||
|
||||
Эта инструкция является единой точкой входа для любого приложения, которое нужно публиковать через Argus или подключить к системе обновлений Argus.
|
||||
|
||||
Argus в текущей архитектуре является read-only web catalog. У него нет HTTP admin API, upload endpoint, токена публикации или браузерной формы публикации. Публикация выполняется только на сервере через SSH: файл релиза кладётся в `Data/Packages`, а метаданные записываются в SQLite `Data/argus.db`.
|
||||
|
||||
## Production Argus
|
||||
|
||||
- Public base URL: `https://argus.kusoft.xyz`
|
||||
- SSH host: `192.168.0.185`
|
||||
- SSH user: `sevenhill`
|
||||
- Server project path: `/home/sevenhill/argus`
|
||||
- Data path: `/srv/argus-data`
|
||||
- SQLite database: `/srv/argus-data/argus.db`
|
||||
- Package root: `/srv/argus-data/Packages`
|
||||
|
||||
Do not put SSH passwords, private keys, or local `.env` files into application repositories.
|
||||
|
||||
## App Values
|
||||
|
||||
Every app must choose stable values:
|
||||
|
||||
- `slug`: public Argus id. Use lowercase letters, digits, and hyphens only, for example `keeper-android` or `my-desktop-app`.
|
||||
- `name`: display name.
|
||||
- `summary`: short catalog text.
|
||||
- `description`: full catalog text.
|
||||
- `version`: release version. Semantic versions are recommended, for example `1.2.3`.
|
||||
- `channel`: release channel, normally `stable`.
|
||||
- `platform`: target platform, for example `windows-x64`, `linux-arm64`, `android`, `web`.
|
||||
- `packageKind`: package type, for example `zip`, `msi`, `deb`, `apk`, `binary`.
|
||||
|
||||
Argus selects the latest release by semantic version first, then by publish time. The running service currently prunes to one retained release per app on service startup.
|
||||
|
||||
## Publish A Release Over SSH
|
||||
|
||||
From the development machine, copy the built artifact to the Pi:
|
||||
|
||||
```bash
|
||||
scp ./path/to/my-app-1.2.3.zip sevenhill@192.168.0.185:/tmp/my-app-1.2.3.zip
|
||||
```
|
||||
|
||||
Then connect to the Pi:
|
||||
|
||||
```bash
|
||||
ssh sevenhill@192.168.0.185
|
||||
```
|
||||
|
||||
Run this publication block on the Pi after editing the variables at the top:
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
|
||||
export ARGUS_DATA="/srv/argus-data"
|
||||
export SOURCE_FILE="/tmp/my-app-1.2.3.zip"
|
||||
|
||||
export ARGUS_SLUG="my-app"
|
||||
export ARGUS_NAME="My App"
|
||||
export ARGUS_SUMMARY="Short summary for the Argus catalog."
|
||||
export ARGUS_DESCRIPTION="Full description shown in the Argus catalog."
|
||||
export ARGUS_REPOSITORY_URL=""
|
||||
export ARGUS_HOMEPAGE_URL=""
|
||||
export ARGUS_IS_LISTED="1"
|
||||
|
||||
export ARGUS_VERSION="1.2.3"
|
||||
export ARGUS_CHANNEL="stable"
|
||||
export ARGUS_PLATFORM="windows-x64"
|
||||
export ARGUS_PACKAGE_KIND="zip"
|
||||
export ARGUS_NOTES="What changed in this release."
|
||||
|
||||
python3 - "$SOURCE_FILE" <<'PY'
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
source = Path(sys.argv[1]).resolve()
|
||||
data_root = Path(os.environ.get("ARGUS_DATA", "/srv/argus-data")).resolve()
|
||||
db_path = data_root / "argus.db"
|
||||
packages_root = data_root / "Packages"
|
||||
|
||||
slug = os.environ["ARGUS_SLUG"].strip()
|
||||
name = os.environ["ARGUS_NAME"].strip()
|
||||
summary = os.environ["ARGUS_SUMMARY"].strip()
|
||||
description = os.environ["ARGUS_DESCRIPTION"].strip()
|
||||
repository_url = os.environ.get("ARGUS_REPOSITORY_URL", "").strip() or None
|
||||
homepage_url = os.environ.get("ARGUS_HOMEPAGE_URL", "").strip() or None
|
||||
is_listed = 1 if os.environ.get("ARGUS_IS_LISTED", "1").strip() != "0" else 0
|
||||
|
||||
version = os.environ["ARGUS_VERSION"].strip()
|
||||
channel = os.environ.get("ARGUS_CHANNEL", "stable").strip().lower()
|
||||
platform = os.environ.get("ARGUS_PLATFORM", "generic").strip().lower()
|
||||
package_kind = os.environ.get("ARGUS_PACKAGE_KIND", "binary").strip().lower()
|
||||
notes = os.environ.get("ARGUS_NOTES", "").strip() or None
|
||||
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,99}", slug):
|
||||
raise SystemExit("ARGUS_SLUG must contain only lowercase letters, digits, and hyphens, max 100 chars.")
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"SOURCE_FILE does not exist: {source}")
|
||||
if not db_path.is_file():
|
||||
raise SystemExit(f"Argus database does not exist: {db_path}")
|
||||
for key, value in {
|
||||
"ARGUS_NAME": name,
|
||||
"ARGUS_SUMMARY": summary,
|
||||
"ARGUS_DESCRIPTION": description,
|
||||
"ARGUS_VERSION": version,
|
||||
}.items():
|
||||
if not value:
|
||||
raise SystemExit(f"{key} is required.")
|
||||
|
||||
release_id = str(uuid.uuid4()).upper()
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="microseconds")
|
||||
safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", version).strip("-._") or "release"
|
||||
extension = source.suffix or ".bin"
|
||||
stored_name = f"{datetime.now(timezone.utc):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}{extension}"
|
||||
stored_relative_path = f"{slug}/{stored_name}"
|
||||
target_dir = packages_root / slug
|
||||
target_path = target_dir / stored_name
|
||||
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target_path)
|
||||
|
||||
size_bytes = target_path.stat().st_size
|
||||
sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest()
|
||||
content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
|
||||
if source.suffix.lower() == ".apk":
|
||||
content_type = "application/vnd.android.package-archive"
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("BEGIN")
|
||||
|
||||
row = conn.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone()
|
||||
if row is None:
|
||||
app_id = str(uuid.uuid4()).upper()
|
||||
conn.execute(
|
||||
'''
|
||||
INSERT INTO "Apps"
|
||||
("Id", "Slug", "Name", "Summary", "Description", "RepositoryUrl", "HomepageUrl", "IsListed", "CreatedAt", "UpdatedAt")
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
(app_id, slug, name, summary, description, repository_url, homepage_url, is_listed, now, now),
|
||||
)
|
||||
else:
|
||||
app_id = row[0]
|
||||
conn.execute(
|
||||
'''
|
||||
UPDATE "Apps"
|
||||
SET "Name" = ?,
|
||||
"Summary" = ?,
|
||||
"Description" = ?,
|
||||
"RepositoryUrl" = ?,
|
||||
"HomepageUrl" = ?,
|
||||
"IsListed" = ?,
|
||||
"UpdatedAt" = ?
|
||||
WHERE "Id" = ?
|
||||
''',
|
||||
(name, summary, description, repository_url, homepage_url, is_listed, now, app_id),
|
||||
)
|
||||
|
||||
duplicate = conn.execute(
|
||||
'''
|
||||
SELECT "Id"
|
||||
FROM "Releases"
|
||||
WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ?
|
||||
''',
|
||||
(app_id, version, channel, platform),
|
||||
).fetchone()
|
||||
if duplicate is not None:
|
||||
raise RuntimeError(f"Release already exists for {slug} {version} {channel} {platform}.")
|
||||
|
||||
conn.execute(
|
||||
'''
|
||||
INSERT INTO "Releases"
|
||||
("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind",
|
||||
"OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes",
|
||||
"Sha256", "Notes", "PublishedAt")
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
(
|
||||
release_id,
|
||||
app_id,
|
||||
version,
|
||||
channel,
|
||||
platform,
|
||||
package_kind,
|
||||
source.name,
|
||||
stored_relative_path,
|
||||
content_type,
|
||||
size_bytes,
|
||||
sha256,
|
||||
notes,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
try:
|
||||
target_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(f"Published app={slug} version={version} channel={channel} platform={platform}")
|
||||
print(f"StoredRelativePath={stored_relative_path}")
|
||||
print(f"Size={size_bytes}")
|
||||
print(f"Sha256={sha256}")
|
||||
print(f"Manifest=https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}")
|
||||
PY
|
||||
```
|
||||
|
||||
Verify publication from the Pi:
|
||||
|
||||
```bash
|
||||
curl -sS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"
|
||||
```
|
||||
|
||||
Verify publication from outside the Pi:
|
||||
|
||||
```bash
|
||||
curl -sS "https://argus.kusoft.xyz/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"
|
||||
```
|
||||
|
||||
If you want Argus to apply its one-release retention rule immediately after manual publication, restart the container:
|
||||
|
||||
```bash
|
||||
docker restart argus
|
||||
```
|
||||
|
||||
The restart is optional for making a newer semantic version visible. It is only needed to force startup pruning immediately.
|
||||
|
||||
## Public API Contract For Update Clients
|
||||
|
||||
Update clients must use the public read-only API. They must not call `/api/admin/*`; those routes are not part of this system.
|
||||
|
||||
Check for an update:
|
||||
|
||||
```text
|
||||
GET https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}
|
||||
```
|
||||
|
||||
A `404` response means one of these states:
|
||||
|
||||
- the app slug is not listed;
|
||||
- there is no release for the requested `platform`;
|
||||
- there is no release for the requested `channel`.
|
||||
|
||||
A successful manifest response contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "my-app",
|
||||
"name": "My App",
|
||||
"summary": "Short summary.",
|
||||
"description": "Full description.",
|
||||
"repositoryUrl": null,
|
||||
"homepageUrl": null,
|
||||
"release": {
|
||||
"id": "release-guid",
|
||||
"version": "1.2.3",
|
||||
"channel": "stable",
|
||||
"platform": "windows-x64",
|
||||
"packageKind": "zip",
|
||||
"downloadPath": "/api/apps/my-app/releases/release-guid/download",
|
||||
"originalFileName": "my-app-1.2.3.zip",
|
||||
"contentType": "application/zip",
|
||||
"packageSizeBytes": 123456,
|
||||
"sha256": "hex-encoded-sha256",
|
||||
"publishedAt": "2026-06-13T18:00:00+00:00",
|
||||
"notes": "What changed."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Download URL:
|
||||
|
||||
```text
|
||||
https://argus.kusoft.xyz{release.downloadPath}
|
||||
```
|
||||
|
||||
There is also a latest-download endpoint:
|
||||
|
||||
```text
|
||||
GET https://argus.kusoft.xyz/api/apps/{slug}/download/latest?platform={platform}&channel={channel}
|
||||
```
|
||||
|
||||
The manifest path is preferred for update clients because it provides `sha256`, `packageSizeBytes`, `version`, and release metadata before downloading.
|
||||
|
||||
## Required Update Client Behavior
|
||||
|
||||
Every app integrating with Argus must implement this flow:
|
||||
|
||||
1. Store its current installed version locally.
|
||||
2. Request the manifest for its `slug`, `platform`, and `channel`.
|
||||
3. Treat HTTP `404` as "no update available for this app/platform/channel".
|
||||
4. Compare local version to `release.version`.
|
||||
5. If update is needed, download `baseUrl + release.downloadPath` to a temporary file.
|
||||
6. Calculate SHA-256 of the downloaded file.
|
||||
7. Compare the calculated SHA-256 to `release.sha256`.
|
||||
8. Reject and delete the temporary file if SHA-256 does not match.
|
||||
9. Apply the update with the app platform's own installer or replacement mechanism.
|
||||
10. Store the new installed version only after a successful install or successful staged update.
|
||||
|
||||
Argus does not install files on client devices. It only publishes metadata and serves package bytes. Installation logic belongs to each app.
|
||||
|
||||
## Versioning Rule
|
||||
|
||||
Use semantic versions when possible:
|
||||
|
||||
```text
|
||||
1.2.3
|
||||
1.2.4
|
||||
1.3.0
|
||||
2.0.0
|
||||
```
|
||||
|
||||
Argus latest selection is semantic-version aware. If an app uses a non-semantic version string, Argus falls back to case-insensitive string comparison for ordering. For predictable updates, keep release versions semantic.
|
||||
|
||||
## Recommended Prompt For App Repositories
|
||||
|
||||
When asking another app project to integrate with Argus, use this instruction:
|
||||
|
||||
```text
|
||||
Read ARGUS_PUBLICATION.md from the Argus repository.
|
||||
Implement the "Required Update Client Behavior" section for this app.
|
||||
Use:
|
||||
- baseUrl: https://argus.kusoft.xyz
|
||||
- slug: <app-slug>
|
||||
- platform: <platform>
|
||||
- channel: stable
|
||||
Do not implement publishing over HTTP. Publishing is SSH-only and happens on the Argus server.
|
||||
Always verify release.sha256 before installing or applying an update.
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace Argus.Contracts;
|
||||
|
||||
public sealed record AppReleaseDto(
|
||||
Guid Id,
|
||||
string Version,
|
||||
string Channel,
|
||||
string Platform,
|
||||
string PackageKind,
|
||||
string DownloadPath,
|
||||
string OriginalFileName,
|
||||
string ContentType,
|
||||
long PackageSizeBytes,
|
||||
string Sha256,
|
||||
DateTimeOffset PublishedAt,
|
||||
string? Notes);
|
||||
|
||||
public sealed record AppListItemDto(
|
||||
string Slug,
|
||||
string Name,
|
||||
string Summary,
|
||||
string? RepositoryUrl,
|
||||
string? HomepageUrl,
|
||||
DateTimeOffset UpdatedAt,
|
||||
AppReleaseDto? LatestRelease,
|
||||
int ReleaseCount);
|
||||
|
||||
public sealed record AppDetailDto(
|
||||
string Slug,
|
||||
string Name,
|
||||
string Summary,
|
||||
string Description,
|
||||
string? RepositoryUrl,
|
||||
string? HomepageUrl,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
bool IsListed,
|
||||
IReadOnlyCollection<AppReleaseDto> Releases);
|
||||
|
||||
public sealed record AppManifestDto(
|
||||
string Slug,
|
||||
string Name,
|
||||
string Summary,
|
||||
string Description,
|
||||
string? RepositoryUrl,
|
||||
string? HomepageUrl,
|
||||
AppReleaseDto Release);
|
||||
|
||||
public sealed record ReleaseFeedItemDto(
|
||||
string Slug,
|
||||
string AppName,
|
||||
AppReleaseDto Release);
|
||||
@@ -0,0 +1,50 @@
|
||||
using Argus.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Argus.Data;
|
||||
|
||||
public sealed class ArgusDbContext(DbContextOptions<ArgusDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<CatalogApp> Apps => Set<CatalogApp>();
|
||||
|
||||
public DbSet<AppRelease> Releases => Set<AppRelease>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<CatalogApp>(entity =>
|
||||
{
|
||||
entity.ToTable("Apps");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Slug).HasMaxLength(100).IsRequired();
|
||||
entity.Property(x => x.Name).HasMaxLength(160).IsRequired();
|
||||
entity.Property(x => x.Summary).HasMaxLength(280).IsRequired();
|
||||
entity.Property(x => x.Description).HasMaxLength(4_000).IsRequired();
|
||||
entity.Property(x => x.RepositoryUrl).HasMaxLength(500);
|
||||
entity.Property(x => x.HomepageUrl).HasMaxLength(500);
|
||||
entity.HasIndex(x => x.Slug).IsUnique();
|
||||
entity.HasIndex(x => new { x.IsListed, x.Name });
|
||||
entity.HasMany(x => x.Releases)
|
||||
.WithOne(x => x.CatalogApp)
|
||||
.HasForeignKey(x => x.CatalogAppId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AppRelease>(entity =>
|
||||
{
|
||||
entity.ToTable("Releases");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Version).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.Channel).HasMaxLength(40).IsRequired();
|
||||
entity.Property(x => x.Platform).HasMaxLength(80).IsRequired();
|
||||
entity.Property(x => x.PackageKind).HasMaxLength(40).IsRequired();
|
||||
entity.Property(x => x.OriginalFileName).HasMaxLength(255).IsRequired();
|
||||
entity.Property(x => x.StoredRelativePath).HasMaxLength(255).IsRequired();
|
||||
entity.Property(x => x.ContentType).HasMaxLength(160).IsRequired();
|
||||
entity.Property(x => x.Sha256).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.Notes).HasMaxLength(4_000);
|
||||
entity.HasIndex(x => new { x.CatalogAppId, x.Version, x.Channel, x.Platform }).IsUnique();
|
||||
entity.HasIndex(x => new { x.CatalogAppId, x.PublishedAt });
|
||||
entity.HasIndex(x => x.PublishedAt);
|
||||
});
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY ["Argus.csproj", "./"]
|
||||
RUN dotnet restore "Argus.csproj"
|
||||
|
||||
COPY . .
|
||||
RUN dotnet publish "Argus.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
ENV ASPNETCORE_URLS=http://+:5105
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
EXPOSE 5105
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl --fail http://127.0.0.1:5105/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dotnet", "Argus.dll"]
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Argus.Domain;
|
||||
|
||||
public sealed class AppRelease
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid CatalogAppId { get; set; }
|
||||
|
||||
public CatalogApp CatalogApp { get; set; } = null!;
|
||||
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
public string Channel { get; set; } = "stable";
|
||||
|
||||
public string Platform { get; set; } = "generic";
|
||||
|
||||
public string PackageKind { get; set; } = "binary";
|
||||
|
||||
public string OriginalFileName { get; set; } = string.Empty;
|
||||
|
||||
public string StoredRelativePath { get; set; } = string.Empty;
|
||||
|
||||
public string ContentType { get; set; } = "application/octet-stream";
|
||||
|
||||
public long PackageSizeBytes { get; set; }
|
||||
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DateTimeOffset PublishedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Argus.Domain;
|
||||
|
||||
public sealed class CatalogApp
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public string? RepositoryUrl { get; set; }
|
||||
|
||||
public string? HomepageUrl { get; set; }
|
||||
|
||||
public bool IsListed { get; set; } = true;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public ICollection<AppRelease> Releases { get; set; } = new List<AppRelease>();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Argus.Infrastructure;
|
||||
|
||||
public static class IdentifierUtility
|
||||
{
|
||||
public static string Normalize(string? value, string fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(value.Length);
|
||||
var lastWasSeparator = false;
|
||||
|
||||
foreach (var character in value.Trim().ToLowerInvariant())
|
||||
{
|
||||
if (char.IsLetterOrDigit(character))
|
||||
{
|
||||
builder.Append(character);
|
||||
lastWasSeparator = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character is '-' or '_' or '.')
|
||||
{
|
||||
if (!lastWasSeparator)
|
||||
{
|
||||
builder.Append(character);
|
||||
lastWasSeparator = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var normalized = builder.ToString().Trim('-', '_', '.');
|
||||
return string.IsNullOrWhiteSpace(normalized) ? fallback : normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Argus.Infrastructure;
|
||||
|
||||
public sealed class PackageStorageService(StoragePaths storagePaths)
|
||||
{
|
||||
public string ResolvePhysicalPath(string storedRelativePath)
|
||||
{
|
||||
var normalizedRelativePath = storedRelativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||
return Path.Combine(storagePaths.PackagesRootPath, normalizedRelativePath);
|
||||
}
|
||||
|
||||
public void TryDelete(string storedRelativePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var physicalPath = ResolvePhysicalPath(storedRelativePath);
|
||||
if (File.Exists(physicalPath))
|
||||
{
|
||||
File.Delete(physicalPath);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
namespace Argus.Infrastructure;
|
||||
|
||||
public static class SemanticVersionUtility
|
||||
{
|
||||
public static int Compare(string? left, string? right)
|
||||
{
|
||||
var leftParsed = SemanticVersion.TryParse(left, out var leftVersion);
|
||||
var rightParsed = SemanticVersion.TryParse(right, out var rightVersion);
|
||||
|
||||
if (leftParsed && rightParsed)
|
||||
{
|
||||
return leftVersion.CompareTo(rightVersion);
|
||||
}
|
||||
|
||||
if (leftParsed)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (rightParsed)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return string.Compare(left, right, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class SemanticVersion : IComparable<SemanticVersion>
|
||||
{
|
||||
private readonly IReadOnlyList<Identifier> _preReleaseIdentifiers;
|
||||
|
||||
private SemanticVersion(int major, int minor, int patch, IReadOnlyList<Identifier> preReleaseIdentifiers)
|
||||
{
|
||||
Major = major;
|
||||
Minor = minor;
|
||||
Patch = patch;
|
||||
_preReleaseIdentifiers = preReleaseIdentifiers;
|
||||
}
|
||||
|
||||
public int Major { get; }
|
||||
|
||||
public int Minor { get; }
|
||||
|
||||
public int Patch { get; }
|
||||
|
||||
public bool IsPreRelease => _preReleaseIdentifiers.Count > 0;
|
||||
|
||||
public static bool TryParse(string? value, out SemanticVersion version)
|
||||
{
|
||||
version = null!;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.Length > 1 && (trimmed[0] == 'v' || trimmed[0] == 'V') && char.IsDigit(trimmed[1]))
|
||||
{
|
||||
trimmed = trimmed[1..];
|
||||
}
|
||||
|
||||
var buildSeparatorIndex = trimmed.IndexOf('+');
|
||||
if (buildSeparatorIndex >= 0)
|
||||
{
|
||||
trimmed = trimmed[..buildSeparatorIndex];
|
||||
}
|
||||
|
||||
string corePart;
|
||||
string? preReleasePart = null;
|
||||
var preReleaseSeparatorIndex = trimmed.IndexOf('-');
|
||||
if (preReleaseSeparatorIndex >= 0)
|
||||
{
|
||||
corePart = trimmed[..preReleaseSeparatorIndex];
|
||||
preReleasePart = trimmed[(preReleaseSeparatorIndex + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
corePart = trimmed;
|
||||
}
|
||||
|
||||
var coreSegments = corePart.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (coreSegments.Length is < 1 or > 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParseNumeric(coreSegments[0], out var major))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var minor = 0;
|
||||
if (coreSegments.Length >= 2 && !TryParseNumeric(coreSegments[1], out minor))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var patch = 0;
|
||||
if (coreSegments.Length == 3 && !TryParseNumeric(coreSegments[2], out patch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var preReleaseIdentifiers = new List<Identifier>();
|
||||
if (!string.IsNullOrWhiteSpace(preReleasePart))
|
||||
{
|
||||
foreach (var segment in preReleasePart.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!Identifier.TryParse(segment, out var identifier))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
preReleaseIdentifiers.Add(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
version = new SemanticVersion(major, minor, patch, preReleaseIdentifiers);
|
||||
return true;
|
||||
}
|
||||
|
||||
public int CompareTo(SemanticVersion? other)
|
||||
{
|
||||
if (other is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var majorComparison = Major.CompareTo(other.Major);
|
||||
if (majorComparison != 0)
|
||||
{
|
||||
return majorComparison;
|
||||
}
|
||||
|
||||
var minorComparison = Minor.CompareTo(other.Minor);
|
||||
if (minorComparison != 0)
|
||||
{
|
||||
return minorComparison;
|
||||
}
|
||||
|
||||
var patchComparison = Patch.CompareTo(other.Patch);
|
||||
if (patchComparison != 0)
|
||||
{
|
||||
return patchComparison;
|
||||
}
|
||||
|
||||
if (!IsPreRelease && !other.IsPreRelease)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!IsPreRelease)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!other.IsPreRelease)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var count = Math.Max(_preReleaseIdentifiers.Count, other._preReleaseIdentifiers.Count);
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
if (index >= _preReleaseIdentifiers.Count)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (index >= other._preReleaseIdentifiers.Count)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var identifierComparison = _preReleaseIdentifiers[index].CompareTo(other._preReleaseIdentifiers[index]);
|
||||
if (identifierComparison != 0)
|
||||
{
|
||||
return identifierComparison;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool TryParseNumeric(string value, out int parsed) =>
|
||||
int.TryParse(value, out parsed) && parsed >= 0;
|
||||
|
||||
private readonly record struct Identifier(bool IsNumeric, long NumericValue, string TextValue) : IComparable<Identifier>
|
||||
{
|
||||
public static bool TryParse(string value, out Identifier identifier)
|
||||
{
|
||||
identifier = default;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
foreach (var character in trimmed)
|
||||
{
|
||||
if (!char.IsLetterOrDigit(character) && character != '-')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (long.TryParse(trimmed, out var numericValue))
|
||||
{
|
||||
identifier = new Identifier(true, numericValue, trimmed);
|
||||
return true;
|
||||
}
|
||||
|
||||
identifier = new Identifier(false, 0, trimmed);
|
||||
return true;
|
||||
}
|
||||
|
||||
public int CompareTo(Identifier other)
|
||||
{
|
||||
if (IsNumeric && other.IsNumeric)
|
||||
{
|
||||
return NumericValue.CompareTo(other.NumericValue);
|
||||
}
|
||||
|
||||
if (IsNumeric)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (other.IsNumeric)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return string.Compare(TextValue, other.TextValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Argus.Infrastructure;
|
||||
|
||||
public static class SlugUtility
|
||||
{
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(value.Length);
|
||||
var lastWasHyphen = false;
|
||||
|
||||
foreach (var character in value.Trim().ToLowerInvariant())
|
||||
{
|
||||
if (char.IsLetterOrDigit(character))
|
||||
{
|
||||
builder.Append(character);
|
||||
lastWasHyphen = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lastWasHyphen)
|
||||
{
|
||||
builder.Append('-');
|
||||
lastWasHyphen = true;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString().Trim('-');
|
||||
}
|
||||
|
||||
public static bool IsValid(string? value) =>
|
||||
!string.IsNullOrWhiteSpace(value) &&
|
||||
value.Length <= 100 &&
|
||||
string.Equals(value, Normalize(value), StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Data;
|
||||
using Argus.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Argus.Infrastructure;
|
||||
|
||||
public static class SqliteSchemaMigrator
|
||||
{
|
||||
public static async Task EnsureCurrentSchemaAsync(
|
||||
ArgusDbContext dbContext,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await dbContext.Database.EnsureCreatedAsync(cancellationToken);
|
||||
|
||||
await ExecuteNonQueryAsync(
|
||||
dbContext,
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_Apps_IsListed_Name"
|
||||
ON "Apps" ("IsListed", "Name")
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
await ExecuteNonQueryAsync(
|
||||
dbContext,
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_Releases_PublishedAt"
|
||||
ON "Releases" ("PublishedAt")
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
await ExecuteNonQueryAsync(
|
||||
dbContext,
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_Releases_CatalogAppId_PublishedAt"
|
||||
ON "Releases" ("CatalogAppId", "PublishedAt")
|
||||
""",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task ExecuteNonQueryAsync(
|
||||
ArgusDbContext dbContext,
|
||||
string commandText,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = dbContext.Database.GetDbConnection();
|
||||
var mustClose = connection.State != ConnectionState.Open;
|
||||
|
||||
if (mustClose)
|
||||
{
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = commandText;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (mustClose)
|
||||
{
|
||||
await connection.CloseAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Argus.Infrastructure;
|
||||
|
||||
public sealed class StoragePaths(IHostEnvironment hostEnvironment)
|
||||
{
|
||||
public string DataRootPath => Path.Combine(hostEnvironment.ContentRootPath, "Data");
|
||||
|
||||
public string PackagesRootPath => Path.Combine(DataRootPath, "Packages");
|
||||
|
||||
public void EnsureCreated()
|
||||
{
|
||||
Directory.CreateDirectory(DataRootPath);
|
||||
Directory.CreateDirectory(PackagesRootPath);
|
||||
}
|
||||
}
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
using System.Net;
|
||||
using Argus.Contracts;
|
||||
using Argus.Data;
|
||||
using Argus.Domain;
|
||||
using Argus.Infrastructure;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using EntityTagHeaderValue = Microsoft.Net.Http.Headers.EntityTagHeaderValue;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
const int MaxRetainedReleasesPerApp = 1;
|
||||
var configuredUrls =
|
||||
builder.Configuration["ASPNETCORE_URLS"] ??
|
||||
builder.Configuration["URLS"] ??
|
||||
"http://0.0.0.0:5105";
|
||||
|
||||
builder.WebHost.UseUrls(configuredUrls);
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddResponseCompression(options =>
|
||||
{
|
||||
options.EnableForHttps = true;
|
||||
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
|
||||
});
|
||||
builder.Services.AddDbContextPool<ArgusDbContext>(options =>
|
||||
options.UseSqlite(
|
||||
builder.Configuration.GetConnectionString("ArgusDb") ??
|
||||
"Data Source=Data/argus.db"));
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
|
||||
options.KnownProxies.Add(IPAddress.Loopback);
|
||||
options.KnownProxies.Add(IPAddress.IPv6Loopback);
|
||||
});
|
||||
builder.Services.AddSingleton<StoragePaths>();
|
||||
builder.Services.AddSingleton<PackageStorageService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.Services.GetRequiredService<StoragePaths>().EnsureCreated();
|
||||
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ArgusDbContext>();
|
||||
await SqliteSchemaMigrator.EnsureCurrentSchemaAsync(dbContext);
|
||||
|
||||
var storageService = scope.ServiceProvider.GetRequiredService<PackageStorageService>();
|
||||
await PruneOldReleasesAsync(dbContext, storageService, MaxRetainedReleasesPerApp);
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.UseResponseCompression();
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.MapGet("/health", async (ArgusDbContext dbContext, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
return canConnect
|
||||
? Results.Ok(new { status = "ok" })
|
||||
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
});
|
||||
|
||||
var api = app.MapGroup("/api");
|
||||
|
||||
api.MapGet("/apps", async (ArgusDbContext dbContext, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var apps = await dbContext.Apps
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Releases)
|
||||
.Where(x => x.IsListed)
|
||||
.OrderBy(x => x.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(apps.Select(MapListItem));
|
||||
});
|
||||
|
||||
api.MapGet("/apps/{slug}", async (string slug, ArgusDbContext dbContext, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!SlugUtility.IsValid(slug))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var catalogApp = await dbContext.Apps
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Releases)
|
||||
.SingleOrDefaultAsync(x => x.Slug == slug && x.IsListed, cancellationToken);
|
||||
|
||||
return catalogApp is null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(MapDetailItem(catalogApp));
|
||||
});
|
||||
|
||||
api.MapGet("/apps/{slug}/manifest", async (
|
||||
string slug,
|
||||
string? platform,
|
||||
string? channel,
|
||||
ArgusDbContext dbContext,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!SlugUtility.IsValid(slug))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var normalizedPlatform = NormalizeFilter(platform);
|
||||
var normalizedChannel = NormalizeFilter(channel);
|
||||
var catalogApp = await dbContext.Apps
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Releases.Where(release =>
|
||||
(normalizedPlatform == null || release.Platform == normalizedPlatform) &&
|
||||
(normalizedChannel == null || release.Channel == normalizedChannel)))
|
||||
.SingleOrDefaultAsync(x => x.Slug == slug && x.IsListed, cancellationToken);
|
||||
|
||||
if (catalogApp is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var latestRelease = SelectLatestRelease(catalogApp, normalizedPlatform, normalizedChannel);
|
||||
if (latestRelease is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new AppManifestDto(
|
||||
catalogApp.Slug,
|
||||
catalogApp.Name,
|
||||
catalogApp.Summary,
|
||||
catalogApp.Description,
|
||||
catalogApp.RepositoryUrl,
|
||||
catalogApp.HomepageUrl,
|
||||
MapReleaseItem(catalogApp.Slug, latestRelease)));
|
||||
});
|
||||
|
||||
api.MapGet("/apps/{slug}/download/latest", async Task<IResult> (
|
||||
string slug,
|
||||
string? platform,
|
||||
string? channel,
|
||||
HttpContext httpContext,
|
||||
ArgusDbContext dbContext,
|
||||
PackageStorageService storageService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!SlugUtility.IsValid(slug))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var normalizedPlatform = NormalizeFilter(platform);
|
||||
var normalizedChannel = NormalizeFilter(channel);
|
||||
var catalogApp = await dbContext.Apps
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Releases.Where(release =>
|
||||
(normalizedPlatform == null || release.Platform == normalizedPlatform) &&
|
||||
(normalizedChannel == null || release.Channel == normalizedChannel)))
|
||||
.SingleOrDefaultAsync(x => x.Slug == slug && x.IsListed, cancellationToken);
|
||||
|
||||
if (catalogApp is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var latestRelease = SelectLatestRelease(catalogApp, normalizedPlatform, normalizedChannel);
|
||||
if (latestRelease is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var physicalPath = storageService.ResolvePhysicalPath(latestRelease.StoredRelativePath);
|
||||
if (!File.Exists(physicalPath))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
SetPackageCacheHeaders(httpContext, immutable: false);
|
||||
return Results.File(
|
||||
physicalPath,
|
||||
latestRelease.ContentType,
|
||||
latestRelease.OriginalFileName,
|
||||
lastModified: File.GetLastWriteTimeUtc(physicalPath),
|
||||
entityTag: CreatePackageEntityTag(latestRelease.Sha256),
|
||||
enableRangeProcessing: true);
|
||||
});
|
||||
|
||||
api.MapGet("/apps/{slug}/releases/{releaseId:guid}/download", async Task<IResult> (
|
||||
string slug,
|
||||
Guid releaseId,
|
||||
HttpContext httpContext,
|
||||
ArgusDbContext dbContext,
|
||||
PackageStorageService storageService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!SlugUtility.IsValid(slug))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var release = await dbContext.Releases
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Id == releaseId && x.CatalogApp.Slug == slug && x.CatalogApp.IsListed)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StoredRelativePath,
|
||||
x.ContentType,
|
||||
x.OriginalFileName,
|
||||
x.Sha256
|
||||
})
|
||||
.SingleOrDefaultAsync(
|
||||
cancellationToken);
|
||||
|
||||
if (release is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var physicalPath = storageService.ResolvePhysicalPath(release.StoredRelativePath);
|
||||
if (!File.Exists(physicalPath))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
SetPackageCacheHeaders(httpContext, immutable: true);
|
||||
return Results.File(
|
||||
physicalPath,
|
||||
release.ContentType,
|
||||
release.OriginalFileName,
|
||||
lastModified: File.GetLastWriteTimeUtc(physicalPath),
|
||||
entityTag: CreatePackageEntityTag(release.Sha256),
|
||||
enableRangeProcessing: true);
|
||||
});
|
||||
|
||||
api.MapGet("/releases/recent", async (ArgusDbContext dbContext, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var releases = (await dbContext.Releases
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT "Releases".*
|
||||
FROM "Releases"
|
||||
INNER JOIN "Apps" ON "Apps"."Id" = "Releases"."CatalogAppId"
|
||||
WHERE "Apps"."IsListed" = 1
|
||||
ORDER BY "Releases"."PublishedAt" DESC, "Releases"."Id" DESC
|
||||
LIMIT 12
|
||||
""")
|
||||
.AsNoTracking()
|
||||
.Include(x => x.CatalogApp)
|
||||
.ToListAsync(cancellationToken))
|
||||
.OrderByDescending(x => x.PublishedAt)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.ToList();
|
||||
|
||||
var items = releases.Select(release => new ReleaseFeedItemDto(
|
||||
release.CatalogApp.Slug,
|
||||
release.CatalogApp.Name,
|
||||
MapReleaseItem(release.CatalogApp.Slug, release)));
|
||||
|
||||
return Results.Ok(items);
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
static AppListItemDto MapListItem(CatalogApp catalogApp)
|
||||
{
|
||||
var latestRelease = OrderReleases(catalogApp.Releases)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new AppListItemDto(
|
||||
catalogApp.Slug,
|
||||
catalogApp.Name,
|
||||
catalogApp.Summary,
|
||||
catalogApp.RepositoryUrl,
|
||||
catalogApp.HomepageUrl,
|
||||
catalogApp.UpdatedAt,
|
||||
latestRelease is null ? null : MapReleaseItem(catalogApp.Slug, latestRelease),
|
||||
catalogApp.Releases.Count);
|
||||
}
|
||||
|
||||
static AppDetailDto MapDetailItem(CatalogApp catalogApp)
|
||||
{
|
||||
var releases = OrderReleases(catalogApp.Releases)
|
||||
.Select(release => MapReleaseItem(catalogApp.Slug, release))
|
||||
.ToArray();
|
||||
|
||||
return new AppDetailDto(
|
||||
catalogApp.Slug,
|
||||
catalogApp.Name,
|
||||
catalogApp.Summary,
|
||||
catalogApp.Description,
|
||||
catalogApp.RepositoryUrl,
|
||||
catalogApp.HomepageUrl,
|
||||
catalogApp.CreatedAt,
|
||||
catalogApp.UpdatedAt,
|
||||
catalogApp.IsListed,
|
||||
releases);
|
||||
}
|
||||
|
||||
static AppReleaseDto MapReleaseItem(string slug, AppRelease release) =>
|
||||
new(
|
||||
release.Id,
|
||||
release.Version,
|
||||
release.Channel,
|
||||
release.Platform,
|
||||
release.PackageKind,
|
||||
$"/api/apps/{slug}/releases/{release.Id}/download",
|
||||
release.OriginalFileName,
|
||||
release.ContentType,
|
||||
release.PackageSizeBytes,
|
||||
release.Sha256,
|
||||
release.PublishedAt,
|
||||
release.Notes);
|
||||
|
||||
static AppRelease? SelectLatestRelease(CatalogApp catalogApp, string? platform, string? channel)
|
||||
{
|
||||
return OrderReleases(catalogApp.Releases
|
||||
.Where(x => platform is null || x.Platform == platform)
|
||||
.Where(x => channel is null || x.Channel == channel))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
static IOrderedEnumerable<AppRelease> OrderReleases(IEnumerable<AppRelease> releases) =>
|
||||
releases
|
||||
.OrderByDescending(x => x.Version, Comparer<string>.Create(SemanticVersionUtility.Compare))
|
||||
.ThenByDescending(x => x.PublishedAt)
|
||||
.ThenByDescending(x => x.Id);
|
||||
|
||||
static void DeleteStoredPackages(PackageStorageService storageService, IEnumerable<AppRelease> releases)
|
||||
{
|
||||
foreach (var release in releases)
|
||||
{
|
||||
storageService.TryDelete(release.StoredRelativePath);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task PruneOldReleasesAsync(
|
||||
ArgusDbContext dbContext,
|
||||
PackageStorageService storageService,
|
||||
int maxRetainedReleasesPerApp,
|
||||
CancellationToken cancellationToken = default,
|
||||
Guid? catalogAppId = null)
|
||||
{
|
||||
if (maxRetainedReleasesPerApp < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxRetainedReleasesPerApp));
|
||||
}
|
||||
|
||||
IQueryable<AppRelease> releasesQuery = dbContext.Releases;
|
||||
if (catalogAppId is { } appId)
|
||||
{
|
||||
releasesQuery = releasesQuery.Where(x => x.CatalogAppId == appId);
|
||||
}
|
||||
|
||||
var releases = await releasesQuery.ToListAsync(cancellationToken);
|
||||
var releasesToPrune = releases
|
||||
.GroupBy(x => x.CatalogAppId)
|
||||
.SelectMany(group => group
|
||||
.OrderByDescending(x => x.PublishedAt)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Skip(maxRetainedReleasesPerApp))
|
||||
.ToArray();
|
||||
|
||||
if (releasesToPrune.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dbContext.Releases.RemoveRange(releasesToPrune);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
DeleteStoredPackages(storageService, releasesToPrune);
|
||||
}
|
||||
|
||||
static void SetPackageCacheHeaders(HttpContext httpContext, bool immutable)
|
||||
{
|
||||
httpContext.Response.Headers.CacheControl = immutable
|
||||
? "public,max-age=31536000,immutable,no-transform"
|
||||
: "private,no-cache,no-transform";
|
||||
}
|
||||
|
||||
static EntityTagHeaderValue CreatePackageEntityTag(string sha256) =>
|
||||
new($"\"{sha256}\"");
|
||||
|
||||
static string? NormalizeFilter(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : IdentifierUtility.Normalize(value, string.Empty);
|
||||
@@ -0,0 +1,105 @@
|
||||
# Argus
|
||||
|
||||
`Argus` is an ASP.NET Core 10 web application. It serves a public catalog of published applications and downloadable release files.
|
||||
|
||||
## Scope
|
||||
|
||||
- public web catalog
|
||||
- public read-only API
|
||||
- package file serving from disk
|
||||
- SQLite metadata storage
|
||||
- Docker-ready deployment for Raspberry Pi 5 (`linux/arm64`)
|
||||
|
||||
There is no HTTP admin API, browser publishing form, or external publishing endpoint. Releases must be published on the server side, for example over SSH, by updating the SQLite database and placing package files under `Data/Packages`.
|
||||
|
||||
Use [ARGUS_PUBLICATION.md](ARGUS_PUBLICATION.md) as the canonical instruction for publishing applications and integrating update checks through Argus.
|
||||
|
||||
## What It Stores
|
||||
|
||||
- app metadata: slug, name, summary, description, repository URL, homepage URL
|
||||
- release metadata: version, channel, platform, package kind, notes, publish timestamp
|
||||
- package files on disk under `Data/Packages`
|
||||
- SHA-256 checksum and file size for each package
|
||||
|
||||
`latest` release selection is based on semantic version precedence first, then on publish time as a tie-breaker.
|
||||
|
||||
Successful startup keeps only the most recently published release per app. Older release records and their stored package files are pruned automatically.
|
||||
|
||||
SQLite data is stored in `Data/argus.db`.
|
||||
|
||||
## Public API
|
||||
|
||||
- `GET /health`
|
||||
- `GET /api/apps`
|
||||
- `GET /api/apps/{slug}`
|
||||
- `GET /api/apps/{slug}/manifest?platform=web&channel=stable`
|
||||
- `GET /api/apps/{slug}/download/latest?platform=web&channel=stable`
|
||||
- `GET /api/apps/{slug}/releases/{releaseId}/download`
|
||||
- `GET /api/releases/recent`
|
||||
|
||||
## Local Run
|
||||
|
||||
```powershell
|
||||
dotnet run --project .\Argus.csproj
|
||||
```
|
||||
|
||||
Default local URL:
|
||||
|
||||
```text
|
||||
http://localhost:5105
|
||||
```
|
||||
|
||||
## Publishing
|
||||
|
||||
Publishing is intentionally not exposed through HTTP. Connect to the server over SSH, copy package files into `Data/Packages`, and update `Data/argus.db` locally.
|
||||
|
||||
See [ARGUS_PUBLICATION.md](ARGUS_PUBLICATION.md) for the complete publication command template and the client update protocol.
|
||||
|
||||
Required release fields in SQLite:
|
||||
|
||||
- `CatalogAppId`
|
||||
- `Version`
|
||||
- `Channel`
|
||||
- `Platform`
|
||||
- `PackageKind`
|
||||
- `OriginalFileName`
|
||||
- `StoredRelativePath`
|
||||
- `ContentType`
|
||||
- `PackageSizeBytes`
|
||||
- `Sha256`
|
||||
- `PublishedAt`
|
||||
|
||||
## Docker On Raspberry Pi 5
|
||||
|
||||
Files included:
|
||||
|
||||
- `Dockerfile`
|
||||
- `docker-compose.rpi5.yml`
|
||||
- `.env.example`
|
||||
|
||||
Quick start:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.rpi5.yml up -d --build
|
||||
```
|
||||
|
||||
The service binds only to `127.0.0.1:5105`. Put Nginx or another reverse proxy in front of it for external access.
|
||||
|
||||
## Reverse Proxy For argus.kusoft.xyz
|
||||
|
||||
Prepared Nginx site config:
|
||||
|
||||
- `deploy/nginx/argus.kusoft.xyz.conf`
|
||||
|
||||
It is configured to:
|
||||
|
||||
- redirect `http://argus.kusoft.xyz` to HTTPS
|
||||
- proxy HTTPS traffic to `http://127.0.0.1:5105`
|
||||
- pass `X-Forwarded-*` headers expected by the app
|
||||
- use standard Let's Encrypt certificate paths for `argus.kusoft.xyz`
|
||||
|
||||
Persistent data mount:
|
||||
|
||||
- host: `${ARGUS_DATA_PATH}`
|
||||
- container: `/app/Data`
|
||||
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"ArgusDb": "Data Source=Data/argus.db"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<VirtualHost *:80>
|
||||
ServerName argus.kusoft.xyz
|
||||
|
||||
Alias /.well-known/acme-challenge/ /var/www/argus/.well-known/acme-challenge/
|
||||
|
||||
<Directory "/var/www/argus/.well-known/acme-challenge/">
|
||||
Options None
|
||||
AllowOverride None
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
ProxyPass /.well-known/acme-challenge/ !
|
||||
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
|
||||
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName argus.kusoft.xyz
|
||||
|
||||
SSLEngine On
|
||||
SSLCertificateFile /etc/letsencrypt/live/argus.kusoft.xyz/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/argus.kusoft.xyz/privkey.pem
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
AllowEncodedSlashes NoDecode
|
||||
ProxyTimeout 600
|
||||
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
RequestHeader set X-Forwarded-Host "argus.kusoft.xyz"
|
||||
|
||||
ProxyPass / http://127.0.0.1:5105/
|
||||
ProxyPassReverse / http://127.0.0.1:5105/
|
||||
</VirtualHost>
|
||||
@@ -0,0 +1,41 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name argus.kusoft.xyz;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name argus.kusoft.xyz;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/argus.kusoft.xyz/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/argus.kusoft.xyz/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
client_max_body_size 2g;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5105;
|
||||
proxy_http_version 1.1;
|
||||
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 https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
argus:
|
||||
container_name: argus
|
||||
image: argus:rpi5
|
||||
platform: linux/arm64
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5105:5105"
|
||||
volumes:
|
||||
- ${ARGUS_DATA_PATH:?ARGUS_DATA_PATH must be set in .env}:/app/Data
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="456" height="456" viewBox="0 0 456 456">
|
||||
<path fill="#2B1B15" d="M104,0H352A104,104 0 0 1 456,104V352A104,104 0 0 1 352,456H104A104,104 0 0 1 0,352V104A104,104 0 0 1 104,0Z"/>
|
||||
<path fill="#3A2218" d="M124,36H332A88,88 0 0 1 420,124V332A88,88 0 0 1 332,420H124A88,88 0 0 1 36,332V124A88,88 0 0 1 124,36Z"/>
|
||||
<path fill="none" stroke="#A65436" stroke-opacity="0.28" stroke-width="18" d="M228,74A154,154 0 1 1 228,382A154,154 0 1 1 228,74Z"/>
|
||||
<path fill="none" stroke="#F2CBB0" stroke-opacity="0.18" stroke-width="10" d="M228,112A116,116 0 1 1 228,344A116,116 0 1 1 228,112Z"/>
|
||||
<path fill="none" stroke="#A65436" stroke-opacity="0.42" stroke-linecap="round" stroke-width="18" d="M110,332C141,358 179,372 228,372C277,372 315,358 346,332"/>
|
||||
<path fill="#FFF6ED" d="M134,112C98,112 70,140 70,176V298C70,334 98,362 134,362H188C205,362 220,367 228,378V194C217,143 182,112 144,112H134Z"/>
|
||||
<path fill="#EAD3BE" d="M322,112C358,112 386,140 386,176V298C386,334 358,362 322,362H268C251,362 236,367 228,378V194C239,143 274,112 312,112H322Z"/>
|
||||
<path fill="#A65436" d="M228,120C244,120 258,134 258,150V252L228,232L198,252V150C198,134 212,120 228,120Z"/>
|
||||
<path fill="#FFF1E4" d="M228,194C214,166 190,152 160,152H128C117,152 108,161 108,172V302C108,313 117,322 128,322H190C204,322 218,327 228,336V194Z"/>
|
||||
<path fill="#E8C7A6" d="M228,194C242,166 266,152 296,152H328C339,152 348,161 348,172V302C348,313 339,322 328,322H266C252,322 238,327 228,336V194Z"/>
|
||||
<path fill="none" stroke="#D5A789" stroke-linecap="round" stroke-width="10" d="M144,194H190"/>
|
||||
<path fill="none" stroke="#D5A789" stroke-opacity="0.6" stroke-linecap="round" stroke-width="10" d="M144,226H184"/>
|
||||
<path fill="none" stroke="#B97E58" stroke-linecap="round" stroke-width="10" d="M266,194H312"/>
|
||||
<path fill="none" stroke="#B97E58" stroke-opacity="0.6" stroke-linecap="round" stroke-width="10" d="M272,226H312"/>
|
||||
<path fill="#A65436" d="M228,134L282,292H254L244,258H212L202,292H174L228,134Z"/>
|
||||
<path fill="#FFD9B7" d="M218,236H238L228,190L218,236Z"/>
|
||||
<path fill="#FFD9B7" d="M202,272A6,6 0 0 1 208,266H248A6,6 0 0 1 254,272A6,6 0 0 1 248,278H208A6,6 0 0 1 202,272Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 320 KiB |
@@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Argus</title>
|
||||
<link rel="icon" type="image/png" href="/assets/argus-icon.png?v=argus-panoptes-3d-20260508">
|
||||
<link rel="stylesheet" href="/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="store-header">
|
||||
<nav class="store-nav" aria-label="Главная навигация">
|
||||
<a class="brand" href="/" aria-label="Argus">
|
||||
<img class="brand-icon" src="/assets/argus-icon.png?v=argus-panoptes-3d-20260508" alt="" width="34" height="34">
|
||||
<span>Argus</span>
|
||||
</a>
|
||||
<div class="nav-tabs" aria-label="Разделы">
|
||||
<a class="active" href="/">Приложения</a>
|
||||
<a href="#release-card">Версия</a>
|
||||
</div>
|
||||
<button class="icon-button" id="refresh-button" type="button" aria-label="Обновить данные">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 12a8 8 0 1 1-2.34-5.66" />
|
||||
<path d="M20 4v6h-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="store-shell">
|
||||
<div class="device-tabs" aria-label="Устройства">
|
||||
<span class="device-chip active">Телефон</span>
|
||||
</div>
|
||||
|
||||
<section class="app-hero" id="catalog" aria-labelledby="app-name">
|
||||
<div class="app-summary">
|
||||
<div class="app-title-row">
|
||||
<img class="app-icon" id="app-icon" src="/assets/argus-icon.png?v=argus-panoptes-3d-20260508" alt="" width="112" height="112">
|
||||
<div>
|
||||
<p class="collection-label">Опубликованные приложения</p>
|
||||
<h1 id="app-name">Argus</h1>
|
||||
<p class="developer-name">Kusoft</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="app-description" id="app-description">Загрузка каталога приложений...</p>
|
||||
<div class="app-actions">
|
||||
<a class="button primary" id="primary-download" href="#" aria-disabled="true">Скачать файл</a>
|
||||
<a class="button secondary" id="manifest-link" href="#" target="_blank" rel="noreferrer">Манифест</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="release-card" id="release-card" aria-label="Текущая версия">
|
||||
<div class="release-card-head">
|
||||
<span>Текущая версия</span>
|
||||
<strong id="release-version">-</strong>
|
||||
</div>
|
||||
<div class="stat-grid" id="detail-meta"></div>
|
||||
<div class="release-list" id="release-list"></div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="store-section" aria-labelledby="available-title">
|
||||
<div class="section-head">
|
||||
<h2 id="available-title">Доступно в Argus</h2>
|
||||
</div>
|
||||
<div class="catalog-grid" id="catalog-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="store-section" aria-labelledby="current-release-title">
|
||||
<div class="section-head">
|
||||
<h2 id="current-release-title">Последняя публикация</h2>
|
||||
</div>
|
||||
<div class="timeline" id="recent-releases"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/site.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,634 @@
|
||||
:root {
|
||||
--bg: #060711;
|
||||
--bg-2: #0b0f20;
|
||||
--surface: rgba(15, 20, 39, 0.78);
|
||||
--surface-strong: rgba(23, 29, 55, 0.92);
|
||||
--surface-soft: rgba(255, 255, 255, 0.055);
|
||||
--surface-lift: rgba(255, 255, 255, 0.09);
|
||||
--text: #f4f7ff;
|
||||
--muted: #b6c0d8;
|
||||
--subtle: #7f8ba7;
|
||||
--line: rgba(178, 194, 255, 0.2);
|
||||
--line-soft: rgba(178, 194, 255, 0.12);
|
||||
--cyan: #56e4ff;
|
||||
--cyan-soft: rgba(86, 228, 255, 0.14);
|
||||
--gold: #f7c76d;
|
||||
--gold-soft: rgba(247, 199, 109, 0.16);
|
||||
--violet: #8b5cf6;
|
||||
--green: #35d6a1;
|
||||
--green-dark: #13b884;
|
||||
--green-soft: rgba(53, 214, 161, 0.14);
|
||||
--danger: #ff7b7b;
|
||||
--shadow: 0 24px 70px rgba(0, 0, 0, 0.34);
|
||||
--glass-blur: blur(22px) saturate(130%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
background:
|
||||
linear-gradient(145deg, #03040b 0%, #090b18 34%, #141024 72%, #050711 100%),
|
||||
repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.035) 0 1px, transparent 1px 96px),
|
||||
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 96px);
|
||||
font-family: "Segoe UI", Roboto, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(115deg, transparent 0 16%, rgba(86, 228, 255, 0.09) 16% 17%, transparent 17% 100%),
|
||||
linear-gradient(72deg, transparent 0 60%, rgba(247, 199, 109, 0.09) 60% 61%, transparent 61% 100%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 28%, rgba(0, 0, 0, 0.22));
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.store-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: rgba(6, 7, 17, 0.84);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.26);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
.store-nav {
|
||||
width: min(1280px, calc(100% - 48px));
|
||||
min-height: 72px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-size: 1.42rem;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: block;
|
||||
padding: 3px;
|
||||
border: 1px solid rgba(247, 199, 109, 0.58);
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(86, 228, 255, 0.36));
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(86, 228, 255, 0.24),
|
||||
0 0 26px rgba(86, 228, 255, 0.34),
|
||||
0 0 38px rgba(247, 199, 109, 0.18);
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
.nav-tabs a {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.96rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nav-tabs a.active {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.nav-tabs a.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
border-radius: 999px 999px 0 0;
|
||||
background: linear-gradient(90deg, var(--gold), var(--cyan));
|
||||
box-shadow: 0 0 18px rgba(86, 228, 255, 0.46);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-left: auto;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 50%;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--line-soft);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.store-shell {
|
||||
width: min(1280px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
padding: 30px 0 78px;
|
||||
}
|
||||
|
||||
.device-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px 0 30px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.device-chip {
|
||||
min-height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
font-size: 0.94rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.device-chip.active {
|
||||
border-color: rgba(247, 199, 109, 0.42);
|
||||
color: var(--gold);
|
||||
background: linear-gradient(135deg, rgba(247, 199, 109, 0.16), rgba(86, 228, 255, 0.08));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
|
||||
.app-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 390px;
|
||||
gap: 44px;
|
||||
padding: 36px 0 46px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.app-summary {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 118px;
|
||||
height: 118px;
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
padding: 7px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: 28px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow:
|
||||
0 22px 42px rgba(0, 0, 0, 0.32),
|
||||
0 0 34px rgba(86, 228, 255, 0.14);
|
||||
}
|
||||
|
||||
.collection-label {
|
||||
margin: 0 0 8px;
|
||||
color: var(--gold);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: clamp(3rem, 6vw, 4.75rem);
|
||||
font-weight: 540;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.06;
|
||||
text-shadow: 0 0 36px rgba(86, 228, 255, 0.16);
|
||||
}
|
||||
|
||||
.developer-name {
|
||||
margin: 8px 0 0;
|
||||
color: var(--cyan);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-description {
|
||||
max-width: 760px;
|
||||
margin: 30px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.68;
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 46px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 24px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
font-size: 0.96rem;
|
||||
font-weight: 760;
|
||||
cursor: pointer;
|
||||
transition: transform 160ms ease, background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
color: #08101f;
|
||||
background: linear-gradient(135deg, #ffe29a, var(--gold) 52%, #51dfff);
|
||||
box-shadow: 0 14px 34px rgba(247, 199, 109, 0.18), 0 0 24px rgba(86, 228, 255, 0.14);
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
box-shadow: 0 18px 40px rgba(247, 199, 109, 0.26), 0 0 30px rgba(86, 228, 255, 0.2);
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
border-color: rgba(86, 228, 255, 0.4);
|
||||
background: rgba(86, 228, 255, 0.09);
|
||||
}
|
||||
|
||||
.button[aria-disabled="true"] {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.release-card {
|
||||
min-width: 0;
|
||||
align-self: start;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(26, 32, 60, 0.86), rgba(12, 16, 32, 0.82));
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
.release-card-head {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.release-card-head span,
|
||||
.meta-label {
|
||||
color: var(--subtle);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.release-card-head strong {
|
||||
color: var(--gold);
|
||||
font-size: 1.58rem;
|
||||
font-weight: 720;
|
||||
text-shadow: 0 0 22px rgba(247, 199, 109, 0.32);
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meta-block {
|
||||
min-height: 82px;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.36;
|
||||
}
|
||||
|
||||
.meta-value a {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.store-section {
|
||||
padding: 36px 0 0;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 1.46rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.catalog-grid,
|
||||
.timeline,
|
||||
.release-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.catalog-grid {
|
||||
grid-template-columns: minmax(280px, 430px);
|
||||
}
|
||||
|
||||
.app-card,
|
||||
.timeline-item,
|
||||
.release-item {
|
||||
display: grid;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
grid-template-columns: 76px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
max-width: 430px;
|
||||
padding: 14px;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.app-card:hover,
|
||||
.app-card.active {
|
||||
border-color: var(--line-soft);
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.app-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.app-card-icon {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
display: block;
|
||||
padding: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.66);
|
||||
border-radius: 18px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.app-card h3,
|
||||
.timeline-item h3,
|
||||
.release-item h3 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.app-card p,
|
||||
.timeline-item p,
|
||||
.release-item p,
|
||||
.status,
|
||||
.release-notes,
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.app-card p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.rating-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
color: var(--subtle);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.timeline-item,
|
||||
.release-item {
|
||||
gap: 12px;
|
||||
padding: 18px 0;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.release-item {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.timeline-item header,
|
||||
.release-item header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pill-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(53, 214, 161, 0.2);
|
||||
border-radius: 999px;
|
||||
color: #8af6cf;
|
||||
background: var(--green-soft);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.release-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.release-links a {
|
||||
color: var(--gold);
|
||||
font-weight: 760;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.release-links a:hover {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 18px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.store-nav,
|
||||
.store-shell {
|
||||
width: min(100% - 28px, 1280px);
|
||||
}
|
||||
|
||||
.store-nav {
|
||||
min-height: 64px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.brand span:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
gap: 18px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.app-hero,
|
||||
.stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-hero {
|
||||
gap: 30px;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.app-title-row {
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.release-card {
|
||||
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.store-shell {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.device-tabs {
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.45rem;
|
||||
}
|
||||
|
||||
.app-actions,
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.catalog-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
const fallbackIcon = "/assets/argus-icon.png?v=argus-panoptes-3d-20260508";
|
||||
const appIcons = new Map([
|
||||
["aletheia-kotlin", "/assets/aletheia-icon.svg?v=aletheia-launcher-20260508"]
|
||||
]);
|
||||
|
||||
const state = {
|
||||
apps: [],
|
||||
selectedSlug: null
|
||||
};
|
||||
|
||||
const catalogGrid = document.getElementById("catalog-grid");
|
||||
const recentReleases = document.getElementById("recent-releases");
|
||||
const appIcon = document.getElementById("app-icon");
|
||||
const appName = document.getElementById("app-name");
|
||||
const appDescription = document.getElementById("app-description");
|
||||
const releaseVersion = document.getElementById("release-version");
|
||||
const detailMeta = document.getElementById("detail-meta");
|
||||
const releaseList = document.getElementById("release-list");
|
||||
const manifestLink = document.getElementById("manifest-link");
|
||||
const primaryDownload = document.getElementById("primary-download");
|
||||
const refreshButton = document.getElementById("refresh-button");
|
||||
|
||||
refreshButton.addEventListener("click", () => loadAll());
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
|
||||
loadAll();
|
||||
|
||||
async function loadAll() {
|
||||
await Promise.all([loadCatalog(), loadRecentReleases()]);
|
||||
handleHashChange();
|
||||
}
|
||||
|
||||
async function loadCatalog() {
|
||||
const response = await fetch("/api/apps");
|
||||
const apps = await response.json();
|
||||
state.apps = Array.isArray(apps) ? apps : [];
|
||||
renderCatalog();
|
||||
}
|
||||
|
||||
async function loadRecentReleases() {
|
||||
const response = await fetch("/api/releases/recent");
|
||||
const items = await response.json();
|
||||
|
||||
if (!Array.isArray(items) || !items.length) {
|
||||
recentReleases.innerHTML = '<div class="empty-state">Пока нет опубликованных релизов.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
recentReleases.innerHTML = items.map(item => `
|
||||
<article class="timeline-item">
|
||||
<header>
|
||||
<h3>${escapeHtml(item.appName)}</h3>
|
||||
<span class="pill">${escapeHtml(item.release.version)}</span>
|
||||
</header>
|
||||
<p>${escapeHtml(item.release.platform)} - ${escapeHtml(item.release.packageKind)} - ${formatDate(item.release.publishedAt)}</p>
|
||||
<div class="release-links">
|
||||
<a href="#${encodeURIComponent(item.slug)}">Открыть карточку</a>
|
||||
<a href="${item.release.downloadPath}">Скачать</a>
|
||||
</div>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderCatalog() {
|
||||
if (!state.apps.length) {
|
||||
catalogGrid.innerHTML = '<div class="empty-state">Пока нет опубликованных приложений.</div>';
|
||||
renderEmptyDetail();
|
||||
return;
|
||||
}
|
||||
|
||||
catalogGrid.innerHTML = state.apps.map(app => {
|
||||
const isActive = app.slug === state.selectedSlug;
|
||||
const latest = app.latestRelease;
|
||||
return `
|
||||
<article class="app-card ${isActive ? "active" : ""}" data-slug="${escapeHtml(app.slug)}">
|
||||
${renderAppIcon("app-card-icon", app, 72)}
|
||||
<div>
|
||||
<h3>${escapeHtml(app.name)}</h3>
|
||||
<p>${escapeHtml(app.summary)}</p>
|
||||
<div class="rating-line">
|
||||
<span>${latest ? escapeHtml(latest.version) : "нет релиза"}</span>
|
||||
<span>${latest ? escapeHtml(latest.packageKind) : "пакет"}</span>
|
||||
<span>${app.releaseCount} версия</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
for (const card of catalogGrid.querySelectorAll(".app-card")) {
|
||||
card.addEventListener("click", () => {
|
||||
window.location.hash = card.dataset.slug;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleHashChange() {
|
||||
const slugFromHash = decodeURIComponent(window.location.hash.replace(/^#/, ""));
|
||||
const hashApp = state.apps.find(app => app.slug === slugFromHash);
|
||||
const nextSlug = hashApp?.slug ?? state.apps[0]?.slug ?? null;
|
||||
state.selectedSlug = nextSlug;
|
||||
|
||||
if (!nextSlug) {
|
||||
renderEmptyDetail();
|
||||
return;
|
||||
}
|
||||
|
||||
renderCatalog();
|
||||
|
||||
if (slugFromHash && !hashApp) {
|
||||
window.history.replaceState(null, "", `#${encodeURIComponent(nextSlug)}`);
|
||||
}
|
||||
|
||||
loadAppDetail(nextSlug);
|
||||
}
|
||||
|
||||
async function loadAppDetail(slug) {
|
||||
const response = await fetch(`/api/apps/${encodeURIComponent(slug)}`);
|
||||
if (!response.ok) {
|
||||
renderEmptyDetail("Карточка приложения не загрузилась.");
|
||||
return;
|
||||
}
|
||||
|
||||
const app = await response.json();
|
||||
const latest = app.releases[0] ?? null;
|
||||
appIcon.src = getAppIconSrc(app);
|
||||
appName.textContent = app.name;
|
||||
appDescription.textContent = app.description || app.summary;
|
||||
manifestLink.href = `/api/apps/${encodeURIComponent(app.slug)}/manifest`;
|
||||
|
||||
if (latest) {
|
||||
releaseVersion.textContent = latest.version;
|
||||
primaryDownload.href = latest.downloadPath;
|
||||
primaryDownload.removeAttribute("aria-disabled");
|
||||
} else {
|
||||
releaseVersion.textContent = "-";
|
||||
primaryDownload.href = "#";
|
||||
primaryDownload.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
|
||||
detailMeta.innerHTML = `
|
||||
${renderMetaBlock("Обновлено", formatDate(app.updatedAt))}
|
||||
${renderMetaBlock("Платформа", latest ? latest.platform : "Не опубликовано")}
|
||||
${renderMetaBlock("Тип", latest ? latest.packageKind : "-")}
|
||||
${renderMetaBlock("Размер", latest ? formatBytes(latest.packageSizeBytes) : "-")}
|
||||
`;
|
||||
|
||||
if (!app.releases.length) {
|
||||
releaseList.innerHTML = '<div class="empty-state">Для этого приложения ещё нет опубликованного файла.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
releaseList.innerHTML = app.releases.slice(0, 1).map(release => `
|
||||
<article class="release-item">
|
||||
<header>
|
||||
<h3>Файл ${escapeHtml(release.version)}</h3>
|
||||
<span class="pill">${escapeHtml(release.channel)}</span>
|
||||
</header>
|
||||
<p>${formatDate(release.publishedAt)} - ${formatBytes(release.packageSizeBytes)} - ${escapeHtml(release.originalFileName)}</p>
|
||||
${release.notes ? `<p class="release-notes">${escapeHtml(release.notes)}</p>` : ""}
|
||||
<div class="pill-row">
|
||||
<span class="pill">${escapeHtml(release.platform)}</span>
|
||||
<span class="pill">${escapeHtml(release.packageKind)}</span>
|
||||
</div>
|
||||
<div class="release-links">
|
||||
<a href="${release.downloadPath}">Скачать файл</a>
|
||||
<a href="/api/apps/${encodeURIComponent(app.slug)}/manifest?platform=${encodeURIComponent(release.platform)}&channel=${encodeURIComponent(release.channel)}" target="_blank" rel="noreferrer">Открыть manifest</a>
|
||||
</div>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderEmptyDetail(message = "Данные приложения появятся после загрузки API.") {
|
||||
appIcon.src = fallbackIcon;
|
||||
appName.textContent = "Argus";
|
||||
appDescription.textContent = message;
|
||||
releaseVersion.textContent = "-";
|
||||
detailMeta.innerHTML = "";
|
||||
releaseList.innerHTML = "";
|
||||
manifestLink.href = "#";
|
||||
primaryDownload.href = "#";
|
||||
primaryDownload.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
|
||||
function renderMetaBlock(label, value) {
|
||||
return `
|
||||
<div class="meta-block">
|
||||
<span class="meta-label">${escapeHtml(label)}</span>
|
||||
<div class="meta-value">${escapeHtml(value)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAppIcon(className, app, size) {
|
||||
return `<img class="${escapeHtml(className)}" src="${escapeHtml(getAppIconSrc(app))}" alt="" width="${size}" height="${size}">`;
|
||||
}
|
||||
|
||||
function getAppIconSrc(app) {
|
||||
return appIcons.get(app?.slug) ?? fallbackIcon;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return "0 Б";
|
||||
}
|
||||
|
||||
const units = ["Б", "КБ", "МБ", "ГБ"];
|
||||
let index = 0;
|
||||
let value = bytes;
|
||||
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
Reference in New Issue
Block a user