Skip to content
Open
Show file tree
Hide file tree
Changes from 47 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
8f68456
feat(webapi): support HTTP-only listener and configurable WebDAV probe
Papyszoo May 14, 2026
81a65ee
feat(asset-processor): add hardware-acceleration toggle
Papyszoo May 14, 2026
808c349
feat: add native desktop launcher (Electron)
Papyszoo May 14, 2026
728a809
ci: build and test native installers on all three platforms
Papyszoo May 14, 2026
85a7336
docs: document native installer alongside Docker
Papyszoo May 14, 2026
a9a3cda
feat(desktop): tray host with service status window
Papyszoo Jun 3, 2026
bac256d
feat(desktop): host configuration panel + headless-safe tray
Papyszoo Jun 3, 2026
1bd7910
feat(desktop-client): standalone Electron client app
Papyszoo Jun 3, 2026
d59ad8e
ci+docs: build desktop client installers and document host/client split
Papyszoo Jun 3, 2026
283ffb9
chore(desktop): drop unused loading screen
Papyszoo Jun 3, 2026
0f90ad6
feat(desktop): host checks GitHub Releases for updates
Papyszoo Jun 3, 2026
f668d43
ci: temporarily build installers on push to this branch
Papyszoo Jun 3, 2026
e8639e4
ci: fix cross-platform installer build (Node path export + Linux home…
Papyszoo Jun 3, 2026
10c6aa5
fix(asset-processor): use @napi-rs/canvas instead of node-canvas
Papyszoo Jun 4, 2026
e871b51
ci: set deb maintainer email and cancel superseded installer runs
Papyszoo Jun 4, 2026
729fb4a
fix(asset-processor): regenerate lockfile in sync for npm ci
Papyszoo Jun 4, 2026
9ea1003
ci: retry flaky Chocolatey PostgreSQL install on Windows
Papyszoo Jun 4, 2026
722965e
fix(desktop): e2e blockers on Linux/macOS/Windows
Papyszoo Jun 4, 2026
9e981f3
ci: bundle self-contained PostgreSQL from Maven Central (Zonky)
Papyszoo Jun 4, 2026
730c8b3
fix(desktop): resolve bundled PostgreSQL shared libs on Linux/macOS
Papyszoo Jun 4, 2026
242a3eb
fix(desktop): dereference PostgreSQL lib symlinks when staging bundle
Papyszoo Jun 4, 2026
057929a
fix(desktop): point WebApi storage paths at userData
Papyszoo Jun 4, 2026
f5b9c0b
fix(desktop): address CodeQL findings (cert validation, rate limiting)
Papyszoo Jun 4, 2026
a66a633
fix(asset-processor): keep lockfile devDeps pinned to main (Prettier …
Papyszoo Jun 4, 2026
8bb885f
test(e2e): de-flake pack-filter and demo sound checks
Papyszoo Jun 4, 2026
21a06a1
fix(webapi): create the database on first boot in native installs
Papyszoo Jun 5, 2026
0bb1306
test(installer): run the full Playwright e2e suite against the instal…
Papyszoo Jun 5, 2026
80a53c3
fix(desktop): stop edge server from eating proxied request bodies
Papyszoo Jun 5, 2026
0e4687a
fix(desktop): default to software rendering for thumbnails
Papyszoo Jun 5, 2026
4b7e035
diag(asset-processor): log render-template page/console/request errors
Papyszoo Jun 5, 2026
0859f68
fix(asset-processor): move import map before module scripts in render…
Papyszoo Jun 5, 2026
8f12814
fix(desktop): share a worker API key between WebApi and asset processor
Papyszoo Jun 5, 2026
73e0393
feat(desktop): default to a CPU-aware asset-worker pool
Papyszoo Jun 5, 2026
2081f94
test(e2e): make suite portable to a non-Docker deployment
Papyszoo Jun 5, 2026
169a022
ci(native): run full E2E suite on Windows and macOS too
Papyszoo Jun 5, 2026
b397f19
fix(asset-processor): render via Metal on macOS (swiftshader has no W…
Papyszoo Jun 5, 2026
72c16bc
ci(native): launch the Windows app without stream redirection
Papyszoo Jun 5, 2026
63f3bed
fix(asset-processor): render via D3D11 (WARP) on Windows software path
Papyszoo Jun 5, 2026
e5429cd
ci(native): skip the demo phase for the installed-app E2E run
Papyszoo Jun 5, 2026
8d28984
ci(native): raise installed-app E2E ceiling to 180m for the slow Wind…
Papyszoo Jun 5, 2026
681ddd4
ci(native): reduce browser parallelism and raise test timeout on host…
Papyszoo Jun 5, 2026
ce042e4
feat(desktop): configurable ports + data folder, and a safe Restart
Papyszoo Jun 6, 2026
6602884
feat(client): explicit host + port connection settings for LAN hosts
Papyszoo Jun 6, 2026
759deca
feat(desktop): one-click Desktop Client install (download + launch)
Papyszoo Jun 6, 2026
c2c821a
feat(desktop+client): in-app auto-update via electron-updater
Papyszoo Jun 6, 2026
238b5df
fix(native): single worker process + single-worker E2E to stop races/…
Papyszoo Jun 6, 2026
6dd19d8
fix(thumbnails): make job claiming atomic so multiple workers are safe
Papyszoo Jun 6, 2026
95661bc
ci(native): non-blocking Chromium UI phase on Windows + extra retries
Papyszoo Jun 6, 2026
e6dd30d
ci(native): drop the temporary feat/native-installer push trigger
Papyszoo Jun 6, 2026
25c5a08
ci(native): publish manual builds to a draft release (separate, unzip…
Papyszoo Jun 6, 2026
83c6271
fix(desktop): honest active-vs-desired config so port changes can't lie
Papyszoo Jun 7, 2026
653e0b7
fix(desktop): address review findings — real edge test, safe worker r…
Papyszoo Jun 11, 2026
c54b89d
fix(desktop): restart no longer hangs ~90s or shows green dots while …
Papyszoo Jun 11, 2026
ad3f857
feat(desktop): detect installed client, warn on app-port change, poli…
Papyszoo Jun 11, 2026
d1d4b33
feat(desktop): simplified status window for non-technical users
Papyszoo Jun 11, 2026
30c4b9b
ci(native): event-tiered installed-app testing (main smoke + nightly …
Papyszoo Jun 11, 2026
9cf7a25
feat(desktop): confirm before quitting the host
Papyszoo Jun 11, 2026
82fb0a9
feat(desktop): auto-pick free ports and migrate data on a folder change
Papyszoo Jun 11, 2026
d654b95
feat(desktop): inform about the leftover data folder instead of delet…
Papyszoo Jun 11, 2026
623674c
test(desktop): host integration harness — data survives a folder change
Papyszoo Jun 11, 2026
9f575fb
feat(desktop): opt-in network access so LAN clients can connect
Papyszoo Jun 11, 2026
207e5ad
fix(release): put the client on its own update channel to avoid feed …
Papyszoo Jun 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
557 changes: 557 additions & 0 deletions .github/workflows/native-release.yml

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

Modelibr is a self-hosted game asset library. It keeps **models**, **texture sets**, **environment maps**, **sprites**, and **sounds** in one place, lets you preview them in the browser, and helps you organize them into **projects** and reusable **packs**.

Native installers for Windows, macOS, and Linux are published in the GitHub Releases page. They bundle the local web UI, WebApi, asset processor, and PostgreSQL so non-technical users can run Modelibr without setting up Docker.

**[Main Site](https://papyszoo.github.io/Modelibr/)** | **[Documentation](https://papyszoo.github.io/Modelibr/docs)** | **[Live Demo](https://papyszoo.github.io/Modelibr/demo/)** | **[Discord](https://discord.gg/KgwgTDVP3F)** | **[GitHub Issues](https://github.com/Papyszoo/Modelibr/issues)**

The live demo stores its data in your browser, so what you add there is visible only to you.
Expand All @@ -33,6 +35,17 @@ The live demo stores its data in your browser, so what you add there is visible

## Quick start

### Native installers

Download the installer for your platform from the GitHub Releases page and run it. Two installers are published:

- **Modelibr** (host) — the full self-contained app for users who can't run the Docker stack. It bundles the database, WebApi, and worker runtime and runs from a tray / menu-bar icon. Open its **Show Status** window for live service health, the frontend URL, and a **Configuration** panel (app port, worker process count, jobs per worker, GPU acceleration). The host checks GitHub Releases for newer versions and surfaces an update prompt in the tray and status window.
- **Modelibr Client** (optional) — a thin desktop window that opens a running host in its own app frame instead of a browser tab. Point it at the host URL shown in the host's status window.

The app and WebDAV are exposed on the same local configurable port.

### Docker

```bash
git clone https://github.com/Papyszoo/Modelibr.git
cd Modelibr
Expand All @@ -48,6 +61,7 @@ Open **https://localhost:3010** in your browser. The first visit uses a self-sig

- WebDAV gives Modelibr a more file-browser style workflow, which is useful when you want the library to sit closer to art-pipeline tools.
- Environment maps are exposed through WebDAV globally and inside packs/projects, alongside the rest of the library.
- Native installers expose WebDAV through the local launcher port by default.
- Blender-related flows are part of the repository, and Blender CLI can be downloaded at runtime from the Settings page when you want that workflow.
- If you want more detail, start with the [main site](https://papyszoo.github.io/Modelibr/) and the [documentation](https://papyszoo.github.io/Modelibr/docs).

Expand Down
4 changes: 3 additions & 1 deletion docs/docs/features/webdav.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ Each environment map directory exposes:

## Notes

- Availability depends on your local deployment configuration and network access - you can change ports in .env file.
- Native installers expose WebDAV on the same local port as the app by default. Change that port in the host status window's **Configuration** panel and restart Modelibr.
- Docker deployments still use `.env` for WebDAV port configuration.
- Availability depends on your local deployment configuration and network access.

:::note Video placeholder
The walkthrough video for this page will be added later.
Expand Down
19 changes: 18 additions & 1 deletion docs/docs/intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,24 @@ Modelibr is a self-hosted game asset library that helps you keep models, texture

## Quick Start

### Option A: Native installer

If you want the simplest local setup, download the **Modelibr** (host) installer for your platform from the GitHub Releases page. It targets users who can't run the Docker stack.

- Windows, macOS, and Linux installers bundle the frontend, WebApi, asset processor, and PostgreSQL.
- The host runs from a tray / menu-bar icon. Use its **Show Status** window for live service health (backend, database, asset processor) and the frontend URL.
- The application and WebDAV are exposed on the same local configurable port.
- Worker process count, jobs per worker, GPU acceleration, and the app port are configured in the host status window's **Configuration** panel.
- The host checks GitHub Releases for newer versions and shows an update prompt in the tray and status window.
- An optional **Modelibr Client** installer opens a running host in its own desktop window instead of a browser tab.

Native installs store data in your operating system's application data folder instead of the repository `data` directory.

### Option B: Docker

### 1. Prerequisites

Modelibr runs on Docker. This ensures it works on any system without polluting your computer with dependencies.
Modelibr runs on Docker. This remains the most transparent source-based setup and keeps all runtime data in the repository folder.

<details>
<summary>**How to install Docker**</summary>
Expand Down Expand Up @@ -124,6 +139,8 @@ All your uploaded assets, generated thumbnails, and database files are stored in
Back up the `data` folder regularly to keep your library safe!
:::

For native installs, the same data lives in the host-managed application data directory. Open it from the host tray menu's **Open Data Folder** action (or the **Data folder** button in the status window).

## Next Steps

- [Model Management](/docs/features/models) - Learn about versions and organization
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"test:frontend": "cd src/frontend && npm test",
"test:e2e": "cd tests/e2e && npm test",
"test:e2e:demo": "cd tests/e2e && npm run test:demo",
"build:frontend": "cd src/frontend && npm run build"
"build:frontend": "cd src/frontend && npm run build",
"stage:desktop": "cd src/desktop && npm run stage",
"build:desktop": "cd src/desktop && npm run dist"
},
"repository": {
"type": "git",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ public interface IThumbnailJobRepository
/// </summary>
Task<ThumbnailJob?> GetNextPendingJobAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Atomically claims a still-pending job for a worker via a single conditional
/// UPDATE (WHERE Status = Pending). Returns true only for the worker whose
/// update actually changed the row, so multiple worker processes can never
/// claim the same job. Returns false if another worker already claimed it.
/// </summary>
Task<bool> TryClaimPendingJobAsync(int jobId, string workerId, DateTime claimedAtUtc, CancellationToken cancellationToken = default);

/// <summary>
/// Gets all jobs with expired locks.
/// </summary>
Expand Down
19 changes: 8 additions & 11 deletions src/Infrastructure/Extensions/DatabaseExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,15 @@ public static async Task InitializeDatabaseAsync(this WebApplication app)
try
{
logger.LogInformation("Attempting database initialization...");

// Test basic connectivity first
var canConnect = await context.Database.CanConnectAsync();
if (!canConnect)
{
logger.LogWarning("Database is not available. Application will start without database connectivity.");
return;
}

// Apply pending migrations to ensure database is up to date

// MigrateAsync creates the database if it doesn't exist yet and then
// applies pending migrations. Do NOT gate this on CanConnectAsync:
// that returns false when the database itself is missing (e.g. the
// native installer's embedded server, where nothing pre-creates the
// "Modelibr" database the way the Docker image's POSTGRES_DB does),
// which would skip the very migration meant to create it.
await context.Database.MigrateAsync();

logger.LogInformation("Database initialization completed successfully");
}
catch (Exception ex)
Expand Down
20 changes: 20 additions & 0 deletions src/Infrastructure/Repositories/ThumbnailJobRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
var job = await _context.ThumbnailJobs
.Include(tj => tj.Model)
.Include(tj => tj.ModelVersion)
.ThenInclude(mv => mv.TextureMappings)

Check warning on line 109 in src/Infrastructure/Repositories/ThumbnailJobRepository.cs

View workflow job for this annotation

GitHub Actions / Build macOS Installer

Dereference of a possibly null reference.

Check warning on line 109 in src/Infrastructure/Repositories/ThumbnailJobRepository.cs

View workflow job for this annotation

GitHub Actions / Build Linux Installer

Dereference of a possibly null reference.

Check warning on line 109 in src/Infrastructure/Repositories/ThumbnailJobRepository.cs

View workflow job for this annotation

GitHub Actions / Build Windows Installer

Dereference of a possibly null reference.
.Include(tj => tj.EnvironmentMap)
.Include(tj => tj.EnvironmentMapVariant)
.Where(tj => tj.Status == ThumbnailJobStatus.Pending ||
Expand All @@ -133,6 +133,26 @@
}
}

public async Task<bool> TryClaimPendingJobAsync(int jobId, string workerId, DateTime claimedAtUtc, CancellationToken cancellationToken = default)
{
// Single atomic statement: UPDATE ... WHERE Id = @id AND Status = Pending.
// PostgreSQL row-locks the matching row, so when several workers race only
// the first commit changes the row; the rest re-evaluate the predicate
// against the now-Processing row and update zero rows. This mirrors the
// domain TryClaim transition (status/lock/attempt/timestamp).
var rowsAffected = await _context.ThumbnailJobs
.Where(tj => tj.Id == jobId && tj.Status == ThumbnailJobStatus.Pending)
.ExecuteUpdateAsync(setters => setters
.SetProperty(tj => tj.Status, ThumbnailJobStatus.Processing)
.SetProperty(tj => tj.LockedBy, workerId)
.SetProperty(tj => tj.LockedAt, claimedAtUtc)
.SetProperty(tj => tj.AttemptCount, tj => tj.AttemptCount + 1)
.SetProperty(tj => tj.UpdatedAt, claimedAtUtc),
cancellationToken);

return rowsAffected == 1;
}

public async Task<IEnumerable<ThumbnailJob>> GetJobsWithExpiredLocksAsync(CancellationToken cancellationToken = default)
{
var currentTime = DateTime.UtcNow;
Expand Down
18 changes: 13 additions & 5 deletions src/Infrastructure/Services/ThumbnailQueue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,16 +202,24 @@
return null;
}

var claimSuccessful = job.TryClaim(workerId, DateTime.UtcNow);
if (!claimSuccessful)
// Atomically claim the job so multiple worker processes can't both pick it
// up. The conditional UPDATE (WHERE Status = Pending) is resolved by the
// database under row locking: exactly one worker's claim changes the row;
// any that lose the race affect zero rows and simply poll again.
var claimedAt = DateTime.UtcNow;
var claimed = await _thumbnailJobRepository.TryClaimPendingJobAsync(job.Id, workerId, claimedAt, cancellationToken);
if (!claimed)
{
_logger.LogWarning("Failed to claim job {JobId} for worker {WorkerId}", job.Id, workerId);
_logger.LogDebug("Worker {WorkerId} lost the claim race for job {JobId}; will poll again", workerId, job.Id);

Check warning

Code scanning / CodeQL

Log entries created from user input Medium

This log entry depends on a
user-provided value
.
return null;
}

await _thumbnailJobRepository.UpdateAsync(job, cancellationToken);
// Reflect the persisted claim on the entity returned to the worker (the
// atomic UPDATE bypassed the change tracker, so the in-memory copy would
// otherwise still look pending). This sets the same fields the UPDATE did.
job.TryClaim(workerId, claimedAt);

_logger.LogInformation("Worker {WorkerId} claimed thumbnail job {JobId} for model {ModelId} version {ModelVersionId} (attempt {AttemptCount})",
_logger.LogInformation("Worker {WorkerId} claimed thumbnail job {JobId} for model {ModelId} version {ModelVersionId} (attempt {AttemptCount})",
workerId, job.Id, job.ModelId, job.ModelVersionId, job.AttemptCount);

// Notify other workers about job status change for coordination
Expand Down
12 changes: 9 additions & 3 deletions src/WebApi/Endpoints/SettingsEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,15 @@ public static void MapSettingsEndpoints(this IEndpointRouteBuilder app)

try
{
// Probe through the internal nginx HTTP server to validate the full
// nginx → webapi chain without TLS certificate issues.
var probeUrl = new Uri(new Uri("http://nginx:80"), new Uri(url).PathAndQuery);
// Probe through the locally configured edge server so the full
// public request chain is validated in both Docker and native mode.
var probeBaseUrl = Environment.GetEnvironmentVariable("WEBDAV_PROBE_BASE_URL")?.Trim();
if (string.IsNullOrWhiteSpace(probeBaseUrl))
{
probeBaseUrl = "http://nginx:80";
}

var probeUrl = new Uri(new Uri(probeBaseUrl), new Uri(url).PathAndQuery);

var httpClient = httpClientFactory.CreateClient("WebDavProbe");
var request = new HttpRequestMessage(new HttpMethod("PROPFIND"), probeUrl);
Expand Down
27 changes: 22 additions & 5 deletions src/WebApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,39 @@ await RestoreOnBootProcessor.RunAsync(builder.Configuration,
// Allow uploads up to 1 GB (Kestrel + form options)
const long maxFileSize = 1L * 1024 * 1024 * 1024; // 1 GB

// Generate a self-signed certificate once in memory for HTTPS
var selfSignedCert = GenerateSelfSignedCertificate();
var disableHttpsListener = builder.Configuration.GetValue<bool>("DISABLE_HTTPS_LISTENER");
var httpPort = builder.Configuration.GetValue<int?>("HTTP_PORT");

X509Certificate2? selfSignedCert = null;
if (!disableHttpsListener)
{
// Generate a self-signed certificate once in memory for HTTPS
selfSignedCert = GenerateSelfSignedCertificate();
}

builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = maxFileSize;

if (httpPort is > 0)
{
options.ListenAnyIP(httpPort.Value);
}

if (disableHttpsListener)
{
return;
}

var httpsPort = builder.Configuration.GetValue<int>("HTTPS_PORT", 8443);
options.ListenAnyIP(httpsPort, listenOptions =>
listenOptions.UseHttps(selfSignedCert));
listenOptions.UseHttps(selfSignedCert!));

var expose443 = builder.Configuration.GetValue<bool>("EXPOSE_443_PORT", true);
if (expose443 && httpsPort != 443)
{
options.ListenAnyIP(443, listenOptions =>
listenOptions.UseHttps(selfSignedCert));
listenOptions.UseHttps(selfSignedCert!));
}
});
builder.Services.Configure<FormOptions>(options =>
Expand Down Expand Up @@ -113,7 +130,7 @@ await RestoreOnBootProcessor.RunAsync(builder.Configuration,
// Only use HTTPS redirection when not running in a container
// This prevents certificate issues with internal Docker communication
var disableHttpsRedirection = builder.Configuration.GetValue<bool>("DisableHttpsRedirection");
if (!disableHttpsRedirection)
if (!disableHttpsRedirection && !disableHttpsListener)
{
app.UseHttpsRedirection();
}
Expand Down
1 change: 1 addition & 0 deletions src/asset-processor/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const config = {
backgroundColor: process.env.RENDER_BACKGROUND || 'transparent',
cameraDistance: parseFloat(process.env.CAMERA_DISTANCE) || 5,
enableAntialiasing: process.env.ENABLE_ANTIALIASING !== 'false',
useHardwareAcceleration: process.env.ENABLE_GPU_RENDERING === 'true',
},

// Orbit animation settings
Expand Down
Loading
Loading