feat: add apply option to set wallpaper from command line and enhance process completion signals

This commit is contained in:
2026-03-11 05:44:58 +01:00
parent 5df0b53df0
commit b06d27cecf
12 changed files with 174 additions and 50 deletions
+6 -1
View File
@@ -50,9 +50,11 @@ It might not be that worthy to build a Qt application from ground for such a sma
Install it to the previously specified prefix. This step may require root permissions if the install prefix is set to a system directory like `/usr/local`. Install it to the previously specified prefix. This step may require root permissions if the install prefix is set to a system directory like `/usr/local`.
```bash ```bash
cmake --install build cmake --install build --strip
``` ```
`--strip` option is used to reduce the binary size by removing symbol information, which is generally not needed for normal usage.
## Configuration Reference ## Configuration Reference
Refer to [config.schema.json](config.schema.json) for a complete reference of the configuration file schema. Below is a summary of the available options. Refer to [config.schema.json](config.schema.json) for a complete reference of the configuration file schema. Below is a summary of the available options.
@@ -203,6 +205,7 @@ Options:
-q, --quiet Suppress all log output -q, --quiet Suppress all log output
-d, --append-dir <dir> Append an additional wallpaper search directory -d, --append-dir <dir> Append an additional wallpaper search directory
-c, --config-file <file> Specify a custom configuration file -c, --config-file <file> Specify a custom configuration file
-a, --apply <file> Apply the specified image as wallpaper and exit
``` ```
A few things to notice: A few things to notice:
@@ -212,3 +215,5 @@ A few things to notice:
- The `--append-dir` option can be used multiple times to add multiple directories. - The `--append-dir` option can be used multiple times to add multiple directories.
- It is quite obvious that some options conflicts with each other (e.g. `--verbose` and `--quiet`). Case mutually exclusive options are provided together, the behavior is un.. just please, don't do that. - It is quite obvious that some options conflicts with each other (e.g. `--verbose` and `--quiet`). Case mutually exclusive options are provided together, the behavior is un.. just please, don't do that.
- Given `--apply`, the config file (default or specified with `--config-file`) will be parsed, and the `onSelected` action will be executed with the properties of the specified image available for placeholders. If `savePalette` is enabled and a palette is selected in the last session, `palette`, `colorName` and `colorHex` placeholders will also be available. `saveState` commands will also be executed to fetch states for placeholders. After the action is executed, the application will exit immediately without showing the UI. This allows you to use WallReel as a command-line wallpaper setter that also supports palette-based theming and state management.
+1 -1
View File
@@ -10,7 +10,7 @@ WALLREEL_DECLARE_SENDER("PaletteMatchColor")
namespace WallReel::Core::Palette { namespace WallReel::Core::Palette {
const ColorItem& bestMatch(const QColor& target, const QList<ColorItem>& candidates) { ColorItem bestMatch(const QColor& target, const QList<ColorItem>& candidates) {
if (candidates.isEmpty() || !target.isValid()) { if (candidates.isEmpty() || !target.isValid()) {
WR_WARN("No candidates or invalid target color for palette matching"); WR_WARN("No candidates or invalid target color for palette matching");
static ColorItem emptyItem; static ColorItem emptyItem;
+2 -2
View File
@@ -10,9 +10,9 @@ namespace WallReel::Core::Palette {
* *
* @param target * @param target
* @param candidates * @param candidates
* @return const ColorItem& The best matching color item, or an empty ColorItem if no candidates are provided * @return ColorItem The best matching color item, or an empty ColorItem if no candidates are provided
*/ */
const ColorItem& bestMatch(const QColor& target, const QList<ColorItem>& candidates); ColorItem bestMatch(const QColor& target, const QList<ColorItem>& candidates);
} // namespace WallReel::Core::Palette } // namespace WallReel::Core::Palette
+60 -3
View File
@@ -10,6 +10,7 @@
#include "Service/manager.hpp" #include "Service/manager.hpp"
#include "Utils/misc.hpp" #include "Utils/misc.hpp"
#include "appoptions.hpp" #include "appoptions.hpp"
#include "logger.hpp"
namespace WallReel::Core::Provider { namespace WallReel::Core::Provider {
@@ -43,7 +44,7 @@ class Bootstrap {
qRegisterMetaType<Palette::PaletteItem>("PaletteItem"); qRegisterMetaType<Palette::PaletteItem>("PaletteItem");
qRegisterMetaType<Palette::ColorItem>("ColorItem"); qRegisterMetaType<Palette::ColorItem>("ColorItem");
ServiceMgr = new Service::Manager( serviceMgr = new Service::Manager(
configMgr->getActionConfig(), configMgr->getActionConfig(),
*imageMgr, *imageMgr,
*paletteMgr); *paletteMgr);
@@ -55,8 +56,64 @@ class Bootstrap {
imageMgr->loadAndProcess(configMgr->getWallpapers()); imageMgr->loadAndProcess(configMgr->getWallpapers());
} }
bool apply(const QString& path) {
QEventLoop loop;
bool successFlag = false;
paletteMgr->setSelectedPalette(cacheMgr->getSetting(
Cache::SettingsType::LastSelectedPalette,
[]() { return Config::CacheConfigItems::defaultSelectedPalette; }));
QObject::connect(
configMgr,
&Config::Manager::stateCaptured,
&loop,
[&]() {
loop.quit();
},
Qt::SingleShotConnection);
configMgr->captureState();
loop.exec();
QMetaObject::Connection connection;
connection = QObject::connect(
imageMgr,
&Image::Manager::isLoadingChanged,
&loop,
[&]() {
if (!imageMgr->isLoading()) {
QObject::disconnect(connection);
QVariant idVar = imageMgr->model()->data(
imageMgr->model()->index(0, 0),
Image::Model::IdRole);
if (idVar.isValid()) {
auto id = idVar.toString();
paletteMgr->updateColor(id);
QObject::connect(
serviceMgr,
&Service::Manager::selectCompleted,
&loop,
[&](bool success) {
successFlag = success;
loop.quit();
},
Qt::SingleShotConnection);
serviceMgr->selectWallpaper(id);
} else {
Logger::critical("Bootstrap", "No images loaded, cannot apply wallpaper");
loop.quit();
}
}
});
imageMgr->loadAndProcess({Utils::expandPath(path)});
loop.exec();
return successFlag;
}
~Bootstrap() { ~Bootstrap() {
delete ServiceMgr; delete serviceMgr;
delete paletteMgr; delete paletteMgr;
delete imageMgr; delete imageMgr;
delete configMgr; delete configMgr;
@@ -68,7 +125,7 @@ class Bootstrap {
Config::Manager* configMgr{}; Config::Manager* configMgr{};
Image::Manager* imageMgr{}; Image::Manager* imageMgr{};
Palette::Manager* paletteMgr{}; Palette::Manager* paletteMgr{};
Service::Manager* ServiceMgr{}; Service::Manager* serviceMgr{};
}; };
} // namespace WallReel::Core::Provider } // namespace WallReel::Core::Provider
+5 -15
View File
@@ -1,8 +1,6 @@
#ifndef WALLREEL_PROVIDER_CAROUSEL_HPP #ifndef WALLREEL_PROVIDER_CAROUSEL_HPP
#define WALLREEL_PROVIDER_CAROUSEL_HPP #define WALLREEL_PROVIDER_CAROUSEL_HPP
#include <qapplication.h>
#include <QApplication> #include <QApplication>
#include "Cache/manager.hpp" #include "Cache/manager.hpp"
@@ -148,10 +146,6 @@ class Carousel : public QObject {
signals: signals:
void isProcessingChanged(); void isProcessingChanged();
void selectCompleted();
void previewCompleted();
void restoreCompleted();
void cancelCompleted();
// Other states // Other states
@@ -196,7 +190,7 @@ class Carousel : public QObject {
m_configMgr(bootstrap.configMgr), m_configMgr(bootstrap.configMgr),
m_imageMgr(bootstrap.imageMgr), m_imageMgr(bootstrap.imageMgr),
m_paletteMgr(bootstrap.paletteMgr), m_paletteMgr(bootstrap.paletteMgr),
m_serviceMgr(bootstrap.ServiceMgr) { m_serviceMgr(bootstrap.serviceMgr) {
// Simply forward signals // Simply forward signals
connect(m_imageMgr, &Image::Manager::isLoadingChanged, this, &Carousel::isLoadingChanged); connect(m_imageMgr, &Image::Manager::isLoadingChanged, this, &Carousel::isLoadingChanged);
connect(m_imageMgr, &Image::Manager::processedCountChanged, this, &Carousel::processedCountChanged); connect(m_imageMgr, &Image::Manager::processedCountChanged, this, &Carousel::processedCountChanged);
@@ -206,10 +200,6 @@ class Carousel : public QObject {
connect(m_paletteMgr, &Palette::Manager::colorChanged, this, &Carousel::colorChanged); connect(m_paletteMgr, &Palette::Manager::colorChanged, this, &Carousel::colorChanged);
connect(m_paletteMgr, &Palette::Manager::colorNameChanged, this, &Carousel::colorNameChanged); connect(m_paletteMgr, &Palette::Manager::colorNameChanged, this, &Carousel::colorNameChanged);
connect(m_serviceMgr, &Service::Manager::isProcessingChanged, this, &Carousel::isProcessingChanged); connect(m_serviceMgr, &Service::Manager::isProcessingChanged, this, &Carousel::isProcessingChanged);
connect(m_serviceMgr, &Service::Manager::selectCompleted, this, &Carousel::selectCompleted);
connect(m_serviceMgr, &Service::Manager::previewCompleted, this, &Carousel::previewCompleted);
connect(m_serviceMgr, &Service::Manager::restoreCompleted, this, &Carousel::restoreCompleted);
connect(m_serviceMgr, &Service::Manager::cancelCompleted, this, &Carousel::cancelCompleted);
// "Preview" is costly, but is (usually) protected by a debounce timer, so it seems fine // "Preview" is costly, but is (usually) protected by a debounce timer, so it seems fine
// to call it multiple times in a short period, and it simplifies the code a lot :) // to call it multiple times in a short period, and it simplifies the code a lot :)
@@ -260,16 +250,16 @@ class Carousel : public QObject {
// Quit on selected // Quit on selected
if (m_configMgr->getActionConfig().quitOnSelected) { if (m_configMgr->getActionConfig().quitOnSelected) {
QObject::connect( QObject::connect(
this, m_serviceMgr,
&Provider::Carousel::selectCompleted, &Service::Manager::selectCompleted,
app, app,
&QApplication::quit, &QApplication::quit,
Qt::QueuedConnection); Qt::QueuedConnection);
} }
// Quit on cancel // Quit on cancel
QObject::connect( QObject::connect(
this, m_serviceMgr,
&Provider::Carousel::cancelCompleted, &Service::Manager::cancelCompleted,
app, app,
&QApplication::quit, &QApplication::quit,
Qt::QueuedConnection); Qt::QueuedConnection);
+11 -10
View File
@@ -47,7 +47,8 @@ void Manager::selectWallpaper(const QString& id) {
WR_WARN(QString("No valid image data at id %1. Skipping select action.").arg(id)); WR_WARN(QString("No valid image data at id %1. Skipping select action.").arg(id));
m_isProcessing = false; m_isProcessing = false;
emit isProcessingChanged(); emit isProcessingChanged();
emit selectCompleted(); emit selectCompleted(false);
return;
} }
const auto command = _renderCommand(m_actionConfig.onSelected, _generateVariables(*data)); const auto command = _renderCommand(m_actionConfig.onSelected, _generateVariables(*data));
@@ -62,7 +63,7 @@ void Manager::restore() {
} }
if (!m_stateCaptured) { if (!m_stateCaptured) {
WR_DEBUG("State not captured yet, skipping restore action"); WR_DEBUG("State not captured yet, skipping restore action");
emit restoreCompleted(); emit restoreCompleted(false);
return; return;
} }
m_isProcessing = true; m_isProcessing = true;
@@ -81,7 +82,7 @@ void Manager::previewWallpaper(const QString& id) {
if (!m_stateCaptured) { if (!m_stateCaptured) {
WR_DEBUG("State not captured yet, deferring preview for id " + id); WR_DEBUG("State not captured yet, deferring preview for id " + id);
m_pendingPreviewId = id; m_pendingPreviewId = id;
emit previewCompleted(); emit previewCompleted(false);
return; return;
} }
@@ -91,7 +92,7 @@ void Manager::previewWallpaper(const QString& id) {
if (!data || !data->isValid()) { if (!data || !data->isValid()) {
WR_WARN(QString("No valid image data at id %1. Skipping preview action.").arg(id)); WR_WARN(QString("No valid image data at id %1. Skipping preview action.").arg(id));
emit previewCompleted(); emit previewCompleted(false);
return; return;
} }
@@ -106,23 +107,23 @@ void Manager::restoreOnQuit() {
Logger::debug("ServiceManager", "Restore on quit"); Logger::debug("ServiceManager", "Restore on quit");
m_wallpaperService->stopAll(); m_wallpaperService->stopAll();
QEventLoop loop; QEventLoop loop;
connect(m_wallpaperService, &WallpaperService::restoreCompleted, &loop, &QEventLoop::quit); connect(this, &Manager::restoreCompleted, &loop, &QEventLoop::quit);
// Call restore after the event loop starts // Call restore after the event loop starts
QTimer::singleShot(0, this, &Manager::restore); QTimer::singleShot(0, this, &Manager::restore);
loop.exec(); loop.exec();
} }
void Manager::_onSelectCompleted() { void Manager::_onSelectCompleted(bool success) {
Logger::debug("ServiceManager", "Select completed"); Logger::debug("ServiceManager", "Select completed");
_onProcessCompleted(); _onProcessCompleted();
m_hasSelected = true; m_hasSelected = m_hasSelected || success;
emit selectCompleted(); emit selectCompleted(success);
} }
void Manager::_onRestoreCompleted() { void Manager::_onRestoreCompleted(bool success) {
Logger::debug("ServiceManager", "Restore completed"); Logger::debug("ServiceManager", "Restore completed");
_onProcessCompleted(); _onProcessCompleted();
emit restoreCompleted(); emit restoreCompleted(success);
} }
void Manager::_onProcessCompleted() { void Manager::_onProcessCompleted() {
+5 -5
View File
@@ -43,17 +43,17 @@ class Manager : public QObject {
private slots: private slots:
void _onSelectCompleted(); void _onSelectCompleted(bool success);
void _onRestoreCompleted(); void _onRestoreCompleted(bool success);
void _onProcessCompleted(); void _onProcessCompleted();
signals: signals:
void isProcessingChanged(); void isProcessingChanged();
void selectCompleted(); void selectCompleted(bool success);
void previewCompleted(); void previewCompleted(bool success);
void restoreCompleted(); void restoreCompleted(bool success);
void cancelCompleted(); void cancelCompleted();
private: private:
+61 -9
View File
@@ -1,5 +1,7 @@
#include "Service/wallpaper.hpp" #include "Service/wallpaper.hpp"
#include <qprocess.h>
#include <QColor> #include <QColor>
#include "logger.hpp" #include "logger.hpp"
@@ -17,31 +19,81 @@ WallpaperService::WallpaperService(int previewDebounceTime, QObject* parent)
_doPreview(m_pendingPreviewCommand); _doPreview(m_pendingPreviewCommand);
}); });
// There is a chance that a QProcess fails to start, changing its state from Starting to NotRunning without emitting finished signal,
// so we need to handle errorOccurred signal to catch that case and emit previewCompleted/selectCompleted/restoreCompleted with
// false to indicate failure.
// However, this is probably impossible since we use "sh" "-c" to execute commands and "sh" should always be available.
m_previewProcess = new QProcess(this); m_previewProcess = new QProcess(this);
connect(m_previewProcess,
&QProcess::errorOccurred,
this,
[this](QProcess::ProcessError error) {
WR_WARN(QString("Preview command process error: %1").arg(error));
if (error == QProcess::FailedToStart) {
WR_WARN("Failed to start preview command process.");
emit previewCompleted(false);
}
});
connect(m_previewProcess, connect(m_previewProcess,
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
this, this,
[this](int exitCode, QProcess::ExitStatus exitStatus) { [this](int exitCode, QProcess::ExitStatus exitStatus) {
WR_DEBUG(QString("Preview process finished with exit code %1 and exit status %2").arg(exitCode).arg(exitStatus)); bool success = exitCode == 0 && exitStatus == QProcess::NormalExit;
emit previewCompleted(); if (!success) {
WR_WARN(QString("Preview command failed with exit code %1 and exit status %2").arg(exitCode).arg(exitStatus));
} else {
WR_DEBUG("Preview command executed successfully");
}
emit previewCompleted(success);
}); });
m_selectProcess = new QProcess(this); m_selectProcess = new QProcess(this);
connect(m_selectProcess,
&QProcess::errorOccurred,
this,
[this](QProcess::ProcessError error) {
WR_WARN(QString("Select command process error: %1").arg(error));
if (error == QProcess::FailedToStart) {
WR_WARN("Failed to start select command process.");
emit selectCompleted(false);
}
});
connect(m_selectProcess, connect(m_selectProcess,
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
this, this,
[this](int exitCode, QProcess::ExitStatus exitStatus) { [this](int exitCode, QProcess::ExitStatus exitStatus) {
WR_DEBUG(QString("Select process finished with exit code %1 and exit status %2").arg(exitCode).arg(exitStatus)); bool success = exitCode == 0 && exitStatus == QProcess::NormalExit;
emit selectCompleted(); if (!success) {
WR_WARN(QString("Select command failed with exit code %1 and exit status %2").arg(exitCode).arg(exitStatus));
} else {
WR_DEBUG("Select command executed successfully");
}
emit selectCompleted(success);
}); });
m_restoreProcess = new QProcess(this); m_restoreProcess = new QProcess(this);
connect(m_restoreProcess,
&QProcess::errorOccurred,
this,
[this](QProcess::ProcessError error) {
WR_WARN(QString("Restore command process error: %1").arg(error));
if (error == QProcess::FailedToStart) {
WR_WARN("Failed to start restore command process.");
emit restoreCompleted(false);
}
});
connect(m_restoreProcess, connect(m_restoreProcess,
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
this, this,
[this](int exitCode, QProcess::ExitStatus exitStatus) { [this](int exitCode, QProcess::ExitStatus exitStatus) {
WR_DEBUG(QString("Restore process finished with exit code %1 and exit status %2").arg(exitCode).arg(exitStatus)); bool success = exitCode == 0 && exitStatus == QProcess::NormalExit;
emit restoreCompleted(); if (!success) {
WR_WARN(QString("Restore command failed with exit code %1 and exit status %2").arg(exitCode).arg(exitStatus));
} else {
WR_DEBUG("Restore command executed successfully");
}
emit restoreCompleted(success);
}); });
} }
@@ -87,7 +139,7 @@ void WallpaperService::restore(const QString& command) {
void WallpaperService::_doPreview(const QString& command) { void WallpaperService::_doPreview(const QString& command) {
if (command.isEmpty()) { if (command.isEmpty()) {
WR_DEBUG("No preview command configured. Skipping preview action."); WR_DEBUG("No preview command configured. Skipping preview action.");
emit previewCompleted(); emit previewCompleted(true);
return; return;
} }
WR_DEBUG(QString("Executing preview command: %1").arg(command)); WR_DEBUG(QString("Executing preview command: %1").arg(command));
@@ -102,7 +154,7 @@ void WallpaperService::_doPreview(const QString& command) {
void WallpaperService::_doSelect(const QString& command) { void WallpaperService::_doSelect(const QString& command) {
if (command.isEmpty()) { if (command.isEmpty()) {
WR_DEBUG("No select command configured. Skipping select action."); WR_DEBUG("No select command configured. Skipping select action.");
emit selectCompleted(); emit selectCompleted(true);
return; return;
} }
WR_DEBUG(QString("Executing select command: %1").arg(command)); WR_DEBUG(QString("Executing select command: %1").arg(command));
@@ -112,7 +164,7 @@ void WallpaperService::_doSelect(const QString& command) {
void WallpaperService::_doRestore(const QString& command) { void WallpaperService::_doRestore(const QString& command) {
if (command.isEmpty()) { if (command.isEmpty()) {
WR_DEBUG("Restore command is empty. Skipping restore action."); WR_DEBUG("Restore command is empty. Skipping restore action.");
emit restoreCompleted(); emit restoreCompleted(true);
return; return;
} }
WR_DEBUG(QString("Executing restore command: %1").arg(command)); WR_DEBUG(QString("Executing restore command: %1").arg(command));
+3 -3
View File
@@ -20,9 +20,9 @@ class WallpaperService : public QObject {
void restore(const QString& command); // execute immediately, ignore if already running void restore(const QString& command); // execute immediately, ignore if already running
signals: signals:
void previewCompleted(); void previewCompleted(bool success);
void selectCompleted(); void selectCompleted(bool success);
void restoreCompleted(); void restoreCompleted(bool success);
private: private:
void _doPreview(const QString& command); void _doPreview(const QString& command);
+15 -1
View File
@@ -67,6 +67,9 @@ void AppOptions::parseArgs(QApplication& app) {
QCommandLineOption configFileOption(QStringList() << "c" << "config-file", "Specify a custom configuration file", "file"); QCommandLineOption configFileOption(QStringList() << "c" << "config-file", "Specify a custom configuration file", "file");
parser.addOption(configFileOption); parser.addOption(configFileOption);
QCommandLineOption applyOption(QStringList() << "a" << "apply", "Apply the specified image as wallpaper and exit", "file");
parser.addOption(applyOption);
// Not parser.process(a->arguments()) because we want to handle exit logics ourselves. // Not parser.process(a->arguments()) because we want to handle exit logics ourselves.
// parser.process(...) will do something like exit(...) that will terminate // parser.process(...) will do something like exit(...) that will terminate
// the application brutally and produce unwanted warnings. // the application brutally and produce unwanted warnings.
@@ -111,7 +114,7 @@ void AppOptions::parseArgs(QApplication& app) {
} }
if (parser.isSet(configFileOption)) { if (parser.isSet(configFileOption)) {
QString path = parser.value(configFileOption); QString path = Utils::expandPath(parser.value(configFileOption));
if (Utils::checkFile(path)) { if (Utils::checkFile(path)) {
configPath = path; configPath = path;
} else { } else {
@@ -120,6 +123,17 @@ void AppOptions::parseArgs(QApplication& app) {
return; return;
} }
} }
if (parser.isSet(applyOption)) {
QString path = Utils::expandPath(parser.value(applyOption));
if (Utils::checkImageFile(path)) {
applyPath = path;
} else {
errorText = QString("Error: Image file does not exist, is not accessible, or has an unsupported format: %1").arg(path);
printError();
return;
}
}
} }
} // namespace WallReel::Core } // namespace WallReel::Core
+1
View File
@@ -26,6 +26,7 @@ class AppOptions {
QString configPath; QString configPath;
QStringList appendDirs; QStringList appendDirs;
QString errorText; QString errorText;
QString applyPath; // -a --apply
bool clearCache = false; // -C --clear-cache bool clearCache = false; // -C --clear-cache
bool doReturn = false; ///< Indicates whether the application should exit after parsing arguments. bool doReturn = false; ///< Indicates whether the application should exit after parsing arguments.
+4
View File
@@ -48,6 +48,10 @@ int main(int argc, char* argv[]) {
return 0; return 0;
} }
if (!options.applyPath.isEmpty()) {
return bootstrap.apply(options.applyPath) ? 0 : 1;
}
{ {
Provider::Carousel provider(&a, bootstrap); Provider::Carousel provider(&a, bootstrap);
qmlRegisterSingletonInstance( qmlRegisterSingletonInstance(