├── 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 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /assets/penguin-26.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 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