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
32 changes: 32 additions & 0 deletions LinguistMac.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,28 @@
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 */; };
B10000000000000000000005 /* AppShellModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000005 /* AppShellModel.swift */; };
B10000000000000000000006 /* MenuBarMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000006 /* MenuBarMenuView.swift */; };
B10000000000000000000007 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000007 /* SettingsView.swift */; };
B10000000000000000000008 /* TranslationPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000008 /* TranslationPopupView.swift */; };
B10000000000000000000009 /* QuickTranslateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000009 /* QuickTranslateView.swift */; };
B1000000000000000000000A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000A /* OnboardingView.swift */; };
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 */; };
B10000000000000000000017 /* AppShellModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000017 /* AppShellModels.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 */; };
B10000000000000000000047 /* AppShellModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000047 /* AppShellModelsTests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand All @@ -47,13 +55,20 @@
/* Begin PBXFileReference section */
B20000000000000000000001 /* LinguistMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinguistMacApp.swift; sourceTree = "<group>"; };
B20000000000000000000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
B20000000000000000000005 /* AppShellModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShellModel.swift; sourceTree = "<group>"; };
B20000000000000000000006 /* MenuBarMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarMenuView.swift; sourceTree = "<group>"; };
B20000000000000000000007 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
B20000000000000000000008 /* TranslationPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationPopupView.swift; sourceTree = "<group>"; };
B20000000000000000000009 /* QuickTranslateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTranslateView.swift; sourceTree = "<group>"; };
B2000000000000000000000A /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
B20000000000000000000010 /* AppFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFeature.swift; sourceTree = "<group>"; };
B20000000000000000000011 /* AppIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIdentity.swift; sourceTree = "<group>"; };
B20000000000000000000012 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
B20000000000000000000013 /* LinguistServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinguistServices.swift; sourceTree = "<group>"; };
B20000000000000000000014 /* Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permissions.swift; sourceTree = "<group>"; };
B20000000000000000000015 /* ServiceProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProtocols.swift; sourceTree = "<group>"; };
B20000000000000000000016 /* TranslationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationModels.swift; sourceTree = "<group>"; };
B20000000000000000000017 /* AppShellModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShellModels.swift; sourceTree = "<group>"; };
B20000000000000000000020 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B20000000000000000000021 /* LinguistMac.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LinguistMac.entitlements; sourceTree = "<group>"; };
B20000000000000000000030 /* libLinguistMacCore.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libLinguistMacCore.a; sourceTree = BUILT_PRODUCTS_DIR; };
Expand All @@ -66,6 +81,7 @@
B20000000000000000000044 /* ServiceMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceMocks.swift; sourceTree = "<group>"; };
B20000000000000000000045 /* ServiceMocksTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceMocksTests.swift; sourceTree = "<group>"; };
B20000000000000000000046 /* TranslationModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranslationModelsTests.swift; sourceTree = "<group>"; };
B20000000000000000000047 /* AppShellModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShellModelsTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -119,6 +135,12 @@
children = (
B20000000000000000000001 /* LinguistMacApp.swift */,
B20000000000000000000002 /* ContentView.swift */,
B20000000000000000000005 /* AppShellModel.swift */,
B20000000000000000000006 /* MenuBarMenuView.swift */,
B20000000000000000000007 /* SettingsView.swift */,
B20000000000000000000008 /* TranslationPopupView.swift */,
B20000000000000000000009 /* QuickTranslateView.swift */,
B2000000000000000000000A /* OnboardingView.swift */,
);
path = LinguistMac;
sourceTree = "<group>";
Expand All @@ -133,6 +155,7 @@
B20000000000000000000014 /* Permissions.swift */,
B20000000000000000000015 /* ServiceProtocols.swift */,
B20000000000000000000016 /* TranslationModels.swift */,
B20000000000000000000017 /* AppShellModels.swift */,
);
path = LinguistMacCore;
sourceTree = "<group>";
Expand All @@ -155,6 +178,7 @@
B20000000000000000000044 /* ServiceMocks.swift */,
B20000000000000000000045 /* ServiceMocksTests.swift */,
B20000000000000000000046 /* TranslationModelsTests.swift */,
B20000000000000000000047 /* AppShellModelsTests.swift */,
);
path = LinguistMacCoreTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -299,6 +323,12 @@
files = (
B10000000000000000000001 /* LinguistMacApp.swift in Sources */,
B10000000000000000000002 /* ContentView.swift in Sources */,
B10000000000000000000005 /* AppShellModel.swift in Sources */,
B10000000000000000000006 /* MenuBarMenuView.swift in Sources */,
B10000000000000000000007 /* SettingsView.swift in Sources */,
B10000000000000000000008 /* TranslationPopupView.swift in Sources */,
B10000000000000000000009 /* QuickTranslateView.swift in Sources */,
B1000000000000000000000A /* OnboardingView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand All @@ -313,6 +343,7 @@
B10000000000000000000014 /* Permissions.swift in Sources */,
B10000000000000000000015 /* ServiceProtocols.swift in Sources */,
B10000000000000000000016 /* TranslationModels.swift in Sources */,
B10000000000000000000017 /* AppShellModels.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand All @@ -327,6 +358,7 @@
B10000000000000000000044 /* ServiceMocks.swift in Sources */,
B10000000000000000000045 /* ServiceMocksTests.swift in Sources */,
B10000000000000000000046 /* TranslationModelsTests.swift in Sources */,
B10000000000000000000047 /* AppShellModelsTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
249 changes: 249 additions & 0 deletions Sources/LinguistMac/AppShellModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import AppKit
import Combine
import LinguistMacCore

enum AppWindow: String {
case status
case quickTranslate
case translationPopup
case onboarding
}

enum AppShellCommand: Equatable {
case screenTranslate
case quickTranslate
case settings
case history
case onboarding
case about
case quit
case copyTranslation
case openSystemSettings(PermissionKind)
}

@MainActor
final class AppShellModel: ObservableObject {
private static let onboardingCompletedKey = "LinguistMac.hasCompletedOnboarding"

@Published var settings: AppSettings
@Published private(set) var recentTranslations: [TranslationResult]
@Published var popupState: TranslationPopupState
@Published var quickDraft: QuickTranslateDraft
@Published var quickSessionState: TranslationSessionState
@Published var readiness: OnboardingReadinessSnapshot
@Published private(set) var lastCommand: AppShellCommand?

let availableLanguages: [TranslationLanguage] = [
.autoDetect,
.english,
.thai,
.japanese,
.korean,
.simplifiedChinese
]

let availableProviders: [TranslationProviderDescriptor] = [
TranslationProviderDescriptor(
id: .apple,
displayName: "Apple Translation",
requiresAPIKey: false,
usesNetwork: false
),
TranslationProviderDescriptor(
id: .deepl,
displayName: "DeepL",
requiresAPIKey: true,
usesNetwork: true
),
TranslationProviderDescriptor(
id: .googleCloud,
displayName: "Google Cloud",
requiresAPIKey: true,
usesNetwork: true
),
TranslationProviderDescriptor(
id: .microsoftAzure,
displayName: "Microsoft Azure",
requiresAPIKey: true,
usesNetwork: true
)
]

private let translator: any TranslationProviding
private let clipboard: any ClipboardServicing

init(
settings: AppSettings = AppSettings(),
recentTranslations: [TranslationResult] = [],
translator: any TranslationProviding = PreviewTranslationProvider(),
clipboard: any ClipboardServicing = SystemClipboardService()
) {
var initialSettings = settings
initialSettings.hasCompletedOnboarding = UserDefaults.standard.bool(
forKey: Self.onboardingCompletedKey
)

self.settings = initialSettings
self.recentTranslations = recentTranslations
popupState = .empty
quickDraft = QuickTranslateDraft(
sourceLanguage: initialSettings.sourceLanguage,
targetLanguage: initialSettings.targetLanguage
)
quickSessionState = .idle
readiness = OnboardingReadinessSnapshot.make(
screenRecording: .notDetermined,
accessibility: .notDetermined,
appleTranslation: .unknown,
cloudProviderConfigured: false
)
self.translator = translator
self.clipboard = clipboard
}

var recentMenuItems: [TranslationResult] {
Array(recentTranslations.prefix(5))
}

func record(_ command: AppShellCommand) {
lastCommand = command
}

func prepareQuickTranslate() {
record(.quickTranslate)
quickDraft.sourceLanguage = settings.sourceLanguage
quickDraft.targetLanguage = settings.targetLanguage
quickSessionState = .idle
}

func presentScreenTranslationPreview() {
record(.screenTranslate)

let request = TranslationRequest(
text: "Captured screen text preview",
sourceLanguage: settings.sourceLanguage,
targetLanguage: settings.targetLanguage,
inputMode: .screenSelection,
providerID: settings.selectedProviderID
)
let result = TranslationResult(
request: request,
translatedText: "Preview translation for the selected screen text."
)
popupState = .success(result, showsOriginal: false)
saveRecent(result)
}

func runQuickTranslate() async {
do {
let request = try quickDraft.makeRequest(providerID: settings.selectedProviderID)
quickSessionState = .translating(request)
let result = try await translator.translate(request)
quickSessionState = .completed(result)
popupState = .success(result, showsOriginal: false)
saveRecent(result)

if settings.autoCopyEnabled {
await clipboard.writeText(result.translatedText)
}
} catch let failure as TranslationFailure {
quickSessionState = .failed(failure)
popupState = .failed(failure, originalText: quickDraft.trimmedText)
} catch {
let failure = TranslationFailure.providerFailed(error.localizedDescription)
quickSessionState = .failed(failure)
popupState = .failed(failure, originalText: quickDraft.trimmedText)
}
}

func togglePopupOriginal() {
popupState = popupState.toggledOriginalVisibility()
}

func copyPopupText() async {
guard let text = popupState.copyableText else {
return
}

record(.copyTranslation)
await clipboard.writeText(text)
}

func markOnboardingComplete() {
setOnboardingCompleted(true)
}

func setOnboardingCompleted(_ isCompleted: Bool) {
settings.hasCompletedOnboarding = isCompleted
UserDefaults.standard.set(isCompleted, forKey: Self.onboardingCompletedKey)
}

func reopenOnboarding() {
record(.onboarding)
}

func openSettingsWindow() {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
NSApp.activate(ignoringOtherApps: true)
}

func openSystemSettings(for kind: PermissionKind) {
record(.openSystemSettings(kind))

guard let url = systemSettingsURL(for: kind) else {
return
}

NSWorkspace.shared.open(url)
}

private func saveRecent(_ result: TranslationResult) {
recentTranslations.insert(result, at: 0)
recentTranslations = Array(recentTranslations.prefix(10))
}

private func systemSettingsURL(for kind: PermissionKind) -> URL? {
switch kind {
case .screenRecording:
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")
case .accessibility:
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
case .keychain, .network:
URL(string: "x-apple.systempreferences:com.apple.preference.security")
}
}
}

struct PreviewTranslationProvider: TranslationProviding {
let id: TranslationProviderID = .apple
let displayName = "Apple Translation Preview"
let requiresAPIKey = false
let usesNetwork = false

func translate(_ request: TranslationRequest) async throws -> TranslationResult {
let text = request.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
throw TranslationFailure.emptyInput
}

return TranslationResult(
request: request,
translatedText: "Preview translation: \(text)"
)
}
}

actor SystemClipboardService: ClipboardServicing {
func readText() async -> String? {
await MainActor.run {
NSPasteboard.general.string(forType: .string)
}
}

func writeText(_ text: String) async {
await MainActor.run {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
}
}
Loading
Loading