Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
428bac9
feat(i18n): add French (fr) localization
Sep 10, 2026
ec0631d
Merge pull request #149 from Dam0rt/feature/french-localization
wflixu Sep 12, 2026
1c763a2
feat(menu): support declarative hierarchical tree menu via JSON
Juns-g Sep 12, 2026
175d3a2
feat(settings): add Advanced Menu Layout section with config actions …
Juns-g Sep 12, 2026
1123e2e
fix: observe mounted volumes and their lifecycle in Finder Sync
Juns-g Sep 12, 2026
aafec3b
Merge pull request #151 from Juns-g/feature/hierarchical-tree-menu
wflixu Sep 13, 2026
bed1621
Merge pull request #152 from Juns-g/fix/external-drives-volumes-obser…
wflixu Sep 13, 2026
dd8eef8
Merge remote-tracking branch 'origin/dev' into dev
wflixu Sep 13, 2026
27656dd
chore(i18n): normalize string catalog
wflixu Sep 13, 2026
ea2d4b2
chore: bump version to 2.2.1
wflixu Sep 13, 2026
7e4aafa
refactor(menu): attach menu item paths to custom menu errors
wflixu Sep 13, 2026
6dbbe07
chore: drop unused menu config snapshot
wflixu Sep 13, 2026
ece85f8
feat(settings): apply and restore the custom menu layout
wflixu Sep 13, 2026
19b05f5
fix(settings): ask before enabling the custom menu, and stop adding a…
wflixu Sep 13, 2026
4ed2984
feat(menu): apply the custom menu on request instead of on a timer
wflixu Sep 13, 2026
315f6fd
style(settings): lead with launch-at-login and use a clearer folder icon
wflixu Sep 13, 2026
7dc8a3e
fix(defaults): keep the layout toggles in the shared app group
wflixu Sep 13, 2026
9da3517
fix(settings): make a failed save visible and never skip the menu ref…
wflixu Sep 13, 2026
9c180f6
feat(i18n): localize the built-in new-file names and prune the dead keys
wflixu Sep 13, 2026
97c618a
chore(i18n): adopt Xcode's canonical key ordering
wflixu Sep 13, 2026
9bc5c98
feat(settings): redesign the About tab and stop it lying about updates
wflixu Sep 13, 2026
b7989cc
fix(i18n): give v%@ the translations scripts/check-localization.py as…
wflixu Sep 13, 2026
f27a931
chore: bump build to 20260913002 and write the 2.2.1 changelog
wflixu Sep 13, 2026
e3375d8
docs: delete the design specs that shipped refactors made obsolete
wflixu Sep 13, 2026
583dba0
docs: credit recent contributors and fix the drifted localization notes
wflixu Sep 13, 2026
d689fd2
fix(build): stop shipping the project's docs inside the app bundles
wflixu Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ xcodebuild -project RClick.xcodeproj -scheme RClick -destination 'platform=macOS

## Checklist

- [ ] My code follows the project's Swift 6.2 conventions (see [CONTRIBUTING.md](CONTRIBUTING.md))
- [ ] My code follows the project's Swift 6.2 conventions (see [CONTRIBUTING.md](../CONTRIBUTING.md))
- [ ] All UI is SwiftUI (no AppKit UI components)
- [ ] I have tested on macOS 15.6+
- [ ] I have tested with both light and dark mode
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ All notable changes to RClick are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/).

## [2.2.1] - 2026-09-13

### Added
- Custom menu layout: arrange top-level and nested items in `custom_menu.json`
- French localization
- Simplified Chinese, Japanese and Spanish names for the built-in new-file types (Word 文档 / Excel 表格 / PowerPoint 演示文稿)
- About: live star count and a link to the issue tracker

### Changed
- Settings: the custom menu is applied only when you press "Apply", so editing the file no longer changes the Finder menu mid-keystroke
- Settings: the About tab is rebuilt around the update status, with the description folded into the identity block
- Settings: launch-at-login now leads the General toggles
- Menu layout switches moved into the shared app group, alongside the other preferences

### Fixed
- Adding or deleting an app could leave the Finder menu unchanged until the next heartbeat
- A failed save was silent; it now surfaces in the settings window
- Checking for updates reported "Failed to Check for Updates" even when the app was simply up to date
- A network failure during an update check was reported as "already up to date"
- The Japanese "Open With" submenu fell back to English
- AirDrop was untranslated in Simplified Chinese
- A one-time migration preserves existing menu layout settings after their move to the app group

### Removed
- 20 localization keys that nothing referenced
- The extension's string catalog no longer carries 131 unused copies of the app's strings

## [2.1.0] - 2026-07-12

### Changed
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,13 @@ When working with files outside the sandbox container (required for the App Stor
### Localization
- Default language is **English** (also the fallback)
- **Simplified Chinese** (`zh-Hans`) is the primary localization target
- **Japanese** (`ja`) is supported — activated when system language is Japanese
- **Japanese** (`ja`), **Spanish** (`es`) and **French** (`fr`) are also supported, activated when the system language matches
- All string keys in code use English, localized via `Localizable.xcstrings` (xcstrings format)
- Language detection is fully automatic via `Bundle.main.localizedString` — no manual language picker
- To add a new language: add entries to `Localizable.xcstrings` and register in Xcode project
- There are **two** catalogs — `RClick/` (app) and `FinderSyncExt/` (extension). Each target resolves against its own via `Bundle.main`, so a key the extension renders must exist in the extension's catalog too
- `scripts/check-localization.py` verifies the catalogs; run it before a release
- Edit the catalogs with exact-string text edits. Re-serializing the JSON rewrites the whole file and does not round-trip byte-for-byte

### Data Persistence
- SwiftData models use `@Model` macro
Expand Down
124 changes: 72 additions & 52 deletions FinderSyncExt/FinderSyncExt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ private let logger = Logger(
/// 只负责菜单渲染和事件转发,不读取 SwiftData
class FinderSyncExt: FIFinderSync, @unchecked Sendable {

private var volumeObserver: MountedVolumeObserver?

// MARK: - Properties

/// 菜单配置缓存(内存缓存,从 Main App 推送)
Expand Down Expand Up @@ -84,19 +86,17 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable {

// MARK: - Directory Observing

/// 设置监听目录(全盘监听)
///
/// Observing "/" covers every reachable folder — /Users, /Applications,
/// /opt, /tmp, external and network volumes (mounted under /Volumes) —
/// which is what the original per-path list intended but missed for
/// anything on the system volume outside /Users. FileProvider-backed
/// locations (iCloud Drive, synced Desktop & Documents) still get no
/// FinderSync menus; that is a macOS restriction on all FinderSync
/// extensions, not something an observation URL can change.
/// Register each mounted volume explicitly; filesystem ancestry alone is not
/// a reliable observation boundary for Finder Sync across mount points.
private func setupObservingDirectories() {
let directories: Set<URL> = [URL(fileURLWithPath: "/")]
FIFinderSyncController.default().directoryURLs = directories
logger.info("Observing directories: \(directories.map { $0.path })")
// Finder Sync requires an initial set during extension startup.
FIFinderSyncController.default().directoryURLs = [URL(fileURLWithPath: "/")]
Task { @MainActor [weak self] in
self?.volumeObserver = MountedVolumeObserver { directories in
FIFinderSyncController.default().directoryURLs = directories
logger.info("Observing \(directories.count) filesystem roots")
}
}
}

// MARK: - Message Handling
Expand Down Expand Up @@ -269,8 +269,11 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable {
if let appURL = app.appURL {
let cacheKey = "app:\(appURL)"
if let cached = iconCache[cacheKey] { return cached }
let icon: NSImage = DispatchQueue.main.sync {
NSWorkspace.shared.icon(forFile: appURL)
let icon: NSImage
if Thread.isMainThread {
icon = MainActor.assumeIsolated { NSWorkspace.shared.icon(forFile: appURL) }
} else {
icon = DispatchQueue.main.sync { NSWorkspace.shared.icon(forFile: appURL) }
}
if icon.size.width > 0 {
iconCache[cacheKey] = icon
Expand Down Expand Up @@ -342,19 +345,26 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable {
return menu
}

if let nodes = config.customMenu {
CustomMenu.render(nodes, into: menu, makeItem: { type, id in
switch type {
case .action: return config.actions.first { $0.id == id }.map(self.makeActionItem)
case .app: return config.apps.first { $0.id == id }.map(self.makeAppItem)
case .newFile: return config.newFiles.first { $0.id == id }.map(self.makeNewFileItem)
case .commonDir: return config.commonDirs.first { $0.id == id }.map(self.makeCommonDirItem)
}
}, loadIcon: { self.loadIcon(named: $0, accessibilityDescription: $0) })
return menu
}

// 构建动作菜单
if !config.actions.isEmpty {
if config.actionsCollapsed {
// 折叠:使用子菜单
let actionsTitle = AppLocalization.localized("Actions")
let actionsSubMenu = NSMenu(title: actionsTitle)
for action in config.actions {
let item = NSMenuItem(title: action.name, action: #selector(handleActionClick(_:)), keyEquivalent: "")
item.tag = hashForAction(action)
item.target = self
if let icon = templateSymbol(action.icon) {
item.image = icon
}
let item = makeActionItem(action)
actionsSubMenu.addItem(item)
}
let actionsItem = NSMenuItem(title: actionsTitle, action: nil, keyEquivalent: "")
Expand All @@ -364,12 +374,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable {
} else {
// 不折叠:直接显示菜单项
for action in config.actions {
let item = NSMenuItem(title: action.name, action: #selector(handleActionClick(_:)), keyEquivalent: "")
item.tag = hashForAction(action)
item.target = self
if let icon = templateSymbol(action.icon) {
item.image = icon
}
let item = makeActionItem(action)
menu.addItem(item)
}
}
Expand All @@ -382,10 +387,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable {
let appsTitle = AppLocalization.localized("Open With")
let appsSubMenu = NSMenu(title: appsTitle)
for app in config.apps {
let item = NSMenuItem(title: app.name, action: #selector(handleAppClick(_:)), keyEquivalent: "")
item.tag = hashForApp(app)
item.target = self
item.image = cachedAppIcon(app: app)
let item = makeAppItem(app)
appsSubMenu.addItem(item)
}
let appsItem = NSMenuItem(title: appsTitle, action: nil, keyEquivalent: "")
Expand All @@ -395,10 +397,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable {
} else {
// 不折叠:直接显示菜单项
for app in config.apps {
let item = NSMenuItem(title: app.name, action: #selector(handleAppClick(_:)), keyEquivalent: "")
item.tag = hashForApp(app)
item.target = self
item.image = cachedAppIcon(app: app)
let item = makeAppItem(app)
menu.addItem(item)
}
}
Expand All @@ -411,11 +410,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable {
let newFilesTitle = AppLocalization.localized("New File")
let newFilesSubMenu = NSMenu(title: newFilesTitle)
for newFile in config.newFiles {
let item = NSMenuItem(title: newFile.name, action: #selector(handleNewFileClick(_:)), keyEquivalent: "")
item.tag = hashForNewFile(newFile)
item.target = self
item.image = iconProvider.icon(for: newFile.ext, fallbackSymbol: newFile.icon)
item.image?.accessibilityDescription = newFile.name
let item = makeNewFileItem(newFile)
newFilesSubMenu.addItem(item)
}
let newFilesItem = NSMenuItem(title: newFilesTitle, action: nil, keyEquivalent: "")
Expand All @@ -425,11 +420,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable {
} else {
// 不折叠:直接显示菜单项
for newFile in config.newFiles {
let item = NSMenuItem(title: newFile.name, action: #selector(handleNewFileClick(_:)), keyEquivalent: "")
item.tag = hashForNewFile(newFile)
item.target = self
item.image = iconProvider.icon(for: newFile.ext, fallbackSymbol: newFile.icon)
item.image?.accessibilityDescription = newFile.name
let item = makeNewFileItem(newFile)
menu.addItem(item)
}
}
Expand All @@ -442,10 +433,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable {
let commonDirsTitle = AppLocalization.localized("Common Dirs")
let commonDirsSubMenu = NSMenu(title: commonDirsTitle)
for commonDir in config.commonDirs {
let item = NSMenuItem(title: commonDir.name, action: #selector(handleCommonDirClick(_:)), keyEquivalent: "")
item.tag = hashForCommonDir(commonDir)
item.target = self
item.image = loadIcon(named: commonDir.icon, accessibilityDescription: commonDir.name)
let item = makeCommonDirItem(commonDir)
commonDirsSubMenu.addItem(item)
}
let commonDirsItem = NSMenuItem(title: commonDirsTitle, action: nil, keyEquivalent: "")
Expand All @@ -455,10 +443,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable {
} else {
// 不折叠:直接显示菜单项
for commonDir in config.commonDirs {
let item = NSMenuItem(title: commonDir.name, action: #selector(handleCommonDirClick(_:)), keyEquivalent: "")
item.tag = hashForCommonDir(commonDir)
item.target = self
item.image = loadIcon(named: commonDir.icon, accessibilityDescription: commonDir.name)
let item = makeCommonDirItem(commonDir)
menu.addItem(item)
}
}
Expand All @@ -467,6 +452,41 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable {
return menu
}

private func makeActionItem(_ action: ActionMenuItem) -> NSMenuItem {
let item = NSMenuItem(title: action.name, action: #selector(handleActionClick(_:)), keyEquivalent: "")
item.tag = hashForAction(action)
item.target = self
if let icon = templateSymbol(action.icon) {
item.image = icon
}
return item
}

private func makeAppItem(_ app: AppMenuItem) -> NSMenuItem {
let item = NSMenuItem(title: app.name, action: #selector(handleAppClick(_:)), keyEquivalent: "")
item.tag = hashForApp(app)
item.target = self
item.image = cachedAppIcon(app: app)
return item
}

private func makeNewFileItem(_ newFile: NewFileMenuItem) -> NSMenuItem {
let item = NSMenuItem(title: newFile.name, action: #selector(handleNewFileClick(_:)), keyEquivalent: "")
item.tag = hashForNewFile(newFile)
item.target = self
item.image = iconProvider.icon(for: newFile.ext, fallbackSymbol: newFile.icon)
item.image?.accessibilityDescription = newFile.name
return item
}

private func makeCommonDirItem(_ commonDir: CommonDirMenuItem) -> NSMenuItem {
let item = NSMenuItem(title: commonDir.name, action: #selector(handleCommonDirClick(_:)), keyEquivalent: "")
item.tag = hashForCommonDir(commonDir)
item.target = self
item.image = loadIcon(named: commonDir.icon, accessibilityDescription: commonDir.name)
return item
}

// MARK: - Menu Item Hash Functions

private func hashForAction(_ action: ActionMenuItem) -> Int {
Expand Down
Loading