1
0

feat: update qwfassoc

This commit is contained in:
2026-06-23 21:38:54 +08:00
parent 071347f4d4
commit 1a44240f88
24 changed files with 1169 additions and 684 deletions

View File

@@ -1,13 +1,14 @@
cmake_minimum_required(VERSION 3.20)
project(qwfassoc LANGUAGES CXX)
project(qwfassoc_suite LANGUAGES CXX)
# Qt 6 requires C++17 at minimum.
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Let CMake auto-process Q_OBJECT, .ui files and .qrc resources.
# Let CMake auto-process Q_OBJECT, .ui files and .qrc resources for every
# subproject below.
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
@@ -15,64 +16,21 @@ set(CMAKE_AUTORCC ON)
# Make the bundled Findwfassoc.cmake module visible to find_package().
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
# Locate third-party dependencies. The user is responsible for making these
# discoverable through the usual CMake mechanisms (CMAKE_PREFIX_PATH, etc.).
# LinguistTools is required to wire up Qt's translation pipeline
# (lupdate + lrelease) so that .ts files are kept up to date and .qm files
# are embedded as Qt resources.
# Qt and LinguistTools are used by both subprojects, so they are looked up at
# the top level. The same is true for wfassoc: although only the qwfassoc
# library links against it directly, PUBLIC propagation from the library
# target makes the dependency available to qwfassoc-standalone as well.
# toml11 is only needed when the standalone executable is built, so it is
# looked up conditionally below.
find_package(Qt6 REQUIRED COMPONENTS Widgets LinguistTools)
find_package(toml11 REQUIRED)
# Findwfassoc.cmake requires the wfassoc_ROOT variable to be set.
find_package(wfassoc REQUIRED)
set(QWFASSOC_SOURCES
"${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/main_window.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/manifest.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/icon_utils.cpp"
)
# The standalone executable is optional: embedders may want only the library.
option(QWFASSOC_BUILD_STANDALONE "Build the qwfassoc-standalone executable" ON)
set(QWFASSOC_HEADERS
"${CMAKE_CURRENT_SOURCE_DIR}/src/main_window.h"
"${CMAKE_CURRENT_SOURCE_DIR}/src/manifest.h"
"${CMAKE_CURRENT_SOURCE_DIR}/src/icon_utils.h"
)
add_subdirectory(qwfassoc)
set(QWFASSOC_UI
"${CMAKE_CURRENT_SOURCE_DIR}/src/main_window.ui"
)
# WIN32 makes this a windowed application on Windows (no console window).
add_executable(qwfassoc WIN32
${QWFASSOC_SOURCES}
${QWFASSOC_HEADERS}
${QWFASSOC_UI}
)
target_include_directories(qwfassoc PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/src"
)
target_link_libraries(qwfassoc PRIVATE
Qt6::Widgets
toml11::toml11
wfassoc::wfassoc
)
# Translation pipeline.
#
# qt6_add_translations() runs lupdate against the target's sources (keeping
# the .ts files below in sync) and then runs lrelease to compile them into
# .qm files. The .qm files are embedded under the ":/i18n" resource prefix,
# where installTranslators() in main.cpp looks them up at runtime.
#
# The .ts files are intentionally shipped empty: translators are expected to
# fill them in. Re-running cmake / building the target refreshes the entries
# found by lupdate without dropping already-translated ones.
set(QWFASSOC_TS_FILES
"${CMAKE_CURRENT_SOURCE_DIR}/i18n/qwfassoc_zh_CN.ts"
)
qt6_add_translations(qwfassoc
TS_FILES ${QWFASSOC_TS_FILES}
)
if(QWFASSOC_BUILD_STANDALONE)
find_package(toml11 REQUIRED)
add_subdirectory(qwfassoc-standalone)
endif()

View File

@@ -1,137 +1,154 @@
# qwfassoc
# qwfassoc (suite)
A Qt Widgets based GUI front-end for the [wfassoc](../../wfassoc) library.
A Qt Widgets based GUI for the [wfassoc](../../wfassoc) library, split into a
reusable shared library and a small standalone executable that exercises it.
`qwfassoc` exposes the same install / uninstall / file-association operations
as the `wfassoc-exec` command line tool, but in a small tabbed dialog aimed at
end users rather than scripts.
## Layout
The project is organized as two CMake subprojects:
```
qwfassoc/
├── CMakeLists.txt CMake build script
├── README.md This file
qwfassoc/ Parent directory (this README)
├── CMakeLists.txt Top-level CMake; add_subdirectory's both
subprojects and finds Qt, wfassoc, toml11
├── cmake/
│ ├── Findwfassoc.cmake Verbatim copy of wfassoc's Find module
│ └── README.md Provenance notes for the copy
├── i18n/
── qwfassoc_zh_CN.ts Empty placeholder translation file
└── src/
├── qwfassoc/ Shared library subproject
── CMakeLists.txt
│ ├── i18n/
│ │ └── qwfassoc_zh_CN.ts Empty placeholder translation file
│ └── src/
│ ├── qwfassoc_global.h QWFASSOC_EXPORT macro
│ ├── scope.h Shared TargetScope enum
│ ├── manifest.h Manifest data struct (no TOML dependency)
│ ├── icon_utils.h/.cpp wfassocpp::HICON -> QPixmap conversion
│ ├── application_widget.h/.cpp/.ui
│ │ Install / uninstall widget
│ └── association_widget.h/.cpp/.ui
│ File associations widget
└── qwfassoc-standalone/ Executable subproject
├── CMakeLists.txt
├── i18n/
│ └── qwfassoc-standalone_zh_CN.ts
│ Empty placeholder translation file
└── src/
├── main.cpp Entry point, CLI parsing, translator loading
├── main_window.h/.cpp Main configuration dialog
├── main_window.ui Qt Designer description of the dialog
── manifest.h/.cpp TOML manifest loader (toml11) and schema builder
└── icon_utils.h/.cpp wfassocpp::HICON -> QPixmap conversion helper
├── main_window.h/.cpp/.ui
│ QDialog hosting the two widgets in a tab widget
── manifest_parser.h/.cpp
TOML -> Manifest, Manifest -> Schema
```
## Subprojects at a glance
### `qwfassoc` (shared library)
Exports two reusable widgets that wrap wfassoc:
* `qwfassoc::ApplicationWidget` — install / uninstall the program in the
configured scope.
* `qwfassoc::AssociationWidget` — stage and apply per-extension link / unlink
operations.
Both widgets follow the **two-phase initialization** pattern expected by Qt
Designer promoted widgets: the constructor only takes a `QWidget*` parent and
leaves the widget disabled. A `setConfig(Config)` method injects the
`wfassocpp::Program` pointer and `TargetScope` (and, for the association
widget, whether the OK/Cancel buttons are visible). Each widget also exposes:
* a `refresh()` slot that re-queries the live wfassoc state, intended to be
called by the host when another component has mutated the registry;
* a `changed()` signal emitted whenever the widget itself mutates the
registry (install / uninstall / apply);
* (`AssociationWidget` only) a `finished(bool accepted)` signal emitted when
the user clicks OK (after `changed()`) or Cancel, so the host can close the
dialog.
The library also exposes the plain `qwfassoc::Manifest` data struct and a
`qwfassoc::icon_utils::fromHicon()` helper, but the TOML parsing logic (which
depends on toml11) lives in the standalone executable.
### `qwfassoc-standalone` (executable)
Reproduces the original tabbed wfassoc configurator by:
1. parsing `-c/--manifest <path>` and `-f/--for <user|system>` from the
command line;
2. building a `wfassocpp::Program` via `parseManifestFile` + `buildSchema`;
3. hosting `ApplicationWidget` and `AssociationWidget` inside a `QTabWidget`
in a `MainWindow` dialog;
4. wiring the widgets' `changed()` and `finished()` signals so that any
registry mutation refreshes both pages and OK/Cancel drive the dialog's
acceptance.
## Requirements
* **CMake** 3.20 or newer.
* **CMake** 3.20 or newer (3.21+ recommended for `qt6_add_translations`).
* A C++17 compiler.
* **Qt 6** (6.3 or newer is recommended for `qt6_add_translations`). The
`Widgets` and `LinguistTools` components are required:
`find_package(Qt6 COMPONENTS Widgets LinguistTools)`.
* **toml11**. `find_package(toml11)` is used.
* **wfassoc**. `find_package(wfassoc)` is used, which requires `wfassoc_ROOT`
to point at an installed wfassoc tree (see
* **Qt 6** with the `Widgets` and `LinguistTools` components.
* **wfassoc**, with `wfassoc_ROOT` pointing at an installed tree (see
[`cmake/Findwfassoc.cmake`](cmake/Findwfassoc.cmake) for the expected
directory layout).
* **toml11** — only required when building the standalone executable.
## Building
```bat
cmake -S . -B build -DCMAKE_PREFIX_PATH=C:\Qt\6.x.x\msvc2022_64 ^
cmake -S . -B build ^
-DCMAKE_PREFIX_PATH=C:\Qt\6.x.x\msvc2022_64 ^
-Dwfassoc_ROOT=C:\path\to\wfassoc\install ^
-Dtoml11_DIR=C:\path\to\toml11\share\toml11\cmake
cmake --build build --config Release
```
The resulting executable is `build/Release/qwfassoc.exe` (or a similar path
depending on the generator).
To skip the standalone executable (and the toml11 dependency):
## Running
```bat
cmake -S . -B build -DQWFASSOC_BUILD_STANDALONE=OFF ...
```
`qwfassoc` requires two command line arguments:
The standalone executable is `build/qwfassoc-standalone/Release/qwfassoc-standalone.exe`
(or similar, depending on the generator); the library is
`build/qwfassoc/Release/qwfassoc.dll`.
## Running the standalone executable
| Short | Long | Meaning |
| ----- | ------------ | ------------------------------------------------------------- |
| ----- | ------------ | ------------------------------------------------------------------------ |
| `-c` | `--manifest` | Path to the application manifest TOML file (see [`example/manifest/ppic.toml`](../manifest/ppic.toml)). |
| `-f` | `--for` | Target scope: `user` or `system`. |
Example:
```bat
qwfassoc -c C:\path\to\ppic.toml -f user
qwfassoc-standalone -c C:\path\to\ppic.toml -f user
```
When `-f user` is used the "All Users" column of the file-association table is
read-only (cells render greyed out and clicks are ignored). Use `-f system`
(with appropriate administrator privileges) to make changes that affect all
users.
## Internationalization
The user-facing strings shipped in the source code and the `.ui` file are
written in English. Every translatable string is wrapped in `tr()` (in code)
or marked as a regular `<string>` (in the `.ui` file, which `uic` then wraps
in `QCoreApplication::translate`).
Source strings are English and every user-facing string is wrapped in `tr()`
(in code) or is a plain `<string>` element in the `.ui` file (which `uic`
wraps in `QCoreApplication::translate`).
The CMake build wires up the standard Qt translation pipeline via
`qt6_add_translations()`:
Each subproject ships its own empty placeholder `.ts` file under its
`i18n/` directory and registers it with `qt6_add_translations()`:
* the listed `.ts` files under `i18n/` are kept in sync with the source by
`lupdate`,
* `lrelease` compiles them into `.qm` files which are embedded under the
`:/i18n/` resource prefix,
* `installTranslators()` in `src/main.cpp` loads the `.qm` file matching the
current locale at startup.
* `qwfassoc/i18n/qwfassoc_zh_CN.ts` — covers the library widgets.
* `qwfassoc-standalone/i18n/qwfassoc-standalone_zh_CN.ts` — covers the
executable-specific messages (CLI errors, tab titles, dialog window
title, etc.).
The repository ships `i18n/qwfassoc_zh_CN.ts` as an **empty placeholder**.
Translators are expected to fill it in (or add new language files and list
them in `CMakeLists.txt`). No actual translation work is performed by the
build on its own.
## UI Overview
The dialog is a fixed 480x600 `QDialog` with two tabs.
### Applications tab
Lets the user install or uninstall the program described by the manifest in
the scope selected by `--for`. The currently-active action button is enabled
based on whether the program is already registered; the other one is disabled.
### File associations tab
Lists every extension declared in the manifest. Each row shows:
* the dotted extension (`.jpg`) with a hybrid-view icon,
* the display name of the handler currently registered for the current user,
* the display name of the handler currently registered for all users.
Clicking a cell in the user or all-users column toggles the cell state between
the program-provided handler (link) and no handler (unlink). The "+ " buttons
above each column progressively select more: the first click only fills blank
cells, the next click overrides any cell pointing at another handler.
Changes are buffered in memory and only written to the registry when **OK** or
**Apply** is pressed. **OK** writes and closes the dialog; **Apply** writes
and refreshes the table; **Cancel** discards the pending changes and closes
the dialog. The **Apply** button is disabled when no changes are pending.
If the program is not installed in the active scope, the whole file
associations tab is disabled.
At runtime, `installTranslators()` in `qwfassoc-standalone/src/main.cpp`
loads both `.qm` files for the user's preferred UI language from the
`:/i18n/` resource prefix. Translators are expected to fill in the `.ts`
files; no actual translation work is performed by the build on its own.
## Notes and Limitations
* The "self" detection in the file-association table is based on comparing the
* "Self" detection in the file-association table is based on comparing the
display name returned by wfassoc with the display name this program would
use. Two programs sharing the exact same display name could therefore be
confused.
* The dialog uses `Qt::ItemIsSelectable` (without `Qt::ItemIsEnabled`) to
render disabled system-column cells in user mode; their text is still shown
but they cannot be clicked.
* The system column in `AssociationWidget` is rendered disabled (using
`Qt::ItemIsSelectable` without `Qt::ItemIsEnabled`) when `TargetScope` is
`User`; the cells stay visible but cannot be clicked.
* All errors originating from wfassoc are surfaced through `QMessageBox`
dialogs; fatal errors during startup cause the process to exit with a
non-zero status code.

View File

@@ -0,0 +1,48 @@
# qwfassoc-standalone: executable that uses the qwfassoc library to reproduce
# the original tabbed wfassoc configurator.
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(QWFASSOC_STANDALONE_SOURCES
"${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/main_window.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/manifest_parser.cpp"
)
set(QWFASSOC_STANDALONE_HEADERS
"${CMAKE_CURRENT_SOURCE_DIR}/src/main_window.h"
"${CMAKE_CURRENT_SOURCE_DIR}/src/manifest_parser.h"
)
set(QWFASSOC_STANDALONE_UI
"${CMAKE_CURRENT_SOURCE_DIR}/src/main_window.ui"
)
add_executable(qwfassoc-standalone WIN32
${QWFASSOC_STANDALONE_SOURCES}
${QWFASSOC_STANDALONE_HEADERS}
${QWFASSOC_STANDALONE_UI}
)
target_include_directories(qwfassoc-standalone PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/src"
)
target_link_libraries(qwfassoc-standalone PRIVATE
qwfassoc
Qt6::Widgets
toml11::toml11
)
# Translation pipeline for the standalone executable. The library's strings
# are translated by qwfassoc's own .ts file; this one only covers the
# executable-specific messages (CLI errors, tab titles, etc.).
set(QWFASSOC_STANDALONE_TS_FILES
"${CMAKE_CURRENT_SOURCE_DIR}/i18n/qwfassoc-standalone_zh_CN.ts"
)
qt6_add_translations(qwfassoc-standalone
TS_FILES ${QWFASSOC_STANDALONE_TS_FILES}
)

View File

@@ -13,21 +13,18 @@
#include <wfassoc++.h>
#include "main_window.h"
#include "manifest.h"
#include "manifest_parser.h"
#include "scope.h"
namespace {
// Context used for translatable strings that live outside of any QObject.
// Keeping it stable lets translators find these messages under the same key
// across builds.
constexpr const char* kTranslationContext = "qwfassoc";
constexpr const char* kTranslationContext = "qwfassoc-standalone";
// Show a modal error dialog with the given message and return a non-zero
// exit code. Used for the various fatal conditions that may occur before the
// main dialog can be shown.
int fatal(QWidget* parent, const QString& message) {
// The application name is treated as a brand identifier and is not
// translated; the message text itself is already translated by callers.
QMessageBox::critical(parent, QApplication::applicationName(), message);
return 1;
}
@@ -46,18 +43,24 @@ qwfassoc::TargetScope parseScope(const QString& value) {
"Invalid value for --for. Use \"user\" or \"system\".");
}
// Load the translation matching the user's preferred UI language, if any.
// Qt's qt6_add_translations() compiles .ts files into .qm files and embeds
// them under the ":/i18n" resource prefix.
// Install the translation(s) matching the user's preferred UI language, if
// any. Both the qwfassoc library's .qm and this executable's .qm are loaded
// (their .ts files live under the per-project i18n/ directories and are
// embedded under the ":/i18n" resource prefix by qt6_add_translations).
void installTranslators(QApplication& app) {
QTranslator* translator = new QTranslator(&app);
const QStringList uiLanguages = QLocale::system().uiLanguages();
for (const QString& locale : uiLanguages) {
const QString baseName =
QStringLiteral("qwfassoc_") + QLocale(locale).name();
if (translator->load(QStringLiteral(":/i18n/") + baseName)) {
app.installTranslator(translator);
return;
const QString name = QLocale(locale).name();
QTranslator* libTranslator = new QTranslator(&app);
if (libTranslator->load(QStringLiteral(":/i18n/qwfassoc_") + name)) {
app.installTranslator(libTranslator);
}
QTranslator* appTranslator = new QTranslator(&app);
if (appTranslator->load(
QStringLiteral(":/i18n/qwfassoc-standalone_") + name)) {
app.installTranslator(appTranslator);
}
}
}
@@ -66,11 +69,11 @@ void installTranslators(QApplication& app) {
int main(int argc, char* argv[]) {
QApplication app(argc, argv);
QApplication::setApplicationName(QStringLiteral("qwfassoc"));
QApplication::setApplicationName(QStringLiteral("qwfassoc-standalone"));
// Install available translations before any translatable string is
// resolved (including the application display name below) so that tr()
// and QCoreApplication::translate() pick up the right language.
// resolved so that tr() and QCoreApplication::translate() pick up the
// right language.
installTranslators(app);
QApplication::setApplicationDisplayName(
@@ -80,8 +83,9 @@ int main(int argc, char* argv[]) {
// Parse command line arguments using Qt's built-in parser.
QCommandLineParser parser;
parser.setApplicationDescription(
QCoreApplication::translate(kTranslationContext,
"Qt-based GUI for the wfassoc library."));
QCoreApplication::translate(
kTranslationContext,
"Qt-based GUI executable for the wfassoc library."));
parser.addHelpOption();
QCommandLineOption manifestOption(
@@ -136,8 +140,8 @@ int main(int argc, char* argv[]) {
int exitCode = 0;
try {
qwfassoc::Manifest manifest =
qwfassoc::Manifest::fromFile(manifestPath.toStdString());
wfassocpp::Schema schema = manifest.toSchema();
qwfassoc::parseManifestFile(manifestPath.toStdString());
wfassocpp::Schema schema = qwfassoc::buildSchema(manifest);
wfassocpp::Program program(std::move(schema));
qwfassoc::MainWindow window(std::move(program), scope);

View File

@@ -0,0 +1,79 @@
#include "main_window.h"
#include "ui_main_window.h"
#include "application_widget.h"
#include "association_widget.h"
#include "icon_utils.h"
#include <QTabWidget>
namespace qwfassoc {
MainWindow::MainWindow(wfassocpp::Program program,
TargetScope scope,
QWidget* parent)
: QDialog(parent),
ui_(new Ui::MainWindow),
appTab_(nullptr),
assocTab_(nullptr),
program_(std::move(program)),
scope_(scope) {
ui_->setupUi(this);
// Resolve program metadata that several labels depend on.
programName_ = QString::fromUtf8(program_.ResolveName());
{
auto iconRc = program_.ResolveIcon();
auto handle = iconRc.GetIcon();
programIcon_ = icon_utils::fromHicon(handle);
}
// Compose the window title and icon.
setWindowTitle(tr("%1 Options").arg(programName_));
if (!programIcon_.isNull()) {
setWindowIcon(QIcon(programIcon_));
}
// Build the two tab pages from the library widgets.
appTab_ = new ApplicationWidget(this);
assocTab_ = new AssociationWidget(this);
ui_->tabWidget->addTab(appTab_, tr("Applications"));
ui_->tabWidget->addTab(assocTab_, tr("File Associations"));
// Two-phase initialization. The standalone executable wants the OK and
// Cancel buttons visible because they drive dialog acceptance.
appTab_->setConfig({&program_, scope_});
assocTab_->setConfig({&program_, scope_, /*showOkCancelButtons=*/true});
// Wire widget signals so that any wfassoc change refreshes both pages,
// and the association widget can request dialog closure.
connect(appTab_, &ApplicationWidget::changed, this,
&MainWindow::onAnyChanged);
connect(assocTab_, &AssociationWidget::changed, this,
&MainWindow::onAnyChanged);
connect(assocTab_, &AssociationWidget::finished, this,
&MainWindow::onFinished);
}
MainWindow::~MainWindow() = default;
void MainWindow::onAnyChanged() {
if (appTab_ != nullptr) {
appTab_->refresh();
}
if (assocTab_ != nullptr) {
assocTab_->refresh();
}
}
void MainWindow::onFinished(bool accepted) {
if (accepted) {
accept();
} else {
reject();
}
}
} // namespace qwfassoc

View File

@@ -0,0 +1,57 @@
#pragma once
#ifndef QWFASSOC_STANDALONE_MAIN_WINDOW_H_
#define QWFASSOC_STANDALONE_MAIN_WINDOW_H_
#include <QDialog>
#include <QPixmap>
#include <QString>
#include <wfassoc++.h>
#include "scope.h"
namespace Ui {
class MainWindow;
}
namespace qwfassoc {
class ApplicationWidget;
class AssociationWidget;
}
namespace qwfassoc {
// Top-level dialog used by the qwfassoc-standalone executable. Hosts the two
// reusable widgets from the qwfassoc library inside a QTabWidget and wires
// their changed() / finished() signals together.
class MainWindow : public QDialog {
Q_OBJECT
public:
explicit MainWindow(wfassocpp::Program program,
TargetScope scope,
QWidget* parent = nullptr);
~MainWindow() override;
private slots:
// Called whenever one of the embedded widgets reports that wfassoc state
// has changed. Refreshes both widgets so they stay in sync.
void onAnyChanged();
// Called when the association widget asks the dialog to close.
void onFinished(bool accepted);
private:
Ui::MainWindow* ui_;
ApplicationWidget* appTab_;
AssociationWidget* assocTab_;
wfassocpp::Program program_;
TargetScope scope_;
QString programName_;
QPixmap programIcon_;
};
} // namespace qwfassoc
#endif // QWFASSOC_STANDALONE_MAIN_WINDOW_H_

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QDialog" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>480</width>
<height>600</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>480</width>
<height>600</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>480</width>
<height>600</height>
</size>
</property>
<property name="windowTitle">
<string>Options</string>
</property>
<layout class="QVBoxLayout" name="mainLayout">
<property name="leftMargin">
<number>9</number>
</property>
<property name="topMargin">
<number>9</number>
</property>
<property name="rightMargin">
<number>9</number>
</property>
<property name="bottomMargin">
<number>9</number>
</property>
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>0</number>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -1,4 +1,4 @@
#include "manifest.h"
#include "manifest_parser.h"
#include <stdexcept>
@@ -6,9 +6,9 @@
namespace qwfassoc {
// region: Manifest Parsing
// region: TOML Parsing
Manifest Manifest::fromFile(const std::string& path) {
Manifest parseManifestFile(const std::string& path) {
toml::value root;
try {
root = toml::parse(path);
@@ -103,30 +103,33 @@ Manifest Manifest::fromFile(const std::string& path) {
// region: Schema Conversion
wfassocpp::Schema Manifest::toSchema() const {
wfassocpp::Schema buildSchema(const Manifest& manifest) {
wfassocpp::Schema schema;
// The wfassocpp wrappers translate any underlying failure into a
// std::runtime_error via _Check, so we let those propagate untouched.
schema.SetIdentifier(identifier.c_str());
schema.SetPath(path.c_str());
schema.SetClsid(clsid.c_str());
schema.SetIdentifier(manifest.identifier.c_str());
schema.SetPath(manifest.path.c_str());
schema.SetClsid(manifest.clsid.c_str());
// Optional fields: passing nullptr tells wfassoc to clear the value.
schema.SetName(name.has_value() ? name->c_str() : nullptr);
schema.SetIcon(icon.has_value() ? icon->c_str() : nullptr);
schema.SetBehavior(behavior.has_value() ? behavior->c_str() : nullptr);
schema.SetName(manifest.name.has_value() ? manifest.name->c_str()
: nullptr);
schema.SetIcon(manifest.icon.has_value() ? manifest.icon->c_str()
: nullptr);
schema.SetBehavior(manifest.behavior.has_value() ? manifest.behavior->c_str()
: nullptr);
for (const auto& [key, value] : strs) {
for (const auto& [key, value] : manifest.strs) {
schema.AddStr(key.c_str(), value.c_str());
}
for (const auto& [key, value] : icons) {
for (const auto& [key, value] : manifest.icons) {
schema.AddIcon(key.c_str(), value.c_str());
}
for (const auto& [key, value] : behaviors) {
for (const auto& [key, value] : manifest.behaviors) {
schema.AddBehavior(key.c_str(), value.c_str());
}
for (const auto& [key, value] : exts) {
for (const auto& [key, value] : manifest.exts) {
schema.AddExt(key.c_str(),
value.name.c_str(),
value.icon.c_str(),

View File

@@ -0,0 +1,25 @@
#pragma once
#ifndef QWFASSOC_STANDALONE_MANIFEST_PARSER_H_
#define QWFASSOC_STANDALONE_MANIFEST_PARSER_H_
#include <string>
#include <wfassoc++.h>
#include "manifest.h"
namespace qwfassoc {
// Parse a manifest TOML file from disk into a Manifest value.
// Throws std::runtime_error on any IO or TOML syntax error.
Manifest parseManifestFile(const std::string& path);
// Build a wfassocpp::Schema from a manifest value.
// Throws std::runtime_error (originating from wfassocpp::_Check) when the
// wfassoc library rejects an operation, e.g. on duplicate keys or dangling
// references.
wfassocpp::Schema buildSchema(const Manifest& manifest);
} // namespace qwfassoc
#endif // QWFASSOC_STANDALONE_MANIFEST_PARSER_H_

View File

@@ -0,0 +1,58 @@
# qwfassoc: shared library exporting reusable Qt widgets that wrap wfassoc.
# Qt 6 requires C++17 at minimum.
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(QWFASSOC_SOURCES
"${CMAKE_CURRENT_SOURCE_DIR}/src/application_widget.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/association_widget.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/icon_utils.cpp"
)
set(QWFASSOC_HEADERS
"${CMAKE_CURRENT_SOURCE_DIR}/src/qwfassoc_global.h"
"${CMAKE_CURRENT_SOURCE_DIR}/src/scope.h"
"${CMAKE_CURRENT_SOURCE_DIR}/src/manifest.h"
"${CMAKE_CURRENT_SOURCE_DIR}/src/application_widget.h"
"${CMAKE_CURRENT_SOURCE_DIR}/src/association_widget.h"
"${CMAKE_CURRENT_SOURCE_DIR}/src/icon_utils.h"
)
set(QWFASSOC_UI
"${CMAKE_CURRENT_SOURCE_DIR}/src/application_widget.ui"
"${CMAKE_CURRENT_SOURCE_DIR}/src/association_widget.ui"
)
add_library(qwfassoc SHARED
${QWFASSOC_SOURCES}
${QWFASSOC_HEADERS}
${QWFASSOC_UI}
)
# QWFASSOC_LIBRARY switches QWFASSOC_EXPORT from import to export mode.
target_compile_definitions(qwfassoc PRIVATE QWFASSOC_LIBRARY)
# Consumers (and the library itself) need to find the public headers under
# src/. PUBLIC propagates the include path to anyone linking against qwfassoc.
target_include_directories(qwfassoc PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/src"
)
target_link_libraries(qwfassoc PUBLIC
Qt6::Widgets
wfassoc::wfassoc
)
# Translation pipeline for the library. qt6_add_translations() runs lupdate
# against the target's sources and embeds the lrelease output under the
# ":/i18n" resource prefix, where installTranslators() in the executable
# looks it up at runtime.
set(QWFASSOC_TS_FILES
"${CMAKE_CURRENT_SOURCE_DIR}/i18n/qwfassoc_zh_CN.ts"
)
qt6_add_translations(qwfassoc
TS_FILES ${QWFASSOC_TS_FILES}
)

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="zh_CN">
</TS>

View File

@@ -0,0 +1,119 @@
#include "application_widget.h"
#include "ui_application_widget.h"
#include "icon_utils.h"
#include <QMessageBox>
#include <QPushButton>
#include <stdexcept>
namespace qwfassoc {
namespace {
// Convert the library's TargetScope enum to the wfassocpp::Scope value used by
// the program APIs (register / unregister / link / unlink / is_registered).
wfassocpp::Scope toWfassocScope(TargetScope scope) {
return scope == TargetScope::User ? wfassocpp::Scope::User
: wfassocpp::Scope::System;
}
} // namespace
ApplicationWidget::ApplicationWidget(QWidget* parent)
: QWidget(parent), ui_(new Ui::ApplicationWidget) {
ui_->setupUi(this);
connect(ui_->installButton, &QPushButton::clicked, this,
&ApplicationWidget::onInstallClicked);
connect(ui_->uninstallButton, &QPushButton::clicked, this,
&ApplicationWidget::onUninstallClicked);
// Until setConfig() is called we have no program to operate on; keep the
// whole widget disabled.
setEnabled(false);
}
ApplicationWidget::~ApplicationWidget() = default;
void ApplicationWidget::setConfig(const Config& config) {
program_ = config.program;
scope_ = config.scope;
if (program_ != nullptr) {
// Resolve program metadata that several labels depend on. These calls
// may throw std::runtime_error on failure; the caller is expected to
// wrap setConfig() in a try/catch and present an error dialog.
programName_ = QString::fromUtf8(program_->ResolveName());
auto iconRc = program_->ResolveIcon();
auto handle = iconRc.GetIcon();
programIcon_ = icon_utils::fromHicon(handle);
if (!programIcon_.isNull()) {
ui_->appIconLabel->setPixmap(
programIcon_.scaled(32, 32, Qt::KeepAspectRatio,
Qt::SmoothTransformation));
}
ui_->appDescLabel->setText(
tr("Install or uninstall %1 here.").arg(programName_));
setEnabled(true);
refresh();
} else {
programName_.clear();
programIcon_ = QPixmap();
ui_->appIconLabel->setPixmap(QPixmap());
ui_->appIconLabel->setText(QString());
ui_->appDescLabel->setText(QString());
setEnabled(false);
}
}
void ApplicationWidget::refresh() {
if (program_ == nullptr) {
ui_->installButton->setEnabled(false);
ui_->uninstallButton->setEnabled(false);
return;
}
const bool registered = program_->IsRegistered(toWfassocScope(scope_));
ui_->installButton->setEnabled(!registered);
ui_->uninstallButton->setEnabled(registered);
}
void ApplicationWidget::onInstallClicked() {
if (program_ == nullptr) {
return;
}
try {
program_->Register(toWfassocScope(scope_));
} catch (const std::exception& e) {
QMessageBox::critical(this, tr("Error"), QString::fromUtf8(e.what()));
return;
}
QMessageBox::information(this, tr("Information"),
tr("Application installed successfully."));
refresh();
emit changed();
}
void ApplicationWidget::onUninstallClicked() {
if (program_ == nullptr) {
return;
}
try {
program_->Unregister(toWfassocScope(scope_));
} catch (const std::exception& e) {
QMessageBox::critical(this, tr("Error"), QString::fromUtf8(e.what()));
return;
}
QMessageBox::information(this, tr("Information"),
tr("Application uninstalled successfully."));
refresh();
emit changed();
}
} // namespace qwfassoc

View File

@@ -0,0 +1,74 @@
#pragma once
#ifndef QWFASSOC_APPLICATION_WIDGET_H_
#define QWFASSOC_APPLICATION_WIDGET_H_
#include <QPixmap>
#include <QString>
#include <QWidget>
#include <wfassoc++.h>
#include "qwfassoc_global.h"
#include "scope.h"
namespace Ui {
class ApplicationWidget;
}
namespace qwfassoc {
// Widget exposing install / uninstall actions for a single wfassoc program.
//
// The widget follows the two-phase initialization pattern required by Qt
// Designer promoted widgets: the constructor only takes a parent, and the
// caller must invoke setConfig() with the target program and scope before the
// widget becomes usable. Until setConfig() is called the widget is disabled.
class QWFASSOC_EXPORT ApplicationWidget : public QWidget {
Q_OBJECT
public:
// Configuration bundle passed to setConfig().
struct Config {
// Non-owning pointer to the wfassoc program. Must outlive the widget.
wfassocpp::Program* program = nullptr;
// Scope that install/unregister operations apply to.
TargetScope scope = TargetScope::User;
};
explicit ApplicationWidget(QWidget* parent = nullptr);
~ApplicationWidget() override;
// Two-phase initialization. Calling this with a non-null program enables
// the widget and triggers an initial refresh. Calling it with a null
// program (or not calling it at all) leaves the widget disabled.
void setConfig(const Config& config);
// Re-query the live wfassoc state and update the enabled state of the
// install / uninstall buttons. Called automatically by setConfig() and
// also intended to be called by the host whenever another component has
// mutated wfassoc state.
void refresh();
signals:
// Emitted whenever the user performs an action that mutates wfassoc
// state (i.e. install or uninstall). The host should refresh every
// widget that depends on wfassoc state in response.
void changed();
private slots:
void onInstallClicked();
void onUninstallClicked();
private:
Ui::ApplicationWidget* ui_;
wfassocpp::Program* program_ = nullptr;
TargetScope scope_ = TargetScope::User;
// Cached metadata used to fill the widget labels.
QString programName_;
QPixmap programIcon_;
};
} // namespace qwfassoc
#endif // QWFASSOC_APPLICATION_WIDGET_H_

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ApplicationWidget</class>
<widget class="QWidget" name="ApplicationWidget">
<layout class="QVBoxLayout" name="mainLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Install and Uninstall</string>
</property>
<layout class="QVBoxLayout" name="groupLayout">
<item>
<layout class="QHBoxLayout" name="headerLayout">
<item>
<widget class="QLabel" name="appIconLabel">
<property name="text">
<string notr="true">[icon]</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="appDescLabel">
<property name="text">
<string>Install or uninstall the application here.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="actionLayout">
<item>
<widget class="QPushButton" name="installButton">
<property name="text">
<string>Install</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="uninstallButton">
<property name="text">
<string>Uninstall</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -1,5 +1,6 @@
#include "main_window.h"
#include "ui_main_window.h"
#include "association_widget.h"
#include "ui_association_widget.h"
#include "icon_utils.h"
#include <QHeaderView>
#include <QMessageBox>
@@ -7,19 +8,13 @@
#include <QPushButton>
#include <QTableWidgetItem>
// icon_utils.h pulls in <qt_windows.h>, so it is included after all Qt
// headers to keep the Windows include ordering that Qt expects.
#include "icon_utils.h"
#include <stdexcept>
namespace qwfassoc {
// region: Helpers
namespace {
// Convert our internal TargetScope enum to the wfassocpp::Scope value used by
// Convert the library's TargetScope enum to the wfassocpp::Scope value used by
// the program APIs (register / unregister / link / unlink / is_registered).
wfassocpp::Scope toWfassocScope(TargetScope scope) {
return scope == TargetScope::User ? wfassocpp::Scope::User
@@ -29,12 +24,16 @@ wfassocpp::Scope toWfassocScope(TargetScope scope) {
// Decide which CellState a queried ExtStatus corresponds to, given the
// resolved "self" name for this extension. We treat the cell as Self when the
// resolved display name matches our own; otherwise it is treated as Other.
CellState classifyCell(const QString& selfName, const QString& observedName) {
return observedName == selfName ? CellState::Self : CellState::Other;
detail::CellState classifyCell(const QString& selfName,
const QString& observedName) {
return observedName == selfName ? detail::CellState::Self
: detail::CellState::Other;
}
// Effective icon to draw for a cell, based on its state.
QPixmap effectiveCellIcon(const ExtRow& row, const CellData& cell) {
QPixmap effectiveCellIcon(const detail::ExtRow& row,
const detail::CellData& cell) {
using detail::CellState;
switch (cell.state) {
case CellState::Blank:
return QPixmap();
@@ -48,30 +47,10 @@ QPixmap effectiveCellIcon(const ExtRow& row, const CellData& cell) {
} // namespace
// endregion
// region: Construction
MainWindow::MainWindow(wfassocpp::Program program,
TargetScope scope,
QWidget* parent)
: QDialog(parent),
ui_(new Ui::MainWindow),
program_(std::move(program)),
scope_(scope) {
AssociationWidget::AssociationWidget(QWidget* parent)
: QWidget(parent), ui_(new Ui::AssociationWidget) {
ui_->setupUi(this);
// Resolve program metadata that several labels depend on. These calls may
// throw std::runtime_error on failure; the caller (main.cpp) is expected
// to handle that and present an error dialog before exiting.
programName_ = QString::fromUtf8(program_.ResolveName());
{
auto iconRc = program_.ResolveIcon();
auto handle = iconRc.GetIcon();
programIcon_ = icon_utils::fromHicon(handle);
}
// Fetch the current user name for the second column header. The USERNAME
// environment variable is good enough on Windows; fall back to a static
// translatable placeholder if it is unset for any reason.
@@ -79,105 +58,114 @@ MainWindow::MainWindow(wfassocpp::Program program,
QStringLiteral("USERNAME"), tr("User"));
// Give the table as much vertical room as possible inside its layout.
ui_->associationsLayout->setStretch(2, 1);
ui_->mainLayout->setStretch(2, 1);
// Reasonable default column widths so dotted extensions and ProgId names
// stay readable in the 480px dialog.
// stay readable in the 480px dialog the widget typically lives in.
ui_->assocTable->setColumnWidth(0, 90);
ui_->assocTable->setColumnWidth(1, 175);
ui_->assocTable->setColumnWidth(2, 175);
ui_->assocTable->verticalHeader()->setVisible(false);
ui_->assocTable->setShowGrid(true);
// Wire signals.
connect(ui_->installButton, &QPushButton::clicked, this,
&MainWindow::onInstallClicked);
connect(ui_->uninstallButton, &QPushButton::clicked, this,
&MainWindow::onUninstallClicked);
connect(ui_->selectUserButton, &QPushButton::clicked, this,
&MainWindow::onSelectUserClicked);
&AssociationWidget::onSelectUserClicked);
connect(ui_->selectSystemButton, &QPushButton::clicked, this,
&MainWindow::onSelectSystemClicked);
&AssociationWidget::onSelectSystemClicked);
connect(ui_->assocTable, &QTableWidget::cellClicked, this,
&MainWindow::onCellClicked);
&AssociationWidget::onCellClicked);
connect(ui_->okButton, &QPushButton::clicked, this,
&MainWindow::onOkClicked);
&AssociationWidget::onOkClicked);
connect(ui_->cancelButton, &QPushButton::clicked, this,
&MainWindow::onCancelClicked);
&AssociationWidget::onCancelClicked);
connect(ui_->applyButton, &QPushButton::clicked, this,
&MainWindow::onApplyClicked);
&AssociationWidget::onApplyClicked);
retranslateUi();
refreshProgramState();
// Until setConfig() is called we have no program to operate on; keep the
// whole widget disabled.
setEnabled(false);
}
MainWindow::~MainWindow() = default;
AssociationWidget::~AssociationWidget() = default;
// endregion
void AssociationWidget::setConfig(const Config& config) {
program_ = config.program;
scope_ = config.scope;
showOkCancelButtons_ = config.showOkCancelButtons;
// region: UI Text
ui_->okButton->setVisible(showOkCancelButtons_);
ui_->cancelButton->setVisible(showOkCancelButtons_);
void MainWindow::retranslateUi() {
// The window title embeds the program name, so it is composed at runtime
// through tr() + QString::arg to stay translatable.
setWindowTitle(tr("%1 Options").arg(programName_));
// Application tab.
if (!programIcon_.isNull()) {
ui_->appIconLabel->setPixmap(
programIcon_.scaled(32, 32, Qt::KeepAspectRatio,
Qt::SmoothTransformation));
}
ui_->appDescLabel->setText(
tr("Install or uninstall %1 here.").arg(programName_));
// File association tab.
if (program_ != nullptr) {
programName_ = QString::fromUtf8(program_->ResolveName());
ui_->assocHeaderLabel->setText(
tr("File types associated with %1:").arg(programName_));
QStringList headers;
headers << tr("Type") << userName_ << tr("All Users");
ui_->assocTable->setHorizontalHeaderLabels(headers);
setEnabled(true);
refresh();
} else {
programName_.clear();
ui_->assocHeaderLabel->setText(QString());
ui_->assocTable->setRowCount(0);
rows_.clear();
setEnabled(false);
}
}
// endregion
void AssociationWidget::refresh() {
if (program_ == nullptr) {
return;
}
updateEnabledState();
rebuildTable();
}
// region: State Refresh
void AssociationWidget::updateEnabledState() {
if (program_ == nullptr) {
ui_->selectUserButton->setEnabled(false);
ui_->selectSystemButton->setEnabled(false);
ui_->assocTable->setEnabled(false);
ui_->applyButton->setEnabled(false);
return;
}
void MainWindow::refreshProgramState() {
const bool registered = program_.IsRegistered(toWfassocScope(scope_));
ui_->installButton->setEnabled(!registered);
ui_->uninstallButton->setEnabled(registered);
// The whole file-association tab is disabled until the application has
// been registered in the active scope. Additionally, the system column is
// permanently disabled when running in user mode.
const bool registered = program_->IsRegistered(toWfassocScope(scope_));
const bool userColumnActive = registered;
const bool systemColumnActive = registered && isSystemColumnEnabled();
ui_->selectUserButton->setEnabled(userColumnActive);
ui_->selectSystemButton->setEnabled(systemColumnActive);
ui_->assocTable->setEnabled(registered);
rebuildTable();
// The Apply button enable state is driven by pending changes too; only
// touch it here to make sure it's disabled when nothing is registered.
if (!registered) {
ui_->applyButton->setEnabled(false);
}
}
void MainWindow::rebuildTable() {
void AssociationWidget::rebuildTable() {
if (program_ == nullptr) {
return;
}
refreshing_ = true;
rows_.clear();
const size_t count = program_.ExtsLen();
const size_t count = program_->ExtsLen();
rows_.reserve(count);
ui_->assocTable->setRowCount(static_cast<int>(count));
for (size_t i = 0; i < count; ++i) {
ExtRow row;
detail::ExtRow row;
row.index = i;
// Self extension info: dotted body, display name and cached icon.
auto selfExt = program_.ResolveExt(i);
auto selfExt = program_->ResolveExt(i);
row.extBody = QString::fromUtf8(selfExt.GetExt());
row.dottedExt = QString::fromUtf8(selfExt.GetDottedExt());
row.selfName = QString::fromUtf8(selfExt.GetName());
@@ -186,7 +174,7 @@ void MainWindow::rebuildTable() {
// Query the user-view and system-view states. None means blank;
// a match against our self name means Self; anything else is Other
// and we keep the original name/icon around for display.
auto userStatus = program_.QueryExt(wfassocpp::View::User, i);
auto userStatus = program_->QueryExt(wfassocpp::View::User, i);
if (userStatus) {
const QString observedName =
QString::fromUtf8(userStatus->GetName());
@@ -196,7 +184,7 @@ void MainWindow::rebuildTable() {
icon_utils::fromHicon(userStatus->GetIcon());
}
auto systemStatus = program_.QueryExt(wfassocpp::View::System, i);
auto systemStatus = program_->QueryExt(wfassocpp::View::System, i);
if (systemStatus) {
const QString observedName =
QString::fromUtf8(systemStatus->GetName());
@@ -216,7 +204,7 @@ void MainWindow::rebuildTable() {
// update their text/icon and flags.
const int rowIdx = static_cast<int>(i);
auto* typeItem = new QTableWidgetItem(row.dottedExt);
auto* typeItem = new QTableWidgetItem(rows_.back().dottedExt);
ui_->assocTable->setItem(rowIdx, 0, typeItem);
auto* userItem = new QTableWidgetItem;
@@ -235,11 +223,13 @@ void MainWindow::rebuildTable() {
updateApplyButtonEnabled();
}
void MainWindow::refreshRowDisplay(int row) {
void AssociationWidget::refreshRowDisplay(int row) {
using detail::CellState;
if (row < 0 || row >= static_cast<int>(rows_.size())) {
return;
}
const ExtRow& r = rows_[row];
const detail::ExtRow& r = rows_[row];
// Column 0: hybrid icon (user-preferred) + dotted extension.
QPixmap hybridIcon;
@@ -285,9 +275,9 @@ void MainWindow::refreshRowDisplay(int row) {
}
}
void MainWindow::updateApplyButtonEnabled() {
void AssociationWidget::updateApplyButtonEnabled() {
bool dirty = false;
for (const ExtRow& r : rows_) {
for (const detail::ExtRow& r : rows_) {
if (r.pendingUser.state != r.initialUser.state ||
r.pendingSystem.state != r.initialSystem.state) {
dirty = true;
@@ -297,11 +287,9 @@ void MainWindow::updateApplyButtonEnabled() {
ui_->applyButton->setEnabled(dirty);
}
// endregion
void AssociationWidget::toggleCell(int row, int column) {
using detail::CellState;
// region: Cell Interaction
void MainWindow::toggleCell(int row, int column) {
if (refreshing_) {
return;
}
@@ -309,8 +297,8 @@ void MainWindow::toggleCell(int row, int column) {
return;
}
CellData* cell = nullptr;
const ExtRow* rowPtr = &rows_[row];
detail::CellData* cell = nullptr;
const detail::ExtRow* rowPtr = &rows_[row];
if (column == 1) {
cell = &rows_[row].pendingUser;
} else if (column == 2) {
@@ -337,7 +325,9 @@ void MainWindow::toggleCell(int row, int column) {
updateApplyButtonEnabled();
}
void MainWindow::selectAllInScope(bool isUser) {
void AssociationWidget::selectAllInScope(bool isUser) {
using detail::CellState;
if (!isUser && !isSystemColumnEnabled()) {
return;
}
@@ -346,16 +336,16 @@ void MainWindow::selectAllInScope(bool isUser) {
// first click only fills blanks; otherwise the click overrides cells
// pointing at other handlers as well.
bool hasBlank = false;
for (ExtRow& r : rows_) {
const CellData& cell = isUser ? r.pendingUser : r.pendingSystem;
for (detail::ExtRow& r : rows_) {
const detail::CellData& cell = isUser ? r.pendingUser : r.pendingSystem;
if (cell.state == CellState::Blank) {
hasBlank = true;
break;
}
}
for (ExtRow& r : rows_) {
CellData& cell = isUser ? r.pendingUser : r.pendingSystem;
for (detail::ExtRow& r : rows_) {
detail::CellData& cell = isUser ? r.pendingUser : r.pendingSystem;
if (hasBlank) {
if (cell.state == CellState::Blank) {
cell.state = CellState::Self;
@@ -375,27 +365,23 @@ void MainWindow::selectAllInScope(bool isUser) {
updateApplyButtonEnabled();
}
// endregion
// region: Apply
void MainWindow::applyAllChanges() {
void AssociationWidget::applyAllChanges() {
// Walk through every row and commit any cell whose pending state differs
// from the initial snapshot. wfassoc's link/unlink take an index rather
// than a scope/view, so we map columns back to (scope, index) pairs.
for (const ExtRow& r : rows_) {
for (const detail::ExtRow& r : rows_) {
if (r.pendingUser.state != r.initialUser.state) {
if (r.pendingUser.state == CellState::Self) {
program_.LinkExt(wfassocpp::Scope::User, r.index);
if (r.pendingUser.state == detail::CellState::Self) {
program_->LinkExt(wfassocpp::Scope::User, r.index);
} else {
program_.UnlinkExt(wfassocpp::Scope::User, r.index);
program_->UnlinkExt(wfassocpp::Scope::User, r.index);
}
}
if (r.pendingSystem.state != r.initialSystem.state) {
if (r.pendingSystem.state == CellState::Self) {
program_.LinkExt(wfassocpp::Scope::System, r.index);
if (r.pendingSystem.state == detail::CellState::Self) {
program_->LinkExt(wfassocpp::Scope::System, r.index);
} else {
program_.UnlinkExt(wfassocpp::Scope::System, r.index);
program_->UnlinkExt(wfassocpp::Scope::System, r.index);
}
}
}
@@ -404,83 +390,49 @@ void MainWindow::applyAllChanges() {
rebuildTable();
}
bool MainWindow::isSystemColumnEnabled() const {
bool AssociationWidget::isSystemColumnEnabled() const {
return scope_ == TargetScope::System;
}
// endregion
// region: Slots
void MainWindow::onInstallClicked() {
try {
program_.Register(toWfassocScope(scope_));
} catch (const std::exception& e) {
QMessageBox::critical(this, windowTitle(),
QString::fromUtf8(e.what()));
return;
}
QMessageBox::information(this, windowTitle(),
tr("Application installed successfully."));
refreshProgramState();
}
void MainWindow::onUninstallClicked() {
try {
program_.Unregister(toWfassocScope(scope_));
} catch (const std::exception& e) {
QMessageBox::critical(this, windowTitle(),
QString::fromUtf8(e.what()));
return;
}
QMessageBox::information(this, windowTitle(),
tr("Application uninstalled successfully."));
refreshProgramState();
}
void MainWindow::onSelectUserClicked() {
void AssociationWidget::onSelectUserClicked() {
selectAllInScope(/*isUser=*/true);
}
void MainWindow::onSelectSystemClicked() {
void AssociationWidget::onSelectSystemClicked() {
selectAllInScope(/*isUser=*/false);
}
void MainWindow::onCellClicked(int row, int column) {
void AssociationWidget::onCellClicked(int row, int column) {
toggleCell(row, column);
}
void MainWindow::onOkClicked() {
void AssociationWidget::onOkClicked() {
try {
applyAllChanges();
} catch (const std::exception& e) {
QMessageBox::critical(this, windowTitle(),
QString::fromUtf8(e.what()));
QMessageBox::critical(this, tr("Error"), QString::fromUtf8(e.what()));
// Sync the table with the live registry, since some changes may have
// been committed before the failure.
refreshProgramState();
refresh();
return;
}
accept();
emit changed();
emit finished(/*accepted=*/true);
}
void MainWindow::onCancelClicked() {
reject();
void AssociationWidget::onCancelClicked() {
emit finished(/*accepted=*/false);
}
void MainWindow::onApplyClicked() {
void AssociationWidget::onApplyClicked() {
try {
applyAllChanges();
} catch (const std::exception& e) {
QMessageBox::critical(this, windowTitle(),
QString::fromUtf8(e.what()));
refreshProgramState();
QMessageBox::critical(this, tr("Error"), QString::fromUtf8(e.what()));
refresh();
return;
}
emit changed();
}
// endregion
} // namespace qwfassoc

View File

@@ -1,32 +1,29 @@
#pragma once
#ifndef QWFASSOC_MAIN_WINDOW_H_
#define QWFASSOC_MAIN_WINDOW_H_
#ifndef QWFASSOC_ASSOCIATION_WIDGET_H_
#define QWFASSOC_ASSOCIATION_WIDGET_H_
#include <QDialog>
#include <QPixmap>
#include <QString>
#include <QWidget>
#include <memory>
#include <vector>
#include <wfassoc++.h>
#include "qwfassoc_global.h"
#include "scope.h"
namespace Ui {
class MainWindow;
class AssociationWidget;
}
namespace qwfassoc {
// The target scope the application is being managed for.
// This value comes from the --for command line argument and decides which
// columns in the file association table are interactive, as well as which
// scope install/uninstall operate on.
enum class TargetScope {
User,
System,
};
// Internal helper types used by AssociationWidget. They live in a `detail`
// namespace to signal that they are not part of the public API even though
// they need to be visible in the header.
namespace detail {
// The pending state of a single (extension, scope) cell.
enum class CellState {
// The extension has no associated handler in this scope.
Blank,
@@ -68,42 +65,64 @@ struct ExtRow {
CellData pendingSystem;
};
// Main configuration dialog. Owns the wfassoc Program for its lifetime.
class MainWindow : public QDialog {
} // namespace detail
// Widget showing the per-extension file-association status of a wfassoc
// program, and letting the user stage link / unlink operations.
//
// The widget follows the two-phase initialization pattern: the constructor
// only takes a parent, and the caller invokes setConfig() with the target
// program and scope before the widget becomes usable.
//
// By default the OK and Cancel buttons are hidden because they imply a
// dialog-level operation (close). Hosts that embed this widget in a dialog
// can enable them through Config::showOkCancelButtons and react to the
// finished() signal.
class QWFASSOC_EXPORT AssociationWidget : public QWidget {
Q_OBJECT
public:
explicit MainWindow(wfassocpp::Program program,
TargetScope scope,
QWidget* parent = nullptr);
~MainWindow() override;
struct Config {
// Non-owning pointer to the wfassoc program. Must outlive the widget.
wfassocpp::Program* program = nullptr;
// Scope that link / unlink operations apply to. Also controls whether
// the system column is interactive (only System scope unlocks it).
TargetScope scope = TargetScope::User;
// Whether the OK and Cancel buttons are visible. They are hidden by
// default since closing the host window is a host-level decision.
bool showOkCancelButtons = false;
};
explicit AssociationWidget(QWidget* parent = nullptr);
~AssociationWidget() override;
// Two-phase initialization.
void setConfig(const Config& config);
// Re-query the live wfassoc state and rebuild the table.
void refresh();
signals:
// Emitted after the user applies pending changes (OK or Apply). The host
// should refresh every widget that depends on wfassoc state in response.
void changed();
// Emitted when the widget wants its host window to close. `accepted` is
// true when the OK button was used (after the changes were applied and
// changed() was emitted) and false when the Cancel button was used.
void finished(bool accepted);
private slots:
// Slot connected to the "Install" button.
void onInstallClicked();
// Slot connected to the "Uninstall" button.
void onUninstallClicked();
// Slot connected to the "+" button above the user column.
void onSelectUserClicked();
// Slot connected to the "+" button above the system column.
void onSelectSystemClicked();
// Slot connected to QTableWidget::cellClicked.
void onCellClicked(int row, int column);
// Slot connected to the "OK" button: apply pending changes then close.
void onOkClicked();
// Slot connected to the "Cancel" button: close without applying.
void onCancelClicked();
// Slot connected to the "Apply" button: apply pending changes, stay open.
void onApplyClicked();
private:
// Re-apply labels that depend on the resolved program name and the
// current user name. Translatable strings use tr() so they get picked up
// by Qt's translation tooling.
void retranslateUi();
// Refresh install/uninstall button enable state and the file association
// tab enable state from the live registry.
void refreshProgramState();
// Refresh install / apply button enable state based on the live registry
// and the pending edits.
void updateEnabledState();
// Drop and rebuild the table contents from the live registry.
void rebuildTable();
// Refresh a single row's displayed cells from its pending state.
@@ -119,19 +138,18 @@ private:
void selectAllInScope(bool isUser);
// Commit every pending change to the registry via wfassoc.
void applyAllChanges();
// True when the system column should be interactive.
bool isSystemColumnEnabled() const;
std::unique_ptr<Ui::MainWindow> ui_;
wfassocpp::Program program_;
TargetScope scope_;
Ui::AssociationWidget* ui_;
wfassocpp::Program* program_ = nullptr;
TargetScope scope_ = TargetScope::User;
bool showOkCancelButtons_ = false;
QString programName_;
QPixmap programIcon_;
QString userName_;
std::vector<ExtRow> rows_;
std::vector<detail::ExtRow> rows_;
// Re-entrancy guard used while rebuilding the table to avoid feeding
// model-change signals back into toggleCell().
bool refreshing_ = false;
@@ -139,4 +157,4 @@ private:
} // namespace qwfassoc
#endif // QWFASSOC_MAIN_WINDOW_H_
#endif // QWFASSOC_ASSOCIATION_WIDGET_H_

View File

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>AssociationWidget</class>
<widget class="QWidget" name="AssociationWidget">
<layout class="QVBoxLayout" name="mainLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="assocHeaderLabel">
<property name="text">
<string>File types associated with this application:</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="selectButtonsLayout">
<item>
<widget class="QPushButton" name="selectUserButton">
<property name="toolTip">
<string>Select all for current user</string>
</property>
<property name="text">
<string notr="true">+</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="selectSystemButton">
<property name="toolTip">
<string>Select all for all users</string>
</property>
<property name="text">
<string notr="true">+</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="selectButtonsSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QTableWidget" name="assocTable">
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectItems</enum>
</property>
<column>
<property name="text">
<string>Type</string>
</property>
</column>
<column>
<property name="text">
<string>User</string>
</property>
</column>
<column>
<property name="text">
<string>All Users</string>
</property>
</column>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="bottomButtonsLayout">
<item>
<spacer name="bottomButtonsSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="okButton">
<property name="text">
<string>OK</string>
</property>
<property name="autoDefault">
<bool>true</bool>
</property>
<property name="default">
<bool>true</bool>
</property>
<property name="visible">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="cancelButton">
<property name="text">
<string>Cancel</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
<property name="visible">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="applyButton">
<property name="text">
<string>Apply</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
<property name="enabled">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -6,6 +6,8 @@
#include <wfassoc++.h>
#include "qwfassoc_global.h"
namespace qwfassoc {
namespace icon_utils {
@@ -16,7 +18,7 @@ namespace icon_utils {
// Windows) and then QPixmap::fromImage() using its rvalue-reference overload
// so that no extra pixel buffer copy is performed. The returned pixmap is null
// if the input handle is null.
QPixmap fromHicon(wfassocpp::HICON handle);
QWFASSOC_EXPORT QPixmap fromHicon(wfassocpp::HICON handle);
} // namespace icon_utils
} // namespace qwfassoc

View File

@@ -6,8 +6,6 @@
#include <optional>
#include <string>
#include <wfassoc++.h>
namespace qwfassoc {
// Description of a single extension declared in the manifest.
@@ -23,9 +21,11 @@ struct ManifestExt {
// In-memory representation of a wfassoc manifest TOML file.
//
// This struct mirrors the Rust `Manifest` type defined in
// `wfassoc-exec/src/manifest.rs`. All validation that requires the wfassoc
// library itself (identifier regex, dangling references, etc.) is deferred to
// `wfassocpp::Program` construction; this struct only performs TOML parsing.
// `wfassoc-exec/src/manifest.rs`. It is a plain data struct: parsing from
// TOML and conversion into a wfassocpp::Schema are intentionally kept out of
// the library so that the library itself does not depend on a TOML parser.
// Consumers (such as qwfassoc-standalone) are responsible for filling the
// fields in.
struct Manifest {
std::string identifier;
std::string path;
@@ -39,15 +39,6 @@ struct Manifest {
std::map<std::string, std::string> icons;
std::map<std::string, std::string> behaviors;
std::map<std::string, ManifestExt> exts;
// Parse a manifest TOML file from disk.
// Throws std::runtime_error on any IO or TOML syntax error.
static Manifest fromFile(const std::string& path);
// Build a wfassocpp::Schema from this manifest.
// Throws std::runtime_error (originating from wfassocpp::_Check) when the
// wfassoc library rejects an operation, e.g. on duplicate keys.
wfassocpp::Schema toSchema() const;
};
} // namespace qwfassoc

View File

@@ -0,0 +1,20 @@
#pragma once
#ifndef QWFASSOC_GLOBAL_H_
#define QWFASSOC_GLOBAL_H_
#include <qglobal.h>
// Standard Qt shared-library export/import macros.
//
// When the qwfassoc library itself is being built, QWFASSOC_LIBRARY is
// defined (see the library's CMakeLists.txt) and QWFASSOC_EXPORT expands to
// Q_DECL_EXPORT so that symbols are exported from the .dll. Consumers of the
// library leave QWFASSOC_LIBRARY undefined, so QWFASSOC_EXPORT expands to
// Q_DECL_IMPORT and the same symbols are imported.
#if defined(QWFASSOC_LIBRARY)
# define QWFASSOC_EXPORT Q_DECL_EXPORT
#else
# define QWFASSOC_EXPORT Q_DECL_IMPORT
#endif
#endif // QWFASSOC_GLOBAL_H_

View File

@@ -0,0 +1,20 @@
#pragma once
#ifndef QWFASSOC_SCOPE_H_
#define QWFASSOC_SCOPE_H_
namespace qwfassoc {
// The target scope an application is being managed for.
//
// This value typically comes from a `--for user` / `--for system` command
// line argument and decides which columns of the file-association table are
// interactive, as well as which scope install / unregister operations apply
// to.
enum class TargetScope {
User,
System,
};
} // namespace qwfassoc
#endif // QWFASSOC_SCOPE_H_

View File

@@ -1,267 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QDialog" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>480</width>
<height>600</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>480</width>
<height>600</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>480</width>
<height>600</height>
</size>
</property>
<property name="windowTitle">
<string>Options</string>
</property>
<layout class="QVBoxLayout" name="mainLayout">
<property name="leftMargin">
<number>9</number>
</property>
<property name="topMargin">
<number>9</number>
</property>
<property name="rightMargin">
<number>9</number>
</property>
<property name="bottomMargin">
<number>9</number>
</property>
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tabApplications">
<attribute name="title">
<string>Applications</string>
</attribute>
<layout class="QVBoxLayout" name="applicationsLayout">
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Install and Uninstall</string>
</property>
<layout class="QVBoxLayout" name="groupLayout">
<item>
<layout class="QHBoxLayout" name="headerLayout">
<item>
<widget class="QLabel" name="appIconLabel">
<property name="text">
<string notr="true">[icon]</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="appDescLabel">
<property name="text">
<string>Install or uninstall the application here.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="actionLayout">
<item>
<widget class="QPushButton" name="installButton">
<property name="text">
<string>Install</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="uninstallButton">
<property name="text">
<string>Uninstall</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="applicationsVerticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<widget class="QWidget" name="tabAssociations">
<attribute name="title">
<string>File Associations</string>
</attribute>
<layout class="QVBoxLayout" name="associationsLayout">
<item>
<widget class="QLabel" name="assocHeaderLabel">
<property name="text">
<string>File types associated with this application:</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="selectButtonsLayout">
<item>
<widget class="QPushButton" name="selectUserButton">
<property name="toolTip">
<string>Select all for current user</string>
</property>
<property name="text">
<string notr="true">+</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="selectSystemButton">
<property name="toolTip">
<string>Select all for all users</string>
</property>
<property name="text">
<string notr="true">+</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="selectButtonsSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QTableWidget" name="assocTable">
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectItems</enum>
</property>
<column>
<property name="text">
<string>Type</string>
</property>
</column>
<column>
<property name="text">
<string>User</string>
</property>
</column>
<column>
<property name="text">
<string>All Users</string>
</property>
</column>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="bottomButtonsLayout">
<item>
<spacer name="bottomButtonsSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="okButton">
<property name="text">
<string>OK</string>
</property>
<property name="autoDefault">
<bool>true</bool>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="cancelButton">
<property name="text">
<string>Cancel</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="applyButton">
<property name="text">
<string>Apply</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
<property name="enabled">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>