From bbb12a0eab9a05eddaf2e5ed72ecd8d194051151 Mon Sep 17 00:00:00 2001 From: Peerapat_J Date: Thu, 4 Jun 2026 21:52:21 +0700 Subject: [PATCH 01/12] Document clean-room M0 baseline --- docs/clean-room-rules.md | 62 +++++++++++++++++ docs/contribution-notes.md | 50 ++++++++++++++ docs/parity-roadmap.md | 88 ++++++++++++++++++++++++ docs/reference-feature-inventory.md | 102 ++++++++++++++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 docs/clean-room-rules.md create mode 100644 docs/contribution-notes.md create mode 100644 docs/parity-roadmap.md create mode 100644 docs/reference-feature-inventory.md diff --git a/docs/clean-room-rules.md b/docs/clean-room-rules.md new file mode 100644 index 0000000..59846df --- /dev/null +++ b/docs/clean-room-rules.md @@ -0,0 +1,62 @@ +# Clean-Room Rewrite Rules + +LinguistMac is a fresh implementation of a macOS screen translation app. The +reference repository is useful for product behavior, but its GPLv3 source, +assets, and implementation structure must not be copied into this project. + +This document is an engineering guardrail, not legal advice. + +## Allowed Reference Material + +Use these sources to understand product behavior: + +- Public README, CHANGELOG, release notes, and project descriptions. +- Public screenshots or user-provided screenshots. +- User-provided requirements written in this repository or in GitHub issues. +- Independently written notes that describe behavior without code-level detail. +- Public documentation for Apple, DeepL, Google Cloud Translation, Microsoft + Azure Translator, GitHub Actions, Swift, and other APIs used directly here. + +## Disallowed Material + +Do not copy or translate these into LinguistMac: + +- Source files from the reference project. +- Test code from the reference project. +- Build scripts, CI scripts, signing scripts, or generated files from the + reference project. +- UI strings, icon artwork, images, fonts, layout constants, window behavior + constants, or custom assets from the reference project. +- File organization, type names, protocol names, dependency graph, or internal + implementation architecture from the reference project. +- Line-by-line behavior inferred from source code. + +## Implementation Rules + +- Implement behavior from first principles in this repository. +- Keep platform wrappers thin and testable. +- Put product logic in `LinguistMacCore`. +- Keep UI presentation in the app target. +- Add focused tests for each behavior as it lands. +- Prefer public API documentation over reverse-engineering the reference + project. +- Document any uncertain feature before implementing it. + +## PR Review Checklist + +Every implementation PR should answer: + +- Which GitHub issue or milestone does this close or advance? +- Which behavior was implemented? +- Which public/product source describes that behavior? +- Which tests cover the behavior? +- Was any GPL source, asset, script, UI text, or architecture copied? The + expected answer is no. +- Were privacy-sensitive paths reviewed for clipboard, permissions, API keys, + network calls, or captured text? + +## Commit Discipline + +Keep commits scoped. If review feedback reports multiple unrelated findings, +fix and push them as separate commits so each finding can be inspected without +being mixed into a larger change. diff --git a/docs/contribution-notes.md b/docs/contribution-notes.md new file mode 100644 index 0000000..6f854f1 --- /dev/null +++ b/docs/contribution-notes.md @@ -0,0 +1,50 @@ +# Contribution Notes + +These notes keep implementation work easy to continue across Codex chats and +easy to review in GitHub. + +## Before Editing + +- Fetch the requested base branch first. If that branch does not exist, verify + the live remote state and use the repository default branch only when it is + clearly the available source of truth. +- Read the relevant GitHub issue body before implementing. +- Check the working tree for unrelated changes. + +## While Editing + +- Keep changes scoped to the issue or PR slice. +- Prefer small files and explicit service boundaries. +- Put business logic in `LinguistMacCore`. +- Keep UI code in the app target. +- Add tests when the behavior has state, branching, permissions, persistence, + network boundaries, or privacy impact. +- Do not copy source, assets, UI text, scripts, test code, or architecture from + the GPL reference project. + +## Before Committing + +- Re-check `git status`. +- Inspect the diff. +- Run the focused tests for the changed behavior. +- Run broader validation before pushing a PR. + +## Commit And Review Shape + +- Commit by issue or review finding. +- Do not bundle unrelated findings into one large commit. +- Use PR bodies that link issues with closing keywords when merge should close + them. +- Include validation commands and any skipped checks. + +## Suggested Local Validation + +Start narrow and expand: + +```sh +swift test +swift build -c debug --product LinguistMac +``` + +Before a PR, also run the CI-equivalent commands documented in +`docs/ci-cd.md` when local tooling is available. diff --git a/docs/parity-roadmap.md b/docs/parity-roadmap.md new file mode 100644 index 0000000..aca262f --- /dev/null +++ b/docs/parity-roadmap.md @@ -0,0 +1,88 @@ +# Parity Roadmap + +This roadmap maps the clean-room implementation sequence to GitHub milestones +and issues. The first goal is behavior parity without copying GPL source, +assets, UI text, implementation structure, or scripts. + +## M0 Foundation + Clean-Room Baseline + +Goal: make future work safe, testable, and reviewable. + +- `#1` `[M0][app] Scaffold macOS SwiftUI project` - completed scaffold. +- `#2` `[M0][architecture] Set up project structure and module boundaries` - + define core models, service protocols, and dependency injection. +- `#3` `[M0][app] Configure identity, entitlements, and permission baseline` - + add app metadata and permission posture. +- `#4` `[M0][ci] Add GitHub Actions CI for build and tests` - completed CI. +- `#5` `[M0][clean-room] Add rewrite documentation and feature inventory` - + document clean-room rules and parity scope. +- `#6` `[M0][tests] Add test target, mocks, and validation fixtures` - add + test doubles and focused baseline tests. + +## M1 Menu Bar + UI Shell + +Goal: create app surfaces before complex behavior. + +- `#7` menu bar app shell. +- `#10` settings window shell. +- `#11` translation popup shell. +- `#12` Quick Translate panel shell. +- `#13` onboarding and status surfaces. + +## M2 Screen Translation MVP + +Goal: first end-to-end selected-region translation. + +- `#14` region selection overlay and screen capture service. +- `#15` Apple Vision OCR and text preprocessing. +- `#16` Apple Translation coordinator. +- `#17` language selection, swap, auto-detect, and pack status. +- `#18` wire end-to-end screen translation flow. + +## M3 Text Selection + Shortcut Input Modes + +Goal: add non-OCR input workflows. + +- `#19` configurable global shortcuts. +- `#20` selected-text translation workflow. +- `#21` double-copy clipboard translation trigger. +- `#22` drag/text capture translation mode. + +## M4 Settings + Translation Providers + +Goal: complete user-configurable provider parity. + +- `#23` persisted user preferences and defaults. +- `#24` BYOK provider architecture. +- `#25` secure API key storage. +- `#26` launch at login, app language, and auto-copy settings. + +## M5 History + UX Polish + Release Readiness + +Goal: finish daily-use polish and release posture. + +- `#27` SwiftData translation history and recent menu. +- `#28` popup resizing, width, font, and position polish. +- `#29` user-facing errors and recovery paths. +- `#30` privacy posture and no telemetry/update assumptions. +- `#31` signing, notarization, and distributable artifact workflow. + +## M6 Future Differentiation After Parity + +Goal: keep non-parity ideas out of the critical path until parity is stable. + +- `#32` word card and dictionary planning. +- `#33` speech or voice translation planning. + +## Review Strategy + +M0 can be reviewed in one PR because the open M0 issues are a single foundation +slice. Keep commits separated by issue: + +- docs commit for `#5` +- architecture commit for `#2` +- tests/mocks commit for `#6` +- identity/permissions commit for `#3` + +If a later milestone has independent user-facing surfaces, split PRs by workflow +or review risk rather than forcing all issues into one branch. diff --git a/docs/reference-feature-inventory.md b/docs/reference-feature-inventory.md new file mode 100644 index 0000000..96cb850 --- /dev/null +++ b/docs/reference-feature-inventory.md @@ -0,0 +1,102 @@ +# Reference Feature Inventory + +This inventory captures high-level behavior for clean-room planning. It is based +on public README and CHANGELOG level information from `Peerapat-J/translateOnScreen` +as inspected on 2026-06-04. It must not be treated as implementation guidance. + +## Product Shape + +- macOS menu bar screen translation app. +- Personal-use fork, not a public download or auto-updating distribution. +- On-device default behavior through Apple Vision and Apple Translation. +- Optional bring-your-own-key cloud translation providers. +- No telemetry or Sparkle auto-update feed in the referenced fork target per + its README. + +## Capture And Input Modes + +- Region-based screen selection for screen translation. +- Screen capture overlay with immediate selection affordance. +- Guard against re-triggering the overlay while already active. +- Selected text translation from other apps without OCR. +- Double-copy clipboard translation trigger. +- Drag translation behavior that requires Accessibility readiness. +- Quick Translate panel for typed text. + +## OCR + +- Apple Vision OCR. +- Text cleanup for natural sentence flow. +- Paragraph break preservation. +- Bullet and numbered list preservation where appropriate. +- User-visible no-text or OCR-failure states. + +## Translation + +- Apple Translation as the on-device default engine. +- Source language selection. +- Target language selection. +- Source auto-detect. +- Language swap. +- Language pack status and download guidance. +- Optional DeepL, Google Cloud Translation, and Microsoft Azure Translator + providers when the user selects an engine and supplies a key. + +## UI Surfaces + +- Menu bar app shell. +- Settings window. +- Translation popup with translated text, original text toggle, copy, and close. +- Quick Translate floating panel. +- First-launch onboarding or setup guidance. +- History window or list. +- About/status surface. + +## Settings + +- Screen translation shortcut. +- Text selection shortcut. +- Quick Translate shortcut. +- Source and target language preferences. +- Engine selection. +- API key status for cloud providers. +- Auto-copy toggle. +- Launch at login toggle. +- Popup width behavior. +- Popup font size. +- Popup font family strategy. +- App language setting. + +## Persistence + +- Translation history. +- Recent translations in the menu bar. +- User-friendly timestamps. +- Automatic history trimming, currently planned as latest 50 records unless a + later product decision changes it. +- Popup size or position preferences where useful. + +## Permissions And Privacy + +- Screen Recording is relevant to capture workflows. +- Accessibility is relevant to selected text, double-copy, and drag workflows. +- Clipboard paths must preserve user clipboard state unless the user explicitly + enables auto-copy behavior. +- Cloud providers send text to the selected provider only when the user chooses + that provider and configures a key. +- API keys must be stored securely and redacted from logs, tests, and errors. + +## Release And Packaging + +- Development artifacts can remain unsigned. +- Signed distribution, notarization, and packaging are release-readiness work. +- Auto-update and telemetry are out of the initial clean-room parity baseline + unless explicitly approved in a future issue. + +## Needs Confirmation + +- Final default keyboard shortcuts. +- Final supported language catalog. +- Exact drag translation behavior. +- Whether app language should remain English/Korean only or expand later. +- Which fonts can be bundled or selected without licensing risk. From 22bcf6afecbb7f794f0f638c118938c5e16dc41f Mon Sep 17 00:00:00 2001 From: Peerapat_J Date: Thu, 4 Jun 2026 21:55:33 +0700 Subject: [PATCH 02/12] Add M0 core architecture boundaries --- Sources/LinguistMacCore/AppFeature.swift | 8 +- Sources/LinguistMacCore/AppSettings.swift | 66 +++++++ .../LinguistMacCore/LinguistServices.swift | 30 ++++ Sources/LinguistMacCore/Permissions.swift | 55 ++++++ .../LinguistMacCore/ServiceProtocols.swift | 45 +++++ .../LinguistMacCore/TranslationModels.swift | 166 ++++++++++++++++++ 6 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 Sources/LinguistMacCore/AppSettings.swift create mode 100644 Sources/LinguistMacCore/LinguistServices.swift create mode 100644 Sources/LinguistMacCore/Permissions.swift create mode 100644 Sources/LinguistMacCore/ServiceProtocols.swift create mode 100644 Sources/LinguistMacCore/TranslationModels.swift diff --git a/Sources/LinguistMacCore/AppFeature.swift b/Sources/LinguistMacCore/AppFeature.swift index e0d5bad..1baa4d4 100644 --- a/Sources/LinguistMacCore/AppFeature.swift +++ b/Sources/LinguistMacCore/AppFeature.swift @@ -26,10 +26,10 @@ public extension AppFeature { systemImage: "rectangle.dashed" ), AppFeature( - id: "word-card", - title: "Word Card", - summary: "Show pronunciation, parts of speech, and alternate meanings.", - systemImage: "text.magnifyingglass" + id: "quick-translate", + title: "Quick Translate", + summary: "Translate typed or selected text without taking a screenshot.", + systemImage: "text.cursor" ), AppFeature( id: "history", diff --git a/Sources/LinguistMacCore/AppSettings.swift b/Sources/LinguistMacCore/AppSettings.swift new file mode 100644 index 0000000..712a6f1 --- /dev/null +++ b/Sources/LinguistMacCore/AppSettings.swift @@ -0,0 +1,66 @@ +import Foundation + +public struct AppSettings: Equatable, Sendable { + public var sourceLanguage: TranslationLanguage + public var targetLanguage: TranslationLanguage + public var selectedProviderID: TranslationProviderID + public var autoCopyEnabled: Bool + public var launchAtLoginEnabled: Bool + public var screenTranslationShortcut: KeyboardShortcut + public var textSelectionShortcut: KeyboardShortcut + public var quickTranslateShortcut: KeyboardShortcut + + public init( + sourceLanguage: TranslationLanguage = .autoDetect, + targetLanguage: TranslationLanguage = .english, + selectedProviderID: TranslationProviderID = .apple, + autoCopyEnabled: Bool = false, + launchAtLoginEnabled: Bool = false, + screenTranslationShortcut: KeyboardShortcut = .screenTranslationDefault, + textSelectionShortcut: KeyboardShortcut = .textSelectionDefault, + quickTranslateShortcut: KeyboardShortcut = .quickTranslateDefault + ) { + self.sourceLanguage = sourceLanguage + self.targetLanguage = targetLanguage + self.selectedProviderID = selectedProviderID + self.autoCopyEnabled = autoCopyEnabled + self.launchAtLoginEnabled = launchAtLoginEnabled + self.screenTranslationShortcut = screenTranslationShortcut + self.textSelectionShortcut = textSelectionShortcut + self.quickTranslateShortcut = quickTranslateShortcut + } +} + +public struct KeyboardShortcut: Equatable, Hashable, Sendable { + public var key: String + public var modifiers: Set + + public init(key: String, modifiers: Set) { + self.key = key + self.modifiers = modifiers + } +} + +public enum KeyboardModifier: String, CaseIterable, Sendable { + case command + case control + case option + case shift +} + +public extension KeyboardShortcut { + static let screenTranslationDefault = KeyboardShortcut( + key: "E", + modifiers: [.command] + ) + + static let quickTranslateDefault = KeyboardShortcut( + key: "E", + modifiers: [.command, .shift] + ) + + static let textSelectionDefault = KeyboardShortcut( + key: "Z", + modifiers: [.command, .option] + ) +} diff --git a/Sources/LinguistMacCore/LinguistServices.swift b/Sources/LinguistMacCore/LinguistServices.swift new file mode 100644 index 0000000..3cb10d1 --- /dev/null +++ b/Sources/LinguistMacCore/LinguistServices.swift @@ -0,0 +1,30 @@ +public struct LinguistServices: Sendable { + public let screenCapture: any ScreenCaptureServicing + public let ocr: any OCRServicing + public let translatorRegistry: any TranslationProviderRegistry + public let settingsStore: any AppSettingsStoring + public let historyStore: any TranslationHistoryStoring + public let permissionChecker: any PermissionChecking + public let clipboard: any ClipboardServicing + public let shortcutRegistry: any ShortcutRegistering + + public init( + screenCapture: any ScreenCaptureServicing, + ocr: any OCRServicing, + translatorRegistry: any TranslationProviderRegistry, + settingsStore: any AppSettingsStoring, + historyStore: any TranslationHistoryStoring, + permissionChecker: any PermissionChecking, + clipboard: any ClipboardServicing, + shortcutRegistry: any ShortcutRegistering + ) { + self.screenCapture = screenCapture + self.ocr = ocr + self.translatorRegistry = translatorRegistry + self.settingsStore = settingsStore + self.historyStore = historyStore + self.permissionChecker = permissionChecker + self.clipboard = clipboard + self.shortcutRegistry = shortcutRegistry + } +} diff --git a/Sources/LinguistMacCore/Permissions.swift b/Sources/LinguistMacCore/Permissions.swift new file mode 100644 index 0000000..e8df373 --- /dev/null +++ b/Sources/LinguistMacCore/Permissions.swift @@ -0,0 +1,55 @@ +public enum PermissionKind: String, CaseIterable, Sendable { + case screenRecording + case accessibility + case keychain + case network +} + +public enum PermissionStatus: Equatable, Sendable { + case notDetermined + case granted + case denied + case restricted + case unavailable +} + +public struct PermissionRequirement: Equatable, Sendable { + public let kind: PermissionKind + public let reason: String + public let isRequiredForDefaultWorkflow: Bool + + public init( + kind: PermissionKind, + reason: String, + isRequiredForDefaultWorkflow: Bool + ) { + self.kind = kind + self.reason = reason + self.isRequiredForDefaultWorkflow = isRequiredForDefaultWorkflow + } +} + +public enum PermissionBaseline { + public static let defaultRequirements: [PermissionRequirement] = [ + PermissionRequirement( + kind: .screenRecording, + reason: "Capturing a selected screen region for OCR.", + isRequiredForDefaultWorkflow: true + ), + PermissionRequirement( + kind: .accessibility, + reason: "Reading selected text, double-copy, and drag translation workflows.", + isRequiredForDefaultWorkflow: false + ), + PermissionRequirement( + kind: .keychain, + reason: "Storing optional bring-your-own-key provider credentials.", + isRequiredForDefaultWorkflow: false + ), + PermissionRequirement( + kind: .network, + reason: "Calling optional cloud translation providers selected by the user.", + isRequiredForDefaultWorkflow: false + ) + ] +} diff --git a/Sources/LinguistMacCore/ServiceProtocols.swift b/Sources/LinguistMacCore/ServiceProtocols.swift new file mode 100644 index 0000000..175c5a2 --- /dev/null +++ b/Sources/LinguistMacCore/ServiceProtocols.swift @@ -0,0 +1,45 @@ +public protocol ScreenCaptureServicing: Sendable { + func captureSelection() async throws -> CapturedScreenRegion +} + +public protocol OCRServicing: Sendable { + func recognizeText(in region: CapturedScreenRegion) async throws -> RecognizedText +} + +public protocol TranslationProviding: Sendable { + var id: TranslationProviderID { get } + var displayName: String { get } + var requiresAPIKey: Bool { get } + var usesNetwork: Bool { get } + + func translate(_ request: TranslationRequest) async throws -> TranslationResult +} + +public protocol TranslationProviderRegistry: Sendable { + func provider(for id: TranslationProviderID) async throws -> any TranslationProviding + func availableProviders() async -> [TranslationProviderDescriptor] +} + +public protocol AppSettingsStoring: Sendable { + func loadSettings() async throws -> AppSettings + func saveSettings(_ settings: AppSettings) async throws +} + +public protocol TranslationHistoryStoring: Sendable { + func save(_ result: TranslationResult) async throws + func recent(limit: Int) async throws -> [TranslationResult] +} + +public protocol PermissionChecking: Sendable { + func status(for kind: PermissionKind) async -> PermissionStatus +} + +public protocol ClipboardServicing: Sendable { + func readText() async -> String? + func writeText(_ text: String) async +} + +public protocol ShortcutRegistering: Sendable { + func register(_ shortcut: KeyboardShortcut, for action: ShortcutAction) async throws + func unregister(_ action: ShortcutAction) async +} diff --git a/Sources/LinguistMacCore/TranslationModels.swift b/Sources/LinguistMacCore/TranslationModels.swift new file mode 100644 index 0000000..ce84e4c --- /dev/null +++ b/Sources/LinguistMacCore/TranslationModels.swift @@ -0,0 +1,166 @@ +import Foundation + +public struct TranslationLanguage: Equatable, Hashable, Sendable { + public let id: String + public let displayName: String + public let supportsAutoDetect: Bool + + public init( + id: String, + displayName: String, + supportsAutoDetect: Bool = false + ) { + self.id = id + self.displayName = displayName + self.supportsAutoDetect = supportsAutoDetect + } +} + +public extension TranslationLanguage { + static let autoDetect = TranslationLanguage( + id: "auto", + displayName: "Auto Detect", + supportsAutoDetect: true + ) + + static let english = TranslationLanguage(id: "en", displayName: "English") + static let thai = TranslationLanguage(id: "th", displayName: "Thai") + static let japanese = TranslationLanguage(id: "ja", displayName: "Japanese") + static let korean = TranslationLanguage(id: "ko", displayName: "Korean") + static let simplifiedChinese = TranslationLanguage(id: "zh-Hans", displayName: "Chinese Simplified") +} + +public struct TranslationProviderID: RawRepresentable, Equatable, Hashable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } +} + +public extension TranslationProviderID { + static let apple = TranslationProviderID(rawValue: "apple") + static let deepl = TranslationProviderID(rawValue: "deepl") + static let googleCloud = TranslationProviderID(rawValue: "google-cloud") + static let microsoftAzure = TranslationProviderID(rawValue: "microsoft-azure") +} + +public struct TranslationProviderDescriptor: Equatable, Sendable { + public let id: TranslationProviderID + public let displayName: String + public let requiresAPIKey: Bool + public let usesNetwork: Bool + + public init( + id: TranslationProviderID, + displayName: String, + requiresAPIKey: Bool, + usesNetwork: Bool + ) { + self.id = id + self.displayName = displayName + self.requiresAPIKey = requiresAPIKey + self.usesNetwork = usesNetwork + } +} + +public enum TranslationInputMode: String, CaseIterable, Sendable { + case screenSelection + case selectedText + case clipboardDoubleCopy + case dragTranslation + case quickTranslate +} + +public enum ShortcutAction: String, CaseIterable, Sendable { + case screenTranslation + case textSelectionTranslation + case quickTranslate + case clipboardDoubleCopy + case dragTranslation +} + +public struct CapturedScreenRegion: Equatable, Sendable { + public let imageData: Data + public let scale: Double + + public init(imageData: Data, scale: Double = 1) { + self.imageData = imageData + self.scale = scale + } +} + +public struct RecognizedText: Equatable, Sendable { + public let text: String + public let language: TranslationLanguage? + + public init(text: String, language: TranslationLanguage? = nil) { + self.text = text + self.language = language + } +} + +public struct TranslationRequest: Equatable, Sendable { + public let text: String + public let sourceLanguage: TranslationLanguage + public let targetLanguage: TranslationLanguage + public let inputMode: TranslationInputMode + public let providerID: TranslationProviderID + + public init( + text: String, + sourceLanguage: TranslationLanguage, + targetLanguage: TranslationLanguage, + inputMode: TranslationInputMode, + providerID: TranslationProviderID + ) { + self.text = text + self.sourceLanguage = sourceLanguage + self.targetLanguage = targetLanguage + self.inputMode = inputMode + self.providerID = providerID + } +} + +public struct TranslationResult: Identifiable, Equatable, Sendable { + public let id: UUID + public let request: TranslationRequest + public let translatedText: String + public let originalText: String + public let createdAt: Date + + public init( + id: UUID = UUID(), + request: TranslationRequest, + translatedText: String, + originalText: String? = nil, + createdAt: Date = Date() + ) { + self.id = id + self.request = request + self.translatedText = translatedText + self.originalText = originalText ?? request.text + self.createdAt = createdAt + } +} + +public enum TranslationFailure: Error, Equatable, Sendable { + case permissionDenied(PermissionKind) + case captureCancelled + case noTextRecognized + case emptyInput + case unsupportedLanguagePair + case providerUnavailable(TranslationProviderID) + case missingAPIKey(TranslationProviderID) + case providerFailed(String) +} + +public enum TranslationSessionState: Equatable, Sendable { + case idle + case requestingPermission(PermissionKind) + case capturing + case recognizing + case translating(TranslationRequest) + case completed(TranslationResult) + case failed(TranslationFailure) +} From 0941024b462d25346c7f4b662668487c2eb694b1 Mon Sep 17 00:00:00 2001 From: Peerapat_J Date: Thu, 4 Jun 2026 21:59:33 +0700 Subject: [PATCH 03/12] Add M0 service mocks and tests --- .../AppFeatureTests.swift | 6 + .../AppSettingsTests.swift | 22 +++ .../PermissionBaselineTests.swift | 27 ++++ Tests/LinguistMacCoreTests/ServiceMocks.swift | 129 ++++++++++++++++++ .../ServiceMocksTests.swift | 77 +++++++++++ .../TranslationModelsTests.swift | 50 +++++++ 6 files changed, 311 insertions(+) create mode 100644 Tests/LinguistMacCoreTests/AppSettingsTests.swift create mode 100644 Tests/LinguistMacCoreTests/PermissionBaselineTests.swift create mode 100644 Tests/LinguistMacCoreTests/ServiceMocks.swift create mode 100644 Tests/LinguistMacCoreTests/ServiceMocksTests.swift create mode 100644 Tests/LinguistMacCoreTests/TranslationModelsTests.swift diff --git a/Tests/LinguistMacCoreTests/AppFeatureTests.swift b/Tests/LinguistMacCoreTests/AppFeatureTests.swift index 3c1059a..b143a01 100644 --- a/Tests/LinguistMacCoreTests/AppFeatureTests.swift +++ b/Tests/LinguistMacCoreTests/AppFeatureTests.swift @@ -16,4 +16,10 @@ final class AppFeatureTests: XCTestCase { AppFeature.starterFeatures.contains { $0.id == "screen-translation" } ) } + + func testStarterFeaturesIncludeQuickTranslate() { + XCTAssertTrue( + AppFeature.starterFeatures.contains { $0.id == "quick-translate" } + ) + } } diff --git a/Tests/LinguistMacCoreTests/AppSettingsTests.swift b/Tests/LinguistMacCoreTests/AppSettingsTests.swift new file mode 100644 index 0000000..1afdb12 --- /dev/null +++ b/Tests/LinguistMacCoreTests/AppSettingsTests.swift @@ -0,0 +1,22 @@ +@testable import LinguistMacCore +import XCTest + +final class AppSettingsTests: XCTestCase { + func testDefaultSettingsUsePrivateOnDeviceDefaults() { + let settings = AppSettings() + + XCTAssertEqual(settings.sourceLanguage, .autoDetect) + XCTAssertEqual(settings.targetLanguage, .english) + XCTAssertEqual(settings.selectedProviderID, .apple) + XCTAssertFalse(settings.autoCopyEnabled) + XCTAssertFalse(settings.launchAtLoginEnabled) + } + + func testDefaultShortcutsCoverPrimaryInputModes() { + let settings = AppSettings() + + XCTAssertEqual(settings.screenTranslationShortcut, .screenTranslationDefault) + XCTAssertEqual(settings.textSelectionShortcut, .textSelectionDefault) + XCTAssertEqual(settings.quickTranslateShortcut, .quickTranslateDefault) + } +} diff --git a/Tests/LinguistMacCoreTests/PermissionBaselineTests.swift b/Tests/LinguistMacCoreTests/PermissionBaselineTests.swift new file mode 100644 index 0000000..eea8b18 --- /dev/null +++ b/Tests/LinguistMacCoreTests/PermissionBaselineTests.swift @@ -0,0 +1,27 @@ +@testable import LinguistMacCore +import XCTest + +final class PermissionBaselineTests: XCTestCase { + func testDefaultRequirementsIncludeScreenRecordingForDefaultWorkflow() { + let requirement = PermissionBaseline.defaultRequirements.first { + $0.kind == .screenRecording + } + + XCTAssertEqual(requirement?.isRequiredForDefaultWorkflow, true) + } + + func testAccessibilityIsTrackedButNotRequiredForDefaultWorkflow() { + let requirement = PermissionBaseline.defaultRequirements.first { + $0.kind == .accessibility + } + + XCTAssertEqual(requirement?.isRequiredForDefaultWorkflow, false) + } + + func testDefaultRequirementsCoverCloudProviderPrivacyBoundaries() { + let kinds = Set(PermissionBaseline.defaultRequirements.map(\.kind)) + + XCTAssertTrue(kinds.contains(.keychain)) + XCTAssertTrue(kinds.contains(.network)) + } +} diff --git a/Tests/LinguistMacCoreTests/ServiceMocks.swift b/Tests/LinguistMacCoreTests/ServiceMocks.swift new file mode 100644 index 0000000..e5bee2c --- /dev/null +++ b/Tests/LinguistMacCoreTests/ServiceMocks.swift @@ -0,0 +1,129 @@ +@testable import LinguistMacCore +import Foundation + +struct StubScreenCaptureService: ScreenCaptureServicing { + var result: Result + + func captureSelection() async throws -> CapturedScreenRegion { + try result.get() + } +} + +struct StubOCRService: OCRServicing { + var result: Result + + func recognizeText(in region: CapturedScreenRegion) async throws -> RecognizedText { + _ = region + return try result.get() + } +} + +struct StubTranslationProvider: TranslationProviding { + var id: TranslationProviderID + var displayName: String + var requiresAPIKey: Bool + var usesNetwork: Bool + var translatedText: String + + func translate(_ request: TranslationRequest) async throws -> TranslationResult { + TranslationResult( + request: request, + translatedText: translatedText + ) + } +} + +struct StubTranslationProviderRegistry: TranslationProviderRegistry { + var provider: StubTranslationProvider + + func provider(for id: TranslationProviderID) async throws -> any TranslationProviding { + guard id == provider.id else { + throw TranslationFailure.providerUnavailable(id) + } + + return provider + } + + func availableProviders() async -> [TranslationProviderDescriptor] { + [ + TranslationProviderDescriptor( + id: provider.id, + displayName: provider.displayName, + requiresAPIKey: provider.requiresAPIKey, + usesNetwork: provider.usesNetwork + ) + ] + } +} + +actor InMemoryAppSettingsStore: AppSettingsStoring { + private var settings: AppSettings + + init(settings: AppSettings = AppSettings()) { + self.settings = settings + } + + func loadSettings() async throws -> AppSettings { + settings + } + + func saveSettings(_ settings: AppSettings) async throws { + self.settings = settings + } +} + +actor InMemoryTranslationHistoryStore: TranslationHistoryStoring { + private var results: [TranslationResult] + + init(results: [TranslationResult] = []) { + self.results = results + } + + func save(_ result: TranslationResult) async throws { + results.insert(result, at: 0) + } + + func recent(limit: Int) async throws -> [TranslationResult] { + Array(results.prefix(limit)) + } +} + +struct StubPermissionChecker: PermissionChecking { + var statuses: [PermissionKind: PermissionStatus] + + func status(for kind: PermissionKind) async -> PermissionStatus { + statuses[kind] ?? .notDetermined + } +} + +actor InMemoryClipboard: ClipboardServicing { + private var text: String? + + init(text: String? = nil) { + self.text = text + } + + func readText() async -> String? { + text + } + + func writeText(_ text: String) async { + self.text = text + } +} + +actor RecordingShortcutRegistry: ShortcutRegistering { + private var registrations: [ShortcutAction: KeyboardShortcut] = [:] + + func register(_ shortcut: KeyboardShortcut, for action: ShortcutAction) async throws { + registrations[action] = shortcut + } + + func unregister(_ action: ShortcutAction) async { + registrations.removeValue(forKey: action) + } + + func registeredShortcut(for action: ShortcutAction) async -> KeyboardShortcut? { + registrations[action] + } +} diff --git a/Tests/LinguistMacCoreTests/ServiceMocksTests.swift b/Tests/LinguistMacCoreTests/ServiceMocksTests.swift new file mode 100644 index 0000000..4504a78 --- /dev/null +++ b/Tests/LinguistMacCoreTests/ServiceMocksTests.swift @@ -0,0 +1,77 @@ +@testable import LinguistMacCore +import Foundation +import XCTest + +final class ServiceMocksTests: XCTestCase { + func testServicesCanDriveMockTranslationFlow() async throws { + let request = TranslationRequest( + text: "hello", + sourceLanguage: .english, + targetLanguage: .thai, + inputMode: .screenSelection, + providerID: .apple + ) + let region = CapturedScreenRegion(imageData: Data([1, 2, 3])) + let provider = StubTranslationProvider( + id: .apple, + displayName: "Apple Translation", + requiresAPIKey: false, + usesNetwork: false, + translatedText: "sawasdee" + ) + let services = LinguistServices( + screenCapture: StubScreenCaptureService(result: .success(region)), + ocr: StubOCRService(result: .success(RecognizedText(text: request.text, language: .english))), + translatorRegistry: StubTranslationProviderRegistry(provider: provider), + settingsStore: InMemoryAppSettingsStore(), + historyStore: InMemoryTranslationHistoryStore(), + permissionChecker: StubPermissionChecker(statuses: [.screenRecording: .granted]), + clipboard: InMemoryClipboard(), + shortcutRegistry: RecordingShortcutRegistry() + ) + + let capturedRegion = try await services.screenCapture.captureSelection() + let recognizedText = try await services.ocr.recognizeText(in: capturedRegion) + let translator = try await services.translatorRegistry.provider(for: request.providerID) + let result = try await translator.translate(request) + + XCTAssertEqual(capturedRegion, region) + XCTAssertEqual(recognizedText.text, "hello") + XCTAssertEqual(result.translatedText, "sawasdee") + XCTAssertEqual(result.originalText, "hello") + } + + func testInMemoryStoresSupportSettingsHistoryClipboardAndShortcuts() async throws { + let settingsStore = InMemoryAppSettingsStore() + var settings = try await settingsStore.loadSettings() + settings.autoCopyEnabled = true + try await settingsStore.saveSettings(settings) + + let request = TranslationRequest( + text: "hello", + sourceLanguage: .english, + targetLanguage: .thai, + inputMode: .quickTranslate, + providerID: .apple + ) + let result = TranslationResult(request: request, translatedText: "sawasdee") + let historyStore = InMemoryTranslationHistoryStore() + try await historyStore.save(result) + + let clipboard = InMemoryClipboard() + await clipboard.writeText(result.translatedText) + + let shortcuts = RecordingShortcutRegistry() + try await shortcuts.register(.quickTranslateDefault, for: .quickTranslate) + + let savedSettings = try await settingsStore.loadSettings() + let recentHistory = try await historyStore.recent(limit: 1) + let clipboardText = await clipboard.readText() + let registeredShortcut = await shortcuts.registeredShortcut(for: .quickTranslate) + + XCTAssertEqual(savedSettings.autoCopyEnabled, true) + XCTAssertEqual(recentHistory, [result]) + XCTAssertEqual(clipboardText, "sawasdee") + XCTAssertEqual(registeredShortcut, .quickTranslateDefault) + } +} diff --git a/Tests/LinguistMacCoreTests/TranslationModelsTests.swift b/Tests/LinguistMacCoreTests/TranslationModelsTests.swift new file mode 100644 index 0000000..f484501 --- /dev/null +++ b/Tests/LinguistMacCoreTests/TranslationModelsTests.swift @@ -0,0 +1,50 @@ +@testable import LinguistMacCore +import XCTest + +final class TranslationModelsTests: XCTestCase { + func testTranslationResultUsesRequestTextAsOriginalByDefault() { + let request = TranslationRequest( + text: "hello", + sourceLanguage: .english, + targetLanguage: .thai, + inputMode: .quickTranslate, + providerID: .apple + ) + + let result = TranslationResult(request: request, translatedText: "sawasdee") + + XCTAssertEqual(result.originalText, "hello") + XCTAssertEqual(result.request, request) + } + + func testProviderDescriptorDistinguishesOnDeviceAndCloudProviders() { + let apple = TranslationProviderDescriptor( + id: .apple, + displayName: "Apple Translation", + requiresAPIKey: false, + usesNetwork: false + ) + let deepl = TranslationProviderDescriptor( + id: .deepl, + displayName: "DeepL", + requiresAPIKey: true, + usesNetwork: true + ) + + XCTAssertFalse(apple.requiresAPIKey) + XCTAssertFalse(apple.usesNetwork) + XCTAssertTrue(deepl.requiresAPIKey) + XCTAssertTrue(deepl.usesNetwork) + } + + func testSessionStateCanRepresentPermissionAndProviderFailures() { + let permissionState = TranslationSessionState.failed( + .permissionDenied(.screenRecording) + ) + let providerState = TranslationSessionState.failed( + .missingAPIKey(.deepl) + ) + + XCTAssertNotEqual(permissionState, providerState) + } +} From 725e28268a03b20e8efc4e5ff07d0b134669b913 Mon Sep 17 00:00:00 2001 From: Peerapat_J Date: Thu, 4 Jun 2026 22:05:00 +0700 Subject: [PATCH 04/12] Add M0 app identity and permissions baseline --- Configuration/LinguistMac/Info.plist | 30 ++++++++++ .../LinguistMac/LinguistMac.entitlements | 8 +++ Sources/LinguistMacCore/AppIdentity.swift | 31 +++++++++++ .../AppIdentityTests.swift | 14 +++++ docs/app-identity-permissions.md | 55 +++++++++++++++++++ script/build_and_run.sh | 24 +------- 6 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 Configuration/LinguistMac/Info.plist create mode 100644 Configuration/LinguistMac/LinguistMac.entitlements create mode 100644 Sources/LinguistMacCore/AppIdentity.swift create mode 100644 Tests/LinguistMacCoreTests/AppIdentityTests.swift create mode 100644 docs/app-identity-permissions.md diff --git a/Configuration/LinguistMac/Info.plist b/Configuration/LinguistMac/Info.plist new file mode 100644 index 0000000..0e4505f --- /dev/null +++ b/Configuration/LinguistMac/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + LinguistMac + CFBundleExecutable + LinguistMac + CFBundleIdentifier + com.peerapatj.LinguistMac + CFBundleName + LinguistMac + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1 + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + 15.0 + NSHumanReadableCopyright + Copyright 2026 Peerapat Jardrit + NSPrincipalClass + NSApplication + + diff --git a/Configuration/LinguistMac/LinguistMac.entitlements b/Configuration/LinguistMac/LinguistMac.entitlements new file mode 100644 index 0000000..1b44cd3 --- /dev/null +++ b/Configuration/LinguistMac/LinguistMac.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/Sources/LinguistMacCore/AppIdentity.swift b/Sources/LinguistMacCore/AppIdentity.swift new file mode 100644 index 0000000..e4f7b2a --- /dev/null +++ b/Sources/LinguistMacCore/AppIdentity.swift @@ -0,0 +1,31 @@ +public struct AppIdentity: Equatable, Sendable { + public let displayName: String + public let bundleIdentifier: String + public let minimumMacOSVersion: String + public let shortVersion: String + public let buildVersion: String + + public init( + displayName: String, + bundleIdentifier: String, + minimumMacOSVersion: String, + shortVersion: String, + buildVersion: String + ) { + self.displayName = displayName + self.bundleIdentifier = bundleIdentifier + self.minimumMacOSVersion = minimumMacOSVersion + self.shortVersion = shortVersion + self.buildVersion = buildVersion + } +} + +public extension AppIdentity { + static let linguistMac = AppIdentity( + displayName: "LinguistMac", + bundleIdentifier: "com.peerapatj.LinguistMac", + minimumMacOSVersion: "15.0", + shortVersion: "0.1", + buildVersion: "1" + ) +} diff --git a/Tests/LinguistMacCoreTests/AppIdentityTests.swift b/Tests/LinguistMacCoreTests/AppIdentityTests.swift new file mode 100644 index 0000000..3795804 --- /dev/null +++ b/Tests/LinguistMacCoreTests/AppIdentityTests.swift @@ -0,0 +1,14 @@ +@testable import LinguistMacCore +import XCTest + +final class AppIdentityTests: XCTestCase { + func testLinguistMacIdentityMatchesM0BundlePlan() { + let identity = AppIdentity.linguistMac + + XCTAssertEqual(identity.displayName, "LinguistMac") + XCTAssertEqual(identity.bundleIdentifier, "com.peerapatj.LinguistMac") + XCTAssertEqual(identity.minimumMacOSVersion, "15.0") + XCTAssertEqual(identity.shortVersion, "0.1") + XCTAssertEqual(identity.buildVersion, "1") + } +} diff --git a/docs/app-identity-permissions.md b/docs/app-identity-permissions.md new file mode 100644 index 0000000..e1adc9a --- /dev/null +++ b/docs/app-identity-permissions.md @@ -0,0 +1,55 @@ +# App Identity And Permission Baseline + +This document records the M0 identity and permission posture for LinguistMac. +It should be updated whenever app capabilities or distribution choices change. + +## App Identity + +- Display name: `LinguistMac` +- Bundle identifier: `com.peerapatj.LinguistMac` +- Minimum macOS: `15.0` +- Initial short version: `0.1` +- Initial build version: `1` +- Category: `public.app-category.utilities` + +The SwiftPM packaging helper copies `Configuration/LinguistMac/Info.plist` +into the generated `.app` bundle. The same values are mirrored in +`AppIdentity.linguistMac` so tests can catch accidental drift. + +## Entitlement Baseline + +`Configuration/LinguistMac/LinguistMac.entitlements` currently enables only the +App Sandbox: + +- `com.apple.security.app-sandbox` + +Do not add network, file, automation, or broader security exceptions until the +feature issue that requires them is implemented and reviewed. + +## Permission Matrix + +| Permission or capability | Needed for | M0 posture | +| --- | --- | --- | +| Screen Recording | Selected-region screenshot capture for OCR | Required for default screen translation; model and docs only in M0 | +| Accessibility | Selected text, double-copy, and drag translation workflows | Optional until M3 input modes | +| Keychain | Optional cloud provider API keys | Optional until M4 provider/key issues | +| Network client | Optional cloud translation providers | Not enabled in baseline entitlement; add only with M4 provider work | +| Launch at login | User preference for startup behavior | Optional until M4 app-preferences issue | + +## Privacy Defaults + +- Apple Translation remains the planned default provider. +- Auto-copy is off by default. +- Cloud translation providers are opt-in and require user-supplied keys. +- No telemetry or auto-update integration is part of M0. +- Permission failures should become user-visible states rather than silent + failures when feature workflows land. + +## Review Checklist + +- Does a feature require a new entitlement? +- Does it read selected text, clipboard text, captured screen content, or API + keys? +- Does it send text over the network? +- Does the app still work when the permission is denied? +- Is the permission documented in this file and tested in core state models? diff --git a/script/build_and_run.sh b/script/build_and_run.sh index 4740126..0783dce 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -4,7 +4,6 @@ set -euo pipefail MODE="${1:-run}" APP_NAME="LinguistMac" BUNDLE_ID="com.peerapatj.LinguistMac" -MIN_SYSTEM_VERSION="15.0" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DIST_DIR="$ROOT_DIR/dist" @@ -13,6 +12,7 @@ APP_CONTENTS="$APP_BUNDLE/Contents" APP_MACOS="$APP_CONTENTS/MacOS" APP_BINARY="$APP_MACOS/$APP_NAME" INFO_PLIST="$APP_CONTENTS/Info.plist" +INFO_PLIST_TEMPLATE="$ROOT_DIR/Configuration/LinguistMac/Info.plist" cd "$ROOT_DIR" @@ -46,27 +46,7 @@ build_app_bundle() { mkdir -p "$APP_MACOS" cp "$build_binary" "$APP_BINARY" chmod +x "$APP_BINARY" - - cat >"$INFO_PLIST" < - - - - CFBundleExecutable - $APP_NAME - CFBundleIdentifier - $BUNDLE_ID - CFBundleName - $APP_NAME - CFBundlePackageType - APPL - LSMinimumSystemVersion - $MIN_SYSTEM_VERSION - NSPrincipalClass - NSApplication - - -PLIST + cp "$INFO_PLIST_TEMPLATE" "$INFO_PLIST" } open_app() { From 2a10df80c74c5bce16e171cb8658d032c855ee0f Mon Sep 17 00:00:00 2001 From: Peerapat_J Date: Thu, 4 Jun 2026 22:07:33 +0700 Subject: [PATCH 05/12] Format M0 service mock imports --- Tests/LinguistMacCoreTests/ServiceMocks.swift | 2 +- Tests/LinguistMacCoreTests/ServiceMocksTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/LinguistMacCoreTests/ServiceMocks.swift b/Tests/LinguistMacCoreTests/ServiceMocks.swift index e5bee2c..80b44c3 100644 --- a/Tests/LinguistMacCoreTests/ServiceMocks.swift +++ b/Tests/LinguistMacCoreTests/ServiceMocks.swift @@ -1,5 +1,5 @@ -@testable import LinguistMacCore import Foundation +@testable import LinguistMacCore struct StubScreenCaptureService: ScreenCaptureServicing { var result: Result diff --git a/Tests/LinguistMacCoreTests/ServiceMocksTests.swift b/Tests/LinguistMacCoreTests/ServiceMocksTests.swift index 4504a78..74b8ce2 100644 --- a/Tests/LinguistMacCoreTests/ServiceMocksTests.swift +++ b/Tests/LinguistMacCoreTests/ServiceMocksTests.swift @@ -1,5 +1,5 @@ -@testable import LinguistMacCore import Foundation +@testable import LinguistMacCore import XCTest final class ServiceMocksTests: XCTestCase { From de558a72a64817f0c8c538e63ca3a655e210db44 Mon Sep 17 00:00:00 2001 From: Peerapat_J Date: Thu, 4 Jun 2026 22:17:07 +0700 Subject: [PATCH 06/12] Fix M0 SwiftLint analyzer import finding --- Sources/LinguistMacCore/AppSettings.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/LinguistMacCore/AppSettings.swift b/Sources/LinguistMacCore/AppSettings.swift index 712a6f1..7cfd1cb 100644 --- a/Sources/LinguistMacCore/AppSettings.swift +++ b/Sources/LinguistMacCore/AppSettings.swift @@ -1,5 +1,3 @@ -import Foundation - public struct AppSettings: Equatable, Sendable { public var sourceLanguage: TranslationLanguage public var targetLanguage: TranslationLanguage From 90ba558668bfa61acbc576e05bb0ae3fc639e751 Mon Sep 17 00:00:00 2001 From: Peerapat_J Date: Thu, 4 Jun 2026 22:33:01 +0700 Subject: [PATCH 07/12] Convert M0 baseline to Xcode project --- .github/workflows/ci.yml | 34 +- LinguistMac.xcodeproj/project.pbxproj | 509 ++++++++++++++++++ .../xcschemes/LinguistMac.xcscheme | 105 ++++ Package.swift | 36 -- docs/app-identity-permissions.md | 6 +- docs/ci-cd.md | 24 +- docs/contribution-notes.md | 4 +- script/build_and_run.sh | 25 +- 8 files changed, 661 insertions(+), 82 deletions(-) create mode 100644 LinguistMac.xcodeproj/project.pbxproj create mode 100644 LinguistMac.xcodeproj/xcshareddata/xcschemes/LinguistMac.xcscheme delete mode 100644 Package.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae1b1da..e85721a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,7 @@ jobs: - name: Capture compiler log for SwiftLint analyzer run: | xcodebuild \ + -project LinguistMac.xcodeproj \ -scheme LinguistMac \ -configuration Debug \ -destination 'platform=macOS' \ @@ -67,9 +68,7 @@ jobs: matrix: include: - configuration: Debug - swift-configuration: debug - configuration: Release - swift-configuration: release steps: - name: Check out repository @@ -79,18 +78,12 @@ jobs: - name: Print tool versions run: | - swift --version xcodebuild -version - - name: Build Swift package - run: swift build -c ${{ matrix.swift-configuration }} --product LinguistMac - - - name: Run Swift package tests - run: swift test -c ${{ matrix.swift-configuration }} - - - name: Build app scheme with xcodebuild + - name: Build app with xcodebuild run: | xcodebuild \ + -project LinguistMac.xcodeproj \ -scheme LinguistMac \ -configuration ${{ matrix.configuration }} \ -destination 'platform=macOS' \ @@ -98,10 +91,11 @@ jobs: CODE_SIGNING_ALLOWED=NO \ build - - name: Test package scheme with xcodebuild + - name: Run unit tests with xcodebuild run: | xcodebuild \ - -scheme LinguistMac-Package \ + -project LinguistMac.xcodeproj \ + -scheme LinguistMac \ -configuration ${{ matrix.configuration }} \ -destination 'platform=macOS' \ -derivedDataPath "$RUNNER_TEMP/LinguistMacDerivedDataTests-${{ matrix.configuration }}" \ @@ -122,15 +116,21 @@ jobs: - name: Build with strict Swift compiler flags run: | - swift build \ - -c debug \ - --product LinguistMac \ - -Xswiftc -warnings-as-errors \ - -Xswiftc -strict-concurrency=complete + xcodebuild \ + -project LinguistMac.xcodeproj \ + -scheme LinguistMac \ + -configuration Debug \ + -destination 'platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/LinguistMacStrictDerivedData" \ + CODE_SIGNING_ALLOWED=NO \ + SWIFT_TREAT_WARNINGS_AS_ERRORS=YES \ + GCC_TREAT_WARNINGS_AS_ERRORS=YES \ + build - name: Run Xcode static analyzer run: | xcodebuild \ + -project LinguistMac.xcodeproj \ -scheme LinguistMac \ -configuration Debug \ -destination 'platform=macOS' \ diff --git a/LinguistMac.xcodeproj/project.pbxproj b/LinguistMac.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c8fa8ca --- /dev/null +++ b/LinguistMac.xcodeproj/project.pbxproj @@ -0,0 +1,509 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + B10000000000000000000001 /* LinguistMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* LinguistMacApp.swift */; }; + B10000000000000000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002 /* ContentView.swift */; }; + B10000000000000000000003 /* libLinguistMacCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = B20000000000000000000030 /* libLinguistMacCore.a */; }; + B10000000000000000000004 /* libLinguistMacCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = B20000000000000000000030 /* libLinguistMacCore.a */; }; + B10000000000000000000010 /* AppFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000010 /* AppFeature.swift */; }; + B10000000000000000000011 /* AppIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000011 /* AppIdentity.swift */; }; + B10000000000000000000012 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000012 /* AppSettings.swift */; }; + B10000000000000000000013 /* LinguistServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000013 /* LinguistServices.swift */; }; + B10000000000000000000014 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000014 /* Permissions.swift */; }; + B10000000000000000000015 /* ServiceProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000015 /* ServiceProtocols.swift */; }; + B10000000000000000000016 /* TranslationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000016 /* TranslationModels.swift */; }; + B10000000000000000000040 /* AppFeatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000040 /* AppFeatureTests.swift */; }; + B10000000000000000000041 /* AppIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000041 /* AppIdentityTests.swift */; }; + B10000000000000000000042 /* AppSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000042 /* AppSettingsTests.swift */; }; + B10000000000000000000043 /* PermissionBaselineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000043 /* PermissionBaselineTests.swift */; }; + B10000000000000000000044 /* ServiceMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000044 /* ServiceMocks.swift */; }; + B10000000000000000000045 /* ServiceMocksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000045 /* ServiceMocksTests.swift */; }; + B10000000000000000000046 /* TranslationModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000046 /* TranslationModelsTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + B30000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B90000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B40000000000000000000200; + remoteInfo = LinguistMacCore; + }; + B30000000000000000000002 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B90000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B40000000000000000000200; + remoteInfo = LinguistMacCore; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + B20000000000000000000001 /* LinguistMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinguistMacApp.swift; sourceTree = ""; }; + B20000000000000000000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + B20000000000000000000010 /* AppFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFeature.swift; sourceTree = ""; }; + B20000000000000000000011 /* AppIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIdentity.swift; sourceTree = ""; }; + B20000000000000000000012 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + B20000000000000000000013 /* LinguistServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinguistServices.swift; sourceTree = ""; }; + B20000000000000000000014 /* Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permissions.swift; sourceTree = ""; }; + B20000000000000000000015 /* ServiceProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProtocols.swift; sourceTree = ""; }; + B20000000000000000000016 /* TranslationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationModels.swift; sourceTree = ""; }; + B20000000000000000000020 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B20000000000000000000021 /* LinguistMac.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LinguistMac.entitlements; sourceTree = ""; }; + B20000000000000000000030 /* libLinguistMacCore.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libLinguistMacCore.a; sourceTree = BUILT_PRODUCTS_DIR; }; + B20000000000000000000031 /* LinguistMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LinguistMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B20000000000000000000032 /* LinguistMacCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LinguistMacCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B20000000000000000000040 /* AppFeatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFeatureTests.swift; sourceTree = ""; }; + B20000000000000000000041 /* AppIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIdentityTests.swift; sourceTree = ""; }; + B20000000000000000000042 /* AppSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsTests.swift; sourceTree = ""; }; + B20000000000000000000043 /* PermissionBaselineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionBaselineTests.swift; sourceTree = ""; }; + B20000000000000000000044 /* ServiceMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceMocks.swift; sourceTree = ""; }; + B20000000000000000000045 /* ServiceMocksTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceMocksTests.swift; sourceTree = ""; }; + B20000000000000000000046 /* TranslationModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationModelsTests.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + B50000000000000000000102 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000003 /* libLinguistMacCore.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B50000000000000000000202 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B50000000000000000000302 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000004 /* libLinguistMacCore.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + B60000000000000000000001 = { + isa = PBXGroup; + children = ( + B60000000000000000000002 /* Sources */, + B60000000000000000000005 /* Tests */, + B60000000000000000000007 /* Configuration */, + B60000000000000000000008 /* Products */, + ); + sourceTree = ""; + }; + B60000000000000000000002 /* Sources */ = { + isa = PBXGroup; + children = ( + B60000000000000000000003 /* LinguistMac */, + B60000000000000000000004 /* LinguistMacCore */, + ); + path = Sources; + sourceTree = ""; + }; + B60000000000000000000003 /* LinguistMac */ = { + isa = PBXGroup; + children = ( + B20000000000000000000001 /* LinguistMacApp.swift */, + B20000000000000000000002 /* ContentView.swift */, + ); + path = LinguistMac; + sourceTree = ""; + }; + B60000000000000000000004 /* LinguistMacCore */ = { + isa = PBXGroup; + children = ( + B20000000000000000000010 /* AppFeature.swift */, + B20000000000000000000011 /* AppIdentity.swift */, + B20000000000000000000012 /* AppSettings.swift */, + B20000000000000000000013 /* LinguistServices.swift */, + B20000000000000000000014 /* Permissions.swift */, + B20000000000000000000015 /* ServiceProtocols.swift */, + B20000000000000000000016 /* TranslationModels.swift */, + ); + path = LinguistMacCore; + sourceTree = ""; + }; + B60000000000000000000005 /* Tests */ = { + isa = PBXGroup; + children = ( + B60000000000000000000006 /* LinguistMacCoreTests */, + ); + path = Tests; + sourceTree = ""; + }; + B60000000000000000000006 /* LinguistMacCoreTests */ = { + isa = PBXGroup; + children = ( + B20000000000000000000040 /* AppFeatureTests.swift */, + B20000000000000000000041 /* AppIdentityTests.swift */, + B20000000000000000000042 /* AppSettingsTests.swift */, + B20000000000000000000043 /* PermissionBaselineTests.swift */, + B20000000000000000000044 /* ServiceMocks.swift */, + B20000000000000000000045 /* ServiceMocksTests.swift */, + B20000000000000000000046 /* TranslationModelsTests.swift */, + ); + path = LinguistMacCoreTests; + sourceTree = ""; + }; + B60000000000000000000007 /* Configuration */ = { + isa = PBXGroup; + children = ( + B20000000000000000000020 /* Info.plist */, + B20000000000000000000021 /* LinguistMac.entitlements */, + ); + path = Configuration/LinguistMac; + sourceTree = ""; + }; + B60000000000000000000008 /* Products */ = { + isa = PBXGroup; + children = ( + B20000000000000000000031 /* LinguistMac.app */, + B20000000000000000000030 /* libLinguistMacCore.a */, + B20000000000000000000032 /* LinguistMacCoreTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + B40000000000000000000100 /* LinguistMac */ = { + isa = PBXNativeTarget; + buildConfigurationList = B70000000000000000000100 /* Build configuration list for PBXNativeTarget "LinguistMac" */; + buildPhases = ( + B50000000000000000000101 /* Sources */, + B50000000000000000000102 /* Frameworks */, + B50000000000000000000103 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + B80000000000000000000001 /* PBXTargetDependency */, + ); + name = LinguistMac; + productName = LinguistMac; + productReference = B20000000000000000000031 /* LinguistMac.app */; + productType = "com.apple.product-type.application"; + }; + B40000000000000000000200 /* LinguistMacCore */ = { + isa = PBXNativeTarget; + buildConfigurationList = B70000000000000000000200 /* Build configuration list for PBXNativeTarget "LinguistMacCore" */; + buildPhases = ( + B50000000000000000000201 /* Sources */, + B50000000000000000000202 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = LinguistMacCore; + productName = LinguistMacCore; + productReference = B20000000000000000000030 /* libLinguistMacCore.a */; + productType = "com.apple.product-type.library.static"; + }; + B40000000000000000000300 /* LinguistMacCoreTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B70000000000000000000300 /* Build configuration list for PBXNativeTarget "LinguistMacCoreTests" */; + buildPhases = ( + B50000000000000000000301 /* Sources */, + B50000000000000000000302 /* Frameworks */, + B50000000000000000000303 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + B80000000000000000000002 /* PBXTargetDependency */, + ); + name = LinguistMacCoreTests; + productName = LinguistMacCoreTests; + productReference = B20000000000000000000032 /* LinguistMacCoreTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B90000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; + TargetAttributes = { + B40000000000000000000100 = { + CreatedOnToolsVersion = 26.5; + }; + B40000000000000000000200 = { + CreatedOnToolsVersion = 26.5; + }; + B40000000000000000000300 = { + CreatedOnToolsVersion = 26.5; + TestTargetID = B40000000000000000000200; + }; + }; + }; + buildConfigurationList = B70000000000000000000001 /* Build configuration list for PBXProject "LinguistMac" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = B60000000000000000000001; + productRefGroup = B60000000000000000000008 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B40000000000000000000100 /* LinguistMac */, + B40000000000000000000200 /* LinguistMacCore */, + B40000000000000000000300 /* LinguistMacCoreTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B50000000000000000000103 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B50000000000000000000303 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + B50000000000000000000101 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000001 /* LinguistMacApp.swift in Sources */, + B10000000000000000000002 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B50000000000000000000201 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000010 /* AppFeature.swift in Sources */, + B10000000000000000000011 /* AppIdentity.swift in Sources */, + B10000000000000000000012 /* AppSettings.swift in Sources */, + B10000000000000000000013 /* LinguistServices.swift in Sources */, + B10000000000000000000014 /* Permissions.swift in Sources */, + B10000000000000000000015 /* ServiceProtocols.swift in Sources */, + B10000000000000000000016 /* TranslationModels.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B50000000000000000000301 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000040 /* AppFeatureTests.swift in Sources */, + B10000000000000000000041 /* AppIdentityTests.swift in Sources */, + B10000000000000000000042 /* AppSettingsTests.swift in Sources */, + B10000000000000000000043 /* PermissionBaselineTests.swift in Sources */, + B10000000000000000000044 /* ServiceMocks.swift in Sources */, + B10000000000000000000045 /* ServiceMocksTests.swift in Sources */, + B10000000000000000000046 /* TranslationModelsTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + B80000000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B40000000000000000000200 /* LinguistMacCore */; + targetProxy = B30000000000000000000001 /* PBXContainerItemProxy */; + }; + B80000000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B40000000000000000000200 /* LinguistMacCore */; + targetProxy = B30000000000000000000002 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + B70000000000000000000002 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + MACOSX_DEPLOYMENT_TARGET = 15.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SUPPORTED_PLATFORMS = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + B70000000000000000000003 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + SDKROOT = macosx; + SUPPORTED_PLATFORMS = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + B70000000000000000000101 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Configuration/LinguistMac/LinguistMac.entitlements; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Configuration/LinguistMac/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.peerapatj.LinguistMac; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + B70000000000000000000102 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Configuration/LinguistMac/LinguistMac.entitlements; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Configuration/LinguistMac/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.peerapatj.LinguistMac; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; + B70000000000000000000201 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + DEFINES_MODULE = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.peerapatj.LinguistMacCore; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + }; + name = Debug; + }; + B70000000000000000000202 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + DEFINES_MODULE = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.peerapatj.LinguistMacCore; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + }; + name = Release; + }; + B70000000000000000000301 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @loader_path/../Frameworks @executable_path/../Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.peerapatj.LinguistMacCoreTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = LinguistMacCore; + }; + name = Debug; + }; + B70000000000000000000302 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @loader_path/../Frameworks @executable_path/../Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.peerapatj.LinguistMacCoreTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = LinguistMacCore; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B70000000000000000000001 /* Build configuration list for PBXProject "LinguistMac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B70000000000000000000002 /* Debug */, + B70000000000000000000003 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B70000000000000000000100 /* Build configuration list for PBXNativeTarget "LinguistMac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B70000000000000000000101 /* Debug */, + B70000000000000000000102 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B70000000000000000000200 /* Build configuration list for PBXNativeTarget "LinguistMacCore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B70000000000000000000201 /* Debug */, + B70000000000000000000202 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B70000000000000000000300 /* Build configuration list for PBXNativeTarget "LinguistMacCoreTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B70000000000000000000301 /* Debug */, + B70000000000000000000302 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = B90000000000000000000001 /* Project object */; +} diff --git a/LinguistMac.xcodeproj/xcshareddata/xcschemes/LinguistMac.xcscheme b/LinguistMac.xcodeproj/xcshareddata/xcschemes/LinguistMac.xcscheme new file mode 100644 index 0000000..f00ebe4 --- /dev/null +++ b/LinguistMac.xcodeproj/xcshareddata/xcschemes/LinguistMac.xcscheme @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.swift b/Package.swift deleted file mode 100644 index 65ed0fd..0000000 --- a/Package.swift +++ /dev/null @@ -1,36 +0,0 @@ -// swift-tools-version: 6.0 - -import PackageDescription - -let package = Package( - name: "LinguistMac", - platforms: [ - .macOS(.v15) - ], - products: [ - .executable( - name: "LinguistMac", - targets: ["LinguistMac"] - ), - .library( - name: "LinguistMacCore", - targets: ["LinguistMacCore"] - ) - ], - targets: [ - .executableTarget( - name: "LinguistMac", - dependencies: ["LinguistMacCore"], - path: "Sources/LinguistMac" - ), - .target( - name: "LinguistMacCore", - path: "Sources/LinguistMacCore" - ), - .testTarget( - name: "LinguistMacCoreTests", - dependencies: ["LinguistMacCore"], - path: "Tests/LinguistMacCoreTests" - ) - ] -) diff --git a/docs/app-identity-permissions.md b/docs/app-identity-permissions.md index e1adc9a..1f4e16b 100644 --- a/docs/app-identity-permissions.md +++ b/docs/app-identity-permissions.md @@ -12,9 +12,9 @@ It should be updated whenever app capabilities or distribution choices change. - Initial build version: `1` - Category: `public.app-category.utilities` -The SwiftPM packaging helper copies `Configuration/LinguistMac/Info.plist` -into the generated `.app` bundle. The same values are mirrored in -`AppIdentity.linguistMac` so tests can catch accidental drift. +`LinguistMac.xcodeproj` uses `Configuration/LinguistMac/Info.plist` as the app +target Info.plist. The same values are mirrored in `AppIdentity.linguistMac` so +tests can catch accidental drift. ## Entitlement Baseline diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 0ca63e3..aca809d 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -9,10 +9,8 @@ The `CI` workflow runs on pull requests, pushes to `main`, and manual dispatches - SwiftLint in strict mode - SwiftLint analyzer rules with compiler-log input - SwiftFormat in lint mode -- `swift build` for Debug and Release -- `swift test` for Debug and Release -- `xcodebuild` build for the `LinguistMac` scheme in Debug and Release -- `xcodebuild` test for the `LinguistMac-Package` scheme in Debug and Release +- `xcodebuild` build for `LinguistMac.xcodeproj` in Debug and Release +- `xcodebuild` unit tests for the shared `LinguistMac` scheme in Debug and Release - strict Swift compiler checks with warnings treated as errors - Xcode static analyzer checks with warnings treated as errors @@ -28,18 +26,14 @@ Run the same checks locally before pushing: ```sh swiftlint lint --strict --no-cache -xcodebuild -scheme LinguistMac -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-swiftlint CODE_SIGNING_ALLOWED=NO clean build > /tmp/linguistmac-swiftlint-analyze.log 2>&1 +xcodebuild -project LinguistMac.xcodeproj -scheme LinguistMac -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-swiftlint CODE_SIGNING_ALLOWED=NO clean build > /tmp/linguistmac-swiftlint-analyze.log 2>&1 swiftlint analyze --strict --compiler-log-path /tmp/linguistmac-swiftlint-analyze.log swiftformat --lint . --config .swiftformat --cache ignore -swift build -c debug --product LinguistMac -swift build -c release --product LinguistMac -swift test -c debug -swift test -c release -xcodebuild -scheme LinguistMac -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-debug CODE_SIGNING_ALLOWED=NO build -xcodebuild -scheme LinguistMac -configuration Release -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-release CODE_SIGNING_ALLOWED=NO build -xcodebuild -scheme LinguistMac-Package -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-debug-test CODE_SIGNING_ALLOWED=NO ENABLE_TESTABILITY=YES test -xcodebuild -scheme LinguistMac-Package -configuration Release -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-release-test CODE_SIGNING_ALLOWED=NO ENABLE_TESTABILITY=YES test -swift build -c debug --product LinguistMac -Xswiftc -warnings-as-errors -Xswiftc -strict-concurrency=complete -xcodebuild -scheme LinguistMac -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-analyze CODE_SIGNING_ALLOWED=NO SWIFT_TREAT_WARNINGS_AS_ERRORS=YES GCC_TREAT_WARNINGS_AS_ERRORS=YES CLANG_ANALYZER_NONNULL=YES analyze +xcodebuild -project LinguistMac.xcodeproj -scheme LinguistMac -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-debug CODE_SIGNING_ALLOWED=NO build +xcodebuild -project LinguistMac.xcodeproj -scheme LinguistMac -configuration Release -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-release CODE_SIGNING_ALLOWED=NO build +xcodebuild -project LinguistMac.xcodeproj -scheme LinguistMac -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-debug-test CODE_SIGNING_ALLOWED=NO ENABLE_TESTABILITY=YES test +xcodebuild -project LinguistMac.xcodeproj -scheme LinguistMac -configuration Release -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-release-test CODE_SIGNING_ALLOWED=NO ENABLE_TESTABILITY=YES test +xcodebuild -project LinguistMac.xcodeproj -scheme LinguistMac -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-strict CODE_SIGNING_ALLOWED=NO SWIFT_TREAT_WARNINGS_AS_ERRORS=YES GCC_TREAT_WARNINGS_AS_ERRORS=YES build +xcodebuild -project LinguistMac.xcodeproj -scheme LinguistMac -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-analyze CODE_SIGNING_ALLOWED=NO SWIFT_TREAT_WARNINGS_AS_ERRORS=YES GCC_TREAT_WARNINGS_AS_ERRORS=YES CLANG_ANALYZER_NONNULL=YES analyze ./script/build_and_run.sh --package ``` diff --git a/docs/contribution-notes.md b/docs/contribution-notes.md index 6f854f1..d5444e2 100644 --- a/docs/contribution-notes.md +++ b/docs/contribution-notes.md @@ -42,8 +42,8 @@ easy to review in GitHub. Start narrow and expand: ```sh -swift test -swift build -c debug --product LinguistMac +xcodebuild -project LinguistMac.xcodeproj -scheme LinguistMac -configuration Debug -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO ENABLE_TESTABILITY=YES test +xcodebuild -project LinguistMac.xcodeproj -scheme LinguistMac -configuration Debug -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build ``` Before a PR, also run the CI-equivalent commands documented in diff --git a/script/build_and_run.sh b/script/build_and_run.sh index 0783dce..b8b4a16 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -4,6 +4,8 @@ set -euo pipefail MODE="${1:-run}" APP_NAME="LinguistMac" BUNDLE_ID="com.peerapatj.LinguistMac" +SCHEME="LinguistMac" +CONFIGURATION="${CONFIGURATION:-Debug}" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DIST_DIR="$ROOT_DIR/dist" @@ -11,8 +13,9 @@ APP_BUNDLE="$DIST_DIR/$APP_NAME.app" APP_CONTENTS="$APP_BUNDLE/Contents" APP_MACOS="$APP_CONTENTS/MacOS" APP_BINARY="$APP_MACOS/$APP_NAME" -INFO_PLIST="$APP_CONTENTS/Info.plist" -INFO_PLIST_TEMPLATE="$ROOT_DIR/Configuration/LinguistMac/Info.plist" +PROJECT_PATH="$ROOT_DIR/LinguistMac.xcodeproj" +DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$ROOT_DIR/.build/xcode}" +BUILT_APP="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME.app" cd "$ROOT_DIR" @@ -38,15 +41,19 @@ APPLESCRIPT } build_app_bundle() { - swift build --product "$APP_NAME" - local build_binary - build_binary="$(swift build --show-bin-path)/$APP_NAME" + xcodebuild \ + -quiet \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -destination 'platform=macOS' \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + CODE_SIGNING_ALLOWED="${CODE_SIGNING_ALLOWED:-NO}" \ + build rm -rf "$APP_BUNDLE" - mkdir -p "$APP_MACOS" - cp "$build_binary" "$APP_BINARY" - chmod +x "$APP_BINARY" - cp "$INFO_PLIST_TEMPLATE" "$INFO_PLIST" + mkdir -p "$DIST_DIR" + ditto "$BUILT_APP" "$APP_BUNDLE" } open_app() { From 9b4a7b5de07e66c2871fbd5e80f0ec0ea337edc9 Mon Sep 17 00:00:00 2001 From: Peerapat_J Date: Fri, 5 Jun 2026 14:41:20 +0700 Subject: [PATCH 08/12] Require immutable clean-room citations --- docs/clean-room-rules.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/clean-room-rules.md b/docs/clean-room-rules.md index 59846df..78057b6 100644 --- a/docs/clean-room-rules.md +++ b/docs/clean-room-rules.md @@ -48,13 +48,17 @@ Every implementation PR should answer: - Which GitHub issue or milestone does this close or advance? - Which behavior was implemented? -- Which public/product source describes that behavior? +- Which public/product source describes that behavior? Include `URL + accessed + date + version/tag/commit (or immutable snapshot)`. - Which tests cover the behavior? - Was any GPL source, asset, script, UI text, or architecture copied? The expected answer is no. - Were privacy-sensitive paths reviewed for clipboard, permissions, API keys, network calls, or captured text? +Expected provenance format for auditability: +`Source: ; accessed: ; immutable ref: `. + ## Commit Discipline Keep commits scoped. If review feedback reports multiple unrelated findings, From 8587be8e9145ee25df38bfa370d564faab2cf9ca Mon Sep 17 00:00:00 2001 From: Peerapat_J Date: Fri, 5 Jun 2026 14:42:29 +0700 Subject: [PATCH 09/12] Document reference inventory source commit --- docs/reference-feature-inventory.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/reference-feature-inventory.md b/docs/reference-feature-inventory.md index 96cb850..7fc675f 100644 --- a/docs/reference-feature-inventory.md +++ b/docs/reference-feature-inventory.md @@ -1,8 +1,12 @@ # Reference Feature Inventory This inventory captures high-level behavior for clean-room planning. It is based -on public README and CHANGELOG level information from `Peerapat-J/translateOnScreen` -as inspected on 2026-06-04. It must not be treated as implementation guidance. +on public README and CHANGELOG level information from +`Peerapat-J/translateOnScreen` as inspected on 2026-06-04. Source: +https://github.com/Peerapat-J/translateOnScreen; immutable ref: `main` commit +`47e0e672c551a151d52b2aadab46315578d9ffaf`; archive: +https://github.com/Peerapat-J/translateOnScreen/archive/47e0e672c551a151d52b2aadab46315578d9ffaf.zip. +It must not be treated as implementation guidance. ## Product Shape From 8b5453ec120ca29d1b2062f31798f17543a7f327 Mon Sep 17 00:00:00 2001 From: Peerapat_J Date: Fri, 5 Jun 2026 14:47:00 +0700 Subject: [PATCH 10/12] Add failing service orchestration test --- Tests/LinguistMacCoreTests/ServiceMocks.swift | 7 ++- .../ServiceMocksTests.swift | 62 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/Tests/LinguistMacCoreTests/ServiceMocks.swift b/Tests/LinguistMacCoreTests/ServiceMocks.swift index 80b44c3..a3e420b 100644 --- a/Tests/LinguistMacCoreTests/ServiceMocks.swift +++ b/Tests/LinguistMacCoreTests/ServiceMocks.swift @@ -24,9 +24,14 @@ struct StubTranslationProvider: TranslationProviding { var requiresAPIKey: Bool var usesNetwork: Bool var translatedText: String + var failure: TranslationFailure? func translate(_ request: TranslationRequest) async throws -> TranslationResult { - TranslationResult( + if let failure { + throw failure + } + + return TranslationResult( request: request, translatedText: translatedText ) diff --git a/Tests/LinguistMacCoreTests/ServiceMocksTests.swift b/Tests/LinguistMacCoreTests/ServiceMocksTests.swift index 74b8ce2..27a938e 100644 --- a/Tests/LinguistMacCoreTests/ServiceMocksTests.swift +++ b/Tests/LinguistMacCoreTests/ServiceMocksTests.swift @@ -2,6 +2,26 @@ import Foundation @testable import LinguistMacCore import XCTest +private func XCTAssertThrowsError( + _ expression: () async throws -> Void, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + errorHandler: (Error) -> Void = { _ in } +) async { + do { + try await expression() + let failureMessage = message() + XCTFail( + failureMessage.isEmpty ? "XCTAssertThrowsError failed: did not throw error" : failureMessage, + file: file, + line: line + ) + } catch { + errorHandler(error) + } +} + final class ServiceMocksTests: XCTestCase { func testServicesCanDriveMockTranslationFlow() async throws { let request = TranslationRequest( @@ -41,6 +61,48 @@ final class ServiceMocksTests: XCTestCase { XCTAssertEqual(result.originalText, "hello") } + func testServicesSurfaceMockTranslationFailure() async throws { + let request = TranslationRequest( + text: "hello", + sourceLanguage: .english, + targetLanguage: .thai, + inputMode: .screenSelection, + providerID: .apple + ) + let region = CapturedScreenRegion(imageData: Data([1, 2, 3])) + let expectedFailure = TranslationFailure.providerFailed("translator unavailable") + let provider = StubTranslationProvider( + id: .apple, + displayName: "Apple Translation", + requiresAPIKey: false, + usesNetwork: false, + translatedText: "unused", + failure: expectedFailure + ) + let services = LinguistServices( + screenCapture: StubScreenCaptureService(result: .success(region)), + ocr: StubOCRService(result: .success(RecognizedText(text: request.text, language: .english))), + translatorRegistry: StubTranslationProviderRegistry(provider: provider), + settingsStore: InMemoryAppSettingsStore(), + historyStore: InMemoryTranslationHistoryStore(), + permissionChecker: StubPermissionChecker(statuses: [.screenRecording: .granted]), + clipboard: InMemoryClipboard(), + shortcutRegistry: RecordingShortcutRegistry() + ) + + let capturedRegion = try await services.screenCapture.captureSelection() + let recognizedText = try await services.ocr.recognizeText(in: capturedRegion) + let translator = try await services.translatorRegistry.provider(for: request.providerID) + + XCTAssertEqual(capturedRegion, region) + XCTAssertEqual(recognizedText.text, "hello") + await XCTAssertThrowsError { + _ = try await translator.translate(request) + } errorHandler: { error in + XCTAssertEqual(error as? TranslationFailure, expectedFailure) + } + } + func testInMemoryStoresSupportSettingsHistoryClipboardAndShortcuts() async throws { let settingsStore = InMemoryAppSettingsStore() var settings = try await settingsStore.loadSettings() From 09a8dfc33be6bbe36d8c5cbfda2dc52e8edcb831 Mon Sep 17 00:00:00 2001 From: Peerapat_J Date: Fri, 5 Jun 2026 15:27:03 +0700 Subject: [PATCH 11/12] Remove hard-pinned Xcode architectures --- LinguistMac.xcodeproj/project.pbxproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/LinguistMac.xcodeproj/project.pbxproj b/LinguistMac.xcodeproj/project.pbxproj index c8fa8ca..e4cf2f7 100644 --- a/LinguistMac.xcodeproj/project.pbxproj +++ b/LinguistMac.xcodeproj/project.pbxproj @@ -349,7 +349,6 @@ B70000000000000000000002 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ARCHS = arm64; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CODE_SIGN_IDENTITY = "-"; @@ -376,7 +375,6 @@ B70000000000000000000003 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ARCHS = arm64; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CODE_SIGN_IDENTITY = "-"; From fc9b53a8085cdfb40ab85db65cede02a9eb999b5 Mon Sep 17 00:00:00 2001 From: Peerapat_J Date: Fri, 5 Jun 2026 15:35:39 +0700 Subject: [PATCH 12/12] Ad-hoc sign packaged app entitlements --- docs/ci-cd.md | 5 ++++- script/build_and_run.sh | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index aca809d..6d393a4 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -16,7 +16,9 @@ The `CI` workflow runs on pull requests, pushes to `main`, and manual dispatches ## Delivery artifact -The workflow also builds an unsigned `.app` artifact on pushes to `main` and manual dispatches. This is only a development artifact, not a signed or notarized release. +The workflow also builds an ad-hoc-signed `.app` artifact on pushes to `main` +and manual dispatches so development packages keep the sandbox entitlement. This +is only a development artifact, not a Developer ID signed or notarized release. Signed distribution should wait until the app identity, entitlements, Developer ID signing, and notarization flow are defined. @@ -36,4 +38,5 @@ xcodebuild -project LinguistMac.xcodeproj -scheme LinguistMac -configuration Rel xcodebuild -project LinguistMac.xcodeproj -scheme LinguistMac -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-strict CODE_SIGNING_ALLOWED=NO SWIFT_TREAT_WARNINGS_AS_ERRORS=YES GCC_TREAT_WARNINGS_AS_ERRORS=YES build xcodebuild -project LinguistMac.xcodeproj -scheme LinguistMac -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/linguistmac-analyze CODE_SIGNING_ALLOWED=NO SWIFT_TREAT_WARNINGS_AS_ERRORS=YES GCC_TREAT_WARNINGS_AS_ERRORS=YES CLANG_ANALYZER_NONNULL=YES analyze ./script/build_and_run.sh --package +codesign -d --entitlements :- dist/LinguistMac.app ``` diff --git a/script/build_and_run.sh b/script/build_and_run.sh index b8b4a16..aedfac5 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -48,7 +48,7 @@ build_app_bundle() { -configuration "$CONFIGURATION" \ -destination 'platform=macOS' \ -derivedDataPath "$DERIVED_DATA_PATH" \ - CODE_SIGNING_ALLOWED="${CODE_SIGNING_ALLOWED:-NO}" \ + CODE_SIGNING_ALLOWED="${CODE_SIGNING_ALLOWED:-YES}" \ build rm -rf "$APP_BUNDLE"