From f95be8bc72ffd6f12cd7b5b0ba95434d5e68968e Mon Sep 17 00:00:00 2001 From: Peng Cheng Date: Tue, 23 Jun 2026 00:39:48 +0800 Subject: [PATCH] feat(macos): Typeless-style permission onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the first-run permission step so it stops treating screen recording like the other two permissions. Screen recording is the only one that needs an app restart to take effect (CGPreflight caches the old value) and the only one that's optional (microphone-only mode covers the rest), yet it was required and shown identically — users granted it, returned, saw a red X, and got stuck. - Microphone + speech recognition are required (standard prompts, instant); onboarding proceeds once those two are granted - Screen recording is marked Recommended/optional with a muted icon; skipping it falls back to microphone-only capture - After requesting screen recording, show a 'Restart to apply' button that relaunches the app, instead of a permanently-red row - requiredPermissionsGranted gates progress on the two essential permissions Why not just copy Typeless: it only needs the microphone, so its flow is trivially clean. LCT must capture system audio, which on macOS requires the restart-prone screen-recording permission — so the fix is making that one permission's guidance honest, not hiding it. Co-Authored-By: Claude Opus 4.8 --- macos/LCTMac/Views/WelcomeView.swift | 168 +++++++++++++++++---------- 1 file changed, 108 insertions(+), 60 deletions(-) diff --git a/macos/LCTMac/Views/WelcomeView.swift b/macos/LCTMac/Views/WelcomeView.swift index 3ce5317..09709ba 100644 --- a/macos/LCTMac/Views/WelcomeView.swift +++ b/macos/LCTMac/Views/WelcomeView.swift @@ -23,6 +23,9 @@ struct WelcomeView: View { @State private var hasMicrophonePermission = false @State private var hasSpeechRecognitionPermission = false @State private var isCheckingPermissions = false + /// Set once the user has triggered the screen-recording prompt; until the + /// app restarts, CGPreflight keeps returning false even after granting. + @State private var screenRecordingRequested = false /// Default model to use private let defaultModel = RecommendedModel.defaultModel @@ -181,39 +184,45 @@ struct WelcomeView: View { .font(.title) .fontWeight(.bold) - Text("LCT needs the following permissions to capture and translate audio") + Text("Grant microphone and speech recognition to get started. Screen recording is optional — it lets LCT caption audio from videos and meetings.") .foregroundStyle(.secondary) .multilineTextAlignment(.center) - + .fixedSize(horizontal: false, vertical: true) + VStack(spacing: 16) { - // Screen Recording Permission - PermissionRow( - icon: "rectangle.dashed.badge.record", - title: "Screen Recording", - description: "Required to capture system audio", - isGranted: hasScreenCapturePermission, - isChecking: isCheckingPermissions, - action: requestScreenCapturePermission - ) - - // Microphone Permission + // Microphone Permission (required) PermissionRow( icon: "mic.fill", title: "Microphone", - description: "Required to capture your voice", + description: "Capture your voice", isGranted: hasMicrophonePermission, isChecking: isCheckingPermissions, - action: requestMicrophonePermission + onGrant: requestMicrophonePermission ) - - // Speech Recognition Permission + + // Speech Recognition Permission (required) PermissionRow( icon: "waveform", title: "Speech Recognition", - description: "Required for speech-to-text", + description: "Convert speech to text", isGranted: hasSpeechRecognitionPermission, isChecking: isCheckingPermissions, - action: requestSpeechRecognitionPermission + onGrant: requestSpeechRecognitionPermission + ) + + Divider() + + // Screen Recording Permission (optional — needs a restart to take effect) + PermissionRow( + icon: "rectangle.dashed.badge.record", + title: "Screen Recording", + description: "Capture audio from videos & meetings", + isGranted: hasScreenCapturePermission, + isChecking: isCheckingPermissions, + isOptional: true, + pendingRestart: screenRecordingRequested && !hasScreenCapturePermission, + onGrant: requestScreenCapturePermission, + onRestart: relaunchApp ) } .padding() @@ -221,16 +230,13 @@ struct WelcomeView: View { RoundedRectangle(cornerRadius: 12) .fill(Color(nsColor: .controlBackgroundColor)) ) - - if !allPermissionsGranted { - Text("Click each permission to grant access, or open System Settings manually") + + if screenRecordingRequested && !hasScreenCapturePermission { + Text("macOS requires a restart for screen recording to take effect. You can also continue with microphone only.") .font(.caption) .foregroundStyle(.secondary) - - Button(action: openSystemSettings) { - Label("Open System Settings", systemImage: "gear") - } - .buttonStyle(.bordered) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } } .onAppear { @@ -240,7 +246,7 @@ struct WelcomeView: View { } // Auto-refresh when coming back from System Settings .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - if currentStep == .permissions && !allPermissionsGranted { + if currentStep == .permissions { print("[WelcomeView] App became active. Auto-checking permissions...") Task { await checkAllPermissions() @@ -249,8 +255,10 @@ struct WelcomeView: View { } } - private var allPermissionsGranted: Bool { - hasScreenCapturePermission && hasMicrophonePermission && hasSpeechRecognitionPermission + /// Only microphone + speech recognition are required to proceed. Screen + /// recording is optional (microphone-only mode covers the rest). + private var requiredPermissionsGranted: Bool { + hasMicrophonePermission && hasSpeechRecognitionPermission } // MARK: - Checking Ollama Content @@ -539,7 +547,7 @@ struct WelcomeView: View { case .welcome: return "Get Started" case .permissions: - return allPermissionsGranted ? "Continue" : "Skip for Now" + return requiredPermissionsGranted ? "Continue" : "Skip for Now" case .checkingOllama: return "Setting up..." case .ollamaNotInstalled: @@ -864,21 +872,36 @@ struct WelcomeView: View { } private func requestScreenCapturePermission() { + screenRecordingRequested = true Task { - // Requesting screen capture will trigger the system dialog + // Requesting screen capture triggers the system dialog (first time) do { _ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) hasScreenCapturePermission = true } catch { - // Permission denied or not yet granted + // Not yet granted — open System Settings so the user can enable it. + // Note: even after granting, macOS needs an app restart for it to + // take effect, which is why we surface a Restart button. hasScreenCapturePermission = false - // Open System Settings for Screen Recording if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { NSWorkspace.shared.open(url) } } } } + + /// Relaunch the app so a newly granted screen-recording permission takes + /// effect (CGPreflight caches the old value for the process's lifetime). + private func relaunchApp() { + let bundleURL = Bundle.main.bundleURL + let config = NSWorkspace.OpenConfiguration() + config.createsNewApplicationInstance = true + NSWorkspace.shared.openApplication(at: bundleURL, configuration: config) { _, _ in + Task { @MainActor in + NSApp.terminate(nil) + } + } + } private func requestMicrophonePermission() { Task { @@ -896,12 +919,6 @@ struct WelcomeView: View { } } } - - private func openSystemSettings() { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy") { - NSWorkspace.shared.open(url) - } - } } // MARK: - Supporting Views @@ -937,38 +954,69 @@ struct PermissionRow: View { let description: String let isGranted: Bool let isChecking: Bool - let action: () -> Void - + var isOptional: Bool = false + var pendingRestart: Bool = false + let onGrant: () -> Void + var onRestart: (() -> Void)? = nil + var body: some View { HStack(spacing: 16) { Image(systemName: icon) .font(.title2) - .foregroundStyle(isGranted ? .green : .orange) + .foregroundStyle(iconColor) .frame(width: 40) - + VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.headline) + HStack(spacing: 6) { + Text(title) + .font(.headline) + if isOptional && !isGranted { + Text("Recommended") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(Color.secondary.opacity(0.15))) + .foregroundStyle(.secondary) + } + } Text(description) .font(.subheadline) .foregroundStyle(.secondary) } - + Spacer() - - if isChecking { - ProgressView() - .scaleEffect(0.8) - } else if isGranted { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.title2) - } else { - Button("Grant") { - action() - } - .buttonStyle(.bordered) + + trailing + } + } + + @ViewBuilder + private var trailing: some View { + if isChecking { + ProgressView() + .scaleEffect(0.8) + } else if isGranted { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.title2) + } else if pendingRestart { + Button("Restart to apply") { + onRestart?() } + .buttonStyle(.borderedProminent) + .tint(.orange) + .controlSize(.small) + } else { + Button("Grant") { + onGrant() + } + .buttonStyle(.bordered) } } + + private var iconColor: Color { + if isGranted { return .green } + if pendingRestart { return .orange } + return isOptional ? Color(nsColor: .tertiaryLabelColor) : .orange + } }