Use native candidate actions and harden frontend resource loading

This commit is contained in:
justme committed 2026-10-20 11:52:39 +08:00
1 parent 99fe8f589f
commit d8e780dfa1
18 files changed
+1083 -227

No files matched your search

+15 -15
View File
@@ -10,7 +10,7 @@
刚度、阻尼、方向、距离都可以调。
- 在候选词上**右键**打开动作菜单,内容取决于输入法提供的动作。拼音有
"忘记候选词 / 置顶为自定义短语 / 从自定义短语中删除"。
- **忘记候选词后不会再出现**,包括系统词库和扩展词库里的词(见下文"屏蔽列表")。
- 候选菜单只调用引擎提供的原生动作,不在前端屏蔽词语。
- Wayland 和 X11(Xwayland)输入上下文都支持,定位和鼠标处理沿用 `classicui`。
- 可选的 KWin 辅助脚本:只缩短候选框自己的 KWin 淡出时间。
@@ -104,23 +104,23 @@ systemctl --user daemon-reload && systemctl --user restart app-org.fcitx.Fcitx5@
`SurfaceColor` 完全透明时回退到 classicui 的图片绘制,所以原有的 classicui
主题仍然可用(但没有动画)。
## 屏蔽列表
## 候选动作与资源防护
拼音引擎的"忘记候选词"只会删除你自己打字学到的词,系统词库(`sc.dict`)和
扩展词库里的词是只读的,忘记之后重新输入仍会出现,当前输入状态里也一样。
右键菜单只显示引擎的 `candidateActions()` 并通过 `triggerAction()` 调用。
“忘记”由引擎处理,不保证永久隐藏或降低到某个排名。前端不再读取或写入
旧的 `blocked-words.txt`,已有文件不会被删除。
所以 m3ui 在你点"忘记候选词"时,会把这个词记到
`~/.local/share/fcitx5/m3ui/blocked-words.txt`(一行一个词),并在每次刷新候选
之前把它们从候选列表里摘掉。
图片限制为 16 MiB 文件、单边不超过 8192 像素、总计不超过 16 Mi 像素。
SVG 渲染一次后缓存为位图,异常资源回退到既有主题或文字图标。
这些限制不是解码沙箱,不能保证复杂 SVG 的处理时间,也不能替代底层库安全更新。
- 按候选词的完整文字精确匹配,只隐藏这个词本身。
- **重启不会丢**:文件是原子写入的(先写临时文件、`fsync` 再改名,中途被杀也不会留下写坏的文件),
权限是 `600`;某次保存失败时这个词在本次运行里仍然隐藏,下次会重试写入。
- 文件被别的程序或手动修改后会自动重新读取,不用重启。
- 只有**通过右键菜单点"忘记候选词"**才会记录。用拼音自带的快捷键(`Ctrl+7`)忘记的词不会被记录,
这类词如果来自系统词库,下次还会出现。
- 想恢复某个词:删掉文件里对应的一行即可。
- 这是界面层的补充。换回原版 classicui 后,这些词会重新出现。
`patches/fcitx5-icon-cache.patch` 修复核心图标缓存的循环链、越界、
字符串终止和映射文件截断问题,损坏缓存回退为文件查找。
`build.sh` 会检查并应用此补丁;但 `install.sh` 只安装插件,
**不会替换运行中的 Fcitx5 核心库**,所以仅安装插件不代表核心修复已部署。
构建后可运行 `bash scripts/test.sh` 验证候选菜单、缓存和真实图片解码。
也可通过 `FCITX_SOURCE_DIR`、`FCITX_BUILD_DIR` 指定已有源码和构建目录。
## KWin 淡出(可选,KDE Plasma)
+297
View File
@@ -0,0 +1,297 @@
--- a/src/lib/fcitx/icontheme.cpp
+++ b/src/lib/fcitx/icontheme.cpp
@@ -7,9 +7,11 @@
#include "icontheme.h"
#include <fcntl.h>
#include <sys/stat.h>
+#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
+#include <cstring>
#include <filesystem>
#include <fstream>
#include <functional>
@@ -39,10 +41,6 @@
#include "config.h" // IWYU pragma: keep
#include "misc_p.h"
-#ifdef HAVE_SYS_MMAN_H
-#include <sys/mman.h>
-#endif
-
namespace {
// helper type for the visitor
@@ -194,6 +192,9 @@
FCITX_DEFINE_READ_ONLY_PROPERTY_PRIVATE(IconThemeDirectory, int, threshold);
static uint32_t iconNameHash(const char *p) {
+ if (!*p) {
+ return 0;
+ }
uint32_t h = static_cast<signed char>(*p);
for (p += 1; *p != '\0'; p++) {
h = (h << 5) - h + *p;
@@ -207,7 +208,8 @@
#ifdef _WIN32
FCITX_UNUSED(filename);
#else
- auto fd = StandardPaths::openPath(filename);
+ // Never block opening a FIFO masquerading as a cache.
+ auto fd = StandardPaths::openPath(filename, O_RDONLY | O_NONBLOCK);
if (!fd.isValid()) {
return;
}
@@ -225,35 +227,46 @@
}
struct stat st;
- if (fstat(fd.fd(), &st) < 0) {
+ constexpr size_t maxCacheSize = 16 * 1024 * 1024;
+ if (fstat(fd.fd(), &st) < 0 || !S_ISREG(st.st_mode) ||
+ st.st_size < 12 ||
+ static_cast<uint64_t>(st.st_size) > maxCacheSize) {
return;
}
- memory_ = static_cast<uint8_t *>(
- mmap(nullptr, st.st_size, PROT_READ, MAP_SHARED, fd.fd(), 0));
- if (memory_ == MAP_FAILED) {
- // Failed to mmap is not critical here.
+ // Own a bounded snapshot: a concurrent in-place truncation must not
+ // turn a cache lookup into SIGBUS. Invalid snapshots are discarded.
+ memory_.resize(st.st_size);
+ size_t done = 0;
+ while (done < memory_.size()) {
+ auto n = fs::safeRead(fd.fd(), memory_.data() + done,
+ memory_.size() - done);
+ if (n <= 0) {
+ memory_.clear();
+ return;
+ }
+ done += n;
+ }
+
+ isValid_ = true;
+ if (readWord(0) != 1 || readWord(2) != 0) { // version major minor
+ isValid_ = false;
return;
}
- size_ = st.st_size;
-
- if (readWord(0) != 1 || readWord(2) != 0) { // version major minor
- return;
- }
-
- isValid_ = true;
// Check that all the directories are older than the cache
uint32_t dirListOffset = readDoubleWord(8);
uint32_t dirListLen = readDoubleWord(dirListOffset);
// We assume it won't be so long just in case we hit a corruptted file.
- if (dirListLen > 4096) {
+ if (!isValid_ || dirListLen > 4096 ||
+ !checkRange(static_cast<size_t>(dirListOffset) + 4,
+ static_cast<size_t>(dirListLen) * 4)) {
isValid_ = false;
return;
}
for (uint32_t i = 0; i < dirListLen; ++i) {
uint32_t offset = readDoubleWord(dirListOffset + 4 + (4 * i));
- if (!isValid_ || offset >= size_) {
+ if (!isValid_) {
isValid_ = false;
return;
}
@@ -263,7 +276,8 @@
return;
}
std::filesystem::path subdirName(dir);
- if (subdirName.is_absolute()) {
+ if (subdirName.is_absolute() ||
+ std::ranges::find(subdirName, "..") != subdirName.end()) {
isValid_ = false;
return;
}
@@ -277,65 +291,52 @@
}
IconThemeCache() = default;
- IconThemeCache(IconThemeCache &&other) noexcept
- : isValid_(other.isValid_), memory_(other.memory_), size_(other.size_) {
- other.isValid_ = false;
- other.memory_ = nullptr;
- other.size_ = 0;
- }
-
- IconThemeCache &operator=(IconThemeCache other) {
- std::swap(other.isValid_, isValid_);
- std::swap(other.size_, size_);
- std::swap(other.memory_, memory_);
- return *this;
- }
+ IconThemeCache(IconThemeCache &&other) noexcept = default;
+ IconThemeCache &operator=(IconThemeCache &&other) noexcept = default;
bool isValid() const { return isValid_; }
- ~IconThemeCache() {
-#ifndef _WIN32
- if (memory_) {
- munmap(memory_, size_);
- }
-#endif
- }
-
- uint16_t readWord(uint32_t offset) const {
- if (offset + 2 > size_ || (offset % 2)) {
+ bool checkRange(size_t offset, size_t length) const {
+ if (offset > memory_.size() || length > memory_.size() - offset) {
+ isValid_ = false;
+ return false;
+ }
+ return true;
+ }
+
+ uint16_t readWord(size_t offset) const {
+ if (!checkRange(offset, 2) || (offset % 2)) {
isValid_ = false;
return 0;
}
return memory_[offset + 1] | memory_[offset] << 8;
}
- uint32_t readDoubleWord(unsigned int offset) const {
- if (offset + 4 > size_ || (offset % 4)) {
+ uint32_t readDoubleWord(size_t offset) const {
+ if (!checkRange(offset, 4) || (offset % 4)) {
isValid_ = false;
return 0;
}
- return memory_[offset + 3] | memory_[offset + 2] << 8 |
- memory_[offset + 1] << 16 | memory_[offset] << 24;
- }
-
- char *checkString(uint32_t offset) const {
- // assume string length is less than 1k.
- for (uint32_t i = 0; i < 1024; i++) {
- if (offset + i >= size_) {
- return nullptr;
- }
- auto c = memory_[offset + i];
- if (c == '\0') {
- break;
- }
- }
- return reinterpret_cast<char *>(memory_ + offset);
+ return uint32_t(memory_[offset + 3]) | uint32_t(memory_[offset + 2]) << 8 |
+ uint32_t(memory_[offset + 1]) << 16 | uint32_t(memory_[offset]) << 24;
+ }
+
+ const char *checkString(size_t offset) const {
+ if (!checkRange(offset, 1)) {
+ return nullptr;
+ }
+ const auto length = std::min<size_t>(1024, memory_.size() - offset);
+ auto *str = reinterpret_cast<const char *>(memory_.data() + offset);
+ if (!std::memchr(str, '\0', length)) {
+ isValid_ = false;
+ return nullptr;
+ }
+ return str;
}
std::unordered_set<std::string> lookup(const std::string &name) const;
mutable bool isValid_ = false;
- uint8_t *memory_ = nullptr;
- size_t size_ = 0;
+ std::vector<uint8_t> memory_;
};
std::unordered_set<std::string>
@@ -343,27 +344,43 @@
std::unordered_set<std::string> ret;
auto hash = iconNameHash(name.c_str());
- uint32_t hashOffset = readDoubleWord(4);
+ if (!isValid_) {
+ return ret;
+ }
+ size_t hashOffset = readDoubleWord(4);
uint32_t hashBucketCount = readDoubleWord(hashOffset);
- if (!isValid_ || hashBucketCount == 0) {
+ if (!isValid_ || hashBucketCount == 0 ||
+ hashBucketCount > memory_.size() / 4 ||
+ !checkRange(hashOffset + 4, static_cast<size_t>(hashBucketCount) * 4)) {
isValid_ = false;
return ret;
}
- uint32_t bucketIndex = hash % hashBucketCount;
- uint32_t bucketOffset = readDoubleWord(hashOffset + 4 + (bucketIndex * 4));
- while (bucketOffset > 0 && bucketOffset <= size_ - 12) {
+ size_t bucketIndex = hash % hashBucketCount;
+ size_t bucketOffset = readDoubleWord(hashOffset + 4 + (bucketIndex * 4));
+ std::unordered_set<size_t> visited;
+ while (bucketOffset != 0 && bucketOffset != UINT32_MAX) {
+ // A cache is only an accelerator. Bound even acyclic hostile chains.
+ if (!isValid_ || !checkRange(bucketOffset, 12) ||
+ visited.size() >= 4096 || !visited.insert(bucketOffset).second) {
+ isValid_ = false;
+ return {};
+ }
uint32_t nameOff = readDoubleWord(bucketOffset + 4);
auto *namePtr = checkString(nameOff);
- if (nameOff < size_ && namePtr && name == namePtr) {
- uint32_t dirListOffset = readDoubleWord(8);
+ if (!isValid_ || !namePtr) {
+ return {};
+ }
+ if (name == namePtr) {
+ size_t dirListOffset = readDoubleWord(8);
uint32_t dirListLen = readDoubleWord(dirListOffset);
- uint32_t listOffset = readDoubleWord(bucketOffset + 8);
+ size_t listOffset = readDoubleWord(bucketOffset + 8);
uint32_t listLen = readDoubleWord(listOffset);
- if (!isValid_ || listOffset + 4 + 8 * listLen > size_) {
+ if (!isValid_ || listLen > 4096 ||
+ !checkRange(listOffset + 4, static_cast<size_t>(listLen) * 8)) {
isValid_ = false;
return ret;
}
@@ -371,11 +388,11 @@
ret.reserve(listLen);
for (uint32_t j = 0; j < listLen && isValid_; ++j) {
uint32_t dirIndex = readWord(listOffset + 4 + (8 * j));
- uint32_t o = readDoubleWord(dirListOffset + 4 + (dirIndex * 4));
- if (!isValid_ || dirIndex >= dirListLen || o >= size_) {
+ if (!isValid_ || dirIndex >= dirListLen) {
isValid_ = false;
return ret;
}
+ uint32_t o = readDoubleWord(dirListOffset + 4 + (dirIndex * 4));
if (auto *str = checkString(o)) {
ret.emplace(str);
} else {
@@ -565,7 +582,7 @@
std::unordered_set<std::string> dirFilter;
if (baseDir.second.isValid()) {
dirFilter = baseDir.second.lookup(iconname);
- hasCache = true;
+ hasCache = baseDir.second.isValid();
}
for (const auto &directory : directories_) {
if ((hasCache && !dirFilter.contains(directory.path())) ||
@@ -600,7 +617,7 @@
std::unordered_set<std::string> dirFilter;
if (baseDir.second.isValid()) {
dirFilter = baseDir.second.lookup(iconname);
- hasCache = true;
+ hasCache = baseDir.second.isValid();
}
auto checkDirectoryWithSize =
+9
View File
@@ -55,6 +55,15 @@ fetch_commit https://git.oeo.one/justme/libmonet.git "$work/libmonet" "$monet_co
[ -z "${FETCH_ONLY:-}" ] || { echo ">> sources are in place"; exit 0; }
echo ">> overlaying m3ui"
echo ">> applying icon cache hardening"
cache_patch="$root/patches/fcitx5-icon-cache.patch"
if git -C "$srcdir" apply --check "$cache_patch"; then
git -C "$srcdir" apply "$cache_patch"
elif git -C "$srcdir" apply --reverse --check "$cache_patch"; then
echo ">> icon cache hardening already applied"
else
die "icon cache patch does not match Fcitx5 $version; refusing a partial build"
fi
rm -rf "$srcdir/src/ui/m3ui"
cp -r "$root/src" "$srcdir/src/ui/m3ui"
if ! grep -q 'add_subdirectory(m3ui)' "$srcdir/src/ui/CMakeLists.txt"; then
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# Run the regression tests against an already built, patched Fcitx5 tree.
set -euo pipefail
ulimit -c 0
root=$(cd "$(dirname "$0")/.." && pwd)
work=${WORK_DIR:-$root/build}
version=${FCITX_VERSION:-$(pkg-config --modversion Fcitx5Core)}
srcdir=${FCITX_SOURCE_DIR:-$work/fcitx5-$version}
builddir=${FCITX_BUILD_DIR:-$work/cmake}
out=$(mktemp -d "${TMPDIR:-/tmp}/m3ui-tests-XXXXXX")
trap 'rm -f "$out/testm3uicandidatemenu" "$out/testiconthemecache" "$out/testm3uiresourceguard" "$out/testm3uiimageloader"; rmdir "$out"' EXIT
flags=(-std=c++20 -Wall -Wextra -I"$srcdir/src/lib" -I"$builddir/src/lib")
libs=(-L"$builddir/bin" -Wl,-rpath,"$builddir/bin" -lFcitx5Core -lFcitx5Config -lFcitx5Utils)
for name in testm3uicandidatemenu testiconthemecache testm3uiresourceguard; do
"${CXX:-c++}" "${flags[@]}" "$root/tests/$name.cpp" "${libs[@]}" -o "$out/$name"
done
read -r -a image_flags <<< "$(pkg-config --cflags --libs cairo gdk-pixbuf-2.0 librsvg-2.0)"
"${CXX:-c++}" "${flags[@]}" "$root/tests/testm3uiimageloader.cpp" \
"$root/src/imageloader.cpp" "${image_flags[@]}" "${libs[@]}" -o "$out/testm3uiimageloader"
for name in testm3uicandidatemenu testiconthemecache testm3uiresourceguard testm3uiimageloader; do
echo ">> $name"
timeout 15 "$out/$name"
done
+1 -1
View File
@@ -36,7 +36,7 @@ if (NOT MONET_INCLUDE_DIR OR NOT EXISTS "${MONET_INCLUDE_DIR}/monet/monet.h")
endif()
add_fcitx5_addon(m3ui
classicui.cpp window.cpp theme.cpp inputwindow.cpp plasmathemewatchdog.cpp colorhelper.cpp ${CLASSICUI_SRCS}
classicui.cpp window.cpp theme.cpp imageloader.cpp inputwindow.cpp plasmathemewatchdog.cpp colorhelper.cpp ${CLASSICUI_SRCS}
)
if (ENABLE_X11)
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <utility>
#include <vector>
#include "fcitx/candidateaction.h"
#include "fcitx/candidatelist.h"
namespace fcitx::m3ui {
struct CandidateMenu {
std::vector<CandidateAction> actions;
};
inline CandidateMenu candidateMenu(const CandidateList &list,
const CandidateWord &candidate) {
CandidateMenu menu;
if (auto *actionable = list.toActionable();
actionable && actionable->hasAction(candidate)) {
for (auto &action : actionable->candidateActions(candidate)) {
if (!action.isSeparator()) {
menu.actions.push_back(std::move(action));
}
}
}
// Ranking and learning belong to the engine. In particular, forgetting a
// learned candidate must not be turned into permanent UI-side blocking.
return menu;
}
} // namespace fcitx::m3ui
-18
View File
@@ -151,24 +151,6 @@ M3UI::M3UI(Instance *instance) : instance_(instance) {
M3UI::~M3UI() {}
std::filesystem::path M3UI::blockedWordsPath() {
return StandardPaths::global().userDirectory(StandardPathsType::PkgData) /
"m3ui" / "blocked-words.txt";
}
bool M3UI::blockWord(const std::string &word) {
if (blockStore_.add(word)) {
return true;
}
if (BlockStore::valid(word)) {
CLASSICUI_ERROR() << "Could not save the blocked word to "
<< blockStore_.path()
<< ", it stays hidden until Fcitx5 restarts unless "
"a later save succeeds.";
}
return false;
}
void M3UI::reloadConfig() {
readAsIni(config_, "conf/m3ui.conf");
reloadTheme();
-19
View File
@@ -30,7 +30,6 @@
#include "fcitx/event.h"
#include "fcitx/instance.h"
#include "fcitx/userinterface.h"
#include "blockstore.h"
#include "classicui_public.h"
#include "config.h"
#include "plasmathemewatchdog.h"
@@ -274,25 +273,7 @@ public:
bool preferTextIcon() const;
bool showLayoutNameInIcon() const;
// Words the user chose to forget. Forgetting in the Pinyin engine only
// removes learned words, anything from a read only dictionary would come
// back, so the window also hides these. The list lives in a file, see
// BlockStore for how it is kept safe.
bool hasBlockedWords() const { return !blockStore_.empty(); }
bool isBlocked(const std::string &word) const {
return blockStore_.contains(word);
}
// Picks up changes made to the file behind our back and retries a save that
// failed earlier. One stat call.
void reloadBlockedWords() { blockStore_.reloadIfChanged(); }
// Returns false if the word could not be saved, it is hidden for this
// session anyway.
bool blockWord(const std::string &word);
private:
static std::filesystem::path blockedWordsPath();
BlockStore blockStore_{blockedWordsPath()};
FCITX_ADDON_DEPENDENCY_LOADER(notificationitem, instance_->addonManager());
FCITX_ADDON_EXPORT_FUNCTION(M3UI, labelIcon);
FCITX_ADDON_EXPORT_FUNCTION(M3UI, preferTextIcon);
+100
View File
@@ -0,0 +1,100 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include "imageloader.h"
#include <algorithm>
#include <cstring>
#include <gio/gio.h>
#include <librsvg/rsvg.h>
#include "resourceguard.h"
namespace fcitx::m3ui {
LoadedSurface loadSvgImage(const std::vector<uint8_t> &bytes) {
if (bytes.empty() || bytes.size() > maxImageFileSize) {
return nullptr;
}
UniqueCPtr<GInputStream, g_object_unref> stream(
g_memory_input_stream_new_from_data(bytes.data(), bytes.size(), nullptr));
if (!stream) {
return nullptr;
}
// No base URI: do not grant SVGs access to sibling files. Keep librsvg's
// default safety limits (in particular, never use FLAG_UNLIMITED).
UniqueCPtr<RsvgHandle, g_object_unref> handle(rsvg_handle_new_from_stream_sync(
stream.get(), nullptr, RSVG_HANDLE_FLAGS_NONE, nullptr, nullptr));
g_input_stream_close(stream.get(), nullptr, nullptr);
double width = 0;
double height = 0;
if (!handle ||
!rsvg_handle_get_intrinsic_size_in_pixels(handle.get(), &width, &height) ||
!safeImageSize(width, height)) {
return nullptr;
}
const auto w = static_cast<int>(std::ceil(width));
const auto h = static_cast<int>(std::ceil(height));
LoadedSurface surface(cairo_image_surface_create(CAIRO_FORMAT_ARGB32, w, h));
if (cairo_surface_status(surface.get()) != CAIRO_STATUS_SUCCESS) {
return nullptr;
}
UniqueCPtr<cairo_t, cairo_destroy> context(cairo_create(surface.get()));
const RsvgRectangle viewport{0, 0, static_cast<double>(w),
static_cast<double>(h)};
// Render once, rather than recording and replaying SVG commands on each
// repaint. This bounds pixel storage, not SVG parse/render execution time.
if (cairo_status(context.get()) != CAIRO_STATUS_SUCCESS ||
!rsvg_handle_render_document(handle.get(), context.get(), &viewport,
nullptr) ||
cairo_status(context.get()) != CAIRO_STATUS_SUCCESS ||
cairo_surface_status(surface.get()) != CAIRO_STATUS_SUCCESS) {
return nullptr;
}
return surface;
}
LoadedPixbuf loadRasterImage(const std::vector<uint8_t> &bytes) {
if (bytes.empty() || bytes.size() > maxImageFileSize) {
return nullptr;
}
// Check PNG dimensions even when the filename has another extension.
constexpr unsigned char signature[] = {137, 80, 78, 71, 13, 10, 26, 10};
if (bytes.size() >= sizeof(signature) &&
!std::memcmp(bytes.data(), signature, sizeof(signature)) &&
!safePngSize(bytes)) {
return nullptr;
}
// The callback data must outlive the loader, including its destruction.
bool rejected = false;
UniqueCPtr<GdkPixbufLoader, g_object_unref> loader(gdk_pixbuf_loader_new());
if (!loader) {
return nullptr;
}
g_signal_connect(
loader.get(), "size-prepared",
G_CALLBACK(+[](GdkPixbufLoader *loader, int width, int height,
gpointer data) {
if (!safeImageSize(width, height)) {
*static_cast<bool *>(data) = true;
gdk_pixbuf_loader_set_size(loader, 1, 1);
}
}),
&rejected);
bool loaded = true;
for (size_t offset = 0; offset < bytes.size() && !rejected;) {
const auto length = std::min<size_t>(4096, bytes.size() - offset);
if (!gdk_pixbuf_loader_write(loader.get(), bytes.data() + offset,
length, nullptr)) {
loaded = false;
break;
}
offset += length;
}
const bool closed = gdk_pixbuf_loader_close(loader.get(), nullptr);
auto *image = gdk_pixbuf_loader_get_pixbuf(loader.get());
if (!loaded || !closed || rejected || !image ||
!safeImageSize(gdk_pixbuf_get_width(image), gdk_pixbuf_get_height(image))) {
return nullptr;
}
return LoadedPixbuf(static_cast<GdkPixbuf *>(g_object_ref(image)));
}
} // namespace fcitx::m3ui
+18
View File
@@ -0,0 +1,18 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#pragma once
#include <cstdint>
#include <vector>
#include <cairo.h>
#include <gdk-pixbuf/gdk-pixbuf.h>
#include "fcitx-utils/misc.h"
namespace fcitx::m3ui {
// Own the returned reference independently of the incremental loader.
using LoadedPixbuf = UniqueCPtr<GdkPixbuf, g_object_unref>;
LoadedPixbuf loadRasterImage(const std::vector<uint8_t> &bytes);
using LoadedSurface = UniqueCPtr<cairo_surface_t, cairo_surface_destroy>;
LoadedSurface loadSvgImage(const std::vector<uint8_t> &bytes);
} // namespace fcitx::m3ui
+80 -84
View File
@@ -45,7 +45,7 @@
#include "fcitx/text.h"
#include "fcitx/userinterface.h"
#include "classicui.h"
#include "blockfilter.h"
#include "candidatemenu.h"
#include "common.h"
#include "theme.h"
@@ -131,16 +131,6 @@ double springSettleSeconds(double stiffness, double damping) {
return damping >= 1.0 ? 7.5 / w : 5.3 / (damping * w);
}
// The engine's "forget" action, as shown in English or Chinese.
bool isForgetAction(const std::string &text) {
std::string lower = text;
std::ranges::transform(lower, lower.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return lower.find("forget") != std::string::npos ||
text.find("\xe5\xbf\x98\xe8\xae\xb0") != std::string::npos; // 忘记
}
double millisecondsSince(std::chrono::steady_clock::time_point start,
std::chrono::steady_clock::time_point now) {
return std::chrono::duration<double, std::milli>(now - start).count();
@@ -430,14 +420,6 @@ std::pair<int, int> InputWindow::update(InputContext *inputContext) {
auxUp = instance->outputFilter(inputContext, inputPanel.auxUp());
auxDown = instance->outputFilter(inputContext, inputPanel.auxDown());
candidateList = inputPanel.candidateList();
// Drop forgotten words, removing from the engine's list keeps labels
// and selection keys consistent with what is shown.
parent_->reloadBlockedWords();
if (candidateList && parent_->hasBlockedWords()) {
removeBlockedCandidates(*candidateList, [this](const std::string &w) {
return parent_->isBlocked(w);
});
}
} else if (!inputPanel.overlayMessage().empty()) {
auxUp = inputPanel.overlayMessage();
}
@@ -590,19 +572,31 @@ std::pair<unsigned int, unsigned int> InputWindow::sizeHint() {
float yogaWidth = YGNodeLayoutGetWidth(rootNode_.get());
float yogaHeight = YGNodeLayoutGetHeight(rootNode_.get());
menuTop_ = yogaHeight;
panelWidth_ = yogaWidth;
panelHeight_ = yogaHeight;
menuTop_ = yogaHeight + 8;
if (menuOpen_) {
auto [menuWidth, menuHeight] = menuSize();
menuWidth_ = menuWidth;
yogaWidth = std::max<double>(yogaWidth, menuWidth);
yogaHeight += menuHeight;
menuLeft_ = std::clamp(menuAnchor_, 0.0,
static_cast<double>(yogaWidth) - menuWidth);
yogaHeight = menuTop_ + menuHeight;
menuRegions_.clear();
for (size_t i = 0; i < menuActions_.size(); i++) {
Rect region;
region.setPosition(menuLeft_ + 4, menuTop_ + 8 + i * menuItemHeight_)
.setSize(menuWidth_ - 8, menuItemHeight_);
menuRegions_.push_back(region);
}
}
auto width = static_cast<unsigned int>(yogaWidth);
auto height = static_cast<unsigned int>(yogaHeight);
auto width = static_cast<unsigned int>(std::ceil(yogaWidth));
auto height = static_cast<unsigned int>(std::ceil(yogaHeight));
return {width, height};
}
void InputWindow::paint(cairo_t *cr, double width, double height) {
void InputWindow::paint(cairo_t *cr, double /*width*/, double /*height*/) {
auto &theme = parent_->theme();
cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR);
cairo_paint(cr);
@@ -651,7 +645,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
break;
}
cairo_save(cr);
roundedRectPath(cr, 0.5, 0.5, width - 1, height - 1,
roundedRectPath(cr, 0.5, 0.5, panelWidth_ - 1, panelHeight_ - 1,
*theme.inputPanel->panelRadius);
cairoSetSourceColor(cr, *theme.inputPanel->surfaceColor);
cairo_fill_preserve(cr);
@@ -664,7 +658,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
}
cairo_restore(cr);
} else {
theme.paint(cr, *theme.inputPanel->background, 0, 0, width, height,
theme.paint(cr, *theme.inputPanel->background, 0, 0, panelWidth_, panelHeight_,
/*alpha=*/1.0);
}
const auto &margin = *theme.inputPanel->contentMargin;
@@ -725,7 +719,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
const auto &inner = candidateNodes_[hi].inner;
double w = YGNodeLayoutGetWidth(inner.get());
if (*theme.inputPanel->fullWidthHighlight && vertical) {
w = width - *margin.marginLeft - *margin.marginRight -
w = panelWidth_ - *margin.marginLeft - *margin.marginRight -
*textMargin.marginRight - *textMargin.marginLeft;
}
RectF target;
@@ -810,7 +804,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
if (*theme.inputPanel->fullWidthHighlight && vertical) {
// Last candidate, fill.
highlightWidth = width - *margin.marginLeft - *margin.marginRight -
highlightWidth = panelWidth_ - *margin.marginLeft - *margin.marginRight -
*textMargin.marginRight - *textMargin.marginLeft;
}
@@ -942,7 +936,7 @@ void InputWindow::paint(cairo_t *cr, double width, double height) {
}
if (menuOpen_) {
paintMenu(cr, width, menuTop_, fontHeight);
paintMenu(cr);
}
if (m3ui_logcategory().checkLogLevel(Debug)) {
@@ -971,31 +965,26 @@ void InputWindow::click(int x, int y) {
if (menuOpen_) {
// Copy what is needed first, triggering an action rebuilds the list.
int actionId = -1;
std::string actionText;
bool actionSelected = false;
const auto candidateIndex = menuCandidate_;
const std::string candidateText = menuCandidateText_;
for (size_t i = 0; i < menuRegions_.size(); i++) {
if (menuRegions_[i].contains(x, y)) {
actionSelected = true;
actionId = menuActions_[i].id();
actionText = menuActions_[i].text();
break;
}
}
closeMenu();
if (actionId >= 0) {
if (actionSelected) {
auto *actionable = candidateList->toActionable();
const auto *candidate =
nthCandidateIgnorePlaceholder(*candidateList, candidateIndex);
// The list may have been refreshed since the menu was opened. Only
// act when the candidate is still the one the user picked, a
// different word must never be forgotten or blocked.
// different word must never be forgotten.
if (actionable && candidate &&
candidate->text().toString() == candidateText) {
// Block before the engine refreshes its list so the refreshed
// list is already filtered.
if (isForgetAction(actionText)) {
parent_->blockWord(candidateText);
}
actionable->triggerAction(*candidate, actionId);
}
}
@@ -1029,6 +1018,7 @@ void InputWindow::click(int x, int y) {
}
void InputWindow::rightClick(int x, int y) {
CLASSICUI_DEBUG() << "Candidate right click at " << x << ", " << y;
auto *inputContext = inputContext_.get();
if (!inputContext) {
return;
@@ -1042,36 +1032,32 @@ void InputWindow::rightClick(int x, int y) {
inputContext->updateUserInterface(UserInterfaceComponent::InputPanel);
return;
}
auto *actionable = candidateList->toActionable();
if (!actionable) {
return;
}
for (size_t idx = 0, e = candidateRegions_.size(); idx < e; idx++) {
if (!candidateRegions_[idx].contains(x, y)) {
continue;
}
const auto *candidate =
nthCandidateIgnorePlaceholder(*candidateList, idx);
if (!candidate || !actionable->hasAction(*candidate)) {
if (!candidate) {
return;
}
std::vector<CandidateAction> actions;
for (auto &action : actionable->candidateActions(*candidate)) {
if (!action.isSeparator()) {
actions.push_back(std::move(action));
}
}
if (actions.empty()) {
auto menu = candidateMenu(*candidateList, *candidate);
CLASSICUI_DEBUG() << "Candidate menu: engine action interface="
<< (candidateList->toActionable() != nullptr)
<< ", actions=" << menu.actions.size();
if (menu.actions.empty()) {
return;
}
menuActions_ = std::move(actions);
menuActions_ = std::move(menu.actions);
menuCandidate_ = idx;
menuCandidateText_ = candidate->text().toString();
menuAnchor_ = candidateRegions_[idx].left();
menuHover_ = -1;
menuOpen_ = true;
inputContext->updateUserInterface(UserInterfaceComponent::InputPanel);
return;
}
CLASSICUI_DEBUG() << "Candidate right click did not hit a candidate region";
}
void InputWindow::closeMenu() {
@@ -1081,6 +1067,7 @@ void InputWindow::closeMenu() {
menuHover_ = -1;
menuActions_.clear();
menuRegions_.clear();
menuLayouts_.clear();
}
std::pair<double, double> InputWindow::menuSize() {
@@ -1090,56 +1077,65 @@ std::pair<double, double> InputWindow::menuSize() {
const int fontHeight = PANGO_PIXELS(pango_font_metrics_get_ascent(metrics) +
pango_font_metrics_get_descent(metrics));
pango_font_metrics_unref(metrics);
menuItemHeight_ = fontHeight + 16;
menuTextWidth_ = 0;
menuItemHeight_ = std::max(40, fontHeight + 16);
double textWidth = 0;
menuLayouts_.clear();
for (const auto &action : menuActions_) {
auto layout = newPangoLayout(context_.get());
pango_layout_set_text(layout.get(), action.text().c_str(), -1);
int w = 0;
int h = 0;
pango_layout_get_pixel_size(layout.get(), &w, &h);
menuTextWidth_ = std::max<double>(menuTextWidth_, w);
textWidth = std::max<double>(textWidth, w);
menuLayouts_.push_back(std::move(layout));
}
// 1px divider + padding, item padding of 16 and a margin of 8 each side.
return {menuTextWidth_ + 2 * 16 + 2 * 8,
1.0 + 6 + menuActions_.size() * menuItemHeight_ + 6};
// M3 menu spacing, adapted to a compact desktop input panel: 16px
// horizontal / 8px vertical padding and at least 40px per action.
const double width = std::clamp(textWidth + 32, 112.0, 280.0);
for (auto &layout : menuLayouts_) {
pango_layout_set_width(layout.get(), (width - 32) * PANGO_SCALE);
pango_layout_set_wrap(layout.get(), PANGO_WRAP_WORD_CHAR);
int h = 0;
pango_layout_get_pixel_size(layout.get(), nullptr, &h);
menuItemHeight_ = std::max(menuItemHeight_, h + 16);
}
return {width, 16.0 + menuActions_.size() * menuItemHeight_};
}
void InputWindow::paintMenu(cairo_t *cr, double width, double yTop,
double /*fontHeight*/) {
void InputWindow::paintMenu(cairo_t *cr) {
auto &theme = parent_->theme();
cairo_save(cr);
auto line = *theme.inputPanel->outlineColor;
if (line.alpha() == 0) {
const auto &text = theme.inputPanelText();
line = Color(text.red(), text.green(), text.blue(), 40);
}
cairoSetSourceColor(cr, line);
cairo_rectangle(cr, 12, yTop, width - 24, 1);
cairo_fill(cr);
const double height = 16.0 + menuActions_.size() * menuItemHeight_;
auto surface = materialStyle() ? *theme.inputPanel->surfaceColor
: theme.inputPanelBackground();
// Menus must remain legible over application content, even when the
// candidate panel is configured to be translucent.
surface.setAlphaF(1);
fillRoundedRect(cr, surface, menuLeft_, menuTop_, menuWidth_, height, 12);
auto outline = theme.inputPanelText();
outline.setAlphaF(0.12);
roundedRectPath(cr, menuLeft_ + 0.5, menuTop_ + 0.5,
menuWidth_ - 1, height - 1, 12);
cairoSetSourceColor(cr, outline);
cairo_set_line_width(cr, 1);
cairo_stroke(cr);
roundedRectPath(cr, menuLeft_, menuTop_, menuWidth_, height, 12);
cairo_clip(cr);
menuRegions_.clear();
double y = yTop + 1 + 6;
for (size_t i = 0; i < menuActions_.size(); i++) {
Rect region;
region.setPosition(8, y).setSize(width - 16, menuItemHeight_);
menuRegions_.push_back(region);
const auto &region = menuRegions_[i];
const bool hovered = static_cast<int>(i) == menuHover_;
if (hovered) {
fillRoundedRect(cr, *theme.inputPanel->highlightFillColor, 8, y,
width - 16, menuItemHeight_,
*theme.inputPanel->itemRadius);
auto state = theme.inputPanelText();
state.setAlphaF(0.08);
fillRoundedRect(cr, state, region.left(), region.top(),
region.width(), region.height(), 8);
}
auto layout = newPangoLayout(context_.get());
pango_layout_set_text(layout.get(), menuActions_[i].text().c_str(), -1);
int w = 0;
int h = 0;
pango_layout_get_pixel_size(layout.get(), &w, &h);
cairoSetSourceColor(cr, hovered
? theme.inputPanelHighlightCandidateText()
: theme.inputPanelText());
renderLayout(cr, layout.get(), 8 + 16, y + (menuItemHeight_ - h) / 2.0);
y += menuItemHeight_;
pango_layout_get_pixel_size(menuLayouts_[i].get(), nullptr, &h);
cairoSetSourceColor(cr, theme.inputPanelText());
renderLayout(cr, menuLayouts_[i].get(), menuLeft_ + 16,
region.top() + (menuItemHeight_ - h) / 2.0);
}
cairo_restore(cr);
}
+7 -3
View File
@@ -194,7 +194,7 @@ protected:
// Candidate action menu.
void closeMenu();
void paintMenu(cairo_t *cr, double width, double yTop, double fontHeight);
void paintMenu(cairo_t *cr);
std::pair<double, double> menuSize();
bool menuOpen_ = false;
int menuCandidate_ = -1;
@@ -206,9 +206,13 @@ protected:
std::vector<CandidateAction> menuActions_;
std::vector<Rect> menuRegions_;
double menuTop_ = 0;
double menuLeft_ = 0;
double menuAnchor_ = 0;
double menuWidth_ = 0;
double panelWidth_ = 0;
double panelHeight_ = 0;
int menuItemHeight_ = 0;
std::vector<std::string> menuLabels_;
double menuTextWidth_ = 0;
std::vector<GObjectUniquePtr<PangoLayout>> menuLayouts_;
private:
std::pair<unsigned int, unsigned int> sizeHint();
+68
View File
@@ -0,0 +1,68 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#pragma once
#include <sys/stat.h>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <optional>
#include <vector>
#include "fcitx-utils/fs.h"
#include "fcitx-utils/unixfd.h"
namespace fcitx::m3ui {
// UI assets are not arbitrary documents. Reject excessive inputs and let the
// existing theme/icon fallbacks handle them instead of risking the IM process.
inline constexpr size_t maxImageFileSize = 16 * 1024 * 1024;
inline bool safeImageSize(double width, double height) {
return std::isfinite(width) && std::isfinite(height) && width > 0 &&
height > 0 && width <= 8192 && height <= 8192 &&
std::ceil(width) * std::ceil(height) <= 16 * 1024 * 1024;
}
inline bool safeImageMargins(int width, int height, int left, int top,
int right, int bottom) {
// Subtraction avoids overflow from adding untrusted margin values.
return width > 0 && height > 0 && left >= 0 && top >= 0 && right >= 0 &&
bottom >= 0 && left <= width && right <= width - left &&
top <= height && bottom <= height - top;
}
inline bool safePngSize(const std::vector<uint8_t> &bytes) {
constexpr unsigned char signature[] = {137, 80, 78, 71, 13, 10, 26, 10};
if (bytes.size() < 24 ||
std::memcmp(bytes.data(), signature, sizeof(signature)) ||
std::memcmp(bytes.data() + 8, "\0\0\0\rIHDR", 8)) {
return false;
}
auto dimension = [&](size_t offset) {
return uint32_t(bytes[offset]) << 24 |
uint32_t(bytes[offset + 1]) << 16 |
uint32_t(bytes[offset + 2]) << 8 | uint32_t(bytes[offset + 3]);
};
return safeImageSize(dimension(16), dimension(20));
}
inline std::optional<std::vector<uint8_t>> readImageSnapshot(const UnixFD &file) {
struct stat st;
if (!file.isValid() || fstat(file.fd(), &st) < 0 || !S_ISREG(st.st_mode) ||
st.st_size <= 0 || static_cast<uint64_t>(st.st_size) > maxImageFileSize) {
return std::nullopt;
}
std::vector<uint8_t> bytes(st.st_size);
size_t done = 0;
while (done < bytes.size()) {
const auto n = fs::safeRead(file.fd(), bytes.data() + done,
bytes.size() - done);
if (n <= 0) {
return std::nullopt;
}
done += n;
}
return bytes;
}
} // namespace fcitx::m3ui
+63 -87
View File
@@ -12,6 +12,7 @@
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <optional>
#include <string>
@@ -24,11 +25,8 @@
#include <cairo.h>
#include <format>
#include <gdk-pixbuf/gdk-pixbuf.h>
#include <gio/gio.h>
#include <gio/gunixinputstream.h>
#include <glib.h>
#include <glibconfig.h>
#include <librsvg/rsvg.h>
#include <pango/pango-font.h>
#include <pango/pango-fontmap.h>
#include <pango/pango-layout.h>
@@ -51,11 +49,27 @@
#include "classicui.h"
#include "colorhelper.h"
#include "common.h"
#include "imageloader.h"
#include "resourceguard.h"
namespace fcitx::m3ui {
namespace {
UnixFD openImageFile(StandardPathsType type, const std::filesystem::path &name,
StandardPathsModes modes, std::filesystem::path *outPath) {
for (const auto &directory : StandardPaths::global().directories(type, modes)) {
auto path = directory / name;
auto file = StandardPaths::openPath(path, O_RDONLY | O_NONBLOCK);
struct stat st;
if (file.isValid() && fstat(file.fd(), &st) == 0 && S_ISREG(st.st_mode)) {
*outPath = std::move(path);
return file;
}
}
return {};
}
inline uint32_t charWidth(uint32_t c) {
if (g_unichar_iszerowidth(c)) {
return 0;
@@ -88,18 +102,19 @@ std::pair<std::string, size_t> extractTextForLabel(const std::string &label) {
return {extracted, currentWidth};
}
cairo_status_t readFromFd(void *closure, unsigned char *data,
unsigned int length) {
int fd = *static_cast<int *>(closure);
struct ImageReadBuffer {
const std::vector<uint8_t> &bytes;
size_t offset = 0;
};
while (length) {
auto sz = fs::safeRead(fd, data, length);
if (sz <= 0) {
return CAIRO_STATUS_READ_ERROR;
}
length -= sz;
data += sz;
cairo_status_t readFromBuffer(void *closure, unsigned char *data,
unsigned int length) {
auto &buffer = *static_cast<ImageReadBuffer *>(closure);
if (length > buffer.bytes.size() - buffer.offset) {
return CAIRO_STATUS_READ_ERROR;
}
std::memcpy(data, buffer.bytes.data() + buffer.offset, length);
buffer.offset += length;
return CAIRO_STATUS_SUCCESS;
}
@@ -204,13 +219,18 @@ ThemeImage::Pixmap pixBufToPixmap(GdkPixbuf *image) {
}
ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) {
if (file.fd() < 0) {
auto bytes = readImageSnapshot(file);
if (!bytes) {
return nullptr;
}
if (path.extension() == ".png") {
int fd = file.fd();
// Check IHDR before Cairo/libpng allocates the full raster.
if (!safePngSize(*bytes)) {
return nullptr;
}
ImageReadBuffer buffer{*bytes};
auto *surface =
cairo_image_surface_create_from_png_stream(readFromFd, &fd);
cairo_image_surface_create_from_png_stream(readFromBuffer, &buffer);
if (!surface) {
return nullptr;
}
@@ -221,14 +241,7 @@ ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) {
return ThemeImage::Pixmap{surface};
}
GObjectUniquePtr<GInputStream> stream(
g_unix_input_stream_new(file.fd(), false));
if (!stream) {
return nullptr;
}
GObjectUniquePtr<GdkPixbuf> image(
gdk_pixbuf_new_from_stream(stream.get(), nullptr, nullptr));
g_input_stream_close(stream.get(), nullptr, nullptr);
auto image = loadRasterImage(*bytes);
if (!image) {
return nullptr;
}
@@ -236,67 +249,20 @@ ThemeImage::Pixmap loadPixmap(UnixFD &file, const std::filesystem::path &path) {
return pixBufToPixmap(image.get());
}
std::optional<std::pair<int, int>> svgSize(RsvgHandle *svg) {
double w = 0;
double h = 0;
if (rsvg_handle_get_intrinsic_size_in_pixels(svg, &w, &h)) {
int width = std::max(1, static_cast<int>(std::ceil(w)));
int height = std::max(1, static_cast<int>(std::ceil(h)));
return std::make_pair(width, height);
}
return std::nullopt;
}
ThemeImage::CairoPattern renderSvg(RsvgHandle *svg, int width, int height) {
const cairo_rectangle_t extents{0, 0, static_cast<double>(width),
static_cast<double>(height)};
ThemeImage::Pixmap surface(
cairo_recording_surface_create(CAIRO_CONTENT_COLOR_ALPHA, &extents));
if (cairo_surface_status(surface.get()) != CAIRO_STATUS_SUCCESS) {
return nullptr;
}
UniqueCPtr<cairo_t, cairo_destroy> context(cairo_create(surface.get()));
if (cairo_status(context.get()) != CAIRO_STATUS_SUCCESS) {
return nullptr;
}
const RsvgRectangle viewport{0, 0, static_cast<double>(width),
static_cast<double>(height)};
if (!rsvg_handle_render_document(svg, context.get(), &viewport, nullptr) ||
cairo_status(context.get()) != CAIRO_STATUS_SUCCESS) {
return nullptr;
}
ThemeImage::CairoPattern pattern(
cairo_pattern_create_for_surface(surface.get()));
if (cairo_pattern_status(pattern.get()) != CAIRO_STATUS_SUCCESS) {
return nullptr;
}
return pattern;
}
std::optional<ThemeImage::Svg> loadSvg(UnixFD &file) {
if (!file.isValid()) {
auto bytes = readImageSnapshot(file);
if (!bytes) {
return std::nullopt;
}
GObjectUniquePtr<GInputStream> stream(
g_unix_input_stream_new(file.fd(), false));
if (!stream) {
return std::nullopt;
}
GObjectUniquePtr<RsvgHandle> handle(rsvg_handle_new_from_stream_sync(
stream.get(), nullptr, RSVG_HANDLE_FLAGS_NONE, nullptr, nullptr));
g_input_stream_close(stream.get(), nullptr, nullptr);
if (!handle) {
return std::nullopt;
}
auto size = svgSize(handle.get());
if (!size) {
auto surface = loadSvgImage(*bytes);
if (!surface) {
return std::nullopt;
}
ThemeImage::Svg svg;
svg.width = size->first;
svg.height = size->second;
svg.pattern = renderSvg(handle.get(), svg.width, svg.height);
if (!svg.pattern) {
svg.width = cairo_image_surface_get_width(surface.get());
svg.height = cairo_image_surface_get_height(surface.get());
svg.pattern.reset(cairo_pattern_create_for_surface(surface.get()));
if (cairo_pattern_status(svg.pattern.get()) != CAIRO_STATUS_SUCCESS) {
return std::nullopt;
}
return svg;
@@ -343,8 +309,12 @@ double pixelFloor(double f) { return std::floor(f + RoundEpsilon); }
template <typename CreatePattern>
std::optional<ThemeImage::NineTiles>
createImage(int width, int height, int marginLeft, int marginTop,
int marginRight, int marginBottom, bool retainSourceOffset,
CreatePattern createPattern) {
int marginRight, int marginBottom, bool retainSourceOffset,
CreatePattern createPattern) {
if (!safeImageMargins(width, height, marginLeft, marginTop, marginRight,
marginBottom)) {
return std::nullopt;
}
ThemeImage::NineTiles result;
result.width = width;
result.height = height;
@@ -396,7 +366,7 @@ createImage(ThemeImage::Pixmap pixmap, int marginLeft = 0, int marginTop = 0,
}
int sourceWidth = cairo_image_surface_get_width(pixmap.get());
int sourceHeight = cairo_image_surface_get_height(pixmap.get());
if (sourceWidth % scale || sourceHeight % scale) {
if (scale <= 0 || sourceWidth % scale || sourceHeight % scale) {
return std::nullopt;
}
int width = sourceWidth / scale;
@@ -430,7 +400,7 @@ void loadScaledImages(std::map<int, Image> &images,
int width, int height, Load load) {
for (int scale = 2; scale <= supportedScale; scale++) {
auto scaledPath = scaledImagePath(path, scale);
auto file = StandardPaths::openPath(scaledPath);
auto file = StandardPaths::openPath(scaledPath, O_RDONLY | O_NONBLOCK);
if (auto image = load(scale, file, scaledPath)) {
if (image->width == width && image->height == height) {
images.emplace(scale, std::move(*image));
@@ -568,6 +538,9 @@ ThemeImage::ThemeImage(const IconTheme &iconTheme, const std::string &icon,
const std::string &label, uint32_t size,
const M3UI *classicui)
: size_(size) {
if (!safeImageSize(size, size)) {
return;
}
bool preferTextIcon =
!label.empty() &&
((icon == "input-keyboard" &&
@@ -576,7 +549,7 @@ ThemeImage::ThemeImage(const IconTheme &iconTheme, const std::string &icon,
if (!preferTextIcon && !icon.empty()) {
std::filesystem::path iconPath =
iconTheme.findIconPath(icon, size, 1, gdkPixbufSupportedFormats());
auto fd = StandardPaths::openPath(iconPath);
auto fd = StandardPaths::openPath(iconPath, O_RDONLY | O_NONBLOCK);
if (isSvgPath(iconPath)) {
if (auto svg = loadSvg(fd)) {
if (auto image = createImage(svg->pattern.get(), svg->width,
@@ -594,6 +567,9 @@ ThemeImage::ThemeImage(const IconTheme &iconTheme, const std::string &icon,
if (!valid()) {
Pixmap textImage(
cairo_image_surface_create(CAIRO_FORMAT_ARGB32, size, size));
if (cairo_surface_status(textImage.get()) != CAIRO_STATUS_SUCCESS) {
return;
}
drawTextIcon(textImage.get(), label, size, classicui->config());
if (auto image = createImage(std::move(textImage))) {
image_ = std::move(*image);
@@ -605,7 +581,7 @@ ThemeImage::ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg,
const Color &color, const Color &borderColor) {
if (!cfg.image->empty()) {
std::filesystem::path imagePath;
auto imageFile = StandardPaths::global().open(
auto imageFile = openImageFile(
StandardPathsType::PkgData,
std::filesystem::path("themes") / theme.name() / *cfg.image,
theme.isSystemTheme() ? StandardPathsMode::System
@@ -643,7 +619,7 @@ ThemeImage::ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg,
if (!cfg.overlay->empty()) {
std::filesystem::path imagePath;
auto imageFile = StandardPaths::global().open(
auto imageFile = openImageFile(
StandardPathsType::PkgData,
std::filesystem::path("themes") / theme.name() / *cfg.overlay,
theme.isSystemTheme() ? StandardPathsMode::System
@@ -741,7 +717,7 @@ ThemeImage::ThemeImage(const Theme &theme, const BackgroundImageConfig &cfg,
ThemeImage::ThemeImage(const Theme &theme, const ActionImageConfig &cfg) {
if (!cfg.image->empty()) {
std::filesystem::path imagePath;
auto imageFile = StandardPaths::global().open(
auto imageFile = openImageFile(
StandardPathsType::PkgData,
std::filesystem::path("themes") / theme.name() / *cfg.image,
theme.isSystemTheme() ? StandardPathsMode::System
+167
View File
@@ -0,0 +1,167 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <sys/stat.h>
#include <unistd.h>
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <random>
#include <string>
#include <vector>
#include "fcitx-utils/environ.h"
#include "fcitx-utils/log.h"
#include "fcitx-utils/standardpaths.h"
#include "fcitx/icontheme.h"
namespace fs = std::filesystem;
using fcitx::IconTheme;
using fcitx::setEnvironment;
using fcitx::StandardPaths;
using fcitx::StandardPathsOption;
void word(std::vector<uint8_t> &bytes, size_t offset, uint32_t value) {
for (int i = 3; i >= 0; --i) {
bytes.at(offset + 3 - i) = (value >> (8 * i)) & 255;
}
}
std::vector<uint8_t> validCache() {
std::vector<uint8_t> bytes(128);
bytes[1] = 1; // version 1.0
word(bytes, 4, 16); // hash table
word(bytes, 8, 48); // directory table
word(bytes, 16, 1); // buckets
word(bytes, 20, 24); // first node
word(bytes, 24, UINT32_MAX); // end of chain
word(bytes, 28, 64); // icon name
word(bytes, 32, 80); // image list
word(bytes, 48, 1);
word(bytes, 52, 96); // directory string
const std::string name = "audit-missing";
std::copy(name.begin(), name.end(), bytes.begin() + 64);
word(bytes, 80, 1); // one image in directory index zero
const std::string dir = "16x16/apps";
std::copy(dir.begin(), dir.end(), bytes.begin() + 96);
return bytes;
}
void save(const fs::path &path, const std::vector<uint8_t> &bytes) {
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out.write(reinterpret_cast<const char *>(bytes.data()), bytes.size());
out.close();
FCITX_ASSERT(out.good());
fs::last_write_time(path, fs::file_time_type::clock::now() +
std::chrono::seconds(10));
}
int main() {
auto pattern = (fs::temp_directory_path() / "fcitx-icon-cache-test-XXXXXX").string();
auto *temporary = mkdtemp(pattern.data());
FCITX_ASSERT(temporary);
const fs::path root(temporary);
// The fixture is private and all recursive cleanup is limited to this root.
struct Cleanup {
fs::path root;
~Cleanup() { fs::remove_all(root); }
} cleanup{root};
setEnvironment("HOME", (root / "home").c_str());
setEnvironment("XDG_DATA_HOME", (root / "data").c_str());
setEnvironment("XDG_DATA_DIRS", (root / "empty").c_str());
StandardPaths paths("fcitx5", {}, StandardPathsOption::SkipBuiltInPath);
const auto themeDir = root / "data/icons/audit";
fs::create_directories(themeDir / "16x16/apps");
std::ofstream(themeDir / "index.theme")
<< "[Icon Theme]\nName=Audit\nDirectories=16x16/apps\n"
"[16x16/apps]\nSize=16\nType=Fixed\n";
const auto icon = themeDir / "16x16/apps/audit-missing.png";
std::ofstream(icon) << "lookup only";
const auto cache = themeDir / "icon-theme.cache";
auto check = [&](const std::vector<uint8_t> &bytes) {
save(cache, bytes);
IconTheme theme(std::string("audit"), paths);
FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon);
FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon);
};
check(validCache());
auto bytes = validCache();
// Distinguish cache-assisted lookup from a parser that always ignores it.
word(bytes, 80, 0);
save(cache, bytes);
{
IconTheme theme(std::string("audit"), paths);
FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}).empty());
}
for (uint32_t offset : {UINT32_MAX, UINT32_MAX - 3, uint32_t(126)}) {
bytes = validCache();
word(bytes, 4, offset);
check(bytes);
}
bytes = validCache();
word(bytes, 16, UINT32_MAX); // overflowing hash table length
check(bytes);
bytes = validCache();
word(bytes, 8, UINT32_MAX - 3); // overflowing directory table offset
check(bytes);
bytes = validCache();
word(bytes, 24, 24); // self cycle on a nonmatching name
bytes[64] = 'x';
check(bytes);
bytes = validCache();
word(bytes, 24, 36); // two-node cycle
word(bytes, 36, 24);
word(bytes, 40, 64);
word(bytes, 44, 80);
bytes[64] = 'x';
check(bytes);
bytes = validCache();
word(bytes, 80, UINT32_MAX); // overflowing image count
check(bytes);
bytes = validCache();
bytes[85] = 2; // invalid directory index
check(bytes);
bytes = validCache();
bytes.resize(1200, 'x');
word(bytes, 28, 128); // no terminating NUL in 1024 bytes
check(bytes);
for (size_t length = 0; length < 128; ++length) {
bytes = validCache();
bytes.resize(length);
check(bytes);
}
// Deterministic mutation smoke test: no assumptions about whether each
// mutated cache is valid, but neither lookup may hang or access bad memory.
std::mt19937 random(0x1c0);
for (int i = 0; i < 512; ++i) {
bytes = validCache();
for (int j = 0; j < 4; ++j) {
bytes[random() % bytes.size()] = random() & 255;
}
save(cache, bytes);
IconTheme theme(std::string("audit"), paths);
theme.findIconPath("audit-missing", 16, 1, {".png"});
theme.findIconPath("audit-missing", 32, 2, {".png"});
}
save(cache, validCache());
{
IconTheme theme(std::string("audit"), paths);
std::ofstream(cache, std::ios::trunc).close();
FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon);
}
fs::remove(cache);
FCITX_ASSERT(mkfifo(cache.c_str(), 0600) == 0);
{
IconTheme theme(std::string("audit"), paths);
FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon);
}
fs::remove(cache);
// Oversized caches are ignored without loading their contents.
save(cache, validCache());
fs::resize_file(cache, 16 * 1024 * 1024 + 1);
{
IconTheme theme(std::string("audit"), paths);
FCITX_ASSERT(theme.findIconPath("audit-missing", 16, 1, {".png"}) == icon);
}
return 0;
}
+63
View File
@@ -0,0 +1,63 @@
#include <memory>
#include <vector>
#include "fcitx-utils/log.h"
#include "fcitx/candidatelist.h"
#include "../src/candidatemenu.h"
using namespace fcitx;
using namespace fcitx::m3ui;
namespace {
class EngineActions : public ActionableCandidateList {
public:
explicit EngineActions(bool available) : available_(available) {}
bool hasAction(const CandidateWord &) const override { return available_; }
std::vector<CandidateAction>
candidateActions(const CandidateWord &) const override {
CandidateAction action;
action.setId(-42); // IDs must not be interpreted by the UI.
action.setText("Engine action");
CandidateAction separator;
separator.setSeparator(true);
return {separator, action};
}
void triggerAction(const CandidateWord &, int) override {}
private:
bool available_;
};
} // namespace
int main() {
CommonCandidateList list;
list.setPageSize(5);
list.append<DisplayOnlyCandidateWord>(Text("候选词"));
list.append<DisplayOnlyCandidateWord>(Text("保留的词"));
// Never invent a blocking action when ranking operations are unavailable.
auto menu = candidateMenu(list, list.candidate(0));
FCITX_ASSERT(menu.actions.empty());
// An engine can have an action interface but no actions for this word.
list.setActionableImpl(std::make_unique<EngineActions>(false));
menu = candidateMenu(list, list.candidate(0));
FCITX_ASSERT(menu.actions.empty());
// Keep engine actions and IDs intact; omit separators, never append blocking.
list.setActionableImpl(std::make_unique<EngineActions>(true));
menu = candidateMenu(list, list.candidate(0));
FCITX_ASSERT(menu.actions.size() == 1);
FCITX_ASSERT(menu.actions[0].id() == -42);
FCITX_ASSERT(menu.actions[0].text() == "Engine action");
FCITX_ASSERT(list.size() == 2);
FCITX_ASSERT(list.candidate(0).text().toString() == "候选词");
// Do not offer an ineffective block action for a read-only list.
DisplayOnlyCandidateList readOnly;
readOnly.setContent(std::vector<std::string>{"只读"});
menu = candidateMenu(readOnly, readOnly.candidate(0));
FCITX_ASSERT(menu.actions.empty());
}
+77
View File
@@ -0,0 +1,77 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <cstdint>
#include <string>
#include <vector>
#include "fcitx-utils/log.h"
#include "../src/imageloader.h"
using namespace fcitx::m3ui;
int main() {
FCITX_ASSERT(!loadRasterImage({}));
FCITX_ASSERT(!loadSvgImage({}));
FCITX_ASSERT(!loadRasterImage({'b', 'a', 'd'}));
FCITX_ASSERT(!loadSvgImage({'b', 'a', 'd'}));
// Valid PNG bytes exercise the real incremental decoder, regardless of
// extension. The reference must remain valid after loader destruction.
LoadedSurface surface(cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 16, 16));
std::vector<uint8_t> png;
FCITX_ASSERT(cairo_surface_write_to_png_stream(
surface.get(),
+[](void *data, const unsigned char *bytes, unsigned int length) {
auto &out = *static_cast<std::vector<uint8_t> *>(data);
out.insert(out.end(), bytes, bytes + length);
return CAIRO_STATUS_SUCCESS;
},
&png) == CAIRO_STATUS_SUCCESS);
auto image = loadRasterImage(png);
FCITX_ASSERT(image);
FCITX_ASSERT(gdk_pixbuf_get_width(image.get()) == 16);
FCITX_ASSERT(gdk_pixbuf_get_height(image.get()) == 16);
png[16] = 255; // Huge IHDR, rejected before entering libpng.
FCITX_ASSERT(!loadRasterImage(png));
png.resize(23);
FCITX_ASSERT(!loadRasterImage(png));
// A real non-PNG image exercises size-prepared rather than PNG preflight.
gchar *bmpData = nullptr;
gsize bmpSize = 0;
FCITX_ASSERT(gdk_pixbuf_save_to_buffer(image.get(), &bmpData, &bmpSize,
"bmp", nullptr, nullptr));
std::vector<uint8_t> bmp(bmpData, bmpData + bmpSize);
g_free(bmpData);
FCITX_ASSERT(loadRasterImage(bmp));
// BMP BITMAPINFOHEADER width is little-endian at byte 18.
FCITX_ASSERT(bmp.size() > 26 && bmp[14] >= 40);
bmp[18] = 1;
bmp[19] = 32; // 8193 pixels wide
bmp[20] = bmp[21] = 0;
FCITX_ASSERT(!loadRasterImage(bmp));
auto svg = [](const std::string &text) {
return loadSvgImage(std::vector<uint8_t>(text.begin(), text.end()));
};
auto rendered = svg("<svg xmlns='http://www.w3.org/2000/svg' width='16' "
"height='16'><rect width='16' height='16' "
"fill='red'/></svg>");
FCITX_ASSERT(rendered);
FCITX_ASSERT(cairo_surface_get_type(rendered.get()) == CAIRO_SURFACE_TYPE_IMAGE);
FCITX_ASSERT(cairo_image_surface_get_width(rendered.get()) == 16);
FCITX_ASSERT(cairo_image_surface_get_height(rendered.get()) == 16);
cairo_surface_flush(rendered.get());
const auto *pixel = reinterpret_cast<const uint32_t *>(
cairo_image_surface_get_data(rendered.get()));
FCITX_ASSERT(pixel[0] == 0xffff0000); // Native-endian Cairo ARGB32.
FCITX_ASSERT(!svg("<svg xmlns='http://www.w3.org/2000/svg' "
"width='1e30' height='16'/>"));
FCITX_ASSERT(!svg("<svg xmlns='http://www.w3.org/2000/svg' "
"width='8192' height='8192'/>"));
FCITX_ASSERT(!svg("<svg xmlns='http://www.w3.org/2000/svg' "
"width='0' height='16'/>"));
FCITX_ASSERT(!svg("<svg xmlns='http://www.w3.org/2000/svg'"));
std::vector<uint8_t> oversized(16 * 1024 * 1024 + 1);
FCITX_ASSERT(!loadRasterImage(oversized));
FCITX_ASSERT(!loadSvgImage(oversized));
}
+63
View File
@@ -0,0 +1,63 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <fcntl.h>
#include <unistd.h>
#include <cstdlib>
#include <filesystem>
#include <limits>
#include "fcitx-utils/log.h"
#include "../src/resourceguard.h"
using namespace fcitx::m3ui;
int main() {
FCITX_ASSERT(safeImageSize(1, 1));
FCITX_ASSERT(safeImageSize(4096, 4096));
FCITX_ASSERT(safeImageSize(8192, 128));
FCITX_ASSERT(!safeImageSize(0, 16));
FCITX_ASSERT(!safeImageSize(-1, 16));
FCITX_ASSERT(!safeImageSize(8193, 16));
FCITX_ASSERT(!safeImageSize(8192, 8192));
FCITX_ASSERT(!safeImageSize(4096.1, 4096));
FCITX_ASSERT(!safeImageSize(std::numeric_limits<double>::infinity(), 16));
FCITX_ASSERT(!safeImageSize(std::numeric_limits<double>::quiet_NaN(), 16));
FCITX_ASSERT(safeImageMargins(16, 16, 0, 0, 0, 0));
FCITX_ASSERT(safeImageMargins(16, 16, 8, 8, 8, 8));
FCITX_ASSERT(!safeImageMargins(16, 16, 9, 8, 8, 8));
FCITX_ASSERT(!safeImageMargins(16, 16, -1, 0, 0, 0));
FCITX_ASSERT(!safeImageMargins(0, 16, 0, 0, 0, 0));
FCITX_ASSERT(!safeImageMargins(16, 16,
std::numeric_limits<int>::max(), 0, 1, 0));
std::vector<uint8_t> png = {137, 80, 78, 71, 13, 10, 26, 10,
0, 0, 0, 13, 'I', 'H', 'D', 'R',
0, 0, 0, 16, 0, 0, 0, 16};
FCITX_ASSERT(safePngSize(png));
png[16] = 255;
FCITX_ASSERT(!safePngSize(png));
png[16] = 0;
png[19] = 0;
FCITX_ASSERT(!safePngSize(png));
png.resize(23);
FCITX_ASSERT(!safePngSize(png));
FCITX_ASSERT(!readImageSnapshot(fcitx::UnixFD{}));
auto name = (std::filesystem::temp_directory_path() /
"fcitx-image-test-XXXXXX").string();
fcitx::UnixFD file = fcitx::UnixFD::own(mkstemp(name.data()));
FCITX_ASSERT(file.isValid());
// Unlink immediately; the descriptor owns this isolated fixture.
FCITX_ASSERT(unlink(name.c_str()) == 0);
FCITX_ASSERT(!readImageSnapshot(file));
FCITX_ASSERT(write(file.fd(), "image", 5) == 5);
FCITX_ASSERT(lseek(file.fd(), 0, SEEK_SET) == 0);
auto bytes = readImageSnapshot(file);
FCITX_ASSERT(bytes && bytes->size() == 5);
// A short read (e.g. concurrent truncation) is rejected.
FCITX_ASSERT(lseek(file.fd(), 3, SEEK_SET) == 3);
FCITX_ASSERT(!readImageSnapshot(file));
FCITX_ASSERT(ftruncate(file.fd(), maxImageFileSize + 1) == 0);
FCITX_ASSERT(!readImageSnapshot(file));
int fds[2];
FCITX_ASSERT(pipe(fds) == 0);
fcitx::UnixFD readEnd = fcitx::UnixFD::own(fds[0]);
fcitx::UnixFD writeEnd = fcitx::UnixFD::own(fds[1]);
FCITX_ASSERT(!readImageSnapshot(readEnd));
}