OmaPhoto 1.0.0: the Linux port of Compositor
Release / deb (push) Canceled after 0s
Release / rpm (push) Canceled after 0s
Release / arch (push) Canceled after 0s
Release / publish (push) Canceled after 0s

A layered image editor for Linux and Omarchy, ported from Compositor
(github.com/robbietilton/Compositor), whose Swift source is its
specification. It reads and writes Compositor's .comp projects.

Packages for Ubuntu, Fedora and Arch come from the release workflow;
scripts/install/ holds one install script per distribution family,
and flake.nix serves NixOS.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
雷电芽衣andClaude committed 2026-09-26 00:44:52 -04:00
commit c864304695
564 files changed
+100804

No files matched your search

+372
View File
@@ -0,0 +1,372 @@
#include "ProjectFixtures.h"
#include <QTemporaryDir>
#include <QtTest>
#include <cerrno>
#include <csignal>
#include <fcntl.h>
#include <sys/resource.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>
namespace {
// What the store's system calls must do, and did.
struct Faults {
// The errno exchanges fail with, from the nth one on.
int exchangeError = 0;
int firstFailingExchange = 1;
int failingRename = 0;
bool fileSyncFails = false;
// The nth folder sync fails; 0 for none.
int failingFolderSync = 0;
// Manifest reads come 4096 bytes at most, then fail.
bool manifestReadsFail = false;
int renames = 0;
int exchanges = 0;
int folderSyncs = 0;
int manifestReads = 0;
QStringList trace;
} faults;
const QDir::Filters everything = QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden;
// Counts critical logs on their way to Qt Test.
int criticals = 0;
QtMessageHandler forward = nullptr;
void countCriticals(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
criticals += type == QtCriticalMsg;
forward(type, context, message);
}
}
// These take libc's place for the store linked in here.
extern "C" int renameat2(int oldDirectory, const char *from, int newDirectory, const char *to, unsigned int flags)
{
if (flags != 0 && faults.exchangeError != 0 && ++faults.exchanges >= faults.firstFailingExchange) {
errno = faults.exchangeError;
return -1;
}
faults.trace << (flags != 0 ? "exchange" : "rename");
return int(syscall(SYS_renameat2, oldDirectory, from, newDirectory, to, flags));
}
extern "C" int rename(const char *from, const char *to)
{
if (++faults.renames == faults.failingRename) {
errno = EIO;
return -1;
}
faults.trace << "rename";
return int(syscall(SYS_renameat2, AT_FDCWD, from, AT_FDCWD, to, 0));
}
extern "C" int fsync(int descriptor)
{
struct stat info;
const bool folder = fstat(descriptor, &info) == 0 && S_ISDIR(info.st_mode);
if (folder ? ++faults.folderSyncs == faults.failingFolderSync : faults.fileSyncFails) {
errno = EIO;
return -1;
}
faults.trace << (folder ? "sync folder" : "sync file");
return int(syscall(SYS_fsync, descriptor));
}
extern "C" ssize_t read(int descriptor, void *buffer, size_t count)
{
char path[512] = {};
const QByteArray link = "/proc/self/fd/" + QByteArray::number(descriptor);
const bool manifest = faults.manifestReadsFail && readlink(link.constData(), path, sizeof(path) - 1) > 0
&& QByteArray(path).endsWith("manifest.json");
if (manifest && ++faults.manifestReads > 1) {
errno = EIO;
return -1;
}
return syscall(SYS_read, descriptor, buffer, manifest ? std::min<size_t>(count, 4096) : count);
}
class ProjectReplaceTests : public QObject {
Q_OBJECT
private slots:
void init();
void everythingIsSyncedBeforeTheSwap();
void withoutAnExchangeTheProjectIsReplacedInTwoSteps_data();
void withoutAnExchangeTheProjectIsReplacedInTwoSteps();
void aFailedSwapLeavesTheOldProject_data();
void aFailedSwapLeavesTheOldProject();
void aSyncFailingAfterTheSwapPutsTheOldProjectBack_data();
void aSyncFailingAfterTheSwapPutsTheOldProjectBack();
void aFirstSaveThatCannotBeSyncedIsTakenAway();
void aManifestThatStopsReadingIsRefused();
void aDiskThatTakesNoMoreBytesFailsTheSave();
void savesLeakNoDescriptors();
};
void ProjectReplaceTests::init()
{
faults = Faults();
}
void ProjectReplaceTests::everythingIsSyncedBeforeTheSwap()
{
QTemporaryDir root;
const QString path = root.filePath("Durable.comp");
ProjectStore::save(twoLayers(), path);
// The manifest, the image, both folders, the move, the parent.
QCOMPARE(faults.trace, (QStringList{"sync file", "sync file", "sync folder", "sync folder", "rename", "sync folder"}));
faults.trace.clear();
ProjectStore::save(twoLayers(), path);
QCOMPARE(faults.trace, (QStringList{"sync file", "sync file", "sync folder", "sync folder", "exchange", "sync folder"}));
QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"Durable.comp"});
}
void ProjectReplaceTests::withoutAnExchangeTheProjectIsReplacedInTwoSteps_data()
{
QTest::addColumn<int>("error");
QTest::newRow("EINVAL, a file system without it") << EINVAL;
QTest::newRow("ENOSYS, a kernel without it") << ENOSYS;
QTest::newRow("ENOTSUP") << ENOTSUP;
}
void ProjectReplaceTests::withoutAnExchangeTheProjectIsReplacedInTwoSteps()
{
QFETCH(int, error);
QTemporaryDir root;
const QString path = root.filePath("Stick.comp");
ProjectSnapshot snapshot = twoLayers();
ProjectStore::save(snapshot, path);
faults.exchangeError = error;
// A first save needs no exchange.
ProjectStore::save(snapshot, root.filePath("Fresh.comp"));
QCOMPARE(contents(root.filePath("Fresh.comp/manifest.json")), contents(path + "/manifest.json"));
// An overwrite falls back, says so, and leaves nothing behind.
snapshot.manifest.layers.erase(snapshot.manifest.layers.begin());
snapshot.images.clear();
faults.trace.clear();
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("this file system cannot exchange paths; replacing \".*Stick.comp\" in two steps"));
ProjectStore::save(snapshot, path);
// The old copy goes only once the swap is synced.
QCOMPARE(faults.trace, (QStringList{"sync file", "sync folder", "sync folder", "rename", "rename", "sync folder", "rename"}));
const ProjectSnapshot loaded = ProjectStore::load(path);
QCOMPARE(int(loaded.manifest.layers.size()), 1);
QVERIFY(QDir(path + "/images").entryList(QDir::Files).isEmpty());
QCOMPARE(QDir(root.path()).entryList(everything), (QStringList{"Fresh.comp", "Stick.comp"}));
// A plain file in the way goes the same road.
overwrite(root.filePath("WasAFile.comp"), "x");
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("this file system cannot exchange paths; .*"));
ProjectStore::save(snapshot, root.filePath("WasAFile.comp"));
QCOMPARE(int(ProjectStore::load(root.filePath("WasAFile.comp")).manifest.layers.size()), 1);
QCOMPARE(QDir(root.path()).entryList(everything), (QStringList{"Fresh.comp", "Stick.comp", "WasAFile.comp"}));
}
void ProjectReplaceTests::aFailedSwapLeavesTheOldProject_data()
{
QTest::addColumn<int>("exchangeError");
QTest::addColumn<int>("failingRename");
QTest::addColumn<bool>("fileSyncFails");
QTest::addColumn<int>("failingFolderSync");
QTest::addColumn<QString>("message");
QTest::newRow("the exchange itself fails") << EIO << 0 << false << 0 << "could not replace .*Kept.comp: Input/output error";
QTest::newRow("the old project cannot move aside") << EINVAL << 1 << false << 0 << "could not replace .*Kept.comp: Input/output error";
QTest::newRow("the new project cannot move in") << EINVAL << 2 << false << 0 << "could not replace .*Kept.comp: Input/output error";
QTest::newRow("a file cannot be synced") << 0 << 0 << true << 0 << "could not write .*manifest.json: Input/output error";
QTest::newRow("the images folder cannot be synced") << 0 << 0 << false << 1 << "could not sync .*images: Input/output error";
QTest::newRow("the sibling cannot be synced") << 0 << 0 << false << 2 << "could not sync .*tmp: Input/output error";
}
void ProjectReplaceTests::aFailedSwapLeavesTheOldProject()
{
QFETCH(int, exchangeError);
QFETCH(int, failingRename);
QFETCH(bool, fileSyncFails);
QFETCH(int, failingFolderSync);
QFETCH(QString, message);
QTemporaryDir root;
const QString path = root.filePath("Kept.comp");
ProjectSnapshot snapshot = twoLayers();
ProjectStore::save(snapshot, path);
const QByteArray original = contents(path + "/manifest.json");
snapshot.manifest.layers.erase(snapshot.manifest.layers.begin());
snapshot.images.clear();
faults = Faults();
faults.exchangeError = exchangeError;
faults.failingRename = failingRename;
faults.fileSyncFails = fileSyncFails;
faults.failingFolderSync = failingFolderSync;
if (exchangeError == EINVAL)
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("this file system cannot exchange paths; .*"));
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(message));
try {
ProjectStore::save(snapshot, path);
QFAIL("the save went through");
} catch (const std::runtime_error &error) {
QVERIFY2(QRegularExpression(message).match(error.what()).hasMatch(), error.what());
}
faults = Faults();
QCOMPARE(contents(path + "/manifest.json"), original);
QCOMPARE(int(ProjectStore::load(path).manifest.layers.size()), 2);
QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"Kept.comp"});
// A first save that cannot move in leaves nothing.
faults.failingRename = 1;
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not move the project to .*First.comp: Input/output error"));
QVERIFY_EXCEPTION_THROWN(ProjectStore::save(snapshot, root.filePath("First.comp")), std::runtime_error);
QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"Kept.comp"});
}
void ProjectReplaceTests::aSyncFailingAfterTheSwapPutsTheOldProjectBack_data()
{
QTest::addColumn<int>("exchangeError");
QTest::addColumn<int>("firstFailingExchange");
QTest::addColumn<int>("failingRename");
// Where the old project stays, and what else is left.
QTest::addColumn<QString>("kept");
QTest::addColumn<QStringList>("left");
QTest::newRow("after an exchange") << 0 << 1 << 0 << "" << QStringList{"Kept.comp"};
QTest::newRow("after two moves") << EINVAL << 1 << 0 << "" << QStringList{"Kept.comp"};
QTest::newRow("an exchange that cannot be undone") << EIO << 2 << 0 << ".tmp" << QStringList{".tmp", "Kept.comp"};
// The third move takes the new project away again.
QTest::newRow("two moves, the new project stuck") << EINVAL << 1 << 3 << ".tmp.old" << QStringList{".tmp.old", "Kept.comp"};
QTest::newRow("two moves, the old project stuck") << EINVAL << 1 << 4 << ".tmp.old" << QStringList{".tmp", ".tmp.old"};
}
void ProjectReplaceTests::aSyncFailingAfterTheSwapPutsTheOldProjectBack()
{
QFETCH(int, exchangeError);
QFETCH(int, firstFailingExchange);
QFETCH(int, failingRename);
QFETCH(QString, kept);
QFETCH(QStringList, left);
QTemporaryDir root;
const QString path = root.filePath("Kept.comp");
ProjectSnapshot snapshot = twoLayers();
ProjectStore::save(snapshot, path);
const QByteArray original = contents(path + "/manifest.json");
const QImage pixels = snapshot.images.begin()->second.image();
snapshot.manifest.layers.erase(snapshot.manifest.layers.begin());
snapshot.images.clear();
faults = Faults();
faults.exchangeError = exchangeError;
faults.firstFailingExchange = firstFailingExchange;
faults.failingRename = failingRename;
// The third folder synced is the parent, after the swap.
faults.failingFolderSync = 3;
if (exchangeError == EINVAL)
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("this file system cannot exchange paths; .*"));
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not sync .*: Input/output error"));
const QString sibling = QStringLiteral("\\.Kept\\.comp\\.[0-9A-F-]{36}");
if (!kept.isEmpty()) {
const QString stays = "^the previous project could not be put back; it stays at " + QRegularExpression::escape(root.path()) + "/"
+ sibling + QRegularExpression::escape(kept) + "$";
QTest::ignoreMessage(QtCriticalMsg, QRegularExpression(stays));
}
criticals = 0;
forward = qInstallMessageHandler(countCriticals);
QVERIFY_EXCEPTION_THROWN(ProjectStore::save(snapshot, path), std::runtime_error);
qInstallMessageHandler(forward);
faults = Faults();
// A way back that worked raises no alarm.
QCOMPARE(criticals, kept.isEmpty() ? 0 : 1);
// Nothing is deleted when the way back fails.
QMap<QString, QString> paths;
for (const QString &name : QDir(root.path()).entryList(everything)) {
const QRegularExpressionMatch match = QRegularExpression("^" + sibling + "(\\.tmp(\\.old)?)$").match(name);
paths.insert(match.hasMatch() ? match.captured(1) : name, root.filePath(name));
}
QCOMPARE(QStringList(paths.keys()), left);
// The old project opens whole from where it stays.
const QString previous = paths.value(kept.isEmpty() ? "Kept.comp" : kept);
QCOMPARE(contents(previous + "/manifest.json"), original);
const ProjectSnapshot old = ProjectStore::load(previous);
QCOMPARE(int(old.manifest.layers.size()), 2);
QCOMPARE(old.images.begin()->second.image(), pixels);
// The new one stays too, wherever the moves left it.
if (!kept.isEmpty())
QCOMPARE(int(ProjectStore::load(paths.value(left.contains("Kept.comp") ? "Kept.comp" : ".tmp")).manifest.layers.size()), 1);
}
void ProjectReplaceTests::aFirstSaveThatCannotBeSyncedIsTakenAway()
{
QTemporaryDir root;
const QString path = root.filePath("First.comp");
faults.failingFolderSync = 3;
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not sync .*: Input/output error"));
criticals = 0;
forward = qInstallMessageHandler(countCriticals);
QVERIFY_EXCEPTION_THROWN(ProjectStore::save(twoLayers(), path), std::runtime_error);
qInstallMessageHandler(forward);
QCOMPARE(criticals, 0);
QCOMPARE(QDir(root.path()).entryList(everything), QStringList());
// What cannot be taken away stays whole, and is named.
faults = Faults();
faults.failingFolderSync = 3;
faults.failingRename = 2;
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not sync .*: Input/output error"));
QTest::ignoreMessage(QtCriticalMsg, QRegularExpression("^the unsynced project could not be taken from " + QRegularExpression::escape(path) + "$"));
forward = qInstallMessageHandler(countCriticals);
QVERIFY_EXCEPTION_THROWN(ProjectStore::save(twoLayers(), path), std::runtime_error);
qInstallMessageHandler(forward);
QCOMPARE(criticals, 1);
faults = Faults();
QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"First.comp"});
QCOMPARE(int(ProjectStore::load(path).manifest.layers.size()), 2);
}
void ProjectReplaceTests::aManifestThatStopsReadingIsRefused()
{
QTemporaryDir root;
const QString path = root.filePath("Read.comp");
ProjectStore::save(twoLayers(), path);
// Whole in 4096 bytes, then padded: a short read parses.
const QByteArray manifest = contents(path + "/manifest.json");
QVERIFY(manifest.size() < 4096);
overwrite(path + "/manifest.json", manifest + QByteArray(8192, ' '));
QCOMPARE(projectError([&] { ProjectStore::load(path); }), std::nullopt);
faults.manifestReadsFail = true;
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not read \".*manifest.json\" : .*"));
const std::optional<ProjectError::Kind> refused = projectError([&] { ProjectStore::load(path); });
faults = Faults();
QCOMPARE(refused, std::optional(ProjectError::Kind::invalid));
}
void ProjectReplaceTests::aDiskThatTakesNoMoreBytesFailsTheSave()
{
QTemporaryDir root;
const QString path = root.filePath("Full.comp");
ProjectStore::save(twoLayers(), path);
const QByteArray original = contents(path + "/manifest.json");
// No file may pass 100 bytes: every write stops short.
std::signal(SIGXFSZ, SIG_IGN);
rlimit previous;
QCOMPARE(getrlimit(RLIMIT_FSIZE, &previous), 0);
rlimit tight = previous;
tight.rlim_cur = 100;
QCOMPARE(setrlimit(RLIMIT_FSIZE, &tight), 0);
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("could not write .*manifest.json: .*"));
bool thrown = false;
try {
ProjectStore::save(twoLayers(), path);
} catch (const std::runtime_error &) {
thrown = true;
}
QCOMPARE(setrlimit(RLIMIT_FSIZE, &previous), 0);
QVERIFY(thrown);
QCOMPARE(contents(path + "/manifest.json"), original);
QCOMPARE(QDir(root.path()).entryList(everything), QStringList{"Full.comp"});
}
void ProjectReplaceTests::savesLeakNoDescriptors()
{
QTemporaryDir root;
const auto open = [] { return QDir("/proc/self/fd").entryList(QDir::NoDotAndDotDot | QDir::AllEntries).size(); };
ProjectStore::save(twoLayers(), root.filePath("Again.comp"));
const qsizetype before = open();
for (int round = 0; round < 5; ++round) {
ProjectStore::save(twoLayers(), root.filePath("Again.comp"));
ProjectStore::load(root.filePath("Again.comp"));
}
QCOMPARE(open(), before);
}
QTEST_MAIN(ProjectReplaceTests)
#include "ProjectReplaceTests.moc"