feat: add Android app and end-to-end encrypted library sync

Introduce the Kotlin/Compose Android companion app and extend the TMDB
proxy into a combo server that synchronizes the whole library as an
encrypted snapshot, with all three components (Go server, Android,
desktop) sharing one zero-knowledge crypto envelope and JSON schema.

- server: add PostgreSQL-backed /sync/library (GET/PUT) with optimistic
  concurrency (revision + 409 on conflict); sync stays disabled unless
  DATABASE_URL is set. Add docker-compose with Postgres.
- desktop: add libsodium CryptoEnvelope, LibrarySerializer and SyncClient;
  storage-mode and passphrase settings; a "Sync now" toolbar action with
  conflict resolution.
- android: Room-backed library with local/cloud storage modes, encrypted
  sync client, and settings UI. The proxy server (URL + token) can be
  configured as a TMDB proxy even in local-only mode.

Crypto is identical across platforms: Argon2id (libsodium crypto_pwhash,
INTERACTIVE) + XChaCha20-Poly1305 in a versioned "UMTS" envelope, so a
snapshot encrypted on one client decrypts on the other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tronax 2026-06-21 00:35:54 +02:00
parent 83a26ef0cf
commit de353f0218
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
65 changed files with 4178 additions and 0 deletions

View file

@ -72,6 +72,28 @@ void AppSettings::setRawgApiKey(const QString &key) {
m_s.setValue(QStringLiteral("providers/rawgKey"), key);
}
QString AppSettings::storageMode() const {
return m_s.value(QStringLiteral("sync/storageMode"),
QStringLiteral("local")).toString();
}
void AppSettings::setStorageMode(const QString &mode) {
m_s.setValue(QStringLiteral("sync/storageMode"), mode);
}
QString AppSettings::syncPassphrase() const {
return m_s.value(QStringLiteral("sync/passphrase")).toString();
}
void AppSettings::setSyncPassphrase(const QString &pass) {
m_s.setValue(QStringLiteral("sync/passphrase"), pass);
}
qlonglong AppSettings::syncRevision() const {
return m_s.value(QStringLiteral("sync/revision"), 0).toLongLong();
}
void AppSettings::setSyncRevision(qlonglong revision) {
m_s.setValue(QStringLiteral("sync/revision"), revision);
}
bool AppSettings::autoFetchCovers() const {
return m_s.value(QStringLiteral("providers/autoCovers"), true).toBool();
}

View file

@ -46,6 +46,20 @@ public:
QString rawgApiKey() const;
void setRawgApiKey(const QString &key);
// --- Library sync ---
// Where the library lives: "local" (SQLite only) or "cloud" (E2E sync).
QString storageMode() const; // "local" | "cloud"
void setStorageMode(const QString &mode);
// Passphrase used to derive the end-to-end encryption key. Never leaves the
// device in plaintext; the server only ever sees ciphertext.
QString syncPassphrase() const;
void setSyncPassphrase(const QString &pass);
// Last library revision this device knows about (optimistic concurrency).
qlonglong syncRevision() const;
void setSyncRevision(qlonglong revision);
bool autoFetchCovers() const;
void setAutoFetchCovers(bool on);

158
src/sync/CryptoEnvelope.cpp Normal file
View file

@ -0,0 +1,158 @@
#include "sync/CryptoEnvelope.h"
#include <QtEndian>
#include <QtGlobal>
#include <sodium.h>
namespace umt {
namespace CryptoEnvelope {
namespace {
const char MAGIC[4] = { 'U', 'M', 'T', 'S' };
const quint8 VERSION = 1;
constexpr int SALT_BYTES = 16; // crypto_pwhash_SALTBYTES
constexpr int NONCE_BYTES = 24; // crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
constexpr int KEY_BYTES = 32; // crypto_aead_xchacha20poly1305_ietf_KEYBYTES
constexpr int ABYTES = 16; // crypto_aead_xchacha20poly1305_ietf_ABYTES
// libsodium INTERACTIVE limits — a balance of security and CPU cost. Must match
// the Android client so snapshots are interchangeable.
constexpr quint64 OPSLIMIT = 2ULL; // crypto_pwhash_OPSLIMIT_INTERACTIVE
constexpr quint64 MEMLIMIT = 67108864ULL; // crypto_pwhash_MEMLIMIT_INTERACTIVE (64 MiB)
constexpr int HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES;
void setError(QString *error, const QString &msg) {
if (error) *error = msg;
}
bool ensureInit(QString *error) {
static const int rc = sodium_init();
if (rc < 0) {
setError(error, QStringLiteral("Krypto-Bibliothek konnte nicht initialisiert werden"));
return false;
}
return true;
}
QByteArray deriveKey(const QString &passphrase, const QByteArray &salt,
quint64 ops, quint64 mem, QString *error) {
const QByteArray pw = passphrase.toUtf8();
QByteArray key(KEY_BYTES, Qt::Uninitialized);
const int rc = crypto_pwhash(
reinterpret_cast<unsigned char *>(key.data()), KEY_BYTES,
pw.constData(), static_cast<unsigned long long>(pw.size()),
reinterpret_cast<const unsigned char *>(salt.constData()),
ops, static_cast<size_t>(mem),
crypto_pwhash_ALG_ARGON2ID13);
if (rc != 0) {
setError(error, QStringLiteral("Schlüsselableitung fehlgeschlagen"));
return {};
}
return key;
}
} // namespace
QByteArray encrypt(const QByteArray &plaintext, const QString &passphrase,
QString *error) {
if (!ensureInit(error)) return {};
QByteArray salt(SALT_BYTES, Qt::Uninitialized);
randombytes_buf(salt.data(), SALT_BYTES);
QByteArray nonce(NONCE_BYTES, Qt::Uninitialized);
randombytes_buf(nonce.data(), NONCE_BYTES);
const QByteArray key = deriveKey(passphrase, salt, OPSLIMIT, MEMLIMIT, error);
if (key.isEmpty()) return {};
QByteArray cipher(plaintext.size() + ABYTES, Qt::Uninitialized);
unsigned long long cipherLen = 0;
const int rc = crypto_aead_xchacha20poly1305_ietf_encrypt(
reinterpret_cast<unsigned char *>(cipher.data()), &cipherLen,
reinterpret_cast<const unsigned char *>(plaintext.constData()),
static_cast<unsigned long long>(plaintext.size()),
nullptr, 0, // no associated data
nullptr, // nsec (unused)
reinterpret_cast<const unsigned char *>(nonce.constData()),
reinterpret_cast<const unsigned char *>(key.constData()));
if (rc != 0) {
setError(error, QStringLiteral("Verschlüsselung fehlgeschlagen"));
return {};
}
cipher.resize(static_cast<int>(cipherLen));
QByteArray out;
out.reserve(HEADER_BYTES + cipher.size());
out.append(MAGIC, 4);
out.append(static_cast<char>(VERSION));
quint32 opsBE = qToBigEndian<quint32>(static_cast<quint32>(OPSLIMIT));
quint32 memBE = qToBigEndian<quint32>(static_cast<quint32>(MEMLIMIT));
out.append(reinterpret_cast<const char *>(&opsBE), 4);
out.append(reinterpret_cast<const char *>(&memBE), 4);
out.append(salt);
out.append(nonce);
out.append(cipher);
return out;
}
QByteArray decrypt(const QByteArray &envelope, const QString &passphrase,
QString *error) {
if (!ensureInit(error)) return {};
if (envelope.size() < HEADER_BYTES) {
setError(error, QStringLiteral("Daten unvollständig"));
return {};
}
const char *p = envelope.constData();
if (qstrncmp(p, MAGIC, 4) != 0) {
setError(error, QStringLiteral("Unbekanntes Format"));
return {};
}
int off = 4;
const quint8 version = static_cast<quint8>(p[off]); off += 1;
if (version != VERSION) {
setError(error, QStringLiteral("Nicht unterstützte Version: %1").arg(version));
return {};
}
quint32 opsBE, memBE;
memcpy(&opsBE, p + off, 4); off += 4;
memcpy(&memBE, p + off, 4); off += 4;
const quint64 ops = qFromBigEndian<quint32>(opsBE);
const quint64 mem = qFromBigEndian<quint32>(memBE);
const QByteArray salt = envelope.mid(off, SALT_BYTES); off += SALT_BYTES;
const QByteArray nonce = envelope.mid(off, NONCE_BYTES); off += NONCE_BYTES;
const QByteArray cipher = envelope.mid(off);
if (cipher.size() < ABYTES) {
setError(error, QStringLiteral("Daten unvollständig"));
return {};
}
const QByteArray key = deriveKey(passphrase, salt, ops, mem, error);
if (key.isEmpty()) return {};
QByteArray plain(cipher.size() - ABYTES, Qt::Uninitialized);
unsigned long long plainLen = 0;
const int rc = crypto_aead_xchacha20poly1305_ietf_decrypt(
reinterpret_cast<unsigned char *>(plain.data()), &plainLen,
nullptr, // nsec (unused)
reinterpret_cast<const unsigned char *>(cipher.constData()),
static_cast<unsigned long long>(cipher.size()),
nullptr, 0, // no associated data
reinterpret_cast<const unsigned char *>(nonce.constData()),
reinterpret_cast<const unsigned char *>(key.constData()));
if (rc != 0) {
setError(error, QStringLiteral("Entschlüsselung fehlgeschlagen (falsche Passphrase?)"));
return {};
}
plain.resize(static_cast<int>(plainLen));
return plain;
}
} // namespace CryptoEnvelope
} // namespace umt

35
src/sync/CryptoEnvelope.h Normal file
View file

@ -0,0 +1,35 @@
#pragma once
#include <QByteArray>
#include <QString>
namespace umt {
// Versioned, zero-knowledge encryption envelope shared byte-for-byte with the
// Android app. A library snapshot is encrypted on the device with a passphrase;
// the server only ever stores the resulting opaque ciphertext.
//
// Byte layout (big-endian integers):
//
// magic "UMTS" (4) | version u8 = 1 | opslimit u32 | memlimit u32 |
// salt[16] | nonce[24] | ciphertext (XChaCha20-Poly1305 AEAD, incl. 16-byte tag)
//
// KDF: Argon2id (libsodium crypto_pwhash, ALG 1.3) -> 32-byte key.
// AEAD: XChaCha20-Poly1305-IETF, no associated data.
//
// Both platforms must agree on this exact layout and the Argon2id algorithm so
// either can decrypt the other's data.
namespace CryptoEnvelope {
// Returns the full envelope, or an empty QByteArray on failure (sets *error).
QByteArray encrypt(const QByteArray &plaintext, const QString &passphrase,
QString *error = nullptr);
// Decrypts an envelope produced by encrypt() (on either platform). Returns the
// plaintext, or an empty QByteArray on failure (sets *error).
QByteArray decrypt(const QByteArray &envelope, const QString &passphrase,
QString *error = nullptr);
} // namespace CryptoEnvelope
} // namespace umt

View file

@ -0,0 +1,171 @@
#include "sync/LibrarySerializer.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
namespace umt {
namespace LibrarySerializer {
namespace {
QString dtToStr(const QDateTime &dt) {
return dt.isValid() ? dt.toString(Qt::ISODate) : QString();
}
QDateTime strToDt(const QString &s) {
return s.isEmpty() ? QDateTime() : QDateTime::fromString(s, Qt::ISODate);
}
QJsonObject unitToJson(const Unit &u) {
QJsonObject o;
o[QStringLiteral("number")] = u.number;
o[QStringLiteral("title")] = u.title;
o[QStringLiteral("watched")] = u.watched;
o[QStringLiteral("watchedDate")] = dtToStr(u.watchedDate);
return o;
}
QJsonObject segmentToJson(const Segment &s) {
QJsonObject o;
o[QStringLiteral("number")] = s.number;
o[QStringLiteral("title")] = s.title;
QJsonArray units;
for (const Unit &u : s.units) units.append(unitToJson(u));
o[QStringLiteral("units")] = units;
return o;
}
QJsonObject itemToJson(const MediaItem &m) {
QJsonObject o;
o[QStringLiteral("type")] = mediaTypeToString(m.type);
o[QStringLiteral("title")] = m.title;
o[QStringLiteral("originalTitle")] = m.originalTitle;
QJsonObject loc;
for (auto it = m.localizedTitles.constBegin(); it != m.localizedTitles.constEnd(); ++it)
loc[it.key()] = it.value();
o[QStringLiteral("localizedTitles")] = loc;
o[QStringLiteral("coverUrl")] = m.coverUrl;
QJsonArray genres;
for (const QString &g : m.genres) genres.append(g);
o[QStringLiteral("genres")] = genres;
QJsonArray tags;
for (const QString &t : m.tags) tags.append(t);
o[QStringLiteral("tags")] = tags;
o[QStringLiteral("franchise")] = m.franchise;
o[QStringLiteral("status")] = statusToString(m.status);
o[QStringLiteral("rating")] = m.rating;
o[QStringLiteral("favorite")] = m.favorite;
o[QStringLiteral("year")] = m.year;
o[QStringLiteral("overview")] = m.overview;
o[QStringLiteral("notes")] = m.notes;
QJsonArray segments;
for (const Segment &s : m.segments) segments.append(segmentToJson(s));
o[QStringLiteral("segments")] = segments;
QJsonObject custom;
for (auto it = m.customFields.constBegin(); it != m.customFields.constEnd(); ++it)
custom[it.key()] = it.value();
o[QStringLiteral("customFields")] = custom;
o[QStringLiteral("dateAdded")] = dtToStr(m.dateAdded);
o[QStringLiteral("dateCompleted")] = dtToStr(m.dateCompleted);
o[QStringLiteral("externalId")] = m.externalId;
o[QStringLiteral("externalSource")] = m.externalSource;
return o;
}
Unit unitFromJson(const QJsonObject &o) {
Unit u;
u.number = o.value(QStringLiteral("number")).toInt();
u.title = o.value(QStringLiteral("title")).toString();
u.watched = o.value(QStringLiteral("watched")).toBool();
u.watchedDate = strToDt(o.value(QStringLiteral("watchedDate")).toString());
return u;
}
Segment segmentFromJson(const QJsonObject &o) {
Segment s;
s.number = o.value(QStringLiteral("number")).toInt();
s.title = o.value(QStringLiteral("title")).toString();
const QJsonArray units = o.value(QStringLiteral("units")).toArray();
for (const QJsonValue &v : units) s.units.append(unitFromJson(v.toObject()));
return s;
}
MediaItem itemFromJson(const QJsonObject &o) {
MediaItem m;
m.type = mediaTypeFromString(o.value(QStringLiteral("type")).toString());
m.title = o.value(QStringLiteral("title")).toString();
m.originalTitle = o.value(QStringLiteral("originalTitle")).toString();
const QJsonObject loc = o.value(QStringLiteral("localizedTitles")).toObject();
for (auto it = loc.constBegin(); it != loc.constEnd(); ++it)
m.localizedTitles.insert(it.key(), it.value().toString());
m.coverUrl = o.value(QStringLiteral("coverUrl")).toString();
const QJsonArray genres = o.value(QStringLiteral("genres")).toArray();
for (const QJsonValue &v : genres) m.genres << v.toString();
const QJsonArray tags = o.value(QStringLiteral("tags")).toArray();
for (const QJsonValue &v : tags) m.tags << v.toString();
m.franchise = o.value(QStringLiteral("franchise")).toString();
m.status = statusFromString(o.value(QStringLiteral("status")).toString());
m.rating = o.value(QStringLiteral("rating")).toInt();
m.favorite = o.value(QStringLiteral("favorite")).toBool();
m.year = o.value(QStringLiteral("year")).toInt();
m.overview = o.value(QStringLiteral("overview")).toString();
m.notes = o.value(QStringLiteral("notes")).toString();
const QJsonArray segments = o.value(QStringLiteral("segments")).toArray();
for (const QJsonValue &v : segments) m.segments.append(segmentFromJson(v.toObject()));
const QJsonObject custom = o.value(QStringLiteral("customFields")).toObject();
for (auto it = custom.constBegin(); it != custom.constEnd(); ++it)
m.customFields.insert(it.key(), it.value().toString());
m.dateAdded = strToDt(o.value(QStringLiteral("dateAdded")).toString());
m.dateCompleted = strToDt(o.value(QStringLiteral("dateCompleted")).toString());
m.externalId = o.value(QStringLiteral("externalId")).toString();
m.externalSource = o.value(QStringLiteral("externalSource")).toString();
return m;
}
} // namespace
QByteArray toJson(const QVector<MediaItem> &items) {
QJsonObject root;
root[QStringLiteral("version")] = 1;
root[QStringLiteral("exportedAt")] =
QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
QJsonArray arr;
for (const MediaItem &m : items) arr.append(itemToJson(m));
root[QStringLiteral("items")] = arr;
return QJsonDocument(root).toJson(QJsonDocument::Compact);
}
bool fromJson(const QByteArray &json, QVector<MediaItem> *out, QString *error) {
QJsonParseError perr;
const QJsonDocument doc = QJsonDocument::fromJson(json, &perr);
if (perr.error != QJsonParseError::NoError || !doc.isObject()) {
if (error) *error = QStringLiteral("Ungültiges JSON: %1").arg(perr.errorString());
return false;
}
const QJsonArray arr = doc.object().value(QStringLiteral("items")).toArray();
QVector<MediaItem> items;
items.reserve(arr.size());
for (const QJsonValue &v : arr) items.append(itemFromJson(v.toObject()));
*out = items;
return true;
}
} // namespace LibrarySerializer
} // namespace umt

View file

@ -0,0 +1,26 @@
#pragma once
#include "core/MediaItem.h"
#include <QByteArray>
#include <QString>
#include <QVector>
namespace umt {
// Serializes the library to/from the canonical JSON snapshot shared with the
// Android app (the `LibraryExport` schema: { version, exportedAt, items:[...] }).
// Field names and string values mirror the desktop MediaItem so a snapshot can
// round-trip between platforms.
namespace LibrarySerializer {
// Produces a UTF-8 JSON snapshot of the given items.
QByteArray toJson(const QVector<MediaItem> &items);
// Parses a JSON snapshot into items. Returns true on success; on a parse error
// returns false and leaves *out untouched (all-or-nothing).
bool fromJson(const QByteArray &json, QVector<MediaItem> *out, QString *error = nullptr);
} // namespace LibrarySerializer
} // namespace umt

199
src/sync/SyncClient.cpp Normal file
View file

@ -0,0 +1,199 @@
#include "sync/SyncClient.h"
#include "core/Database.h"
#include "core/Settings.h"
#include "sync/CryptoEnvelope.h"
#include "sync/LibrarySerializer.h"
#include <QEventLoop>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrl>
namespace umt {
SyncClient::SyncClient(Database *db, AppSettings *settings, QObject *parent)
: QObject(parent)
, m_db(db)
, m_settings(settings)
, m_nam(new QNetworkAccessManager(this))
{
}
bool SyncClient::isConfigured() const {
return m_settings->storageMode() == QLatin1String("cloud")
&& !baseUrl().isEmpty()
&& !m_settings->proxyToken().isEmpty()
&& !m_settings->syncPassphrase().isEmpty();
}
QString SyncClient::baseUrl() const {
QString u = m_settings->proxyUrl().trimmed();
while (u.endsWith(QLatin1Char('/'))) u.chop(1);
return u;
}
SyncClient::HttpResponse SyncClient::request(const QString &method,
const QString &path,
const QByteArray &body) {
HttpResponse out;
QNetworkRequest req(QUrl(baseUrl() + path));
req.setHeader(QNetworkRequest::UserAgentHeader,
QStringLiteral("UltimateMediaTracker/1.0"));
req.setRawHeader("Authorization",
QByteArrayLiteral("Bearer ") + m_settings->proxyToken().toUtf8());
if (!body.isEmpty())
req.setHeader(QNetworkRequest::ContentTypeHeader,
QStringLiteral("application/json"));
QNetworkReply *r = nullptr;
if (method == QLatin1String("GET"))
r = m_nam->get(req);
else if (method == QLatin1String("PUT"))
r = m_nam->put(req, body);
else
r = m_nam->sendCustomRequest(req, method.toUtf8(), body);
QEventLoop loop;
connect(r, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
out.statusCode = r->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
out.body = r->readAll();
if (r->error() != QNetworkReply::NoError && out.statusCode == 0)
out.networkError = r->errorString();
r->deleteLater();
return out;
}
SyncClient::Result SyncClient::pull() {
Result res;
const HttpResponse resp = request(QStringLiteral("GET"),
QStringLiteral("/sync/library"), {});
if (!resp.networkError.isEmpty()) {
res.status = Status::Error;
res.message = QStringLiteral("Netzwerkfehler: %1").arg(resp.networkError);
return res;
}
if (resp.statusCode == 404) {
// No snapshot on the server yet — nothing to download.
res.status = Status::Success;
return res;
}
if (resp.statusCode != 200) {
res.status = Status::Error;
res.message = QStringLiteral("Server antwortete mit Status %1").arg(resp.statusCode);
return res;
}
const QJsonObject obj = QJsonDocument::fromJson(resp.body).object();
const qlonglong revision = obj.value(QStringLiteral("revision")).toVariant().toLongLong();
const QByteArray envelope =
QByteArray::fromBase64(obj.value(QStringLiteral("payload")).toString().toUtf8());
QString cryptoErr;
const QByteArray plain = CryptoEnvelope::decrypt(
envelope, m_settings->syncPassphrase(), &cryptoErr);
if (plain.isNull()) {
res.status = Status::Error;
res.message = cryptoErr;
return res;
}
QString replaceErr;
if (!replaceLibrary(plain, &replaceErr)) {
res.status = Status::Error;
res.message = replaceErr;
return res;
}
m_settings->setSyncRevision(revision);
res.status = Status::Success;
return res;
}
SyncClient::Result SyncClient::push(bool force) {
Result res;
qlonglong baseRevision = m_settings->syncRevision();
if (force) {
// Re-read the current server revision so the overwrite is accepted.
const HttpResponse cur = request(QStringLiteral("GET"),
QStringLiteral("/sync/library"), {});
if (!cur.networkError.isEmpty()) {
res.status = Status::Error;
res.message = QStringLiteral("Netzwerkfehler: %1").arg(cur.networkError);
return res;
}
if (cur.statusCode == 200) {
const QJsonObject obj = QJsonDocument::fromJson(cur.body).object();
baseRevision = obj.value(QStringLiteral("revision")).toVariant().toLongLong();
} else if (cur.statusCode == 404) {
baseRevision = 0;
}
}
const QVector<MediaItem> items = m_db->queryItems(FilterCriteria{});
const QByteArray json = LibrarySerializer::toJson(items);
QString cryptoErr;
const QByteArray envelope = CryptoEnvelope::encrypt(
json, m_settings->syncPassphrase(), &cryptoErr);
if (envelope.isNull()) {
res.status = Status::Error;
res.message = cryptoErr;
return res;
}
QJsonObject reqObj;
reqObj[QStringLiteral("baseRevision")] = baseRevision;
reqObj[QStringLiteral("payload")] =
QString::fromUtf8(envelope.toBase64());
const QByteArray reqBody = QJsonDocument(reqObj).toJson(QJsonDocument::Compact);
const HttpResponse resp = request(QStringLiteral("PUT"),
QStringLiteral("/sync/library"), reqBody);
if (!resp.networkError.isEmpty()) {
res.status = Status::Error;
res.message = QStringLiteral("Netzwerkfehler: %1").arg(resp.networkError);
return res;
}
if (resp.statusCode == 409) {
const QJsonObject obj = QJsonDocument::fromJson(resp.body).object();
res.status = Status::Conflict;
res.serverRevision = obj.value(QStringLiteral("revision")).toVariant().toLongLong();
return res;
}
if (resp.statusCode != 200) {
res.status = Status::Error;
res.message = QStringLiteral("Server antwortete mit Status %1").arg(resp.statusCode);
return res;
}
const QJsonObject obj = QJsonDocument::fromJson(resp.body).object();
const qlonglong newRevision = obj.value(QStringLiteral("revision")).toVariant().toLongLong();
m_settings->setSyncRevision(newRevision);
res.status = Status::Success;
return res;
}
bool SyncClient::replaceLibrary(const QByteArray &snapshotJson, QString *error) {
QVector<MediaItem> incoming;
if (!LibrarySerializer::fromJson(snapshotJson, &incoming, error))
return false;
// All-or-nothing replace: wipe the current library, then insert the snapshot.
const QVector<MediaItem> existing = m_db->queryItems(FilterCriteria{});
for (const MediaItem &m : existing)
m_db->deleteItem(m.id);
for (MediaItem m : incoming) {
m.id = -1;
m_db->saveItem(m);
}
return true;
}
} // namespace umt

66
src/sync/SyncClient.h Normal file
View file

@ -0,0 +1,66 @@
#pragma once
#include <QObject>
#include <QString>
class QNetworkAccessManager;
namespace umt {
class Database;
class AppSettings;
// Drives the whole-library snapshot sync against the combo server's
// /sync/library endpoint. The library is encrypted client-side (CryptoEnvelope)
// so the server only ever stores opaque ciphertext (zero-knowledge).
//
// Concurrency is optimistic: each push carries the base revision it was built
// from; the server rejects a stale base with 409, surfaced here as Conflict so
// the caller can offer "pull server state" or "overwrite".
//
// The network calls run synchronously (driven by a local event loop) since they
// are triggered by an explicit, user-initiated "Sync now" action.
class SyncClient : public QObject {
Q_OBJECT
public:
enum class Status { Success, Conflict, Error };
struct Result {
Status status = Status::Error;
QString message; // German, user-facing (on Error)
qlonglong serverRevision = 0; // valid on Conflict
};
SyncClient(Database *db, AppSettings *settings, QObject *parent = nullptr);
// True if cloud mode is selected and server URL / token / passphrase are set.
bool isConfigured() const;
// Downloads the server snapshot, decrypts it and replaces the local library.
// A 404 (no snapshot yet) is treated as Success (nothing to do).
Result pull();
// Encrypts the whole local library and uploads it. With force=false a stale
// base revision yields Conflict; with force=true it re-reads the server
// revision and overwrites unconditionally.
Result push(bool force = false);
private:
struct HttpResponse {
int statusCode = 0;
QByteArray body;
QString networkError; // non-empty on transport failure
};
HttpResponse request(const QString &method, const QString &path,
const QByteArray &body);
QString baseUrl() const;
// Replaces the entire local library with the given snapshot (all-or-nothing).
bool replaceLibrary(const QByteArray &snapshotJson, QString *error);
Database *m_db;
AppSettings *m_settings;
QNetworkAccessManager *m_nam;
};
} // namespace umt

View file

@ -7,9 +7,11 @@
#include "ui/EditDialog.h"
#include "ui/SettingsDialog.h"
#include "core/Settings.h"
#include "sync/SyncClient.h"
#include "providers/ProviderManager.h"
#include "providers/ImageCache.h"
#include <QApplication>
#include <QWidget>
#include <QHBoxLayout>
#include <QVBoxLayout>
@ -141,12 +143,21 @@ void MainWindow::buildUi()
connect(themeBtn, &QPushButton::clicked, this, &MainWindow::toggleTheme);
tb->addWidget(themeBtn);
m_syncBtn = new QPushButton(QStringLiteral(""), topBar);
m_syncBtn->setObjectName(QStringLiteral("IconButton"));
m_syncBtn->setFixedSize(40, rowH);
m_syncBtn->setToolTip(QStringLiteral("Bibliothek synchronisieren"));
connect(m_syncBtn, &QPushButton::clicked, this, &MainWindow::syncNow);
tb->addWidget(m_syncBtn);
auto *settingsBtn = new QPushButton(QStringLiteral(""), topBar);
settingsBtn->setObjectName(QStringLiteral("IconButton"));
settingsBtn->setFixedSize(40, rowH);
connect(settingsBtn, &QPushButton::clicked, this, &MainWindow::openSettings);
tb->addWidget(settingsBtn);
m_syncBtn->setVisible(m_settings->storageMode() == QLatin1String("cloud"));
libLayout->addWidget(topBar);
// grid scroll area
@ -364,11 +375,83 @@ void MainWindow::openSettings()
connect(&dlg, &SettingsDialog::providersConfigChanged, this, [this]{
m_providers->refreshConfig();
m_theme->apply();
m_syncBtn->setVisible(m_settings->storageMode() == QLatin1String("cloud"));
refresh();
});
dlg.exec();
}
void MainWindow::syncNow()
{
SyncClient client(m_db, m_settings, this);
if (!client.isConfigured()) {
QMessageBox::information(this, QStringLiteral("Synchronisieren"),
QStringLiteral("Cloud-Sync ist nicht vollständig konfiguriert. "
"Bitte in den Einstellungen den Speicherort auf „Cloud-Sync“ "
"stellen und Proxy-URL, Token sowie Sync-Passphrase eintragen."));
return;
}
m_syncBtn->setEnabled(false);
QApplication::setOverrideCursor(Qt::WaitCursor);
// Upload local changes first; resolve a conflict by asking the user.
SyncClient::Result up = client.push(false);
if (up.status == SyncClient::Status::Conflict) {
QApplication::restoreOverrideCursor();
QMessageBox box(this);
box.setIcon(QMessageBox::Question);
box.setWindowTitle(QStringLiteral("Sync-Konflikt"));
box.setText(QStringLiteral(
"Auf dem Server liegt eine neuere Version der Bibliothek "
"(Revision %1).\n\n"
"Möchtest du den Serverstand herunterladen (deine lokalen "
"Änderungen gehen verloren) oder lokal überschreiben "
"(der Serverstand wird ersetzt)?").arg(up.serverRevision));
QPushButton *loadBtn = box.addButton(
QStringLiteral("Server laden"), QMessageBox::AcceptRole);
box.addButton(QStringLiteral("Lokal überschreiben"), QMessageBox::DestructiveRole);
box.exec();
const bool loadServer = (box.clickedButton() == loadBtn);
QApplication::setOverrideCursor(Qt::WaitCursor);
if (loadServer) {
const SyncClient::Result pulled = client.pull();
QApplication::restoreOverrideCursor();
m_syncBtn->setEnabled(true);
if (pulled.status == SyncClient::Status::Success) {
rebuildFilterLists();
refresh();
QMessageBox::information(this, QStringLiteral("Synchronisieren"),
QStringLiteral("Serverstand geladen."));
} else {
QMessageBox::warning(this, QStringLiteral("Synchronisieren"), pulled.message);
}
return;
}
up = client.push(true); // overwrite
}
if (up.status != SyncClient::Status::Success) {
QApplication::restoreOverrideCursor();
m_syncBtn->setEnabled(true);
QMessageBox::warning(this, QStringLiteral("Synchronisieren"), up.message);
return;
}
// Then pull the canonical server state back down so this device matches it.
const SyncClient::Result down = client.pull();
QApplication::restoreOverrideCursor();
m_syncBtn->setEnabled(true);
if (down.status != SyncClient::Status::Success) {
QMessageBox::warning(this, QStringLiteral("Synchronisieren"), down.message);
return;
}
rebuildFilterLists();
refresh();
QMessageBox::information(this, QStringLiteral("Synchronisieren"),
QStringLiteral("Bibliothek synchronisiert."));
}
void MainWindow::toggleTheme()
{
m_settings->setDarkMode(!m_settings->darkMode());

View file

@ -41,6 +41,7 @@ private slots:
void openEditExisting(int id);
void deleteItem(int id);
void openSettings();
void syncNow();
void toggleTheme();
private:
@ -66,6 +67,7 @@ private:
QLineEdit *m_search;
QComboBox *m_sort;
QPushButton *m_sortDir;
QPushButton *m_syncBtn;
FilterPanel *m_filters;
QStackedWidget*m_stack;
QWidget *m_libraryPage;

View file

@ -142,6 +142,44 @@ SettingsDialog::SettingsDialog(AppSettings *settings, QWidget *parent)
"Ohne Key funktionieren Filme, Serien, Mangas und Bücher weiterhin nur "
"die Spiele-Suche braucht ihn.")));
m_storageMode = new QComboBox(this);
m_storageMode->addItem(QStringLiteral("Nur lokal (SQLite)"), QStringLiteral("local"));
m_storageMode->addItem(QStringLiteral("Cloud-Sync (verschlüsselt)"), QStringLiteral("cloud"));
m_storageMode->setCurrentIndex(
qMax(0, m_storageMode->findData(m_settings->storageMode())));
form->addRow(QStringLiteral("Speicherort"), withHelp(m_storageMode,
QStringLiteral("Speicherort der Bibliothek"),
QStringLiteral(
"<b>Wo liegt deine Bibliothek?</b>"
"<ul>"
"<li><b>Nur lokal:</b> Die Bibliothek bleibt ausschließlich auf diesem "
"Gerät (SQLite). Kein Netzwerk-Zugriff.</li>"
"<li><b>Cloud-Sync:</b> Die komplette Bibliothek wird "
"<b>Ende-zu-Ende verschlüsselt</b> (Argon2id + XChaCha20-Poly1305) und "
"über den Server abgeglichen. Der Server speichert nur Chiffretext "
"Passphrase und Klartext verlassen das Gerät nie.</li>"
"</ul>"
"Server-URL und Token werden aus der Proxy-Anmeldung oben "
"wiederverwendet.")));
m_syncPassphrase = new QLineEdit(this);
m_syncPassphrase->setText(m_settings->syncPassphrase());
m_syncPassphrase->setEchoMode(QLineEdit::Password);
m_syncPassphrase->setPlaceholderText(QStringLiteral("Frei wählbare Sync-Passphrase"));
form->addRow(QStringLiteral("Sync-Passphrase"), withHelp(m_syncPassphrase,
QStringLiteral("Sync-Passphrase"),
QStringLiteral(
"<b>Die Sync-Passphrase ver- und entschlüsselt deine Bibliothek.</b>"
"<ul>"
"<li>Wähle eine starke, frei gewählte Passphrase.</li>"
"<li><b>Dieselbe</b> Passphrase muss auf allen Geräten (Desktop und "
"Handy) gesetzt sein, damit sie sich gegenseitig entschlüsseln "
"können.</li>"
"<li>Sie verlässt dein Gerät nie und wird nicht auf dem Server "
"gespeichert. Geht sie verloren, sind die Cloud-Daten nicht mehr "
"lesbar.</li>"
"</ul>")));
m_autoCovers = new QCheckBox(QStringLiteral("Cover automatisch laden"), this);
m_autoCovers->setChecked(m_settings->autoFetchCovers());
form->addRow(QString(), m_autoCovers);
@ -180,6 +218,8 @@ SettingsDialog::SettingsDialog(AppSettings *settings, QWidget *parent)
m_settings->setProxyUrl(m_proxyUrl->text().trimmed());
m_settings->setProxyToken(m_proxyToken->text().trimmed());
m_settings->setRawgApiKey(m_rawgKey->text().trimmed());
m_settings->setStorageMode(m_storageMode->currentData().toString());
m_settings->setSyncPassphrase(m_syncPassphrase->text());
m_settings->setAutoFetchCovers(m_autoCovers->isChecked());
m_settings->setCardWidth(m_cardSize->value());
emit providersConfigChanged();

View file

@ -40,6 +40,8 @@ private:
QPushButton *m_proxyLogin;
QLineEdit *m_proxyToken;
QLineEdit *m_rawgKey;
QComboBox *m_storageMode;
QLineEdit *m_syncPassphrase;
QCheckBox *m_autoCovers;
QSlider *m_cardSize;
};