From 5036adc8c7cfc34854e04897b86ca00445454711 Mon Sep 17 00:00:00 2001 From: wflixu Date: Fri, 10 Jul 2026 23:24:03 +0800 Subject: [PATCH 1/9] docs: update README with contributors, star history, and badges; add open-source community files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add contributors section (contrib.rocks) and Star History chart - Add License, Release, Downloads badges - Replace Report Issue with Contributing section linking to templates - New: CONTRIBUTING.md, SECURITY.md, CHANGELOG.md, CODE_OF_CONDUCT.md - New: ISSUE_TEMPLATE (bug_report, feature_request), PULL_REQUEST_TEMPLATE - New: specs/design-bookmark-permission.md (FDA → security-scoped bookmark) - Fix License badge: MIT → GPLv3 Co-Authored-By: Claude --- .github/ISSUE_TEMPLATE/bug_report.md | 42 +++ .github/ISSUE_TEMPLATE/feature_request.md | 35 +++ .github/PULL_REQUEST_TEMPLATE.md | 57 ++++ CHANGELOG.md | 92 +++++++ CODE_OF_CONDUCT.md | 133 ++++++++++ CONTRIBUTING.md | 199 ++++++++++++++ README.md | 27 +- SECURITY.md | 58 +++++ specs/design-bookmark-permission.md | 303 ++++++++++++++++++++++ 9 files changed, 944 insertions(+), 2 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 specs/design-bookmark-permission.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..010bacb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,42 @@ +--- +name: Bug Report +about: Report a bug to help us improve RClick +title: '[BUG] ' +labels: bug +assignees: '' +--- + +## Describe the Bug + +A clear and concise description of what the bug is. + +## Steps to Reproduce + +1. Open Finder, right-click on ... +2. Click on '...' +3. Observe '...' + +## Expected Behavior + +A clear description of what you expected to happen. + +## Screenshots + +If applicable, add screenshots to help explain the problem. + +## Environment + +| Detail | Value | +|--------|-------| +| **RClick Version** | [e.g. 2.0.4] | +| **macOS Version** | [e.g. 15.6] | +| **Mac Model** | [e.g. MacBook Pro M3] | +| **Installation Method** | [DMG / Built from Source] | + +## Additional Context + +- Does the issue persist after restarting the app? +- Does the issue persist after a system restart? +- Does the issue occur with specific file types or in specific folders? + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..4a889ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,35 @@ +--- +name: Feature Request +about: Suggest a new feature or enhancement for RClick +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +## Problem Statement + +A clear description of the problem you want to solve. +*e.g., "I often need to convert images to PNG format, and going through Preview each time is cumbersome."* + +## Proposed Solution + +Describe the feature or enhancement you'd like to see. +*e.g., "Add a right-click action to convert selected images to PNG directly in Finder."* + +## Alternatives Considered + +Have you considered any workarounds or alternative solutions? + +## macOS Integration + +How should this feature integrate with macOS? +- Finder context menu only? +- Menu bar interaction? +- Keyboard shortcut? +- Settings configuration? + +## Additional Context + +- Would this feature require new permissions? (e.g., Accessibility, Full Disk Access) +- Are there existing macOS APIs that could support this? +- Add any other context, mockups, or references here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..2166b57 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,57 @@ +## Summary + + + +## Type of Change + +- [ ] 🐛 Bug fix +- [ ] ✨ New feature +- [ ] 📝 Documentation update +- [ ] ♻️ Refactoring +- [ ] ⚡ Performance improvement +- [ ] 🔧 Build/CI change +- [ ] 🧪 Test addition/update + +## Related Issue + + + +## Screenshots + + + +| Before | After | +|--------|-------| +| | | + +## Testing + +### Manual Testing + + + +- [ ] App launches and appears in menu bar +- [ ] Right-click menu works correctly in Finder +- [ ] Dark mode looks correct +- [ ] Settings persist across restarts +- [ ] FinderSync extension loads properly + +### Build Verification + +```bash +# Paste the output of: +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)) +- [ ] All UI is SwiftUI (no AppKit UI components) +- [ ] I have tested on macOS 15.6+ +- [ ] I have tested with both light and dark mode +- [ ] I have updated documentation if needed +- [ ] My branch is up-to-date with `dev` + +## Additional Notes + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7d0c3f6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,92 @@ +# Changelog + +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.0.4] - 2026-07-05 + +### Fixed +- Add sandbox entitlement for FinderSyncExt to fix menu rendering in protected directories +- Set proper icons for collapsed parent menus in FinderSync +- Isolate DMG and ZIP staging into separate subdirectories to prevent artifact corruption +- Prevent nested `.app` in release ZIP caused by stale DMG temp copy + +### Changed +- Bump `actions/checkout` from v4 to v5 in CI workflows + +## [2.0.3] - 2026-07-04 + +### Fixed +- Preserve `.app` bundle structure in release ZIP archive +- Use `cp` + `ditto` + `keepParent` for correct `.app` ZIP packaging +- Avoid nested `.app` in release ZIP and DMG artifacts +- Remove stale "Create Blank File" menu item; add missing localization entries + +### Added +- CI: GitHub Actions workflows for PR build verification and tag-triggered release + +### Changed +- Select latest available Xcode version on CI runner +- Lower `objectVersion` for Xcode 26.0.1 CI compatibility + +## [2.0.1] - 2026-06-28 + +### Added +- Trilingual localization with String Catalogs (zh-Hans, en, ja) +- Editable blank file creation from templates +- Draggable settings menu ordering for customizing menu appearance +- Quick-access folders master toggle (default off) +- CI: Add GitHub Actions workflows for PR build and tag release + +### Fixed +- FinderSync menu click unresponsiveness after localization refactor +- Sandbox permission issues for FinderSync Extension +- VS Code detection with percent-encoded path double-encoding +- Toolbar icons disappearing after settings refactor +- Heartbeat monitoring logic for extension health checks +- Menu item default values and collapse toggle not applying correctly +- Chinese menu title truncation in Finder context menu +- Common folders icon not matching system directory appearance +- Dark mode icon adaptation for SF Symbols using `paletteColors` +- File type icon three-level fallback strategy +- `onChange(of:)` deprecation warning on macOS 14+ + +### Changed +- Swift 5 → Swift 6.2 migration with full concurrency checking +- Settings UI refactored to unified `Form` layout +- macOS 15.6 + Xcode 16.4 compatibility as minimum requirement +- Menu icon loading now uses memory cache for instant display +- New file icons prioritize system app icons when available +- FDA (Full Disk Access) permission guidance and detection + +### Performance +- Menu icon loading now uses memory cache for instant display on subsequent right-clicks + +## [1.7.2] - 2025-09-27 + +### Changed +- Code signing and entitlements configuration for FinderSync Extension +- Improved extension loading reliability + +## [1.7.0] - 2025-09-27 + +### Added +- Initial stable release with core Finder context menu features +- Open with External App support +- Copy Path functionality +- Delete Directly (bypass Trash) +- Hide / Unhide file toggle +- AirDrop sharing +- Create New Files from templates +- Quick Access Folders +- Dark Mode support + +--- + +[2.0.4]: https://github.com/wflixu/RClick/releases/tag/v2.0.4 +[2.0.3]: https://github.com/wflixu/RClick/releases/tag/v2.0.3 +[2.0.1]: https://github.com/wflixu/RClick/releases/tag/v2.0.1 +[1.7.2]: https://github.com/wflixu/RClick/releases/tag/v1.7.2 +[1.7.0]: https://github.com/wflixu/RClick/releases/tag/v1.7.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..91517fc --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at the GitHub +repository via private message to the maintainers. + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c5f24e2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,199 @@ +# Contributing to RClick + +Thank you for your interest in contributing to RClick! 🎉 + +RClick is a macOS Finder enhancement tool built with Swift 6.2 and SwiftUI. This guide will help you get set up and make your first contribution. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) + - [Development Environment](#development-environment) + - [Building the Project](#building-the-project) + - [Project Structure](#project-structure) +- [Development Workflow](#development-workflow) + - [Branching Strategy](#branching-strategy) + - [Commit Conventions](#commit-conventions) + - [Pull Request Process](#pull-request-process) +- [Coding Guidelines](#coding-guidelines) + - [Swift Style](#swift-style) + - [SwiftUI Best Practices](#swiftui-best-practices) + - [Architecture Constraints](#architecture-constraints) +- [Testing](#testing) +- [Finding Issues to Work On](#finding-issues-to-work-on) + +## Code of Conduct + +This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers. + +## Getting Started + +### Development Environment + +| Tool | Version | +|------|---------| +| **macOS** | 15.6 (Sequoia) or later | +| **Xcode** | 16.4 or later | +| **Swift** | 6.2 or later | + +### Building the Project + +```bash +# Clone the repository +git clone https://github.com/wflixu/RClick.git +cd RClick + +# Open in Xcode +open RClick.xcodeproj + +# Or build from CLI +xcodebuild -project RClick.xcodeproj -scheme RClick -destination 'platform=macOS' +``` + +> **Note:** The FinderSync Extension requires code signing. When building for development, Xcode will automatically use your development team. You may need to adjust the signing settings in **Signing & Capabilities**. + +### Project Structure + +``` +RClick/ +├── RClick/ # Main application target +│ ├── RClickApp.swift # App entry point +│ ├── AppState.swift # Global state management +│ ├── Model/ # SwiftData models +│ ├── Settings/ # Settings UI (SwiftUI) +│ ├── Shared/ # Utilities & services +│ └── Assets.xcassets/ # Images, icons, templates +├── FinderSyncExt/ # Finder extension target +│ ├── FinderSyncExt.swift # Extension entry point +│ └── MenuItemClickable.swift # Menu item handlers +└── specs/ # Feature specifications & contracts +``` + +## Development Workflow + +### Branching Strategy + +- **`main`** — Production-ready releases. Always stable. +- **`dev`** — Integration branch. All feature branches merge here first. +- **Feature branches** — `feature/your-feature-name` or `fix/your-bug-fix` + +```bash +# Start new feature +git checkout dev +git pull origin dev +git checkout -b feature/my-new-feature + +# Start a bug fix +git checkout -b fix/my-bug-fix +``` + +### Commit Conventions + +We follow [Conventional Commits](https://www.conventionalcommits.org/). Please structure your commit messages as: + +``` +(): + +[optional body] + +[optional footer] +``` + +**Types:** + +| Type | Usage | +|------|-------| +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation changes | +| `refactor` | Code refactoring (no feature/fix) | +| `perf` | Performance improvement | +| `test` | Adding or updating tests | +| `chore` | Build, CI, dependencies | +| `style` | Formatting, whitespace | + +**Examples:** +``` +feat(settings): add dark mode toggle to general settings +fix(finder): resolve menu item not appearing on first launch +docs: update README with installation guide +refactor(ipc): simplify messaging layer with async/await +``` + +### Pull Request Process + +1. **Fork** the repository and create your branch from `dev`. +2. **Implement** your changes, following the coding guidelines below. +3. **Test** your changes thoroughly (build, run, verify in Finder). +4. **Update documentation** if your changes affect user-facing behavior. +5. **Submit a PR** targeting the `dev` branch. +6. **Fill out the PR template** — describe what changed, why, and how to test. +7. **Wait for review** — a maintainer will review your PR. CI checks must pass. + +**PR titles** should also follow Conventional Commits, e.g., `feat: add AirDrop sharing support`. + +## Coding Guidelines + +### Swift Style + +- Use **Swift 6.2** syntax — no legacy patterns. +- Prefer `let` over `var` where possible. +- Use `async/await` for concurrency. Avoid completion handlers. +- Use `@MainActor` when updating `@Published` properties from background contexts. +- Mark types and methods with appropriate access control (`private`, `internal`). +- Use `guard` for early returns, not nested `if` statements. +- Prefer value types (`struct`, `enum`) over reference types when identity isn't needed. + +### SwiftUI Best Practices + +- **All UI must be SwiftUI** — no AppKit UI components (e.g., no `NSViewRepresentable` wrappers for visual elements). +- **AppKit is ONLY for system integration**: `NSWorkspace`, `NSPasteboard`, file operations, etc. +- Use `@StateObject` for view-owned `ObservableObject` instances; use `@ObservedObject` for injected ones. +- Keep views small and composable. Extract reusable components. +- Use SF Symbols for icons to ensure native look and dark mode support. + +### Architecture Constraints + +These are **hard constraints** from the project constitution: + +1. **Swift 6.2 syntax only** — no older Swift patterns. +2. **SwiftUI for all UI** — no AppKit UI components. +3. **AppKit limited to system integration** — `NSWorkspace`, `NSPasteboard`, file operations, etc. +4. **Target macOS 15 Sequoia and above only** — no backward compatibility hacks. + +**Dual-Process Communication:** +- Main app and FinderSync Extension communicate via `DistributedNotificationCenter`. +- Message protocol is defined in `RClick/Shared/Messager.swift`. +- Always handle `isHostAppOpen` state — don't block if the main app isn't running. + +## Testing + +Before submitting a PR, please verify: + +```bash +# Build the main app +xcodebuild -project RClick.xcodeproj -scheme RClick -destination 'platform=macOS' + +# Build the extension +xcodebuild -project RClick.xcodeproj -scheme FinderSyncExt -destination 'platform=macOS' + +# Run tests (if applicable) +xcodebuild test -project RClick.xcodeproj -scheme RClick -destination 'platform=macOS' +``` + +**Manual testing checklist:** +- [ ] App launches and appears in the menu bar +- [ ] Right-click menu appears in Finder with all configured items +- [ ] Dark mode works correctly (icons adapt) +- [ ] Settings persist across app restarts +- [ ] File operations work (create, delete, copy path, etc.) +- [ ] Extension loads after system restart + +## Finding Issues to Work On + +- Browse [open issues](https://github.com/wflixu/RClick/issues) — look for **`good first issue`** or **`help wanted`** labels. +- If you have an idea, [open a discussion](https://github.com/wflixu/RClick/issues) first to get feedback before writing code. + +--- + +Thank you for contributing! 🚀 diff --git a/README.md b/README.md index b3e47df..46557fa 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ [![SwiftUI](https://img.shields.io/badge/SwiftUI-✓-orange)](https://developer.apple.com/xcode/swiftui/) [![macOS 15.6+](https://img.shields.io/badge/macOS_15.6+-Compatible-green)](https://www.apple.com/macos/macos-sequoia/) [![Xcode 16.4+](https://img.shields.io/badge/Xcode-16.4+-blue)](https://developer.apple.com/xcode/) +[![License](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) +[![GitHub release](https://img.shields.io/github/v/release/wflixu/RClick)](https://github.com/wflixu/RClick/releases) +[![GitHub downloads](https://img.shields.io/github/downloads/wflixu/RClick/total)](https://github.com/wflixu/RClick/releases) +[![Star History](https://api.star-history.com/svg?repos=wflixu/RClick&type=Date)](https://star-history.com/#wflixu/RClick&Date) Enhance your macOS Finder context menu with custom actions. Built with Swift 6.2 and SwiftUI for macOS 15.6+. Full dark mode support with adaptive icons. @@ -87,6 +91,18 @@ xcodebuild -project RClick.xcodeproj -scheme RClick -configuration Release - **FinderSync** framework for Finder extension - **DistributedNotificationCenter** for IPC +## 👥 Contributors + +Thanks to all the people who have contributed to RClick! + + + + + +## 📈 Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=wflixu/RClick&type=Date)](https://star-history.com/#wflixu/RClick&Date) + ## Similar Projects - [SzContext](https://github.com/RoadToDream/SzContext) @@ -94,6 +110,13 @@ xcodebuild -project RClick.xcodeproj -scheme RClick -configuration Release - [SwiftyMenu](https://github.com/lexrus/SwiftyMenu) - [OpenInTerminal](https://github.com/Ji4n1ng/OpenInTerminal) -## 🤝 Report Issue +## 🤝 Contributing + +We welcome contributions! Here's how you can help: + +- **[Report a Bug](https://github.com/wflixu/RClick/issues/new?template=bug_report.md)** — Found something broken? Let us know. +- **[Request a Feature](https://github.com/wflixu/RClick/issues/new?template=feature_request.md)** — Have an idea? We'd love to hear it. +- **[Submit a PR](CONTRIBUTING.md)** — Read our contribution guide to get started. +- **[Security Policy](SECURITY.md)** — How to responsibly report vulnerabilities. -Found a bug or have a suggestion? [Open an issue](https://github.com/wflixu/RClick/issues). +Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before participating. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b280446 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,58 @@ +# Security Policy + +## Supported Versions + +We release patches for security vulnerabilities in the following versions: + +| Version | Supported | +|---------|--------------------| +| 2.0.x | :white_check_mark: | +| 1.7.x | :white_check_mark: | +| < 1.7 | :x: | + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them via email to the project maintainers. We will respond as quickly as possible to acknowledge receipt and coordinate a fix. + +### What to Include + +When reporting a vulnerability, please include: + +- **Description** — What is the vulnerability and what is its potential impact? +- **Steps to Reproduce** — A step-by-step guide, including specific macOS version and RClick version. +- **Affected Components** — Which part of the app is vulnerable? (Main app, FinderSync Extension, IPC layer) +- **Proof of Concept** — If possible, code or steps that demonstrate the issue. + +### Response Timeline + +- **Acknowledgment**: Within 48 hours +- **Assessment**: Within 5 business days +- **Fix Release**: Depending on severity; critical issues will be patched as quickly as possible + +### Disclosure Policy + +We follow a coordinated disclosure process: + +1. Reporter submits vulnerability via email. +2. We acknowledge and assess within 48 hours. +3. We develop and test a fix. +4. We release the patched version. +5. After the patch is released, we may publish a security advisory (CVE if applicable) crediting the reporter (if desired). + +## Security Considerations for RClick + +As a macOS Finder extension, RClick has these security-sensitive areas: + +- **File System Access** — RClick uses security-scoped bookmarks and requires Full Disk Access permissions. We follow Apple's sandboxing guidelines. +- **Inter-Process Communication** — Messages between the main app and FinderSync Extension use `DistributedNotificationCenter`. All message payloads are validated. +- **Third-Party App Launching** — RClick can launch external applications with custom arguments. Arguments are properly escaped to prevent injection. + +## Responsible Disclosure Hall of Fame + +We appreciate the security research community. Researchers who report valid vulnerabilities will be acknowledged here (with their permission). + +--- + +This policy is adapted from the [GitHub Security Policy template](https://docs.github.com/en/code-security). diff --git a/specs/design-bookmark-permission.md b/specs/design-bookmark-permission.md new file mode 100644 index 0000000..c9118de --- /dev/null +++ b/specs/design-bookmark-permission.md @@ -0,0 +1,303 @@ +# 设计文档:用 Security-Scoped Bookmark 替代 Full Disk Access + +## 一、背景 + +### 现状 + +RClick 当前依赖 **Full Disk Access (FDA)** 获取文件系统权限: + +| 机制 | 说明 | +|------|------| +| FDA 检测 | `PermissionChecker.hasFullDiskAccess()` 通过读取 `~/Library/Application Support/com.apple.TCC` 来探测 | +| 启动引导 | `checkFDAAndGuideIfNeeded()` 在首次启动时弹 Alert 引导用户去系统设置开启 FDA | +| 沙箱例外 | `com.apple.security.temporary-exception.files.home-relative-path.read-write` = `/`,覆盖 home 目录 | +| 文件操作 | `deleteFoldorFile` / `createFile` / `hideFilesAndDirs` 等直接调用 `FileManager`,无运行时权限检查 | +| 无 bookmark | 代码库中零处使用 `startAccessingSecurityScopedResource` / `bookmarkData` | + +### 问题 + +- FDA 是**全局权限**,用户越来越不愿意授予 +- 设置界面的 "Full Disk Access" 行始终显示 `未授权`,用户体验差 +- 依赖沙箱例外 entitlement,不是 Apple 推荐的权限模型 + +### 目标 + +用 **按需授权 + Security-Scoped Bookmark** 模式替代 FDA。参考 iMonet 项目的模式:操作文件时检查是否有该目录的 bookmark → 没有则弹出 `NSOpenPanel` 让用户授权具体文件夹 → 缓存 bookmark 供后续使用。 + +--- + +## 二、数据模型 + +### 2.1 新增 `BookmarkEntity`(SwiftData) + +``` +BookmarkEntity +├── id: String @Attribute(.unique) 主键 +├── bookmarkData: Data 安全作用域 bookmark 凭证 +├── pathString: String 目录路径(匹配操作 + 设置页展示) +└── createdAt: Date 创建时间 +``` + +**设计理由:** +- 独立实体,不依附于 `CommonDirEntity`。CommonDir 的语义是"快速打开的收藏夹",Bookmark 的语义是"持有安全访问凭证"—— 两者是不同的领域概念 +- 三个字段刚好满足当前需求:匹配目录 + 持有凭证 + 设置页列表展示 +- 不过度设计:需要扩展时(如加 `accessScope`),SwiftData 迁移成本很低 +- 使用 SwiftData 而非 UserDefaults,与项目现有架构一致 + +### 2.2 注册到 ModelContainer + +在 `ModelContainer.swift` 的 `ModelContainer(for:)` 中增加 `BookmarkEntity.self`: + +```swift +let container = try ModelContainer( + for: AppEntity.self, + ActionEntity.self, + NewFileTypeEntity.self, + CommonDirEntity.self, + BookmarkEntity.self, // 新增 + DataVersion.self, + configurations: configuration +) +``` + +### 2.3 与 CommonDirEntity 的关系 + +两者**并存但不耦合**: + +| | CommonDirEntity | BookmarkEntity | +|---|---|---| +| 业务意图 | "我常用这个文件夹" | "持有这个文件夹的访问凭证" | +| 创建方式 | 用户手动添加 | 文件操作时自动生成 | +| 出现在右键菜单 | ✅ 是 | ❌ 否 | +| 可独立增删 | ✅ | ✅ | +| 未来扩展方向 | 排序、分组、自定义图标 | 作用域、关联动作/脚本 | + +--- + +## 三、BookmarkManager + +### 3.1 职责 + +新建 `RClick/Shared/BookmarkManager.swift`,`@MainActor` + `ObservableObject`: + +``` +BookmarkManager +├── 持久化层:通过 ModelContext 读写 BookmarkEntity +├── 权限恢复:启动时 resolve 所有 bookmark → startAccessing +├── 访问检查:ensureAccess(to:) → 已有 bookmark 直接通过 +├── 授权提示:promptForPermission(for:) → NSOpenPanel → 保存新 bookmark +└── 权限管理:addDirectory() / removeDirectory() 供设置页使用 +``` + +### 3.2 核心 API + +```swift +@MainActor +final class BookmarkManager: ObservableObject { + /// 当前已激活访问的目录(内存) + @Published var authorizedDirectories: [URL] = [] + + /// 启动时调用:从 SwiftData 恢复所有 bookmark 并 startAccessing + func restoreBookmarks(context: ModelContext) + + /// 确保对某个文件/目录有安全访问权限 + /// - 已有 bookmark → 返回 true + /// - 无 bookmark → 返回 false(调用方决定是否 prompt) + func hasAccess(to fileURL: URL) -> Bool + + /// 弹出 NSOpenPanel 请求目录访问权限 + func promptForPermission(for directoryURL: URL) async -> URL? + + /// 保存 bookmark 到 SwiftData + func saveBookmark(for directoryURL: URL, context: ModelContext) + + /// 从设置页添加目录 + func addDirectory(context: ModelContext) async -> URL? + + /// 从设置页移除目录及其 bookmark + func removeDirectory(_ url: URL, context: ModelContext) +} +``` + +### 3.3 关键设计决策 + +**NSOpenPanel 呈现方式:** +- RClick 是菜单栏应用(`activationPolicy: .accessory`),无主窗口 +- 使用 `.runModal()` 而非 `.beginSheetModal()`(与 iMonet 不同) +- 弹出前调用 `NSApp.activate(ignoringOtherApps: true)` 确保面板在前台 + +**`startAccessing` 生命周期:** +- 启动时 resolve bookmark → `startAccessing` → 保持到 app 终止(会话级) +- 新的 NSOpenPanel 授权后也调用 `startAccessing`,同样保持会话级 +- 不调用 `stopAccessing`,系统在 app 退出时自动释放 + +**防并发:** +- `private var isPrompting = false` 防止多个 NSOpenPanel 同时出现 + +**路径匹配:** +- `hasAccess(to:)` 将 target URL 的父目录路径与 `authorizedDirectories` 做前缀匹配 +- 路径标准化使用 `resolvingSymlinksInPath()` 处理符号链接 + +--- + +## 四、文件操作改动 + +### 4.1 改动范围 + +`RClickApp.swift` 中的文件操作需要增加权限检查: + +| 操作 | 是否需要 ensureAccess | 原因 | +|------|---------------------|------| +| `deleteFoldorFile` | ✅ | `FileManager.removeItem` 需要写权限 | +| `createFile` | ✅ | `Data().write` / `FileManager.copyItem` 需要写权限 | +| `hideFilesAndDirs` | ✅ | `setResourceValues` 修改 isHidden 需要写权限 | +| `unhideFilesAndDirs` | ✅ | 同上 | +| `showAirDrop` | ✅ | 读文件需要访问权限 | +| `copyPath` | ❌ | 只操作 NSPasteboard | +| `openApp` | ❌ | `NSWorkspace.open` 不需要文件系统写权限 | +| `openCommonDirs` | ❌ | 同上 | + +### 4.2 改动模式 + +以 `deleteFoldorFile` 为例: + +```swift +func deleteFoldorFile(_ target: [String], _ trigger: String) async { + // ... 现有 ctx-container guard 和 isProtectedFolder 检查不变 ... + + for item in target { + let decodedPath = item.removingPercentEncoding ?? item + let url = URL(fileURLWithPath: decodedPath) + + // 新增:确保有父目录的访问权限 + let dirURL = url.deletingLastPathComponent() + if !bookmarkManager.hasAccess(to: dirURL) { + guard let _ = await bookmarkManager.promptForPermission(for: dirURL) else { + continue // 用户取消 + } + } + + // 现有删除逻辑不变 + do { + try FileManager.default.removeItem(atPath: decodedPath) + } catch { + logger.error("delete error: \(error)") + } + } +} +``` + +其他操作(`createFile`、`hideFilesAndDirs`、`unhideFilesAndDirs`、`showAirDrop`)遵循相同模式。 + +### 4.3 异步化 + +`handleClickEvent` 中原本同步调用的操作需要改为 `Task { @MainActor in await ... }`: + +```swift +case .action: + Task { @MainActor in + await self.actionHandler(rid: event.itemId, target: event.target, trigger: event.trigger.rawValue) + } +case .newFile: + Task { @MainActor in + await self.createFile(rid: event.itemId, target: event.target) + } +``` + +--- + +## 五、移除 FDA 相关代码 + +### 5.1 RClickApp.swift + +- **删除** `checkFDAAndGuideIfNeeded()` 方法(lines 274-304) +- **删除** `applicationDidFinishLaunching` 中对其的调用(line 167) + +### 5.2 AppState.swift + +- **删除** `@Published var hasFullDiskAccess: Bool = false` +- **删除** `func checkFullDiskAccess()` +- **新增** `let bookmarkManager = BookmarkManager()` +- `init()` 中添加 `bookmarkManager.restoreBookmarks(context: modelContext)` + +### 5.3 PermissionChecker.swift + +- **删除** `hasFullDiskAccess()` 和 `protectedTestPaths` +- **删除** `openFullDiskAccessSettings()` +- **保留** `hasAccessibilityPermission()` 和 `openAccessibilitySettings()`(与 FDA 无关,`revealInFinderAndRename` 仍需要) + +### 5.4 StringExtension.swift + +- **删除** `static let hasSeenFDAGuide = "HAS_SEEN_FDA_GUIDE"` + +--- + +## 六、设置界面改动 + +### GeneralSettingsTabView.swift + +Permissions 区域的改动: + +**删除:** +- `@State private var fullDiskAccessStatus: PermissionStatus` +- "Full Disk Access" 行(lines 97-105) +- FDA 相关的 footer 引导文字(lines 127-143) +- `updatePermissionStatus()` 中的 `store.checkFullDiskAccess()` 调用 + +**新增:** +- "Folder Permissions" 行,显示已授权目录数量 + "Manage…" 按钮 +- 点击 "Manage…" 打开 sheet: + - 列表展示 `bookmarkManager.authorizedDirectories`,每个路径可删除 + - "Add Folder…" 按钮调用 `bookmarkManager.addDirectory()` + +```swift +// Folder Permissions 行 +LabeledContent { + HStack { + Text("\(bookmarkManager.authorizedDirectories.count) folders authorized") + .foregroundColor(.secondary) + Button("Manage…") { showFolderPermissionsSheet = true } + } +} label: { + Label("Folder Permissions", systemImage: "folder.badge.person.crop") +} +``` + +--- + +## 七、保持不变的部分 + +- **entitlements**:保留 `home-relative-path.read-write` 作为非 TCC 目录的后备 +- **Accessibility 权限**:`revealInFinderAndRename` 仍然需要,不动 +- **Finder Extension 权限**:不动 +- **CommonDirEntity**:不动,独立于 Bookmark + +--- + +## 八、涉及的本地化字符串 + +| Key | 用途 | +|-----|------| +| `Folder Permissions` | 设置页权限行标签 | +| `Manage…` | 打开管理 sheet 的按钮 | +| `Add Folder…` | 添加目录按钮 | +| `No folders authorized` | 空状态 | +| `Grant Access` | NSOpenPanel 的 prompt 文案 | +| `RClick needs permission to access this folder to perform file operations.` | NSOpenPanel 的 message | + +--- + +## 九、文件清单 + +| 文件 | 改动类型 | +|------|---------| +| `RClick/Model/BookmarkEntity.swift` | **新增** | +| `RClick/Shared/BookmarkManager.swift` | **新增** | +| `RClick/Model/ModelContainer.swift` | 注册 BookmarkEntity | +| `RClick/AppState.swift` | 删 FDA、加 BookmarkManager | +| `RClick/RClickApp.swift` | 删 FDA 引导、文件操作加 ensureAccess、异步化 | +| `RClick/Settings/GeneralSettingsTabView.swift` | 替换 FDA 行为 Folder Permissions | +| `RClick/Shared/PermissionChecker.swift` | 删 hasFullDiskAccess | +| `RClick/Shared/StringExtension.swift` | 删 hasSeenFDAGuide key | +| `README.md` | FDA → Folder Access | +| `SECURITY.md` | 更新 bookmark 描述的准确性 | From 2a3ad0fd812a9094868d35442c686a6e668eb5b5 Mon Sep 17 00:00:00 2001 From: wflixu Date: Sun, 12 Jul 2026 18:42:25 +0800 Subject: [PATCH 2/9] fix: add delete button and fix enable/disable for new file types - Respect `enabled` state when building menu config (filter disabled items) - Preserve `isEnabled` when loading NewFileTypeEntity from SwiftData - Add delete button with SwiftUI confirmation dialog for new file types - Set JSON file type to disabled by default - Add missing localization entries (en, zh-Hans, ja) for delete confirmation Closes #129 --- RClick/AppState.swift | 11 +++ RClick/Localizable.xcstrings | 94 +++++++++++++++++++- RClick/Model/NewFileTypeEntity.swift | 2 +- RClick/RClickApp.swift | 2 +- RClick/Settings/NewFileSettingsTabView.swift | 27 ++++++ Shared/RCBase.swift | 2 +- 6 files changed, 134 insertions(+), 4 deletions(-) diff --git a/RClick/AppState.swift b/RClick/AppState.swift index 3ac52bf..8ba1b55 100644 --- a/RClick/AppState.swift +++ b/RClick/AppState.swift @@ -125,6 +125,16 @@ class AppState: ObservableObject { }) } + @MainActor func deleteNewFile(id: String) { + newFiles.removeAll { $0.id == id } + do { + try save() + } catch { + logger.info("save error: \(error.localizedDescription)") + } + NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) + } + @MainActor func addNewFile(_ item: NewFile) { logger.debug("start add new file type") newFiles.append(item) @@ -269,6 +279,7 @@ class AppState: ObservableObject { NewFile( ext: entity.fileExtension, name: entity.name, + enabled: entity.isEnabled, idx: entity.sortOrder, icon: entity.icon ) diff --git a/RClick/Localizable.xcstrings b/RClick/Localizable.xcstrings index 5426302..bff1ea6 100644 --- a/RClick/Localizable.xcstrings +++ b/RClick/Localizable.xcstrings @@ -3190,7 +3190,99 @@ } } } + }, + "Cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "Delete": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除" + } + } + } + }, + "Delete File Type": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete File Type" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル種類を削除" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除文件类型" + } + } + } + }, + "Are you sure you want to delete \"%@\" (%@)? This action cannot be undone.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Are you sure you want to delete \"%@\" (%@)? This action cannot be undone." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "\"%@\"(%@)を削除してもよろしいですか?この操作は取り消せません。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确定要删除「%@」(%@)吗?此操作无法撤销。" + } + } + } } }, "version": "1.0" -} +} \ No newline at end of file diff --git a/RClick/Model/NewFileTypeEntity.swift b/RClick/Model/NewFileTypeEntity.swift index d099275..dc37b29 100644 --- a/RClick/Model/NewFileTypeEntity.swift +++ b/RClick/Model/NewFileTypeEntity.swift @@ -59,7 +59,7 @@ final class NewFileTypeEntity { return [ NewFileTypeEntity(id: "txt", fileExtension: ".txt", name: "TXT", icon: "doc.text", sortOrder: 0), NewFileTypeEntity(id: "md", fileExtension: ".md", name: "Markdown", icon: "doc.richtext", sortOrder: 1), - NewFileTypeEntity(id: "json", fileExtension: ".json", name: "JSON", icon: "curlybraces", sortOrder: 2), + NewFileTypeEntity(id: "json", fileExtension: ".json", name: "JSON", icon: "curlybraces", isEnabled: false, sortOrder: 2), NewFileTypeEntity(id: "docx", fileExtension: ".docx", name: "DOCX", icon: "doc.richtext.fill", sortOrder: 3), NewFileTypeEntity(id: "pptx", fileExtension: ".pptx", name: "PPTX", icon: "rectangle.on.rectangle.fill", sortOrder: 4), NewFileTypeEntity(id: "xlsx", fileExtension: ".xlsx", name: "XLSX", icon: "tablecells", sortOrder: 5), diff --git a/RClick/RClickApp.swift b/RClick/RClickApp.swift index b383a38..4f3c951 100644 --- a/RClick/RClickApp.swift +++ b/RClick/RClickApp.swift @@ -194,7 +194,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Build menu config from AppState let actionMenuItems = appState.actions.filter(\.enabled).map { $0.toActionMenuItem() } let appMenuItems = appState.apps.map { $0.toAppMenuItem() } - let newFileMenuItems = appState.newFiles.map { NewFileMenuItem(id: $0.id, name: $0.name, ext: $0.ext, icon: $0.icon) } + let newFileMenuItems = appState.newFiles.filter(\.enabled).map { NewFileMenuItem(id: $0.id, name: $0.name, ext: $0.ext, icon: $0.icon) } let commonDirMenuItems = appState.showCommonDirs ? appState.cdirs.map { CommonDirMenuItem(id: $0.id, name: $0.displayName, icon: $0.icon, url: $0.url.path) } : [] // 更新版本号 diff --git a/RClick/Settings/NewFileSettingsTabView.swift b/RClick/Settings/NewFileSettingsTabView.swift index dbe7fca..90e9639 100644 --- a/RClick/Settings/NewFileSettingsTabView.swift +++ b/RClick/Settings/NewFileSettingsTabView.swift @@ -17,6 +17,7 @@ struct NewFileSettingsTabView: View { @EnvironmentObject var appState: AppState @State private var editingFile: NewFile? + @State private var fileToDelete: NewFile? let messager = Messager.shared @@ -59,6 +60,14 @@ struct NewFileSettingsTabView: View { .buttonStyle(.borderless) .help(AppLocalization.localized("Edit File Type")) + Button { + fileToDelete = item + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help(AppLocalization.localized("Delete File Type")) + Toggle(AppLocalization.localized("Enabled"), isOn: $item.enabled) .toggleStyle(.switch) .onChange(of: item.enabled) { @@ -105,6 +114,24 @@ struct NewFileSettingsTabView: View { .sheet(item: $editingFile) { file in EditFileTypeSheetView(file: file, appState: appState) } + .confirmationDialog( + AppLocalization.localized("Delete File Type"), + isPresented: Binding( + get: { fileToDelete != nil }, + set: { if !$0 { fileToDelete = nil } } + ), + presenting: fileToDelete + ) { file in + Button(AppLocalization.localized("Delete"), role: .destructive) { + appState.deleteNewFile(id: file.id) + messager.sendRunningNotification() + } + Button(AppLocalization.localized("Cancel"), role: .cancel) { + fileToDelete = nil + } + } message: { file in + Text(String(format: AppLocalization.localized("Are you sure you want to delete \"%@\" (%@)? This action cannot be undone."), file.name, file.ext)) + } } } diff --git a/Shared/RCBase.swift b/Shared/RCBase.swift index f8eda7a..9d666bc 100644 --- a/Shared/RCBase.swift +++ b/Shared/RCBase.swift @@ -240,7 +240,7 @@ extension NewFile { static let all: [NewFile] = [.txt, .md, .json, .docx, .pptx, .xlsx, .pages, .key, .numbers] // icon 字段为 SF Symbol 名称,作为 NSWorkspace 获取失败时的 fallback - static let json = NewFile(ext: ".json", name: "JSON", idx: 0, icon: "curlybraces") + static let json = NewFile(ext: ".json", name: "JSON", enabled: false, idx: 0, icon: "curlybraces") static let txt = NewFile(ext: ".txt", name: "TXT", idx: 1, icon: "doc.text") static let md = NewFile(ext: ".md", name: "Markdown", idx: 2, icon: "doc.richtext") static let docx = NewFile(ext: ".docx", name: "DOCX", idx: 3, icon: "doc.richtext.fill") From 25b69e8f6d3c692d135fe37f4dad1a7a559086f3 Mon Sep 17 00:00:00 2001 From: wflixu Date: Sun, 12 Jul 2026 18:44:58 +0800 Subject: [PATCH 3/9] fix: remove random badge identifiers that override removable disk icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the demo `requestBadgeIdentifier` code that randomly assigned non-empty badge identifiers ("One" or "Two") to files. When Finder receives a non-empty badge ID, it overlays the extension icon on the item — causing removable disks and optical discs mounted under /Volumes/ to show RClick icon instead of their original drive icon. Always set an empty badge identifier to keep item icons unchanged. Closes #128 --- FinderSyncExt/FinderSyncExt.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/FinderSyncExt/FinderSyncExt.swift b/FinderSyncExt/FinderSyncExt.swift index 64c610f..2635091 100644 --- a/FinderSyncExt/FinderSyncExt.swift +++ b/FinderSyncExt/FinderSyncExt.swift @@ -155,12 +155,10 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { } override func requestBadgeIdentifier(for url: URL) { - logger.debug("requestBadgeIdentifierForURL: \(url.path)") - - // 示例:根据文件状态设置徽章 - let whichBadge = abs(url.path.hash) % 3 - let badgeIdentifier = ["", "One", "Two"][whichBadge] - FIFinderSyncController.default().setBadgeIdentifier(badgeIdentifier, for: url) + // 不设置任何徽章标识,避免 Finder 在项目上叠加 RClick 图标。 + // 非空徽章 ID 会使 Finder 在文件/磁盘图标上显示扩展的图标叠加层, + // 这会导致移动磁盘和光盘等外部卷的图标被 RClick 图标覆盖。 + FIFinderSyncController.default().setBadgeIdentifier("", for: url) } // MARK: - Menu and toolbar item support From b8fc8de1b846b4f158dc771e2c9036b54f8acb75 Mon Sep 17 00:00:00 2001 From: wflixu Date: Sun, 12 Jul 2026 19:03:04 +0800 Subject: [PATCH 4/9] refactor: replace Full Disk Access with Security-Scoped Bookmark permission model Remove the FDA permission check and startup guide, replacing it with a per-folder authorization model using Security-Scoped Bookmarks. Key changes: - Add BookmarkEntity (SwiftData) and BookmarkManager for bookmark lifecycle - File operations (delete/create/hide/unhide/airdrop) now prompt for per-folder access via NSOpenPanel when no bookmark exists - Settings: replace "Full Disk Access" row with "Folder Permissions" management (add/remove authorized directories) - Remove PermissionChecker.hasFullDiskAccess(), checkFDAAndGuideIfNeeded() - Make file operations async to support NSOpenPanel modal - Keep Accessibility permission checker (separate from FDA) - Add localization strings for folder permissions UI (en, zh-Hans, ja) Benefits: - No more System Settings navigation for users - Lower App Store review barrier - No misleading FDA warnings during Xcode debug runs --- CHANGELOG.md | 11 + RClick/AppState.swift | 10 +- RClick/Localizable.xcstrings | 230 ++++++++++++++++++ RClick/Model/BookmarkEntity.swift | 36 +++ RClick/Model/ModelContainer.swift | 1 + RClick/RClickApp.swift | 134 +++++----- .../Settings/FolderPermissionsSheetView.swift | 82 +++++++ RClick/Settings/GeneralSettingsTabView.swift | 60 ++--- RClick/Shared/BookmarkManager.swift | 210 ++++++++++++++++ RClick/Shared/PermissionChecker.swift | 67 +---- RClick/Shared/StringExtension.swift | 1 - README.md | 4 +- 12 files changed, 672 insertions(+), 174 deletions(-) create mode 100644 RClick/Model/BookmarkEntity.swift create mode 100644 RClick/Settings/FolderPermissionsSheetView.swift create mode 100644 RClick/Shared/BookmarkManager.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0c3f6..3bd28dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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.1.0] - 2026-07-12 + +### Changed +- Replace Full Disk Access (FDA) with Security-Scoped Bookmark permission model +- File operations (delete, create, hide, unhide, AirDrop) now prompt for per-folder authorization +- Settings: "Full Disk Access" row replaced with "Folder Permissions" management + +### Removed +- Full Disk Access detection, guide alert, and settings UI +- `PermissionChecker.hasFullDiskAccess()` and related FDA methods + ## [2.0.4] - 2026-07-05 ### Fixed diff --git a/RClick/AppState.swift b/RClick/AppState.swift index 8ba1b55..682a48c 100644 --- a/RClick/AppState.swift +++ b/RClick/AppState.swift @@ -24,9 +24,10 @@ class AppState: ObservableObject { @Published var newFiles: [NewFile] = [] @Published var cdirs: [CommonDir] = [] @Published var inExt: Bool - @Published var hasFullDiskAccess: Bool = false @Published var locale: Locale + let bookmarkManager = BookmarkManager() + // 折叠开关状态 - 每个分类独立控制 @AppStorage("foldAppsMenu") var foldAppsMenu: Bool = false @AppStorage("foldActionsMenu") var foldActionsMenu: Bool = false @@ -48,7 +49,7 @@ class AppState: ObservableObject { Task { @MainActor in logger.debug("start load") try? load() - checkFullDiskAccess() + bookmarkManager.restoreBookmarks(context: modelContext) } NotificationCenter.default.addObserver( @@ -72,11 +73,6 @@ class AppState: ObservableObject { } } - func checkFullDiskAccess() { - hasFullDiskAccess = PermissionChecker.hasFullDiskAccess() - logger.debug("Full Disk Access status: \(self.hasFullDiskAccess)") - } - // Apps @MainActor func deleteApp(index: Int) { apps.remove(at: index) diff --git a/RClick/Localizable.xcstrings b/RClick/Localizable.xcstrings index bff1ea6..8c8e49e 100644 --- a/RClick/Localizable.xcstrings +++ b/RClick/Localizable.xcstrings @@ -3282,6 +3282,236 @@ } } } + }, + "Folder Permissions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Folder Permissions" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件夹权限" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダ権限" + } + } + } + }, + "Manage…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "管理…" + } + } + } + }, + "No folders authorized": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No folders authorized" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未授权任何文件夹" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認証されたフォルダはありません" + } + } + } + }, + "Grant Access": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Grant Access" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "授权访问" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクセスを許可" + } + } + } + }, + "Grant RClick access to this folder to perform file operations.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Grant RClick access to this folder to perform file operations." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "授权 RClick 访问此文件夹以执行文件操作。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル操作を実行するために、RClick にこのフォルダへのアクセスを許可してください。" + } + } + } + }, + "Choose a folder for RClick to access.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a folder for RClick to access." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择 RClick 可以访问的文件夹。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "RClick がアクセスできるフォルダを選択してください。" + } + } + } + }, + "Authorize folders to let RClick create, delete, and manage files in them.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authorize folders to let RClick create, delete, and manage files in them." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "授权文件夹,让 RClick 可以在其中创建、删除和管理文件。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを認証して、RClick がファイルの作成、削除、管理を行えるようにします。" + } + } + } + }, + "Remove folder permission": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove folder permission" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除文件夹权限" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダ権限を削除" + } + } + } + }, + "Done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "完成" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + } + } + }, + "Add a Folder…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add a Folder…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加一个文件夹…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを追加…" + } + } + } } }, "version": "1.0" diff --git a/RClick/Model/BookmarkEntity.swift b/RClick/Model/BookmarkEntity.swift new file mode 100644 index 0000000..e45a102 --- /dev/null +++ b/RClick/Model/BookmarkEntity.swift @@ -0,0 +1,36 @@ +// +// BookmarkEntity.swift +// RClick +// +// Created by Claude on 2026/07/12. +// + +import Foundation +import SwiftData + +/// Security-Scoped Bookmark 持久化实体 +/// +/// 存储用户通过 NSOpenPanel 授权的目录的安全作用域书签, +/// 用于替代 Full Disk Access 权限。 +/// +/// 与 CommonDirEntity 独立: +/// - CommonDir: "我常用这个文件夹"(收藏夹语义) +/// - BookmarkEntity: "我授权访问这个文件夹"(权限凭证语义) +@Model +final class BookmarkEntity { + /// 唯一标识 + @Attribute(.unique) var id: String + /// 安全作用域 bookmark 二进制数据 + var bookmarkData: Data + /// 目录路径(展示 + 匹配) + var pathString: String + /// 创建时间 + var createdAt: Date + + init(id: String = UUID().uuidString, bookmarkData: Data, pathString: String, createdAt: Date = Date()) { + self.id = id + self.bookmarkData = bookmarkData + self.pathString = pathString + self.createdAt = createdAt + } +} diff --git a/RClick/Model/ModelContainer.swift b/RClick/Model/ModelContainer.swift index 58a0412..8af1bb7 100644 --- a/RClick/Model/ModelContainer.swift +++ b/RClick/Model/ModelContainer.swift @@ -42,6 +42,7 @@ class SharedDataManager { ActionEntity.self, NewFileTypeEntity.self, CommonDirEntity.self, + BookmarkEntity.self, DataVersion.self, configurations: configuration ) diff --git a/RClick/RClickApp.swift b/RClick/RClickApp.swift index 4f3c951..4821f35 100644 --- a/RClick/RClickApp.swift +++ b/RClick/RClickApp.swift @@ -138,7 +138,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { messager.onExtensionMessage(.click) { [weak self] data in guard let self = self else { return } if let event: ClickEventPayload = messager.decodeSignedData(data) { - self.handleClickEvent(event) + Task { @MainActor in + await self.handleClickEvent(event) + } } else { logger.warning("Invalid click event data") } @@ -164,7 +166,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { startRunningMessageRetry() sendObserveDirMessage() - checkFDAAndGuideIfNeeded() } } @@ -221,16 +222,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { logger.debug("Sent menu configuration to extension: \(actionMenuItems.count) actions, \(appMenuItems.count) apps") } - func handleClickEvent(_ event: ClickEventPayload) { + func handleClickEvent(_ event: ClickEventPayload) async { logger.debug("Handling click event: \(event.itemId) type=\(event.itemType.rawValue) trigger=\(event.trigger.rawValue) target=\(event.target)") switch event.itemType { case .app: self.openApp(rid: event.itemId, target: event.target) case .action: - self.actionHandler(rid: event.itemId, target: event.target, trigger: event.trigger.rawValue) + await self.actionHandler(rid: event.itemId, target: event.target, trigger: event.trigger.rawValue) case .newFile: - self.createFile(rid: event.itemId, target: event.target) + await self.createFile(rid: event.itemId, target: event.target) case .commonDir: self.openCommonDirs(target: event.target) } @@ -271,38 +272,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - // MARK: - FDA Permission Guide - - private func checkFDAAndGuideIfNeeded() { - let hasSeenGuide = UserDefaults.group.bool(forKey: Key.hasSeenFDAGuide) - guard !hasSeenGuide else { return } - - let hasFDA = PermissionChecker.hasFullDiskAccess() - appState.hasFullDiskAccess = hasFDA - guard !hasFDA else { return } - - Task { @MainActor in - try? await Task.sleep(for: .seconds(2)) - let alert = NSAlert() - alert.messageText = AppLocalization.localized("Enable Full Disk Access") - alert.informativeText = AppLocalization.localized("RClick needs Full Disk Access to create files, delete files, and manage hidden files in protected folders (like Desktop, Documents, and Downloads).\n\nWithout this permission, some operations may fail on protected directories.\n\nTo enable:\n1. Click \"Open Settings\" below\n2. Click the lock icon and authenticate\n3. Click \"+\" and add RClick from your Applications folder\n4. Toggle the switch next to RClick to ON") - alert.alertStyle = .informational - alert.addButton(withTitle: AppLocalization.localized("Open Settings")) - alert.addButton(withTitle: AppLocalization.localized("Later")) - alert.addButton(withTitle: AppLocalization.localized("Don't Ask Again")) - - let response = alert.runModal() - switch response { - case .alertFirstButtonReturn: - PermissionChecker.openFullDiskAccessSettings() - case .alertThirdButtonReturn: - UserDefaults.group.set(true, forKey: Key.hasSeenFDAGuide) - default: - break - } - } - } - /// 执行重连:发送菜单配置请求 @MainActor private func performReconnection() { logger.debug("Performing reconnection: requesting menu config from main app") @@ -432,7 +401,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - func actionHandler(rid: String, target: [String], trigger: String) { + func actionHandler(rid: String, target: [String], trigger: String) async { guard let rcitem = appState.getActionItem(rid: rid) else { logger.warning("when createFile, but not have fileType ") return @@ -442,19 +411,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { case "copy-path": copyPath(target) case "delete-direct": - deleteFoldorFile(target, trigger) + await deleteFoldorFile(target, trigger) case "unhide": - unhideFilesAndDirs(target, trigger) + await unhideFilesAndDirs(target, trigger) case "hide": - hideFilesAndDirs(target, trigger) + await hideFilesAndDirs(target, trigger) case "airdrop": - showAirDrop(target, trigger) + await showAirDrop(target, trigger) default: logger.warning("no action id matched") } } - func showAirDrop(_ target: [String], _ trigger: String) { + func showAirDrop(_ target: [String], _ trigger: String) async { logger.info("---- showAirDrop trigger:\(trigger)") let fm = FileManager.default var fileURLs: [URL] = [] @@ -496,7 +465,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { alert.runModal() continue } else { - fileURLs.append(URL(fileURLWithPath: decodedPath)) + // 确保有文件所在目录的访问权限 + let fileURL = URL(fileURLWithPath: decodedPath) + let dirURL = fileURL.deletingLastPathComponent() + if !appState.bookmarkManager.hasAccess(to: dirURL) { + guard let _ = await appState.bookmarkManager.promptForPermission(for: dirURL) else { + logger.warning("用户取消授权,跳过 AirDrop:\(decodedPath)") + continue + } + } + fileURLs.append(fileURL) } } } @@ -511,13 +489,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - func unhideFilesAndDirs(_ target: [String], _ trigger: String) { + func unhideFilesAndDirs(_ target: [String], _ trigger: String) async { logger.info("开始取消隐藏文件和目录,目标路径:\(target)") if let dirPath = target.first { let fileManager = FileManager.default let path = dirPath.removingPercentEncoding ?? dirPath logger.info("处理主目录:\(path)") - var url = URL(fileURLWithPath: path) + let url = URL(fileURLWithPath: path) + + // 确保有目录的访问权限 + if !appState.bookmarkManager.hasAccess(to: url) { + guard let _ = await appState.bookmarkManager.promptForPermission(for: url) else { + logger.warning("用户取消授权,跳过取消隐藏:\(path)") + return + } + } do { let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isHiddenKey], options: [.skipsPackageDescendants]) @@ -538,7 +524,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { do { var resourceValues = URLResourceValues() resourceValues.isHidden = false - try url.setResourceValues(resourceValues) + var targetURL = url + try targetURL.setResourceValues(resourceValues) logger.info("成功取消隐藏主目录:\(path)") } catch { logger.error("取消隐藏主目录失败:\(path): \(error)") @@ -547,7 +534,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - func hideFilesAndDirs(_ target: [String], _ trigger: String) { + func hideFilesAndDirs(_ target: [String], _ trigger: String) async { logger.info("开始隐藏文件和目录,目标路径:\(target), 触发器:\(trigger)") let fileManager = FileManager.default @@ -556,6 +543,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { logger.info("处理主目录:\(path)") let url = URL(fileURLWithPath: path) + // 确保有目录的访问权限 + if !appState.bookmarkManager.hasAccess(to: url) { + guard let _ = await appState.bookmarkManager.promptForPermission(for: url) else { + logger.warning("用户取消授权,跳过隐藏:\(path)") + return + } + } + do { let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsPackageDescendants]) for case var fileURL in contents { @@ -579,16 +574,27 @@ class AppDelegate: NSObject, NSApplicationDelegate { for dirPath in target { let path = dirPath.removingPercentEncoding ?? dirPath logger.info("处理路径:\(path)") - var url = URL(fileURLWithPath: path) + let url = URL(fileURLWithPath: path) if Utils.isProtectedFolder(path) { logger.warning("跳过受保护的文件路径:\(path)") continue } + + // 确保有文件所在目录的访问权限 + let dirURL = url.deletingLastPathComponent() + if !appState.bookmarkManager.hasAccess(to: dirURL) { + guard let _ = await appState.bookmarkManager.promptForPermission(for: dirURL) else { + logger.warning("用户取消授权,跳过隐藏:\(path)") + continue + } + } + do { var resourceValues = URLResourceValues() resourceValues.isHidden = true - try url.setResourceValues(resourceValues) + var targetURL = url + try targetURL.setResourceValues(resourceValues) logger.info("成功隐藏:\(path)") } catch { logger.error("隐藏失败:\(path): \(error)") @@ -606,7 +612,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - func deleteFoldorFile(_ target: [String], _ trigger: String) { + func deleteFoldorFile(_ target: [String], _ trigger: String) async { logger.info("---- deleteFoldorFile trigger:\(trigger)") let fm = FileManager.default @@ -634,21 +640,40 @@ class AppDelegate: NSObject, NSApplicationDelegate { continue } - // 使用完全磁盘访问权限,直接删除 + // 确保有父目录的访问权限 + let url = URL(fileURLWithPath: decodedPath) + let dirURL = url.deletingLastPathComponent() + if !appState.bookmarkManager.hasAccess(to: dirURL) { + guard let _ = await appState.bookmarkManager.promptForPermission(for: dirURL) else { + logger.warning("用户取消授权,跳过删除:\(decodedPath)") + continue + } + } + do { - try fm.removeItem(atPath: item.removingPercentEncoding ?? item) + try fm.removeItem(atPath: decodedPath) } catch { logger.error("delete \(target) file run error \(error)") } } } - func createFile(rid: String, target: [String]) { + func createFile(rid: String, target: [String]) async { guard let dirPath = targetDirectoryForNewFile(target) else { logger.warning("when createFile, but not have target directory") return } + let dirURL = URL(fileURLWithPath: dirPath) + + // 确保有目标目录的访问权限 + if !appState.bookmarkManager.hasAccess(to: dirURL) { + guard let _ = await appState.bookmarkManager.promptForPermission(for: dirURL) else { + logger.warning("用户取消授权,跳过创建文件") + return + } + } + if rid == NewFileMenuItem.customFileId { let filePath = getUniqueFilePath(dir: dirPath, ext: "") let fileURL = URL(fileURLWithPath: filePath) @@ -672,7 +697,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { let filePath = getUniqueFilePath(dir: dirPath, ext: ext) let fileURL = URL(fileURLWithPath: filePath) - // 使用完全磁盘访问权限,直接创建文件 do { let fileManager = FileManager.default diff --git a/RClick/Settings/FolderPermissionsSheetView.swift b/RClick/Settings/FolderPermissionsSheetView.swift new file mode 100644 index 0000000..04fd61b --- /dev/null +++ b/RClick/Settings/FolderPermissionsSheetView.swift @@ -0,0 +1,82 @@ +// +// FolderPermissionsSheetView.swift +// RClick +// +// Created by Claude on 2026/07/12. +// + +import SwiftUI + +struct FolderPermissionsSheetView: View { + @ObservedObject var bookmarkManager: BookmarkManager + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + Text(AppLocalization.localized("Folder Permissions")) + .font(.headline) + .padding(.top, 20) + .padding(.bottom, 12) + + if bookmarkManager.authorizedDirectories.isEmpty { + VStack(spacing: 8) { + Image(systemName: "folder.badge.questionmark") + .font(.largeTitle) + .foregroundColor(.secondary) + Text(AppLocalization.localized("No folders authorized")) + .foregroundColor(.secondary) + Text(AppLocalization.localized("Authorize folders to let RClick create, delete, and manage files in them.")) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .padding(.vertical, 40) + } else { + List { + ForEach(bookmarkManager.authorizedDirectories, id: \.path) { url in + HStack { + Image(systemName: "folder.fill") + .foregroundColor(.accentColor) + Text(url.path) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button { + bookmarkManager.removeDirectory(url) + } label: { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(.borderless) + .help(AppLocalization.localized("Remove folder permission")) + } + } + } + .frame(minHeight: 200) + } + + Divider() + + HStack { + Button { + Task { @MainActor in + _ = await bookmarkManager.addDirectory() + } + } label: { + Label(AppLocalization.localized("Add a Folder…"), systemImage: "plus") + } + + Spacer() + + Button(AppLocalization.localized("Done")) { + dismiss() + } + .keyboardShortcut(.escape) + } + .padding() + } + .frame(width: 480, height: 360) + } +} diff --git a/RClick/Settings/GeneralSettingsTabView.swift b/RClick/Settings/GeneralSettingsTabView.swift index a209980..c9a3160 100644 --- a/RClick/Settings/GeneralSettingsTabView.swift +++ b/RClick/Settings/GeneralSettingsTabView.swift @@ -22,8 +22,8 @@ struct GeneralSettingsTabView: View { @EnvironmentObject var store: AppState @State private var finderSyncStatus: PermissionStatus = .unknown - @State private var fullDiskAccessStatus: PermissionStatus = .unknown @State private var accessibilityStatus: PermissionStatus = .unknown + @State private var showFolderPermissionsSheet = false @State private var showDirImporter = false @State private var wrongFold = false @@ -94,14 +94,17 @@ struct GeneralSettingsTabView: View { .foregroundColor(finderSyncStatus.color) } - // 完全磁盘访问权限 + // 文件夹权限(Bookmark) LabeledContent { - Button(AppLocalization.localized("Settings…")) { - openFullDiskAccessSettings() + HStack(spacing: 8) { + Text("\(store.bookmarkManager.authorizedDirectories.count)") + .foregroundColor(.secondary) + Button(AppLocalization.localized("Manage…")) { + showFolderPermissionsSheet = true + } } } label: { - Label(AppLocalization.localized("Full Disk Access"), systemImage: fullDiskAccessStatus.icon) - .foregroundColor(fullDiskAccessStatus.color) + Label(AppLocalization.localized("Folder Permissions"), systemImage: "folder.badge.person.crop") } // 辅助功能权限 @@ -116,31 +119,9 @@ struct GeneralSettingsTabView: View { } header: { Text(appLocalized: "Permissions") } footer: { - VStack(alignment: .leading, spacing: 4) { - Text(appLocalized: "File Provider: Select \"RClick\" in the list to enable the Finder context menu") - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - Text(appLocalized: "Full Disk Access: Required to create and delete files in protected directories") - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - if fullDiskAccessStatus != .enabled { - VStack(alignment: .leading, spacing: 4) { - Text(appLocalized: "RClick needs Full Disk Access to work with files in protected directories") - .fontWeight(.medium) - .fixedSize(horizontal: false, vertical: true) - Text(appLocalized: "1. Click \"Settings…\" on the right to open System Settings") - .fixedSize(horizontal: false, vertical: true) - Text(appLocalized: "2. Click the lock icon and authenticate") - .fixedSize(horizontal: false, vertical: true) - Text(appLocalized: "3. Click \"+\" -> open \"Applications\" -> select \"RClick\"") - .fixedSize(horizontal: false, vertical: true) - Text(appLocalized: "4. Make sure the switch next to RClick is turned on") - .fixedSize(horizontal: false, vertical: true) - } - .font(.caption) - .foregroundColor(.secondary) - } - } + Text(appLocalized: "File Provider: Select \"RClick\" in the list to enable the Finder context menu") + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) } // MARK: - 第三组:设置管理 @@ -210,6 +191,9 @@ struct GeneralSettingsTabView: View { } message: { Text(appLocalized: "Folder access permission is required to use this feature.") } + .sheet(isPresented: $showFolderPermissionsSheet) { + FolderPermissionsSheetView(bookmarkManager: store.bookmarkManager) + } .alert( Text(appLocalized: "Language Change Requires Restart"), isPresented: $showLanguageRestartAlert @@ -229,18 +213,10 @@ struct GeneralSettingsTabView: View { // Finder 扩展状态 finderSyncStatus = FIFinderSyncController.isExtensionEnabled ? .enabled : .disabled - // 完全磁盘访问权限检测(同步到 AppState) - store.checkFullDiskAccess() - fullDiskAccessStatus = store.hasFullDiskAccess ? .enabled : .disabled - - // 辅助功能权限检测(使用 PermissionChecker) + // 辅助功能权限检测 accessibilityStatus = PermissionChecker.hasAccessibilityPermission() ? .enabled : .disabled } - private func hasFullDiskAccess() -> Bool { - return PermissionChecker.hasFullDiskAccess() - } - private func hasAccessibilityPermission() -> Bool { return PermissionChecker.hasAccessibilityPermission() } @@ -252,10 +228,6 @@ struct GeneralSettingsTabView: View { NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.AppleFileProvider")!) } - private func openFullDiskAccessSettings() { - PermissionChecker.openFullDiskAccessSettings() - } - private func openAccessibilitySettings() { PermissionChecker.openAccessibilitySettings() } diff --git a/RClick/Shared/BookmarkManager.swift b/RClick/Shared/BookmarkManager.swift new file mode 100644 index 0000000..45e03cf --- /dev/null +++ b/RClick/Shared/BookmarkManager.swift @@ -0,0 +1,210 @@ +// +// BookmarkManager.swift +// RClick +// +// Created by Claude on 2026/07/12. +// + +import Foundation +import SwiftData +import OSLog +import AppKit +import Combine + +/// Security-Scoped Bookmark 管理器 +/// +/// 职责: +/// - 启动时恢复持久化的 bookmark 并 startAccessing +/// - 运行时检查文件路径是否在已授权目录下 +/// - 未授权时弹出 NSOpenPanel 引导用户授权 +/// - 提供设置页所需的目录管理接口 +@MainActor +final class BookmarkManager: ObservableObject { + /// 当前已授权并激活访问的目录 + @Published var authorizedDirectories: [URL] = [] + + private var isPrompting = false + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "RClick", + category: "BookmarkManager" + ) + + // MARK: - 恢复 + + /// 从 SwiftData 恢复所有 bookmark 并 startAccessing + func restoreBookmarks(context: ModelContext) { + let descriptor = FetchDescriptor() + guard let entities = try? context.fetch(descriptor) else { return } + + for entity in entities { + do { + var isStale = false + let url = try URL( + resolvingBookmarkData: entity.bookmarkData, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + + if isStale { + // 刷新 bookmark + guard url.startAccessingSecurityScopedResource() else { + logger.warning("无法恢复 bookmark(startAccessing 失败):\(entity.pathString)") + continue + } + let newData = try url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + entity.bookmarkData = newData + url.stopAccessingSecurityScopedResource() + } + + guard url.startAccessingSecurityScopedResource() else { + logger.warning("无法恢复 bookmark(startAccessing 失败):\(entity.pathString)") + continue + } + + let normalized = url.resolvingSymlinksInPath() + if !authorizedDirectories.contains(normalized) { + authorizedDirectories.append(normalized) + } + logger.debug("已恢复 bookmark:\(entity.pathString)") + } catch { + logger.error("恢复 bookmark 失败:\(entity.pathString) — \(error.localizedDescription)") + } + } + try? context.save() + logger.info("Bookmark 恢复完成:\(self.authorizedDirectories.count) 个目录") + } + + // MARK: - 访问检查 + + /// 检查某个文件/目录是否在已授权的目录下 + func hasAccess(to url: URL) -> Bool { + let target = url.resolvingSymlinksInPath().path + for dir in authorizedDirectories { + let dirPath = dir.resolvingSymlinksInPath().path + // 自身或子路径匹配 + if target == dirPath || target.hasPrefix(dirPath + "/") { + return true + } + } + return false + } + + // MARK: - 授权弹窗 + + /// 弹出 NSOpenPanel 请求用户授权访问某个目录 + /// - Returns: 用户授权的 URL,用户取消时返回 nil + func promptForPermission(for url: URL) async -> URL? { + guard !isPrompting else { return nil } + isPrompting = true + defer { isPrompting = false } + + let panel = NSOpenPanel() + panel.message = AppLocalization.localized("Grant RClick access to this folder to perform file operations.") + panel.prompt = AppLocalization.localized("Grant Access") + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = false + panel.directoryURL = url + + // 菜单栏应用需要激活才能弹出面板 + NSApp.activate(ignoringOtherApps: true) + + let response = panel.runModal() + + guard response == .OK, let chosenURL = panel.url else { + return nil + } + + // 保存 bookmark + await saveBookmark(for: chosenURL) + return chosenURL + } + + // MARK: - 持久化 + + /// 保存目录的 security-scoped bookmark + func saveBookmark(for url: URL) { + do { + let bookmarkData = try url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + + guard url.startAccessingSecurityScopedResource() else { + logger.error("startAccessing 失败:\(url.path)") + return + } + + let normalized = url.resolvingSymlinksInPath() + if !authorizedDirectories.contains(normalized) { + authorizedDirectories.append(normalized) + } + + let entity = BookmarkEntity(bookmarkData: bookmarkData, pathString: url.path) + let context = ModelContext(SharedDataManager.sharedModelContainer) + context.insert(entity) + try context.save() + + logger.debug("已保存 bookmark:\(url.path)") + } catch { + logger.error("保存 bookmark 失败:\(url.path) — \(error.localizedDescription)") + } + } + + /// 移除某个目录的 bookmark + func removeDirectory(_ url: URL) { + let normalized = url.resolvingSymlinksInPath() + authorizedDirectories.removeAll { $0.resolvingSymlinksInPath() == normalized } + + // 停止访问 + normalized.stopAccessingSecurityScopedResource() + + // 从 SwiftData 删除 + let context = ModelContext(SharedDataManager.sharedModelContainer) + let path = normalized.path + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.pathString == path } + ) + if let entities = try? context.fetch(descriptor) { + for entity in entities { + context.delete(entity) + } + try? context.save() + } + logger.debug("已移除 bookmark:\(path)") + } + + // MARK: - 设置页工具 + + /// 从设置页添加目录(弹出 NSOpenPanel) + func addDirectory() async -> URL? { + guard !isPrompting else { return nil } + isPrompting = true + defer { isPrompting = false } + + let panel = NSOpenPanel() + panel.message = AppLocalization.localized("Choose a folder for RClick to access.") + panel.prompt = AppLocalization.localized("Grant Access") + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = false + + NSApp.activate(ignoringOtherApps: true) + + let response = panel.runModal() + + guard response == .OK, let chosenURL = panel.url else { + return nil + } + + await saveBookmark(for: chosenURL) + return chosenURL + } +} diff --git a/RClick/Shared/PermissionChecker.swift b/RClick/Shared/PermissionChecker.swift index bb6a6e8..0a56072 100644 --- a/RClick/Shared/PermissionChecker.swift +++ b/RClick/Shared/PermissionChecker.swift @@ -2,7 +2,7 @@ // PermissionChecker.swift // RClick // -// 完全磁盘访问权限多点验证模块 +// 辅助功能权限检查模块 // import Foundation @@ -20,72 +20,9 @@ private let logger = Logger( // MARK: - 权限检查器 -/// 提供完全磁盘访问权限的多点验证 +/// 提供辅助功能权限的检测 public class PermissionChecker { - /// 需要 FDA 才能访问的检测路径 - /// 每个路径下的文件/子目录只有开启了 FDA 才能被读取 - /// - /// Deliberately NOT ~/Library/Mail, Messages or Safari: those are other - /// apps' data containers, and on macOS 15+ merely attempting to read them - /// fires the TCC "App Data" consent ("RClick" would like to access data - /// from other apps) every time the app launches. The TCC store below is - /// gated by Full Disk Access itself (kTCCServiceSystemPolicyAllFiles), - /// which macOS denies *silently* — an accurate FDA probe with no prompt. - private static let protectedTestPaths: [(path: String, description: String)] = [ - (NSString(string: "~/Library/Application Support/com.apple.TCC").expandingTildeInPath, - "User TCC store"), - ] - - /// 检测是否拥有完全磁盘访问权限 - /// 策略:尝试读取受保护目录下的内容,只要有一个成功即认为 FDA 已开启 - /// - Returns: 如果有完全磁盘访问权限则返回 true - public static func hasFullDiskAccess() -> Bool { - for (path, desc) in protectedTestPaths { - let testURL = URL(fileURLWithPath: path) - if canAccessDirectory(at: testURL) { - logger.debug("完全磁盘访问权限检测通过(\(desc))") - return true - } - logger.warning("FDA 检测:无法访问 \(path)(\(desc))") - } - logger.warning("完全磁盘访问权限未开启") - return false - } - - /// 检测是否可以访问指定目录(不跳过隐藏文件,容忍空目录) - private static func canAccessDirectory(at url: URL) -> Bool { - // 先确认路径存在 - guard FileManager.default.fileExists(atPath: url.path) else { - logger.warning("FDA 检测路径不存在:\(url.path)") - return false - } - - // No POSIX access() fast path here: the probe directory is owned by - // the current user, so access(R_OK) succeeds on POSIX permissions - // alone and would report FDA as granted when it is not. TCC is only - // enforced on the actual read attempt below. - do { - // 不用 .skipsHiddenFiles,避免空目录误判 - let _ = try FileManager.default.contentsOfDirectory( - at: url, - includingPropertiesForKeys: nil, - options: [] - ) - return true - } catch { - return false - } - } - - /// 打开完全磁盘访问权限设置 - @MainActor - public static func openFullDiskAccessSettings() { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") { - NSWorkspace.shared.open(url) - } - } - /// 打开辅助功能权限设置 @MainActor public static func openAccessibilitySettings() { diff --git a/RClick/Shared/StringExtension.swift b/RClick/Shared/StringExtension.swift index ac99ca0..2972e10 100644 --- a/RClick/Shared/StringExtension.swift +++ b/RClick/Shared/StringExtension.swift @@ -37,7 +37,6 @@ enum Key { static let commonDirs = "RCLICK_COMMON_DIRS" static let showMenuBarExtra = "showMenuBarExtra" static let showInDock = "SHOW_IN_DOCK" - static let hasSeenFDAGuide = "HAS_SEEN_FDA_GUIDE" static let selectedLanguage = "SELECTED_LANGUAGE" static let actionMenuItems = "RCLICK_ACTION_MENU_ITEMS" static let appMenuItems = "RCLICK_APP_MENU_ITEMS" diff --git a/README.md b/README.md index 46557fa..f3708eb 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![License](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) [![GitHub release](https://img.shields.io/github/v/release/wflixu/RClick)](https://github.com/wflixu/RClick/releases) [![GitHub downloads](https://img.shields.io/github/downloads/wflixu/RClick/total)](https://github.com/wflixu/RClick/releases) -[![Star History](https://api.star-history.com/svg?repos=wflixu/RClick&type=Date)](https://star-history.com/#wflixu/RClick&Date) +[⭐ Star History](https://star-history.com/#wflixu/RClick&Date) Enhance your macOS Finder context menu with custom actions. Built with Swift 6.2 and SwiftUI for macOS 15.6+. Full dark mode support with adaptive icons. @@ -69,7 +69,7 @@ open RClick.xcodeproj | Requirement | Details | |-------------|---------| -| **Full Disk Access** | Required for file operations in protected directories | +| **Folder Access** | Per-folder authorization for file operations (prompted automatically) | | **Accessibility** | Required for certain automation features | | **Finder Extension** | Must be enabled in System Settings | From a0db83e18ea03ca418bcf07ce47b95bfa6298f99 Mon Sep 17 00:00:00 2001 From: wflixu Date: Sun, 12 Jul 2026 19:13:06 +0800 Subject: [PATCH 5/9] chore: reorder permissions section - Folder Permissions last Move Folder Permissions row below Accessibility in Settings. --- RClick/Settings/GeneralSettingsTabView.swift | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/RClick/Settings/GeneralSettingsTabView.swift b/RClick/Settings/GeneralSettingsTabView.swift index c9a3160..85eb94b 100644 --- a/RClick/Settings/GeneralSettingsTabView.swift +++ b/RClick/Settings/GeneralSettingsTabView.swift @@ -94,6 +94,16 @@ struct GeneralSettingsTabView: View { .foregroundColor(finderSyncStatus.color) } + // 辅助功能权限 + LabeledContent { + Button(AppLocalization.localized("Settings…")) { + openAccessibilitySettings() + } + } label: { + Label(AppLocalization.localized("Accessibility"), systemImage: accessibilityStatus.icon) + .foregroundColor(accessibilityStatus.color) + } + // 文件夹权限(Bookmark) LabeledContent { HStack(spacing: 8) { @@ -106,16 +116,6 @@ struct GeneralSettingsTabView: View { } label: { Label(AppLocalization.localized("Folder Permissions"), systemImage: "folder.badge.person.crop") } - - // 辅助功能权限 - LabeledContent { - Button(AppLocalization.localized("Settings…")) { - openAccessibilitySettings() - } - } label: { - Label(AppLocalization.localized("Accessibility"), systemImage: accessibilityStatus.icon) - .foregroundColor(accessibilityStatus.color) - } } header: { Text(appLocalized: "Permissions") } footer: { From 22dd2871e135f47f84efe6aabb1203ae69f0d1f7 Mon Sep 17 00:00:00 2001 From: wflixu Date: Sun, 12 Jul 2026 19:21:49 +0800 Subject: [PATCH 6/9] refactor: remove language picker from settings, keep auto-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the manual language override picker in Settings > Main Controls. The app already auto-detects the system language and falls back to English — the picker was redundant since macOS provides per-app language override in System Settings. Keeping Japanese localization (ja) which continues to work through automatic detection. Removed: - Language Picker UI from GeneralSettingsTabView - selectedLanguage property and locale observer from AppState - locale environment override from RClickApp and SettingsWindow - selectedLanguage and systemLanguage from AppLocalization - selectedLanguage key from StringExtension - Unused localization strings for the language picker --- .claude/settings.local.json | 3 +- RClick.xcodeproj/project.pbxproj | 75 +- RClick/AppState.swift | 23 - RClick/Localizable.xcstrings | 795 ++++++++----------- RClick/RClickApp.swift | 2 - RClick/Settings/GeneralSettingsTabView.swift | 34 - RClick/Settings/SettingsWindow.swift | 1 - RClick/Shared/StringExtension.swift | 1 - Shared/AppLocalization.swift | 17 +- 9 files changed, 376 insertions(+), 575 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e090761..4726dd6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -29,7 +29,8 @@ "Bash(/usr/bin/log show *)", "Bash(xcodebuild -project RClick.xcodeproj -scheme RClick -destination 'platform=macOS' build)", "WebSearch", - "WebFetch(domain:stackoverflow.com)" + "WebFetch(domain:stackoverflow.com)", + "Bash(pluginkit *)" ] } } diff --git a/RClick.xcodeproj/project.pbxproj b/RClick.xcodeproj/project.pbxproj index aa327f6..84351be 100644 --- a/RClick.xcodeproj/project.pbxproj +++ b/RClick.xcodeproj/project.pbxproj @@ -8,9 +8,6 @@ /* Begin PBXBuildFile section */ 5C8A291F2F83D74A00FE687C /* FinderSyncExt.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5C8A29182F83D74A00FE687C /* FinderSyncExt.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 5C969FB42F8A494600D022F8 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB32F8A494600D022F8 /* README.md */; }; - 5C969FB52F8A494600D022F8 /* CLAUDE.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB12F8A494600D022F8 /* CLAUDE.md */; }; - 5C969FB62F8A494600D022F8 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB22F8A494600D022F8 /* LICENSE */; }; 5C969FB72F8A494600D022F8 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB32F8A494600D022F8 /* README.md */; }; 5C969FB82F8A494600D022F8 /* CLAUDE.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB12F8A494600D022F8 /* CLAUDE.md */; }; 5C969FB92F8A494600D022F8 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB22F8A494600D022F8 /* LICENSE */; }; @@ -44,14 +41,6 @@ 5C969FE42F8A496000D022F8 /* extension-reconnect.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC72F8A496000D022F8 /* extension-reconnect.md */; }; 5C969FE52F8A496000D022F8 /* git-branch-strategy.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC82F8A496000D022F8 /* git-branch-strategy.md */; }; 5C969FE62F8A496000D022F8 /* 设置界面重构设计.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCB2F8A496000D022F8 /* 设置界面重构设计.md */; }; - 5C969FE72F8A496000D022F8 /* 架构重构设计及计划.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCA2F8A496000D022F8 /* 架构重构设计及计划.md */; }; - 5C969FE82F8A496000D022F8 /* 重构计划.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCC2F8A496000D022F8 /* 重构计划.md */; }; - 5C969FE92F8A496000D022F8 /* 重构设计.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCD2F8A496000D022F8 /* 重构设计.md */; }; - 5C969FEA2F8A496000D022F8 /* communication-protocol.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC52F8A496000D022F8 /* communication-protocol.md */; }; - 5C969FEB2F8A496000D022F8 /* data-models.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC62F8A496000D022F8 /* data-models.md */; }; - 5C969FEC2F8A496000D022F8 /* extension-reconnect.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC72F8A496000D022F8 /* extension-reconnect.md */; }; - 5C969FED2F8A496000D022F8 /* git-branch-strategy.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC82F8A496000D022F8 /* git-branch-strategy.md */; }; - 5C969FEE2F8A496000D022F8 /* 设置界面重构设计.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCB2F8A496000D022F8 /* 设置界面重构设计.md */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -74,19 +63,20 @@ /* Begin PBXCopyFilesBuildPhase section */ 5C8A29202F83D74A00FE687C /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; dstPath = ""; - dstSubfolder = PlugIns; + dstSubfolderSpec = 13; files = ( 5C8A291F2F83D74A00FE687C /* FinderSyncExt.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 5C8A28DA2F83D65900FE687C /* RClick.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RClick.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5C8A28E92F83D65B00FE687C /* RClickTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RClickTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 5C8A29182F83D74A00FE687C /* FinderSyncExt.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FinderSyncExt.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5C969FB12F8A494600D022F8 /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = ""; }; 5C969FB22F8A494600D022F8 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; @@ -116,7 +106,6 @@ ); target = 5C8A28E82F83D65B00FE687C /* RClickTests */; }; - 5C96A3892F8A9E2B00D022F8 /* Exceptions for "RClick" folder in "FinderSyncExt" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -141,7 +130,6 @@ path = RClickTests; sourceTree = ""; }; - 5C8A29192F83D74A00FE687C /* FinderSyncExt */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -160,18 +148,24 @@ /* Begin PBXFrameworksBuildPhase section */ 5C8A28D72F83D65900FE687C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; files = ( ); + runOnlyForDeploymentPostprocessing = 0; }; 5C8A28E62F83D65B00FE687C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; files = ( ); + runOnlyForDeploymentPostprocessing = 0; }; 5C8A29152F83D74A00FE687C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; files = ( ); + runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ @@ -271,7 +265,6 @@ productReference = 5C8A28E92F83D65B00FE687C /* RClickTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - 5C8A29172F83D74A00FE687C /* FinderSyncExt */ = { isa = PBXNativeTarget; buildConfigurationList = 5C8A29242F83D74A00FE687C /* Build configuration list for PBXNativeTarget "FinderSyncExt" */; @@ -282,6 +275,8 @@ ); buildRules = ( ); + dependencies = ( + ); fileSystemSynchronizedGroups = ( 5C8A29192F83D74A00FE687C /* FinderSyncExt */, 5C8A29302F83D85000FE687C /* Shared */, @@ -339,6 +334,7 @@ /* Begin PBXResourcesBuildPhase section */ 5C8A28D82F83D65900FE687C /* Resources */ = { isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; files = ( 5C969FB72F8A494600D022F8 /* README.md in Resources */, 5C969FB82F8A494600D022F8 /* CLAUDE.md in Resources */, @@ -352,9 +348,11 @@ 5C969FDE2F8A496000D022F8 /* 设置界面重构设计.md in Resources */, 5C969FB92F8A494600D022F8 /* LICENSE in Resources */, ); + runOnlyForDeploymentPostprocessing = 0; }; 5C8A28E72F83D65B00FE687C /* Resources */ = { isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; files = ( 5C969FBA2F8A494600D022F8 /* README.md in Resources */, 5C969FBB2F8A494600D022F8 /* CLAUDE.md in Resources */, @@ -368,9 +366,11 @@ 5C969FE52F8A496000D022F8 /* git-branch-strategy.md in Resources */, 5C969FE62F8A496000D022F8 /* 设置界面重构设计.md in Resources */, ); + runOnlyForDeploymentPostprocessing = 0; }; 5C8A29162F83D74A00FE687C /* Resources */ = { isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; files = ( 5C969FBD2F8A494600D022F8 /* README.md in Resources */, 5C969FBE2F8A494600D022F8 /* CLAUDE.md in Resources */, @@ -384,24 +384,31 @@ 5C969FD52F8A496000D022F8 /* git-branch-strategy.md in Resources */, 5C969FD62F8A496000D022F8 /* 设置界面重构设计.md in Resources */, ); + runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 5C8A28D62F83D65900FE687C /* Sources */ = { isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; files = ( ); + runOnlyForDeploymentPostprocessing = 0; }; 5C8A28E52F83D65B00FE687C /* Sources */ = { isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; files = ( ); + runOnlyForDeploymentPostprocessing = 0; }; 5C8A29142F83D74A00FE687C /* Sources */ = { isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; files = ( ); + runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ @@ -419,7 +426,7 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - 5C8A28FB2F83D65B00FE687C /* Debug configuration for PBXProject "RClick" */ = { + 5C8A28FB2F83D65B00FE687C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -485,7 +492,7 @@ }; name = Debug; }; - 5C8A28FC2F83D65B00FE687C /* Release configuration for PBXProject "RClick" */ = { + 5C8A28FC2F83D65B00FE687C /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; @@ -543,7 +550,7 @@ }; name = Release; }; - 5C8A28FE2F83D65B00FE687C /* Debug configuration for PBXNativeTarget "RClick" */ = { + 5C8A28FE2F83D65B00FE687C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -581,7 +588,7 @@ }; name = Debug; }; - 5C8A28FF2F83D65B00FE687C /* Release configuration for PBXNativeTarget "RClick" */ = { + 5C8A28FF2F83D65B00FE687C /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -619,7 +626,7 @@ }; name = Release; }; - 5C8A29012F83D65B00FE687C /* Debug configuration for PBXNativeTarget "RClickTests" */ = { + 5C8A29012F83D65B00FE687C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; @@ -641,7 +648,7 @@ }; name = Debug; }; - 5C8A29022F83D65B00FE687C /* Release configuration for PBXNativeTarget "RClickTests" */ = { + 5C8A29022F83D65B00FE687C /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; @@ -663,7 +670,7 @@ }; name = Release; }; - 5C8A29212F83D74A00FE687C /* Debug configuration for PBXNativeTarget "FinderSyncExt" */ = { + 5C8A29212F83D74A00FE687C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = FinderSyncExt/FinderSyncExt.entitlements; @@ -698,7 +705,7 @@ }; name = Debug; }; - 5C8A29222F83D74A00FE687C /* Release configuration for PBXNativeTarget "FinderSyncExt" */ = { + 5C8A29222F83D74A00FE687C /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = FinderSyncExt/FinderSyncExt.entitlements; @@ -739,33 +746,37 @@ 5C8A28D52F83D65900FE687C /* Build configuration list for PBXProject "RClick" */ = { isa = XCConfigurationList; buildConfigurations = ( - 5C8A28FB2F83D65B00FE687C /* Debug configuration for PBXProject "RClick" */, - 5C8A28FC2F83D65B00FE687C /* Release configuration for PBXProject "RClick" */, + 5C8A28FB2F83D65B00FE687C /* Debug */, + 5C8A28FC2F83D65B00FE687C /* Release */, ); + defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 5C8A28FD2F83D65B00FE687C /* Build configuration list for PBXNativeTarget "RClick" */ = { isa = XCConfigurationList; buildConfigurations = ( - 5C8A28FE2F83D65B00FE687C /* Debug configuration for PBXNativeTarget "RClick" */, - 5C8A28FF2F83D65B00FE687C /* Release configuration for PBXNativeTarget "RClick" */, + 5C8A28FE2F83D65B00FE687C /* Debug */, + 5C8A28FF2F83D65B00FE687C /* Release */, ); + defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 5C8A29002F83D65B00FE687C /* Build configuration list for PBXNativeTarget "RClickTests" */ = { isa = XCConfigurationList; buildConfigurations = ( - 5C8A29012F83D65B00FE687C /* Debug configuration for PBXNativeTarget "RClickTests" */, - 5C8A29022F83D65B00FE687C /* Release configuration for PBXNativeTarget "RClickTests" */, + 5C8A29012F83D65B00FE687C /* Debug */, + 5C8A29022F83D65B00FE687C /* Release */, ); + defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 5C8A29242F83D74A00FE687C /* Build configuration list for PBXNativeTarget "FinderSyncExt" */ = { isa = XCConfigurationList; buildConfigurations = ( - 5C8A29212F83D74A00FE687C /* Debug configuration for PBXNativeTarget "FinderSyncExt" */, - 5C8A29222F83D74A00FE687C /* Release configuration for PBXNativeTarget "FinderSyncExt" */, + 5C8A29212F83D74A00FE687C /* Debug */, + 5C8A29222F83D74A00FE687C /* Release */, ); + defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ diff --git a/RClick/AppState.swift b/RClick/AppState.swift index 682a48c..4f9d323 100644 --- a/RClick/AppState.swift +++ b/RClick/AppState.swift @@ -24,7 +24,6 @@ class AppState: ObservableObject { @Published var newFiles: [NewFile] = [] @Published var cdirs: [CommonDir] = [] @Published var inExt: Bool - @Published var locale: Locale let bookmarkManager = BookmarkManager() @@ -38,39 +37,17 @@ class AppState: ObservableObject { // 菜单栏显示 @AppStorage(Key.showMenuBarExtra) var showMenuBar: Bool = true - @AppStorage(Key.selectedLanguage, store: .group) private var selectedLanguageRawValue = AppLanguage.automatic.rawValue // SwiftData ModelContext(lazy 复用单个实例) private lazy var modelContext = ModelContext(SharedDataManager.sharedModelContainer) init(inExt: Bool = false) { self.inExt = inExt - self.locale = AppLocalization.currentLocale Task { @MainActor in logger.debug("start load") try? load() bookmarkManager.restoreBookmarks(context: modelContext) } - - NotificationCenter.default.addObserver( - forName: NSLocale.currentLocaleDidChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - guard let self = self, self.selectedLanguage == .automatic else { return } - self.locale = AppLocalization.currentLocale - } - } - } - - var selectedLanguage: AppLanguage { - get { AppLanguage(rawValue: selectedLanguageRawValue) ?? .automatic } - set { - selectedLanguageRawValue = newValue.rawValue - locale = Locale(identifier: newValue.localeIdentifier) - NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) - } } // Apps diff --git a/RClick/Localizable.xcstrings b/RClick/Localizable.xcstrings index 8c8e49e..1962e41 100644 --- a/RClick/Localizable.xcstrings +++ b/RClick/Localizable.xcstrings @@ -30,6 +30,7 @@ } } }, + "%lld": {}, "1. Click \"Settings…\" on the right to open System Settings": { "extractionState": "manual", "localizations": { @@ -191,6 +192,29 @@ } } }, + "Add a Folder…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add a Folder…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを追加…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加一个文件夹…" + } + } + } + }, "Add App": { "extractionState": "manual", "localizations": { @@ -352,6 +376,29 @@ } } }, + "Are you sure you want to delete \"%@\" (%@)? This action cannot be undone.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Are you sure you want to delete \"%@\" (%@)? This action cannot be undone." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "\"%@\"(%@)を削除してもよろしいですか?この操作は取り消せません。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确定要删除「%@」(%@)吗?此操作无法撤销。" + } + } + } + }, "Arguments (semicolon separated)": { "extractionState": "manual", "localizations": { @@ -398,48 +445,48 @@ } } }, - "Authorized": { + "Authorize folders to let RClick create, delete, and manage files in them.": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Authorized" + "value": "Authorize folders to let RClick create, delete, and manage files in them." } }, "ja": { "stringUnit": { "state": "translated", - "value": "許可済み" + "value": "フォルダを認証して、RClick がファイルの作成、削除、管理を行えるようにします。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "已授权" + "value": "授权文件夹,让 RClick 可以在其中创建、删除和管理文件。" } } } }, - "Automatic": { + "Authorized": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Automatic" + "value": "Authorized" } }, "ja": { "stringUnit": { "state": "translated", - "value": "自動" + "value": "許可済み" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "自动" + "value": "已授权" } } } @@ -467,6 +514,29 @@ } } }, + "Cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, "Change App": { "extractionState": "manual", "localizations": { @@ -559,6 +629,29 @@ } } }, + "Choose a folder for RClick to access.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a folder for RClick to access." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "RClick がアクセスできるフォルダを選択してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择 RClick 可以访问的文件夹。" + } + } + } + }, "Choose Default App": { "extractionState": "manual", "localizations": { @@ -743,6 +836,29 @@ } } }, + "Delete": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除" + } + } + } + }, "Delete App": { "extractionState": "manual", "localizations": { @@ -789,6 +905,29 @@ } } }, + "Delete File Type": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete File Type" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル種類を削除" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除文件类型" + } + } + } + }, "Desktop": { "extractionState": "manual", "localizations": { @@ -858,6 +997,29 @@ } } }, + "Done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "完成" + } + } + } + }, "Download and Install": { "extractionState": "manual", "localizations": { @@ -1088,29 +1250,6 @@ } } }, - "English": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "English" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "English" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "English" - } - } - } - }, "Enter an SF Symbol name, for example doc.text or curlybraces": { "extractionState": "manual", "localizations": { @@ -1387,6 +1526,29 @@ } } }, + "Folder Permissions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Folder Permissions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダ権限" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件夹权限" + } + } + } + }, "Folders cannot be shared via AirDrop: %@": { "extractionState": "manual", "localizations": { @@ -1526,255 +1688,255 @@ } }, "github.com/wflixu/RClick": {}, - "Grant Permission": { + "Grant Access": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Grant Permission" + "value": "Grant Access" } }, "ja": { "stringUnit": { "state": "translated", - "value": "権限を付与" + "value": "アクセスを許可" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "授予权限" + "value": "授权访问" } } } }, - "Hide": { + "Grant Permission": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Hide" + "value": "Grant Permission" } }, "ja": { "stringUnit": { "state": "translated", - "value": "隠す" + "value": "権限を付与" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "隐藏" + "value": "授予权限" } } } }, - "Home": { + "Grant RClick access to this folder to perform file operations.": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Home" + "value": "Grant RClick access to this folder to perform file operations." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ホーム" + "value": "ファイル操作を実行するために、RClick にこのフォルダへのアクセスを許可してください。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "用户主目录" + "value": "授权 RClick 访问此文件夹以执行文件操作。" } } } }, - "Icon": { + "Hide": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Icon" + "value": "Hide" } }, "ja": { "stringUnit": { "state": "translated", - "value": "アイコン" + "value": "隠す" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "图标" + "value": "隐藏" } } } }, - "Ignore This Version": { + "Home": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Ignore This Version" + "value": "Home" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このバージョンを無視" + "value": "ホーム" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "忽略此版本" + "value": "用户主目录" } } } }, - "Import…": { + "Icon": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Import…" + "value": "Icon" } }, "ja": { "stringUnit": { "state": "translated", - "value": "読み込み…" + "value": "アイコン" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "导入…" + "value": "图标" } } } }, - "Installation failed: %@": { + "Ignore This Version": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Installation failed: %@" + "value": "Ignore This Version" } }, "ja": { "stringUnit": { "state": "translated", - "value": "インストールに失敗しました: %@" + "value": "このバージョンを無視" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "安装失败:%@" + "value": "忽略此版本" } } } }, - "Invalid Folder": { + "Import…": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Invalid Folder" + "value": "Import…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "無効なフォルダ" + "value": "読み込み…" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "无效文件夹" + "value": "导入…" } } } }, - "Japanese": { + "Installation failed: %@": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Japanese" + "value": "Installation failed: %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "日本語" + "value": "インストールに失敗しました: %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "日本語" + "value": "安装失败:%@" } } } }, - "Language": { + "Invalid Folder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Language" + "value": "Invalid Folder" } }, "ja": { "stringUnit": { "state": "translated", - "value": "言語" + "value": "無効なフォルダ" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "语言" + "value": "无效文件夹" } } } }, - "Later": { + "Launch at login": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Later" + "value": "Launch at login" } }, "ja": { "stringUnit": { "state": "translated", - "value": "あとで" + "value": "ログイン時に起動" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "稍后" + "value": "登录时启动" } } } @@ -1825,6 +1987,29 @@ } } }, + "Manage…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "管理…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理…" + } + } + } + }, "Name": { "extractionState": "manual", "localizations": { @@ -1848,6 +2033,29 @@ } } }, + "New File": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New File" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ファイル" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建文件" + } + } + } + }, "New Version Available": { "extractionState": "manual", "localizations": { @@ -1917,6 +2125,29 @@ } } }, + "No folders authorized": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No folders authorized" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認証されたフォルダはありません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未授权任何文件夹" + } + } + } + }, "No update is available.": { "extractionState": "manual", "localizations": { @@ -2346,6 +2577,29 @@ } } }, + "Remove folder permission": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove folder permission" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダ権限を削除" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除文件夹权限" + } + } + } + }, "Reset": { "extractionState": "stale", "localizations": { @@ -2668,29 +2922,6 @@ } } }, - "Simplified Chinese": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Simplified Chinese" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "简体中文" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "简体中文" - } - } - } - }, "Template": { "extractionState": "manual", "localizations": { @@ -3144,374 +3375,6 @@ } } } - }, - "New File": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "New File" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新規ファイル" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "新建文件" - } - } - } - }, - "Launch at login": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Launch at login" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ログイン時に起動" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "登录时启动" - } - } - } - }, - "Cancel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancel" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "キャンセル" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "取消" - } - } - } - }, - "Delete": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "削除" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "删除" - } - } - } - }, - "Delete File Type": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete File Type" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイル種類を削除" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "删除文件类型" - } - } - } - }, - "Are you sure you want to delete \"%@\" (%@)? This action cannot be undone.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Are you sure you want to delete \"%@\" (%@)? This action cannot be undone." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "\"%@\"(%@)を削除してもよろしいですか?この操作は取り消せません。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "确定要删除「%@」(%@)吗?此操作无法撤销。" - } - } - } - }, - "Folder Permissions": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Folder Permissions" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "文件夹权限" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダ権限" - } - } - } - }, - "Manage…": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Manage…" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "管理…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "管理…" - } - } - } - }, - "No folders authorized": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No folders authorized" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "尚未授权任何文件夹" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "認証されたフォルダはありません" - } - } - } - }, - "Grant Access": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Grant Access" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "授权访问" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アクセスを許可" - } - } - } - }, - "Grant RClick access to this folder to perform file operations.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Grant RClick access to this folder to perform file operations." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "授权 RClick 访问此文件夹以执行文件操作。" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイル操作を実行するために、RClick にこのフォルダへのアクセスを許可してください。" - } - } - } - }, - "Choose a folder for RClick to access.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose a folder for RClick to access." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "选择 RClick 可以访问的文件夹。" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick がアクセスできるフォルダを選択してください。" - } - } - } - }, - "Authorize folders to let RClick create, delete, and manage files in them.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Authorize folders to let RClick create, delete, and manage files in them." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "授权文件夹,让 RClick 可以在其中创建、删除和管理文件。" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダを認証して、RClick がファイルの作成、削除、管理を行えるようにします。" - } - } - } - }, - "Remove folder permission": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Remove folder permission" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "移除文件夹权限" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダ権限を削除" - } - } - } - }, - "Done": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Done" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "完成" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "完了" - } - } - } - }, - "Add a Folder…": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add a Folder…" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "添加一个文件夹…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダを追加…" - } - } - } } }, "version": "1.0" diff --git a/RClick/RClickApp.swift b/RClick/RClickApp.swift index 4821f35..07a9918 100644 --- a/RClick/RClickApp.swift +++ b/RClick/RClickApp.swift @@ -43,7 +43,6 @@ struct RClickApp: App { SettingsWindow(appState: appState, onAppear: {}) .defaultAppStorage(.group) .environmentObject(updateManager) - .environment(\.locale, appState.locale) .modelContainer(SharedDataManager.sharedModelContainer) // showMenuBarExtra 为 true 时显示菜单条 @@ -53,7 +52,6 @@ struct RClickApp: App { MenuBarView() } .defaultAppStorage(.group) - .environment(\.locale, appState.locale) } } diff --git a/RClick/Settings/GeneralSettingsTabView.swift b/RClick/Settings/GeneralSettingsTabView.swift index 85eb94b..0257d8a 100644 --- a/RClick/Settings/GeneralSettingsTabView.swift +++ b/RClick/Settings/GeneralSettingsTabView.swift @@ -28,7 +28,6 @@ struct GeneralSettingsTabView: View { @State private var showDirImporter = false @State private var wrongFold = false @State private var showAlert = false - @State private var showLanguageRestartAlert = false let messager = Messager.shared @@ -60,22 +59,6 @@ struct GeneralSettingsTabView: View { Toggle(isOn: $launchAtLogin) { Text(appLocalized: "Launch at login") } - - Picker(selection: Binding( - get: { store.selectedLanguage }, - set: { newValue in - guard newValue != store.selectedLanguage else { return } - store.selectedLanguage = newValue - showLanguageRestartAlert = true - } - )) { - Text(appLocalized: "Automatic").tag(AppLanguage.automatic) - Text(appLocalized: "Simplified Chinese").tag(AppLanguage.simplifiedChinese) - Text(appLocalized: "English").tag(AppLanguage.english) - Text(appLocalized: "Japanese").tag(AppLanguage.japanese) - } label: { - Text(appLocalized: "Language") - } } header: { Text(appLocalized: "Main Controls") } footer: { @@ -194,17 +177,6 @@ struct GeneralSettingsTabView: View { .sheet(isPresented: $showFolderPermissionsSheet) { FolderPermissionsSheetView(bookmarkManager: store.bookmarkManager) } - .alert( - Text(appLocalized: "Language Change Requires Restart"), - isPresented: $showLanguageRestartAlert - ) { - Button(AppLocalization.localized("Restart Now")) { - restartApplication() - } - Button(AppLocalization.localized("Later"), role: .cancel) {} - } message: { - Text(appLocalized: "Some interface elements and Finder menus will update after restarting RClick.") - } } // MARK: - 权限状态检测 @@ -232,12 +204,6 @@ struct GeneralSettingsTabView: View { PermissionChecker.openAccessibilitySettings() } - private func restartApplication() { - let appURL = Bundle.main.bundleURL - try? Process.run(URL(fileURLWithPath: "/usr/bin/open"), arguments: ["-n", appURL.path]) - NSApp.terminate(nil) - } - // MARK: - 设置管理 private func exportSettings() { diff --git a/RClick/Settings/SettingsWindow.swift b/RClick/Settings/SettingsWindow.swift index 96b3dee..4d37a11 100644 --- a/RClick/Settings/SettingsWindow.swift +++ b/RClick/Settings/SettingsWindow.swift @@ -18,7 +18,6 @@ struct SettingsWindow: Scene { Window(AppLocalization.localized("Settings"), id: Constants.settingsWindowID) { SettingsView() .environmentObject(appState) - .environment(\.locale, appState.locale) .onAppear { onAppear() } diff --git a/RClick/Shared/StringExtension.swift b/RClick/Shared/StringExtension.swift index 2972e10..cadb815 100644 --- a/RClick/Shared/StringExtension.swift +++ b/RClick/Shared/StringExtension.swift @@ -37,7 +37,6 @@ enum Key { static let commonDirs = "RCLICK_COMMON_DIRS" static let showMenuBarExtra = "showMenuBarExtra" static let showInDock = "SHOW_IN_DOCK" - static let selectedLanguage = "SELECTED_LANGUAGE" static let actionMenuItems = "RCLICK_ACTION_MENU_ITEMS" static let appMenuItems = "RCLICK_APP_MENU_ITEMS" diff --git a/Shared/AppLocalization.swift b/Shared/AppLocalization.swift index 2f3e9ab..89b6336 100644 --- a/Shared/AppLocalization.swift +++ b/Shared/AppLocalization.swift @@ -19,7 +19,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { var localeIdentifier: String { switch self { case .automatic: - AppLocalization.systemLanguage.localeIdentifier + AppLocalization.currentLanguage.localeIdentifier case .simplifiedChinese: "zh-Hans" case .english: @@ -32,12 +32,8 @@ enum AppLanguage: String, CaseIterable, Identifiable { enum AppLocalization { static let tableName = "Localizable" - private static let selectedLanguageKey = "SELECTED_LANGUAGE" - private static var groupDefaults: UserDefaults { - UserDefaults(suiteName: "group.cn.wflixu.RClick") ?? .standard - } - static var systemLanguage: AppLanguage { + static var currentLanguage: AppLanguage { let preferred = Locale.preferredLanguages.first?.lowercased() ?? "en" if preferred.hasPrefix("zh") { return .simplifiedChinese @@ -48,15 +44,6 @@ enum AppLocalization { return .english } - static var currentLanguage: AppLanguage { - guard let rawValue = groupDefaults.string(forKey: selectedLanguageKey), - let language = AppLanguage(rawValue: rawValue) - else { - return .automatic - } - return language - } - static var currentLocale: Locale { Locale(identifier: currentLanguage.localeIdentifier) } From 4fbbb54f7c37a3edb9ebdbafdafe81fb27ebf03a Mon Sep 17 00:00:00 2001 From: wflixu Date: Sun, 12 Jul 2026 19:24:00 +0800 Subject: [PATCH 7/9] fix: GeneralSettingsTabView now observes BookmarkManager directly Add @ObservedObject for bookmarkManager so the Folder Permissions count updates immediately when authorization changes from file operations, without requiring a tab switch to refresh. --- RClick/Settings/GeneralSettingsTabView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/RClick/Settings/GeneralSettingsTabView.swift b/RClick/Settings/GeneralSettingsTabView.swift index 0257d8a..dc4f129 100644 --- a/RClick/Settings/GeneralSettingsTabView.swift +++ b/RClick/Settings/GeneralSettingsTabView.swift @@ -20,6 +20,7 @@ struct GeneralSettingsTabView: View { @AppStorage("launchAtLogin") private var launchAtLogin = false @AppStorage(Key.showMenuBarExtra, store: .group) private var showMenuBarExtra = true @EnvironmentObject var store: AppState + @ObservedObject private var bookmarkManager = AppState.shared.bookmarkManager @State private var finderSyncStatus: PermissionStatus = .unknown @State private var accessibilityStatus: PermissionStatus = .unknown @@ -90,7 +91,7 @@ struct GeneralSettingsTabView: View { // 文件夹权限(Bookmark) LabeledContent { HStack(spacing: 8) { - Text("\(store.bookmarkManager.authorizedDirectories.count)") + Text("\(bookmarkManager.authorizedDirectories.count)") .foregroundColor(.secondary) Button(AppLocalization.localized("Manage…")) { showFolderPermissionsSheet = true @@ -175,7 +176,7 @@ struct GeneralSettingsTabView: View { Text(appLocalized: "Folder access permission is required to use this feature.") } .sheet(isPresented: $showFolderPermissionsSheet) { - FolderPermissionsSheetView(bookmarkManager: store.bookmarkManager) + FolderPermissionsSheetView(bookmarkManager: bookmarkManager) } } From 40fa6d2c9a184240c3645df5946eb0fb6aed3de9 Mon Sep 17 00:00:00 2001 From: wflixu Date: Sun, 12 Jul 2026 19:27:25 +0800 Subject: [PATCH 8/9] chore: remove dead AppLanguage.automatic case and unused conformances Clean up after language picker removal: - Remove .automatic case (never assigned since picker removed) - Remove CaseIterable, Identifiable conformances (only needed for Picker) - Simplify localeIdentifier switch (no more .automatic case) --- Shared/AppLocalization.swift | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/Shared/AppLocalization.swift b/Shared/AppLocalization.swift index 89b6336..a34453e 100644 --- a/Shared/AppLocalization.swift +++ b/Shared/AppLocalization.swift @@ -8,24 +8,16 @@ import Foundation import SwiftUI -enum AppLanguage: String, CaseIterable, Identifiable { - case automatic = "auto" +enum AppLanguage: String { case simplifiedChinese = "zh-Hans" case english = "en" case japanese = "ja" - var id: String { rawValue } - var localeIdentifier: String { switch self { - case .automatic: - AppLocalization.currentLanguage.localeIdentifier - case .simplifiedChinese: - "zh-Hans" - case .english: - "en" - case .japanese: - "ja" + case .simplifiedChinese: "zh-Hans" + case .english: "en" + case .japanese: "ja" } } } From 6145c35aac010774c72b1009b7fcd96dd2a5d89a Mon Sep 17 00:00:00 2001 From: wflixu Date: Sun, 12 Jul 2026 19:28:37 +0800 Subject: [PATCH 9/9] refactor: remove custom language detection, use Bundle.localizedString directly xcstrings handles language selection automatically based on the user's system language preferences (en/zh-Hans/ja). The custom AppLanguage enum and currentLanguage detection logic were redundant. Removed: - AppLanguage enum (simplifiedChinese/english/japanese) - currentLanguage auto-detection - currentLocale computed property - localizedBundle manual bundle lookup - localeIdentifier on AppLanguage - bundle parameter from localized() (no longer needed) --- Shared/AppLocalization.swift | 56 ++---------------------------------- 1 file changed, 2 insertions(+), 54 deletions(-) diff --git a/Shared/AppLocalization.swift b/Shared/AppLocalization.swift index a34453e..16b1ab2 100644 --- a/Shared/AppLocalization.swift +++ b/Shared/AppLocalization.swift @@ -8,63 +8,11 @@ import Foundation import SwiftUI -enum AppLanguage: String { - case simplifiedChinese = "zh-Hans" - case english = "en" - case japanese = "ja" - - var localeIdentifier: String { - switch self { - case .simplifiedChinese: "zh-Hans" - case .english: "en" - case .japanese: "ja" - } - } -} - enum AppLocalization { static let tableName = "Localizable" - static var currentLanguage: AppLanguage { - let preferred = Locale.preferredLanguages.first?.lowercased() ?? "en" - if preferred.hasPrefix("zh") { - return .simplifiedChinese - } - if preferred.hasPrefix("ja") { - return .japanese - } - return .english - } - - static var currentLocale: Locale { - Locale(identifier: currentLanguage.localeIdentifier) - } - - static func localized(_ key: String, bundle: Bundle = .main) -> String { - localized(key, tableName: tableName, bundle: bundle) - } - - static func localized(_ key: String, tableName: String, bundle: Bundle = .main) -> String { - let bundle = localizedBundle(for: bundle) - return bundle.localizedString(forKey: key, value: key, table: tableName) - } - - private static func localizedBundle(for bundle: Bundle) -> Bundle { - let localeIdentifier = currentLanguage.localeIdentifier - - if let path = bundle.path(forResource: localeIdentifier, ofType: "lproj"), - let localizedBundle = Bundle(path: path) { - return localizedBundle - } - - let candidates = Bundle.preferredLocalizations(from: bundle.localizations, forPreferences: [localeIdentifier]) - if let preferred = candidates.first, - let path = bundle.path(forResource: preferred, ofType: "lproj"), - let localizedBundle = Bundle(path: path) { - return localizedBundle - } - - return bundle + static func localized(_ key: String) -> String { + Bundle.main.localizedString(forKey: key, value: key, table: tableName) } }