From de2fe88dbde47538b7def502ea4cf20b4e1c5b72 Mon Sep 17 00:00:00 2001 From: Hubert Date: Thu, 26 Mar 2026 12:42:03 +0800 Subject: [PATCH 1/3] feat: simplify onboarding to 3 steps with permission UX improvements Reduce onboarding from 4 steps to 3 by removing the engine configuration step (PaddleOCR/MTranServer setup moved to Settings). Add permission purpose descriptions, skip-for-now option with timeout hint, and menubar location guidance on completion page. --- .../OnboardingCompleteStepView.swift | 21 ++ .../Onboarding/OnboardingComponents.swift | 4 + .../OnboardingConfigurationStepView.swift | 199 ---------------- .../OnboardingPermissionsStepView.swift | 47 +++- .../Features/Onboarding/OnboardingView.swift | 13 +- .../Onboarding/OnboardingViewModel.swift | 222 ++---------------- .../OnboardingWindowController.swift | 2 +- ScreenTranslate/Models/AppSettings.swift | 8 + .../Resources/en.lproj/Localizable.strings | 36 +-- .../zh-Hans.lproj/Localizable.strings | 36 +-- 10 files changed, 115 insertions(+), 473 deletions(-) delete mode 100644 ScreenTranslate/Features/Onboarding/OnboardingConfigurationStepView.swift diff --git a/ScreenTranslate/Features/Onboarding/OnboardingCompleteStepView.swift b/ScreenTranslate/Features/Onboarding/OnboardingCompleteStepView.swift index 22a3178..c1adfe7 100644 --- a/ScreenTranslate/Features/Onboarding/OnboardingCompleteStepView.swift +++ b/ScreenTranslate/Features/Onboarding/OnboardingCompleteStepView.swift @@ -2,6 +2,7 @@ import SwiftUI struct OnboardingCompleteStepView: View { let onStart: () -> Void + let hasSkippedPermissions: Bool var body: some View { VStack(spacing: 24) { @@ -23,6 +24,13 @@ struct OnboardingCompleteStepView: View { } VStack(alignment: .leading, spacing: 12) { + OnboardingInfoRow( + icon: "menubar.rectangle", + text: NSLocalizedString("onboarding.complete.menubar.hint", comment: "") + ) + + Divider() + OnboardingInfoRow( icon: "command", text: NSLocalizedString("onboarding.complete.shortcuts", comment: "") @@ -38,6 +46,19 @@ struct OnboardingCompleteStepView: View { } .padding(.vertical, 8) + if hasSkippedPermissions { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(NSLocalizedString("onboarding.complete.permissions.warning", comment: "")) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(12) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(.rect(cornerRadius: 8)) + } + Spacer() Button { diff --git a/ScreenTranslate/Features/Onboarding/OnboardingComponents.swift b/ScreenTranslate/Features/Onboarding/OnboardingComponents.swift index b98eb79..5614a9e 100644 --- a/ScreenTranslate/Features/Onboarding/OnboardingComponents.swift +++ b/ScreenTranslate/Features/Onboarding/OnboardingComponents.swift @@ -77,6 +77,7 @@ struct OnboardingNavigationButtons: View { struct OnboardingPermissionRow: View { let icon: String let title: String + let subtitle: String let isGranted: Bool let requestAction: () -> Void let openSettingsAction: () -> Void @@ -91,6 +92,9 @@ struct OnboardingPermissionRow: View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.headline) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) Text(isGranted ? NSLocalizedString("onboarding.permission.granted", comment: "") : NSLocalizedString("onboarding.permission.not.granted", comment: "")) diff --git a/ScreenTranslate/Features/Onboarding/OnboardingConfigurationStepView.swift b/ScreenTranslate/Features/Onboarding/OnboardingConfigurationStepView.swift deleted file mode 100644 index 0e95199..0000000 --- a/ScreenTranslate/Features/Onboarding/OnboardingConfigurationStepView.swift +++ /dev/null @@ -1,199 +0,0 @@ -import SwiftUI - -struct OnboardingConfigurationStepView: View { - @Bindable var viewModel: OnboardingViewModel - - var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(spacing: 24) { - Image(systemName: "gearshape.2.fill") - .font(.system(size: 50)) - .foregroundStyle(.blue) - .padding(.top, 16) - - VStack(spacing: 12) { - Text(NSLocalizedString("onboarding.configuration.title", comment: "")) - .font(.largeTitle) - .fontWeight(.semibold) - - Text(NSLocalizedString("onboarding.configuration.message", comment: "")) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - - VStack(alignment: .leading, spacing: 16) { - OnboardingPaddleOCRSection(viewModel: viewModel) - - Divider() - - VStack(alignment: .leading, spacing: 8) { - Text(NSLocalizedString("onboarding.configuration.mtran", comment: "")) - .font(.headline) - Text(NSLocalizedString("onboarding.configuration.mtran.hint", comment: "")) - .font(.caption) - .foregroundStyle(.secondary) - TextField( - NSLocalizedString("onboarding.configuration.placeholder.address", comment: ""), - text: $viewModel.mtranServerURL - ) - .textFieldStyle(.roundedBorder) - } - - VStack(alignment: .leading, spacing: 8) { - Text(NSLocalizedString("onboarding.configuration.test", comment: "")) - .font(.headline) - - if let result = viewModel.translationTestResult { - let imageName = viewModel.translationTestSuccess ? "checkmark.circle.fill" : "xmark.circle.fill" - HStack(spacing: 8) { - Image(systemName: imageName) - .foregroundStyle(viewModel.translationTestSuccess ? .green : .red) - Text(result) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Button { - Task { - await viewModel.testTranslation() - } - } label: { - if viewModel.isTestingTranslation { - Text(NSLocalizedString("onboarding.configuration.testing", comment: "")) - } else { - Text(NSLocalizedString("onboarding.configuration.test.button", comment: "")) - } - } - .buttonStyle(.bordered) - .disabled(viewModel.isTestingTranslation) - } - } - .frame(maxWidth: 400) - } - .padding(32) - } - - Divider() - - HStack(spacing: 16) { - Button { - viewModel.skipConfiguration() - } label: { - Text(NSLocalizedString("onboarding.skip", comment: "")) - .fontWeight(.medium) - } - .buttonStyle(.borderedProminent) - .tint(.secondary) - - Spacer() - - Button { - viewModel.goToNextStep() - } label: { - Text(NSLocalizedString("onboarding.complete", comment: "")) - .fontWeight(.semibold) - } - .buttonStyle(.borderedProminent) - } - .padding(16) - .background(Color(nsColor: .windowBackgroundColor)) - } - } -} - -struct OnboardingPaddleOCRSection: View { - @Bindable var viewModel: OnboardingViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text(NSLocalizedString("onboarding.paddleocr.title", comment: "")) - .font(.headline) - - Spacer() - - if viewModel.isPaddleOCRInstalled { - HStack(spacing: 4) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text(NSLocalizedString("onboarding.paddleocr.installed", comment: "")) - .font(.caption) - .foregroundStyle(.green) - } - } else { - HStack(spacing: 4) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - Text(NSLocalizedString("onboarding.paddleocr.not.installed", comment: "")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - Text(NSLocalizedString("onboarding.paddleocr.description", comment: "")) - .font(.caption) - .foregroundStyle(.secondary) - - if !viewModel.isPaddleOCRInstalled { - VStack(alignment: .leading, spacing: 8) { - Text(NSLocalizedString("onboarding.paddleocr.install.hint", comment: "")) - .font(.caption) - .foregroundStyle(.secondary) - - HStack(spacing: 12) { - Button { - viewModel.installPaddleOCR() - } label: { - if viewModel.isInstallingPaddleOCR { - ProgressView() - .controlSize(.small) - .frame(width: 16, height: 16) - Text(NSLocalizedString("onboarding.paddleocr.installing", comment: "")) - } else { - Image(systemName: "arrow.down.circle") - Text(NSLocalizedString("onboarding.paddleocr.install", comment: "")) - } - } - .buttonStyle(.borderedProminent) - .disabled(viewModel.isInstallingPaddleOCR) - - Button { - viewModel.copyInstallCommand() - } label: { - Image(systemName: "doc.on.doc") - Text(NSLocalizedString("onboarding.paddleocr.copy.command", comment: "")) - } - .buttonStyle(.bordered) - - Button { - viewModel.refreshPaddleOCRStatus() - } label: { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.borderless) - .help(NSLocalizedString("onboarding.paddleocr.refresh", comment: "")) - } - - if let error = viewModel.paddleOCRInstallError { - Text(error) - .font(.caption) - .foregroundStyle(.red) - } - } - } else { - if let version = viewModel.paddleOCRVersion { - Text(String(format: NSLocalizedString("onboarding.paddleocr.version", comment: ""), version)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .padding() - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(.rect(cornerRadius: 8)) - } -} diff --git a/ScreenTranslate/Features/Onboarding/OnboardingPermissionsStepView.swift b/ScreenTranslate/Features/Onboarding/OnboardingPermissionsStepView.swift index 7a31886..d25fb96 100644 --- a/ScreenTranslate/Features/Onboarding/OnboardingPermissionsStepView.swift +++ b/ScreenTranslate/Features/Onboarding/OnboardingPermissionsStepView.swift @@ -6,12 +6,14 @@ struct OnboardingPermissionsStepView: View { let canGoPrevious: Bool let canGoNext: Bool let isLastStep: Bool + let permissionCheckTimedOut: Bool let onRequestScreenRecording: () -> Void let onOpenScreenRecordingSettings: () -> Void let onRequestAccessibility: () -> Void let onOpenAccessibilitySettings: () -> Void let onPrevious: () -> Void let onNext: () -> Void + let onSkip: () -> Void var body: some View { VStack(spacing: 24) { @@ -36,6 +38,7 @@ struct OnboardingPermissionsStepView: View { OnboardingPermissionRow( icon: "video.fill", title: NSLocalizedString("onboarding.permission.screen.recording", comment: ""), + subtitle: NSLocalizedString("onboarding.permission.screen.recording.subtitle", comment: ""), isGranted: hasScreenRecordingPermission, requestAction: onRequestScreenRecording, openSettingsAction: onOpenScreenRecordingSettings @@ -44,25 +47,55 @@ struct OnboardingPermissionsStepView: View { OnboardingPermissionRow( icon: "command.square.fill", title: NSLocalizedString("onboarding.permission.accessibility", comment: ""), + subtitle: NSLocalizedString("onboarding.permission.accessibility.subtitle", comment: ""), isGranted: hasAccessibilityPermission, requestAction: onRequestAccessibility, openSettingsAction: onOpenAccessibilitySettings ) } + if permissionCheckTimedOut { + Text(NSLocalizedString("onboarding.permissions.timeout.hint", comment: "")) + .font(.caption) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + } + Spacer() Text(NSLocalizedString("onboarding.permissions.hint", comment: "")) .font(.caption) .foregroundStyle(.secondary) - OnboardingNavigationButtons( - canGoPrevious: canGoPrevious, - canGoNext: canGoNext, - isLastStep: isLastStep, - onPrevious: onPrevious, - onNext: onNext - ) + HStack(spacing: 16) { + if canGoPrevious { + Button { + onPrevious() + } label: { + Text(NSLocalizedString("onboarding.back", comment: "")) + } + .buttonStyle(.bordered) + } + + Spacer() + + Button { + onSkip() + } label: { + Text(NSLocalizedString("onboarding.skip.permissions", comment: "")) + } + .buttonStyle(.bordered) + .tint(.secondary) + + if canGoNext && !isLastStep { + Button { + onNext() + } label: { + Text(NSLocalizedString("onboarding.continue", comment: "")) + } + .buttonStyle(.borderedProminent) + } + } } .padding(32) } diff --git a/ScreenTranslate/Features/Onboarding/OnboardingView.swift b/ScreenTranslate/Features/Onboarding/OnboardingView.swift index dbea3d7..74126d9 100644 --- a/ScreenTranslate/Features/Onboarding/OnboardingView.swift +++ b/ScreenTranslate/Features/Onboarding/OnboardingView.swift @@ -28,17 +28,20 @@ struct OnboardingView: View { canGoPrevious: viewModel.canGoPrevious, canGoNext: viewModel.canGoNext, isLastStep: viewModel.isLastStep, + permissionCheckTimedOut: viewModel.permissionCheckTimedOut, onRequestScreenRecording: { viewModel.requestScreenRecordingPermission() }, onOpenScreenRecordingSettings: { viewModel.openScreenRecordingSettings() }, onRequestAccessibility: { viewModel.requestAccessibilityPermission() }, onOpenAccessibilitySettings: { viewModel.openAccessibilitySettings() }, onPrevious: { viewModel.goToPreviousStep() }, - onNext: { viewModel.goToNextStep() } + onNext: { viewModel.goToNextStep() }, + onSkip: { viewModel.skipPermissions() } ) case 2: - OnboardingConfigurationStepView(viewModel: viewModel) - case 3: - OnboardingCompleteStepView(onStart: { viewModel.goToNextStep() }) + OnboardingCompleteStepView( + onStart: { viewModel.goToNextStep() }, + hasSkippedPermissions: viewModel.hasSkippedPermissions + ) default: OnboardingWelcomeStepView( onContinue: { viewModel.goToNextStep() }, @@ -48,7 +51,7 @@ struct OnboardingView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(width: 600, height: 620) + .frame(width: 600, height: 520) .onReceive(NotificationCenter.default.publisher(for: .onboardingCompleted)) { _ in dismiss() } diff --git a/ScreenTranslate/Features/Onboarding/OnboardingViewModel.swift b/ScreenTranslate/Features/Onboarding/OnboardingViewModel.swift index 622ffe7..3f7468e 100644 --- a/ScreenTranslate/Features/Onboarding/OnboardingViewModel.swift +++ b/ScreenTranslate/Features/Onboarding/OnboardingViewModel.swift @@ -18,7 +18,7 @@ final class OnboardingViewModel { var currentStep = 0 /// Total number of steps in the onboarding flow - let totalSteps = 4 + let totalSteps = 3 /// Screen recording permission status var hasScreenRecordingPermission = false @@ -26,44 +26,18 @@ final class OnboardingViewModel { /// Accessibility permission status var hasAccessibilityPermission = false + /// Whether user has skipped the permissions step + var hasSkippedPermissions = false + + /// Whether permission check has timed out (30s polling exceeded) + var permissionCheckTimedOut = false + /// Type of permission being requested enum PermissionType { case screenRecording case accessibility } - /// PaddleOCR server address - var paddleOCRServerAddress = "" - - var mtranServerURL = "localhost:8989" { - didSet { - // Clear test result when URL changes - translationTestResult = nil - translationTestSuccess = false - } - } - - /// Whether a translation test is in progress - var isTestingTranslation = false - - /// Translation test result message - var translationTestResult: String? - - /// Translation test success status - var translationTestSuccess = false - - /// Whether PaddleOCR is installed - var isPaddleOCRInstalled = false - - /// Whether PaddleOCR installation is in progress - var isInstallingPaddleOCR = false - - /// PaddleOCR installation error message - var paddleOCRInstallError: String? - - /// PaddleOCR version if installed - var paddleOCRVersion: String? - /// Task for permission checking (stored for cancellation) private var permissionCheckTask: Task? @@ -79,9 +53,6 @@ final class OnboardingViewModel { // Permissions step - need both permissions return hasScreenRecordingPermission && hasAccessibilityPermission case 2: - // Configuration step - optional, always can proceed - return true - case 3: // Complete step - can finish return true default: @@ -107,8 +78,6 @@ final class OnboardingViewModel { await MainActor.run { // Only check accessibility permission on init (no system dialog) hasAccessibilityPermission = AccessibilityPermissionChecker.hasPermission - // Don't auto-check screen recording - it may trigger system dialog - refreshPaddleOCRStatus() } } } @@ -119,12 +88,10 @@ final class OnboardingViewModel { func goToNextStep() { guard canGoNext else { return } guard currentStep < totalSteps - 1 else { - // Complete onboarding completeOnboarding() return } currentStep += 1 - // Check permissions when entering the permissions step if currentStep == 1 { checkPermissions() } @@ -134,15 +101,21 @@ final class OnboardingViewModel { func goToPreviousStep() { guard canGoPrevious else { return } currentStep -= 1 - // Check permissions when entering the permissions step if currentStep == 1 { checkPermissions() } } + /// Skips the permissions step and completes onboarding + func skipPermissions() { + hasSkippedPermissions = true + completeOnboarding() + } + /// Checks all permission statuses func checkPermissions() { hasAccessibilityPermission = AccessibilityPermissionChecker.hasPermission + permissionCheckTimedOut = false // Check screen recording permission using async method Task { @@ -185,6 +158,7 @@ final class OnboardingViewModel { openScreenRecordingSettings() // Start polling for permission status + permissionCheckTimedOut = false startPermissionCheck(for: .screenRecording) } @@ -206,6 +180,7 @@ final class OnboardingViewModel { // Request accessibility - triggers system dialog (will guide user to settings if needed) _ = AccessibilityPermissionChecker.requestPermission() // Start checking for permission + permissionCheckTimedOut = false startPermissionCheck(for: .accessibility) } @@ -230,7 +205,6 @@ final class OnboardingViewModel { switch type { case .screenRecording: - // Use async ScreenCaptureKit check for reliable detection let granted = await checkScreenRecordingPermission() if granted { hasScreenRecordingPermission = true @@ -247,171 +221,17 @@ final class OnboardingViewModel { } } } + // Polling timed out after 30 seconds + permissionCheckTimedOut = true } } - func testTranslation() async { - isTestingTranslation = true - translationTestResult = nil - translationTestSuccess = false - - let testText = "Hello" - - do { - if let (host, port) = parseServerURL(mtranServerURL), !host.isEmpty { - let originalHost = settings.mtranServerHost - let originalPort = settings.mtranServerPort - settings.mtranServerHost = host - settings.mtranServerPort = port - - let result = try await MTranServerEngine.shared.translate(testText, to: "zh") - - settings.mtranServerHost = originalHost - settings.mtranServerPort = originalPort - - translationTestResult = String( - format: NSLocalizedString("onboarding.test.success", comment: ""), - testText, - result.translatedText - ) - translationTestSuccess = true - } else { - let config = TranslationEngine.Configuration( - targetLanguage: TranslationLanguage.chineseSimplified, - timeout: 10.0, - autoDetectSourceLanguage: true - ) - let result = try await TranslationEngine.shared.translate(testText, config: config) - - translationTestResult = String( - format: NSLocalizedString("onboarding.test.success", comment: ""), - testText, - result.translatedText - ) - translationTestSuccess = true - } - } catch { - translationTestResult = String( - format: NSLocalizedString("onboarding.test.failed", comment: ""), - error.localizedDescription - ) - translationTestSuccess = false - } - - isTestingTranslation = false - } - private func completeOnboarding() { - if !paddleOCRServerAddress.isEmpty { - settings.paddleOCRServerAddress = paddleOCRServerAddress - } - - if let (host, port) = parseServerURL(mtranServerURL), !host.isEmpty { - settings.mtranServerHost = host - settings.mtranServerPort = port - } - settings.onboardingCompleted = true - NotificationCenter.default.post(name: .onboardingCompleted, object: nil) - } - - private func parseServerURL(_ url: String) -> (host: String, port: Int)? { - let trimmed = url.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { return nil } - - // Remove protocol if present - var hostPart = trimmed - if hostPart.hasPrefix("http://") { - hostPart = String(hostPart.dropFirst(7)) - } else if hostPart.hasPrefix("https://") { - hostPart = String(hostPart.dropFirst(8)) + if hasSkippedPermissions { + settings.userSkippedPermissions = true } - - // Split by colon for port - if let colonIndex = hostPart.firstIndex(of: ":") { - let host = String(hostPart[.. String? { - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/env") - task.arguments = ["pip3", "install", "paddleocr", "paddlepaddle"] - - let stderrPipe = Pipe() - task.standardError = stderrPipe - task.standardOutput = Pipe() - - do { - try task.run() - task.waitUntilExit() - - if task.terminationStatus != 0 { - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let stderr = String(data: stderrData, encoding: .utf8) ?? "Unknown error" - return stderr.isEmpty ? "Installation failed with exit code \(task.terminationStatus)" : stderr - } - return nil - } catch { - return error.localizedDescription - } - } - - func copyInstallCommand() { - let command = "pip3 install paddleocr paddlepaddle" - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(command, forType: .string) + NotificationCenter.default.post(name: .onboardingCompleted, object: nil) } } diff --git a/ScreenTranslate/Features/Onboarding/OnboardingWindowController.swift b/ScreenTranslate/Features/Onboarding/OnboardingWindowController.swift index cbbca81..0f63cf0 100644 --- a/ScreenTranslate/Features/Onboarding/OnboardingWindowController.swift +++ b/ScreenTranslate/Features/Onboarding/OnboardingWindowController.swift @@ -56,7 +56,7 @@ final class OnboardingWindowController: NSObject { // Create the window let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 600, height: 620), + contentRect: NSRect(x: 0, y: 0, width: 600, height: 520), styleMask: [.titled, .closable], backing: .buffered, defer: false diff --git a/ScreenTranslate/Models/AppSettings.swift b/ScreenTranslate/Models/AppSettings.swift index 9135c95..90cc002 100644 --- a/ScreenTranslate/Models/AppSettings.swift +++ b/ScreenTranslate/Models/AppSettings.swift @@ -63,6 +63,7 @@ final class AppSettings { static let translationEngine = prefix + "translationEngine" static let translationMode = prefix + "translationMode" static let onboardingCompleted = prefix + "onboardingCompleted" + static let userSkippedPermissions = prefix + "userSkippedPermissions" static let paddleOCRServerAddress = prefix + "paddleOCRServerAddress" static let mtranServerHost = prefix + "mtranServerHost" static let mtranServerPort = prefix + "mtranServerPort" @@ -212,6 +213,11 @@ final class AppSettings { didSet { save(onboardingCompleted, forKey: Keys.onboardingCompleted) } } + /// Whether the user skipped permissions during onboarding + var userSkippedPermissions: Bool { + didSet { save(userSkippedPermissions, forKey: Keys.userSkippedPermissions) } + } + var paddleOCRServerAddress: String { didSet { save(paddleOCRServerAddress, forKey: Keys.paddleOCRServerAddress) } } @@ -416,6 +422,7 @@ final class AppSettings { translationMode = defaults.string(forKey: Keys.translationMode) .flatMap { TranslationMode(rawValue: $0) } ?? .below onboardingCompleted = defaults.object(forKey: Keys.onboardingCompleted) as? Bool ?? false + userSkippedPermissions = defaults.object(forKey: Keys.userSkippedPermissions) as? Bool ?? false paddleOCRServerAddress = defaults.string(forKey: Keys.paddleOCRServerAddress) ?? "" mtranServerHost = defaults.string(forKey: Keys.mtranServerHost) ?? "localhost" mtranServerPort = defaults.object(forKey: Keys.mtranServerPort) as? Int ?? 8989 @@ -504,6 +511,7 @@ final class AppSettings { translationEngine = .apple translationMode = .below onboardingCompleted = false + userSkippedPermissions = false translateAndInsertSourceLanguage = .auto translateAndInsertTargetLanguage = nil // Reset PaddleOCR settings diff --git a/ScreenTranslate/Resources/en.lproj/Localizable.strings b/ScreenTranslate/Resources/en.lproj/Localizable.strings index b295db7..9a876df 100644 --- a/ScreenTranslate/Resources/en.lproj/Localizable.strings +++ b/ScreenTranslate/Resources/en.lproj/Localizable.strings @@ -539,54 +539,30 @@ "onboarding.permissions.title" = "Permissions"; "onboarding.permissions.message" = "ScreenTranslate needs a few permissions to work properly. Please grant the following permissions:"; "onboarding.permissions.hint" = "After granting permissions, the status will update automatically."; +"onboarding.permissions.timeout.hint" = "Having trouble? Skip for now and configure later in Menu Bar → Settings"; "onboarding.permission.screen.recording" = "Screen Recording"; +"onboarding.permission.screen.recording.subtitle" = "For capturing screen content for OCR text recognition"; "onboarding.permission.accessibility" = "Accessibility"; +"onboarding.permission.accessibility.subtitle" = "For registering global shortcuts to trigger capture & translate from any app"; "onboarding.permission.granted" = "Granted"; "onboarding.permission.not.granted" = "Not Granted"; "onboarding.permission.grant" = "Grant Permission"; -/* Onboarding - Configuration Step */ -"onboarding.configuration.title" = "Optional Configuration"; -"onboarding.configuration.message" = "Your local OCR and translation features are already enabled. Optionally configure external services:"; -"onboarding.configuration.paddleocr" = "PaddleOCR Server Address"; -"onboarding.configuration.paddleocr.hint" = "Leave empty to use macOS Vision OCR"; -"onboarding.configuration.mtran" = "MTranServer Address"; -"onboarding.configuration.mtran.hint" = "Leave empty to use Apple Translation"; -"onboarding.configuration.placeholder" = "http://localhost:8080"; -"onboarding.configuration.placeholder.address" = "localhost"; -"onboarding.configuration.test" = "Test Translation"; -"onboarding.configuration.test.button" = "Test Translation"; -"onboarding.configuration.testing" = "Testing..."; -"onboarding.test.success" = "Translation test successful: \"%@\" → \"%@\""; -"onboarding.test.failed" = "Translation test failed: %@"; - /* Onboarding - Complete Step */ "onboarding.complete.title" = "You're All Set!"; "onboarding.complete.message" = "ScreenTranslate is now ready to use. Here's how to get started:"; +"onboarding.complete.menubar.hint" = "ScreenTranslate runs in the menu bar. Click the menu bar icon to get started."; "onboarding.complete.shortcuts" = "Use ⌘⇧F to capture the full screen"; "onboarding.complete.selection" = "Use ⌘⇧A to capture a selection and translate"; "onboarding.complete.settings" = "Open Settings from the menu bar to customize options"; "onboarding.complete.start" = "Start Using ScreenTranslate"; +"onboarding.complete.permissions.warning" = "Some permissions are not granted yet. You can configure them in Menu Bar → Settings anytime."; /* Onboarding - Navigation */ "onboarding.back" = "Back"; "onboarding.continue" = "Continue"; -"onboarding.next" = "Next"; -"onboarding.skip" = "Skip"; -"onboarding.complete" = "Complete"; - -/* Onboarding - PaddleOCR */ -"onboarding.paddleocr.title" = "PaddleOCR (Optional)"; -"onboarding.paddleocr.description" = "Enhanced OCR engine for better text recognition accuracy, especially for Chinese."; -"onboarding.paddleocr.installed" = "Installed"; -"onboarding.paddleocr.not.installed" = "Not Installed"; -"onboarding.paddleocr.install" = "Install"; -"onboarding.paddleocr.installing" = "Installing..."; -"onboarding.paddleocr.install.hint" = "Requires Python 3 and pip. Run: pip3 install paddleocr paddlepaddle"; -"onboarding.paddleocr.copy.command" = "Copy Command"; -"onboarding.paddleocr.refresh" = "Refresh Status"; -"onboarding.paddleocr.version" = "Version: %@"; +"onboarding.skip.permissions" = "Skip for Now"; /* Settings - PaddleOCR */ "settings.paddleocr.installed" = "Installed"; diff --git a/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings b/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings index d023dd3..4bdc8af 100644 --- a/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings +++ b/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings @@ -538,54 +538,30 @@ "onboarding.permissions.title" = "权限"; "onboarding.permissions.message" = "ScreenTranslate 需要一些权限才能正常工作。请授予以下权限:"; "onboarding.permissions.hint" = "授权后,状态将自动更新。"; +"onboarding.permissions.timeout.hint" = "授权遇到困难?可以暂时跳过,稍后在菜单栏 → 设置中配置"; "onboarding.permission.screen.recording" = "屏幕录制"; +"onboarding.permission.screen.recording.subtitle" = "用于截取屏幕内容进行 OCR 文字识别"; "onboarding.permission.accessibility" = "辅助功能"; +"onboarding.permission.accessibility.subtitle" = "用于注册全局快捷键,在任何应用中触发截图翻译"; "onboarding.permission.granted" = "已授权"; "onboarding.permission.not.granted" = "未授权"; "onboarding.permission.grant" = "授权"; -/* 引导 - 配置步骤 */ -"onboarding.configuration.title" = "可选配置"; -"onboarding.configuration.message" = "您的本地 OCR 和翻译功能已启用。可选择配置外部服务:"; -"onboarding.configuration.paddleocr" = "PaddleOCR 服务器地址"; -"onboarding.configuration.paddleocr.hint" = "留空则使用 macOS Vision OCR"; -"onboarding.configuration.mtran" = "MTranServer 地址"; -"onboarding.configuration.mtran.hint" = "留空则使用 Apple 翻译"; -"onboarding.configuration.placeholder" = "http://localhost:8080"; -"onboarding.configuration.placeholder.address" = "localhost"; -"onboarding.configuration.test" = "测试翻译"; -"onboarding.configuration.test.button" = "测试翻译"; -"onboarding.configuration.testing" = "测试中..."; -"onboarding.test.success" = "翻译测试成功:\"%@\" → \"%@\""; -"onboarding.test.failed" = "翻译测试失败:%@"; - /* 引导 - 完成步骤 */ "onboarding.complete.title" = "设置完成!"; "onboarding.complete.message" = "ScreenTranslate 已准备就绪。以下是使用方法:"; +"onboarding.complete.menubar.hint" = "ScreenTranslate 将在菜单栏运行,点击菜单栏图标即可使用"; "onboarding.complete.shortcuts" = "使用 ⌘⇧F 全屏截图"; "onboarding.complete.selection" = "使用 ⌘⇧A 区域截图并翻译"; "onboarding.complete.settings" = "从菜单栏打开设置自定义选项"; "onboarding.complete.start" = "开始使用 ScreenTranslate"; +"onboarding.complete.permissions.warning" = "部分权限尚未授予,可在菜单栏 → 设置中随时配置"; /* 引导 - 导航 */ "onboarding.back" = "上一步"; "onboarding.continue" = "继续"; -"onboarding.next" = "下一步"; -"onboarding.skip" = "跳过"; -"onboarding.complete" = "完成"; - -/* 引导 - PaddleOCR */ -"onboarding.paddleocr.title" = "PaddleOCR(可选)"; -"onboarding.paddleocr.description" = "增强型 OCR 引擎,文字识别更准确,尤其适合中文。"; -"onboarding.paddleocr.installed" = "已安装"; -"onboarding.paddleocr.not.installed" = "未安装"; -"onboarding.paddleocr.install" = "安装"; -"onboarding.paddleocr.installing" = "安装中..."; -"onboarding.paddleocr.install.hint" = "需要 Python 3 和 pip。执行命令:pip3 install paddleocr paddlepaddle"; -"onboarding.paddleocr.copy.command" = "复制命令"; -"onboarding.paddleocr.refresh" = "刷新状态"; -"onboarding.paddleocr.version" = "版本:%@"; +"onboarding.skip.permissions" = "暂时跳过"; /* 设置 - PaddleOCR */ "settings.paddleocr.installed" = "已安装"; From 26c5fba7f037a7c5f1a280b2b6fa0b5d581c7dec Mon Sep 17 00:00:00 2001 From: Hubert Date: Thu, 26 Mar 2026 12:48:41 +0800 Subject: [PATCH 2/3] fix: resolve data race in PreviewWindow timer callback Move lastCounter and timer references to class properties to avoid sending non-Sendable values across isolation boundaries. --- .../Features/Preview/PreviewWindow.swift | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/ScreenTranslate/Features/Preview/PreviewWindow.swift b/ScreenTranslate/Features/Preview/PreviewWindow.swift index 7c2dc74..af271f5 100644 --- a/ScreenTranslate/Features/Preview/PreviewWindow.swift +++ b/ScreenTranslate/Features/Preview/PreviewWindow.swift @@ -12,6 +12,12 @@ final class PreviewWindow: NSPanel { /// The hosting view for SwiftUI content private var hostingView: NSHostingView? + /// Timer for observing image size changes + private var imageSizeTimer: Timer? + + /// Tracks the last image size change counter value + private var lastImageSizeCounter: Int = 0 + // MARK: - Initialization /// Creates a new preview window for the given screenshot. @@ -92,20 +98,15 @@ final class PreviewWindow: NSPanel { /// Observes changes to the image size and resizes the window accordingly @MainActor private func observeImageSizeChanges() { - // Track the current counter value - var lastCounter = viewModel.imageSizeChangeCounter - - // Use a timer to periodically check for changes (more reliable than withObservationTracking) - Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in - guard let self = self else { - timer.invalidate() - return - } + lastImageSizeCounter = viewModel.imageSizeChangeCounter + + imageSizeTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self = self else { return } - Task { @MainActor in let currentCounter = self.viewModel.imageSizeChangeCounter - if currentCounter != lastCounter { - lastCounter = currentCounter + if currentCounter != self.lastImageSizeCounter { + self.lastImageSizeCounter = currentCounter self.resizeToFitImage() } } From 037263ded2244af2cc3e05fb9b0ef7fcbd0f0a55 Mon Sep 17 00:00:00 2001 From: Hubert Date: Thu, 26 Mar 2026 13:01:09 +0800 Subject: [PATCH 3/3] fix: address onboarding UX issues from testing feedback - Fix button calling both requestAction and openSettingsAction (caused wrong permission dialog to appear) - Increase window height from 520 to 580 to fit all content - Defer Keychain access on launch to avoid prompt during onboarding - Add note about removing and re-adding app for permission changes --- ScreenTranslate/Features/Onboarding/OnboardingComponents.swift | 1 - ScreenTranslate/Features/Onboarding/OnboardingView.swift | 2 +- .../Features/Onboarding/OnboardingWindowController.swift | 2 +- ScreenTranslate/Models/AppSettings.swift | 3 ++- ScreenTranslate/Resources/en.lproj/Localizable.strings | 2 +- ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ScreenTranslate/Features/Onboarding/OnboardingComponents.swift b/ScreenTranslate/Features/Onboarding/OnboardingComponents.swift index 5614a9e..62ccb12 100644 --- a/ScreenTranslate/Features/Onboarding/OnboardingComponents.swift +++ b/ScreenTranslate/Features/Onboarding/OnboardingComponents.swift @@ -110,7 +110,6 @@ struct OnboardingPermissionRow: View { .font(.title2) } else { Button { - openSettingsAction() requestAction() } label: { Text(NSLocalizedString("onboarding.permission.grant", comment: "")) diff --git a/ScreenTranslate/Features/Onboarding/OnboardingView.swift b/ScreenTranslate/Features/Onboarding/OnboardingView.swift index 74126d9..33cdd6d 100644 --- a/ScreenTranslate/Features/Onboarding/OnboardingView.swift +++ b/ScreenTranslate/Features/Onboarding/OnboardingView.swift @@ -51,7 +51,7 @@ struct OnboardingView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(width: 600, height: 520) + .frame(width: 600, height: 580) .onReceive(NotificationCenter.default.publisher(for: .onboardingCompleted)) { _ in dismiss() } diff --git a/ScreenTranslate/Features/Onboarding/OnboardingWindowController.swift b/ScreenTranslate/Features/Onboarding/OnboardingWindowController.swift index 0f63cf0..77d515b 100644 --- a/ScreenTranslate/Features/Onboarding/OnboardingWindowController.swift +++ b/ScreenTranslate/Features/Onboarding/OnboardingWindowController.swift @@ -56,7 +56,7 @@ final class OnboardingWindowController: NSObject { // Create the window let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 600, height: 520), + contentRect: NSRect(x: 0, y: 0, width: 600, height: 580), styleMask: [.titled, .closable], backing: .buffered, defer: false diff --git a/ScreenTranslate/Models/AppSettings.swift b/ScreenTranslate/Models/AppSettings.swift index 90cc002..f61a3f7 100644 --- a/ScreenTranslate/Models/AppSettings.swift +++ b/ScreenTranslate/Models/AppSettings.swift @@ -461,7 +461,8 @@ final class AppSettings { paddleOCRCloudBaseURL = defaults.string(forKey: Keys.paddleOCRCloudBaseURL) ?? "" // Load PaddleOCR cloud API key from Keychain (secure storage) - paddleOCRCloudAPIKey = Self.loadPaddleOCRAPIKeyFromKeychain() + // Defer keychain access to avoid triggering UI on first launch + paddleOCRCloudAPIKey = "" // Load cloud model ID paddleOCRCloudModelId = defaults.string(forKey: Keys.paddleOCRCloudModelId) ?? "" diff --git a/ScreenTranslate/Resources/en.lproj/Localizable.strings b/ScreenTranslate/Resources/en.lproj/Localizable.strings index 9a876df..94e69d7 100644 --- a/ScreenTranslate/Resources/en.lproj/Localizable.strings +++ b/ScreenTranslate/Resources/en.lproj/Localizable.strings @@ -538,7 +538,7 @@ /* Onboarding - Permissions Step */ "onboarding.permissions.title" = "Permissions"; "onboarding.permissions.message" = "ScreenTranslate needs a few permissions to work properly. Please grant the following permissions:"; -"onboarding.permissions.hint" = "After granting permissions, the status will update automatically."; +"onboarding.permissions.hint" = "After granting permissions, the status will update automatically. If the app was previously in the list, remove and re-add it for changes to take effect."; "onboarding.permissions.timeout.hint" = "Having trouble? Skip for now and configure later in Menu Bar → Settings"; "onboarding.permission.screen.recording" = "Screen Recording"; diff --git a/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings b/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings index 4bdc8af..edb2bbc 100644 --- a/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings +++ b/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings @@ -537,7 +537,7 @@ /* 引导 - 权限步骤 */ "onboarding.permissions.title" = "权限"; "onboarding.permissions.message" = "ScreenTranslate 需要一些权限才能正常工作。请授予以下权限:"; -"onboarding.permissions.hint" = "授权后,状态将自动更新。"; +"onboarding.permissions.hint" = "授权后,状态将自动更新。如果应用曾在权限列表中,需要先移除再重新添加才能生效。"; "onboarding.permissions.timeout.hint" = "授权遇到困难?可以暂时跳过,稍后在菜单栏 → 设置中配置"; "onboarding.permission.screen.recording" = "屏幕录制";