Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 144 additions & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ jobs:
runs-on: macos-latest
env:
APP_NAME: RClick
APP_BUNDLE_ID: cn.wflixu.RClick
EXTENSION_NAME: FinderSyncExt
EXTENSION_BUNDLE_ID: cn.wflixu.RClick.FinderSyncExt
APP_GROUP_ID: group.cn.wflixu.RClick
SCHEME: RClick
PROJECT: RClick.xcodeproj
steps:
Expand Down Expand Up @@ -63,9 +66,104 @@ jobs:

security list-keychains -d user -s build.keychain login.keychain

CERT_NAME=$(security find-identity -v -s 'Developer ID Application' | head -n 1 | awk -F'"' '{print $2}')
echo "CERT_NAME=$CERT_NAME" >> $GITHUB_ENV
echo "Using certificate: $CERT_NAME"
IDENTITY_INFO=$(security find-identity -v -p codesigning build.keychain \
| awk '/"Developer ID Application:/')
IDENTITY_COUNT=$(printf '%s\n' "$IDENTITY_INFO" \
| awk 'NF { count++ } END { print count + 0 }')
if [ "$IDENTITY_COUNT" -ne 1 ]; then
echo "Expected exactly one Developer ID Application identity, found $IDENTITY_COUNT"
printf '%s\n' "$IDENTITY_INFO"
exit 1
fi

CERT_IDENTITY=$(printf '%s\n' "$IDENTITY_INFO" | awk '{print $2}')
CERT_NAME=$(printf '%s\n' "$IDENTITY_INFO" | awk -F'"' '{print $2}')
echo "CERT_IDENTITY=$CERT_IDENTITY" >> "$GITHUB_ENV"
echo "Using certificate: $CERT_NAME ($CERT_IDENTITY)"

- name: Prepare App Group Provisioning Profiles
env:
APP_PROFILE_BASE64: ${{ secrets.MACOS_APP_PROVISIONING_PROFILE }}
EXTENSION_PROFILE_BASE64: ${{ secrets.MACOS_EXTENSION_PROVISIONING_PROFILE }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
: "${APP_PROFILE_BASE64:?Missing MACOS_APP_PROVISIONING_PROFILE secret}"
: "${EXTENSION_PROFILE_BASE64:?Missing MACOS_EXTENSION_PROVISIONING_PROFILE secret}"
: "${TEAM_ID:?Missing APPLE_TEAM_ID secret}"
: "${CERT_IDENTITY:?Missing Developer ID Application identity}"

mkdir -p signing
APP_PROFILE_PATH="$PWD/signing/${APP_NAME}.provisionprofile"
EXTENSION_PROFILE_PATH="$PWD/signing/${EXTENSION_NAME}.provisionprofile"
printf '%s' "$APP_PROFILE_BASE64" | base64 --decode > "$APP_PROFILE_PATH"
printf '%s' "$EXTENSION_PROFILE_BASE64" | base64 --decode > "$EXTENSION_PROFILE_PATH"

security cms -D -i "$APP_PROFILE_PATH" -o signing/app-profile.plist
security cms -D -i "$EXTENSION_PROFILE_PATH" -o signing/extension-profile.plist

validate_profile() {
local profile_plist="$1"
local expected_bundle_id="$2"

python3 - \
"$profile_plist" \
"$expected_bundle_id" \
"$TEAM_ID" \
"$APP_GROUP_ID" \
"$CERT_IDENTITY" <<'PY'
import hashlib
import plistlib
import sys

profile_path, expected_bundle_id, expected_team, expected_group, signing_identity = sys.argv[1:]
with open(profile_path, "rb") as profile_file:
profile = plistlib.load(profile_file)

entitlements = profile.get("Entitlements", {})
actual_identifier = entitlements.get("com.apple.application-identifier", "")
_, separator, actual_bundle_id = actual_identifier.partition(".")
if not separator or actual_bundle_id != expected_bundle_id:
raise SystemExit(
f"Provisioning profile does not match bundle ID "
f"{expected_bundle_id}: {actual_identifier}"
)

actual_team = entitlements.get("com.apple.developer.team-identifier")
if actual_team != expected_team:
raise SystemExit(
f"Provisioning profile team mismatch: expected {expected_team}, got {actual_team}"
)

actual_groups = entitlements.get("com.apple.security.application-groups")
if not isinstance(actual_groups, list) or expected_group not in actual_groups:
raise SystemExit(f"Provisioning profile does not authorize {expected_group}")

certificate_hashes = {
hashlib.sha1(certificate).hexdigest().upper()
for certificate in profile.get("DeveloperCertificates", [])
if isinstance(certificate, bytes)
}
if signing_identity.upper() not in certificate_hashes:
raise SystemExit(
f"Provisioning profile does not contain signing certificate {signing_identity}"
)
PY
}

validate_profile signing/app-profile.plist "$APP_BUNDLE_ID"
validate_profile signing/extension-profile.plist "$EXTENSION_BUNDLE_ID"
APP_IDENTIFIER=$(/usr/libexec/PlistBuddy \
-c 'Print :Entitlements:com.apple.application-identifier' \
signing/app-profile.plist)
EXTENSION_IDENTIFIER=$(/usr/libexec/PlistBuddy \
-c 'Print :Entitlements:com.apple.application-identifier' \
signing/extension-profile.plist)

echo "APP_PROFILE_PATH=$APP_PROFILE_PATH" >> "$GITHUB_ENV"
echo "EXTENSION_PROFILE_PATH=$EXTENSION_PROFILE_PATH" >> "$GITHUB_ENV"
echo "APP_IDENTIFIER=$APP_IDENTIFIER" >> "$GITHUB_ENV"
echo "EXTENSION_IDENTIFIER=$EXTENSION_IDENTIFIER" >> "$GITHUB_ENV"

# --- 2. 构建 Release App ---
- name: Build Release
Expand Down Expand Up @@ -102,43 +200,75 @@ jobs:

# --- 3. 分层代码签名 ---
- name: Code Sign
env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail

echo "Signing with: $CERT_NAME"
echo "Signing with identity: $CERT_IDENTITY"

MAIN_ENTITLEMENTS="signing/${APP_NAME}.entitlements"
EXTENSION_ENTITLEMENTS="signing/${EXTENSION_NAME}.entitlements"
cp "${APP_NAME}/${APP_NAME}.entitlements" "$MAIN_ENTITLEMENTS"
cp "${EXTENSION_NAME}/${EXTENSION_NAME}.entitlements" "$EXTENSION_ENTITLEMENTS"
/usr/libexec/PlistBuddy \
-c "Add :com.apple.application-identifier string $APP_IDENTIFIER" \
"$MAIN_ENTITLEMENTS"
/usr/libexec/PlistBuddy \
-c "Add :com.apple.developer.team-identifier string $TEAM_ID" \
"$MAIN_ENTITLEMENTS"
/usr/libexec/PlistBuddy \
-c "Add :com.apple.application-identifier string $EXTENSION_IDENTIFIER" \
"$EXTENSION_ENTITLEMENTS"
/usr/libexec/PlistBuddy \
-c "Add :com.apple.developer.team-identifier string $TEAM_ID" \
"$EXTENSION_ENTITLEMENTS"

# 1) 签名所有 framework
if [ -d "$APP_PATH/Contents/Frameworks" ]; then
echo "Signing frameworks..."
find "$APP_PATH/Contents/Frameworks" -type f -perm +111 \
-exec codesign --force --options runtime \
--timestamp \
--sign "$CERT_NAME" {} \;
--sign "$CERT_IDENTITY" {} \;
fi

# 2) 签名扩展 (FinderSyncExt.appex)
echo "Signing FinderSync extension..."
EXT_PATH="$APP_PATH/Contents/PlugIns/${EXTENSION_NAME}.appex"
if [ -d "$EXT_PATH" ]; then
cp "$EXTENSION_PROFILE_PATH" "$EXT_PATH/Contents/embedded.provisionprofile"
codesign --force --options runtime \
--timestamp \
--entitlements "${EXTENSION_NAME}/${EXTENSION_NAME}.entitlements" \
--sign "$CERT_NAME" \
--entitlements "$EXTENSION_ENTITLEMENTS" \
--sign "$CERT_IDENTITY" \
"$EXT_PATH"
else
echo "FinderSync extension not found at $EXT_PATH"
exit 1
fi

# 3) 签名主 App (使用项目 entitlements)
# 3) 嵌入主 App profile 并签名
echo "Signing main app..."
cp "$APP_PROFILE_PATH" "$APP_PATH/Contents/embedded.provisionprofile"
codesign --force --options runtime \
--timestamp \
--entitlements "${APP_NAME}/${APP_NAME}.entitlements" \
--sign "$CERT_NAME" \
--entitlements "$MAIN_ENTITLEMENTS" \
--sign "$CERT_IDENTITY" \
"$APP_PATH"

# 验证签名
echo "Verifying signatures..."
# 验证签名、profile 和 App Group 身份声明
echo "Verifying signatures and App Group authorization..."
test -f "$APP_PATH/Contents/embedded.provisionprofile"
test -f "$EXT_PATH/Contents/embedded.provisionprofile"
APP_SIGNED_ENTITLEMENTS=$(codesign -d --entitlements - "$APP_PATH" 2>&1)
EXTENSION_SIGNED_ENTITLEMENTS=$(codesign -d --entitlements - "$EXT_PATH" 2>&1)
grep -Fq "$APP_IDENTIFIER" <<< "$APP_SIGNED_ENTITLEMENTS"
grep -Fq "$EXTENSION_IDENTIFIER" <<< "$EXTENSION_SIGNED_ENTITLEMENTS"
grep -Fq "$APP_GROUP_ID" <<< "$APP_SIGNED_ENTITLEMENTS"
grep -Fq "$APP_GROUP_ID" <<< "$EXTENSION_SIGNED_ENTITLEMENTS"
codesign -dv --verbose=4 "$APP_PATH" 2>&1
codesign --verify --verbose "$APP_PATH"
codesign --verify --deep --strict --verbose=4 "$APP_PATH"

# --- 4. 打包为 ZIP 用于公证 ---
- name: Zip for Notarization
Expand Down Expand Up @@ -217,7 +347,7 @@ jobs:
"$DMG_NAME" \
"$DMG_STAGING/${APP_NAME}.app"

codesign --force --sign "$CERT_NAME" "$DMG_NAME"
codesign --force --sign "$CERT_IDENTITY" "$DMG_NAME"

rm -rf "$DMG_STAGING"

Expand Down
76 changes: 38 additions & 38 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,26 +34,25 @@ RClick follows a dual-process architecture:

### 3. Communication Layer
The main app and extension communicate via `DistributedNotificationCenter`:
- **Extension → App**: `RClick.MessageFromFinder` (actions, file operations)
- **App → Extension**: `RClick.MessageFromApp` (menu updates, config changes)
- Protocol defined in: [specs/001-macos-app-macos/contracts/app-extension-communication.md](specs/001-macos-app-macos/contracts/app-extension-communication.md)
- Implementation: [Messager.swift](RClick/Shared/Messager.swift)
- **Extension → App**: `RClick.ExtensionToMain` (click events, heartbeat, requestConfig)
- **App → Extension**: `RClick.MainToExtension` (menu config, running, quit, actionAck)
- Messages are HMAC-SHA256 signed (`MessageSecurity`) and JSON-encoded via `Messager`
- Implementation: [Messager.swift](Shared/Messager.swift)

### 4. State Management
- **AppState**: Centralized `ObservableObject` managing all app state
- Apps: External applications that can open files
- Dirs: Permissive directories (with security bookmarks)
- Actions: Custom context menu actions
- NewFiles: File templates for creation
- CommonDirs: Quick access folders
- Persistence: SwiftData with shared container between app and extension
- Location: [AppState.swift](RClick/AppState.swift)
- **AppState**: `@MainActor ObservableObject` holding runtime state (in-memory config arrays, fold toggles, `BookmarkManager`)
- **ConfigService**: SwiftData config load/save/reset (persistence separated from runtime state)
- **RCRuntime**: in-process dependency container (`RClick/Runtime/`) holding AppState + ConfigService + MenuService + ActionService + PermissionService + Messager. **It is NOT a separate process / Helper.**
- **BookmarkManager**: security-scoped bookmarks — the only folder-authorization mechanism (mandatory for the sandboxed App Store build)
- Data categories: Apps (external apps), Actions (context menu actions), NewFiles (file templates), CommonDirs (quick access folders), BookmarkEntity (authorized folder credentials)
- Persistence: SwiftData with shared App Group container between app and extension
- Location: [AppState.swift](RClick/AppState.swift) / [RClick/Runtime/](RClick/Runtime/)

### 5. Data Models
All models are in [RClick/Model/](RClick/Model/):
- `Models.swift`: SwiftData `@Model` definitions (PermDir, OpenWithApp, RCAction, NewFile, CommonDir)
- `RCBase.swift`: Base protocol for common functionality
- `ModelContainer.swift`: Shared SwiftData container configuration
SwiftData `@Model` entities are in [RClick/Model/](RClick/Model/), one file per entity:
- `AppEntity.swift`, `ActionEntity.swift`, `NewFileTypeEntity.swift`, `CommonDirEntity.swift`, `BookmarkEntity.swift`, `DataVersion.swift`
- In-memory models & IPC DTOs are in [Shared/RCBase.swift](Shared/RCBase.swift) (`OpenWithApp`, `RCAction`, `NewFile`, `CommonDir`, `AppMenuItem`, `ActionMenuItem`, …)
- `ModelContainer.swift`: Shared App Group SwiftData container configuration

## Build and Development Commands

Expand Down Expand Up @@ -88,31 +87,30 @@ swiftlint

### Adding New Context Menu Actions

1. Define action model in [Models.swift](RClick/Model/Models.swift)
1. Define action model in [Shared/RCBase.swift](Shared/RCBase.swift)
2. Add to `RCAction.all` static property
3. Handle action in [RClickApp.swift](RClick/RClickApp.swift) in `actionHandler()` method
4. Extension receives action via menu callback and sends message to main app
3. Handle action in [ActionService.swift](RClick/Runtime/ActionService.swift) in `actionHandler()` method
4. Extension receives action via menu callback and sends `.click` message to main app (via `Messager`)

### Adding New File Templates

1. Add to `NewFile` model in [Models.swift](RClick/Model/Models.swift)
1. Add to `NewFile` model in [Shared/RCBase.swift](Shared/RCBase.swift)
2. Add template file to [Assets.xcassets](RClick/Assets.xcassets/)
3. Handle creation in [RClickApp.swift](RClick/RClickApp.swift) in `createFile()` method
3. Handle creation in [ActionService.swift](RClick/Runtime/ActionService.swift) in `createFile()` method

### Inter-Process Communication

When adding new message types:
1. Define `MessagePayload` structure in [Messager.swift](RClick/Shared/Messager.swift)
2. Register message handler in appropriate init method
3. Update contract documentation in [specs/001-macos-app-macos/contracts/app-extension-communication.md](specs/001-macos-app-macos/contracts/app-extension-communication.md)
1. Define the message enum case + payload in [Messager.swift](Shared/Messager.swift)
2. Register message handler in the appropriate `init` / `applicationDidFinishLaunching`
3. If the extension needs to detect whether the main app received a message, add a matching ack message

### Security-Scoped Resource Access

When working with files outside app sandbox:
1. User must grant permission via `NSOpenPanel`
2. Store bookmark data using `URL.bookmarkData(options: ...)`
3. Access files with `startAccessingSecurityScopedResource()` / `stopAccessingSecurityScopedResource()`
4. See `deleteFoldorFile()` and `createFile()` in [RClickApp.swift](RClick/RClickApp.swift) for examples
When working with files outside the sandbox container (required for the App Store build):
1. Check `PermissionService.hasAccess(to:)` (bookmark prefix match, covers subtree)
2. If not authorized, `PermissionService.promptForPermission(for:)` shows `NSOpenPanel` (powerbox) → user grants once → bookmark cached
3. See [BookmarkManager.swift](RClick/Shared/BookmarkManager.swift) (implementation) and [ActionService.swift](RClick/Runtime/ActionService.swift) (usage in delete/create/hide/open)

## Important Constraints

Expand All @@ -123,9 +121,10 @@ When working with files outside app sandbox:
- **Target macOS 15 Sequoia and above only**

### Extension Development
- Extension runs in separate process with limited memory
- Must handle `isHostAppOpen` state - don't block if main app not available
- Use heartbeat mechanism to verify main app is running
- Extension runs in separate process with limited memory (keep it a thin renderer + event forwarder)
- **Connection state is a UX cache, not a reliability mechanism**: the extension tracks "last message from main app" (`lastMainActivity`, 30s timeout) to show a disabled "RClick is not running" menu — it does NOT determine operation success
- **Real liveness check is at click time**: extension sends `.click`; main app replies `.actionAck`; if no ack in 3s, extension shows a "RClick did not respond" alert
- On `.quit` the extension immediately marks itself offline (no 30s "zombie" window)
- See [FinderSyncExt.swift](FinderSyncExt/FinderSyncExt.swift)

### Logging
Expand Down Expand Up @@ -154,15 +153,16 @@ When working with files outside app sandbox:
RClick/
├── RClick/ # Main application target
│ ├── RClickApp.swift # App entry point & AppDelegate
│ ├── AppState.swift # Global state management
│ ├── Model/ # SwiftData models
│ ├── AppState.swift # Runtime state
│ ├── Runtime/ # In-process service layer (MenuService/ActionService/PermissionService/ConfigService/RCRuntime)
│ ├── Model/ # SwiftData entities
│ ├── Settings/ # Settings views (UI)
│ ├── Shared/ # Utilities & services
│ ├── Shared/ # Utilities (BookmarkManager, LaunchAtLogin, Updater, …)
│ ├── Assets.xcassets/ # Images, templates, icons
│ └── Resources/ # Localization files
├── FinderSyncExt/ # Finder extension target
── FinderSyncExt.swift # Extension main file
│ └── MenuItemClickable.swift # Menu item handlers
├── FinderSyncExt/ # Finder extension target (thin: render + forward)
── FinderSyncExt.swift # Extension main file
├── Shared/ # Code shared by app & extension (Messager, RCBase, AppLocalization, …)
├── specs/ # Feature specifications & contracts
└── RClick.xcodeproj # Xcode project
```
Expand Down
Loading