├── assets
├── Base.lproj
├── fcitx.icns
├── zh-Hans.lproj
│ ├── Localizable.strings
│ └── InfoPlist.strings
├── zh-Hant.lproj
│ ├── Localizable.strings
│ └── InfoPlist.strings
├── README.md
├── en.lproj
│ └── InfoPlist.strings
├── core.entitlements
├── get_im.swift
├── switch_im.swift
├── uninstall.sh
├── penguin-15.svg
├── penguin-26.svg
├── po
│ └── CMakeLists.txt
├── fcitx5-curl
├── update.sh
└── CMakeLists.txt
├── logging
├── debug.swift
├── release.swift
├── CMakeLists.txt
└── logging.swift
├── keycode
├── module.modulemap
├── CMakeLists.txt
└── keycode-public.h
├── macosfrontend
├── module.modulemap
├── macosfrontend.conf.in.in
├── pasteboard.mm
├── macosfrontend-public.h
├── CMakeLists.txt
├── macosfrontend.swift
└── macosfrontend.h
├── macosnotifications
├── module.modulemap
├── notifications.conf.in.in
├── macosnotifications-public.h
├── CMakeLists.txt
├── macosnotifications.h
├── macosnotifications.cpp
└── notify.swift
├── src
├── module.modulemap
├── config
│ ├── meta.swift.in
│ ├── tag.swift
│ ├── config.h
│ ├── globalconfig.swift
│ ├── themeeditor.swift
│ ├── userthemeoptionview.swift
│ ├── cssoptionview.swift
│ ├── keycode.swift
│ ├── thememanager.swift
│ ├── vimmodeoptionview.swift
│ ├── config-public.h
│ ├── sudo.swift
│ ├── pluginoptionview.swift
│ ├── imageoptionview.swift
│ ├── xmlparser.swift
│ ├── appimoptionview.swift
│ ├── downloader.swift
│ ├── navigationsplit.swift
│ ├── importtable.swift
│ ├── officialplugins.swift
│ ├── advanced.swift
│ ├── FontView.swift
│ ├── keyrecorder.swift
│ ├── menu.swift
│ ├── ui.swift
│ ├── installer.swift
│ ├── dictmanager.swift
│ ├── util.swift
│ └── customphrase.swift
├── tunnel.h
├── tunnel.cpp
├── secure.swift
├── color.swift
├── locale.swift
├── fcitx-public.h
├── CMakeLists.txt
├── fcitx.h
├── server.swift
└── nativestreambuf.h
├── scripts
├── code-sign.sh
├── format.sh
├── generate-version.py
├── check-code-style.sh
├── lint.sh
├── install-deps.sh
├── common.py
├── check-validity.sh
├── prepare-release.py
└── update_translations.py
├── checksum
├── .vscode
├── extensions.json
├── settings.json
└── tasks.json
├── webpanel
├── webpanel.conf.in.in
├── tunnel.cpp
└── CMakeLists.txt
├── cache
└── README.md
├── .gitignore
├── version.jsonl
├── README.zh-CN.md
├── cmake
├── AddressSanitizer.cmake
├── AddSwift.cmake
├── MacOSXBundleInfo.plist.in
└── InitializeSwift.cmake
├── deps
└── CMakeLists.txt
├── tests
├── testxmlparser.swift
├── customphrase.plist
├── testcolor.swift
├── testkey.swift
├── CMakeLists.txt
├── testconfig.cpp
├── testtag.swift
├── testkey.cpp
└── testconfig.swift
├── .gitmodules
├── docs
└── release.zh-CN.md
├── .github
└── workflows
│ ├── compare.yml
│ └── ci.yml
├── .swift-format.json
├── README.md
└── CMakeLists.txt
/assets/Base.lproj:
--------------------------------------------------------------------------------
1 | en.lproj
--------------------------------------------------------------------------------
/logging/debug.swift:
--------------------------------------------------------------------------------
1 | public let isDebug = true
2 |
--------------------------------------------------------------------------------
/logging/release.swift:
--------------------------------------------------------------------------------
1 | public let isDebug = false
2 |
--------------------------------------------------------------------------------
/keycode/module.modulemap:
--------------------------------------------------------------------------------
1 | module Keycode {
2 | header "keycode-public.h"
3 | }
4 |
--------------------------------------------------------------------------------
/assets/fcitx.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fcitx-contrib/fcitx5-macos/HEAD/assets/fcitx.icns
--------------------------------------------------------------------------------
/macosfrontend/module.modulemap:
--------------------------------------------------------------------------------
1 | module CxxFrontend {
2 | header "macosfrontend-public.h"
3 | export *
4 | }
5 |
--------------------------------------------------------------------------------
/macosnotifications/module.modulemap:
--------------------------------------------------------------------------------
1 | module CxxNotify {
2 | header "macosnotifications-public.h"
3 | export *
4 | }
5 |
--------------------------------------------------------------------------------
/src/module.modulemap:
--------------------------------------------------------------------------------
1 | module Fcitx {
2 | header "fcitx-public.h"
3 | header "config/config-public.h"
4 | export *
5 | }
6 |
--------------------------------------------------------------------------------
/assets/zh-Hans.lproj/Localizable.strings:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fcitx-contrib/fcitx5-macos/HEAD/assets/zh-Hans.lproj/Localizable.strings
--------------------------------------------------------------------------------
/assets/zh-Hant.lproj/Localizable.strings:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fcitx-contrib/fcitx5-macos/HEAD/assets/zh-Hant.lproj/Localizable.strings
--------------------------------------------------------------------------------
/scripts/code-sign.sh:
--------------------------------------------------------------------------------
1 | sudo /usr/bin/codesign --force --sign - --entitlements assets/core.entitlements --deep /Library/Input\ Methods/Fcitx5.app
2 |
--------------------------------------------------------------------------------
/src/config/meta.swift.in:
--------------------------------------------------------------------------------
1 | let commit = "@COMMIT@"
2 | // swift-format-ignore
3 | let unixTime = @UNIX_TIME@
4 | let releaseTag = "@RELEASE_TAG@"
5 |
--------------------------------------------------------------------------------
/src/tunnel.h:
--------------------------------------------------------------------------------
1 | #pragma once
2 |
3 | extern bool f5m_is_linear_layout;
4 | extern bool f5m_is_vertical_lr;
5 | extern bool f5m_is_vertical_rl;
6 |
--------------------------------------------------------------------------------
/checksum:
--------------------------------------------------------------------------------
1 | a8649eaeb642417ae3beed3ad5c9885c build/arm64/assets/menu_icon_15.pdf
2 | b023a2b068e7dae81e9c06c7f0132db0 build/arm64/assets/menu_icon_26.pdf
3 |
--------------------------------------------------------------------------------
/src/tunnel.cpp:
--------------------------------------------------------------------------------
1 | #include "tunnel.h"
2 |
3 | bool f5m_is_linear_layout = false;
4 | bool f5m_is_vertical_rl = false;
5 | bool f5m_is_vertical_lr = false;
6 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "llvm-vs-code-extensions.vscode-clangd",
4 | "swiftlang.swift-vscode"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/webpanel/webpanel.conf.in.in:
--------------------------------------------------------------------------------
1 | [Addon]
2 | Name=WebPanel
3 | Type=StaticLibrary
4 | Library=libwebpanel
5 | Category=UI
6 | Version=@PROJECT_VERSION@
7 | Configurable=True
8 |
--------------------------------------------------------------------------------
/keycode/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | add_library(Keycode STATIC keycode.cpp)
2 | target_include_directories(Keycode PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")
3 | target_link_libraries(Keycode Fcitx5::Utils)
4 |
--------------------------------------------------------------------------------
/macosfrontend/macosfrontend.conf.in.in:
--------------------------------------------------------------------------------
1 | [Addon]
2 | Name=macOS Frontend
3 | Type=StaticLibrary
4 | Library=libmacosfrontend
5 | Category=Frontend
6 | Version=@PROJECT_VERSION@
7 | Configurable=True
8 |
--------------------------------------------------------------------------------
/macosnotifications/notifications.conf.in.in:
--------------------------------------------------------------------------------
1 | [Addon]
2 | Name=macOS Notification
3 | Type=StaticLibrary
4 | Library=libmacosnotifications
5 | Category=Module
6 | Version=@PROJECT_VERSION@
7 | Configurable=True
8 |
--------------------------------------------------------------------------------
/assets/README.md:
--------------------------------------------------------------------------------
1 | This directory contains files necessary to generate Fcitx5.app.
2 |
3 | [fcitx.icns](./fcitx.icns) is generated from the official [logo](../fcitx5/data/icon/scalable/apps/org.fcitx.Fcitx5.svg).
4 |
--------------------------------------------------------------------------------
/assets/zh-Hans.lproj/InfoPlist.strings:
--------------------------------------------------------------------------------
1 | CFBundleName = "小企鹅";
2 | CFBundleDisplayName = "小企鹅";
3 | org.fcitx.inputmethod.fcitx5 = "小企鹅";
4 | org.fcitx.inputmethod.zhHans = "小企鹅";
5 | org.fcitx.inputmethod.zhHant = "小企鹅";
6 |
--------------------------------------------------------------------------------
/assets/zh-Hant.lproj/InfoPlist.strings:
--------------------------------------------------------------------------------
1 | CFBundleName = "小企鵝";
2 | CFBundleDisplayName = "小企鵝";
3 | org.fcitx.inputmethod.fcitx5 = "小企鵝";
4 | org.fcitx.inputmethod.zhHans = "小企鵝";
5 | org.fcitx.inputmethod.zhHant = "小企鵝";
6 |
--------------------------------------------------------------------------------
/cache/README.md:
--------------------------------------------------------------------------------
1 | This directory caches tarball of build dependencies
2 | (downloaded from [prebuilder](https://github.com/fcitx-contrib/fcitx5-prebuilder/releases/macos)),
3 | which will be extracted to `build/ARCH/usr`.
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | build
3 | *.dmg
4 | *.tar.bz2
5 | .cache
6 |
7 | # Auto-generated data
8 | assets/en.lproj/Localizable.strings
9 | assets/po/base.pot
10 | meta.swift
11 | *~
12 | version.json
13 | *.pyc
14 |
--------------------------------------------------------------------------------
/assets/en.lproj/InfoPlist.strings:
--------------------------------------------------------------------------------
1 | CFBundleName = "Fcitx5";
2 | CFBundleDisplayName = "Fcitx5";
3 | org.fcitx.inputmethod.fcitx5 = "Fcitx5";
4 | org.fcitx.inputmethod.zhHans = "Fcitx5";
5 | org.fcitx.inputmethod.zhHant = "Fcitx5";
6 |
--------------------------------------------------------------------------------
/version.jsonl:
--------------------------------------------------------------------------------
1 | {"tag": "0.2.9", "macos": "13.3", "sha": "afac12a11a06b84c406baf69747006554360e7d3", "time": 1763818582}
2 | {"tag": "0.1.0", "macos": "13", "sha": "212113ab391eb6ec45bd881a22e7f1595834986a", "time": 1740952697}
3 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "clangd.arguments": [
3 | "-background-index",
4 | "-compile-commands-dir=build/arm64"
5 | ],
6 | "swift.sourcekit-lsp.supported-languages": [
7 | "swift"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/README.zh-CN.md:
--------------------------------------------------------------------------------
1 | [English](README.md)
2 | |
3 | 中文
4 |
5 | # 小企鹅输入法 macOS 版
6 |
7 | [Fcitx5](https://github.com/fcitx/fcitx5) 输入框架的 macOS 移植。
8 |
9 | 请下载[安装器](https://github.com/fcitx-contrib/fcitx5-macos-installer/blob/master/README.zh-CN.md)。
10 |
--------------------------------------------------------------------------------
/webpanel/tunnel.cpp:
--------------------------------------------------------------------------------
1 | // Weak definitions for tunnel variables used in webpanel.
2 | __attribute__((weak)) bool f5m_is_linear_layout = false;
3 | __attribute__((weak)) bool f5m_is_vertical_rl = false;
4 | __attribute__((weak)) bool f5m_is_vertical_lr = false;
5 |
--------------------------------------------------------------------------------
/logging/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | set(LOGGING_SRCS logging.swift)
2 | if(CMAKE_BUILD_TYPE MATCHES "Debug")
3 | list(APPEND LOGGING_SRCS debug.swift)
4 | else()
5 | list(APPEND LOGGING_SRCS release.swift)
6 | endif()
7 | add_library(Logging STATIC ${LOGGING_SRCS})
8 |
--------------------------------------------------------------------------------
/assets/core.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.get-task-allow
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/cmake/AddressSanitizer.cmake:
--------------------------------------------------------------------------------
1 | add_compile_options("$<$:-fsanitize=address>")
2 | add_link_options("$<$:-fsanitize=address>")
3 |
4 | add_compile_options("$<$:-sanitize=address>")
5 | add_link_options("$<$:-sanitize=address>")
6 |
--------------------------------------------------------------------------------
/scripts/format.sh:
--------------------------------------------------------------------------------
1 | find macosfrontend macosnotifications webpanel src tests -name '*.cpp' -o -name '*.h' | xargs clang-format -i -style=file:fcitx5/.clang-format
2 | clang-format -i macosfrontend/pasteboard.mm
3 | swift-format format --configuration .swift-format.json --in-place $(find macosfrontend macosnotifications src assets -name '*.swift')
4 |
--------------------------------------------------------------------------------
/deps/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | add_library(SwiftyJSON STATIC "${CMAKE_CURRENT_SOURCE_DIR}/SwiftyJSON/Source/SwiftyJSON/SwiftyJSON.swift")
2 | set_target_properties(SwiftyJSON PROPERTIES Swift_MODULE_NAME SwiftyJSON)
3 | target_include_directories(SwiftyJSON PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/SwiftyJSON/Source/SwiftyJSON")
4 |
5 | add_subdirectory(AlertToast)
6 |
7 | add_subdirectory(url-filter)
8 |
--------------------------------------------------------------------------------
/src/config/tag.swift:
--------------------------------------------------------------------------------
1 | func getTag(currentDebug: Bool, targetDebug: Bool, latestAvailable: Bool, targetTag: String?)
2 | -> String?
3 | {
4 | if targetDebug && latestAvailable {
5 | return "latest"
6 | }
7 | if targetTag != nil {
8 | return targetTag
9 | }
10 | if currentDebug && !targetDebug && latestAvailable {
11 | return "latest"
12 | }
13 | return nil
14 | }
15 |
--------------------------------------------------------------------------------
/assets/get_im.swift:
--------------------------------------------------------------------------------
1 | import Carbon
2 |
3 | let enId = "org.fcitx.inputmethod.Fcitx5.fcitx5"
4 |
5 | let inputSource = TISCopyCurrentKeyboardInputSource().takeRetainedValue()
6 | let id = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID)
7 | if let id = id {
8 | print(Unmanaged.fromOpaque(id).takeUnretainedValue() as? String ?? enId)
9 | } else {
10 | print(enId)
11 | }
12 |
--------------------------------------------------------------------------------
/scripts/generate-version.py:
--------------------------------------------------------------------------------
1 | import json
2 | from common import get_json
3 |
4 | versions = []
5 |
6 | with open('version.jsonl') as f:
7 | while line := f.readline():
8 | versions.append(json.loads(line))
9 |
10 | # Generate sha and time for tag latest.
11 | latest = get_json('latest')
12 |
13 | with open('version.json', 'w') as f:
14 | json.dump({
15 | 'versions': [latest] + versions
16 | }, f)
17 |
--------------------------------------------------------------------------------
/keycode/keycode-public.h:
--------------------------------------------------------------------------------
1 | #pragma once
2 |
3 | #include
4 | #include
5 |
6 | std::string osx_key_to_fcitx_string(uint32_t unicode, uint32_t modifiers,
7 | uint16_t code) noexcept;
8 | std::string fcitx_string_to_osx_keysym(const char *) noexcept;
9 | uint32_t fcitx_string_to_osx_modifiers(const char *) noexcept;
10 | uint16_t fcitx_string_to_osx_keycode(const char *) noexcept;
11 |
--------------------------------------------------------------------------------
/scripts/check-code-style.sh:
--------------------------------------------------------------------------------
1 | set -e
2 |
3 | for file in $(git ls-files | grep '\.swift$'); do
4 | if grep 'NSLog(' $file; then
5 | echo "Please use Logging module instead of NSLog"
6 | exit 1
7 | fi
8 | if grep 'ScrollView.*\.horizontal' $file; then
9 | echo "Please don't use horizontal scroll"
10 | exit 1
11 | fi
12 | if grep 'LocalizedStringKey(' $file; then
13 | echo "Please use NSLocalizedString instead of LocalizedStringKey"
14 | exit 1
15 | fi
16 | done
17 |
--------------------------------------------------------------------------------
/webpanel/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | add_library(webpanel STATIC webpanel.cpp tunnel.cpp)
2 | target_link_libraries(webpanel Fcitx5::Core)
3 | target_include_directories(webpanel PRIVATE "${PROJECT_SOURCE_DIR}/src")
4 |
5 | configure_file(webpanel.conf.in.in webpanel.conf.in @ONLY)
6 | fcitx5_translate_desktop_file(${CMAKE_CURRENT_BINARY_DIR}/webpanel.conf.in webpanel.conf)
7 | install(FILES "${CMAKE_CURRENT_BINARY_DIR}/webpanel.conf"
8 | DESTINATION "${CMAKE_INSTALL_PREFIX}/share/fcitx5/addon"
9 | )
10 |
--------------------------------------------------------------------------------
/assets/switch_im.swift:
--------------------------------------------------------------------------------
1 | import Carbon
2 |
3 | let arguments = CommandLine.arguments
4 |
5 | if arguments.count < 2 {
6 | exit(1)
7 | }
8 |
9 | let im = arguments[1]
10 |
11 | let conditions = NSMutableDictionary()
12 | conditions.setValue(im, forKey: kTISPropertyInputSourceID as String)
13 | if let array = TISCreateInputSourceList(conditions, true)?.takeRetainedValue()
14 | as? [TISInputSource]
15 | {
16 | for inputSource in array {
17 | TISSelectInputSource(inputSource)
18 | exit(0)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/assets/uninstall.sh:
--------------------------------------------------------------------------------
1 | #!/bin/zsh
2 | set -xeu
3 |
4 | user="$1"
5 | remove_user_data="$2"
6 | APP_DIR="/Library/Input Methods/Fcitx5.app"
7 | DATA_DIR="/Users/$user/Library/fcitx5"
8 | CONFIG_DIR="/Users/$user/.config/fcitx5"
9 | LOCAL_DIR="/Users/$user/.local/share/fcitx5"
10 |
11 | rm -rf "$APP_DIR"
12 | rm -rf "$DATA_DIR"
13 | rm -rf "$CONFIG_DIR"
14 |
15 | if [ "$remove_user_data" = "true" ]; then
16 | rm -rf "$LOCAL_DIR"
17 | fi
18 |
19 | # sigterm will save user data but we just removed
20 | killall -9 Fcitx5
21 |
--------------------------------------------------------------------------------
/assets/penguin-15.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/assets/penguin-26.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/scripts/lint.sh:
--------------------------------------------------------------------------------
1 | set -e
2 |
3 | find macosfrontend macosnotifications webpanel src tests -name '*.cpp' -o -name '*.h' | xargs clang-format -Werror --dry-run -style=file:fcitx5/.clang-format
4 | clang-format -Werror --dry-run macosfrontend/pasteboard.mm
5 | swift-format lint --configuration .swift-format.json -rs macosfrontend macosnotifications src assets
6 | ./scripts/check-code-style.sh
7 |
8 | localizables=$(find assets -name 'Localizable.strings')
9 | for localizable in $localizables; do
10 | file $localizable | grep UTF-16
11 | done
12 |
--------------------------------------------------------------------------------
/src/config/config.h:
--------------------------------------------------------------------------------
1 | #pragma once
2 |
3 | #include
4 | #include
5 |
6 | #include "config-public.h"
7 |
8 | constexpr char globalConfigPath[] = "fcitx://config/global";
9 | constexpr char addonConfigPrefix[] = "fcitx://config/addon/";
10 | constexpr char imConfigPrefix[] = "fcitx://config/inputmethod/";
11 |
12 | /// Convert configuration into a json object.
13 | nlohmann::json configToJson(const fcitx::Configuration &config);
14 |
15 | nlohmann::json configValueToJson(const fcitx::Configuration &config);
16 |
--------------------------------------------------------------------------------
/scripts/install-deps.sh:
--------------------------------------------------------------------------------
1 | set -e
2 |
3 | if [[ -z $1 ]]; then
4 | ARCH=`uname -m`
5 | else
6 | ARCH=$1
7 | fi
8 |
9 | EXTRACT_DIR=build/$ARCH/usr
10 | mkdir -p $EXTRACT_DIR
11 |
12 | deps=(
13 | default-icon-theme
14 | boost
15 | libexpat
16 | libintl
17 | json
18 | libuv
19 | libxkbcommon
20 | iso-codes
21 | xkeyboard-config
22 | )
23 |
24 | for dep in "${deps[@]}"; do
25 | file=$dep-$ARCH.tar.bz2
26 | [[ -f cache/$file ]] || wget -P cache https://github.com/fcitx-contrib/fcitx5-prebuilder/releases/download/macos/$file
27 | tar xjvf cache/$file -C $EXTRACT_DIR
28 | done
29 |
--------------------------------------------------------------------------------
/tests/testxmlparser.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | @_cdecl("main")
4 | func main() -> Int {
5 | if CommandLine.argc != 2 {
6 | print("Usage: \(CommandLine.arguments[0]) <.plist file>")
7 | return 1
8 | }
9 | let url = URL(fileURLWithPath: CommandLine.arguments[1])
10 | let expected = [(shortcut: "msd", phrase: "马上到!"), (shortcut: "omw", phrase: "On my way!")]
11 | let actual = parseCustomPhraseXML(url)
12 | assert(actual.count == expected.count)
13 | for i in 0..
3 |
4 | static int lastChangeCount = 0;
5 | static NSPasteboardType passwordType = @"org.nspasteboard.ConcealedType";
6 |
7 | namespace fcitx {
8 | std::string getPasteboardString(bool *isPassword) {
9 | NSPasteboard *pasteboard = [NSPasteboard generalPasteboard];
10 | if (pasteboard.changeCount == lastChangeCount) {
11 | return "";
12 | }
13 | lastChangeCount = (int)pasteboard.changeCount;
14 | NSString *stringData = [pasteboard stringForType:NSPasteboardTypeString];
15 | if (stringData) {
16 | *isPassword = ([pasteboard stringForType:passwordType] != nil);
17 | return std::string([stringData UTF8String]);
18 | }
19 | return "";
20 | }
21 | } // namespace fcitx
22 |
--------------------------------------------------------------------------------
/src/config/userthemeoptionview.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UniformTypeIdentifiers
3 |
4 | private let themeDir = localDir.appendingPathComponent("theme")
5 |
6 | struct UserThemeOptionView: OptionView {
7 | let label: String
8 | @ObservedObject var model: UserThemeOption
9 |
10 | var body: some View {
11 | SelectFileButton(
12 | directory: themeDir,
13 | allowedContentTypes: [UTType.init(filenameExtension: "conf")!],
14 | onFinish: { fileName in
15 | model.value = String(fileName.dropLast(5))
16 | },
17 | label: {
18 | if model.value.isEmpty {
19 | Text("Select/Import theme")
20 | } else {
21 | Text(model.value)
22 | }
23 | }, model: $model.value
24 | )
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/macosfrontend/macosfrontend-public.h:
--------------------------------------------------------------------------------
1 | #pragma once
2 |
3 | #include
4 | #include
5 |
6 | // Identical to fcitx::ICUUID. Replicated for Swift interop.
7 | typedef std::array ICUUID;
8 |
9 | // Though being UInt, 32b is enough for modifiers
10 | std::string process_key(ICUUID uuid, uint32_t unicode, uint32_t osxModifiers,
11 | uint16_t osxKeycode, bool isRelease,
12 | bool isPassword) noexcept;
13 |
14 | ICUUID create_input_context(const char *appId, id client,
15 | const char *accentColor) noexcept;
16 | void destroy_input_context(ICUUID uuid) noexcept;
17 | void focus_in(ICUUID uuid, bool isPassword) noexcept;
18 | std::string commit_composition(ICUUID uuid) noexcept;
19 | void focus_out(ICUUID uuid) noexcept;
20 |
--------------------------------------------------------------------------------
/logging/logging.swift:
--------------------------------------------------------------------------------
1 | import OSLog
2 |
3 | let logger = Logger(subsystem: "org.fcitx.inputmethod.Fcitx5", category: "FcitxLog")
4 |
5 | public func FCITX_DEBUG(_ message: String) {
6 | if isDebug {
7 | logger.debug("\(message, privacy: .public)")
8 | fputs(message + "\n", stderr)
9 | }
10 | }
11 |
12 | public func FCITX_INFO(_ message: String) {
13 | if isDebug {
14 | logger.info("\(message, privacy: .public)")
15 | }
16 | fputs(message + "\n", stderr)
17 | }
18 |
19 | public func FCITX_WARN(_ message: String) {
20 | if isDebug {
21 | logger.error("\(message, privacy: .public)")
22 | }
23 | fputs(message + "\n", stderr)
24 | }
25 |
26 | public func FCITX_ERROR(_ message: String) {
27 | if isDebug {
28 | logger.fault("\(message, privacy: .public)")
29 | }
30 | fputs(message + "\n", stderr)
31 | }
32 |
--------------------------------------------------------------------------------
/scripts/check-validity.sh:
--------------------------------------------------------------------------------
1 | set -e
2 |
3 | has_homebrew_deps=0
4 | has_xcode_rpath=0
5 | has_extra_dylib=0
6 |
7 | cd /Library/Input\ Methods/Fcitx5.app/Contents
8 | libs=(MacOS/Fcitx5)
9 | libs+=($(ls lib/libFcitx5{Config,Core,Utils}.dylib))
10 | libs+=($(ls lib/fcitx5/*.so))
11 | libs+=(lib/fcitx5/libexec/comp-spell-dict)
12 |
13 | for lib in "${libs[@]}"; do
14 | if otool -L $lib | grep '/usr/local\|/opt/homebrew'; then
15 | otool -L $lib
16 | has_homebrew_deps=1
17 | fi
18 | if otool -l $lib | grep -A2 LC_RPATH | grep Xcode; then
19 | otool -l $lib | grep -A2 LC_RPATH
20 | has_xcode_rpath=2
21 | fi
22 | n_dylib=$(otool -L MacOS/Fcitx5 | grep rpath | wc -l | xargs)
23 | if [[ $n_dylib != 3 ]]; then
24 | has_extra_dylib=4
25 | fi
26 | done
27 |
28 | exit $((has_homebrew_deps + has_xcode_rpath + has_extra_dylib))
29 |
--------------------------------------------------------------------------------
/src/config/cssoptionview.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UniformTypeIdentifiers
3 |
4 | private let cssDir = wwwDir.appendingPathComponent("css")
5 | private let fcitxPrefix = "fcitx:///file/css/"
6 |
7 | struct CssOptionView: OptionView {
8 | let label: String
9 | @ObservedObject var model: CssOption
10 |
11 | var body: some View {
12 | SelectFileButton(
13 | directory: cssDir,
14 | allowedContentTypes: [UTType.init(filenameExtension: "css")!],
15 | onFinish: { fileName in
16 | if !fileName.isEmpty {
17 | model.value = fcitxPrefix + fileName
18 | }
19 | },
20 | label: {
21 | if !model.value.hasPrefix(fcitxPrefix) {
22 | Text("Select/Import CSS")
23 | } else {
24 | Text(model.value.dropFirst(fcitxPrefix.count))
25 | }
26 | }, model: $model.value
27 | )
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/config/keycode.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 | import Keycode
3 |
4 | func keyToUnicode(_ key: String) -> UInt32 {
5 | if key.isEmpty {
6 | return 0
7 | }
8 | let usv = key.unicodeScalars
9 | return usv[usv.startIndex].value
10 | }
11 |
12 | func macKeyToFcitxString(_ key: String, _ modifiers: NSEvent.ModifierFlags, _ code: UInt16)
13 | -> String
14 | {
15 | let unicode = keyToUnicode(key)
16 | return String(osx_key_to_fcitx_string(unicode, UInt32(modifiers.rawValue), code))
17 | }
18 |
19 | func fcitxStringToMacShortcut(_ s: String) -> (String, String?) {
20 | let key = String(fcitx_string_to_osx_keysym(s))
21 | let modifiers = NSEvent.ModifierFlags(rawValue: UInt(fcitx_string_to_osx_modifiers(s)))
22 | let code = fcitx_string_to_osx_keycode(s)
23 | if key.isEmpty && code == 0 {
24 | return (s, nil)
25 | }
26 | return shortcutRepr(key, modifiers, code)
27 | }
28 |
--------------------------------------------------------------------------------
/src/config/thememanager.swift:
--------------------------------------------------------------------------------
1 | import Fcitx
2 | import SwiftUI
3 |
4 | struct ExportThemeView: View {
5 | @Environment(\.presentationMode) var presentationMode
6 | @State private var themeName = ""
7 |
8 | var body: some View {
9 | VStack {
10 | TextField(NSLocalizedString("Theme name", comment: ""), text: $themeName)
11 | HStack {
12 | Button {
13 | presentationMode.wrappedValue.dismiss()
14 | } label: {
15 | Text("Cancel")
16 | }
17 | Button {
18 | Fcitx.setConfig(
19 | "fcitx://config/addon/webpanel/exportcurrenttheme", "\"\(quote(themeName))\"")
20 | presentationMode.wrappedValue.dismiss()
21 | } label: {
22 | Text("OK")
23 | }.disabled(themeName.isEmpty)
24 | .buttonStyle(.borderedProminent)
25 | }
26 | }.padding()
27 | .frame(minWidth: 200)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/assets/po/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | #
2 | # C++ i18n through gettext
3 | # Use 'pot' to generate the POT file.
4 | #
5 | set(TRANSLATABLE_CXX_SOURCES
6 | macosfrontend/macosfrontend.h
7 | macosnotifications/macosnotifications.h
8 | webpanel/webpanel.h
9 | )
10 |
11 | list(TRANSFORM TRANSLATABLE_CXX_SOURCES PREPEND "${PROJECT_SOURCE_DIR}/" OUTPUT_VARIABLE TRANSLATABLE_CXX_SOURCES_FULL_PATH)
12 |
13 | add_custom_command(
14 | OUTPUT ${CMAKE_CURRENT_SOURCE_DIR}/base.pot
15 | COMMAND xgettext --c++ --keyword=_ --keyword=N_ ${TRANSLATABLE_CXX_SOURCES} -o ${CMAKE_CURRENT_SOURCE_DIR}/base.pot
16 | DEPENDS ${TRANSLATABLE_CXX_SOURCES_FULL_PATH}
17 | WORKING_DIRECTORY ${PROJECT_SOURCE_DIR}
18 | COMMENT "Generating base.pot..."
19 | )
20 | add_custom_target(pot
21 | DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/base.pot
22 | )
23 |
24 | set(FCITX_INSTALL_LOCALEDIR "${CMAKE_INSTALL_PREFIX}/share/locale")
25 | fcitx5_install_translation(fcitx5-macos)
26 |
--------------------------------------------------------------------------------
/tests/customphrase.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | AppleLanguages
6 |
7 | en-US
8 | zh-Hans-US
9 |
10 | NSUserDictionaryReplacementItems
11 |
12 |
13 | on
14 | 1
15 | replace
16 | msd
17 | with
18 | 马上到!
19 |
20 |
21 | on
22 | 1
23 | replace
24 | omw
25 | with
26 | On my way!
27 |
28 |
29 | NSUserQuotesArray
30 |
31 | “
32 | ”
33 | ‘
34 | ’
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/macosnotifications/macosnotifications-public.h:
--------------------------------------------------------------------------------
1 | #pragma once
2 |
3 | #include
4 |
5 | // Refer to
6 | // https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html
7 | enum NotificationClosedReason {
8 | /// The notification expired.
9 | NOTIFICATION_CLOSED_REASON_EXPIRY = 1,
10 |
11 | /// The notification was dismissed by the user.
12 | NOTIFICATION_CLOSED_REASON_DISMISSED = 2,
13 |
14 | /// The notification was closed by a call to CloseNotification.
15 | NOTIFICATION_CLOSED_REASON_CLOSED = 3,
16 |
17 | /// Undefined/reserved reasons.
18 | NOTIFICATION_CLOSED_REASON_UNDEFINED = 4,
19 | };
20 |
21 | namespace fcitx {
22 |
23 | void handleActionResult(const char *notificationId,
24 | const char *actionId) noexcept;
25 |
26 | void destroyNotificationItem(const char *notificationId,
27 | uint32_t closed_reason) noexcept;
28 |
29 | } // namespace fcitx
30 |
--------------------------------------------------------------------------------
/tests/testcolor.swift:
--------------------------------------------------------------------------------
1 | import AppKit
2 |
3 | func grayScale(gray: CGFloat, alpha: CGFloat) -> NSColor {
4 | let components: [CGFloat] = [gray, alpha]
5 | return NSColor(cgColor: CGColor(colorSpace: CGColorSpaceCreateDeviceGray(), components: components)!)!
6 | }
7 |
8 | func testSRGB() {
9 | let sRGBColor = NSColor(red: 0.1, green: 0.2, blue: 0.3, alpha: 0.4)
10 | assert(sRGBColor.cgColor.components?.count == 4)
11 | assert(nsColorToString(sRGBColor) == "#1A334D66")
12 | }
13 |
14 | func testGrayScale() {
15 | let grayColor = grayScale(gray: 0.5, alpha: 1.0)
16 | assert(grayColor.cgColor.components?.count == 2)
17 | assert(nsColorToString(grayColor) == "#808080FF")
18 | }
19 |
20 | func testGetAccentColor() {
21 | let accentColor = getAccentColor("com.apple.Notes")
22 | assert(accentColor == "#FCB827FF")
23 | }
24 |
25 | @_cdecl("main")
26 | func main() -> Int {
27 | testGrayScale()
28 | testSRGB()
29 | testGetAccentColor()
30 | return 0
31 | }
32 |
--------------------------------------------------------------------------------
/src/config/vimmodeoptionview.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct VimModeOptionView: OptionView {
4 | let label: String
5 | @ObservedObject var model: VimModeOption
6 |
7 | var body: some View {
8 | let openPanel = NSOpenPanel() // macOS 26 crashes if put outside of body.
9 | HStack {
10 | let appPath = appPathFromBundleIdentifier(model.value)
11 | let appName = appNameFromPath(appPath)
12 | if !appPath.isEmpty {
13 | appIconFromPath(appPath)
14 | }
15 | Spacer()
16 | if !appName.isEmpty {
17 | Text(appName)
18 | } else if model.value.isEmpty {
19 | Text("Select App")
20 | } else {
21 | Text(model.value)
22 | }
23 | Button {
24 | selectApplication(
25 | openPanel,
26 | onFinish: { path in
27 | model.value = bundleIdentifier(path)
28 | })
29 | } label: {
30 | Image(systemName: "folder")
31 | }
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/secure.swift:
--------------------------------------------------------------------------------
1 | import AppKit
2 | import Carbon
3 | import IOKit
4 |
5 | // Translated from https://github.com/espanso/espanso
6 | // The return value is not guaranteed accurate. To reproduce, execute
7 | // import Carbon
8 | // EnableSecureEventInput()
9 | // in swift repl, then this function returns terminal app's pid.
10 | // However, after Ctrl+Cmd+Q and re-login, it returns com.apple.loginwindow's pid.
11 | func getSecureInputProcessPID() -> Int32? {
12 | let rootService = IORegistryGetRootEntry(kIOMainPortDefault)
13 | guard rootService != 0 else { return nil }
14 |
15 | defer { IOObjectRelease(rootService) }
16 | if let cfConsoleUsers = IORegistryEntryCreateCFProperty(
17 | rootService,
18 | "IOConsoleUsers" as CFString,
19 | kCFAllocatorDefault,
20 | 0
21 | )?.takeRetainedValue() as? [Any] {
22 | for user in cfConsoleUsers {
23 | if let userDict = user as? [String: Any],
24 | let secureInputPID = userDict["kCGSSessionSecureInputPID"] as? NSNumber
25 | {
26 | return secureInputPID.int32Value
27 | }
28 | }
29 | }
30 | return nil
31 | }
32 |
--------------------------------------------------------------------------------
/scripts/prepare-release.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import re
4 | from common import dollar, get_json
5 |
6 | def get_version():
7 | project_line = dollar('grep "project(fcitx5-macos VERSION" CMakeLists.txt')
8 | match = re.search(r'VERSION ([\d\.]+)', project_line)
9 | if match is None:
10 | raise Exception('CMakeLists.txt should set VERSION properly.')
11 | return match.group(1)
12 |
13 | version = get_version()
14 | if os.system(f'git tag -a {version} -m "release {version}"') != 0:
15 | raise Exception('Failed to create git tag.')
16 |
17 | with open('version.jsonl') as f:
18 | content = f.read()
19 |
20 | with open('version.jsonl', 'w') as f:
21 | json.dump(get_json(version), f)
22 | f.write('\n' + content)
23 |
24 | major, minor, patch = version.split('.')
25 |
26 | if os.system(f'sed -i.bak "s/fcitx5-macos VERSION {major}\\.{minor}\\.{patch}/fcitx5-macos VERSION {major}.{minor}.{int(patch) + 1}/" CMakeLists.txt') != 0:
27 | raise Exception('Failed to update version in CMakeLists.txt.')
28 | os.system('rm CMakeLists.txt.bak')
29 |
30 | os.system('git add version.jsonl CMakeLists.txt')
31 |
--------------------------------------------------------------------------------
/src/config/config-public.h:
--------------------------------------------------------------------------------
1 | #pragma once
2 | #include
3 |
4 | /// Get a json document describing the current config for uri.
5 | ///
6 | /// The formats of the json object are:
7 | /// - {"ERROR": "error message"}, if there are errors
8 | /// - otherwise, it is a json object satisfying
9 | /// returnValue["Children"][i]["Children"][j] corresponds to an option object
10 | /// Foo/Bar, satisfying returnValue["Children"][i]["Option"] == "Foo" and
11 | /// returnValue["Children"][i]["Children"][j]["Option"] == "Bar"
12 | ///
13 | /// type OptionObject = {
14 | /// Option: str,
15 | /// Type: str,
16 | /// Description: str,
17 | /// DefaultValue: T,
18 | /// Value: T, // Current value
19 | /// ... other keys, // Relevant to the option type
20 | /// Children: [
21 | /// ... suboptions
22 | /// ]
23 | /// }
24 | std::string getConfig(const char *uri);
25 |
26 | /// This function applies jsonPatch to the current "Value" for config
27 | /// uri.
28 | ///
29 | /// This function updates the current value and then reload the config.
30 | bool setConfig(const char *uri, const char *jsonPatch);
31 |
--------------------------------------------------------------------------------
/src/config/sudo.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Logging
3 |
4 | func quote(_ s: String) -> String {
5 | return s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
6 | }
7 |
8 | func twiceQuote(_ s: String) -> String {
9 | let quoted = quote(s)
10 | return quote("\"\(quoted)\"")
11 | }
12 |
13 | func sudo(_ script: String, _ arg: String, _ logPath: String) -> Bool {
14 | let user = NSUserName()
15 | guard let scriptPath = Bundle.main.path(forResource: script, ofType: "sh") else {
16 | FCITX_ERROR("\(script).sh not found")
17 | return false
18 | }
19 | let command =
20 | "do shell script \"\(twiceQuote(scriptPath)) \(twiceQuote(user)) \(twiceQuote(arg)) 2>\(logPath)\" with administrator privileges"
21 | guard let appleScript = NSAppleScript(source: command) else {
22 | FCITX_ERROR("Fail to initialize AppleScript")
23 | return false
24 | }
25 | var error: NSDictionary? = nil
26 | appleScript.executeAndReturnError(&error)
27 | if let error = error {
28 | FCITX_ERROR("Fail to execute AppleScript: \(error)")
29 | return false
30 | }
31 | return true
32 | }
33 |
--------------------------------------------------------------------------------
/tests/testkey.swift:
--------------------------------------------------------------------------------
1 | func testMacToFcitx() {
2 | assert(macKeyToFcitxString("a", .control, 0) == "Control+A")
3 | assert(macKeyToFcitxString("A", .control.union(.shift), 0) == "Control+Shift+A")
4 |
5 | assert(macKeyToFcitxString("", .option.union(.shift), 0x38) == "Alt+Shift+Shift_L")
6 | assert(macKeyToFcitxString("", .command.union(.shift), 0x3c) == "Shift+Super+Shift_R")
7 | }
8 |
9 | func testFcitxToMac() {
10 | assert(fcitxStringToMacShortcut("0") == ("0", nil))
11 | assert(fcitxStringToMacShortcut("KP_0") == ("🄋", nil))
12 | assert(fcitxStringToMacShortcut("Control+A") == ("⌃A", nil))
13 | assert(fcitxStringToMacShortcut("Control+Shift+A") == ("⌃⇧A", nil))
14 | assert(fcitxStringToMacShortcut("Shift+Super+Shift_L") == ("⇧⌘", nil))
15 | assert(fcitxStringToMacShortcut("Alt+Shift+Shift_R") == ("⌥⬆", nil))
16 | assert(fcitxStringToMacShortcut("F12") == ("", "F12"))
17 | assert(fcitxStringToMacShortcut("Shift+F12") == ("⇧", "F12"))
18 | assert(fcitxStringToMacShortcut("Super+Home") == ("⌘⤒", nil))
19 | }
20 |
21 | @_cdecl("main")
22 | func main() -> Int {
23 | testMacToFcitx()
24 | testFcitxToMac()
25 | return 0
26 | }
27 |
--------------------------------------------------------------------------------
/src/config/pluginoptionview.swift:
--------------------------------------------------------------------------------
1 | import Logging
2 | import SwiftUI
3 |
4 | struct PluginOptionView: OptionView {
5 | let label: String
6 | @ObservedObject var model: PluginOption
7 | @State private var availablePlugins = [String]()
8 |
9 | var body: some View {
10 | Picker("", selection: $model.value) {
11 | ForEach(availablePlugins, id: \.self) { plugin in
12 | Text(plugin)
13 | }
14 | }.onAppear {
15 | for fileName in getFileNamesWithExtension(jsPluginDir.localPath()) {
16 | let url = jsPluginDir.appendingPathComponent(fileName)
17 | if !url.isDirectory {
18 | continue
19 | }
20 | let packageJsonURL = url.appendingPathComponent("package.json")
21 | if let json = readJSON(packageJsonURL) {
22 | if json["license"].stringValue.hasPrefix("GPL-3.0") {
23 | availablePlugins.append(fileName)
24 | } else {
25 | FCITX_WARN("Rejecting plugin \(fileName) which is not GPLv3")
26 | }
27 | } else {
28 | FCITX_WARN("Invalid package.json for plugin \(fileName)")
29 | }
30 | }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/color.swift:
--------------------------------------------------------------------------------
1 | import AppKit
2 |
3 | public func nsColorToString(_ color: NSColor) -> String? {
4 | guard let rgbColor = color.usingColorSpace(.sRGB) else {
5 | return nil
6 | }
7 | let red = UInt8(round(rgbColor.redComponent * 255.0))
8 | let green = UInt8(round(rgbColor.greenComponent * 255.0))
9 | let blue = UInt8(round(rgbColor.blueComponent * 255.0))
10 | let alpha = UInt8(round(rgbColor.alphaComponent * 255.0))
11 | return String(format: "#%02X%02X%02X%02X", red, green, blue, alpha)
12 | }
13 |
14 | private var colorMap = [String: String]()
15 |
16 | func getAccentColor(_ id: String) -> String {
17 | if let cachedColor = colorMap[id] {
18 | return cachedColor
19 | }
20 | if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: id),
21 | let bundle = Bundle(url: url),
22 | let info = bundle.infoDictionary,
23 | let name = info["NSAccentColorName"] as? String,
24 | let color = NSColor(named: NSColor.Name(name), bundle: bundle),
25 | let string = nsColorToString(color)
26 | {
27 | colorMap[id] = string
28 | return string
29 | } else {
30 | colorMap[id] = ""
31 | return ""
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/macosfrontend/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | add_library(SwiftFrontend STATIC macosfrontend.swift)
2 | set_target_properties(SwiftFrontend PROPERTIES Swift_MODULE_NAME SwiftFrontend)
3 | target_compile_options(SwiftFrontend PUBLIC "$<$:-cxx-interoperability-mode=default>")
4 | target_include_directories(SwiftFrontend PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")
5 |
6 | _swift_generate_cxx_header(
7 | SwiftFrontend
8 | "${CMAKE_CURRENT_BINARY_DIR}/include/macosfrontend-swift.h"
9 | SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/macosfrontend.swift"
10 | SEARCH_PATHS "${CMAKE_CURRENT_SOURCE_DIR}"
11 | )
12 |
13 | add_library(macosfrontend STATIC macosfrontend.cpp pasteboard.mm)
14 | add_dependencies(macosfrontend SwiftFrontend)
15 | target_link_libraries(macosfrontend Fcitx5::Core url-filter Keycode)
16 | target_include_directories(macosfrontend PUBLIC
17 | "${CMAKE_CURRENT_BINARY_DIR}/include"
18 | "${CMAKE_SOURCE_DIR}/src"
19 | )
20 |
21 | configure_file(macosfrontend.conf.in.in macosfrontend.conf.in @ONLY)
22 | fcitx5_translate_desktop_file(${CMAKE_CURRENT_BINARY_DIR}/macosfrontend.conf.in macosfrontend.conf)
23 |
24 | install(FILES "${CMAKE_CURRENT_BINARY_DIR}/macosfrontend.conf"
25 | DESTINATION "${CMAKE_INSTALL_PREFIX}/share/fcitx5/addon"
26 | )
27 |
--------------------------------------------------------------------------------
/src/config/imageoptionview.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | private let modes = [
4 | NSLocalizedString("Local", comment: ""),
5 | "URL",
6 | ]
7 |
8 | private let imageDir = wwwDir.appendingPathComponent("img")
9 |
10 | struct ImageOptionView: OptionView {
11 | let label: String
12 | @ObservedObject var model: ImageOption
13 |
14 | var body: some View {
15 | VStack(alignment: .leading) { // Avoid layout shift of Picker when switching modes.
16 | Picker("", selection: $model.mode) {
17 | ForEach(Array(modes.enumerated()), id: \.0) { idx, mode in
18 | Text(mode)
19 | }
20 | }
21 | if model.mode == 0 {
22 | SelectFileButton(
23 | directory: imageDir,
24 | allowedContentTypes: [.image],
25 | onFinish: { fileName in
26 | model.file = fileName
27 | },
28 | label: {
29 | if model.file.isEmpty {
30 | Text("Select image")
31 | } else {
32 | Text(model.file)
33 | }
34 | }, model: $model.file
35 | )
36 | } else {
37 | TextField(
38 | NSLocalizedString("https:// or data:image/png;base64,", comment: ""), text: $model.url)
39 | }
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/assets/fcitx5-curl:
--------------------------------------------------------------------------------
1 | #!/bin/zsh
2 | set -eu
3 |
4 | DEBUG="${DEBUG:-}"
5 |
6 | if [[ $# -eq 0 || $# -eq 1 && ( "$1" == "-h" || "$1" == "--help" ) ]]; then
7 | echo "Usage: fcitx5-curl path [curl options]"
8 | echo "Example: fcitx5-curl /config/addon/rime/deploy -X POST -d '{}'"
9 | exit 0
10 | fi
11 |
12 | CONFIG_FILE_PATH="$HOME/.config/fcitx5/conf/beast.conf"
13 |
14 | if [ -f "$CONFIG_FILE_PATH" ]
15 | then
16 | COMMUNICATION=$(sed -n 's/Communication=\([^ ]*.*\)/\1/p' "$CONFIG_FILE_PATH")
17 | UDS_PATH=$(sed -n 's/Path=\([^ ]*.*\)/\1/p' "$CONFIG_FILE_PATH")
18 | TCP_PORT=$(sed -n 's/Port=\([^ ]*.*\)/\1/p' "$CONFIG_FILE_PATH")
19 | fi
20 |
21 | COMMUNICATION="${COMMUNICATION:-UDS}"
22 | UDS_PATH="${UDS_PATH:-/tmp/fcitx5.sock}"
23 | TCP_PORT="${TCP_PORT:-32489}"
24 |
25 | CURL_FLAGS=()
26 |
27 | if [[ "$COMMUNICATION" == 'TCP' ]]; then
28 | FCITX_BEAST_URL="http://127.0.0.1:$TCP_PORT$1"
29 | else
30 | CURL_FLAGS+=('--unix-socket' "$UDS_PATH")
31 | FCITX_BEAST_URL="http://fcitx$1"
32 | fi
33 |
34 | shift
35 | CURL_FLAGS+=($@)
36 |
37 | if [[ -n "$DEBUG" ]]; then
38 | echo "COMMUNICATION=$COMMUNICATION"
39 | echo "UDS_PATH=$UDS_PATH"
40 | echo "TCP_PORT=$TCP_PORT"
41 | echo "FCITX_BEAST_URL=$FCITX_BEAST_URL"
42 | echo "CURL_FLAGS=$CURL_FLAGS"
43 | fi
44 |
45 | curl $CURL_FLAGS "$FCITX_BEAST_URL"
46 |
--------------------------------------------------------------------------------
/macosnotifications/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | add_library(SwiftNotify STATIC notify.swift)
2 | set_target_properties(SwiftNotify PROPERTIES Swift_MODULE_NAME SwiftNotify)
3 | target_compile_options(SwiftNotify PUBLIC "$<$:-cxx-interoperability-mode=default>")
4 | target_include_directories(SwiftNotify PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}" "${PROJECT_BINARY_DIR}/logging")
5 | add_dependencies(SwiftNotify Logging)
6 |
7 | _swift_generate_cxx_header(
8 | SwiftNotify
9 | "${CMAKE_CURRENT_BINARY_DIR}/include/notify-swift.h"
10 | SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/notify.swift"
11 | SEARCH_PATHS "${CMAKE_CURRENT_SOURCE_DIR};${PROJECT_BINARY_DIR}/logging"
12 | DEPENDS Logging
13 | )
14 |
15 | add_library(notifications STATIC macosnotifications.cpp)
16 | add_dependencies(notifications SwiftNotify)
17 | target_link_libraries(notifications Fcitx5::Core Fcitx5::Module::Notifications)
18 | target_include_directories(notifications PUBLIC
19 | "${CMAKE_CURRENT_BINARY_DIR}/include"
20 | "${CMAKE_SOURCE_DIR}/src"
21 | )
22 |
23 | configure_file(notifications.conf.in.in notifications.conf.in @ONLY)
24 | fcitx5_translate_desktop_file(${CMAKE_CURRENT_BINARY_DIR}/notifications.conf.in notifications.conf)
25 |
26 | install(FILES "${CMAKE_CURRENT_BINARY_DIR}/notifications.conf"
27 | DESTINATION "${CMAKE_INSTALL_PREFIX}/share/fcitx5/addon"
28 | )
29 |
--------------------------------------------------------------------------------
/src/locale.swift:
--------------------------------------------------------------------------------
1 | // This file aims to convert locale from system to a string that fcitx5 recognizes.
2 | // Given fcitx5 has a limited number of locales in po/, we do not need to convert all locales.
3 | // User sets system locale in System Settings -> General -> Language & Region.
4 | // The first language in Preferred Languages and the Region count.
5 | // However, if the language is not commonly used in the region, it results in funny behavior.
6 | // e.g. 简体中文 with US region, the system locale is zh-Hans_US, but we need zh_CN.
7 | // In this situation, script is Hans (otherwise nil), and identifier = languageCode-script_regionCode
8 | // We also need zh_SG to fall back to zh_CN.
9 |
10 | import Foundation
11 | import Logging
12 |
13 | func getLocale() -> String {
14 | let locale = Locale.current
15 | FCITX_INFO("System locale = \(locale.identifier)")
16 |
17 | if let languageCode = locale.language.languageCode?.identifier {
18 | if languageCode == "zh" {
19 | if let scriptCode = locale.language.script?.identifier {
20 | if scriptCode == "Hans" {
21 | return "zh_CN"
22 | } else {
23 | return "zh_TW"
24 | }
25 | }
26 | if locale.region?.identifier == "SG" {
27 | return "zh_CN"
28 | } else {
29 | return "zh_TW"
30 | }
31 | }
32 | return languageCode
33 | }
34 | return "C"
35 | }
36 |
--------------------------------------------------------------------------------
/src/fcitx-public.h:
--------------------------------------------------------------------------------
1 | #pragma once
2 |
3 | #include
4 |
5 | void start_fcitx_thread(const char *locale) noexcept;
6 | void stop_fcitx_thread() noexcept;
7 |
8 | // NOTE: It's impossible to use std::vector directly
9 | // until Swift fixes C++ interop.
10 | // Returns a json array of group names.
11 | std::string imGetGroupNames() noexcept;
12 | std::string imGetCurrentGroupName() noexcept;
13 | void imSetCurrentGroup(const char *groupName) noexcept;
14 |
15 | // Returns a json array of { "name": ..., "displayName": ... }
16 | std::string imGetCurrentGroup() noexcept;
17 |
18 | int imGroupCount() noexcept;
19 | void imAddToCurrentGroup(const char *imName) noexcept;
20 |
21 | // Returns json
22 | // [{"name": "group name", "inputMethods":
23 | // [{"name": ..., "displayName": ...}...]}...].
24 | std::string imGetGroups() noexcept;
25 | void imSetGroups(const char *json) noexcept;
26 |
27 | std::string imGetCurrentIMName() noexcept;
28 | void imSetCurrentIM(const char *imName) noexcept;
29 | void toggleInputMethod() noexcept;
30 |
31 | // Returns a json array of Input Methods.
32 | // type InputMethod := {uniqueName:str, name:str, nativeName:str,
33 | // languageCode:str, icon:str, label:str, isConfigurable: bool}
34 | std::string imGetAvailableIMs() noexcept;
35 |
36 | std::string getAddons() noexcept;
37 |
38 | std::string getActions() noexcept;
39 | void activateActionById(int id) noexcept;
40 |
41 | std::string isoName(const char *code) noexcept;
42 |
43 | // Tunnel variables
44 | #include "tunnel.h"
45 |
--------------------------------------------------------------------------------
/.github/workflows/compare.yml:
--------------------------------------------------------------------------------
1 | name: Compare
2 |
3 | on:
4 | workflow_call:
5 |
6 | jobs:
7 | compare:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Download release
11 | run: wget -O release.tar.bz2 https://github.com/fcitx-contrib/fcitx5-macos/releases/download/latest/Fcitx5-arm64.tar.bz2
12 |
13 | - name: Download artifact
14 | uses: actions/download-artifact@v6
15 | with:
16 | merge-multiple: true
17 |
18 | - name: Compare content
19 | run: |
20 | tar tjf release.tar.bz2 > release.list
21 | for arch in arm64 x86_64; do
22 | echo "## $arch comparison" >> summary.md
23 | tar tjf Fcitx5-$arch.tar.bz2 > $arch.list
24 | diff -u release.list $arch.list > $arch.diff || true
25 | if [[ -s $arch.diff ]]; then
26 | echo '```diff' >> summary.md
27 | cat $arch.diff >> summary.md
28 | echo '```' >> summary.md
29 | else
30 | echo "No difference." >> summary.md
31 | fi
32 | done
33 |
34 | - name: Find comment
35 | uses: peter-evans/find-comment@v3
36 | id: fc
37 | with:
38 | issue-number: ${{ github.event.pull_request.number }}
39 | comment-author: 'github-actions[bot]'
40 | body-includes: "arm64 comparison"
41 |
42 | - name: Create or update comment
43 | uses: peter-evans/create-or-update-comment@v4
44 | with:
45 | issue-number: ${{ github.event.pull_request.number }}
46 | comment-id: ${{ steps.fc.outputs.comment-id }}
47 | body-path: summary.md
48 | edit-mode: replace
49 |
--------------------------------------------------------------------------------
/scripts/update_translations.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import sys
4 | import re
5 |
6 |
7 | COMMENT_RE = re.compile(r'^/\*')
8 | DATA_LINE_RE = re.compile(r'^"(.*)" = "(.*)";$')
9 | ENCODING = 'utf16'
10 |
11 |
12 | class TranslationFile:
13 | def __init__(self, path: str):
14 | self.path = path
15 | self.table = {}
16 | with open(path, 'r', encoding=ENCODING) as file:
17 | for line in file:
18 | m = re.match(DATA_LINE_RE, line)
19 | if not m:
20 | continue
21 | self.table[m.group(1)] = m.group(2)
22 |
23 | def update_from(self, base: 'TranslationFile'):
24 | with open(self.path, 'w', encoding=ENCODING) as out, open(base.path, 'r', encoding=ENCODING) as inp:
25 | for line in inp:
26 | if not line.strip():
27 | continue
28 | m = re.match(COMMENT_RE, line)
29 | if m:
30 | continue
31 | m = re.match(DATA_LINE_RE, line)
32 | if m:
33 | key = m.group(1)
34 | value = self.table.get(key, m.group(2))
35 | out.write(f'"{key}" = "{value}";\n')
36 | else:
37 | out.write(line)
38 |
39 |
40 | if __name__ == '__main__':
41 | if len(sys.argv) < 3:
42 | print('USAGE: base.strings locale1.strings locale2.strings locale3.strings ...')
43 | sys.exit(1)
44 | print(f'base = {sys.argv[1]}')
45 | base = TranslationFile(sys.argv[1])
46 | for path in sys.argv[2:]:
47 | print(f'updating {path}')
48 | translated = TranslationFile(path)
49 | translated.update_from(base)
50 |
--------------------------------------------------------------------------------
/tests/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | # Config and Option
2 | add_executable(ConfigSwift testconfig.swift
3 | ${PROJECT_SOURCE_DIR}/src/color.swift
4 | ${PROJECT_SOURCE_DIR}/src/config/optionmodels.swift
5 | ${PROJECT_SOURCE_DIR}/src/config/config.swift
6 | ${PROJECT_SOURCE_DIR}/src/config/util.swift
7 | )
8 | target_compile_options(ConfigSwift PUBLIC
9 | "$<$:-cxx-interoperability-mode=default>"
10 | )
11 | target_link_libraries(ConfigSwift Fcitx5Objs Logging SwiftyJSON SwiftFrontend)
12 | add_test(NAME ConfigSwift COMMAND ConfigSwift)
13 |
14 | add_executable(config-cpp testconfig.cpp)
15 | target_link_libraries(config-cpp Fcitx5Objs SwiftFrontend)
16 | fcitx5_import_addons(config-cpp
17 | REGISTRY_VARNAME getStaticAddon
18 | ADDONS keyboard
19 | )
20 | add_test(NAME config-cpp COMMAND config-cpp)
21 |
22 | # CustomPhrase .plist parser
23 | add_executable(XmlParser testxmlparser.swift
24 | ${PROJECT_SOURCE_DIR}/src/config/xmlparser.swift
25 | )
26 | add_test(NAME XmlParser COMMAND XmlParser "${PROJECT_SOURCE_DIR}/tests/customphrase.plist")
27 |
28 | add_executable(key-cpp testkey.cpp)
29 | target_link_libraries(key-cpp Keycode)
30 | add_test(NAME key-cpp COMMAND key-cpp)
31 |
32 | add_executable(KeySwift testkey.swift
33 | ${PROJECT_SOURCE_DIR}/src/config/keycode.swift
34 | ${PROJECT_SOURCE_DIR}/src/config/keyrecorder.swift
35 | )
36 | target_compile_options(KeySwift PUBLIC
37 | "$<$:-cxx-interoperability-mode=default>"
38 | )
39 | target_link_libraries(KeySwift Keycode)
40 | add_test(NAME KeySwift COMMAND KeySwift)
41 |
42 | add_executable(TagSwift testtag.swift
43 | ${PROJECT_SOURCE_DIR}/src/config/tag.swift
44 | )
45 | add_test(NAME TagSwift COMMAND TagSwift)
46 |
47 | add_executable(ColorSwift testcolor.swift
48 | ${PROJECT_SOURCE_DIR}/src/color.swift
49 | )
50 | add_test(NAME ColorSwift COMMAND ColorSwift)
51 |
--------------------------------------------------------------------------------
/tests/testconfig.cpp:
--------------------------------------------------------------------------------
1 | #include
2 | #include
3 | #include
4 | #include "fcitx-public.h"
5 | #include "fcitx-utils/log.h"
6 | #include "config/config-public.h"
7 |
8 | int main() {
9 | start_fcitx_thread("C");
10 | sleep(1);
11 |
12 | // Can get information about input methods.
13 | imGetGroups();
14 |
15 | // Can get config.
16 | {
17 | auto j = nlohmann::json::parse(getConfig("fcitx://config/global"));
18 | FCITX_ASSERT(j.is_object() && j.find("ERROR") == j.end());
19 | }
20 | {
21 | auto j =
22 | nlohmann::json::parse(getConfig("fcitx://config/addon/unicode"));
23 | FCITX_ASSERT(j.is_object() && j.find("ERROR") == j.end());
24 | }
25 | {
26 | auto j = nlohmann::json::parse(
27 | getConfig("fcitx://config/inputmethod/keyboard-us"));
28 | FCITX_ASSERT(j.is_object() && j.find("ERROR") == j.end());
29 | }
30 |
31 | // Can get available input methods.
32 | std::cerr << imGetAvailableIMs() << std::endl;
33 |
34 | // Can set config
35 | std::vector values{"False", "True"};
36 | for (const auto &value : values) {
37 | nlohmann::json j{{"Behavior", {{"ActiveByDefault", value}}}};
38 | FCITX_ASSERT(setConfig("fcitx://config/global", j.dump().c_str()));
39 | auto updated =
40 | nlohmann::json::parse(getConfig("fcitx://config/global"));
41 | for (const auto &child : updated["Children"]) {
42 | if (child["Option"] == "Behavior") {
43 | for (const auto &grand_child : child["Children"]) {
44 | if (grand_child["Option"] == "ActiveByDefault") {
45 | FCITX_ASSERT(grand_child["Value"].get() ==
46 | value);
47 | break;
48 | }
49 | }
50 | break;
51 | }
52 | }
53 | }
54 |
55 | stop_fcitx_thread();
56 | }
57 |
--------------------------------------------------------------------------------
/assets/update.sh:
--------------------------------------------------------------------------------
1 | #!/bin/zsh
2 | set -xeu
3 |
4 | user="$1"
5 | tar_ball="$2"
6 | INSTALL_DIR="/Library/Input Methods"
7 | APP_DIR="$INSTALL_DIR/Fcitx5.app"
8 | RESOURCES_DIR="$APP_DIR/Contents/Resources"
9 |
10 | # Don't remove files that must exist (which will be overwritten) as that will put Fcitx5 in a registered-but-not-listed state.
11 | rm -rf "$APP_DIR"/Contents/{bin,include,lib,share,Resources}
12 | if ls "$APP_DIR"/Contents/MacOS/Fcitx5.*; then # Debug symbols
13 | rm -rf "$APP_DIR"/Contents/MacOS/Fcitx5.*
14 | fi
15 | tar xjvf "$tar_ball" -C "$INSTALL_DIR"
16 | rm -f "$tar_ball"
17 |
18 | major_version=$(sw_vers -productVersion | cut -d. -f1)
19 | if (( major_version >= 26 )); then
20 | cp "$RESOURCES_DIR/menu_icon_26.pdf" "$RESOURCES_DIR/menu_icon.pdf"
21 | else
22 | cp "$RESOURCES_DIR/menu_icon_15.pdf" "$RESOURCES_DIR/menu_icon.pdf"
23 | fi
24 |
25 | xattr -dr com.apple.quarantine "$APP_DIR"
26 | codesign --force --sign - --deep "$APP_DIR"
27 |
28 | cd "$RESOURCES_DIR"
29 | im=$(su -m "$user" -c "./get_im")
30 | # Switching out is necessary, otherwise it doesn't show menu
31 | # Not sure which one so try both.
32 | su -m "$user" -c "./switch_im com.apple.keylayout.ABC"
33 | su -m "$user" -c "./switch_im com.apple.keylayout.US"
34 | killall Fcitx5
35 |
36 | # This input source ID comes from Carbon API:
37 | # import Carbon
38 |
39 | # let bundleId = "org.fcitx.inputmethod.Fcitx5"
40 | # let conditions = NSMutableDictionary()
41 | # conditions.setValue(bundleId, forKey: kTISPropertyBundleID as String)
42 | # if let array = TISCreateInputSourceList(conditions, true)?.takeRetainedValue()
43 | # as? [TISInputSource]
44 | # {
45 | # for inputSource in array {
46 | # if let ptr = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID) {
47 | # let inputSourceID = Unmanaged.fromOpaque(ptr).takeUnretainedValue() as String
48 | # print(inputSourceID)
49 | # }
50 | # }
51 | # }
52 |
53 | # The rule to construct seems:
54 | # org.fcitx.inputmethod.Fcitx5 is our CFBundleIdentifier;
55 | # The rest is the keys under tsInputModeListKey trimming the org.fcitx.inputmethod.
56 |
57 | # Switch back.
58 | su -m "$user" -c "./switch_im $im"
59 |
--------------------------------------------------------------------------------
/src/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | execute_process(COMMAND git rev-parse HEAD
2 | OUTPUT_VARIABLE COMMIT
3 | OUTPUT_STRIP_TRAILING_WHITESPACE
4 | )
5 | execute_process(COMMAND git show --no-patch --format=%ct
6 | OUTPUT_VARIABLE UNIX_TIME
7 | OUTPUT_STRIP_TRAILING_WHITESPACE
8 | )
9 | execute_process(COMMAND bash -c "git describe --exact-match || echo latest"
10 | OUTPUT_VARIABLE RELEASE_TAG
11 | OUTPUT_STRIP_TRAILING_WHITESPACE
12 | )
13 | configure_file(config/meta.swift.in ${CMAKE_CURRENT_SOURCE_DIR}/config/meta.swift @ONLY)
14 |
15 | file(GLOB CONFIG_UI_FILES CONFIGURE_DEPENDS config/*.swift)
16 |
17 | add_library(Fcitx5Objs STATIC
18 | fcitx.cpp
19 | tunnel.cpp
20 | config/config.cpp
21 | )
22 |
23 | target_include_directories(Fcitx5Objs PRIVATE
24 | "${PROJECT_BINARY_DIR}/fcitx5"
25 | "${PROJECT_SOURCE_DIR}/fcitx5/src/im/keyboard"
26 | )
27 |
28 | # This should be disabled in CI even for debug build.
29 | option(KEY_LOGGING "Enable key logging" ON)
30 | if(KEY_LOGGING)
31 | target_compile_definitions(Fcitx5Objs PRIVATE KEY_LOGGING)
32 | endif()
33 |
34 | target_link_libraries(Fcitx5Objs
35 | Fcitx5::Core
36 | keyboard
37 | webpanel
38 | beast
39 | macosfrontend
40 | notifications
41 | WebviewCandidateWindow
42 | )
43 |
44 | add_executable(Fcitx5
45 | MACOSX_BUNDLE
46 | server.swift
47 | locale.swift
48 | controller.swift
49 | color.swift
50 | secure.swift
51 | ${CONFIG_UI_FILES}
52 | )
53 |
54 | target_link_libraries(Fcitx5
55 | Fcitx5Objs
56 | AlertToast
57 | SwiftyJSON
58 | SwiftFrontend
59 | SwiftNotify
60 | Logging
61 | )
62 |
63 | fcitx5_import_addons(Fcitx5
64 | REGISTRY_VARNAME getStaticAddon
65 | ADDONS beast keyboard webpanel macosfrontend notifications
66 | )
67 |
68 | set(APP_PATH "${CMAKE_CURRENT_BINARY_DIR}/Fcitx5.app/Contents")
69 |
70 | set(BINARY_LIB_PATH "${PROJECT_BINARY_DIR}/fcitx5/src/lib")
71 | set(BINARY_MODULE_PATH "${PROJECT_BINARY_DIR}/fcitx5/src/modules")
72 |
73 | add_custom_command(TARGET Fcitx5 POST_BUILD
74 | COMMAND rm -f "${CMAKE_CURRENT_BINARY_DIR}/Fcitx5.app/Contents/MacOS/Fcitx5.d"
75 | )
76 |
77 | install(TARGETS Fcitx5
78 | BUNDLE DESTINATION "${APP_INSTALL_PATH}"
79 | )
80 |
--------------------------------------------------------------------------------
/src/fcitx.h:
--------------------------------------------------------------------------------
1 | #pragma once
2 |
3 | #include
4 | #include
5 | #include
6 | #include
7 |
8 | #include "fcitx-public.h"
9 | #include "../macosfrontend/macosfrontend.h"
10 | #include "../webpanel/webpanel.h"
11 |
12 | extern fcitx::WebPanel *webpanel_;
13 |
14 | /// 'Fcitx' manages the lifecycle of the global Fcitx instance.
15 | class Fcitx final {
16 | public:
17 | Fcitx();
18 | ~Fcitx();
19 | Fcitx(Fcitx &) = delete;
20 |
21 | static Fcitx &shared();
22 | void setup();
23 | void teardown();
24 |
25 | void exec();
26 | void exit();
27 | void schedule(std::function);
28 |
29 | fcitx::Instance *instance();
30 | fcitx::AddonManager &addonMgr();
31 | fcitx::AddonInstance *addon(const std::string &name);
32 |
33 | fcitx::MacosFrontend *frontend();
34 |
35 | private:
36 | void setupLog();
37 | void setupEnv();
38 | void setupInstance();
39 |
40 | std::unique_ptr instance_;
41 | std::unique_ptr dispatcher_;
42 | fcitx::MacosFrontend *frontend_;
43 | };
44 |
45 | /// Check if we are on the fcitx thread.
46 | bool in_fcitx_thread() noexcept;
47 |
48 | /// Run a function in the fcitx thread and obtain its return value
49 | /// synchronously. If it's called in the fcitx thread, the functor is
50 | /// invoked immediately.
51 | template >
52 | inline T with_fcitx(F func) {
53 | // Avoid deadlock when re-entered.
54 | if (in_fcitx_thread()) {
55 | return func(Fcitx::shared());
56 | }
57 | auto &fcitx = Fcitx::shared();
58 | std::promise prom;
59 | std::future fut = prom.get_future();
60 | fcitx.schedule([&prom, func = std::move(func), &fcitx]() {
61 | try {
62 | if constexpr (std::is_void_v) {
63 | func(fcitx);
64 | prom.set_value();
65 | } else {
66 | T result = func(fcitx);
67 | prom.set_value(std::move(result));
68 | }
69 | } catch (...) {
70 | prom.set_exception(std::current_exception());
71 | }
72 | });
73 | fut.wait();
74 | return fut.get();
75 | }
76 |
--------------------------------------------------------------------------------
/src/config/xmlparser.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class CustomPhraseParserDelegate: NSObject, XMLParserDelegate {
4 | var currentKey: String = ""
5 | var expectArray: Bool = false
6 | var expectDict: Bool = false
7 | var expectPhrase: Bool = false
8 | var expectShortcut: Bool = false
9 | var shortcut: String?
10 | var phrase: String?
11 | var result: [(shortcut: String, phrase: String)] = []
12 |
13 | func parser(
14 | _ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
15 | qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]
16 | ) {
17 | if expectArray && elementName == "array" {
18 | expectDict = true
19 | } else if expectDict && elementName == "dict" {
20 | expectPhrase = false
21 | expectShortcut = false
22 | shortcut = nil
23 | phrase = nil
24 | }
25 | currentKey = elementName
26 | }
27 |
28 | func parser(_ parser: XMLParser, foundCharacters string: String) {
29 | if currentKey == "key" {
30 | if string == "NSUserDictionaryReplacementItems" {
31 | expectArray = true
32 | } else if string == "with" {
33 | expectPhrase = true
34 | } else if string == "replace" {
35 | expectShortcut = true
36 | }
37 | } else if currentKey == "string" {
38 | if expectPhrase {
39 | phrase = string
40 | expectPhrase = false
41 | } else if expectShortcut {
42 | shortcut = string
43 | expectShortcut = false
44 | }
45 | }
46 | }
47 |
48 | func parser(
49 | _ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
50 | qualifiedName qName: String?
51 | ) {
52 | if expectArray && elementName == "array" {
53 | expectDict = false
54 | expectArray = false
55 | } else if expectDict && elementName == "dict" {
56 | if let shortcut = shortcut, let phrase = phrase {
57 | result.append((shortcut, phrase))
58 | }
59 | }
60 | }
61 | }
62 |
63 | func parseCustomPhraseXML(_ file: URL) -> [(shortcut: String, phrase: String)] {
64 | if let parser = XMLParser(contentsOf: file) {
65 | let delegate = CustomPhraseParserDelegate()
66 | parser.delegate = delegate
67 | if parser.parse() {
68 | return delegate.result
69 | }
70 | }
71 | return []
72 | }
73 |
--------------------------------------------------------------------------------
/src/config/appimoptionview.swift:
--------------------------------------------------------------------------------
1 | import Fcitx
2 | import SwiftUI
3 | import SwiftyJSON
4 |
5 | // Should only list Apps that are not available in App selector.
6 | private let presetApps: [String] = [
7 | "/System/Library/CoreServices/Spotlight.app",
8 | "/System/Library/Input Methods/CharacterPalette.app", // emoji picker
9 | ]
10 |
11 | struct AppIMOptionView: OptionView {
12 | let label: String
13 | @ObservedObject var model: AppIMOption
14 | @State private var appIcon: NSImage? = nil
15 | @State private var imNameMap: [String: String] = [:]
16 |
17 | func selections() -> [String] {
18 | if model.appPath.isEmpty || presetApps.contains(model.appPath) {
19 | return [""] + presetApps
20 | }
21 | return [""] + [model.appPath] + presetApps
22 | }
23 |
24 | var body: some View {
25 | let openPanel = NSOpenPanel() // macOS 26 crashes if put outside of body.
26 | HStack {
27 | if !model.appPath.isEmpty {
28 | appIconFromPath(model.appPath)
29 | }
30 | Picker("", selection: $model.appPath) {
31 | ForEach(selections(), id: \.self) { key in
32 | if key.isEmpty {
33 | Text("Select App")
34 | } else {
35 | HStack {
36 | if model.appPath != key {
37 | appIconFromPath(key)
38 | }
39 | Text(appNameFromPath(key)).tag(key)
40 | }
41 | }
42 | }
43 | }
44 | Button {
45 | selectApplication(
46 | openPanel,
47 | onFinish: { path in
48 | model.appPath = path
49 | })
50 | } label: {
51 | Image(systemName: "folder")
52 | }
53 | Picker(
54 | NSLocalizedString("uses", comment: "App X *uses* some input method"),
55 | selection: $model.imName
56 | ) {
57 | ForEach(Array(imNameMap.keys), id: \.self) { key in
58 | Text(imNameMap[key] ?? "").tag(key)
59 | }
60 | }
61 | }.padding(.bottom, 8)
62 | .onAppear {
63 | imNameMap = [:]
64 | let curGroup = JSON(parseJSON: String(Fcitx.imGetCurrentGroup()))
65 | for (_, inputMethod) in curGroup {
66 | let imName = inputMethod["name"].stringValue
67 | let nativeName = inputMethod["displayName"].stringValue
68 | imNameMap[imName] = nativeName
69 | }
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/config/downloader.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Logging
3 |
4 | class Downloader {
5 | private var urls = [URL]()
6 | private var results = [String: Bool]()
7 | private var observers = [String: NSKeyValueObservation]()
8 | private var downloadedBytes = [String: Int64]()
9 | private var totalBytes = [String: Int64]()
10 |
11 | init(_ addresses: [String]) {
12 | for address in addresses {
13 | if let url = URL(string: address) {
14 | self.urls.append(url)
15 | }
16 | }
17 | }
18 |
19 | func download(onFinish: @escaping ([String: Bool]) -> Void, onProgress: ((Double) -> Void)? = nil)
20 | {
21 | mkdirP(cacheDir.localPath())
22 | let downloadGroup = DispatchGroup()
23 | for url in urls {
24 | let address = url.absoluteString
25 | let fileName = url.lastPathComponent
26 | let destinationURL = cacheDir.appendingPathComponent(fileName)
27 | if destinationURL.exists() {
28 | FCITX_INFO("Using cached \(fileName)")
29 | results[address] = true
30 | continue
31 | }
32 |
33 | downloadGroup.enter()
34 | let task = URLSession.shared.downloadTask(with: url) { [self] localURL, response, error in
35 | defer { downloadGroup.leave() }
36 | if error != nil {
37 | results[address] = false
38 | return
39 | }
40 | guard let httpResponse = response as? HTTPURLResponse,
41 | let localURL = localURL
42 | else {
43 | results[address] = false
44 | return
45 | }
46 | if !(200..<300).contains(httpResponse.statusCode) {
47 | results[address] = false
48 | return
49 | }
50 | results[address] = moveFile(localURL, destinationURL)
51 | }
52 |
53 | if let onProgress = onProgress {
54 | let observer = task.progress.observe(\.fractionCompleted) { [self] progress, _ in
55 | downloadedBytes[address] = task.countOfBytesReceived
56 | totalBytes[address] = task.countOfBytesExpectedToReceive
57 | let sum = totalBytes.values.reduce(0, +)
58 | onProgress(Double(downloadedBytes.values.reduce(0, +)) / (sum == 0 ? 1.0 : Double(sum)))
59 | }
60 | observers[address] = observer
61 | downloadedBytes[address] = 0
62 | totalBytes[address] = 0
63 | }
64 |
65 | task.resume()
66 | }
67 |
68 | downloadGroup.notify(queue: .main) { [self] in
69 | onFinish(results)
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/config/navigationsplit.swift:
--------------------------------------------------------------------------------
1 | import Fcitx
2 | import Logging
3 | import SwiftUI
4 |
5 | struct ListConfigView: View {
6 | private let path: String
7 | private let key: String
8 | @ObservedObject private var viewModel: ListConfigViewModel
9 | @State private var dummyText = ""
10 |
11 | init(_ path: String, key: String) {
12 | self.path = path
13 | self.key = key
14 | viewModel = ListConfigViewModel(path)
15 | }
16 |
17 | func refresh() {
18 | viewModel.load()
19 | }
20 |
21 | var body: some View {
22 | NavigationSplitView {
23 | List(selection: $viewModel.selectedConfigIndex) {
24 | ForEach(0..)
30 | set(ARG_MODULE_NAME $,${target_module_name},${target}>)
31 | endif()
32 |
33 | if(ARG_SEARCH_PATHS)
34 | list(TRANSFORM ARG_SEARCH_PATHS PREPEND "-I")
35 | endif()
36 |
37 | if(APPLE)
38 | set(SDK_FLAGS "-sdk" "${CMAKE_OSX_SYSROOT}")
39 | elseif(WIN32)
40 | set(SDK_FLAGS "-sdk" "$ENV{SDKROOT}")
41 | elseif(DEFINED ${CMAKE_SYSROOT})
42 | set(SDK_FLAGS "-sdk" "${CMAKE_SYSROOT}")
43 | endif()
44 |
45 | cmake_path(APPEND CMAKE_CURRENT_BINARY_DIR include
46 | OUTPUT_VARIABLE base_path)
47 |
48 | cmake_path(APPEND base_path ${header}
49 | OUTPUT_VARIABLE header_path)
50 |
51 | set(_AllSources $)
52 | set(_SwiftSources $)
53 | add_custom_command(OUTPUT ${header_path}
54 | DEPENDS ${_SwiftSources}
55 | WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
56 | COMMAND
57 | ${CMAKE_Swift_COMPILER} -frontend -typecheck
58 | ${ARG_SEARCH_PATHS}
59 | ${_SwiftSources}
60 | ${SDK_FLAGS}
61 | -target ${F5M_TARGET}
62 | -module-name "${ARG_MODULE_NAME}"
63 | -cxx-interoperability-mode=default
64 | -emit-clang-header-path ${header_path}
65 | COMMENT
66 | "Generating '${header_path}'"
67 | COMMAND_EXPAND_LISTS)
68 |
69 | # Added to public interface for dependees to find.
70 | target_include_directories(${target} PUBLIC ${base_path})
71 | # Added to the target to ensure target rebuilds if header changes and is used
72 | # by sources in the target.
73 | target_sources(${target} PRIVATE ${header_path})
74 | endfunction()
75 |
--------------------------------------------------------------------------------
/cmake/MacOSXBundleInfo.plist.in:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleIdentifier
6 | org.fcitx.inputmethod.Fcitx5
7 | CFBundleName
8 | Fcitx5
9 | CFBundleDisplayName
10 | Fcitx5
11 | CFBundleIconFile
12 | fcitx.icns
13 | NSPrincipalClass
14 | Fcitx5.NSManualApplication
15 | InputMethodConnectionName
16 | org.fcitx.inputmethod.Fcitx5_Connection
17 | InputMethodServerControllerClass
18 | Fcitx5.FcitxInputController
19 |
20 | TICapsLockLanguageSwitchCapable
21 |
22 | LSBackgroundOnly
23 |
24 | tsInputMethodCharacterRepertoireKey
25 |
26 | Latn
27 |
28 | tsInputMethodIconFileKey
29 | menu_icon.pdf
30 | ComponentInputModeDict
31 |
32 | tsInputModeListKey
33 |
34 | org.fcitx.inputmethod.fcitx5
35 |
36 |
37 | tsInputModeMenuIconFileKey
38 | menu_icon.pdf
39 | tsInputModeAlternateMenuIconFileKey
40 | menu_icon.pdf
41 | tsInputModePaletteIconFileKey
42 | menu_icon.pdf
43 |
44 | org.fcitx.inputmethod.zhHans
45 |
46 |
47 | TISIntendedLanguage
48 | zh-Hans
49 | tsInputModeMenuIconFileKey
50 | menu_icon.pdf
51 | tsInputModeAlternateMenuIconFileKey
52 | menu_icon.pdf
53 | tsInputModePaletteIconFileKey
54 | menu_icon.pdf
55 |
56 | org.fcitx.inputmethod.zhHant
57 |
58 |
59 | TISIntendedLanguage
60 | zh-Hant
61 | tsInputModeMenuIconFileKey
62 | menu_icon.pdf
63 | tsInputModeAlternateMenuIconFileKey
64 | menu_icon.pdf
65 | tsInputModePaletteIconFileKey
66 | menu_icon.pdf
67 |
68 |
69 |
70 | NSUserNotificationAlertStyle
71 | alert
72 |
73 |
74 |
--------------------------------------------------------------------------------
/src/config/importtable.swift:
--------------------------------------------------------------------------------
1 | import Fcitx
2 | import Logging
3 | import SwiftUI
4 |
5 | class ImportTableVM: ObservableObject {
6 | // Record IMs and auto add new ones.
7 | @Published private(set) var ims = [String]()
8 | var onError: (String) -> Void = { _ in }
9 | var finalize: () -> Void = {}
10 |
11 | func setHandler(onError: @escaping (String) -> Void, finalize: @escaping () -> Void) {
12 | self.onError = onError
13 | self.finalize = finalize
14 | }
15 |
16 | func load() {
17 | ims = getFileNamesWithExtension(imLocalDir.localPath(), ".conf")
18 | }
19 | }
20 |
21 | private func convertTxt() -> [String] {
22 | let converter = libraryDir.appendingPathComponent("bin/libime_tabledict").localPath()
23 | let tables = getFileNamesWithExtension(tableLocalDir.localPath(), ".txt")
24 | return tables.filter({ table in
25 | let src = tableLocalDir.appendingPathComponent("\(table).txt")
26 | return
27 | !(exec(
28 | converter,
29 | [src.localPath(), tableLocalDir.appendingPathComponent("\(table).dict").localPath()])
30 | && removeFile(src))
31 | })
32 | }
33 |
34 | struct ImportTableView: View {
35 | @Environment(\.presentationMode) var presentationMode
36 |
37 | @ObservedObject private var importTableVM = ImportTableVM()
38 |
39 | func load(onError: @escaping (String) -> Void, finalize: @escaping () -> Void) -> some View {
40 | mkdirP(imLocalDir.localPath())
41 | mkdirP(tableLocalDir.localPath())
42 | importTableVM.setHandler(onError: onError, finalize: finalize)
43 | importTableVM.load()
44 | return self
45 | }
46 |
47 | var body: some View {
48 | VStack(spacing: gapSize) {
49 | Button {
50 | NSWorkspace.shared.open(imLocalDir)
51 | } label: {
52 | Text("Copy \\*.conf to this directory")
53 | }
54 | Button {
55 | NSWorkspace.shared.open(tableLocalDir)
56 | } label: {
57 | Text("Copy \\*.dict/\\*.txt to this directory")
58 | }
59 |
60 | HStack {
61 | Button {
62 | presentationMode.wrappedValue.dismiss()
63 | } label: {
64 | Text("Cancel")
65 | }
66 |
67 | Button {
68 | let existingIMs = Set(importTableVM.ims)
69 | let failures = convertTxt()
70 | importTableVM.load()
71 | let newIMs = importTableVM.ims.filter({ im in !existingIMs.contains(im) })
72 | restartAndReconnect()
73 | if Fcitx.imGroupCount() == 1 {
74 | for im in newIMs {
75 | Fcitx.imAddToCurrentGroup(im)
76 | }
77 | }
78 | presentationMode.wrappedValue.dismiss()
79 | if !failures.isEmpty {
80 | let msg = String(
81 | format: NSLocalizedString("Failed to convert txt table(s): %@", comment: ""),
82 | failures.joined(separator: ", "))
83 | importTableVM.onError(msg)
84 | }
85 | importTableVM.finalize()
86 | } label: {
87 | Text("Reload")
88 | }.buttonStyle(.borderedProminent)
89 | }
90 | }.padding()
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/tests/testtag.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | @_cdecl("main")
4 | func main() -> Int {
5 | // targetTag can be nil, version or latest. When latestAvailable is false, targetTag can't be latest.
6 | // So it's a combination of 2*2*5 = 20 cases.
7 |
8 | // Release to Release
9 | // latest >> stable = current
10 | assert(getTag(currentDebug: false, targetDebug: false, latestAvailable: false, targetTag: nil) == nil)
11 | // latest >> stable > current
12 | assert(getTag(currentDebug: false, targetDebug: false, latestAvailable: false, targetTag: "1") == "1")
13 | // latest = current
14 | assert(getTag(currentDebug: false, targetDebug: false, latestAvailable: true, targetTag: nil) == nil)
15 | // stable > current
16 | assert(getTag(currentDebug: false, targetDebug: false, latestAvailable: true, targetTag: "1") == "1")
17 | // latest > current >= stable
18 | assert(getTag(currentDebug: false, targetDebug: false, latestAvailable: true, targetTag: "latest") == "latest")
19 |
20 | // Release to Debug
21 | // latest >> stable = current
22 | let _ = getTag(currentDebug: false, targetDebug: true, latestAvailable: false, targetTag: nil) // Switch button not clickable.
23 | // latest >> stable > current
24 | let _ = getTag(currentDebug: false, targetDebug: true, latestAvailable: false, targetTag: "1") // Switch button not clickable.
25 | // latest = current
26 | assert(getTag(currentDebug: false, targetDebug: true, latestAvailable: true, targetTag: nil) == "latest")
27 | // stable > current
28 | assert(getTag(currentDebug: false, targetDebug: true, latestAvailable: true, targetTag: "1") == "latest")
29 | // latest > current >= stable
30 | assert(getTag(currentDebug: false, targetDebug: true, latestAvailable: true, targetTag: "latest") == "latest")
31 |
32 | // Debug to Release
33 | // latest >> stable = current
34 | let _ = getTag(currentDebug: true, targetDebug: false, latestAvailable: false, targetTag: nil) // We don't provide debug stable.
35 | // latest >> stable > current
36 | assert(getTag(currentDebug: true, targetDebug: false, latestAvailable: false, targetTag: "1") == "1")
37 | // latest = current
38 | assert(getTag(currentDebug: true, targetDebug: false, latestAvailable: true, targetTag: nil) == "latest")
39 | // stable > current
40 | assert(getTag(currentDebug: true, targetDebug: false, latestAvailable: true, targetTag: "1") == "1")
41 | // latest > current >= stable
42 | assert(getTag(currentDebug: true, targetDebug: false, latestAvailable: true, targetTag: "latest") == "latest")
43 |
44 | // Debug to Debug
45 | // latest >> stable = current
46 | let _ = getTag(currentDebug: true, targetDebug: true, latestAvailable: false, targetTag: nil) // We don't provide debug stable.
47 | // latest >> stable > current
48 | let _ = getTag(currentDebug: true, targetDebug: true, latestAvailable: false, targetTag: "1") // Update button not clickable.
49 | // latest = current
50 | let _ = getTag(currentDebug: true, targetDebug: true, latestAvailable: true, targetTag: nil) // Update button not clickable.
51 | // stable > current
52 | assert(getTag(currentDebug: true, targetDebug: true, latestAvailable: true, targetTag: "1") == "latest")
53 | // latest > current >= stable
54 | assert(getTag(currentDebug: true, targetDebug: true, latestAvailable: true, targetTag: "latest") == "latest")
55 |
56 | return 0
57 | }
58 |
--------------------------------------------------------------------------------
/tests/testkey.cpp:
--------------------------------------------------------------------------------
1 | #include "fcitx-utils/keysym.h"
2 | #include "fcitx-utils/log.h"
3 | #include "keycode.h"
4 |
5 | void test_osx_to_fcitx() {
6 | FCITX_ASSERT(osx_unicode_to_fcitx_keysym('0', 0, 0) == FcitxKey_0);
7 | FCITX_ASSERT(osx_unicode_to_fcitx_keysym('0', 0, OSX_VK_KEYPAD_0) ==
8 | FcitxKey_KP_0);
9 | FCITX_ASSERT(osx_unicode_to_fcitx_keysym('a', 0, 0) == FcitxKey_a);
10 | FCITX_ASSERT(osx_unicode_to_fcitx_keysym('a', OSX_MODIFIER_SHIFT, 0) ==
11 | FcitxKey_A);
12 | FCITX_ASSERT(osx_unicode_to_fcitx_keysym(161 /* ¡ */, OSX_MODIFIER_OPTION,
13 | OSX_VK_KEY_1) == FcitxKey_1);
14 | FCITX_ASSERT(osx_unicode_to_fcitx_keysym(
15 | 8260 /* ⁄ */, OSX_MODIFIER_SHIFT | OSX_MODIFIER_OPTION,
16 | OSX_VK_KEY_1) == FcitxKey_exclam);
17 |
18 | FCITX_ASSERT(osx_keycode_to_fcitx_keycode(OSX_VK_KEY_0) == 11 + 8);
19 | FCITX_ASSERT(osx_keycode_to_fcitx_keycode(OSX_VK_KEYPAD_0) == 82 + 8);
20 | FCITX_ASSERT(osx_keycode_to_fcitx_keycode(OSX_VK_SHIFT_L) == 42 + 8);
21 | FCITX_ASSERT(osx_keycode_to_fcitx_keycode(OSX_VK_SHIFT_R) == 54 + 8);
22 |
23 | FCITX_ASSERT(
24 | osx_modifiers_to_fcitx_keystates(OSX_MODIFIER_CONTROL |
25 | OSX_MODIFIER_SHIFT) ==
26 | (fcitx::KeyStates{} | fcitx::KeyState::Ctrl | fcitx::KeyState::Shift));
27 | }
28 |
29 | void test_fcitx_to_osx() {
30 | FCITX_ASSERT(fcitx_keysym_to_osx_function_key(FcitxKey_Up) == 0xF700);
31 | FCITX_ASSERT(fcitx_keysym_to_osx_function_key(FcitxKey_F12) == 0xF70F);
32 |
33 | FCITX_ASSERT(fcitx_keysym_to_osx_keysym(FcitxKey_Left) == "");
34 | FCITX_ASSERT(fcitx_keysym_to_osx_keysym(FcitxKey_F12) == "");
35 | FCITX_ASSERT(fcitx_keysym_to_osx_keysym(FcitxKey_0) == "0");
36 | FCITX_ASSERT(fcitx_keysym_to_osx_keysym(FcitxKey_KP_0) == "");
37 | FCITX_ASSERT(fcitx_keysym_to_osx_keysym(FcitxKey_grave) == "`");
38 | FCITX_ASSERT(fcitx_keysym_to_osx_keysym(FcitxKey_a) == "a");
39 | FCITX_ASSERT(fcitx_keysym_to_osx_keysym(FcitxKey_A) == "a");
40 |
41 | FCITX_ASSERT(fcitx_keysym_to_osx_keycode(FcitxKey_KP_0) == OSX_VK_KEYPAD_0);
42 | FCITX_ASSERT(fcitx_keysym_to_osx_keycode(FcitxKey_Shift_L) ==
43 | OSX_VK_SHIFT_L);
44 | FCITX_ASSERT(fcitx_keysym_to_osx_keycode(FcitxKey_Shift_R) ==
45 | OSX_VK_SHIFT_R);
46 |
47 | FCITX_ASSERT(fcitx_keystates_to_osx_modifiers(fcitx::KeyStates{} |
48 | fcitx::KeyState::Super |
49 | fcitx::KeyState::Alt) ==
50 | (OSX_MODIFIER_COMMAND | OSX_MODIFIER_OPTION));
51 | }
52 |
53 | void test_fcitx_string() {
54 | FCITX_ASSERT(fcitx_string_to_osx_keysym("Left") == "");
55 | FCITX_ASSERT(fcitx_string_to_osx_keysym("F12") == "");
56 | FCITX_ASSERT(fcitx_string_to_osx_keysym("Control+0") == "0");
57 | FCITX_ASSERT(fcitx_string_to_osx_keysym("Control+Shift+KP_0") == "");
58 | FCITX_ASSERT(fcitx_string_to_osx_keysym("Control+slash") == "/");
59 |
60 | FCITX_ASSERT(fcitx_string_to_osx_modifiers("Control+Super+K") ==
61 | (OSX_MODIFIER_CONTROL | OSX_MODIFIER_COMMAND));
62 |
63 | FCITX_ASSERT(fcitx_string_to_osx_keycode("Alt+Shift+Shift_L") ==
64 | OSX_VK_SHIFT_L);
65 | FCITX_ASSERT(fcitx_string_to_osx_keycode("Shift_R") == OSX_VK_SHIFT_R);
66 | }
67 |
68 | int main() {
69 | test_osx_to_fcitx();
70 | test_fcitx_to_osx();
71 | test_fcitx_string();
72 | }
73 |
--------------------------------------------------------------------------------
/src/config/officialplugins.swift:
--------------------------------------------------------------------------------
1 | // swift-format-ignore-file
2 | import Foundation
3 |
4 | private let Chinese = NSLocalizedString("Chinese", comment: "")
5 | private let English = NSLocalizedString("English", comment: "")
6 | private let Korean = NSLocalizedString("Korean", comment: "")
7 | private let Japanese = NSLocalizedString("Japanese", comment: "")
8 | private let Sinhala = NSLocalizedString("Sinhala", comment: "")
9 | private let Thai = NSLocalizedString("Thai", comment: "")
10 | private let Vietnamese = NSLocalizedString("Vietnamese", comment: "")
11 |
12 | private let Generic = NSLocalizedString("Generic", comment: "")
13 | private let Other = NSLocalizedString("Other", comment: "")
14 |
15 | private let tableExtra = "fcitx/fcitx5-table-extra"
16 |
17 | // NOTE: Currently, It is assumed that all official plugins contain an arch-independent data part,
18 | // which is named as plugin-any.tar.bz2.
19 | // (This will probably remain true for a long time, because at least .conf is there.)
20 | // Should this assumption change in the future, please update install() in plugin.swift.
21 | let officialPlugins = [
22 | Plugin(id: "anthy", category: Japanese, native: true, github: "fcitx/fcitx5-anthy"),
23 | Plugin(id: "array", category: Chinese, native: false, github: tableExtra, dependencies: ["chinese-addons"]),
24 | Plugin(id: "boshiamy", category: Chinese, native: false, github: tableExtra, dependencies: ["chinese-addons"]),
25 | Plugin(id: "cangjie", category: Chinese, native: false, github: tableExtra, dependencies: ["chinese-addons"]),
26 | Plugin(id: "cantonese", category: Chinese, native: false, github: tableExtra, dependencies: ["chinese-addons"]),
27 | Plugin(id: "chinese-addons", category: Chinese, native: true, github: "fcitx/fcitx5-chinese-addons"),
28 | Plugin(id: "hallelujah", category: English, native: true, github: "fcitx-contrib/fcitx5-hallelujah"),
29 | Plugin(id: "jyutping", category: Chinese, native: true, github: "fcitx/libime-jyutping", dependencies: ["chinese-addons"]),
30 | Plugin(id: "lua", category: Other, native: true, github: "fcitx/fcitx5-lua"),
31 | Plugin(id: "quick", category: Chinese, native: false, github: tableExtra, dependencies: ["chinese-addons"]),
32 | Plugin(id: "m17n", category: Generic, native: true, github: "fcitx/fcitx5-m17n"),
33 | Plugin(id: "mozc", category: Japanese, native: true, github: "fcitx/mozc"),
34 | Plugin(id: "rime", category: Generic, native: true, github: "fcitx/fcitx5-rime"),
35 | Plugin(id: "skk", category: Japanese, native: true, github: "fcitx/fcitx5-skk"),
36 | Plugin(id: "stroke", category: Chinese, native: false, github: tableExtra, dependencies: ["chinese-addons"]),
37 | Plugin(id: "thai", category: Thai, native: true, github: "fcitx/fcitx5-libthai"),
38 | Plugin(id: "unikey", category: Vietnamese, native: true, github: "fcitx/fcitx5-unikey"),
39 | Plugin(id: "wu", category: Chinese, native: false, github: tableExtra, dependencies: ["chinese-addons"]),
40 | Plugin(id: "wubi86", category: Chinese, native: false, github: tableExtra, dependencies: ["chinese-addons"]),
41 | Plugin(id: "wubi98", category: Chinese, native: false, github: tableExtra, dependencies: ["chinese-addons"]),
42 | Plugin(id: "zhengma", category: Chinese, native: false, github: tableExtra, dependencies: ["chinese-addons"]),
43 | Plugin(id: "chewing", category: Chinese, native: true, github: "fcitx/fcitx5-chewing"),
44 | Plugin(id: "hangul", category: Korean, native: true, github: "fcitx/fcitx5-hangul"),
45 | Plugin(id: "sayura", category: Sinhala, native: true, github: "fcitx/fcitx5-sayura"),
46 | Plugin(id: "bamboo", category: Vietnamese, native: true, github: "fcitx/fcitx5-bamboo"),
47 | ]
48 |
--------------------------------------------------------------------------------
/src/config/advanced.swift:
--------------------------------------------------------------------------------
1 | import Fcitx
2 | import Logging
3 | import SwiftUI
4 |
5 | class AdvancedController: ConfigWindowController {
6 | let view = AdvancedView()
7 |
8 | convenience init() {
9 | let window = NSWindow(
10 | contentRect: NSRect(x: 0, y: 0, width: configWindowWidth, height: configWindowHeight),
11 | styleMask: styleMask,
12 | backing: .buffered, defer: false)
13 | window.title = NSLocalizedString("Advanced", comment: "")
14 | window.center()
15 | self.init(window: window)
16 | window.contentView = NSHostingView(rootView: view)
17 | window.titlebarAppearsTransparent = true
18 | attachToolbar(window)
19 | }
20 |
21 | override func refresh() {
22 | view.refresh()
23 | }
24 | }
25 |
26 | private struct Addon: Codable, Identifiable {
27 | let name: String
28 | let id: String
29 | let comment: String
30 | }
31 |
32 | private struct Category: Codable, Identifiable {
33 | let name: String
34 | let id: Int
35 | let addons: [Addon]
36 | }
37 |
38 | // non-addon
39 | private let dataManager = NSLocalizedString("Data manager", comment: "")
40 |
41 | struct AdvancedView: View {
42 | @ObservedObject private var viewModel = AdvancedViewModel()
43 |
44 | var body: some View {
45 | NavigationSplitView {
46 | List(selection: $viewModel.selected) {
47 | ForEach([dataManager], id: \.self) { id in
48 | Text(id)
49 | }
50 | ForEach(viewModel.categories) { category in
51 | Section(header: Text(category.name)) {
52 | ForEach(category.addons) { addon in
53 | let text = Text(addon.name)
54 | if !addon.comment.isEmpty {
55 | text.tooltip(addon.comment)
56 | } else {
57 | text
58 | }
59 | }
60 | }
61 | }
62 | }
63 | } detail: {
64 | VStack {
65 | if let selected = viewModel.selected {
66 | if selected == dataManager {
67 | ScrollView {
68 | DataView()
69 | }
70 | } else if let config = viewModel.config {
71 | ScrollView {
72 | buildView(config: config).padding([.leading, .trailing])
73 | }
74 | footer(
75 | reset: {
76 | config.resetToDefault()
77 | },
78 | apply: {
79 | Fcitx.setConfig("fcitx://config/addon/\(selected)", config.encodeValue())
80 | },
81 | close: {
82 | FcitxInputController.closeWindow("advanced")
83 | })
84 | }
85 | }
86 | }.padding([.top], 1)
87 | }
88 | }
89 |
90 | func refresh() {
91 | viewModel.load()
92 | }
93 | }
94 |
95 | class AdvancedViewModel: ObservableObject {
96 | @Published fileprivate var categories = [Category]()
97 | @Published var selected: String? = dataManager {
98 | didSet {
99 | if let selected = selected,
100 | selected != dataManager
101 | {
102 | do {
103 | config = try getConfig(addon: selected)
104 | } catch {
105 | FCITX_ERROR("Couldn't load addon config: \(error)")
106 | }
107 | }
108 | }
109 | }
110 | @Published var config: Config?
111 |
112 | func load() {
113 | do {
114 | let jsonStr = String(Fcitx.getAddons())
115 | if let jsonData = jsonStr.data(using: .utf8) {
116 | categories = try JSONDecoder().decode([Category].self, from: jsonData)
117 | } else {
118 | FCITX_ERROR("Couldn't decode addon config: not UTF-8")
119 | }
120 | } catch {
121 | FCITX_ERROR("Couldn't load addon config: \(error)")
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/assets/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | add_executable(get_im get_im.swift)
2 | add_executable(switch_im switch_im.swift)
3 |
4 | install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/fcitx.icns"
5 | DESTINATION "${CMAKE_INSTALL_PREFIX}/Resources"
6 | )
7 |
8 | add_custom_command(
9 | OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/menu_icon_26.pdf
10 | COMMAND SOURCE_DATE_EPOCH=0 rsvg-convert -f pdf -o ${CMAKE_CURRENT_BINARY_DIR}/menu_icon_26.pdf ${CMAKE_CURRENT_SOURCE_DIR}/penguin-26.svg
11 | DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/penguin-26.svg
12 | )
13 | add_custom_command(
14 | OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/menu_icon_15.pdf
15 | COMMAND SOURCE_DATE_EPOCH=0 rsvg-convert -f pdf -o ${CMAKE_CURRENT_BINARY_DIR}/menu_icon_15.pdf ${CMAKE_CURRENT_SOURCE_DIR}/penguin-15.svg
16 | DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/penguin-15.svg
17 | )
18 | add_custom_target(GeneratePDF ALL
19 | DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/menu_icon_26.pdf ${CMAKE_CURRENT_BINARY_DIR}/menu_icon_15.pdf
20 | )
21 | install(FILES "${CMAKE_CURRENT_BINARY_DIR}/menu_icon_26.pdf"
22 | DESTINATION "${CMAKE_INSTALL_PREFIX}/Resources"
23 | )
24 | install(FILES "${CMAKE_CURRENT_BINARY_DIR}/menu_icon_15.pdf"
25 | DESTINATION "${CMAKE_INSTALL_PREFIX}/Resources"
26 | )
27 | # When upgrade from old version, menu_icon.pdf will be used, so we have to make it the old version.
28 | # Afterwards, script will copy the correct one to overwrite it.
29 | install(FILES "${CMAKE_CURRENT_BINARY_DIR}/menu_icon_15.pdf"
30 | RENAME menu_icon.pdf
31 | DESTINATION "${CMAKE_INSTALL_PREFIX}/Resources"
32 | )
33 |
34 | # Preserve execution permission
35 | install(PROGRAMS "${CMAKE_CURRENT_SOURCE_DIR}/uninstall.sh"
36 | "${CMAKE_CURRENT_SOURCE_DIR}/update.sh"
37 | "${CMAKE_CURRENT_BINARY_DIR}/get_im"
38 | "${CMAKE_CURRENT_BINARY_DIR}/switch_im"
39 | DESTINATION "${CMAKE_INSTALL_PREFIX}/Resources"
40 | )
41 |
42 | install(PROGRAMS "${CMAKE_CURRENT_SOURCE_DIR}/fcitx5-curl"
43 | DESTINATION "${CMAKE_INSTALL_PREFIX}/bin"
44 | )
45 |
46 | install(DIRECTORY "${PREBUILDER_SHARE_DIR}/icons"
47 | DESTINATION "${CMAKE_INSTALL_PREFIX}/share"
48 | )
49 |
50 | install(DIRECTORY "${PREBUILDER_SHARE_DIR}/iso-codes"
51 | DESTINATION "${CMAKE_INSTALL_PREFIX}/share"
52 | )
53 |
54 | install(DIRECTORY "${PREBUILDER_SHARE_DIR}/xkeyboard-config-2"
55 | DESTINATION "${CMAKE_INSTALL_PREFIX}/share"
56 | )
57 |
58 | # iso_639-3.mo and xkeyboard-config.mo
59 | install(DIRECTORY "${PREBUILDER_SHARE_DIR}/locale"
60 | DESTINATION "${CMAKE_INSTALL_PREFIX}/share"
61 | )
62 |
63 | #
64 | # Swift i18n through Localizable.strings
65 | # Use 'GenerateStrings' to update .strings files.
66 | #
67 | set(LOCALES en zh-Hans zh-Hant)
68 |
69 | list(TRANSFORM LOCALES APPEND ".lproj" OUTPUT_VARIABLE LPROJS)
70 | list(TRANSFORM LPROJS PREPEND "${CMAKE_CURRENT_SOURCE_DIR}/" OUTPUT_VARIABLE LPROJ_DIRS)
71 | list(TRANSFORM LPROJ_DIRS APPEND "/Localizable.strings" OUTPUT_VARIABLE LOCALIZABLE_STRINGS_FILES)
72 |
73 | file(GLOB_RECURSE LOCALIZABLE_SWIFT_SOURCES ${CMAKE_SOURCE_DIR}/src/*.swift)
74 | add_custom_command(
75 | OUTPUT ${LOCALIZABLE_STRINGS_FILES}
76 | COMMAND genstrings ${LOCALIZABLE_SWIFT_SOURCES} -SwiftUI -o ${CMAKE_CURRENT_SOURCE_DIR}/en.lproj
77 | COMMAND ${PROJECT_SOURCE_DIR}/scripts/update_translations.py ${LOCALIZABLE_STRINGS_FILES}
78 | DEPENDS ${LOCALIZABLE_SWIFT_SOURCES}
79 | WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
80 | COMMENT "Generating Localizable.strings..."
81 | )
82 | add_custom_target(GenerateStrings
83 | DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/en.lproj/Localizable.strings
84 | )
85 |
86 | install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/Base.lproj"
87 | DESTINATION "${CMAKE_INSTALL_PREFIX}/Resources"
88 | )
89 | foreach(LPROJ_DIR IN LISTS LPROJ_DIRS)
90 | install(DIRECTORY "${LPROJ_DIR}" DESTINATION "${CMAKE_INSTALL_PREFIX}/Resources")
91 | endforeach()
92 |
93 | add_subdirectory(po)
94 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | English
2 | |
3 | [中文](README.zh-CN.md)
4 |
5 | # Fcitx5 macOS
6 |
7 | [Fcitx5](https://github.com/fcitx/fcitx5) input method framework ported to macOS.
8 |
9 | Please download [installer](https://github.com/fcitx-contrib/fcitx5-macos-installer).
10 |
11 | ## Build
12 | Native build on Intel and Apple Silicon is supported.
13 |
14 | This is NOT an Xcode project,
15 | but Xcode is needed for Swift compiler.
16 |
17 | ### Install dependencies
18 | You may use [nvm](https://github.com/nvm-sh/nvm)
19 | to install node, then
20 |
21 | ```sh
22 | sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
23 | brew install cmake ninja extra-cmake-modules gettext librsvg
24 | ./scripts/install-deps.sh
25 | npm i -g pnpm
26 | pnpm --prefix=fcitx5-webview i
27 | ```
28 |
29 | ### Build with CMake
30 | ```sh
31 | cmake -B build/$(uname -m) -G Ninja -DCMAKE_BUILD_TYPE=Debug
32 | cmake --build build/$(uname -m)
33 | sudo cmake --install build/$(uname -m)
34 | ```
35 | After the first time you execute `cmake --install`, you need to logout and login,
36 | then add Fcitx5 in System Setttings -> Keyboard -> Input Sources, Chinese Simplified.
37 |
38 | For installations afterwards, clicking `Restart` in Fcitx5 menu suffices.
39 |
40 | You can also use `Cmd+Shift+B` in VSCode to execute a task.
41 |
42 | ### Code sign
43 | Some features (e.g. notifications, core dump) require the app bundle be code-signed after installation:
44 | ```sh
45 | ./scripts/code-sign.sh
46 | ```
47 |
48 | ## Debug
49 | ### Console.app
50 | * Check `Include Info Messages` and `Include Debug Messages` in `Action` menu.
51 | * Put `FcitxLog` in `Search`.
52 |
53 | ### Log
54 | `/tmp/Fcitx5.log` contains all log in Console.app,
55 | plus those written to stderr by engines, e.g. rime.
56 |
57 | ### lldb
58 | SSH into the mac from another device, then
59 | ```sh
60 | $ /usr/bin/lldb
61 | (lldb) process attach --name Fcitx5
62 | ```
63 |
64 | ### Core dump
65 | ```sh
66 | sudo chmod 1777 /cores
67 | sudo sysctl kern.coredump=1
68 | ulimit -c unlimited # only works for current shell
69 | pkill Fcitx5; /Library/Input\ Methods/Fcitx5.app/Contents/MacOS/Fcitx5
70 | ```
71 |
72 | When Fcitx5 crashes, it creates a ~10GB core file under `/cores`.
73 | ```sh
74 | /usr/bin/lldb -c /cores/core.XXXX
75 | (lldb) bt
76 | ```
77 |
78 | ## Plugins
79 | Fcitx5 only packages keyboard engine.
80 | To install other [engines or tables](https://github.com/fcitx-contrib/fcitx5-plugins),
81 | use the built-in Plugin Manager.
82 |
83 | ## Translation
84 |
85 | ### Swift sources
86 | To update .strings files for each supported locale, run
87 | ```sh
88 | cmake --build build/$(uname -m) --target GenerateStrings
89 | ```
90 |
91 | This will, e.g., update assets/zh-Hans/Localizable.strings, and then the translator can work on it.
92 |
93 | ### C++ sources
94 | First, create assets/po/base.pot file:
95 | ```sh
96 | cmake --build build/$(uname -m) --target pot
97 | ```
98 |
99 | To add a new language, do
100 | ```sh
101 | cd assets/po && msginit -l
102 | ```
103 |
104 | Then, use a PO file editor to translate strings.
105 |
106 | Finally, to merge new strings into PO files, do
107 | ```sh
108 | cd assets/po && msgmerge -U .po base.pot
109 | ```
110 |
111 | ## Credits
112 | * [fcitx5](https://github.com/fcitx/fcitx5): LGPL-2.1-or-later
113 | * [fcitx5-android](https://github.com/fcitx5-android/fcitx5-android): LGPL-2.1-or-later
114 | * [squirrel](https://github.com/rime/squirrel): GPL-3.0-only
115 | * [swift-cmake-examples](https://github.com/apple/swift-cmake-examples): Apache-2.0
116 | * [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON): MIT
117 | * [AlertToast](https://github.com/elai950/AlertToast): MIT
118 | * [pugixml](https://github.com/zeux/pugixml): MIT
119 | * [webview](https://github.com/webview/webview): MIT
120 |
--------------------------------------------------------------------------------
/cmake/InitializeSwift.cmake:
--------------------------------------------------------------------------------
1 | # This source file is part of the Swift open source project
2 | #
3 | # Copyright (c) 2023 Apple Inc. and the Swift project authors.
4 | # Licensed under Apache License v2.0 with Runtime Library Exception
5 | #
6 | # See https://swift.org/LICENSE.txt for license information
7 |
8 | # Compute the name of the architecture directory on Windows from the CMake
9 | # system processor name.
10 | function(_swift_windows_arch_name output_variable_name target_arch)
11 | if(NOT WIN32)
12 | return()
13 | endif()
14 |
15 | if("${target_arch}" STREQUAL "AMD64")
16 | set("${output_variable_name}" "x86_64" PARENT_SCOPE)
17 | elseif("${target_arch}" STREQUAL "ARM64")
18 | set("${output_variable_name}" "aarch64" PARENT_SCOPE)
19 | else()
20 | message(FATAL_ERROR "Unknown windows architecture: ${target_arch}")
21 | endif()
22 | endfunction()
23 |
24 | # Compute flags and search paths
25 | # NOTE: This logic will eventually move to CMake
26 | function(_setup_swift_paths)
27 | # If we haven't set the swift library search paths, do that now
28 | if(NOT SWIFT_LIBRARY_SEARCH_PATHS)
29 | if(APPLE)
30 | set(SDK_FLAGS "-sdk" "${CMAKE_OSX_SYSROOT}")
31 | endif()
32 |
33 | # Note: This does not handle cross-compiling correctly.
34 | # To handle it correctly, we would need to pass the target triple and
35 | # flags to this compiler invocation.
36 | execute_process(
37 | COMMAND ${CMAKE_Swift_COMPILER} ${SDK_FLAGS} -target ${F5M_TARGET} -print-target-info
38 | OUTPUT_VARIABLE SWIFT_TARGET_INFO
39 | )
40 |
41 | # extract search paths from swift driver response
42 | string(JSON SWIFT_TARGET_PATHS GET ${SWIFT_TARGET_INFO} "paths")
43 |
44 | string(JSON SWIFT_TARGET_LIBRARY_PATHS GET ${SWIFT_TARGET_PATHS} "runtimeLibraryPaths")
45 | string(JSON SWIFT_TARGET_LIBRARY_PATHS_LENGTH LENGTH ${SWIFT_TARGET_LIBRARY_PATHS})
46 | math(EXPR SWIFT_TARGET_LIBRARY_PATHS_LENGTH "${SWIFT_TARGET_LIBRARY_PATHS_LENGTH} - 1 ")
47 |
48 | string(JSON SWIFT_TARGET_LIBRARY_IMPORT_PATHS GET ${SWIFT_TARGET_PATHS} "runtimeLibraryImportPaths")
49 | string(JSON SWIFT_TARGET_LIBRARY_IMPORT_PATHS_LENGTH LENGTH ${SWIFT_TARGET_LIBRARY_IMPORT_PATHS})
50 | math(EXPR SWIFT_TARGET_LIBRARY_IMPORT_PATHS_LENGTH "${SWIFT_TARGET_LIBRARY_IMPORT_PATHS_LENGTH} - 1 ")
51 |
52 | string(JSON SWIFT_SDK_IMPORT_PATH ERROR_VARIABLE errno GET ${SWIFT_TARGET_PATHS} "sdkPath")
53 |
54 | foreach(JSON_ARG_IDX RANGE ${SWIFT_TARGET_LIBRARY_PATHS_LENGTH})
55 | string(JSON SWIFT_LIB GET ${SWIFT_TARGET_LIBRARY_PATHS} ${JSON_ARG_IDX})
56 | list(APPEND SWIFT_SEARCH_PATHS ${SWIFT_LIB})
57 | endforeach()
58 |
59 | foreach(JSON_ARG_IDX RANGE ${SWIFT_TARGET_LIBRARY_IMPORT_PATHS_LENGTH})
60 | string(JSON SWIFT_LIB GET ${SWIFT_TARGET_LIBRARY_IMPORT_PATHS} ${JSON_ARG_IDX})
61 | list(APPEND SWIFT_SEARCH_PATHS ${SWIFT_LIB})
62 | endforeach()
63 |
64 | if(SWIFT_SDK_IMPORT_PATH)
65 | list(APPEND SWIFT_SEARCH_PATHS ${SWIFT_SDK_IMPORT_PATH})
66 | endif()
67 |
68 | # Save the swift library search paths
69 | set(SWIFT_LIBRARY_SEARCH_PATHS ${SWIFT_SEARCH_PATHS} CACHE FILEPATH "Swift driver search paths")
70 | endif()
71 |
72 | link_directories(${SWIFT_LIBRARY_SEARCH_PATHS})
73 |
74 | if(WIN32)
75 | _swift_windows_arch_name(SWIFT_WIN_ARCH_DIR "${CMAKE_SYSTEM_PROCESSOR}")
76 | set(SWIFT_SWIFTRT_FILE "$ENV{SDKROOT}/usr/lib/swift/windows/${SWIFT_WIN_ARCH_DIR}/swiftrt.obj")
77 | add_link_options("$<$:${SWIFT_SWIFTRT_FILE}>")
78 | elseif(NOT APPLE)
79 | find_file(SWIFT_SWIFTRT_FILE
80 | swiftrt.o
81 | PATHS ${SWIFT_LIBRARY_SEARCH_PATHS}
82 | NO_CACHE
83 | REQUIRED
84 | NO_DEFAULT_PATH)
85 | add_link_options("$<$:${SWIFT_SWIFTRT_FILE}>")
86 | endif()
87 | endfunction()
88 |
89 | _setup_swift_paths()
90 |
--------------------------------------------------------------------------------
/src/server.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 | import Fcitx
3 | import InputMethodKit
4 | import SwiftFrontend
5 | import SwiftNotify
6 |
7 | class NSManualApplication: NSApplication {
8 | private let appDelegate = AppDelegate()
9 |
10 | override init() {
11 | super.init()
12 | self.delegate = appDelegate
13 | }
14 |
15 | required init?(coder: NSCoder) {
16 | fatalError("Unreachable path")
17 | }
18 | }
19 |
20 | // Redirect stderr to /tmp/Fcitx5.log as it's not captured anyway.
21 | private func redirectStderr() {
22 | let file = fopen("/tmp/Fcitx5.log", "w")
23 | if let file = file {
24 | dup2(fileno(file), STDERR_FILENO)
25 | fclose(file)
26 | }
27 | }
28 |
29 | private func signalHandler(signal: Int32) {
30 | // The signal can be raised on any thread. So we must make sure it's
31 | // routed back to the main thread.
32 | DispatchQueue.main.async {
33 | if signal == SIGTERM {
34 | NSApplication.shared.terminate(nil)
35 | }
36 | }
37 | }
38 |
39 | @main
40 | class AppDelegate: NSObject, NSApplicationDelegate {
41 | static var server = IMKServer()
42 | static var notificationDelegate = NotificationDelegate()
43 | static var statusItem: NSStatusItem?
44 | static var statusItemText: String = "🐧"
45 |
46 | func applicationDidFinishLaunching(_ notification: Notification) {
47 | redirectStderr()
48 |
49 | signal(SIGTERM, signalHandler)
50 |
51 | setStatusItemCallback { mode, text in
52 | // Main thread could call fcitx thread which then calls this, so must be async.
53 | DispatchQueue.main.async { [self] in
54 | if let mode = mode {
55 | if mode == 0 { // Hidden
56 | AppDelegate.statusItem = nil
57 | } else {
58 | // NSStatusItem.variableLength causes layout shift of icons on the left when switching between en and 拼.
59 | let statusItem: NSStatusItem = NSStatusBar.system.statusItem(
60 | withLength: NSStatusItem.squareLength)
61 | AppDelegate.statusItem = statusItem
62 | if let button = statusItem.button {
63 | button.title = AppDelegate.statusItemText
64 | button.target = self
65 | if mode == 1 { // Toggle input method
66 | button.action = #selector(toggle)
67 | } else // Menu
68 | {
69 | let menu = NSMenu()
70 | menu.addItem(
71 | NSMenuItem(
72 | title: NSLocalizedString("Toggle input method", comment: ""),
73 | action: #selector(toggle), keyEquivalent: ""))
74 | menu.addItem(NSMenuItem.separator())
75 | menu.addItem(
76 | NSMenuItem(
77 | title: NSLocalizedString("Hide", comment: ""),
78 | action: #selector(hide), keyEquivalent: ""))
79 | statusItem.menu = menu
80 | }
81 | }
82 | }
83 | }
84 | if let text = text {
85 | AppDelegate.statusItemText = text
86 | if let button = AppDelegate.statusItem?.button {
87 | button.title = text
88 | }
89 | }
90 | }
91 | }
92 |
93 | AppDelegate.server = IMKServer(
94 | name: Bundle.main.infoDictionary?["InputMethodConnectionName"] as? String,
95 | bundleIdentifier: Bundle.main.bundleIdentifier)
96 |
97 | // Initialize notifications.
98 | AppDelegate.notificationDelegate.requestAuthorization()
99 |
100 | let locale = getLocale()
101 | start_fcitx_thread(locale)
102 | }
103 |
104 | func applicationWillTerminate(_ notification: Notification) {
105 | stop_fcitx_thread()
106 | }
107 |
108 | @objc func toggle() {
109 | toggleInputMethod()
110 | }
111 |
112 | @objc func hide() {
113 | Fcitx.setConfig("fcitx://config/addon/macosfrontend", "{\"StatusBar\": \"Hidden\"}")
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | cmake_minimum_required(VERSION 4.0.1)
2 |
3 | project(fcitx5-macos VERSION 0.2.10 LANGUAGES CXX Swift)
4 |
5 | list(PREPEND CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/cmake")
6 | set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
7 |
8 | # On x86 /Library/Frameworks/Mono.framework will mess up libintl.
9 | set(CMAKE_FIND_FRAMEWORK LAST)
10 |
11 | if(NOT CMAKE_OSX_ARCHITECTURES)
12 | set(CMAKE_OSX_ARCHITECTURES "${CMAKE_HOST_SYSTEM_PROCESSOR}")
13 | endif()
14 |
15 | # Starting from cmake 4.0, CMAKE_OSX_SYSROOT defaults to empty.
16 | execute_process(COMMAND xcrun --sdk macosx --show-sdk-path
17 | OUTPUT_VARIABLE CMAKE_OSX_SYSROOT
18 | OUTPUT_STRIP_TRAILING_WHITESPACE
19 | )
20 | set(CMAKE_OSX_DEPLOYMENT_TARGET 13.3)
21 | set(F5M_TARGET "${CMAKE_OSX_ARCHITECTURES}-apple-macos${CMAKE_OSX_DEPLOYMENT_TARGET}")
22 | add_compile_options(-target "${F5M_TARGET}")
23 | add_link_options(-target "${F5M_TARGET}")
24 |
25 | # Disallow InitializeSwift to execute link_directories which adds Xcode paths to rpath.
26 | set(SWIFT_LIBRARY_SEARCH_PATHS "")
27 | include(InitializeSwift)
28 | include(AddSwift)
29 |
30 | set(CMAKE_CXX_STANDARD 20)
31 |
32 | set(FIND_ROOT_PATH "${PROJECT_BINARY_DIR}/usr")
33 | set(PREBUILDER_INCLUDE_DIR "${FIND_ROOT_PATH}/include")
34 | set(PREBUILDER_LIB_DIR "${FIND_ROOT_PATH}/lib")
35 | set(PREBUILDER_SHARE_DIR "${FIND_ROOT_PATH}/share")
36 |
37 | list(APPEND CMAKE_FIND_ROOT_PATH "${FIND_ROOT_PATH}")
38 |
39 | set(ENV{PKG_CONFIG_SYSROOT_DIR} "${PROJECT_BINARY_DIR}")
40 | set(ENV{PKG_CONFIG_PATH} "${PREBUILDER_LIB_DIR}/pkgconfig;${PREBUILDER_SHARE_DIR}/pkgconfig")
41 |
42 | add_library(Libuv_static STATIC IMPORTED)
43 | set_target_properties(Libuv_static PROPERTIES
44 | IMPORTED_LOCATION "${PREBUILDER_LIB_DIR}/libuv.a"
45 | INTERFACE_INCLUDE_DIRECTORIES "${PREBUILDER_INCLUDE_DIR}"
46 | )
47 | set(LIBUV_TARGET Libuv_static)
48 |
49 | option(ENABLE_TESTING_ADDONS "" OFF)
50 | option(ENABLE_TEST "" OFF)
51 | option(ENABLE_COVERAGE "" OFF)
52 | option(ENABLE_ENCHANT "" OFF)
53 | option(ENABLE_X11 "" OFF)
54 | option(ENABLE_WAYLAND "" OFF)
55 | option(ENABLE_DBUS "" OFF)
56 | option(ENABLE_DOC "" OFF)
57 | option(ENABLE_SERVER "" OFF)
58 | option(USE_SYSTEMD "" OFF)
59 | option(ENABLE_XDGAUTOSTART "" OFF)
60 | option(ENABLE_EMOJI "" OFF)
61 | option(ENABLE_LIBUUID "" OFF)
62 | option(ENABLE_ASAN "Enable Address Sanitizer" OFF)
63 |
64 | if(ENABLE_ASAN)
65 | include(AddressSanitizer)
66 | endif()
67 |
68 | set(APP_INSTALL_PATH "/Library/Input Methods")
69 | set(CMAKE_INSTALL_PREFIX "${APP_INSTALL_PATH}/Fcitx5.app/Contents")
70 | set(CMAKE_INSTALL_RPATH "${CMAKE_INSTALL_PREFIX}/lib")
71 | # Reproducible
72 | set(CMAKE_INSTALL_LIBDATADIR "${CMAKE_INSTALL_PREFIX}/lib")
73 |
74 | # Override iso-codes paths and xkb default rules file
75 | set(ISOCODES_ISO639_JSON "${CMAKE_INSTALL_PREFIX}/share/iso-codes/json/iso_639-3.json")
76 | set(XKEYBOARDCONFIG_XKBBASE "${CMAKE_INSTALL_PREFIX}/share/xkeyboard-config-2")
77 | set(XKEYBOARDCONFIG_DATADIR "${CMAKE_INSTALL_PREFIX}/share")
78 |
79 | add_subdirectory(fcitx5)
80 | add_subdirectory(deps)
81 |
82 | add_subdirectory(logging)
83 |
84 | set(WKWEBVIEW_PROTOCOL "fcitx")
85 | set(WEBVIEW_WWW_PATH ".local/share/fcitx5/www")
86 |
87 | include_directories(
88 | fcitx5-webview/include
89 | fcitx5-webview/webview
90 | "${PREBUILDER_INCLUDE_DIR}" # nlohmann-json
91 | )
92 |
93 | add_compile_definitions($<$:-DFCITX_GETTEXT_DOMAIN=\"fcitx5-macos\">)
94 |
95 | add_subdirectory(keycode)
96 | add_subdirectory(macosfrontend)
97 | add_subdirectory(macosnotifications)
98 |
99 | option(BUILD_PREVIEW "" OFF)
100 | add_subdirectory(fcitx5-webview)
101 | add_subdirectory(webpanel)
102 |
103 | set(BUILD_SHARED_FCITX_ADDON OFF)
104 | include(fcitx5/src/lib/fcitx-utils/Fcitx5Macros.cmake)
105 | add_subdirectory(fcitx5-beast/src)
106 |
107 | add_subdirectory(src)
108 | add_subdirectory(assets)
109 |
110 | enable_testing()
111 | add_subdirectory(tests)
112 |
--------------------------------------------------------------------------------
/src/nativestreambuf.h:
--------------------------------------------------------------------------------
1 | /*
2 | * SPDX-License-Identifier: LGPL-2.1-or-later
3 | * SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors
4 |
5 | * SPDX-License-Identifier: GPL-3.0-only
6 | * SPDX-FileCopyrightText: Copyright 2023 Qijia Liu
7 | */
8 | #pragma once
9 |
10 | #include
11 | #include
12 | #include
13 | #include
14 |
15 | // Dynamic content is limited to 256B, see log.h
16 | template
17 | class native_streambuf : public std::streambuf {
18 | public:
19 | using Base = std::streambuf;
20 | using char_type = typename Base::char_type;
21 | using int_type = typename Base::int_type;
22 |
23 | native_streambuf()
24 | : _buffer{},
25 | logger(os_log_create("org.fcitx.inputmethod.Fcitx5", "FcitxLog")) {
26 | Base::setp(_buffer.begin(), _buffer.end() - 1);
27 | }
28 |
29 | // buffer is full but current "line" of log hasn't finished
30 | int_type overflow(int_type ch) override {
31 | // append terminate character to the buffer (usually _buffer.end() when
32 | // overflow)
33 | *Base::pptr() = '\0';
34 | const char *text = Base::pbase();
35 | if (should_offset) {
36 | // it's the first write of this "line", guess priority
37 | update_log_priority(text[0]);
38 | // this write would skip first character
39 | write_log(text);
40 | // consequence write of this "line" should use same priority and
41 | // should not skip characters
42 | should_offset = false;
43 | } else {
44 | // it's not the first write of this "line", so just write
45 | write_log(text);
46 | }
47 | // mark buffer as available, since it's content has been written to
48 | // android log but we need to preserve the last position for '\0' in
49 | // case it overflows
50 | Base::setp(_buffer.begin(), _buffer.end() - 1);
51 | // write 'ch' as char if it's not eof
52 | if (!Base::traits_type::eq_int_type(ch, Base::traits_type::eof())) {
53 | const char_type c = Base::traits_type::to_char_type(ch);
54 | Base::xsputn(&c, 1);
55 | }
56 | return 0;
57 | }
58 |
59 | // current "line" of log has finished, and buffer is not full
60 | int_type sync() override {
61 | *Base::pptr() = '\0';
62 | const char *text = Base::pbase();
63 | if (should_offset) {
64 | // it's the first write of this "line", guess priority
65 | update_log_priority(text[0]);
66 | }
67 | write_log(text);
68 | // this "line" has finished and written to NS log,
69 | // reset state for next "line"
70 | should_offset = true;
71 | // mark buffer as available and preserve last position for '\0'
72 | Base::setp(_buffer.begin(), _buffer.end() - 1);
73 | return 0;
74 | }
75 |
76 | private:
77 | std::array _buffer;
78 | os_log_t logger;
79 | os_log_type_t prio;
80 | /**
81 | * whether the first character in buffer represents log level or not
82 | */
83 | bool should_offset = true;
84 |
85 | void update_log_priority(const char_type first) {
86 | switch (first) {
87 | case 'D':
88 | prio = OS_LOG_TYPE_DEBUG;
89 | break;
90 | case 'I':
91 | prio = OS_LOG_TYPE_INFO;
92 | break;
93 | case 'W':
94 | prio = OS_LOG_TYPE_ERROR;
95 | break;
96 | case 'E':
97 | case 'F':
98 | prio = OS_LOG_TYPE_FAULT;
99 | break;
100 | default:
101 | break;
102 | }
103 | }
104 |
105 | void write_log(const char_type *text) const {
106 | #ifndef NDEBUG
107 | os_log_with_type(logger, prio, "%{public}s",
108 | text + (should_offset ? 1 : 0));
109 | #endif
110 | std::cerr << text;
111 | }
112 | };
113 |
--------------------------------------------------------------------------------
/src/config/FontView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | let genericFamilies = [
4 | "cursive",
5 | "fangsong",
6 | "fantasy",
7 | "kai",
8 | "khmer-mul",
9 | // "math", // Not supported by Safari
10 | "monospace",
11 | "nastaliq",
12 | "sans-serif",
13 | "serif",
14 | "system-ui",
15 | "ui-monospace",
16 | "ui-rounded",
17 | "ui-sans-serif",
18 | "ui-serif",
19 | ]
20 |
21 | struct FontOptionView: OptionView {
22 | let label: String
23 | @ObservedObject var model: FontOption
24 | @State private var selectorIsOpen = false
25 | @State var searchInput = ""
26 | @State var previewInput = NSLocalizedString("Preview", comment: "")
27 |
28 | // If initialize [], the sheet will list nothing on first open.
29 | @State var availableFontFamilies = NSFontManager.shared.availableFontFamilies
30 | var filteredFontFamilies: [String] {
31 | if searchInput.trimmingCharacters(in: .whitespaces).isEmpty {
32 | return availableFontFamilies
33 | } else {
34 | return availableFontFamilies.filter {
35 | $0.localizedCaseInsensitiveContains(searchInput)
36 | || localize($0).localizedCaseInsensitiveContains(searchInput)
37 | }
38 | }
39 | }
40 | @State private var selectedFontFamily: String?
41 |
42 | var body: some View {
43 | Button(action: openSelector) {
44 | if model.value.isEmpty {
45 | Text("Select font")
46 | } else {
47 | Text(localize(model.value))
48 | }
49 | }
50 | .sheet(isPresented: $selectorIsOpen) {
51 | VStack {
52 | TabView {
53 | VStack {
54 | TextField(NSLocalizedString("Search", comment: ""), text: $searchInput)
55 | TextField(NSLocalizedString("Preview", comment: ""), text: $previewInput)
56 | Text(previewInput).font(Font.custom(selectedFontFamily ?? model.value, size: 32)).frame(
57 | height: 64)
58 | List(selection: $selectedFontFamily) {
59 | ForEach(filteredFontFamilies, id: \.self) { family in
60 | HStack {
61 | Text(localize(family)).font(Font.custom(family, size: 14))
62 | Spacer()
63 | Text(localize(family))
64 | }
65 | }
66 | }.contextMenu(forSelectionType: String.self) { items in
67 | } primaryAction: { items in
68 | // Double click
69 | select()
70 | }
71 | }.padding()
72 | .tabItem { Text("Font family") }
73 |
74 | VStack {
75 | List(selection: $selectedFontFamily) {
76 | ForEach(genericFamilies, id: \.self) { family in
77 | Text(family)
78 | }
79 | }.contextMenu(forSelectionType: String.self) { items in
80 | } primaryAction: { items in
81 | // Double click
82 | select()
83 | }
84 | }.padding()
85 | .tabItem { Text("Generic font families") }
86 | }
87 |
88 | HStack {
89 | Button {
90 | selectorIsOpen = false
91 | } label: {
92 | Text("Cancel")
93 | }
94 | Spacer()
95 | Button {
96 | select()
97 | } label: {
98 | Text("Select")
99 | }.buttonStyle(.borderedProminent)
100 | .disabled(selectedFontFamily == nil)
101 | }.padding([.leading, .trailing, .bottom])
102 | }
103 | .padding(.top)
104 | .frame(minWidth: 500, minHeight: 600)
105 | }
106 | }
107 |
108 | private func openSelector() {
109 | availableFontFamilies = NSFontManager.shared.availableFontFamilies
110 | selectorIsOpen = true
111 | }
112 |
113 | private func select() {
114 | if let selectedFontFamily = selectedFontFamily {
115 | model.value = selectedFontFamily
116 | }
117 | selectorIsOpen = false
118 | }
119 |
120 | private func localize(_ fontFamily: String) -> String {
121 | return NSFontManager.shared.localizedName(forFamily: fontFamily, face: nil)
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/config/keyrecorder.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | private let codeMap = [
4 | // keypad
5 | 0x52: "🄋",
6 | 0x53: "➀",
7 | 0x54: "➁",
8 | 0x55: "➂",
9 | 0x56: "➃",
10 | 0x57: "➄",
11 | 0x58: "➅",
12 | 0x59: "➆",
13 | 0x5b: "➇",
14 | 0x5c: "➈",
15 | 0x51: "⊜",
16 | 0x4e: "⊖",
17 | 0x43: "⊗",
18 | 0x45: "⊕",
19 | 0x4b: "⊘",
20 | // special
21 | 0x33: "⌫",
22 | 0x4c: "⌅",
23 | 0x35: "⎋",
24 | 0x75: "⌦",
25 | 0x24: "↵",
26 | 0x31: "␣",
27 | 0x30: "⇥",
28 | // cursor
29 | 0x7e: "▲",
30 | 0x7d: "▼",
31 | 0x7b: "◀",
32 | 0x7c: "▶",
33 | 0x74: "↑",
34 | 0x79: "↓",
35 | 0x73: "⤒",
36 | 0x77: "⤓",
37 | // pc keyboard
38 | 0x72: "⎀",
39 | 0x71: "⎉",
40 | 0x69: "⎙",
41 | 0x6b: "⇳",
42 | ]
43 |
44 | // Separate them because in the menu their font size is smaller and we want the same behavior in recorder UI as well.
45 | private let functionCodeMap = [
46 | 0x7a: "F1",
47 | 0x78: "F2",
48 | 0x63: "F3",
49 | 0x76: "F4",
50 | 0x60: "F5",
51 | 0x61: "F6",
52 | 0x62: "F7",
53 | 0x64: "F8",
54 | 0x65: "F9",
55 | 0x6d: "F10",
56 | 0x67: "F11",
57 | 0x6f: "F12",
58 | ]
59 |
60 | func shortcutRepr(_ key: String, _ modifiers: NSEvent.ModifierFlags, _ code: UInt16) -> (
61 | String, String?
62 | ) {
63 | var desc = ""
64 | if modifiers.contains(.control) { desc += "⌃" }
65 | if modifiers.contains(.option) { desc += "⌥" }
66 | // There could be Shift_L or Shift+Shift_L
67 | // Only when Shift is main key we distinguish L/R.
68 | if code == 0x3c {
69 | desc += "⬆" // Shift_R
70 | } else if code == 0x38 || modifiers.contains(.shift) {
71 | desc += "⇧" // Shift_L
72 | }
73 | if modifiers.contains(.command) { desc += "⌘" }
74 | if let normalFont = codeMap[Int(code)] {
75 | return (desc + normalFont, nil)
76 | } else if let smallerFont = functionCodeMap[Int(code)] {
77 | return (desc, smallerFont)
78 | }
79 | // Use uppercase to match menu.
80 | return (desc + key.uppercased(), nil)
81 | }
82 |
83 | struct RecordingOverlay: NSViewRepresentable {
84 | @Binding var recordedShortcut: (String, String?)
85 | @Binding var recordedKey: String
86 | @Binding var recordedModifiers: NSEvent.ModifierFlags
87 | @Binding var recordedCode: UInt16
88 |
89 | func makeNSView(context: Context) -> NSView {
90 | let view = KeyCaptureView()
91 | view.coordinator = context.coordinator
92 | // Not sure why macOS 15 arm needs this but x86 doesn't.
93 | DispatchQueue.main.async {
94 | view.window?.makeFirstResponder(view)
95 | }
96 | return view
97 | }
98 |
99 | func updateNSView(_ nsView: NSView, context: Context) {
100 | }
101 |
102 | func makeCoordinator() -> Coordinator {
103 | Coordinator(self)
104 | }
105 |
106 | class Coordinator: NSObject {
107 | private var parent: RecordingOverlay
108 | private var key = ""
109 | private var modifiers = NSEvent.ModifierFlags()
110 | private var code: UInt16 = 0
111 |
112 | init(_ parent: RecordingOverlay) {
113 | self.parent = parent
114 | }
115 |
116 | func handleKeyCapture(key: String, code: UInt16) {
117 | self.key = key
118 | self.code = code
119 | updateParent()
120 | }
121 |
122 | func handleKeyCapture(modifiers: NSEvent.ModifierFlags, code: UInt16) {
123 | if modifiers.isDisjoint(with: [.command, .option, .control, .shift]) {
124 | self.modifiers = NSEvent.ModifierFlags()
125 | self.code = 0
126 | } else {
127 | if modifiers.isSuperset(of: self.modifiers) {
128 | // Don't change on release
129 | self.modifiers = modifiers
130 | self.key = ""
131 | self.code = code
132 | }
133 | updateParent()
134 | }
135 | }
136 |
137 | private func updateParent() {
138 | parent.recordedKey = key
139 | parent.recordedModifiers = modifiers
140 | parent.recordedCode = code
141 | parent.recordedShortcut = shortcutRepr(key, modifiers, code)
142 | }
143 | }
144 | }
145 |
146 | class KeyCaptureView: NSView {
147 | weak var coordinator: RecordingOverlay.Coordinator?
148 |
149 | // comment out will focus textfield. What if not textfield?
150 | override var acceptsFirstResponder: Bool {
151 | return true
152 | }
153 |
154 | override func keyDown(with event: NSEvent) {
155 | // For Control+Shift+comma, charactersIgnoringModifiers is less, characters is comma.
156 | // For Shift+comma, both are less.
157 | // This behavior is different with what IM gets.
158 | // We need less for Control+Shift+comma, so we use charactersIgnoringModifiers.
159 | coordinator?.handleKeyCapture(
160 | key: event.charactersIgnoringModifiers ?? "", code: event.keyCode)
161 | }
162 |
163 | override func flagsChanged(with event: NSEvent) {
164 | coordinator?.handleKeyCapture(modifiers: event.modifierFlags, code: event.keyCode)
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/macosnotifications/macosnotifications.h:
--------------------------------------------------------------------------------
1 | #ifndef _FCITX5_MACOS_MACOSNOTIFICATIONS_H_
2 | #define _FCITX5_MACOS_MACOSNOTIFICATIONS_H_
3 |
4 | #include
5 | #include
6 | #include
7 | #include
8 | #include
9 | #include
10 | #include
11 |
12 | #include "macosnotifications-public.h"
13 | #include "notify-swift.h"
14 |
15 | namespace fcitx {
16 |
17 | FCITX_CONFIGURATION(NotificationsConfig,
18 | fcitx::Option> hiddenNotifications{
19 | this, "HiddenNotifications",
20 | _("Hidden Notifications")};);
21 |
22 | struct NotificationItem {
23 | std::string externalId;
24 | uint32_t internalId;
25 | NotificationActionCallback actionCallback;
26 | NotificationClosedCallback closedCallback;
27 | };
28 |
29 | class NotificationTable {
30 | public:
31 | NotificationTable() = default;
32 | ~NotificationTable() = default;
33 |
34 | void insert(NotificationItem item) {
35 | externalToInternal_[item.externalId] = item.internalId;
36 | table_[item.internalId] = std::move(item);
37 | }
38 |
39 | NotificationItem *find(uint32_t internalId) {
40 | if (!table_.count(internalId)) {
41 | return nullptr;
42 | }
43 | return &table_[internalId];
44 | }
45 |
46 | NotificationItem *find(const std::string &externalId) {
47 | if (!externalToInternal_.count(externalId)) {
48 | return nullptr;
49 | }
50 | return find(externalToInternal_[externalId]);
51 | }
52 |
53 | std::optional remove(uint32_t internalId) {
54 | if (table_.count(internalId)) {
55 | auto item = std::move(table_[internalId]);
56 | externalToInternal_.erase(item.externalId);
57 | table_.erase(internalId);
58 | return item;
59 | }
60 | return {};
61 | }
62 |
63 | std::optional remove(const std::string &externalId) {
64 | if (!externalToInternal_.count(externalId)) {
65 | return {};
66 | }
67 | return remove(externalToInternal_[externalId]);
68 | }
69 |
70 | private:
71 | std::unordered_map table_;
72 | std::unordered_map externalToInternal_;
73 | };
74 |
75 | class Notifications final : public AddonInstance {
76 | friend void handleActionResult(const char *notificationId,
77 | const char *actionId) noexcept;
78 | friend void destroyNotificationItem(const char *externalId,
79 | uint32_t closedReason) noexcept;
80 |
81 | public:
82 | Notifications(Instance *instance);
83 | ~Notifications() = default;
84 |
85 | Instance *instance() { return instance_; }
86 |
87 | void updateConfig();
88 | void reloadConfig() override;
89 | void save() override;
90 | const Configuration *getConfig() const override { return &config_; }
91 |
92 | void setConfig(const RawConfig &config) override {
93 | config_.load(config, true);
94 | safeSaveAsIni(config_, ConfPath);
95 | updateConfig();
96 | }
97 |
98 | uint32_t sendNotification(const std::string &appName, uint32_t replaceId,
99 | const std::string &appIcon,
100 | const std::string &summary,
101 | const std::string &body,
102 | const std::vector &actions,
103 | int32_t timeout,
104 | NotificationActionCallback actionCallback,
105 | NotificationClosedCallback closedCallback);
106 |
107 | void showTip(const std::string &tipId, const std::string &appName,
108 | const std::string &appIcon, const std::string &summary,
109 | const std::string &body, int32_t timeout);
110 |
111 | void closeNotification(uint64_t internalId);
112 |
113 | private:
114 | FCITX_ADDON_EXPORT_FUNCTION(Notifications, sendNotification);
115 | FCITX_ADDON_EXPORT_FUNCTION(Notifications, showTip);
116 | FCITX_ADDON_EXPORT_FUNCTION(Notifications, closeNotification);
117 |
118 | static const inline std::string ConfPath = "conf/macosnotifications.conf";
119 |
120 | NotificationsConfig config_;
121 | Instance *instance_;
122 | std::unique_ptr iconTheme_;
123 |
124 | Flags capabilities_;
125 | std::unordered_set hiddenNotifications_;
126 |
127 | int lastTipId_ = 0;
128 | uint32_t internalId_ = 0;
129 | NotificationTable itemTable_;
130 | }; // class Notifications
131 |
132 | class MacosNotificationsFactory : public AddonFactory {
133 | AddonInstance *create(AddonManager *manager) override {
134 | return new Notifications(manager->instance());
135 | }
136 | };
137 |
138 | } // namespace fcitx
139 |
140 | #endif
141 |
--------------------------------------------------------------------------------
/src/config/menu.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 | import Fcitx
3 |
4 | func restartAndReconnect(_ actionBetween: (() -> Void)? = nil) {
5 | stop_fcitx_thread()
6 | actionBetween?()
7 | start_fcitx_thread(nil)
8 | for controller in FcitxInputController.registry.allObjects {
9 | controller.reconnectToFcitx()
10 | }
11 | }
12 |
13 | // Don't call it synchronously in SwiftUI as it will make IM temporarily unavailable in focused client.
14 | func restartProcess() {
15 | NSApp.terminate(nil)
16 | }
17 |
18 | extension FcitxInputController {
19 | static var controllers = [String: ConfigWindowController]()
20 |
21 | func openWindow(_ key: String, _ type: ConfigWindowController.Type) {
22 | var controller = FcitxInputController.controllers[key]
23 | if controller == nil {
24 | controller = type.init()
25 | controller?.setKey(key)
26 | FcitxInputController.controllers[key] = controller
27 | }
28 | controller?.refresh()
29 | controller?.showWindow(nil)
30 | }
31 |
32 | static func closeWindow(_ key: String) {
33 | FcitxInputController.controllers[key]?.window?.performClose(nil)
34 | }
35 |
36 | @objc func plugin(_: Any? = nil) {
37 | openWindow("plugin", PluginManager.self)
38 | }
39 |
40 | @objc func restart(_: Any? = nil) {
41 | restartProcess()
42 | }
43 |
44 | @objc func about(_: Any? = nil) {
45 | openWindow("about", FcitxAboutController.self)
46 | }
47 |
48 | @objc func globalConfig(_: Any? = nil) {
49 | openWindow("global", GlobalConfigController.self)
50 | }
51 |
52 | @objc func inputMethod(_: Any? = nil) {
53 | openWindow("im", InputMethodConfigController.self)
54 | }
55 |
56 | @objc func themeEditor(_: Any? = nil) {
57 | openWindow("theme", ThemeEditorController.self)
58 | }
59 |
60 | @objc func advanced(_: Any? = nil) {
61 | openWindow("advanced", AdvancedController.self)
62 | }
63 | }
64 |
65 | /// All config window controllers should subclass this. It sets up
66 | /// application states so that the config windows can receive user
67 | /// input.
68 | class ConfigWindowController: NSWindowController, NSWindowDelegate, NSToolbarDelegate {
69 | var key: String = ""
70 |
71 | override init(window: NSWindow?) {
72 | super.init(window: window)
73 | if let window = window {
74 | window.delegate = self
75 | }
76 | }
77 |
78 | required init?(coder: NSCoder) {
79 | super.init(coder: coder)
80 | }
81 |
82 | override func showWindow(_ sender: Any? = nil) {
83 | if let window = window {
84 | // Switch to normal activation policy so that the config windows
85 | // can receive key events.
86 | if NSApp.activationPolicy() != .regular {
87 | NSApp.setActivationPolicy(.regular)
88 | }
89 |
90 | window.makeKeyAndOrderFront(nil)
91 | NSApp.activate(ignoringOtherApps: true)
92 | }
93 | }
94 |
95 | func windowShouldClose(_ sender: NSWindow) -> Bool {
96 | sender.orderOut(nil)
97 | // Free memory and reset state.
98 | FcitxInputController.controllers.removeValue(forKey: key)
99 | // Switch back.
100 | if FcitxInputController.controllers.count == 0 {
101 | NSApp.setActivationPolicy(.prohibited)
102 | }
103 | return false
104 | }
105 |
106 | func attachToolbar(_ window: NSWindow) {
107 | // Prior to macOS 14.0, NSHostingView doesn't host toolbars, and
108 | // we have to create a toolbar manually.
109 | //
110 | // Cannot use #available check here because it's a runtime check,
111 | // but the following code should work nevertheless: NSHostingView
112 | // will replace the toolbar if it works.
113 | let toolbar = NSToolbar(identifier: "MainToolbar")
114 | toolbar.delegate = self
115 | toolbar.displayMode = .iconOnly
116 | toolbar.showsBaselineSeparator = false
117 | window.toolbar = toolbar
118 | window.toolbarStyle = .unified
119 | }
120 |
121 | func toolbar(
122 | _ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
123 | willBeInsertedIntoToolbar flag: Bool
124 | ) -> NSToolbarItem? {
125 | if itemIdentifier == .toggleSidebar {
126 | let item = NSToolbarItem(itemIdentifier: .toggleSidebar)
127 | item.label = NSLocalizedString("Toggle Sidebar", comment: "label")
128 | item.paletteLabel = NSLocalizedString("Toggle Sidebar", comment: "label")
129 | item.toolTip = NSLocalizedString("Toggle the visibility of the sidebar", comment: "tooltip")
130 | item.target = self
131 | item.action = #selector(toggleSidebar)
132 | return item
133 | }
134 | return nil
135 | }
136 |
137 | func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
138 | return [.toggleSidebar, .flexibleSpace]
139 | }
140 |
141 | func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
142 | return [.toggleSidebar, .flexibleSpace]
143 | }
144 |
145 | @objc func toggleSidebar(_ sender: Any?) {
146 | // Wow, we don't have to do anything here.
147 | }
148 |
149 | func setKey(_ key: String) {
150 | self.key = key
151 | }
152 |
153 | func refresh() {}
154 | }
155 |
--------------------------------------------------------------------------------
/macosnotifications/macosnotifications.cpp:
--------------------------------------------------------------------------------
1 | #include
2 | #include
3 |
4 | #include "fcitx.h"
5 | #include "macosnotifications.h"
6 |
7 | namespace fcitx {
8 |
9 | Notifications::Notifications(Instance *instance)
10 | : instance_(instance),
11 | iconTheme_(std::make_unique("hicolor")) {
12 | reloadConfig();
13 | }
14 |
15 | void Notifications::updateConfig() {
16 | hiddenNotifications_.clear();
17 | for (const auto &id : config_.hiddenNotifications.value()) {
18 | hiddenNotifications_.insert(id);
19 | }
20 | }
21 |
22 | void Notifications::reloadConfig() {
23 | readAsIni(config_, ConfPath);
24 | updateConfig();
25 | }
26 |
27 | void Notifications::save() {
28 | std::vector values_;
29 | for (const auto &id : hiddenNotifications_) {
30 | values_.push_back(id);
31 | }
32 | config_.hiddenNotifications.setValue(std::move(values_));
33 | safeSaveAsIni(config_, ConfPath);
34 | }
35 |
36 | uint32_t Notifications::sendNotification(
37 | const std::string &appName, uint32_t replaceId, const std::string &appIcon,
38 | const std::string &summary, const std::string &body,
39 | const std::vector &actions, int32_t timeout,
40 | NotificationActionCallback actionCallback,
41 | NotificationClosedCallback closedCallback) {
42 |
43 | if (itemTable_.find(replaceId)) {
44 | closeNotification(replaceId);
45 | }
46 |
47 | if (timeout < 0) {
48 | timeout = 60 * 1000; // 1 minute
49 | }
50 |
51 | // Record a notification item to store callbacks.
52 | auto internalId = ++internalId_;
53 | std::string externalId = appName + "-" + std::to_string(internalId_);
54 | NotificationItem item{externalId, internalId, actionCallback,
55 | closedCallback};
56 | itemTable_.insert(item);
57 |
58 | // Find appIcon file.
59 | static const std::vector iconExtensions{".png"};
60 | auto iconPath = iconTheme_->findIconPath(appIcon, 48, 1, iconExtensions);
61 |
62 | // Send the notification.
63 | std::vector cActionStrings;
64 | for (const auto &action : actions) {
65 | cActionStrings.push_back(action.c_str());
66 | }
67 | SwiftNotify::sendNotificationProxy(
68 | externalId.c_str(), iconPath.c_str(), summary.c_str(), body.c_str(),
69 | cActionStrings.data(), cActionStrings.size(), timeout);
70 |
71 | return internalId_;
72 | }
73 |
74 | void Notifications::showTip(const std::string &tipId,
75 | const std::string &appName,
76 | const std::string &appIcon,
77 | const std::string &summary, const std::string &body,
78 | int32_t timeout) {
79 | if (hiddenNotifications_.count(tipId)) {
80 | return;
81 | }
82 | std::vector actions = {"dont-show", "Do not show again"};
83 | lastTipId_ = sendNotification(
84 | appName, lastTipId_, appIcon, summary, body, actions, timeout,
85 | [this, tipId](const std::string &action) {
86 | if (action == "dont-show") {
87 | FCITX_DEBUG() << "Dont show clicked: " << tipId;
88 | if (hiddenNotifications_.insert(tipId).second) {
89 | save();
90 | }
91 | }
92 | },
93 | {});
94 | }
95 |
96 | void Notifications::closeNotification(uint64_t internalId) {
97 | if (auto item = itemTable_.remove(internalId)) {
98 | SwiftNotify::closeNotification(item->externalId,
99 | NOTIFICATION_CLOSED_REASON_CLOSED);
100 | // This function will call back to destroyNotificationItem, so
101 | // closedCallback will be called.
102 | }
103 | }
104 |
105 | /// Called by NotificationDelegate.userNotificationCenter when there
106 | /// is an action result. This function is merely a bridge to call the
107 | /// global MacosNotifications instance, because it is impossible to
108 | /// call C++ code directly from Swift code.
109 | void handleActionResult(const char *externalId, const char *actionId) noexcept {
110 | with_fcitx([=](Fcitx &fcitx) {
111 | auto that = dynamic_cast(fcitx.addon("notifications"));
112 | if (auto item = that->itemTable_.find(externalId)) {
113 | if (item->actionCallback) {
114 | item->actionCallback(actionId);
115 | }
116 | }
117 | });
118 | }
119 |
120 | /// Called by NotificationDelegate.closeNotification to release the
121 | /// notification item.
122 | void destroyNotificationItem(const char *externalId,
123 | uint32_t closedReason) noexcept {
124 | with_fcitx([=](Fcitx &fcitx) {
125 | auto that = dynamic_cast(fcitx.addon("notifications"));
126 | auto item = that->itemTable_.remove(externalId);
127 | if (item && item->closedCallback) {
128 | item->closedCallback(closedReason);
129 | }
130 | });
131 | }
132 |
133 | } // namespace fcitx
134 |
135 | FCITX_ADDON_FACTORY_V2(notifications, fcitx::MacosNotificationsFactory);
136 |
--------------------------------------------------------------------------------
/src/config/ui.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UniformTypeIdentifiers
3 |
4 | let sectionHeaderSize: CGFloat = 16
5 | let gapSize: CGFloat = 10
6 | let checkboxColumnWidth: CGFloat = 20
7 | let minKeywordColumnWidth: CGFloat = 80
8 | let minPhraseColumnWidth: CGFloat = 160
9 | let configWindowWidth: CGFloat = 800
10 | let configWindowHeight: CGFloat = 600
11 |
12 | let styleMask: NSWindow.StyleMask = [.titled, .closable, .resizable, .fullSizeContentView]
13 |
14 | extension View {
15 | func tooltip(_ text: String) -> some View {
16 | HStack {
17 | self
18 | Image(systemName: "questionmark.circle.fill").help(text)
19 | }
20 | }
21 |
22 | // Enlarge clickable area for border-less icon button, especially minus.
23 | func square() -> some View {
24 | self.frame(width: 20, height: 20).background(Color.black.opacity(0.001))
25 | }
26 | }
27 |
28 | func footer(reset: @escaping () -> Void, apply: @escaping () -> Void, close: @escaping () -> Void)
29 | -> some View
30 | {
31 | return HStack {
32 | Button {
33 | reset()
34 | } label: {
35 | Text("Reset to default").tooltip(
36 | NSLocalizedString(
37 | "Reset current page. To reset a single item/group, right click on its label.", comment: ""
38 | ))
39 | }
40 | Button {
41 | close()
42 | } label: {
43 | Text("Cancel")
44 | }
45 | Spacer()
46 | Button {
47 | apply()
48 | } label: {
49 | Text("Apply")
50 | }
51 | Button {
52 | apply()
53 | close()
54 | } label: {
55 | Text("OK")
56 | }
57 | .buttonStyle(.borderedProminent)
58 | }.padding()
59 | }
60 |
61 | func urlButton(_ text: String, _ link: String) -> some View {
62 | Link(text, destination: URL(string: link)!)
63 | }
64 |
65 | struct SelectFileButton